Domain keys in map (#14478)

- Introduced tests for union, intersection, and difference operations involving domain key types.
- Validated subtype relationships and intersection results for maps with domain keys.
- Enhanced map fetch and delete functionalities to handle domain key types.
- Ensured correct behavior of dynamic types with domain keys in various scenarios.
This commit is contained in:
Guillaume Duboc
2025-07-11 15:52:06 +02:00
committed by José Valim
parent f87fbc2833
commit 855df4fc77
2 changed files with 1075 additions and 104 deletions
+704 -81
View File
@@ -26,6 +26,23 @@ defmodule Module.Types.Descr do
@bit_top (1 <<< 7) - 1
@bit_number @bit_integer ||| @bit_float
defmacrop domain_key(key), do: {:domain_key, key}
@domain_key_types [
{:domain_key, :binary},
{:domain_key, :empty_list},
{:domain_key, :integer},
{:domain_key, :float},
{:domain_key, :pid},
{:domain_key, :port},
{:domain_key, :reference},
{:domain_key, :fun},
{:domain_key, :atom},
{:domain_key, :tuple},
{:domain_key, :map},
{:domain_key, :list}
]
@fun_top :fun_top
@atom_top {:negation, :sets.new(version: 2)}
@map_top [{:open, %{}, []}]
@@ -46,6 +63,11 @@ defmodule Module.Types.Descr do
@not_non_empty_list Map.delete(@term, :list)
@not_list Map.replace!(@not_non_empty_list, :bitmap, @bit_top - @bit_empty_list)
@not_set %{optional: 1}
@term_or_optional Map.put(@term, :optional, 1)
@term_or_dynamic_optional Map.put(@term, :dynamic, %{optional: 1})
@not_atom_or_optional Map.delete(@term_or_optional, :atom)
@empty_intersection [0, []]
@empty_difference [0, []]
@@ -66,7 +88,7 @@ defmodule Module.Types.Descr do
def atom(as), do: %{atom: atom_new(as)}
def atom(), do: %{atom: @atom_top}
def binary(), do: %{bitmap: @bit_binary}
def closed_map(pairs), do: map_descr(:closed, pairs)
def closed_map(pairs), do: map_descr(:closed, pairs, @term_or_optional, false)
def empty_list(), do: %{bitmap: @bit_empty_list}
def empty_map(), do: %{map: @map_empty}
def integer(), do: %{bitmap: @bit_integer}
@@ -74,7 +96,8 @@ defmodule Module.Types.Descr do
def list(type), do: list_descr(type, @empty_list, true)
def non_empty_list(type, tail \\ @empty_list), do: list_descr(type, tail, false)
def open_map(), do: %{map: @map_top}
def open_map(pairs), do: map_descr(:open, pairs)
def open_map(pairs), do: map_descr(:open, pairs, @term_or_optional, false)
def open_map(pairs, default), do: map_descr(:open, pairs, if_set(default), true)
def open_tuple(elements, _fallback \\ term()), do: tuple_descr(:open, elements)
def pid(), do: %{bitmap: @bit_pid}
def port(), do: %{bitmap: @bit_port}
@@ -231,15 +254,18 @@ defmodule Module.Types.Descr do
# is equivalent to `%{:foo => integer() or not_set()}`.
#
# `not_set()` has no meaning outside of map types.
@not_set %{optional: 1}
@term_or_optional Map.put(@term, :optional, 1)
@term_or_dynamic_optional Map.put(@term, :dynamic, %{optional: 1})
@not_atom_or_optional Map.delete(@term_or_optional, :atom)
def not_set(), do: @not_set
def if_set(:term), do: term_or_optional()
def if_set(type), do: Map.put(type, :optional, 1)
# If type contains a :dynamic part, :optional gets added there.
def if_set(type) do
case type do
%{dynamic: dyn} -> Map.put(%{type | dynamic: Map.put(dyn, :optional, 1)}, :optional, 1)
_ -> Map.put(type, :optional, 1)
end
end
defp term_or_optional(), do: @term_or_optional
@compile {:inline,
@@ -539,6 +565,8 @@ defmodule Module.Types.Descr do
end
end
defp empty_or_optional?(type), do: empty?(remove_optional(type))
# For atom, bitmap, tuple, and optional, if the key is present,
# then they are not empty,
defp empty_key?(:fun, value), do: fun_empty?(value)
@@ -944,6 +972,7 @@ defmodule Module.Types.Descr do
end
end
defp atom_only?(:term), do: false
defp atom_only?(descr), do: empty?(Map.delete(descr, :atom))
defp atom_new(as) when is_list(as), do: {:union, :sets.from_list(as, version: 2)}
@@ -2216,34 +2245,79 @@ defmodule Module.Types.Descr do
# is the union of `%{..., a: atom(), b: if_set(not integer())}` and
# `%{..., a: if_set(not atom()), b: integer()}`. For maps with more keys,
# each key in a negated literal may create a new union when eliminated.
#
# Instead of a tag :open or :closed, we can also use a map of domains which
# specifies for each defined key domain (@domain_key_types) the type associated with
# those keys.
#
# For instance, the type `%{atom() => if_set(integer())}` is the type of maps where atom keys
# map to integers, without any non-atom keys. It is represented using the map literal
# `{%{atom: if_set(integer())}, [], []}`, with no defined keys or negations.
#
# The type `%{..., atom() => integer()}` represents maps with atom keys bound to integers,
# and other keys bound to any type. It will be represented using a map domain that maps
# atom to `if_set(integer())`, and every other domain key to `term_or_optional()`.
defp map_descr(tag, fields) do
case map_descr_pairs(fields, [], false) do
{fields, true} ->
%{dynamic: %{map: map_new(tag, fields |> Enum.reverse() |> :maps.from_list())}}
defp map_descr(tag, pairs, default, force?) do
{fields, domains, dynamic?} = map_descr_pairs(pairs, [], %{}, false)
{_, false} ->
%{map: map_new(tag, :maps.from_list(fields))}
map_new =
if domains != %{} or force? do
domains =
if tag == :open do
Enum.reduce(@domain_key_types, domains, &Map.put_new(&2, &1, default))
else
domains
end
map_new(domains, fields)
else
map_new(tag, fields)
end
case dynamic? do
true -> %{dynamic: %{map: map_new}}
false -> %{map: map_new}
end
end
defp map_descr_pairs([{key, :term} | rest], acc, dynamic?) do
map_descr_pairs(rest, [{key, :term} | acc], dynamic?)
# TODO: Double check if we indeed want the union here
# when we start using domain types from Elixir itself
defp map_put_domain(domain, key, value) do
Map.update(domain, key, if_set(value), &union(&1, value))
end
defp map_descr_pairs([{key, value} | rest], acc, dynamic?) do
case :maps.take(:dynamic, value) do
:error -> map_descr_pairs(rest, [{key, value} | acc], dynamic?)
{dynamic, _static} -> map_descr_pairs(rest, [{key, dynamic} | acc], true)
defp map_descr_pairs([{key, :term} | rest], fields, domain, dynamic?) do
case is_atom(key) do
true -> map_descr_pairs(rest, [{key, :term} | fields], domain, dynamic?)
false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, :term), dynamic?)
end
end
defp map_descr_pairs([], acc, dynamic?) do
{acc, dynamic?}
defp map_descr_pairs([{key, value} | rest], fields, domain, dynamic?) do
{value, dynamic?} =
case :maps.take(:dynamic, value) do
:error -> {value, dynamic?}
{dynamic, _static} -> {dynamic, true}
end
case is_atom(key) do
true -> map_descr_pairs(rest, [{key, value} | fields], domain, dynamic?)
false -> map_descr_pairs(rest, fields, map_put_domain(domain, key, value), dynamic?)
end
end
defp tag_to_type(:open), do: term_or_optional()
defp tag_to_type(:closed), do: not_set()
defp map_descr_pairs([], fields, domain, dynamic?) do
{fields |> Enum.reverse() |> :maps.from_list(), domain, dynamic?}
end
defp tuple_tag_to_type(:open), do: term_or_optional()
defp tuple_tag_to_type(:closed), do: not_set()
# Gets the default type associated to atom keys in a map.
defp map_key_tag_to_type(:open), do: term_or_optional()
defp map_key_tag_to_type(:closed), do: not_set()
defp map_key_tag_to_type(domains = %{}), do: Map.get(domains, domain_key(:atom), not_set())
defguardp is_optional_static(map)
when is_map(map) and is_map_key(map, :optional)
@@ -2420,6 +2494,58 @@ defmodule Module.Types.Descr do
:maps.iterator(open) |> :maps.next() |> map_literal_intersection_loop(closed)
end
# At least one tag is a tag-domain pair.
defp map_literal_intersection(tag_or_domains1, map1, tag_or_domains2, map2) do
# For a closed map with domains intersected with an open map with domains:
# 1. The result is closed (more restrictive)
# 2. We need to check each domain in the open map against the closed map
default1 = map_key_tag_to_type(tag_or_domains1)
default2 = map_key_tag_to_type(tag_or_domains2)
# Compute the new domain
tag_or_domains = map_domain_intersection(tag_or_domains1, tag_or_domains2)
# Go over all fields in map1 and map2 with default atom types atom1 and atom2
# 1. If key is in both maps, compute non empty intersection (:error if it is none)
# 2. If key is only in map1, compute non empty intersection with atom2
# 3. If key is only in map2, compute non empty intersection with atom1
# We do that by computing intersection on all key labels in both map1 and map2,
# using default values when a key is not present.
{tag_or_domains,
symmetrical_merge(map1, default1, map2, default2, fn _key, v1, v2 ->
non_empty_intersection!(v1, v2)
end)}
end
# Compute the intersection of two tags or tag-domain pairs.
defp map_domain_intersection(:closed, _), do: :closed
defp map_domain_intersection(_, :closed), do: :closed
defp map_domain_intersection(:open, tag_or_domains), do: tag_or_domains
defp map_domain_intersection(tag_or_domains, :open), do: tag_or_domains
defp map_domain_intersection(domains1 = %{}, domains2 = %{}) do
new_domains =
for {domain_key(_) = domain_key, type1} <- domains1, reduce: %{} do
acc_domains ->
case domains2 do
%{^domain_key => type2} ->
inter = intersection(type1, type2)
if empty_or_optional?(inter) do
acc_domains
else
Map.put(acc_domains, domain_key, inter)
end
_ ->
acc_domains
end
end
# If the explicit domains are empty, use simple atom tags
if map_size(new_domains) == 0, do: :closed, else: new_domains
end
defp map_literal_intersection_loop(:none, acc), do: {:closed, acc}
defp map_literal_intersection_loop({key, type1, iterator}, acc) do
@@ -2452,7 +2578,7 @@ defmodule Module.Types.Descr do
{:open, fields2, []}, dnf1 when map_size(fields2) == 1 ->
Enum.reduce(dnf1, [], fn {tag1, fields1, negs1}, acc ->
{key, value, _rest} = :maps.next(:maps.iterator(fields2))
t_diff = difference(Map.get(fields1, key, tag_to_type(tag1)), value)
t_diff = difference(Map.get(fields1, key, map_key_tag_to_type(tag1)), value)
if empty?(t_diff) do
acc
@@ -2524,11 +2650,9 @@ defmodule Module.Types.Descr do
# Optimization: if the key does not exist in the map, avoid building
# if_set/not_set pairs and return the popped value directly.
defp map_fetch_static(%{map: [{tag, fields, []}]}, key) when not is_map_key(fields, key) do
case tag do
:open -> {true, term()}
:closed -> {true, none()}
end
defp map_fetch_static(%{map: [{tag_or_domains, fields, []}]}, key)
when not is_map_key(fields, key) do
map_key_tag_to_type(tag_or_domains) |> pop_optional_static()
end
# Takes a map dnf and returns the union of types it can take for a given key.
@@ -2536,15 +2660,13 @@ defmodule Module.Types.Descr do
defp map_fetch_static(%{map: dnf}, key) do
dnf
|> Enum.reduce(none(), fn
# Optimization: if there are no negatives,
# we can return the value directly.
# Optimization: if there are no negatives and key exists, return its value
{_tag, %{^key => value}, []}, acc ->
value |> union(acc)
# Optimization: if there are no negatives
# and the key does not exist, return the default one.
# Optimization: if there are no negatives and the key does not exist, return the default one.
{tag, %{}, []}, acc ->
tag_to_type(tag) |> union(acc)
map_key_tag_to_type(tag) |> union(acc)
{tag, fields, negs}, acc ->
{fst, snd} = map_pop_key(tag, fields, key)
@@ -2600,6 +2722,154 @@ defmodule Module.Types.Descr do
end
end
@doc """
Refreshes the type of map after assuming some type was given to a key of a given type.
Assuming that the descr is exclusively a map (or dynamic).
"""
# TODO: Figure out how this operation will be used from Elixir
def map_refresh(:term, _key, _type), do: :badmap
def map_refresh(descr, key_descr, type) do
{dynamic_descr, static_descr} = Map.pop(descr, :dynamic)
key_descr = unfold(key_descr)
type = unfold(type)
cond do
# Either 1) static part is a map, or 2) static part is empty and dynamic part contains maps
not map_only?(static_descr) ->
:badmap
empty?(static_descr) and not (not is_nil(dynamic_descr) and descr_key?(dynamic_descr, :map)) ->
:badmap
# Either of those three types could be dynamic.
not (not is_nil(dynamic_descr) or Map.has_key?(key_descr, :dynamic) or
Map.has_key?(type, :dynamic)) ->
map_refresh_static(descr, key_descr, type)
true ->
# If one of those is dynamic, we just compute the union
{descr_dynamic, descr_static} = Map.pop(descr, :dynamic, descr)
{key_dynamic, key_static} = Map.pop(key_descr, :dynamic, key_descr)
{type_dynamic, type_static} = Map.pop(type, :dynamic, type)
with {:ok, new_static} <- map_refresh_static(descr_static, key_static, type_static),
{:ok, new_dynamic} <- map_refresh_static(descr_dynamic, key_dynamic, type_dynamic) do
{:ok, union(new_static, dynamic(new_dynamic))}
end
end
end
def map_refresh_static(%{map: _} = descr, key_descr = %{}, type) do
# Check if descr is a valid map,
case atom_fetch(key_descr) do
# If the key_descr is a singleton, we directly put the type into the map.
{:finite, [single_key]} ->
map_put(descr, single_key, type)
# In this case, we iterate on key_descr to add type to each key type it covers.
# Since we do not know which key will be used, we do the union with previous types.
_ ->
new_descr =
key_descr
|> covered_key_types()
|> Enum.reduce(descr, fn
{:atom, atom_key}, acc ->
map_refresh_atom(acc, atom_key, type)
domain_key, acc ->
map_refresh_domain(acc, domain_key, type)
end)
{:ok, new_descr}
end
end
def map_refresh_static(:term, _key_descr, _type), do: {:ok, open_map()}
def map_refresh_static(_, _, _), do: {:ok, none()}
@doc """
Updates a key in a map type by fetching its current type, unioning it with a
`new_additional_type`, and then putting the resulting union type back.
Returns:
- `{:ok, new_map_descr}`: If successful.
- `:badmap`: If the input `descr` is not a valid map type.
- `:badkey`: If the key is considered invalid during the take operation (e.g.,
an optional key that resolves to an empty type).
"""
# TODO: Figure out how this operation will be used from Elixir
def map_refresh_key(descr, key, new_additional_type) when is_atom(key) do
case map_fetch(descr, key) do
:badmap ->
:badmap
# Key is not present: we just add the new one and make it optional.
:badkey ->
with {:ok, descr} <- map_put(descr, key, if_set(new_additional_type)) do
descr
end
{_optional?, current_key_type} ->
type_to_put = union(current_key_type, new_additional_type)
case map_fetch_and_put(descr, key, type_to_put) do
{_taken_type, new_map_descr} -> new_map_descr
# Propagates :badmap or :badkey from map_fetch_and_put
error -> error
end
end
end
def map_refresh_domain(%{map: [{tag, fields, []}]}, domain, type) do
%{map: [{map_refresh_tag(tag, domain, type), fields, []}]}
end
def map_refresh_domain(%{map: dnf}, domain, type) do
Enum.map(dnf, fn
{tag, fields, []} ->
{map_refresh_tag(tag, domain, type), fields, []}
{tag, fields, negs} ->
# For negations, we count on the idea that a negation will not remove any
# type from a domain unless it completely cancels out the type.
# So for any non-empty map dnf, we just update the domain with the new type,
# as well as its negations to keep them accurate.
{map_refresh_tag(tag, domain, type), fields,
Enum.map(negs, fn {neg_tag, neg_fields} ->
{map_refresh_tag(neg_tag, domain, type), neg_fields}
end)}
end)
end
def map_refresh_atom(descr = %{map: dnf}, atom_key, type) do
case atom_key do
{:union, keys} ->
keys
|> :sets.to_list()
|> Enum.reduce(descr, fn key, acc -> map_refresh_key(acc, key, type) end)
{:negation, keys} ->
# 1) Fetch all the possible keys in the dnf
# 2) Get them all, except the ones in neg_atoms
possible_keys = map_fetch_all_key_names(dnf)
considered_keys = :sets.subtract(possible_keys, keys)
considered_keys
|> :sets.to_list()
|> Enum.reduce(descr, fn key, acc -> map_refresh_key(acc, key, type) end)
|> map_refresh_domain(domain_key(:atom), type)
end
end
def map_refresh_tag(tag_or_domains, domain_key, type) do
case tag_or_domains do
:open -> :open
:closed -> %{domain_key => if_set(type)}
domains = %{} -> Map.update(domains, domain_key, if_set(type), &union(&1, type))
end
end
defp map_put_shared(descr, key, type) do
with {nil, descr} <- map_take(descr, key, nil, &map_put_static(&1, key, type)) do
{:ok, descr}
@@ -2632,6 +2902,249 @@ defmodule Module.Types.Descr do
end
end
@doc """
Computes the union of types for keys matching `key_type` within the `map_type`.
This generalizes `map_fetch/2` (which operates on a single literal key) to
work with a key type (e.g., `atom()`, `integer()`, `:a or :b`). It's based
on the map-selection operator t.[t'] described in Section 4.2 of "Typing Records,
Maps, and Structs" (Castagna et al., ICFP 2023).
## Return Values
The function returns a tuple indicating the outcome and the resulting type union:
* `{:ok, type}`: Standard success. `type` is the resulting union of types
found for the matching keys. This covers two sub-cases:
* **Keys definitely exist:** If `disjoint?(type, not_set())` is true,
all keys matching `key_type` are guaranteed to exist.
* **Keys may exist:** If `type` includes `not_set()`, some keys
matching `key_type` might exist (contributing their types) while
others might be absent (contributing `not_set()`).
* `{:ok_absent, type}`: Success, but the resulting `type` is `none()` or a
subtype of `not_set()`. This indicates that no key matching `key_type`
can exist with a value other than `not_set()`. The caller may wish to
issue a warning, as this often implies selecting a field that is
effectively undefined.
# TODO: implement/decide if worth it (it's from the paper)
* `{:ok_spillover, type}`: Success, and `type` is the resulting union.
However, this indicates that the `key_type` included keys not explicitly
covered by the `map_type`'s fields or domain specifications. The
projection relied on the map's default behavior (e.g., the `term()`
value type for unspecified keys in an open map). The caller may wish to
issue a warning, as this could conceal issues like selecting keys
not intended by the map's definition.
* `:badmap`: The input `map_type` was invalid (e.g., not a map type or
a dynamic type wrapping a map type).
* `:badkeytype`: The input `key_type` was invalid (e.g., not a subtype
of the allowed key types like `atom()`, `integer()`, etc.).
"""
# TODO: Figure out how to use this operation from Elixir
def map_get(:term, _key_descr), do: :badmap
def map_get(%{} = descr, key_descr) do
case :maps.take(:dynamic, descr) do
:error ->
if descr_key?(descr, :map) and map_only?(descr) do
{optional?, type_selected} = map_get_static(descr, key_descr) |> pop_optional_static()
cond do
empty?(type_selected) -> {:ok_absent, atom([nil])}
optional? -> {:ok, nil_or_type(type_selected)}
true -> {:ok_present, type_selected}
end
else
:badmap
end
{dynamic, static} ->
if descr_key?(dynamic, :map) and map_only?(static) do
{optional_dynamic?, dynamic_type} =
map_get_static(dynamic, key_descr) |> pop_optional_static()
{optional_static?, static_type} =
map_get_static(static, key_descr) |> pop_optional_static()
type_selected = union(dynamic(dynamic_type), static_type)
cond do
empty?(type_selected) -> {:ok_absent, atom([nil])}
optional_dynamic? or optional_static? -> {:ok, nil_or_type(type_selected)}
true -> {:ok_present, type_selected}
end
else
:badmap
end
end
end
# Returns the list of key types that are covered by the key_descr.
# E.g., for `{atom([:ok]), term} or integer()` it returns `[:tuple, :integer]`.
# We treat bitmap types as a separate key type.
defp covered_key_types(:term), do: @domain_key_types
defp covered_key_types(key_descr) do
for {type_kind, type} <- key_descr, reduce: [] do
acc ->
cond do
type_kind == :atom -> [{:atom, type} | acc]
type_kind == :bitmap -> bitmap_to_domain_keys(type) ++ acc
not empty?(%{type_kind => type}) -> [domain_key(type_kind) | acc]
true -> acc
end
end
end
defp bitmap_to_domain_keys(bitmap) do
[
if((bitmap &&& @bit_binary) != 0, do: domain_key(:binary)),
if((bitmap &&& @bit_empty_list) != 0, do: domain_key(:empty_list)),
if((bitmap &&& @bit_integer) != 0, do: domain_key(:integer)),
if((bitmap &&& @bit_float) != 0, do: domain_key(:float)),
if((bitmap &&& @bit_pid) != 0, do: domain_key(:pid)),
if((bitmap &&& @bit_port) != 0, do: domain_key(:port)),
if((bitmap &&& @bit_reference) != 0, do: domain_key(:reference))
]
|> Enum.reject(&is_nil/1)
end
defp nil_or_type(type), do: union(type, atom([nil]))
defp unfold_domains(:closed), do: %{}
defp unfold_domains(:open),
do: Map.new(@domain_key_types, fn domain_key -> {domain_key, @term_or_optional} end)
defp unfold_domains(domains = %{}), do: domains
defp map_get_static(%{map: [{tag_or_domains, fields, []}]}, key_descr) do
# For each non-empty kind of type in the key_descr, we add the corresponding key domain in a union.
domains = unfold_domains(tag_or_domains)
key_descr
|> covered_key_types()
|> Enum.reduce(none(), fn
# Note: we could stop if we reach term_or_optional()
{:atom, atom_type}, acc -> map_get_atom([{domains, fields, []}], atom_type) |> union(acc)
key_type, acc -> Map.get(domains, key_type, not_set()) |> union(acc)
end)
end
defp map_get_static(%{map: dnf}, key_descr) do
key_descr
|> covered_key_types()
|> Enum.reduce(none(), fn
{:atom, atom_type}, acc ->
map_get_atom(dnf, atom_type) |> union(acc)
domain_key, acc ->
map_get_domain(dnf, domain_key) |> union(acc)
end)
end
defp map_get_static(%{}, _key), do: not_set()
defp map_get_static(:term, _key), do: term_or_optional()
# Given a map dnf return the union of types for a given atom type. Handles two cases:
# 1. A union of atoms (e.g., `{:union, atoms}`):
# - Iterates through each atom in the union.
# - Fetches the type for each atom and combines them into a union.
#
# 2. A negation of atoms (e.g., `{:negation, atoms}`):
# - Fetches all possible keys in the map's DNF.
# - Excludes the negated atoms from the considered keys.
# - Includes the domain of all atoms in the map's DNF.
#
# Example:
# Fetching a key of type `atom() and not (:a)` from a map of type
# `%{a: atom(), b: float(), atom() => pid()}`
# would return either `nil` or `float()` (key `:b`) or `pid()` (key `atom()`), but not `atom()` (key `:a`).
defp map_get_atom(dnf, atom_type) do
case atom_type do
{:union, atoms} ->
atoms
|> :sets.to_list()
|> Enum.reduce(none(), fn atom, acc ->
{static_optional?, type} = map_fetch_static(%{map: dnf}, atom)
if static_optional? do
union(type, acc) |> nil_or_type() |> if_set()
else
union(type, acc)
end
end)
{:negation, atoms} ->
# 1) Fetch all the possible keys in the dnf
# 2) Get them all, except the ones in neg_atoms
possible_keys = map_fetch_all_key_names(dnf)
considered_keys = :sets.subtract(possible_keys, atoms)
considered_keys
|> :sets.to_list()
|> Enum.reduce(none(), fn atom, acc ->
{static_optional?, type} = map_fetch_static(%{map: dnf}, atom)
if static_optional? do
union(type, acc) |> nil_or_type() |> if_set()
else
union(type, acc)
end
end)
|> union(map_get_domain(dnf, domain_key(:atom)))
end
end
# Fetch all present keys in a map dnf (including negated ones).
defp map_fetch_all_key_names(dnf) do
dnf
|> Enum.reduce(:sets.new(version: 2), fn {_tag, fields, negs}, acc ->
keys = :sets.from_list(Map.keys(fields))
# Add all the negative keys
# Example: %{...} and not %{a: not_set()} makes key :a present in the map
Enum.reduce(negs, keys, fn {_tag, neg_fields}, acc ->
:sets.from_list(Map.keys(neg_fields)) |> :sets.union(acc)
end)
|> :sets.union(acc)
end)
end
# Take a map dnf and return the union of types for the given key domain.
defp map_get_domain(dnf, domain_key(_) = domain_key) do
dnf
|> Enum.reduce(none(), fn
{tag, _fields, []}, acc when is_atom(tag) ->
map_key_tag_to_type(tag) |> union(acc)
# Optimization: if there are no negatives and domains exists, return its value
{%{^domain_key => value}, _fields, []}, acc ->
value |> union(acc)
# Optimization: if there are no negatives and the key does not exist, return the default type.
{domains = %{}, _fields, []}, acc ->
map_key_tag_to_type(domains) |> union(acc)
{tag_or_domains, fields, negs}, acc ->
{fst, snd} = map_pop_domain(tag_or_domains, fields, domain_key)
case map_split_negative_domain(negs, domain_key) do
:empty ->
acc
negative ->
negative
|> pair_make_disjoint()
|> pair_eliminate_negations_fst(fst, snd)
|> union(acc)
end
end)
end
@doc """
Removes a key from a map type and return its type.
@@ -2742,53 +3255,102 @@ defmodule Module.Types.Descr do
defp map_empty?(:open, fs, [{:closed, _} | negs]), do: map_empty?(:open, fs, negs)
defp map_empty?(tag, fields, [{neg_tag, neg_fields} | negs]) do
(Enum.all?(neg_fields, fn {neg_key, neg_type} ->
cond do
# Keys that are present in the negative map, but not in the positive one
is_map_key(fields, neg_key) ->
true
if map_check_domain_keys(tag, neg_tag) do
atom_default = map_key_tag_to_type(tag)
neg_atom_default = map_key_tag_to_type(neg_tag)
# The key is not shared between positive and negative maps,
# if the negative type is optional, then there may be a value in common
tag == :closed ->
is_optional_static(neg_type)
(Enum.all?(neg_fields, fn {neg_key, neg_type} ->
cond do
# Ignore keys present in both maps; will be handled below
is_map_key(fields, neg_key) ->
true
# There may be value in common
tag == :open ->
diff = difference(term_or_optional(), neg_type)
empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs)
end
end) and
Enum.all?(fields, fn {key, type} ->
case neg_fields do
%{^key => neg_type} ->
diff = difference(type, neg_type)
empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs)
# The key is not shared between positive and negative maps,
# if the negative type is optional, then there may be a value in common
tag == :closed ->
is_optional_static(neg_type)
%{} ->
cond do
neg_tag == :open ->
true
# There may be value in common
tag == :open ->
diff = difference(term_or_optional(), neg_type)
empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs)
neg_tag == :closed and not is_optional_static(type) ->
false
true ->
# an absent key in a open negative map can be ignored
diff = difference(type, tag_to_type(neg_tag))
empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs)
end
true ->
diff = difference(atom_default, neg_type)
empty?(diff) or map_empty?(tag, Map.put(fields, neg_key, diff), negs)
end
end)) or map_empty?(tag, fields, negs)
end) and
Enum.all?(fields, fn {key, type} ->
case neg_fields do
%{^key => neg_type} ->
diff = difference(type, neg_type)
empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs)
%{} ->
cond do
neg_tag == :open ->
true
neg_tag == :closed and not is_optional_static(type) ->
false
true ->
# an absent key in a open negative map can be ignored
diff = difference(type, neg_atom_default)
empty?(diff) or map_empty?(tag, Map.put(fields, key, diff), negs)
end
end
end)) or map_empty?(tag, fields, negs)
else
map_empty?(tag, fields, negs)
end
end
# Verify the domain condition from equation (22) in paper ICFP'23 https://www.irif.fr/~gc/papers/icfp23.pdf
# which is that every domain key type in the positive map is a subtype
# of the corresponding domain key type in the negative map.
defp map_check_domain_keys(:closed, _), do: true
defp map_check_domain_keys(_, :open), do: true
# An open map is a subtype iff the negative domains are all present as term_or_optional()
defp map_check_domain_keys(:open, neg_domains) do
map_size(neg_domains) == length(@domain_key_types) and
Enum.all?(neg_domains, fn {domain_key(_), type} -> subtype?(term_or_optional(), type) end)
end
# A positive domains is smaller than a closed map iff all its keys are empty or optional
defp map_check_domain_keys(pos_domains, :closed) do
Enum.all?(pos_domains, fn {domain_key(_), type} -> empty_or_optional?(type) end)
end
# Component-wise comparison of domains
defp map_check_domain_keys(pos_domains, neg_domains) do
Enum.all?(pos_domains, fn {domain_key(_) = domain_key, type} ->
subtype?(type, Map.get(neg_domains, domain_key, not_set()))
end)
end
defp map_pop_key(tag, fields, key) do
case :maps.take(key, fields) do
{value, fields} -> {value, %{map: map_new(tag, fields)}}
:error -> {tag_to_type(tag), %{map: map_new(tag, fields)}}
:error -> {map_key_tag_to_type(tag), %{map: map_new(tag, fields)}}
end
end
# Pop a domain type, e.g. popping integers from %{integer() => binary()}
# returns {if_set(binary()), %{integer() => if_set(binary()}}
# If the domain is not present, use the tag to type as default.
defp map_pop_domain(domains = %{}, fields, domain_key) do
case :maps.take(domain_key, domains) do
{value, domains} -> {if_set(value), %{map: map_new(domains, fields)}}
:error -> {map_key_tag_to_type(domains), %{map: map_new(domains, fields)}}
end
end
# Atom case
defp map_pop_domain(tag, fields, _domain_key),
do: {map_key_tag_to_type(tag), %{map: map_new(tag, fields)}}
defp map_split_negative(negs, key) do
Enum.reduce_while(negs, [], fn
# A negation with an open map means the whole thing is empty.
@@ -2797,6 +3359,13 @@ defmodule Module.Types.Descr do
end)
end
defp map_split_negative_domain(negs, domain_key) do
Enum.reduce_while(negs, [], fn
{:open, fields}, _acc when map_size(fields) == 0 -> {:halt, :empty}
{tag, fields}, neg_acc -> {:cont, [map_pop_domain(tag, fields, domain_key) | neg_acc]}
end)
end
# Use heuristics to normalize a map dnf for pretty printing.
defp map_normalize(dnfs) do
for dnf <- dnfs, not map_empty?([dnf]) do
@@ -2915,11 +3484,26 @@ defmodule Module.Types.Descr do
{:map, [], []}
end
def map_literal_to_quoted({domains = %{}, fields}, _opts)
when map_size(domains) == 0 and map_size(fields) == 0 do
{:empty_map, [], []}
end
def map_literal_to_quoted({:open, %{__struct__: @not_atom_or_optional} = fields}, _opts)
when map_size(fields) == 1 do
{:non_struct_map, [], []}
end
def map_literal_to_quoted({domains = %{}, fields}, opts) do
domain_fields =
for {domain_key(domain_type), value_type} <- domains do
{{domain_type, [], []}, map_value_to_quoted(value_type, opts)}
end
regular_fields_quoted = map_fields_to_quoted(:closed, Enum.sort(fields), opts)
{:%{}, [], domain_fields ++ regular_fields_quoted}
end
def map_literal_to_quoted({tag, fields}, opts) do
case tag do
:closed ->
@@ -2973,13 +3557,17 @@ defmodule Module.Types.Descr do
literal_to_quoted(key)
end
{optional?, type} = pop_optional_static(type)
{key, map_value_to_quoted(type, opts)}
end
end
cond do
not optional? -> {key, to_quoted(type, opts)}
empty?(type) -> {key, {:not_set, [], []}}
true -> {key, {:if_set, [], [to_quoted(type, opts)]}}
end
defp map_value_to_quoted(type, opts) do
{optional?, type} = pop_optional_static(type)
cond do
not optional? -> to_quoted(type, opts)
empty?(type) -> {:not_set, [], []}
true -> {:if_set, [], [to_quoted(type, opts)]}
end
end
@@ -3410,7 +3998,7 @@ defmodule Module.Types.Descr do
defp tuple_get(dnf, index) do
Enum.reduce(dnf, none(), fn
{tag, elements}, acc -> Enum.at(elements, index, tag_to_type(tag)) |> union(acc)
{tag, elements}, acc -> Enum.at(elements, index, tuple_tag_to_type(tag)) |> union(acc)
end)
end
@@ -3708,9 +4296,9 @@ defmodule Module.Types.Descr do
## Map helpers
# Erlang maps:merge_with/3 has to preserve the order in combiner.
# We don't care about the order, so we have a faster implementation.
defp symmetrical_merge(left, right, fun) do
# Erlang maps:merge_with/3 has to preserve the order in combiner.
# We don't care about the order, so we have a faster implementation.
if map_size(left) > map_size(right) do
iterator_merge(:maps.next(:maps.iterator(right)), left, fun)
else
@@ -3730,9 +4318,44 @@ defmodule Module.Types.Descr do
defp iterator_merge(:none, map, _fun), do: map
# Perform a symmetrical merge with default values
defp symmetrical_merge(left, left_default, right, right_default, fun) do
iterator = :maps.next(:maps.iterator(left))
iterator_merge_left(iterator, left_default, right, right_default, %{}, fun)
end
defp iterator_merge_left({key, v1, iterator}, v1_default, map, v2_default, acc, fun) do
value =
case map do
%{^key => v2} -> fun.(key, v1, v2)
%{} -> fun.(key, v1, v2_default)
end
acc = Map.put(acc, key, value)
iterator_merge_left(:maps.next(iterator), v1_default, map, v2_default, acc, fun)
end
defp iterator_merge_left(:none, v1_default, map, _v2_default, acc, fun) do
iterator_merge_right(:maps.next(:maps.iterator(map)), v1_default, acc, fun)
end
defp iterator_merge_right({key, v2, iterator}, v1_default, acc, fun) do
acc =
case acc do
%{^key => _} -> acc
%{} -> Map.put(acc, key, fun.(key, v1_default, v2))
end
iterator_merge_right(:maps.next(iterator), v1_default, acc, fun)
end
defp iterator_merge_right(:none, _v1_default, acc, _fun) do
acc
end
# Erlang maps:intersect_with/3 has to preserve the order in combiner.
# We don't care about the order, so we have a faster implementation.
defp symmetrical_intersection(left, right, fun) do
# Erlang maps:intersect_with/3 has to preserve the order in combiner.
# We don't care about the order, so we have a faster implementation.
if map_size(left) > map_size(right) do
iterator_intersection(:maps.next(:maps.iterator(right)), left, [], fun)
else
@@ -16,6 +16,16 @@ defmodule Module.Types.DescrTest do
import Module.Types.Descr, except: [fun: 1]
defmacrop domain_key(key), do: {:domain_key, key}
defp number(), do: union(integer(), float())
defp empty_tuple(), do: tuple([])
defp tuple_of_size_at_least(n) when is_integer(n), do: open_tuple(List.duplicate(term(), n))
defp tuple_of_size(n) when is_integer(n) and n >= 0, do: tuple(List.duplicate(term(), n))
defp list(elem_type, tail_type), do: union(empty_list(), non_empty_list(elem_type, tail_type))
defp map_with_default(descr), do: open_map([], if_set(descr))
defp nil_or_type(type), do: union(type, atom([nil]))
describe "union" do
test "bitmap" do
assert union(integer(), float()) == union(float(), integer())
@@ -101,8 +111,28 @@ defmodule Module.Types.DescrTest do
a_integer_open = open_map(a: integer())
assert equal?(union(closed_map(a: integer()), a_integer_open), a_integer_open)
assert difference(open_map(a: integer()), closed_map(b: boolean()))
|> equal?(open_map(a: integer()))
# Domain key types
atom_to_atom = open_map([{domain_key(:atom), atom()}])
atom_to_integer = open_map([{domain_key(:atom), integer()}])
# Test union identity and different type maps
assert union(atom_to_atom, atom_to_atom) == atom_to_atom
# Test subtype relationships with domain key maps
refute open_map([{domain_key(:atom), union(atom(), integer())}])
|> subtype?(union(atom_to_atom, atom_to_integer))
assert union(atom_to_atom, atom_to_integer)
|> subtype?(open_map([{domain_key(:atom), union(atom(), integer())}]))
# Test unions with empty and open maps
assert union(empty_map(), open_map([{domain_key(:integer), atom()}]))
|> equal?(open_map([{domain_key(:integer), atom()}]))
assert union(open_map(), open_map([{domain_key(:integer), atom()}])) == open_map()
# Test union of open map and map with domain key
assert union(open_map(), open_map([{domain_key(:integer), atom()}])) == open_map()
end
test "list" do
@@ -263,7 +293,64 @@ defmodule Module.Types.DescrTest do
assert empty?(intersection(closed_map(a: integer()), closed_map(a: atom())))
end
defp number(), do: union(integer(), float())
test "map with domain keys" do
# %{..., int => t1, atom => t2} and %{int => t3}
# intersection is %{int => t1 and t3, atom => none}
map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:atom), atom()}])
map2 = closed_map([{domain_key(:integer), number()}])
intersection = intersection(map1, map2)
expected =
closed_map([{domain_key(:integer), integer()}, {domain_key(:atom), none()}])
assert equal?(intersection, expected)
# %{..., int => t1, atom => t2} and %{int => t3, pid => t4}
# intersection is %{int =>t1 and t3, atom => none, pid => t4}
map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:atom), atom()}])
map2 = closed_map([{domain_key(:integer), float()}, {domain_key(:pid), binary()}])
intersection = intersection(map1, map2)
expected =
closed_map([
{domain_key(:integer), intersection(integer(), float())},
{domain_key(:atom), none()},
{domain_key(:pid), binary()}
])
assert equal?(intersection, expected)
# %{..., int => t1, string => t3} and %{int => t4}
# intersection is %{int => t1 and t4, string => none}
map1 = open_map([{domain_key(:integer), integer()}, {domain_key(:binary), binary()}])
map2 = closed_map([{domain_key(:integer), float()}])
intersection = intersection(map1, map2)
assert equal?(
intersection,
closed_map([
{domain_key(:integer), intersection(integer(), float())},
{domain_key(:binary), none()}
])
)
assert subtype?(empty_map(), closed_map([{domain_key(:integer), atom()}]))
t1 = closed_map([{domain_key(:integer), atom()}])
t2 = closed_map([{domain_key(:integer), binary()}])
assert equal?(intersection(t1, t2), empty_map())
t1 = closed_map([{domain_key(:integer), atom()}])
t2 = closed_map([{domain_key(:atom), term()}])
# their intersection is the empty map
refute empty?(intersection(t1, t2))
assert equal?(intersection(t1, t2), empty_map())
end
test "list" do
assert intersection(list(term()), list(term())) == list(term())
@@ -376,10 +463,6 @@ defmodule Module.Types.DescrTest do
assert empty?(difference(dynamic(integer()), integer()))
end
defp empty_tuple(), do: tuple([])
defp tuple_of_size_at_least(n) when is_integer(n), do: open_tuple(List.duplicate(term(), n))
defp tuple_of_size(n) when is_integer(n) and n >= 0, do: tuple(List.duplicate(term(), n))
test "tuple" do
assert empty?(difference(open_tuple([atom()]), open_tuple([term()])))
refute empty?(difference(tuple(), empty_tuple()))
@@ -449,9 +532,57 @@ defmodule Module.Types.DescrTest do
|> equal?(open_map(a: atom()))
refute empty?(difference(open_map(), empty_map()))
assert difference(open_map(a: integer()), closed_map(b: boolean()))
|> equal?(open_map(a: integer()))
end
defp list(elem_type, tail_type), do: union(empty_list(), non_empty_list(elem_type, tail_type))
test "map with domain keys" do
# Non-overlapping domain keys
t1 = closed_map([{domain_key(:integer), atom()}])
t2 = closed_map([{domain_key(:atom), binary()}])
assert equal?(difference(t1, t2) |> union(empty_map()), t1)
assert empty?(difference(t1, t1))
# %{atom() => t1} and not %{atom() => t2} is not %{atom() => t1 and not t2}
t3 = closed_map([{domain_key(:integer), atom()}])
t4 = closed_map([{domain_key(:integer), atom([:ok])}])
assert subtype?(difference(t3, t4), t3)
refute difference(t3, t4)
|> equal?(closed_map([{domain_key(:integer), difference(atom(), atom([:ok]))}]))
# Difference with a non-domain key map
t5 = closed_map([{domain_key(:integer), union(atom(), integer())}])
t6 = closed_map(a: atom())
assert equal?(difference(t5, t6), t5)
# Removing atom keys from a map with defined atom keys
a_number = closed_map(a: number())
a_number_and_pids = closed_map([{:a, number()}, {domain_key(:atom), pid()}])
atom_to_float = closed_map([{domain_key(:atom), float()}])
atom_to_term = closed_map([{domain_key(:atom), term()}])
atom_to_pid = closed_map([{domain_key(:atom), pid()}])
t_diff = difference(a_number, atom_to_float)
# Removing atom keys that map to float, make the :a key point to integer only.
assert map_fetch(t_diff, :a) == {false, integer()}
# %{a => number, atom => pid} and not %{atom => float} gives numbers on :a
assert map_fetch(difference(a_number_and_pids, atom_to_float), :a) == {false, number()}
assert map_fetch(t_diff, :foo) == :badkey
assert subtype?(a_number, atom_to_term)
refute subtype?(a_number, atom_to_float)
# Removing all atom keys from map %{:a => type} means there is nothing left.
assert empty?(difference(a_number, atom_to_term))
refute empty?(intersection(atom_to_term, a_number))
assert empty?(intersection(atom_to_pid, a_number))
# (%{:a => number} and not %{:a => float}) is %{:a => integer}
assert equal?(difference(a_number, atom_to_float), closed_map(a: integer()))
end
test "list" do
# Basic list type differences
@@ -556,6 +687,16 @@ defmodule Module.Types.DescrTest do
assert dynamic(open_map(a: union(integer(), binary()))) ==
open_map(a: dynamic(integer()) |> union(binary()))
# For domains too
t1 = dynamic(open_map([{domain_key(:integer), integer()}]))
t2 = open_map([{domain_key(:integer), dynamic(integer())}])
assert t1 == t2
# if_set on dynamic fields also must work
t1 = dynamic(open_map(a: if_set(integer())))
t2 = open_map(a: if_set(dynamic(integer())))
assert t1 == t2
end
end
@@ -612,6 +753,27 @@ defmodule Module.Types.DescrTest do
assert subtype?(closed_map(a: integer()), closed_map(a: if_set(integer())))
refute subtype?(closed_map(a: if_set(term())), closed_map(a: term()))
assert subtype?(closed_map(a: term()), closed_map(a: if_set(term())))
# With domains
t1 = closed_map([{domain_key(:integer), number()}])
t2 = closed_map([{domain_key(:integer), integer()}])
assert subtype?(t2, t1)
t1_minus_t2 = difference(t1, t2)
refute empty?(t1_minus_t2)
assert subtype?(map_with_default(number()), open_map())
t = difference(open_map(), map_with_default(number()))
refute empty?(t)
refute subtype?(open_map(), map_with_default(number()))
assert subtype?(map_with_default(integer()), map_with_default(number()))
refute subtype?(map_with_default(float()), map_with_default(atom()))
assert equal?(
intersection(map_with_default(number()), map_with_default(float())),
map_with_default(float())
)
end
test "list" do
@@ -1438,7 +1600,6 @@ defmodule Module.Types.DescrTest do
test "map_fetch with dynamic" do
assert map_fetch(dynamic(), :a) == {true, dynamic()}
assert map_fetch(union(dynamic(), integer()), :a) == :badmap
assert map_fetch(union(dynamic(open_map(a: integer())), integer()), :a) == :badmap
assert map_fetch(union(dynamic(integer()), integer()), :a) == :badmap
@@ -1455,6 +1616,104 @@ defmodule Module.Types.DescrTest do
|> map_fetch(:a) == {false, union(dynamic(atom()), integer())}
end
test "map_fetch with domain keys" do
integer_to_atom = open_map([{domain_key(:integer), atom()}])
assert map_fetch(integer_to_atom, :foo) == :badkey
# the key :a is for sure of type pid and exists in type
# %{atom() => pid()} and not %{:a => not_set()}
t1 = closed_map([{domain_key(:atom), pid()}])
t2 = closed_map(a: not_set())
t3 = open_map(a: not_set())
# Indeed, t2 is equivalent to the empty map
assert map_fetch(difference(t1, t2), :a) == :badkey
assert map_fetch(difference(t1, t3), :a) == {false, pid()}
t4 = closed_map([{domain_key(:pid), atom()}])
assert map_fetch(difference(t1, t4) |> difference(t3), :a) == {false, pid()}
assert map_fetch(closed_map([{domain_key(:atom), pid()}]), :a) == :badkey
assert map_fetch(dynamic(closed_map([{domain_key(:atom), pid()}])), :a) ==
{true, dynamic(pid())}
assert closed_map([{domain_key(:atom), number()}])
|> difference(open_map(a: if_set(integer())))
|> map_fetch(:a) == {false, float()}
assert closed_map([{domain_key(:atom), number()}])
|> difference(closed_map(b: if_set(integer())))
|> map_fetch(:a) == :badkey
end
test "map_get with domain keys" do
assert map_get(term(), term()) == :badmap
map_type = closed_map([{domain_key(:tuple), binary()}])
assert map_get(map_type, tuple()) == {:ok, nil_or_type(binary())}
# Type with all domain types
# %{:bar => :ok, integer() => :int, float() => :float, atom() => binary(), binary() => integer(), tuple() => float(), map() => pid(), reference() => port(), pid() => boolean()}
all_domains =
closed_map([
{:bar, atom([:ok])},
{domain_key(:integer), atom([:int])},
{domain_key(:float), atom([:float])},
{domain_key(:atom), binary()},
{domain_key(:binary), integer()},
{domain_key(:tuple), float()},
{domain_key(:map), pid()},
{domain_key(:reference), port()},
{domain_key(:pid), reference()},
{domain_key(:port), boolean()}
])
assert map_get(all_domains, atom([:bar])) == {:ok_present, atom([:ok])}
assert map_get(all_domains, integer()) == {:ok, atom([:int]) |> nil_or_type()}
assert map_get(all_domains, number()) == {:ok, atom([:int, :float]) |> nil_or_type()}
assert map_get(all_domains, empty_list()) == {:ok_absent, atom([nil])}
assert map_get(all_domains, atom([:foo])) == {:ok, binary() |> nil_or_type()}
assert map_get(all_domains, binary()) == {:ok, integer() |> nil_or_type()}
assert map_get(all_domains, tuple([integer(), atom()])) == {:ok, nil_or_type(float())}
assert map_get(all_domains, empty_map()) == {:ok, pid() |> nil_or_type()}
# Union
assert map_get(all_domains, union(tuple(), empty_map())) ==
{:ok, union(float(), pid() |> nil_or_type())}
# Removing all maps with tuple keys
t_no_tuple = difference(all_domains, closed_map([{domain_key(:tuple), float()}]))
t_really_no_tuple = difference(all_domains, open_map([{domain_key(:tuple), float()}]))
assert subtype?(all_domains, open_map())
# It's only closed maps, so it should not change
assert map_get(t_no_tuple, tuple()) == {:ok, float() |> nil_or_type()}
# This time we actually removed all tuple to float keys
assert map_get(t_really_no_tuple, tuple()) == {:ok_absent, atom([nil])}
t1 = closed_map([{domain_key(:tuple), integer()}])
t2 = closed_map([{domain_key(:tuple), float()}])
t3 = union(t1, t2)
assert map_get(t3, tuple()) == {:ok, number() |> nil_or_type()}
end
test "map_get with dynamic" do
{_answer, type_selected} = map_get(dynamic(), term())
assert equal?(type_selected, dynamic() |> nil_or_type())
end
test "map_get with atom fall back" do
map = closed_map([{:a, atom([:a])}, {:b, atom([:b])}, {domain_key(:atom), pid()}])
assert map_get(map, atom([:a, :b])) == {:ok_present, atom([:a, :b])}
assert map_get(map, atom([:a, :c])) == {:ok, union(atom([:a]), pid() |> nil_or_type())}
assert map_get(map, atom() |> difference(atom([:a, :b]))) == {:ok, pid() |> nil_or_type()}
assert map_get(map, atom() |> difference(atom([:a]))) ==
{:ok, union(atom([:b]), pid() |> nil_or_type())}
end
test "map_delete" do
assert map_delete(term(), :a) == :badmap
assert map_delete(integer(), :a) == :badmap
@@ -1493,7 +1752,10 @@ defmodule Module.Types.DescrTest do
# Deleting from a difference of maps
{:ok, type} =
map_delete(difference(closed_map(a: integer(), b: atom()), closed_map(a: integer())), :b)
map_delete(
difference(closed_map(a: integer(), b: atom()), closed_map(a: integer())),
:b
)
assert equal?(type, closed_map(a: integer()))
@@ -1501,6 +1763,12 @@ defmodule Module.Types.DescrTest do
assert equal?(type, open_map(a: not_set()))
end
test "map_delete with atom fallback" do
assert closed_map([{:a, integer()}, {:b, atom()}, {domain_key(:atom), pid()}])
|> map_delete(:a) ==
{:ok, closed_map([{:a, not_set()}, {:b, atom()}, {domain_key(:atom), pid()}])}
end
test "map_take" do
assert map_take(term(), :a) == :badmap
assert map_take(integer(), :a) == :badmap
@@ -1588,11 +1856,15 @@ defmodule Module.Types.DescrTest do
assert equal?(
type,
union(closed_map(a: integer(), c: boolean()), closed_map(b: atom(), c: boolean()))
union(
closed_map(a: integer(), c: boolean()),
closed_map(b: atom(), c: boolean())
)
)
# Put a key-value pair in a dynamic map
assert map_put(dynamic(open_map()), :a, integer()) == {:ok, dynamic(open_map(a: integer()))}
assert map_put(dynamic(open_map()), :a, integer()) ==
{:ok, dynamic(open_map(a: integer()))}
# Put a key-value pair in an intersection of maps
{:ok, type} =
@@ -1617,6 +1889,64 @@ defmodule Module.Types.DescrTest do
{false, type} = map_fetch(map, :a)
assert equal?(type, atom())
end
test "map_put with domain keys" do
# Using a literal key or an expression of that singleton key is the same
assert map_refresh(empty_map(), atom([:a]), integer()) == {:ok, closed_map(a: integer())}
# Several keys
assert map_refresh(empty_map(), atom([:a, :b]), integer()) ==
{:ok, closed_map(a: if_set(integer()), b: if_set(integer()))}
assert map_refresh(empty_map(), integer(), integer()) ==
{:ok, closed_map([{domain_key(:integer), integer()}])}
assert map_refresh(closed_map([{domain_key(:integer), integer()}]), integer(), float()) ==
{:ok, closed_map([{domain_key(:integer), number()}])}
assert map_refresh(open_map(), integer(), integer()) == {:ok, open_map()}
# TODO: Revisit this
# {:ok, type} = map_refresh(empty_map(), integer(), dynamic())
# assert equal?(type, dynamic(closed_map([{domain_key(:integer), term()}])))
# Adding a key of type float to a dynamic only guarantees that we have a map
# as we cannot express "has at least one key of type float => float"
{:ok, type} = map_refresh(dynamic(), float(), float())
assert equal?(type, dynamic(open_map()))
assert closed_map([{domain_key(:integer), integer()}])
|> difference(open_map())
|> empty?()
assert closed_map([{domain_key(:integer), integer()}])
|> difference(open_map())
|> map_refresh(integer(), float()) == :badmap
assert map_refresh(empty_map(), number(), float()) ==
{:ok,
closed_map([
{domain_key(:integer), float()},
{domain_key(:float), float()}
])}
# Tricky cases with atoms:
# We add one atom fields that maps to an integer, which is not :a. So we do not touch
# :a, add integer to :b, and add a domain field.
assert map_refresh(
closed_map(a: pid(), b: pid()),
atom() |> difference(atom([:a])),
integer()
) ==
{:ok,
closed_map([
{:a, pid()},
{:b, union(pid(), integer())},
{domain_key(:atom), integer()}
])}
assert map_refresh(empty_map(), term(), integer()) == {:ok, map_with_default(integer())}
end
end
describe "disjoint" do
@@ -1722,7 +2052,8 @@ defmodule Module.Types.DescrTest do
"empty_list() or non_empty_list(float() or integer(), pid())"
# Merge last element types
assert union(list(atom([:ok]), integer()), list(atom([:ok]), float())) |> to_quoted_string() ==
assert union(list(atom([:ok]), integer()), list(atom([:ok]), float()))
|> to_quoted_string() ==
"empty_list() or non_empty_list(:ok, float() or integer())"
assert union(dynamic(list(integer(), float())), dynamic(list(integer(), pid())))
@@ -1747,11 +2078,6 @@ defmodule Module.Types.DescrTest do
assert tuple([closed_map(a: integer()), open_map()]) |> to_quoted_string() ==
"{%{a: integer()}, map()}"
# TODO: eliminate tuple differences
# assert difference(tuple([number(), term()]), tuple([integer(), atom()]))
# |> to_quoted_string() ==
# "{float(), term()} or {number(), term() and not atom()}"
assert union(tuple([integer(), atom()]), tuple([integer(), atom()])) |> to_quoted_string() ==
"{integer(), atom()}"
@@ -1820,7 +2146,12 @@ defmodule Module.Types.DescrTest do
)
decimal_int =
closed_map(__struct__: atom([Decimal]), coef: integer(), exp: integer(), sign: integer())
closed_map(
__struct__: atom([Decimal]),
coef: integer(),
exp: integer(),
sign: integer()
)
assert atom([:error])
|> union(
@@ -1908,7 +2239,7 @@ defmodule Module.Types.DescrTest do
"""
end
test "map" do
test "map as records" do
assert empty_map() |> to_quoted_string() == "empty_map()"
assert open_map() |> to_quoted_string() == "map()"
@@ -1968,9 +2299,15 @@ defmodule Module.Types.DescrTest do
"%{..., a: float() or integer()}"
# Fusing complex nested maps with unions
assert closed_map(status: atom([:ok]), data: closed_map(value: term(), count: empty_list()))
assert closed_map(
status: atom([:ok]),
data: closed_map(value: term(), count: empty_list())
)
|> union(
closed_map(status: atom([:ok]), data: closed_map(value: term(), count: open_map()))
closed_map(
status: atom([:ok]),
data: closed_map(value: term(), count: open_map())
)
)
|> union(closed_map(status: atom([:error]), reason: atom([:timeout])))
|> union(closed_map(status: atom([:error]), reason: atom([:crash])))
@@ -1994,7 +2331,10 @@ defmodule Module.Types.DescrTest do
"%{data: %{x: float() or integer(), y: atom()}, meta: map()}"
# Test complex combinations
assert intersection(open_map(a: number(), b: atom()), open_map(a: integer(), c: boolean()))
assert intersection(
open_map(a: number(), b: atom()),
open_map(a: integer(), c: boolean())
)
|> union(difference(open_map(x: atom()), open_map(x: boolean())))
|> to_quoted_string() ==
"%{..., a: integer(), b: atom(), c: boolean()} or %{..., x: atom() and not boolean()}"
@@ -2017,6 +2357,14 @@ defmodule Module.Types.DescrTest do
|> to_quoted_string() == "%{..., a: float(), b: atom(), c: port()}"
end
test "maps as dictionaries" do
assert closed_map([{domain_key(:integer), integer()}])
|> to_quoted_string() == "%{integer() => if_set(integer())}"
assert closed_map([{domain_key(:integer), integer()}, {:float, float()}])
|> to_quoted_string() == "%{integer() => if_set(integer()), float: float()}"
end
test "structs" do
assert open_map(__struct__: atom([URI])) |> to_quoted_string() ==
"%{..., __struct__: URI}"