Skip to content

Commit 87b78c1

Browse files
committed
Add full support for maps
1 parent a2c9fa4 commit 87b78c1

7 files changed

+276
-14
lines changed

include/proper.hrl

+3-3
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
%%------------------------------------------------------------------------------
4848

4949
-import(proper_types, [integer/2, float/2, atom/0, binary/0, binary/1,
50-
bitstring/0, bitstring/1, list/1, vector/2, union/1,
51-
weighted_union/1, tuple/1, loose_tuple/1, exactly/1,
52-
fixed_list/1, function/2, map/2, any/0]).
50+
bitstring/0, bitstring/1, list/1, map/1, map/2, map_union/2,
51+
vector/2, union/1, weighted_union/1, tuple/1, loose_tuple/1,
52+
exactly/1, fixed_list/1, fixed_map/1, function/2, any/0]).
5353

5454

5555
%%------------------------------------------------------------------------------

src/proper_gen.erl

+17-2
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
binary_rev/1, binary_len_gen/1, bitstring_gen/1, bitstring_rev/1,
4343
bitstring_len_gen/1, list_gen/2, distlist_gen/3, vector_gen/2,
4444
union_gen/1, weighted_union_gen/1, tuple_gen/1, loose_tuple_gen/2,
45-
loose_tuple_rev/2, exactly_gen/1, fixed_list_gen/1, function_gen/2,
46-
any_gen/1, native_type_gen/2, safe_weighted_union_gen/1,
45+
loose_tuple_rev/2, exactly_gen/1, fixed_list_gen/1, fixed_map_gen/1,
46+
function_gen/2, any_gen/1, native_type_gen/2, safe_weighted_union_gen/1,
4747
safe_union_gen/1]).
4848

4949
%% Public API types
@@ -344,6 +344,12 @@ clean_instance({'$to_part',ImmInstance}) ->
344344
clean_instance(ImmInstance);
345345
clean_instance(ImmInstance) when is_list(ImmInstance) ->
346346
clean_instance_list(ImmInstance);
347+
clean_instance(ImmInstance) when is_map(ImmInstance) ->
348+
%% maps:map only changes the values, this handles both values and keys
349+
maps:from_list([
350+
{clean_instance(Key), clean_instance(Value)}
351+
|| {Key, Value} <- maps:to_list(ImmInstance)
352+
]);
347353
clean_instance(ImmInstance) when is_tuple(ImmInstance) ->
348354
list_to_tuple(clean_instance_list(tuple_to_list(ImmInstance)));
349355
clean_instance(ImmInstance) -> ImmInstance.
@@ -576,6 +582,15 @@ fixed_list_gen({ProperHead,ImproperTail}) ->
576582
fixed_list_gen(ProperFields) ->
577583
[generate(F) || F <- ProperFields].
578584

585+
%% @private
586+
-spec fixed_map_gen(map()) -> imm_instance().
587+
fixed_map_gen(Map) when is_map(Map) ->
588+
maps:from_list([
589+
{generate(KeyOrType), generate(ValueOrType)}
590+
||
591+
{KeyOrType, ValueOrType} <- maps:to_list(Map)
592+
]).
593+
579594
%% @private
580595
-spec function_gen(arity(), proper_types:type()) -> function().
581596
function_gen(Arity, RetType) ->

src/proper_shrink.erl

+107
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
-export([number_shrinker/4, union_first_choice_shrinker/3,
3434
union_recursive_shrinker/3]).
3535
-export([split_shrinker/3, remove_shrinker/3]).
36+
-export([map_remove_shrinker/3, map_key_shrinker/3, map_value_shrinker/3]).
3637

3738
-export_type([state/0, shrinker/0]).
3839

@@ -397,6 +398,112 @@ elements_shrinker(Instance, Type,
397398
elements_shrinker(Instance, Type,
398399
{inner,Indices,GetElemType,{shrunk,N,InnerState}}).
399400

401+
-spec map_remove_shrinker(
402+
proper_gen:imm_instance(), proper_types:type(), state()
403+
) -> {[proper_gen:imm_instance()], state()}.
404+
map_remove_shrinker(Instance, Type, init) when is_map(Instance) ->
405+
GetKeys = proper_types:get_prop(get_keys, Type),
406+
Keys = GetKeys(Instance),
407+
map_remove_shrinker(Instance, Type, {shrunk, 1, {keys, ordsets:new(), Keys}});
408+
map_remove_shrinker(Instance, _Type, {keys, _Checked, []}) when is_map(Instance) ->
409+
{[], done};
410+
map_remove_shrinker(Instance, Type, {keys, Checked, [Key | Rest]}) when is_map(Instance) ->
411+
Remove = proper_types:get_prop(remove, Type),
412+
{[Remove(Key, Instance)], {keys, ordsets:add_element(Key, Checked), Rest}};
413+
map_remove_shrinker(Instance, Type, {shrunk, 1, {keys, Checked, ToCheck}}) when is_map(Instance) ->
414+
%% GetKeys = proper_types:get_prop(get_keys, Type),
415+
%% Keys = ordsets:from_list(GetKeys(Instance)),
416+
%% NewToCheck = ordsets:subtract(Keys, Checked),
417+
map_remove_shrinker(Instance, Type, {keys, Checked, ToCheck}).
418+
419+
-spec map_value_shrinker(
420+
proper_gen:imm_instance(), proper_types:type(), state()
421+
) -> {[proper_gen:imm_instance()], state()}.
422+
map_value_shrinker(Instance, _Type, init) when map_size(Instance) =:= 0 ->
423+
{[], done};
424+
map_value_shrinker(Instance, Type, init) when is_map(Instance) ->
425+
GetKeys = proper_types:get_prop(get_keys, Type),
426+
TypeMap = proper_types:get_prop(internal_types, Type),
427+
Keys = GetKeys(Instance),
428+
ValueTypeMap = maps:map(fun(Key, Value) ->
429+
{_KeyType, ValueType} = get_map_field_candidates(Key, Value, TypeMap),
430+
ValueType
431+
end, Instance),
432+
map_value_shrinker(Instance, Type, {inner, Keys, ValueTypeMap, init});
433+
map_value_shrinker(Instance, _Type, {inner, [], _ValueTypeMap, init}) when is_map(Instance) ->
434+
{[], done};
435+
map_value_shrinker(
436+
Instance, Type, {inner, [_Key | Rest], ValueTypeMap, done}
437+
) when is_map(Instance) ->
438+
map_value_shrinker(Instance, Type, {inner, Rest, ValueTypeMap, init});
439+
map_value_shrinker(
440+
Instance, Type, {inner, Keys = [Key | _], ValueTypeMap, InnerState}
441+
) when is_map(Instance) ->
442+
Retrieve = proper_types:get_prop(retrieve, Type),
443+
Update = proper_types:get_prop(update, Type),
444+
Value = Retrieve(Key, Instance),
445+
ValueType = Retrieve(Key, ValueTypeMap),
446+
{NewValues, NewInnerState} = shrink(Value, ValueType, InnerState),
447+
NewInstances = [Update(Key, NewValue, Instance) || NewValue <- NewValues],
448+
{NewInstances, {inner, Keys, ValueTypeMap, NewInnerState}};
449+
map_value_shrinker(
450+
Instance, Type, {shrunk, N, {inner, ToCheck, ValueTypeMap, InnerState}}
451+
) when is_map(Instance) ->
452+
map_value_shrinker(
453+
Instance, Type, {inner, ToCheck, ValueTypeMap, {shrunk, N, InnerState}}
454+
).
455+
456+
-spec map_key_shrinker(
457+
proper_gen:imm_instance(), proper_types:type(), state()
458+
) -> {[proper_gen:imm_instance()], state()}.
459+
map_key_shrinker(Instance, Type, init) when is_map(Instance) ->
460+
GetKeys = proper_types:get_prop(get_keys, Type),
461+
TypeMap = proper_types:get_prop(internal_types, Type),
462+
Keys = GetKeys(Instance),
463+
KeyTypeMap = maps:map(fun(Key, Value) ->
464+
{KeyType, _ValueType} = get_map_field_candidates(Key, Value, TypeMap),
465+
KeyType
466+
end, Instance),
467+
map_key_shrinker(Instance, Type, {inner, Keys, KeyTypeMap, init});
468+
map_key_shrinker(Instance, _Type, {inner, [], _ValueTypeMap, init}) when is_map(Instance) ->
469+
{[], done};
470+
map_key_shrinker(
471+
Instance, Type, {inner, [_Key | Rest], KeyTypeMap, done}
472+
) when is_map(Instance) ->
473+
map_key_shrinker(Instance, Type, {inner, Rest, KeyTypeMap, init});
474+
map_key_shrinker(
475+
Instance, Type, {inner, Keys = [Key | _], KeyTypeMap, InnerState}
476+
) when is_map(Instance) ->
477+
Retrieve = proper_types:get_prop(retrieve, Type),
478+
Update = proper_types:get_prop(update, Type),
479+
Remove = proper_types:get_prop(remove, Type),
480+
Value = Retrieve(Key, Instance),
481+
KeyType = Retrieve(Key, KeyTypeMap),
482+
{NewKeys, NewInnerState} = shrink(Key, KeyType, InnerState),
483+
InstanceWithoutKey = Remove(Key, Instance),
484+
NewInstances = [
485+
Update(NewKey, Value, InstanceWithoutKey) || NewKey <- NewKeys
486+
],
487+
{NewInstances, {inner, Keys, KeyTypeMap, NewInnerState}};
488+
map_key_shrinker(
489+
Instance, Type, {shrunk, N, {inner, ToCheck, KeyTypeMap, InnerState}}
490+
) when is_map(Instance) ->
491+
map_key_shrinker(
492+
Instance, Type, {inner, ToCheck, KeyTypeMap, {shrunk, N, InnerState}}
493+
).
494+
495+
get_map_field_candidates(Key, Value, TypeMap) ->
496+
Candidates = maps:filter(fun(KeyType, ValueType) ->
497+
(Key =:= KeyType orelse
498+
proper_types:is_type(Key) andalso
499+
proper_types:is_instance(Key, KeyType))
500+
andalso
501+
(Key =:= KeyType orelse
502+
proper_types:is_type(Value) andalso
503+
proper_types:is_instance(Value, ValueType))
504+
end, TypeMap),
505+
{KeyType, ValueType, _} = maps:next(maps:iterator(Candidates)),
506+
{KeyType, ValueType}.
400507

401508
%%------------------------------------------------------------------------------
402509
%% Custom shrinkers

src/proper_types.erl

+90-5
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,14 @@
141141

142142
-export([integer/2, float/2, atom/0, binary/0, binary/1, bitstring/0,
143143
bitstring/1, list/1, vector/2, union/1, weighted_union/1, tuple/1,
144-
loose_tuple/1, exactly/1, fixed_list/1, function/2, map/0, map/2,
145-
any/0, shrink_list/1, safe_union/1, safe_weighted_union/1]).
144+
loose_tuple/1, exactly/1, fixed_list/1, fixed_map/1, function/2, map/0,
145+
map/1, map/2, any/0, shrink_list/1, safe_union/1, safe_weighted_union/1]).
146146
-export([integer/0, non_neg_integer/0, pos_integer/0, neg_integer/0, range/2,
147147
float/0, non_neg_float/0, number/0, boolean/0, byte/0, char/0, nil/0,
148148
list/0, tuple/0, string/0, wunion/1, term/0, timeout/0, arity/0]).
149149
-export([int/0, nat/0, largeint/0, real/0, bool/0, choose/2, elements/1,
150150
oneof/1, frequency/1, return/1, default/2, orderedlist/1, function0/1,
151-
function1/1, function2/1, function3/1, function4/1,
151+
function1/1, function2/1, function3/1, function4/1, map_union/1,
152152
weighted_default/2]).
153153
-export([resize/2, non_empty/1, noshrink/1]).
154154

@@ -239,7 +239,7 @@
239239
| 'combine' | 'alt_gens' | 'shrink_to_parts'
240240
| 'size_transform' | 'is_instance' | 'shrinkers'
241241
| 'noshrink' | 'internal_type' | 'internal_types'
242-
| 'get_length' | 'split' | 'join' | 'get_indices'
242+
| 'get_length' | 'split' | 'join' | 'get_indices' | 'get_keys'
243243
| 'remove' | 'retrieve' | 'update' | 'constraints'
244244
| 'parameters' | 'env' | 'subenv'
245245
| 'user_nf' | 'is_user_nf' | 'matcher'.
@@ -258,7 +258,7 @@
258258
| {'shrinkers', [proper_shrink:shrinker()]}
259259
| {'noshrink', boolean()}
260260
| {'internal_type', raw_type()}
261-
| {'internal_types', tuple() | maybe_improper_list(type(),type() | [])}
261+
| {'internal_types', tuple() | map() | maybe_improper_list(type(),type() | [])}
262262
%% The items returned by 'remove' must be of this type.
263263
| {'get_length', fun((proper_gen:imm_instance()) -> length())}
264264
%% If this is a container type, this should return the number of elements
@@ -277,6 +277,10 @@
277277
proper_gen:imm_instance()) -> [index()])}
278278
%% If this is a container type, this should return a list of indices we
279279
%% can use to remove or insert elements from the given instance.
280+
| {'get_keys', fun((proper_types:type(),
281+
proper_gen:imm_instance()) -> [term()])}
282+
%% Simliar to `get_indices' but for mapping types where the keys of the
283+
%% type is not necessarily the same as the keys of the instance.
280284
| {'remove', fun((index(),proper_gen:imm_instance()) ->
281285
proper_gen:imm_instance())}
282286
| {'retrieve', fun((index(), proper_gen:imm_instance() | tuple()
@@ -312,6 +316,8 @@ cook_outer(RawType) when is_tuple(RawType) ->
312316
tuple(tuple_to_list(RawType));
313317
cook_outer(RawType) when is_list(RawType) ->
314318
fixed_list(RawType); %% CAUTION: this must handle improper lists
319+
cook_outer(RawType) when is_map(RawType) ->
320+
fixed_map(RawType);
315321
cook_outer(RawType) -> %% default case (integers, floats, atoms, binaries, ...)
316322
exactly(RawType).
317323

@@ -1113,12 +1119,91 @@ function_is_instance(Type, X) ->
11131119
%% TODO: what if it's not a function we produced?
11141120
andalso equal_types(RetType, proper_gen:get_ret_type(X)).
11151121

1122+
%% @doc A map whose keys and values are defined by the given `Map'.
1123+
%%
1124+
%% Shrinks towards the empty map. That is, all keys are assumed to be optional.
1125+
%%
1126+
%% Also written simply as a {@link maps. map}.
1127+
-spec map(#{Key::raw_type() => Value::raw_type()}) -> proper_types:type().
1128+
map(Map) when is_map(Map) ->
1129+
MapType = fixed_map(Map),
1130+
Shrinkers = get_prop(shrinkers, MapType),
1131+
add_props([
1132+
{remove,fun maps:remove/2},
1133+
{shrinkers, [
1134+
fun proper_shrink:map_remove_shrinker/3,
1135+
fun proper_shrink:map_key_shrinker/3
1136+
| Shrinkers
1137+
]}
1138+
], MapType).
1139+
11161140
%% @doc A map whose keys are defined by the generator `K' and values
11171141
%% by the generator `V'.
11181142
-spec map(K::raw_type(), V::raw_type()) -> proper_types:type().
11191143
map(K, V) ->
11201144
?LET(L, list({K, V}), maps:from_list(L)).
11211145

1146+
%% @doc A map merged from the given map generators.
1147+
-spec map_union([Map::raw_type()]) -> proper_types:type().
1148+
map_union(RawMaps) when is_list(RawMaps) ->
1149+
?LET(Maps, RawMaps, lists:foldl(fun maps:merge/2, #{}, Maps)).
1150+
1151+
%% @doc A map whose keys and values are defined by the given `Map'.
1152+
%% Also written simply as a {@link maps. map}.
1153+
-spec fixed_map(#{Key::raw_type() => Value::raw_type()}) -> proper_types:type().
1154+
fixed_map(Map) when is_map(Map) ->
1155+
WithValueTypes = maps:map(fun(_Key, Value) -> cook_outer(Value) end, Map),
1156+
?CONTAINER([
1157+
{generator, {typed, fun map_gen/1}},
1158+
{is_instance, {typed, fun map_is_instance/2}},
1159+
{shrinkers, [
1160+
fun proper_shrink:map_value_shrinker/3
1161+
]},
1162+
{internal_types, WithValueTypes},
1163+
{get_length, fun maps:size/1},
1164+
{join, fun maps:merge/2},
1165+
{get_keys, fun maps:keys/1},
1166+
{retrieve, fun maps:get/2},
1167+
{update, fun maps:put/3}
1168+
]).
1169+
1170+
map_gen(Type) ->
1171+
Map = get_prop(internal_types, Type),
1172+
proper_gen:fixed_map_gen(Map).
1173+
1174+
map_is_instance(Type, X) when is_map(X) ->
1175+
Map = get_prop(internal_types, Type),
1176+
map_all(
1177+
fun (Key, ValueType) when is_map_key(Key, X) ->
1178+
is_instance(maps:get(Key, X), ValueType);
1179+
(KeyOrType, ValueType) ->
1180+
case is_raw_type(KeyOrType) of
1181+
true ->
1182+
map_all(fun(Key, Value) ->
1183+
case is_instance(Key, KeyOrType) of
1184+
true -> is_instance(Value, ValueType);
1185+
false -> true %% Ignore other keys
1186+
end
1187+
end, X);
1188+
false ->
1189+
%% The key not a type and not in `X'
1190+
false
1191+
end
1192+
end,
1193+
Map
1194+
);
1195+
map_is_instance(_Type, _X) ->
1196+
false.
1197+
1198+
map_all(Fun, Map) when is_function(Fun, 2) andalso is_map(Map) ->
1199+
map_all_internal(Fun, maps:next(maps:iterator(Map)), true).
1200+
1201+
map_all_internal(Fun, _, false) when is_function(Fun, 2) ->
1202+
false;
1203+
map_all_internal(Fun, none, Result) when is_function(Fun, 2) andalso is_boolean(Result) ->
1204+
Result;
1205+
map_all_internal(Fun, {Key, Value, NextIterator}, true) when is_function(Fun, 2) ->
1206+
map_all_internal(Fun, NextIterator, Fun(Key, Value)).
11221207

11231208
%% @doc All Erlang terms (that PropEr can produce). For reasons of efficiency,
11241209
%% functions are never produced as instances of this type.<br />

src/proper_typeserver.erl

+53
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,8 @@ convert(_Mod, {type,_,nonempty_string,[]}, State, _Stack, _VarDict) ->
16481648
{ok, {simple,proper_types:non_empty(proper_types:string())}, State};
16491649
convert(_Mod, {type,_,map,any}, State, _Stack, _VarDict) ->
16501650
{ok, {simple,proper_types:map()}, State};
1651+
convert(Mod, {type,_,map,Fields}, State, Stack, VarDict) ->
1652+
convert_map(Mod, Fields, State, Stack, VarDict);
16511653
convert(_Mod, {type,_,tuple,any}, State, _Stack, _VarDict) ->
16521654
{ok, {simple,proper_types:tuple()}, State};
16531655
convert(Mod, {type,_,tuple,ElemForms}, State, Stack, VarDict) ->
@@ -1787,6 +1789,57 @@ convert_normal_rec_list(RecFun, RecArgs, NonEmpty) ->
17871789
NewRecArgs = clean_rec_args(RecArgs),
17881790
{NewRecFun, NewRecArgs}.
17891791

1792+
-spec convert_map(mod_name(), [Field], state(), stack(), var_dict()) ->
1793+
rich_result2(ret_type(), state())
1794+
when
1795+
Field :: {type, erl_anno:anno(), map_field_assoc, [abs_type()]}
1796+
| {type, erl_anno:anno(), map_field_exact, [abs_type()]}.
1797+
convert_map(Mod, Fields, State1, Stack, VarDict) ->
1798+
{AbstractRequiredFields, AbstractOptionalFields} = lists:partition(
1799+
fun ({type, _, map_field_exact, _FieldType}) ->
1800+
true;
1801+
({type, _, map_field_assoc, _FieldType}) ->
1802+
false
1803+
end,
1804+
Fields
1805+
),
1806+
case process_map_fields(Mod, AbstractRequiredFields, State1, Stack, VarDict) of
1807+
{ok, RequiredFields, State2} ->
1808+
case process_map_fields(Mod, AbstractOptionalFields, State2, Stack, VarDict) of
1809+
{ok, OptionalFields, State3} ->
1810+
Required = proper_types:fixed_map(maps:from_list(RequiredFields)),
1811+
Optional = proper_types:map(maps:from_list(OptionalFields)),
1812+
{ok, {simple, proper_types:map_union([Required, Optional])}, State3};
1813+
{error, Reason} ->
1814+
{error, Reason}
1815+
end;
1816+
{error, Reason} ->
1817+
{error, Reason}
1818+
end.
1819+
1820+
process_map_fields(Mod, AbstractFields, State, Stack, VarDict) ->
1821+
Process =
1822+
fun ({type, _, _, RawFieldTypes}, {ok, Fields, State1}) when
1823+
length(RawFieldTypes) =:= 2
1824+
->
1825+
case process_list(
1826+
Mod, RawFieldTypes, State1, [map | Stack], VarDict
1827+
) of
1828+
{ok, FieldTypes, State2} ->
1829+
{ok, [list_to_tuple(FieldTypes) | Fields], State2};
1830+
{error, Reason} ->
1831+
{error, Reason}
1832+
end;
1833+
(_FieldTypes, {error, Reason}) ->
1834+
{error, Reason}
1835+
end,
1836+
case lists:foldl(Process, {ok, [], State}, AbstractFields) of
1837+
{ok, ReverseFields, NewState} ->
1838+
{ok, lists:reverse(ReverseFields), NewState};
1839+
{error, Reason} ->
1840+
{error, Reason}
1841+
end.
1842+
17901843
-spec convert_tuple(mod_name(), [abs_type()], boolean(), state(), stack(),
17911844
var_dict()) -> rich_result2(ret_type(),state()).
17921845
convert_tuple(Mod, ElemForms, ToList, State, Stack, VarDict) ->

test/proper_exported_types_test.erl

-3
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@
4040
%%
4141
%% Still, the test is currently not 100% there.
4242
%% TODOs:
43-
%% - Eliminate the 12 errors that `proper_typeserver:demo_translate_type/2`
44-
%% currently returns. (Three of these errors are due to the incomplete
45-
%% handling of maps.)
4643
%% - Handle symbolic instances (the {'$call', ...} case below).
4744
%%
4845

0 commit comments

Comments
 (0)