Packages
integratedb
0.1.0
IntegrateDB is a database sharing system. It provides integration primitives and data ownership and migration controls. Use it to integrate applications directly through a Postgres database.
Current section
Files
Jump to
Current section
Files
lib/integrate/specification_data.ex
defmodule Integrate.SpecificationData do
@moduledoc """
Validate and transform specification data.
"""
use Memoize
alias ExJsonSchema.{
Schema,
Validator
}
alias Integrate.Util
alias Integrate.Specification.{
Spec,
Match,
MatchAlternative,
Path,
Field,
FieldAlternative
}
@doc """
Use a JSON Schema to validate user input specifications.
Returns `:ok` or `{:error, json_schema_errors}`.
## Examples
iex> validate(%{})
:ok
iex> validate(%{"foo" => 1})
{:error, [{"Type mismatch. Expected String but got Integer.", "#/foo"}]}
"""
def validate(data) do
"spec.schema.json"
|> resolve_schema()
|> Validator.validate(patch(data))
end
defmemo resolve_schema(name) do
["spec", name]
|> Util.read_priv_file!()
|> Jason.decode!()
|> Schema.resolve()
end
# XXX Fix me.
# Our `spec.schema.json` JSON schema currently fails to allow an empty fields array
# like `fields: []`. This is a natural syntax for a user to write, so in lieu of better
# schema foo, workaround for now by patching the input data.
defp patch(%{"match" => matches} = data) when is_list(matches) do
%{data | "match" => Enum.map(matches, &patch_fields/1)}
end
defp patch(data), do: data
defp patch_fields(%{"alternatives" => alternatives} = match) do
%{match | "alternatives" => Enum.map(alternatives, &patch_fields/1)}
end
defp patch_fields(%{"fields" => []} = match) do
%{match | "fields" => nil}
end
defp patch_fields(match), do: match
@doc """
Expand "terse" syntactic sugar in the spec data into its equivalent full form.
Accepts and returns a map.
## Examples
iex> expand(%{"match": [%{path" => "public.users", "fields" => ["id", "uuid"]}]})
%{
"match" => [
%{
"alternatives" => [
"path" => %{
"schema" => "public",
"table" => "users"
},
"fields" => [
%{
"alternatives" => [
%{"name" => "id"}
]
},
%{
"alternatives" => [
%{"name": "uuid"}
]
}
}
]
}
]
}
"""
def expand(%{"match" => matches} = data) do
%{data | "match" => Enum.map(matches, &expand_match/1)}
end
defp expand_match(match) when not is_map_key(match, "alternatives") do
{optional, match_alt} = Map.pop(match, "optional")
match = %{
"alternatives" => [match_alt]
}
match =
case optional do
nil ->
match
val ->
Map.put(match, "optional", val)
end
match
|> expand_match()
end
defp expand_match(%{"alternatives" => alternatives} = match) do
%{match | "alternatives" => Enum.map(alternatives, &expand_match_alt/1)}
end
defp expand_match_alt(match_alt) when not is_map_key(match_alt, "fields") do
match_alt
|> Map.put_new("fields", [])
|> expand_match_alt()
end
defp expand_match_alt(%{"fields" => nil} = match_alt) do
%{match_alt | "fields" => []}
|> expand_match_alt()
end
defp expand_match_alt(%{"path" => path, "fields" => fields} = match) do
%{match | "path" => expand_path(path), "fields" => expand_fields(fields)}
end
# path: "public.*"
# path: "public.foo"
defp expand_path(path) when is_binary(path) do
[schema, table] = String.split(path, ".")
%{"schema" => schema, "table" => table}
end
# fields: "*"
# fields: ["*"]
# fields: []
# fields: ["id"]
# fields: ["id", "uuid"]
# fields: [%{name: "bar"}]
# fields: [
# %{name: "bar", type: "varchar", min_length: 24},
# %{name: "baz", optional: true}
# ]
# fields: [
# %{alternatives: [%{name: "foo"}, %{name: "bar"}]},
# %{alternatives: [%{name: "baz"}], optional: true}
# ]
defp expand_fields(nil) do
expand_fields([])
end
defp expand_fields("*") do
expand_fields(["*"])
end
defp expand_fields(fields) when is_list(fields) do
fields
|> Enum.map(&expand_field/1)
end
defp expand_field(field) when is_binary(field) do
expand_field(%{"name" => field})
end
defp expand_field(%{"name" => _} = field) do
{optional, field} = Map.pop(field, "optional", false)
%{"alternatives" => [field], "optional" => optional}
end
defp expand_field(%{"alternatives" => _, "optional" => optional} = field)
when not is_boolean(optional) do
field
|> Map.put("optional", false)
|> expand_field()
end
defp expand_field(%{"alternatives" => alternatives} = field) do
alternatives =
alternatives
|> Enum.map(&expand_field_alternative/1)
%{field | "alternatives" => alternatives}
end
defp expand_field_alternative(alt) when is_binary(alt) do
%{"name" => alt}
end
defp expand_field_alternative(alt), do: alt
@doc """
Validate and then expand user input specification data.
Returns `{:ok, attrs}` or `{:error, json_schema_errors}`.
"""
def validate_and_expand(data) do
case validate(data) do
:ok ->
{:ok, expand(data)}
err ->
err
end
end
@doc """
Reverses `expand`, so the full form data is compacted into the tersest equivalent
syntax. Accepts and returns a map.
## Examples
iex> attrs = %Spec{
match: [
%Match{
alternatives: %MatchAlternative{
path: %Path{
schema: "public",
table: "users"
},
fields: [
%Field{
alternatives: [
%FieldAlternative{
name: "id"
}
]
},
%Field{
alternatives: [
%FieldAlternative{
name: "uuid"
}
]
]
}
}
}
]
}
iex> contract(attrs)
%{match: [%{path: "public.users", fields: ["id", "uuid"]}]}
"""
def contract(%Spec{match: matches} = spec) do
spec
|> Map.from_struct()
|> Map.put(:match, Enum.map(matches, &contract_match/1))
end
defp contract_match(%Match{alternatives: [match_alt], optional: optional}) do
match_alt
|> contract_match_alt()
|> with_optional(optional)
end
defp contract_match(%Match{alternatives: match_alts} = match) do
{optional, match_map} =
match
|> Map.from_struct()
|> Map.pop(:optional)
%{match_map | alternatives: Enum.map(match_alts, &contract_match_alt/1)}
|> with_optional(optional)
end
defp contract_match_alt(%MatchAlternative{path: path, fields: fields}) do
%{path: contract_path(path), fields: contract_fields(fields)}
end
defp contract_path(%Path{schema: schema, table: table}) do
"#{schema}.#{table}"
end
defp contract_fields(fields) when is_list(fields) do
fields
|> Enum.map(&contract_field/1)
end
defp contract_field(%Field{alternatives: [field_alt], optional: optional}) do
field_alt
|> contract_field_alt(optional)
end
defp contract_field(%Field{alternatives: field_alts, optional: optional}) do
%{alternatives: Enum.map(field_alts, &contract_field_alt_into_map/1)}
|> with_optional(optional)
end
defp contract_field_alt(%FieldAlternative{} = field_alt, optional) do
field_alt
|> Map.from_struct()
|> with_optional(optional)
|> contract_field_alt()
end
defp contract_field_alt(field_alt_map) when is_map(field_alt_map) do
filtered = filter_field_alt_map(field_alt_map)
case Map.keys(filtered) do
[:name] ->
filtered.name
_alt ->
filtered
end
end
defp contract_field_alt_into_map(%FieldAlternative{} = field_alt) do
field_alt
|> Map.from_struct()
|> filter_field_alt_map()
end
defp filter_field_alt_map(field_alt_map) do
field_alt_map
|> Enum.reject(fn {k, _} -> k == :id end)
|> Enum.reject(fn {_, v} -> is_nil(v) end)
|> Enum.into(%{})
end
defp with_optional(map, optional) do
case optional do
true ->
Map.put(map, :optional, true)
false ->
map
end
end
end