defmodule AvroEx.Schema do
use TypedStruct
alias AvroEx.{Schema}
alias AvroEx.Schema.Enum, as: AvroEnum
alias AvroEx.Schema.Map, as: AvroMap
alias AvroEx.Schema.Record.Field
alias AvroEx.Schema.{Array, Context, Fixed, Primitive, Record, Reference, Union}
@type schema_types ::
Array.t()
| Enum.t()
| Fixed.t()
| AvroMap.t()
| Record.t()
| Primitive.t()
| Union.t()
| Reference.t()
@type named_type ::
AvroEnum.t()
| Fixed.t()
| Record.t()
typedstruct do
field :context, Context.t(), default: %Context{}
field :schema, schema_types()
end
@type name :: String.t()
@type namespace :: nil | String.t()
@type full_name :: String.t()
@type doc :: nil | String.t()
@type metadata :: %{String.t() => String.t()}
@type alias :: name
@type json_schema :: String.t()
@spec encodable?(AvroEx.Schema.t(), any()) :: boolean()
def encodable?(%Schema{schema: schema, context: context}, data) do
encodable?(schema, context, data)
end
@int32_range -2_147_483_648..2_147_483_647
@int64_range -9_223_372_036_854_775_808..9_223_372_036_854_775_807
@spec encodable?(any(), any(), any()) :: boolean()
def encodable?(%Primitive{type: :null}, _, nil), do: true
def encodable?(%Primitive{type: :boolean}, _, bool) when is_boolean(bool), do: true
def encodable?(%Primitive{type: :int}, _, n) when is_integer(n) and n in @int32_range, do: true
def encodable?(%Primitive{type: :long}, _, n) when is_integer(n) and n in @int64_range, do: true
def encodable?(%Primitive{type: :float}, _, n) when is_float(n) do
match?(<<^n::little-float-size(32)>>, <<n::little-float-size(32)>>)
end
def encodable?(%Primitive{type: :double}, _, n) when is_float(n), do: true
def encodable?(%Primitive{type: :bytes}, _, bytes) when is_binary(bytes), do: true
def encodable?(%Primitive{type: :string}, _, str) when is_binary(str), do: String.valid?(str)
def encodable?(%Primitive{type: :string}, _, atom) when is_atom(atom) do
if is_nil(atom) or is_boolean(atom) do
false
else
atom |> to_string() |> String.valid?()
end
end
def encodable?(%Primitive{type: :long, metadata: %{"logicalType" => "timestamp-nanos"}}, _, %DateTime{}), do: true
def encodable?(%Primitive{type: :long, metadata: %{"logicalType" => "timestamp-micros"}}, _, %DateTime{}), do: true
def encodable?(%Primitive{type: :long, metadata: %{"logicalType" => "timestamp-millis"}}, _, %DateTime{}), do: true
def encodable?(%Primitive{type: :long, metadata: %{"logicalType" => "time-micros"}}, _, %Time{}), do: true
def encodable?(%Primitive{type: :int, metadata: %{"logicalType" => "time-millis"}}, _, %Time{}), do: true
def encodable?(%Primitive{type: :int, metadata: %{"logicalType" => "date"}}, _, %Date{}), do: true
def encodable?(%Record{} = record, %Context{} = context, data) when is_map(data),
do: Record.match?(record, context, data)
def encodable?(%Field{} = field, %Context{} = context, data),
do: Field.match?(field, context, data)
def encodable?(%Union{} = union, %Context{} = context, data),
do: Union.match?(union, context, data)
def encodable?(%Fixed{} = fixed, %Context{} = context, data),
do: Fixed.match?(fixed, context, data)
def encodable?(%AvroMap{} = schema, %Context{} = context, data) when is_map(data) do
AvroMap.match?(schema, context, data)
end
def encodable?(%Array{} = schema, %Context{} = context, data) when is_list(data) do
Array.match?(schema, context, data)
end
def encodable?(%AvroEnum{} = schema, %Context{} = context, data) when is_atom(data) do
AvroEnum.match?(schema, context, to_string(data))
end
def encodable?(%AvroEnum{} = schema, %Context{} = context, data) when is_binary(data) do
AvroEnum.match?(schema, context, data)
end
def encodable?(%Reference{type: name}, %Context{} = context, data) do
schema = Context.lookup(context, name)
encodable?(schema, context, data)
end
def encodable?(_, _, _), do: false
@doc """
The namespace of the given Schema type
## Examples
iex> namespace(%Primitive{type: :string})
nil
iex> namespace(%Record{name: "MyRecord"}, "namespace")
"namespace"
iex> namespace(%Record{name: "MyRecord", namespace: "inner"}, "namespace")
"inner"
iex> namespace(%Record{name: "qualified.MyRecord", namespace: "inner"}, "namespace")
"qualified"
"""
@spec namespace(schema_types(), namespace()) :: namespace()
def namespace(schema, parent_namespace \\ nil)
def namespace(%Record.Field{}, parent_namespace), do: parent_namespace
def namespace(%{name: name, namespace: namespace}, parent_namespace) do
split_name = split_name(name)
cond do
# if it has at least two values, its a fullname
# e.g. "namespace.Name" would be `["namespace", "Name"]`
match?([_, _ | _], split_name) ->
split_name |> :lists.droplast() |> Enum.join(".")
is_nil(namespace) ->
parent_namespace
true ->
namespace
end
end
def namespace(_schema, parent_namespace), do: parent_namespace
@doc """
The fully-qualified name of the type
## Examples
iex> full_name(%Primitive{type: "string"})
nil
iex> full_name(%Record{name: "foo", namespace: "beam.community"})
"beam.community.foo"
iex> full_name(%Record{name: "foo"}, "top.level.namespace")
"top.level.namespace.foo"
"""
@spec full_name(schema_types() | name(), namespace()) :: nil | String.t()
def full_name(schema, parent_namespace \\ nil)
def full_name(%{name: name, namespace: namespace}, parent_namespace) do
full_name(name, namespace || parent_namespace)
end
def full_name(%Record.Field{name: name}, _parent_namespace) do
name
end
def full_name(name, namespace) when is_binary(name) do
cond do
is_nil(namespace) ->
name
String.contains?(name, ".") ->
name
true ->
"#{namespace}.#{name}"
end
end
def full_name(_name, _namespace), do: nil
@doc """
The name of the schema type
## Examples
iex> type_name(%Primitive{type: "string"})
"string"
iex> type_name(%Primitive{type: :long, metadata: %{"logicalType" => "timestamp-millis"}})
"timestamp-millis"
iex> type_name(%AvroEnum{name: "switch", symbols: []})
"Enum<name=switch>"
iex> type_name(%Array{items: %Primitive{type: "int"}})
"Array<items=int>"
iex> type_name(%Fixed{size: 2, name: "double"})
"Fixed<name=double, size=2>"
iex> type_name(%Union{possibilities: [%Primitive{type: "string"}, %Primitive{type: "int"}]})
"Union<possibilities=string|int>"
iex> type_name(%Record{name: "foo"})
"Record<name=foo>"
iex> type_name(%Reference{type: "foo"})
"Reference<name=foo>"
"""
@spec type_name(schema_types()) :: String.t()
def type_name(%Primitive{type: :null}), do: "null"
def type_name(%Primitive{metadata: %{"logicalType" => type}}), do: type
def type_name(%Primitive{type: type}), do: to_string(type)
def type_name(%Array{items: type}), do: "Array<items=#{type_name(type)}>"
def type_name(%Union{possibilities: types}), do: "Union<possibilities=#{Enum.map_join(types, "|", &type_name/1)}>"
def type_name(%Record{} = record), do: "Record<name=#{full_name(record)}>"
def type_name(%Reference{type: type}), do: "Reference<name=#{type}>"
def type_name(%Record.Field{} = field), do: "Field<name=#{full_name(field)}>"
def type_name(%Fixed{size: size} = fixed), do: "Fixed<name=#{full_name(fixed)}, size=#{size}>"
def type_name(%AvroEnum{} = enum), do: "Enum<name=#{full_name(enum)}>"
def type_name(%AvroMap{values: values}), do: "Map<values=#{type_name(values)}>"
# split a full name into its parts
defp split_name(string) do
pattern = :binary.compile_pattern(".")
String.split(string, pattern)
end
end