Current section
Files
Jump to
Current section
Files
lib/rock_solid/intersection.ex
defmodule RockSolid.Intersection do
@moduledoc """
Computes intersections
"""
alias RockSolid.Context
alias RockSolid.Exceptions.InvalidSchema
alias RockSolid.Intersection
alias RockSolid.Schemas.Schema
alias RockSolid.Transformation
@number_types ["integer", "number"]
@any_value %{
"anyOf" => [
%{"type" => "array"},
%{"type" => "boolean"},
%{"type" => "null"},
%{"type" => "number"},
%{"type" => "object"},
%{"type" => "string"}
]
}
@doc """
Performs the intersection between two schemas.
"""
@spec intersection(Schema.t(), Schema.t()) :: {:ok, Schema.t()} | {:error, any()}
def intersection(schema1, schema2)
# true/false cases first
def intersection(true, schema), do: {:ok, schema}
def intersection(schema, true), do: {:ok, schema}
def intersection(false, _), do: {:ok, false}
def intersection(_, false), do: {:ok, false}
# Contains not clauses
def intersection(schema1, schema2) do
{not1, s1} = Map.pop(schema1, "not")
{not2, s2} = Map.pop(schema2, "not")
not_clauses = Enum.flat_map([not1, not2], ¬_clauses/1)
with {:ok, result} when result != false <- do_intersection(s1, s2),
value when value != false <- Intersection.Not.add_clauses(result, not_clauses) do
{:ok, value}
else
false ->
{:error,
"empty intersection with 'not' clauses: #{inspect(schema1)}, #{inspect(schema2)}"}
{:ok, false} ->
{:error, "empty intersection #{inspect(schema1)}, #{inspect(schema2)}"}
{:error, _} = e ->
e
end
end
@doc """
Peforms the safe intersection when the left subschema is applied before the right one
"""
def safe_post_intersection(left, right)
def safe_post_intersection(
%{"additionalProperties" => false} = left,
%{"properties" => _} = right
) do
raise InvalidSchema, reason: :additional_properties, schema: {left, right}
end
def safe_post_intersection(
%{"type" => "object"} = left,
%{"additionalProperties" => additional_props} = right
) do
# Handle special case because `additionalProperties` from right are applied AFTER
# everything from left
right_no_additional_props = Map.delete(right, "additionalProperties")
case safe_intersection(left, right_no_additional_props) do
%{"type" => "object"} = intersection ->
Map.update(
intersection,
"additionalProperties",
additional_props,
&safe_intersection(&1, additional_props)
)
other ->
other
end
end
def safe_post_intersection(left, right), do: safe_intersection(left, right)
# Same reference
defp do_intersection(%{"$ref" => r1}, %{"$ref" => r1}), do: {:ok, %{"$ref" => r1}}
defp do_intersection(%{"$ref" => r1}, s2) when s2 == @any_value, do: {:ok, %{"$ref" => r1}}
defp do_intersection(s1, %{"$ref" => r2}) when s1 == @any_value, do: {:ok, %{"$ref" => r2}}
# Different reference
defp do_intersection(%{"$ref" => _} = s1, s2), do: with_ref(s1, s2)
defp do_intersection(s1, %{"$ref" => _} = s2), do: with_ref(s1, s2)
# enum or const
defp do_intersection(%{"const" => val}, schema), do: intersection(%{"enum" => [val]}, schema)
defp do_intersection(schema, %{"const" => val}), do: intersection(%{"enum" => [val]}, schema)
defp do_intersection(%{"enum" => val}, schema), do: Intersection.Enum.intersection(val, schema)
defp do_intersection(schema, %{"enum" => val}), do: Intersection.Enum.intersection(val, schema)
defp do_intersection(%{"anyOf" => _} = s1, s2) when map_size(s1) == 1,
do: Intersection.AnyOf.intersection(s1, s2)
defp do_intersection(s1, %{"anyOf" => _} = s2) when map_size(s2) == 1,
do: Intersection.AnyOf.intersection(s1, s2)
defp do_intersection(s1, %{"anyOf" => any_of} = s2) do
case Intersection.AnyOf.intersection(Map.delete(s2, "anyOf"), %{"anyOf" => any_of}) do
{:ok, value} -> intersection(s1, value)
error -> error
end
end
defp do_intersection(%{"anyOf" => any_of} = s1, s2) do
case Intersection.AnyOf.intersection(Map.delete(s1, "anyOf"), %{"anyOf" => any_of}) do
{:ok, value} -> intersection(s2, value)
error -> error
end
end
## Concrete types
# String
defp do_intersection(%{"type" => "string"} = s1, %{"type" => "string"} = s2) do
Intersection.String.intersection(s1, s2)
end
# Number (number or integer)
defp do_intersection(%{"type" => t1} = s1, %{"type" => t2} = s2)
when t1 in @number_types and t2 in @number_types do
Intersection.Number.intersection(s1, s2)
end
# Null and boolean. Since they don't have extra keywords we can
# simply return the value
defp do_intersection(%{"type" => t} = s, %{"type" => t}) when t in ["boolean", "null"] do
{:ok, s}
end
# Array
defp do_intersection(%{"type" => "array"} = s1, %{"type" => "array"} = s2) do
Intersection.Array.intersection(s1, s2)
end
defp do_intersection(%{"type" => "object"} = s1, %{"type" => "object"} = s2) do
Intersection.Object.intersection(s1, s2)
end
# Mismatched type
defp do_intersection(%{"type" => t1}, %{"type" => t2})
when is_binary(t1) and is_binary(t2) and t1 != t2 do
{:error, "mismatched types #{t1} vs #{t2}"}
end
# Type is either a list or not present, convert to list and intersect
defp do_intersection(schema1, schema2) do
intersection(schema2, Transformation.to_any_of(schema1))
end
defp with_ref(schema1, schema2) do
key = {schema1, schema2}
case Context.get(key) do
"#/PLACEHOLDER_" <> _ = placeholder ->
{:ok, %{"$ref" => placeholder}}
nil ->
placeholder_name = Context.put_placeholder(key)
value =
case do_intersection(expand(schema1), expand(schema2)) do
{:ok, value} -> value
{:error, _} -> false
end
Context.put(key, value)
Context.add_on_the_fly_def(key, placeholder_name)
{:ok, Context.get(key)}
other ->
{:ok, other}
end
end
defp expand(%{"$ref" => ptr}), do: Context.get_ref(ptr)
defp expand(schema), do: schema
@doc """
Same as `intersection/2` but returns the value or `false` when the intersection
is empty
"""
@spec safe_intersection(Schema.t(), Schema.t()) :: Schema.t()
def safe_intersection(schema1, schema2) do
case intersection(schema1, schema2) do
{:ok, value} -> value
{:error, _} -> false
end
end
@doc """
Returns true if two schemas are mutually exclusive
"""
@spec mutually_exclusive?(Schema.t(), Schema.t()) :: boolean()
def mutually_exclusive?(s1, s2), do: safe_intersection(s1, s2) == false
@doc """
Returns true if a schema contains a "not" clause that always
succeeds when the rest of the schema succeeds
"""
@spec impossible?(Schema.t()) :: boolean()
def impossible?(schema) when is_map(schema) do
case Map.pop(schema, "not") do
{nil, _} ->
false
{%{"anyOf" => clauses}, s} ->
Enum.any?(clauses, &(safe_intersection(&1, s) == s))
{not_, %{"const" => value}} ->
match?({:ok, _}, JSV.validate(value, Context.build!(not_)))
{not_, %{"enum" => values}} ->
jsv_schema = Context.build!(not_)
Enum.all?(values, fn value -> match?({:ok, _}, JSV.validate(value, jsv_schema)) end)
{not_, s} ->
safe_intersection(not_, s) == s
end
end
def impossible?(false), do: true
def impossible?(true), do: false
defp not_clauses(nil), do: []
defp not_clauses(%{"anyOf" => clauses}), do: clauses
defp not_clauses(clause), do: [clause]
end