Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
593 changes: 593 additions & 0 deletions lib/parameter/engine.ex

Large diffs are not rendered by default.

50 changes: 47 additions & 3 deletions lib/parameter/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ defmodule Parameter.Schema do
{:ok, %{"level" => 0}}
"""

alias Parameter.Field
alias Parameter.Schema.Builder
alias Parameter.Schema.Compiler
alias Parameter.Types

Expand Down Expand Up @@ -318,6 +320,7 @@ defmodule Parameter.Schema do
end

defdelegate compile!(opts), to: Compiler, as: :compile_schema!
defdelegate build!(opts), to: Builder, as: :build!

defp schema(caller, block) do
precompile =
Expand All @@ -336,14 +339,14 @@ defmodule Parameter.Schema do
compile =
quote do
raw_params = Module.get_attribute(__MODULE__, :param_raw_fields)
Module.put_attribute(__MODULE__, :param_fields, Parameter.Schema.compile!(raw_params))
Module.put_attribute(__MODULE__, :param_fields, Parameter.Schema.build!(raw_params))
end

postcompile =
quote unquote: false do
defstruct Enum.reverse(@param_struct_fields)

def __param__(:fields), do: Enum.reverse(@param_fields)
def __param__(:fields), do: @param_fields

def __param__(:field_names) do
Enum.map(__param__(:fields), & &1.name)
Expand All @@ -360,6 +363,8 @@ defmodule Parameter.Schema do
def __param__(:field, name: name) do
Enum.find(__param__(:fields), &(&1.name == name))
end

def __param__(:runtime_schema), do: @param_raw_fields
end

quote do
Expand All @@ -369,14 +374,41 @@ defmodule Parameter.Schema do
end
end

def module(module) when is_atom(module) do
module
end

def module(_fields) do
nil
end

def fields(module) when is_atom(module) do
module.__param__(:fields)
rescue
_error ->
module
end

def fields(fields) when is_list(fields) do
def fields(fields) do
fields
end

def get_field(module_or_fields, field_name) do
module_or_fields
|> fields()
|> Enum.find(&(&1.name == field_name))
end

def assoc_fields(module_or_fields) do
module_or_fields
|> fields()
|> Enum.filter(fn
%Field{type: {:array, _schema}} -> true
%Field{type: {:map, _schema}} -> true
_ -> false
end)
end

def field_keys(module) when is_atom(module) do
module.__param__(:field_keys)
end
Expand All @@ -393,6 +425,18 @@ defmodule Parameter.Schema do
Enum.find(fields, &(&1.key == key))
end

def field_names(module) when is_atom(module) do
module.__param__(:field_names)
end

def field_names(fields) when is_list(fields) do
Enum.map(fields, & &1.name)
end

def runtime_schema(module) when is_atom(module) do
module.__param__(:runtime_schema)
end

def __mount_nested_schema__(module_name, env, block) do
block =
quote do
Expand Down
75 changes: 75 additions & 0 deletions lib/parameter/schema/builder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Parameter.Schema.Builder do
@moduledoc false
alias Parameter.Field
alias Parameter.Types

def build!(schema) when is_map(schema) do
for {name, opts} <- schema do
{type, opts} = Keyword.pop(opts, :type, :string)
type = compile_type!(type)

field = Field.new!([name: name, type: type] ++ opts)

case validate_default(field) do
:ok -> field
{:error, reason} -> raise ArgumentError, message: inspect(reason)
end
end
end

def build!(schema) when is_atom(schema) or is_list(schema) do
Parameter.Schema.fields(schema)
end

defp compile_type!({type, schema}) when is_tuple(schema) do
{type, compile_type!(schema)}
end

defp compile_type!({type, schema}) do
if Types.composite_type?(type) do
{type, build!(schema)}
else
raise ArgumentError,
message:
"not a valid inner type, please use `{map, inner_type}` or `{array, inner_type}` for nested associations"
end
end

defp compile_type!(type) when is_atom(type) do
type
end

defp validate_default(
%Field{default: default, load_default: load_default, dump_default: dump_default} = field
) do
with :ok <- validate_default(field, default),
:ok <- validate_default(field, load_default),
do: validate_default(field, dump_default)
end

defp validate_default(_field, nil) do
:ok
end

defp validate_default(%Field{name: name} = field, default_value) do
Parameter.validate([field], %{name => default_value})
end

def validate_nested_opts!(opts) do
keys = Keyword.keys(opts)

if :validator in keys do
raise ArgumentError, "validator cannot be used on nested fields"
end

if :on_load in keys do
raise ArgumentError, "on_load cannot be used on nested fields"
end

if :on_dump in keys do
raise ArgumentError, "on_dump cannot be used on nested fields"
end

opts
end
end
4 changes: 4 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule Parameter.MixProject do
app: :parameter,
version: @version,
elixir: "~> 1.13",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),

Expand Down Expand Up @@ -36,6 +37,9 @@ defmodule Parameter.MixProject do
]
end

defp elixirc_paths(env) when env in [:test], do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

defp deps do
[
{:decimal, "~> 2.0", optional: true},
Expand Down
186 changes: 186 additions & 0 deletions test/parameter/engine_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
defmodule Parameter.EngineTest do
use ExUnit.Case

alias Parameter.Engine
alias Parameter.Schema
alias Parameter.Factory.NestedSchema
alias Parameter.Factory.SimpleSchema

describe "build/1" do
test "build %Engine{} with runtime or compile time schemas" do
field_names = Schema.field_names(SimpleSchema)
fields = Schema.fields(SimpleSchema)

assert %Engine{
schema: SimpleSchema,
cast_fields: field_names,
fields: fields
} == Engine.build(SimpleSchema)

assert %Engine{
schema: nil,
cast_fields: field_names,
fields: fields
} == Engine.build(Schema.runtime_schema(SimpleSchema))
end

test "create engine with only a few fields" do
engine = Engine.build(SimpleSchema)

assert %Engine{cast_fields: [:first_name, :last_name]} =
Engine.cast_only(engine, [:first_name, :last_name])

assert %Engine{cast_fields: [:age]} = Engine.cast_only(engine, [:age])
assert %Engine{cast_fields: []} = Engine.cast_only(engine, [:not_a_field])
end
end

describe "load/3" do
import Parameter.Engine

test "load fields in a simple schema" do
field_names = Schema.field_names(SimpleSchema)
fields = Schema.fields(SimpleSchema)
params = %{"firstName" => "John", "lastName" => "Doe", "age" => "40"}

assert %Engine{
schema: SimpleSchema,
changes: %{first_name: "John", last_name: "Doe", age: 40},
data: params,
cast_fields: field_names,
fields: fields,
operation: :load
} ==
SimpleSchema
|> build()
|> load(params)
end

test "filtering fields on simple schema" do
fields = Schema.fields(SimpleSchema)
params = %{"firstName" => "John", "lastName" => "Doe", "age" => "40"}

assert %Engine{
schema: SimpleSchema,
changes: %{first_name: "John", last_name: "Doe"},
data: params,
cast_fields: [:first_name, :last_name],
fields: fields,
operation: :load
} ==
SimpleSchema
|> build()
|> cast_only([:first_name, :last_name])
|> load(params)
end

test "load fields in a nested schema" do
field_names = Schema.field_names(NestedSchema)
fields = Schema.fields(NestedSchema)

params = %{
"addresses" => [
%{"street" => "some street", "number" => 4, "state" => "state"}
],
"phone" => %{"code" => 1, "number" => "123123"}
}

assert %Engine{
schema: NestedSchema,
changes: %{
addresses: [
%Engine{
schema: NestedSchema.Address,
changes: %{street: "some street", number: 4, state: "state"},
data: %{"street" => "some street", "number" => 4, "state" => "state"},
cast_fields: [:state, :number, :street],
fields: Schema.fields(NestedSchema.Address),
operation: :load
}
],
phone: %Engine{
schema: NestedSchema.Phone,
changes: %{code: "1", number: "123123"},
data: %{"code" => 1, "number" => "123123"},
cast_fields: [:code, :number],
fields: Schema.fields(NestedSchema.Phone),
operation: :load
}
},
data: params,
cast_fields: field_names,
fields: fields,
operation: :load
} ==
NestedSchema
|> build()
|> load(params)
end
end

describe "apply_operation/1" do
import Parameter.Engine

test "load operation on SimpleSchema" do
params = %{"firstName" => "John", "lastName" => "Doe", "age" => "22"}

assert {:ok, %{first_name: "John", last_name: "Doe", age: 22}} ==
SimpleSchema
|> load(params)
|> apply_operation()
end

test "validate on SimpleSchema" do
params = %{"lastName" => "Doe", "age" => "22a"}

assert {:error, %{first_name: "is required", age: "invalid integer type"}} ==
SimpleSchema
|> load(params)
|> apply_operation()
end

test "load operation on NestedSchema" do
params = %{
"addresses" => [
%{"street" => "some street", "number" => 1, "state" => "some state"},
%{"street" => "other street", "number" => 5, "state" => "other state"}
],
"phone" => %{"code" => 55, "number" => "123555"}
}

assert {:ok,
%{
addresses: [
%{state: "some state", number: 1, street: "some street"},
%{state: "other state", number: 5, street: "other street"}
],
phone: %{code: "55", number: "123555"}
}} ==
NestedSchema
|> load(params)
|> apply_operation()
end

test "validate on NestedSchema" do
params = %{
"addresses" => [
%{"street" => "some street", "number" => "1A"},
%{"number" => 5, "state" => "other state"}
],
"phone" => %{"code" => 55}
}

assert {:error,
%{
addresses: [
%{0 => %{number: "invalid integer type"}},
%{1 => %{street: "is required"}}
],
phone: %{number: "is required"}
}} ==
NestedSchema
|> load(params)
|> apply_operation()
end
end
end
5 changes: 5 additions & 0 deletions test/support/factory.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule Parameter.Factory do
@moduledoc """
Factory module to create data in tests.
"""
end
Loading