Skip to content

Commit

Permalink
feat: add computed fields
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
thalesmg committed Jan 21, 2025
1 parent 05ff1a0 commit e5e3e5c
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 3 deletions.
2 changes: 2 additions & 0 deletions include/hocon.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
21 changes: 18 additions & 3 deletions src/hocon_tconf.erl
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

-include("hoconsc.hrl").
-include("hocon_private.hrl").
-include("hocon.hrl").

-export_type([opts/0]).

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) ->
Expand Down
175 changes: 175 additions & 0 deletions test/hocon_tconf_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).

Expand Down Expand Up @@ -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.

0 comments on commit e5e3e5c

Please sign in to comment.