From e5e3e5c031c49b12c745714cd049682152ebe336 Mon Sep 17 00:00:00 2001 From: Thales Macedo Garitezi <16166434+thalesmg@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:24:13 -0300 Subject: [PATCH] feat: add computed fields This allows us to compute fields _from checked configurations_, so we may cache arbitrary values we want to associate with the field. Only `map()`'s and structs may have computed fields attached. The resulting value is added to a `?COMPUTED` atom key inside the resulting checked configuration. --- include/hocon.hrl | 2 + src/hocon_tconf.erl | 21 ++++- test/hocon_tconf_tests.erl | 175 +++++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 3 deletions(-) diff --git a/include/hocon.hrl b/include/hocon.hrl index a380bc18..034c49ec 100644 --- a/include/hocon.hrl +++ b/include/hocon.hrl @@ -19,6 +19,8 @@ -ifndef(HOCON_HRL). -define(HOCON_HRL, true). +-define(COMPUTED, '_computed'). + -define(IS_VALUE_LIST(T), (T =:= array orelse T =:= concat orelse T =:= object)). -define(IS_FIELD(F), (is_tuple(F) andalso size(F) =:= 2)). diff --git a/src/hocon_tconf.erl b/src/hocon_tconf.erl index a27316f7..ac908b35 100644 --- a/src/hocon_tconf.erl +++ b/src/hocon_tconf.erl @@ -30,6 +30,7 @@ -include("hoconsc.hrl"). -include("hocon_private.hrl"). +-include("hocon.hrl"). -export_type([opts/0]). @@ -486,7 +487,9 @@ map_one_field_non_hidden(FieldType, FieldSchema, FieldValue0, Opts) -> IsMakeSerializable = is_make_serializable(Opts), {MaybeLog, FieldValue} = resolve_field_value(FieldSchema, FieldValue0, Opts), Converter = upgrade_converter(field_schema(FieldSchema, converter)), - {Acc0, NewValue} = map_field_maybe_convert(FieldType, FieldSchema, FieldValue, Opts, Converter), + {Acc0, NewValue0} = map_field_maybe_convert( + FieldType, FieldSchema, FieldValue, Opts, Converter + ), Acc = MaybeLog ++ Acc0, Validators = case IsMakeSerializable orelse is_primitive_type(FieldType) of @@ -500,7 +503,7 @@ map_one_field_non_hidden(FieldType, FieldSchema, FieldValue0, Opts) -> end, case find_errors(Acc) of ok -> - Pv = ensure_plain(NewValue), + Pv = ensure_plain(NewValue0), ValidationResult = validate(Opts, FieldSchema, Pv, Validators), Mapping = case is_make_serializable(Opts) of @@ -510,14 +513,26 @@ map_one_field_non_hidden(FieldType, FieldSchema, FieldValue0, Opts) -> case ValidationResult of [] -> Mapped = maybe_mapping(Mapping, Pv), + NewValue = maybe_computed(FieldSchema, NewValue0, Opts), {Acc ++ Mapped, NewValue}; Errors -> - {Acc ++ Errors, NewValue} + {Acc ++ Errors, NewValue0} end; _ -> {Acc, FieldValue} end. +maybe_computed(FieldSchema, #{} = CheckedValue, Opts) -> + case field_schema(FieldSchema, computed) of + Fn when is_function(Fn, 2) -> + Computed = Fn(CheckedValue, Opts), + CheckedValue#{?COMPUTED => Computed}; + _ -> + CheckedValue + end; +maybe_computed(_FieldSchema, CheckedValue, _Opts) -> + CheckedValue. + map_field_maybe_convert(Type, Schema, Value0, Opts, undefined) -> map_field(Type, Schema, Value0, Opts); map_field_maybe_convert(Type, Schema, Value0, Opts, Converter) -> diff --git a/test/hocon_tconf_tests.erl b/test/hocon_tconf_tests.erl index ba5483e8..7e85c234 100644 --- a/test/hocon_tconf_tests.erl +++ b/test/hocon_tconf_tests.erl @@ -19,6 +19,7 @@ -include_lib("eunit/include/eunit.hrl"). -include("hocon_private.hrl"). -include("hoconsc.hrl"). +-include("hocon.hrl"). -export([roots/0, fields/1, validations/0, desc/1, namespace/0]). @@ -2586,3 +2587,177 @@ map_type_with_alias_test() -> ?assertEqual(NormalRecord, hocon_tconf:check_plain(Sc, NormalRecord)), ?assertEqual(NormalRecord, hocon_tconf:check_plain(Sc, AliasedRecord)), ok. + +computed_fields_test() -> + Size = 3, + Counter = counters:new(Size, []), + Reset = fun() -> + lists:foreach( + fun(Ix) -> + counters:put(Counter, Ix, 0) + end, + lists:seq(1, Size) + ) + end, + ComputedRoot = fun + ( + #{<<"bag">> := #{?COMPUTED := ComputedBag}, <<"baz">> := #{?COMPUTED := ComputedBaz}}, + _HoconOpts + ) -> + counters:add(Counter, 1, 1), + [ComputedBag, ComputedBaz]; + (#{bag := #{?COMPUTED := ComputedBag}, baz := #{?COMPUTED := ComputedBaz}}, _HoconOpts) -> + counters:add(Counter, 1, 1), + [ComputedBaz, ComputedBag] + end, + ComputedBaz = fun + (#{<<"quux">> := Val}, _HoconOpts) -> + counters:add(Counter, 2, 1), + Val + 333; + (#{quux := Val}, _HoconOpts) -> + counters:add(Counter, 2, 1), + Val - 333 + end, + %% This one is an open map without schema, so keys are not converted to atoms. + ComputedBag = fun(#{<<"key">> := Val}, _HoconOpts) -> + counters:add(Counter, 3, 1), + <<"computed ", Val/binary>> + end, + BadComputed = fun(X, _HoconOpts) -> error({shouldnt_be_called, X}) end, + QuuxValidator = fun(X) -> X > 300 end, + Sc = #{ + roots => [ + {"root", hoconsc:mk(hoconsc:ref(foo), #{computed => ComputedRoot})} + ], + fields => + #{ + foo => [ + {bar, hoconsc:mk(integer(), #{computed => BadComputed})}, + {baz, hoconsc:mk(hoconsc:ref(qux), #{computed => ComputedBaz})}, + {bag, hoconsc:mk(map(), #{computed => ComputedBag})} + ], + qux => [ + {quux, + hoconsc:mk(integer(), #{ + computed => BadComputed, + validator => QuuxValidator + })} + ] + } + }, + Data = #{ + <<"root">> => + #{ + <<"bar">> => 123, + <<"baz">> => + #{<<"quux">> => 666}, + <<"bag">> => #{<<"key">> => <<"value">>} + } + }, + Res1 = hocon_tconf:check_plain(Sc, Data, #{}), + ?assertMatch( + #{ + <<"root">> := + #{ + ?COMPUTED := [<<"computed value">>, 999], + <<"bar">> := 123, + <<"baz">> := + #{ + ?COMPUTED := 999, + <<"quux">> := 666 + }, + <<"bag">> := + #{ + ?COMPUTED := <<"computed value">>, + <<"key">> := <<"value">> + } + } + }, + Res1 + ), + ?assertEqual(1, counters:get(Counter, 1)), + ?assertEqual(1, counters:get(Counter, 2)), + ?assertEqual(1, counters:get(Counter, 3)), + Reset(), + Res2 = hocon_tconf:check_plain(Sc, Data, #{atom_key => true}), + ?assertMatch( + #{ + root := + #{ + ?COMPUTED := [333, <<"computed value">>], + bar := 123, + baz := + #{ + ?COMPUTED := 333, + quux := 666 + }, + bag := + #{ + ?COMPUTED := <<"computed value">>, + <<"key">> := <<"value">> + } + } + }, + Res2 + ), + ?assertEqual(1, counters:get(Counter, 1)), + ?assertEqual(1, counters:get(Counter, 2)), + ?assertEqual(1, counters:get(Counter, 3)), + Reset(), + %% Tests that fail validation/type check do not trigger computation + BadData1 = #{ + <<"root">> => + #{ + <<"bar">> => 123, + <<"baz">> => + #{ + <<"quux">> => + %% bad value: fails validation + 200 + }, + <<"bag">> => #{<<"key">> => <<"value">>} + } + }, + ?assertThrow( + {_, [ + #{ + reason := returned_false, + kind := validation_error, + path := "root.baz.quux" + } + ]}, + hocon_tconf:check_plain(Sc, BadData1, #{}) + ), + %% Root is not called + ?assertEqual(0, counters:get(Counter, 1)), + %% Baz is not called + ?assertEqual(0, counters:get(Counter, 2)), + %% Bag is called + ?assertEqual(1, counters:get(Counter, 3)), + Reset(), + BadData2 = #{ + <<"root">> => + #{ + <<"bar">> => 123, + <<"baz">> => + #{<<"quux">> => <<"wrong type">>}, + <<"bag">> => #{<<"key">> => <<"value">>} + } + }, + ?assertThrow( + {_, [ + #{ + reason := "Unable to parse integer value", + kind := validation_error, + path := "root.baz.quux" + } + ]}, + hocon_tconf:check_plain(Sc, BadData2, #{}) + ), + %% Root is not called + ?assertEqual(0, counters:get(Counter, 1)), + %% Baz is not called + ?assertEqual(0, counters:get(Counter, 2)), + %% Bag is called + ?assertEqual(1, counters:get(Counter, 3)), + ok.