From 4bf05500cbd9e728cfb9179ef3fa6a36db6330cc Mon Sep 17 00:00:00 2001 From: phcurado Date: Tue, 17 Jan 2023 12:46:03 +0200 Subject: [PATCH 1/8] add operator implementation --- lib/parameter/operator.ex | 148 ++++++++++++++++++++++++++++++++++++++ lib/parameter/schema.ex | 10 +++ 2 files changed, 158 insertions(+) create mode 100644 lib/parameter/operator.ex diff --git a/lib/parameter/operator.ex b/lib/parameter/operator.ex new file mode 100644 index 0000000..328d912 --- /dev/null +++ b/lib/parameter/operator.ex @@ -0,0 +1,148 @@ +defmodule Parameter.Operator do + alias Parameter.Field + alias Parameter.Schema + + @type t :: %__MODULE__{ + schema: module() | nil, + valid?: boolean(), + data: map(), + changes: map(), + errors: map(), + cast_fields: list(atom()) + } + + defstruct schema: nil, + valid?: false, + data: nil, + changes: %{}, + errors: %{}, + cast_fields: [] + + @spec cast(module() | list(Field.t()), map(), list(atom())) :: t() + def cast(schema, params, cast_fields) do + %__MODULE__{ + schema: schema, + data: params, + cast_fields: cast_fields + } + end + + @spec cast(module() | list(Field.t()), map()) :: t() + def cast(schema, params) do + schema_fields = Schema.fields(schema) + + %__MODULE__{ + schema: schema, + data: params, + cast_fields: infer_cast_fields(schema_fields) + } + end + + def load( + %__MODULE__{ + schema: schema, + data: data, + cast_fields: cast_fields + } = operator, + opts \\ [] + ) do + schema_fields = Schema.fields(schema) + + fields_to_exclude = + schema_fields + |> Enum.map(& &1.name) + |> Enum.reject(fn field -> field in cast_fields end) + + opts_with_fields_to_exclude = Keyword.merge(opts, exclude: fields_to_exclude) + + case Parameter.load(schema, data, opts_with_fields_to_exclude) do + {:ok, loaded} -> + %__MODULE__{operator | valid?: true, changes: loaded} + |> load_assoc(opts) + + {:error, errors} -> + %__MODULE__{operator | valid?: false, errors: errors} + |> load_assoc(opts) + end + end + + def load_assoc(%__MODULE__{schema: schema, data: data} = operator, opts \\ []) do + schema_fields = Schema.fields(schema) + assoc_fields = Schema.assoc_fields(schema) + + Enum.reduce(assoc_fields, operator, fn assoc_field, operator -> + %Field{name: name, key: key, type: {assoc_type, schema}} = + Enum.find(schema_fields, &(&1.name == assoc_field.name)) + + opts = + if assoc_type == :array do + Keyword.merge(opts, many: true) + else + opts + end + + case schema |> cast(Map.get(data, key)) |> load(opts) do + %__MODULE__{valid?: true} = result -> + %__MODULE__{operator | changes: Map.put(operator.changes, name, result)} + + %__MODULE__{valid?: false} = result -> + %__MODULE__{operator | valid?: false, changes: Map.put(operator.changes, name, result)} + end + end) + end + + defp infer_cast_fields(fields) do + fields + |> Enum.filter(fn + %Parameter.Field{type: {:map, _nested}} -> false + %Parameter.Field{type: {:array, _nested}} -> false + _ -> true + end) + |> Enum.map(& &1.name) + end +end + +defimpl Inspect, for: Parameter.Operator do + import Inspect.Algebra + + def inspect(operator, opts) do + list = + for attr <- [:schema, :cast_fields, :changes, :errors, :data, :valid?] do + {attr, Map.get(operator, attr)} + end + + container_doc("#Parameter.Operator<", list, ">", opts, fn + {:schema, schema}, opts -> + concat("schema: ", schema_field(schema, opts)) + + {:cast_fields, cast_fields}, opts -> + concat("cast_fields: ", to_doc(cast_fields, opts)) + + {:changes, changes}, opts -> + concat("changes: ", to_doc(changes, opts)) + + {:data, data}, _opts -> + concat("data: ", to_doc(data, opts)) + + {:errors, errors}, opts -> + concat("errors: ", to_doc(errors, opts)) + + {:valid?, valid?}, opts -> + concat("valid?: ", to_doc(valid?, opts)) + end) + end + + # defp to_struct(%{__struct__: struct}, _opts), do: "#" <> Kernel.inspect(struct) <> "<>" + # defp to_struct(other, opts), do: to_doc(other, opts) + + defp schema_field(fields, opts) when is_list(fields) do + Enum.reduce(fields, [], fn %Parameter.Field{name: name}, acc -> + [name | acc] + end) + |> to_doc(opts) + end + + defp schema_field(module, opts) do + to_doc(module, opts) + end +end diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index 5ed17ba..56cf473 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -377,6 +377,16 @@ defmodule Parameter.Schema do fields 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 From 3e50953ae3518e8b00385710d5078500f90f862d Mon Sep 17 00:00:00 2001 From: phcurado Date: Mon, 6 Mar 2023 15:18:43 +0200 Subject: [PATCH 2/8] create engine --- lib/parameter/engine.ex | 396 +++++++++++++++++++++++++++++++ lib/parameter/operator.ex | 148 ------------ lib/parameter/schema.ex | 13 +- lib/parameter/schema/compiler.ex | 3 +- 4 files changed, 410 insertions(+), 150 deletions(-) create mode 100644 lib/parameter/engine.ex delete mode 100644 lib/parameter/operator.ex diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex new file mode 100644 index 0000000..14bbd6f --- /dev/null +++ b/lib/parameter/engine.ex @@ -0,0 +1,396 @@ +defmodule Parameter.Engine do + alias Parameter.Field + alias Parameter.Schema + alias Parameter.Types + + @type t :: %__MODULE__{ + schema: module() | nil, + fields: list(Field.t()), + valid?: boolean(), + data: map(), + changes: map(), + errors: map(), + cast_fields: list(atom()), + operation: :load | :dump | :validate + } + + defstruct schema: nil, + fields: [], + valid?: true, + data: nil, + changes: %{}, + errors: %{}, + cast_fields: [], + operation: :load + + def asdf() do + %{ + first_name: [type: :string, key: "firstName", required: true], + info: [type: {:map, %{number: [type: {:array, %{number: [type: :integer]}}]}}], + address: [type: {:array, %{number: [type: :integer]}}] + } + |> Schema.compile!() + |> load( + %{ + "firstName" => "Ola", + "info" => %{ + "number" => [%{"number" => "2"}, %{"number" => "asdajsh"}, %{"number" => "what"}] + } + }, + struct: true + ) + |> apply_operation() + end + + @spec load(module | list(Field.t()), map(), Keyword.t()) :: t() + def load(schema_or_fields, params, opts \\ []) do + fields = Schema.fields(schema_or_fields) + + %__MODULE__{ + schema: Schema.module(schema_or_fields), + fields: fields, + data: params, + cast_fields: infer_cast_fields(fields), + operation: :load + } + |> cast_and_load_params(opts) + end + + def apply_operation(%__MODULE__{} = engine) do + engine + |> case do + %__MODULE__{valid?: true} -> + {:ok, fetch_engine_changes(engine)} + + %__MODULE__{valid?: false} -> + {:error, fetch_engine_errors(engine)} + end + end + + defp fetch_engine_changes(%__MODULE__{changes: changes}) when is_map(changes) do + Enum.reduce(changes, changes, fn + {field_key, %__MODULE__{} = engine}, acc -> + Map.put(acc, field_key, fetch_engine_changes(engine)) + + {field_key, values}, acc when is_list(values) -> + Enum.map(values, fn + %__MODULE__{} = engine -> + fetch_engine_changes(engine) + + value -> + value + end) + |> then(fn list -> + Map.put(acc, field_key, list) + end) + + _, acc -> + acc + end) + end + + defp fetch_engine_errors(%__MODULE__{errors: errors, changes: changes}) do + Enum.reduce(changes, errors, fn + {field_key, values}, acc when is_list(values) -> + values + |> Enum.with_index() + |> Enum.filter(fn {engine, _index} -> !engine.valid? end) + |> Enum.map(fn {engine, index} -> %{index => engine.errors} end) + |> then(fn list -> + Map.put(acc, field_key, list) + end) + + {field_key, %__MODULE__{valid?: false} = engine}, acc -> + Map.merge(acc, %{field_key => fetch_engine_errors(engine)}) + + _, acc -> + acc + end) + end + + def add_change(%__MODULE__{changes: changes} = engine, field, change) do + %__MODULE__{engine | changes: Map.put(changes, field, change)} + end + + def get_change(%__MODULE__{changes: changes}, field) do + Map.get(changes, field) + end + + def add_error(%__MODULE__{errors: errors} = engine, field, error) do + %__MODULE__{engine | valid?: false, errors: Map.put(errors, field, error)} + end + + def operation(%__MODULE__{} = engine, operation) when operation in [:load, :dump, :validate] do + %__MODULE__{engine | operation: operation} + end + + defp cast_and_load_params( + %__MODULE__{ + fields: fields, + cast_fields: cast_fields + } = engine, + opts + ) do + fields_to_exclude = + fields + |> Enum.map(& &1.name) + |> Enum.reject(fn field -> field in cast_fields end) + + opts_with_fields_to_exclude = Keyword.merge(opts, exclude: fields_to_exclude) + cast_params(engine, opts_with_fields_to_exclude) + end + + defp cast_params(%__MODULE__{fields: fields, cast_fields: cast_fields} = engine, opts) do + Enum.reduce(cast_fields, engine, fn field_name, engine -> + field = Enum.find(fields, &(&1.name == field_name)) + fetch_and_verify_input(engine, field, opts) + end) + end + + defp infer_cast_fields(fields) do + Enum.map(fields, & &1.name) + end + + defp fetch_and_verify_input(engine, field, opts) do + case fetch_input(engine, field) do + :error -> + check_required(engine, field, :ignore) + + {:ok, nil} -> + check_nil(engine, field, opts) + + {:ok, ""} -> + check_empty(engine, field, opts) + + {:ok, value} -> + handle_field(engine, field, value, opts) + + {:error, reason} -> + add_error(engine, field.name, reason) + end + end + + defp fetch_input(%__MODULE__{data: data, operation: :load}, field) do + fetched_input = Map.fetch(data, field.key) + + if to_string(field.name) == field.key do + verify_double_key(fetched_input, field, data) + else + fetched_input + end + end + + defp fetch_input(%__MODULE__{data: data}, field) do + Map.fetch(data, field.name) + end + + defp verify_double_key(:error, field, input) do + Map.fetch(input, field.name) + end + + defp verify_double_key(fetched_input, field, input) do + case Map.fetch(input, field.name) do + {:ok, _value} -> + {:error, "field is present as atom and string keys"} + + _ -> + fetched_input + end + end + + defp check_required( + %__MODULE__{operation: :load} = engine, + %Field{name: name, required: true, load_default: nil}, + value + ) + when value in [:ignore, nil] do + add_error(engine, name, "is required") + end + + defp check_required( + %__MODULE__{operation: :validate} = engine, + %Field{name: name, required: true, dump_default: nil}, + value + ) + when value in [:ignore, nil] do + add_error(engine, name, "is required") + end + + defp check_required( + %__MODULE__{operation: :load} = engine, + %Field{name: name, load_default: default}, + :ignore + ) + when not is_nil(default) do + add_change(engine, name, default) + end + + defp check_required( + %__MODULE__{operation: :dump} = engine, + %Field{name: name, dump_default: default}, + :ignore + ) + when not is_nil(default) do + add_change(engine, name, default) + end + + defp check_required(%__MODULE__{} = engine, _field, :ignore) do + engine + end + + defp check_required(%__MODULE__{} = engine, %Field{name: name}, value) do + add_change(engine, name, value) + end + + defp check_nil(engine, field, opts) do + if opts[:ignore_nil] do + check_required(engine, field, :ignore) + else + check_required(engine, field, nil) + end + end + + defp check_empty(engine, field, opts) do + if opts[:ignore_empty] do + check_required(engine, field, :ignore) + else + check_required(engine, field, "") + end + end + + defp handle_field(engine, %Field{virtual: true}, _value, _opts) do + engine + end + + defp handle_field(engine, %Field{type: {:array, schema}} = field, values, opts) + when is_list(values) do + values + |> Enum.reverse() + |> Enum.reduce(engine, fn value, engine -> + case handle_method(engine, schema, value, opts) do + %__MODULE__{valid?: false} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + engine = add_change(engine, field.name, [inner_engine | field_changes]) + %__MODULE__{engine | valid?: false} + + %__MODULE__{valid?: true} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + add_change(engine, field.name, [inner_engine | field_changes]) + end + end) + end + + defp handle_field(engine, %Field{name: name, type: {:array, _schema}}, _values, _opts) do + add_error(engine, name, "invalid array type") + end + + defp handle_field(engine, %Field{type: {:map, schema}} = field, value, opts) + when is_map(value) do + handle_field(engine, schema, value, opts) |> IO.inspect() + # value + # |> Enum.reduce(engine, fn {key, value}, engine -> + # case handle_method(engine, schema, value, opts) do + # %__MODULE__{valid?: false} = inner_engine -> + # IO.inspect("whats happening") + # IO.inspect(key) + # IO.inspect(inner_engine) + + # %__MODULE__{valid?: true} = inner_engine -> + # IO.inspect("Im valid ") + # IO.inspect(key) + # IO.inspect(key) + # IO.inspect(inner_engine) + # # add_change(engine, field.name, inner_engine) + # end + # end) + + # case handle_method(engine, schema, value, opts) do + # %__MODULE__{valid?: false} = inner_engine -> + # %__MODULE__{ + # engine + # | changes: Map.put(engine.changes, field.name, inner_engine), + # valid?: false + # } + + # %__MODULE__{valid?: true} = inner_engine -> + # add_change(engine, field.name, inner_engine) + # end + end + + defp handle_field(engine, %Field{name: name, type: {:map, _schema}}, _values, _opts) do + add_error(engine, name, "invalid map type") + end + + defp handle_field( + %__MODULE__{operation: :load} = engine, + %Field{type: type} = field, + value, + _opts + ) do + case Types.load(type, value) do + {:error, error} -> + add_error(engine, field.name, error) + + {:ok, loaded_value} -> + add_change(engine, field.name, loaded_value) + end + end + + defp handle_method(%__MODULE__{operation: :load}, {:map, schema}, params, opts) do + load(schema, params, opts) + |> IO.inspect() + end + + defp handle_method(%__MODULE__{operation: :load}, schema, params, opts) do + load(schema, params, opts) + end +end + +defimpl Inspect, for: Parameter.Engine do + import Inspect.Algebra + + def inspect(engine, opts) do + list = + for attr <- [:schema, :fields, :cast_fields, :changes, :errors, :data, :valid?] do + {attr, Map.get(engine, attr)} + end + + container_doc("#Parameter.Engine<", list, ">", opts, fn + {:schema, schema}, opts -> + concat("schema: ", to_doc(schema, opts)) + + {:fields, fields}, opts -> + concat("fields: ", fields(fields, opts)) + + {:cast_fields, cast_fields}, opts -> + concat("cast_fields: ", to_doc(cast_fields, opts)) + + {:changes, changes}, opts -> + concat("changes: ", to_doc(changes, opts)) + + {:data, data}, _opts -> + concat("data: ", to_doc(data, opts)) + + {:errors, errors}, opts -> + concat("errors: ", to_doc(errors, opts)) + + {:valid?, valid?}, opts -> + concat("valid?: ", to_doc(valid?, opts)) + end) + end + + # defp to_struct(%{__struct__: struct}, _opts), do: "#" <> Kernel.inspect(struct) <> "<>" + # defp to_struct(other, opts), do: to_doc(other, opts) + + defp fields(fields, opts) when is_list(fields) do + Enum.reduce(fields, [], fn %Parameter.Field{name: name}, acc -> + [name | acc] + end) + |> Enum.reverse() + |> to_doc(opts) + end + + defp fields(module, opts) do + to_doc(module, opts) + end +end diff --git a/lib/parameter/operator.ex b/lib/parameter/operator.ex deleted file mode 100644 index 328d912..0000000 --- a/lib/parameter/operator.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Parameter.Operator do - alias Parameter.Field - alias Parameter.Schema - - @type t :: %__MODULE__{ - schema: module() | nil, - valid?: boolean(), - data: map(), - changes: map(), - errors: map(), - cast_fields: list(atom()) - } - - defstruct schema: nil, - valid?: false, - data: nil, - changes: %{}, - errors: %{}, - cast_fields: [] - - @spec cast(module() | list(Field.t()), map(), list(atom())) :: t() - def cast(schema, params, cast_fields) do - %__MODULE__{ - schema: schema, - data: params, - cast_fields: cast_fields - } - end - - @spec cast(module() | list(Field.t()), map()) :: t() - def cast(schema, params) do - schema_fields = Schema.fields(schema) - - %__MODULE__{ - schema: schema, - data: params, - cast_fields: infer_cast_fields(schema_fields) - } - end - - def load( - %__MODULE__{ - schema: schema, - data: data, - cast_fields: cast_fields - } = operator, - opts \\ [] - ) do - schema_fields = Schema.fields(schema) - - fields_to_exclude = - schema_fields - |> Enum.map(& &1.name) - |> Enum.reject(fn field -> field in cast_fields end) - - opts_with_fields_to_exclude = Keyword.merge(opts, exclude: fields_to_exclude) - - case Parameter.load(schema, data, opts_with_fields_to_exclude) do - {:ok, loaded} -> - %__MODULE__{operator | valid?: true, changes: loaded} - |> load_assoc(opts) - - {:error, errors} -> - %__MODULE__{operator | valid?: false, errors: errors} - |> load_assoc(opts) - end - end - - def load_assoc(%__MODULE__{schema: schema, data: data} = operator, opts \\ []) do - schema_fields = Schema.fields(schema) - assoc_fields = Schema.assoc_fields(schema) - - Enum.reduce(assoc_fields, operator, fn assoc_field, operator -> - %Field{name: name, key: key, type: {assoc_type, schema}} = - Enum.find(schema_fields, &(&1.name == assoc_field.name)) - - opts = - if assoc_type == :array do - Keyword.merge(opts, many: true) - else - opts - end - - case schema |> cast(Map.get(data, key)) |> load(opts) do - %__MODULE__{valid?: true} = result -> - %__MODULE__{operator | changes: Map.put(operator.changes, name, result)} - - %__MODULE__{valid?: false} = result -> - %__MODULE__{operator | valid?: false, changes: Map.put(operator.changes, name, result)} - end - end) - end - - defp infer_cast_fields(fields) do - fields - |> Enum.filter(fn - %Parameter.Field{type: {:map, _nested}} -> false - %Parameter.Field{type: {:array, _nested}} -> false - _ -> true - end) - |> Enum.map(& &1.name) - end -end - -defimpl Inspect, for: Parameter.Operator do - import Inspect.Algebra - - def inspect(operator, opts) do - list = - for attr <- [:schema, :cast_fields, :changes, :errors, :data, :valid?] do - {attr, Map.get(operator, attr)} - end - - container_doc("#Parameter.Operator<", list, ">", opts, fn - {:schema, schema}, opts -> - concat("schema: ", schema_field(schema, opts)) - - {:cast_fields, cast_fields}, opts -> - concat("cast_fields: ", to_doc(cast_fields, opts)) - - {:changes, changes}, opts -> - concat("changes: ", to_doc(changes, opts)) - - {:data, data}, _opts -> - concat("data: ", to_doc(data, opts)) - - {:errors, errors}, opts -> - concat("errors: ", to_doc(errors, opts)) - - {:valid?, valid?}, opts -> - concat("valid?: ", to_doc(valid?, opts)) - end) - end - - # defp to_struct(%{__struct__: struct}, _opts), do: "#" <> Kernel.inspect(struct) <> "<>" - # defp to_struct(other, opts), do: to_doc(other, opts) - - defp schema_field(fields, opts) when is_list(fields) do - Enum.reduce(fields, [], fn %Parameter.Field{name: name}, acc -> - [name | acc] - end) - |> to_doc(opts) - end - - defp schema_field(module, opts) do - to_doc(module, opts) - end -end diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index 56cf473..f962d87 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -187,6 +187,7 @@ defmodule Parameter.Schema do {:ok, %{"level" => 0}} """ + alias Parameter.Field alias Parameter.Schema.Compiler alias Parameter.Types @@ -369,11 +370,21 @@ defmodule Parameter.Schema do end end + def module(module) when is_atom(module) do + module + end + + def module(fields) when is_list(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 diff --git a/lib/parameter/schema/compiler.ex b/lib/parameter/schema/compiler.ex index 89b751a..26dc5c6 100644 --- a/lib/parameter/schema/compiler.ex +++ b/lib/parameter/schema/compiler.ex @@ -18,7 +18,8 @@ defmodule Parameter.Schema.Compiler do end def compile_schema!(schema) when is_atom(schema) do - schema + Parameter.Schema.fields(schema) + # schema end defp compile_type!({type, schema}) when is_tuple(schema) do From fc3f560c171b5378c321032c69ee8c6cb50705c6 Mon Sep 17 00:00:00 2001 From: phcurado Date: Wed, 5 Jul 2023 16:19:12 +0300 Subject: [PATCH 3/8] refactor engine/compiler --- lib/parameter/engine.ex | 79 ++++++++++---------------------- lib/parameter/schema.ex | 2 + lib/parameter/schema/builder.ex | 75 ++++++++++++++++++++++++++++++ lib/parameter/schema/compiler.ex | 3 +- 4 files changed, 101 insertions(+), 58 deletions(-) create mode 100644 lib/parameter/schema/builder.ex diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex index 14bbd6f..b30940b 100644 --- a/lib/parameter/engine.ex +++ b/lib/parameter/engine.ex @@ -23,25 +23,6 @@ defmodule Parameter.Engine do cast_fields: [], operation: :load - def asdf() do - %{ - first_name: [type: :string, key: "firstName", required: true], - info: [type: {:map, %{number: [type: {:array, %{number: [type: :integer]}}]}}], - address: [type: {:array, %{number: [type: :integer]}}] - } - |> Schema.compile!() - |> load( - %{ - "firstName" => "Ola", - "info" => %{ - "number" => [%{"number" => "2"}, %{"number" => "asdajsh"}, %{"number" => "what"}] - } - }, - struct: true - ) - |> apply_operation() - end - @spec load(module | list(Field.t()), map(), Keyword.t()) :: t() def load(schema_or_fields, params, opts \\ []) do fields = Schema.fields(schema_or_fields) @@ -92,13 +73,18 @@ defmodule Parameter.Engine do defp fetch_engine_errors(%__MODULE__{errors: errors, changes: changes}) do Enum.reduce(changes, errors, fn {field_key, values}, acc when is_list(values) -> - values - |> Enum.with_index() - |> Enum.filter(fn {engine, _index} -> !engine.valid? end) - |> Enum.map(fn {engine, index} -> %{index => engine.errors} end) - |> then(fn list -> - Map.put(acc, field_key, list) - end) + invalid_data = + values |> Enum.with_index() |> Enum.filter(fn {engine, _index} -> !engine.valid? end) + + if Enum.empty?(invalid_data) do + acc + else + invalid_data + |> Enum.map(fn {engine, index} -> %{index => engine.errors} end) + |> then(fn list -> + Map.put(acc, field_key, list) + end) + end {field_key, %__MODULE__{valid?: false} = engine}, acc -> Map.merge(acc, %{field_key => fetch_engine_errors(engine)}) @@ -286,35 +272,17 @@ defmodule Parameter.Engine do defp handle_field(engine, %Field{type: {:map, schema}} = field, value, opts) when is_map(value) do - handle_field(engine, schema, value, opts) |> IO.inspect() - # value - # |> Enum.reduce(engine, fn {key, value}, engine -> - # case handle_method(engine, schema, value, opts) do - # %__MODULE__{valid?: false} = inner_engine -> - # IO.inspect("whats happening") - # IO.inspect(key) - # IO.inspect(inner_engine) - - # %__MODULE__{valid?: true} = inner_engine -> - # IO.inspect("Im valid ") - # IO.inspect(key) - # IO.inspect(key) - # IO.inspect(inner_engine) - # # add_change(engine, field.name, inner_engine) - # end - # end) - - # case handle_method(engine, schema, value, opts) do - # %__MODULE__{valid?: false} = inner_engine -> - # %__MODULE__{ - # engine - # | changes: Map.put(engine.changes, field.name, inner_engine), - # valid?: false - # } - - # %__MODULE__{valid?: true} = inner_engine -> - # add_change(engine, field.name, inner_engine) - # end + case handle_method(engine, schema, value, opts) do + %__MODULE__{valid?: false} = inner_engine -> + %__MODULE__{ + engine + | changes: Map.put(engine.changes, field.name, inner_engine), + valid?: false + } + + %__MODULE__{valid?: true} = inner_engine -> + add_change(engine, field.name, inner_engine) + end end defp handle_field(engine, %Field{name: name, type: {:map, _schema}}, _values, _opts) do @@ -338,7 +306,6 @@ defmodule Parameter.Engine do defp handle_method(%__MODULE__{operation: :load}, {:map, schema}, params, opts) do load(schema, params, opts) - |> IO.inspect() end defp handle_method(%__MODULE__{operation: :load}, schema, params, opts) do diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index f962d87..73acd05 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -188,6 +188,7 @@ defmodule Parameter.Schema do """ alias Parameter.Field + alias Parameter.Schema.Builder alias Parameter.Schema.Compiler alias Parameter.Types @@ -319,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 = diff --git a/lib/parameter/schema/builder.ex b/lib/parameter/schema/builder.ex new file mode 100644 index 0000000..e3f8181 --- /dev/null +++ b/lib/parameter/schema/builder.ex @@ -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) 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 diff --git a/lib/parameter/schema/compiler.ex b/lib/parameter/schema/compiler.ex index 26dc5c6..89b751a 100644 --- a/lib/parameter/schema/compiler.ex +++ b/lib/parameter/schema/compiler.ex @@ -18,8 +18,7 @@ defmodule Parameter.Schema.Compiler do end def compile_schema!(schema) when is_atom(schema) do - Parameter.Schema.fields(schema) - # schema + schema end defp compile_type!({type, schema}) when is_tuple(schema) do From aa0bd834c67421636bdeb3cdbdc08a0b46b13349 Mon Sep 17 00:00:00 2001 From: phcurado Date: Fri, 7 Jul 2023 14:19:23 +0300 Subject: [PATCH 4/8] helpers functions for schema --- lib/parameter/engine.ex | 76 +++++++++++++++++++++---- lib/parameter/schema.ex | 29 ++++++++-- lib/parameter/schema/builder.ex | 2 +- mix.exs | 4 ++ test/parameter/engine_test.exs | 81 +++++++++++++++++++++++++++ test/support/factory.ex | 5 ++ test/support/schemas/nested_schema.ex | 16 ++++++ test/support/schemas/simple_schema.ex | 9 +++ 8 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 test/parameter/engine_test.exs create mode 100644 test/support/factory.ex create mode 100644 test/support/schemas/nested_schema.ex create mode 100644 test/support/schemas/simple_schema.ex diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex index b30940b..3a32130 100644 --- a/lib/parameter/engine.ex +++ b/lib/parameter/engine.ex @@ -11,7 +11,7 @@ defmodule Parameter.Engine do changes: map(), errors: map(), cast_fields: list(atom()), - operation: :load | :dump | :validate + operation: :load | :dump | :validate | nil } defstruct schema: nil, @@ -21,19 +21,50 @@ defmodule Parameter.Engine do changes: %{}, errors: %{}, cast_fields: [], - operation: :load + operation: nil - @spec load(module | list(Field.t()), map(), Keyword.t()) :: t() - def load(schema_or_fields, params, opts \\ []) do - fields = Schema.fields(schema_or_fields) + defguard module_or_runtime(param) when is_atom(param) or is_map(param) + + @doc """ + The cast function is the will be used for getting a schema, parameters and apply the desired operation. + + ## Examples + defmodule UserParams do + use Parameter.Schema + + import Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + end + + def load(params) do + UserParams + |> cast(params) + |> load() + end + end + """ + @spec cast(module | map(), map() | list(map()), Keyword.t()) :: t() + def cast(schema, params, opts \\ []) when module_or_runtime(schema) do + compiled_schema = Schema.build!(schema) + fields = Schema.fields(compiled_schema) + + cast_fields = Keyword.get(opts, :only, infer_cast_fields(fields)) + cast_fields = select_valid_cast_fields(compiled_schema, cast_fields) %__MODULE__{ - schema: Schema.module(schema_or_fields), + schema: Schema.module(schema), fields: fields, data: params, - cast_fields: infer_cast_fields(fields), - operation: :load + cast_fields: cast_fields } + end + + @spec load(t(), Keyword.t()) :: t() + def load(%__MODULE__{} = engine, opts \\ []) do + %__MODULE__{engine | operation: :load} |> cast_and_load_params(opts) end @@ -137,6 +168,29 @@ defmodule Parameter.Engine do Enum.map(fields, & &1.name) end + defp select_valid_cast_fields(schema, {nested_field_name, fields}) do + case Schema.get_field(schema, nested_field_name) do + %Field{type: {:map, nested_schema}} -> select_valid_cast_fields(nested_schema, fields) + %Field{type: {:array, nested_schema}} -> select_valid_cast_fields(nested_schema, fields) + _ -> [] + end + end + + defp select_valid_cast_fields(schema, fields) when is_list(fields) do + Enum.reduce(fields, [], fn + {field_name, _fields} = nested_field, acc -> + [{field_name, select_valid_cast_fields(schema, nested_field)} | acc] + + field_name, acc -> + if Schema.get_field(schema, field_name) do + [field_name | acc] + else + acc + end + end) + |> Enum.reverse() + end + defp fetch_and_verify_input(engine, field, opts) do case fetch_input(engine, field) do :error -> @@ -305,11 +359,13 @@ defmodule Parameter.Engine do end defp handle_method(%__MODULE__{operation: :load}, {:map, schema}, params, opts) do - load(schema, params, opts) + cast(schema, params, opts) + |> load() end defp handle_method(%__MODULE__{operation: :load}, schema, params, opts) do - load(schema, params, opts) + cast(schema, params, opts) + |> load() end end diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index 73acd05..88fc76b 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -339,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) @@ -363,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 @@ -376,20 +378,27 @@ defmodule Parameter.Schema do module end - def module(fields) when is_list(fields) do + def module(_fields) do nil end def fields(module) when is_atom(module) do module.__param__(:fields) rescue - _error -> module + _error -> + module end 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() @@ -416,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.names) + 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 diff --git a/lib/parameter/schema/builder.ex b/lib/parameter/schema/builder.ex index e3f8181..7e673ae 100644 --- a/lib/parameter/schema/builder.ex +++ b/lib/parameter/schema/builder.ex @@ -17,7 +17,7 @@ defmodule Parameter.Schema.Builder do end end - def build!(schema) when is_atom(schema) do + def build!(schema) when is_atom(schema) or is_list(schema) do Parameter.Schema.fields(schema) end diff --git a/mix.exs b/mix.exs index 6c36d0e..aaadd30 100644 --- a/mix.exs +++ b/mix.exs @@ -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(), @@ -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}, diff --git a/test/parameter/engine_test.exs b/test/parameter/engine_test.exs new file mode 100644 index 0000000..e26862e --- /dev/null +++ b/test/parameter/engine_test.exs @@ -0,0 +1,81 @@ +defmodule Parameter.EngineTest do + use ExUnit.Case + + alias Parameter.Engine + alias Parameter.Schema + alias Parameter.Factory.NestedSchema + alias Parameter.Factory.SimpleSchema + + describe "cast/3" do + import Parameter.Engine + + test "cast simple schema" do + params = %{} + field_names = Schema.field_names(SimpleSchema) + fields = Schema.fields(SimpleSchema) + + assert %Engine{ + schema: SimpleSchema, + cast_fields: field_names, + changes: params, + data: params, + fields: fields + } == cast(SimpleSchema, params) + + assert %Engine{ + schema: nil, + cast_fields: field_names, + changes: params, + data: params, + fields: fields + } == cast(Schema.runtime_schema(SimpleSchema), params) + end + + test "cast only a few fields" do + params = %{} + + assert %Engine{cast_fields: [:first_name, :last_name]} = + cast(SimpleSchema, params, only: [:first_name, :last_name]) + + assert %Engine{cast_fields: [:age]} = + cast(Schema.runtime_schema(SimpleSchema), params, only: [:age]) + + assert %Engine{cast_fields: []} = + cast(Schema.runtime_schema(SimpleSchema), params, only: [:not_a_field]) + end + + test "cast nested schema" do + params = %{} + field_names = Schema.field_names(NestedSchema) + fields = Schema.fields(NestedSchema) + + assert %Engine{ + schema: NestedSchema, + cast_fields: field_names, + changes: params, + data: params, + fields: fields + } == cast(NestedSchema, params) + + assert %Engine{ + schema: nil, + cast_fields: field_names, + changes: params, + data: params, + fields: fields + } == cast(Schema.runtime_schema(NestedSchema), params) + end + + test "cast only a few nested fields" do + params = %{} + + assert %Engine{cast_fields: [{:addresses, [:street, :state]}]} = + cast(NestedSchema, params, only: [{:addresses, [:street, :state]}]) + + assert %Engine{cast_fields: [{:phone, [:number]}]} = + cast(Schema.runtime_schema(NestedSchema), params, + only: [{:phone, [:number, :not_a_field]}] + ) + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..dcee241 --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,5 @@ +defmodule Parameter.Factory do + @moduledoc """ + Factory module to create data in tests. + """ +end diff --git a/test/support/schemas/nested_schema.ex b/test/support/schemas/nested_schema.ex new file mode 100644 index 0000000..5da6914 --- /dev/null +++ b/test/support/schemas/nested_schema.ex @@ -0,0 +1,16 @@ +defmodule Parameter.Factory.NestedSchema do + use Parameter.Schema + + param do + has_many :addresses, Address, required: true do + field :street, :string, required: true + field :number, :integer, default: 0 + field :state, :string + end + + has_one :phone, Phone do + field :code, :string + field :number, :string, required: true + end + end +end diff --git a/test/support/schemas/simple_schema.ex b/test/support/schemas/simple_schema.ex new file mode 100644 index 0000000..cf5aac6 --- /dev/null +++ b/test/support/schemas/simple_schema.ex @@ -0,0 +1,9 @@ +defmodule Parameter.Factory.SimpleSchema do + use Parameter.Schema + + param do + field :first_name, :string, required: true + field :last_name, :string + field :age, :integer, default: 0 + end +end From 31422e7e81cc2662644e1e7cfe20ee79a789d83d Mon Sep 17 00:00:00 2001 From: phcurado Date: Sun, 23 Jul 2023 10:56:55 +0300 Subject: [PATCH 5/8] fixing nested load --- lib/parameter/engine.ex | 103 +++++++++++++++------- lib/parameter/schema.ex | 2 +- test/parameter/engine_test.exs | 121 +++++++++++++++++--------- test/support/schemas/simple_schema.ex | 4 +- 4 files changed, 156 insertions(+), 74 deletions(-) diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex index 3a32130..8fb0f86 100644 --- a/lib/parameter/engine.ex +++ b/lib/parameter/engine.ex @@ -7,7 +7,7 @@ defmodule Parameter.Engine do schema: module() | nil, fields: list(Field.t()), valid?: boolean(), - data: map(), + data: map() | nil, changes: map(), errors: map(), cast_fields: list(atom()), @@ -26,12 +26,18 @@ defmodule Parameter.Engine do defguard module_or_runtime(param) when is_atom(param) or is_map(param) @doc """ - The cast function is the will be used for getting a schema, parameters and apply the desired operation. + Build compiles the schema and automatically fetch which fields will be casted during the + `load`, `dump` or `validate` functions. + + It can be used as the starting point for building your schema logic. ## Examples - defmodule UserParams do - use Parameter.Schema + Using the given schema as example on how to load parameters and modifying the logic + to only load the `:first_name` field. + + defmodule UserSchema do + use Parameter.Schema import Parameter.Engine param do @@ -39,35 +45,66 @@ defmodule Parameter.Engine do field :last_name, :string end - def load(params) do - UserParams - |> cast(params) - |> load() + def load(params \\ %{}) do + __MODULE__ + |> build() + |> cast_only([:first_name]) + |> load(params) end end + """ - @spec cast(module | map(), map() | list(map()), Keyword.t()) :: t() - def cast(schema, params, opts \\ []) when module_or_runtime(schema) do + @spec build(module | map()) :: t() + def build(schema) when module_or_runtime(schema) do compiled_schema = Schema.build!(schema) fields = Schema.fields(compiled_schema) - cast_fields = Keyword.get(opts, :only, infer_cast_fields(fields)) - cast_fields = select_valid_cast_fields(compiled_schema, cast_fields) - %__MODULE__{ schema: Schema.module(schema), fields: fields, - data: params, - cast_fields: cast_fields + cast_fields: infer_cast_fields(fields) } end - @spec load(t(), Keyword.t()) :: t() - def load(%__MODULE__{} = engine, opts \\ []) do - %__MODULE__{engine | operation: :load} + @spec cast_only(t(), list(atom() | tuple())) :: t() + def cast_only(%__MODULE__{} = engine, fields) when is_list(fields) do + cast_fields = select_valid_cast_fields(engine.fields, fields) + %__MODULE__{engine | cast_fields: cast_fields} + end + + @doc """ + ## Example + defmodule UserParams do + use Parameter.Schema + + import Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + end + + def load(params) do + UserParams + |> build() + |> load(params) + end + end + """ + @spec load(t(), map() | list(map()), Keyword.t()) :: t() + def load(engine, params, opts \\ []) + + def load(%__MODULE__{} = engine, params, opts) do + %__MODULE__{engine | data: params, operation: :load} |> cast_and_load_params(opts) end + def load(schema, params, opts) when module_or_runtime(schema) do + schema + |> build() + |> load(params, opts) + end + def apply_operation(%__MODULE__{} = engine) do engine |> case do @@ -159,13 +196,13 @@ defmodule Parameter.Engine do defp cast_params(%__MODULE__{fields: fields, cast_fields: cast_fields} = engine, opts) do Enum.reduce(cast_fields, engine, fn field_name, engine -> - field = Enum.find(fields, &(&1.name == field_name)) + field = Schema.get_field(fields, field_name) fetch_and_verify_input(engine, field, opts) end) end defp infer_cast_fields(fields) do - Enum.map(fields, & &1.name) + Schema.field_names(fields) end defp select_valid_cast_fields(schema, {nested_field_name, fields}) do @@ -302,12 +339,12 @@ defmodule Parameter.Engine do engine end - defp handle_field(engine, %Field{type: {:array, schema}} = field, values, opts) + defp handle_field(engine, %Field{type: {:array, nested_fields}} = field, values, opts) when is_list(values) do values |> Enum.reverse() |> Enum.reduce(engine, fn value, engine -> - case handle_method(engine, schema, value, opts) do + case handle_method(engine, {:array, nested_fields}, value, opts) do %__MODULE__{valid?: false} = inner_engine -> field_changes = get_change(engine, field.name) || [] engine = add_change(engine, field.name, [inner_engine | field_changes]) @@ -324,9 +361,9 @@ defmodule Parameter.Engine do add_error(engine, name, "invalid array type") end - defp handle_field(engine, %Field{type: {:map, schema}} = field, value, opts) + defp handle_field(engine, %Field{type: {:map, nested_fields}} = field, value, opts) when is_map(value) do - case handle_method(engine, schema, value, opts) do + case handle_method(engine, {:map, nested_fields}, value, opts) do %__MODULE__{valid?: false} = inner_engine -> %__MODULE__{ engine @@ -358,14 +395,20 @@ defmodule Parameter.Engine do end end - defp handle_method(%__MODULE__{operation: :load}, {:map, schema}, params, opts) do - cast(schema, params, opts) - |> load() + defp handle_method(%__MODULE__{operation: :load}, {_nested_type, schema}, params, opts) do + fields = Schema.fields(schema) + + %__MODULE__{ + schema: Schema.module(schema), + fields: fields, + cast_fields: infer_cast_fields(fields) + } + |> load(params, opts) end - defp handle_method(%__MODULE__{operation: :load}, schema, params, opts) do - cast(schema, params, opts) - |> load() + defp handle_method(%__MODULE__{operation: :load} = engine, schema, params, opts) do + engine + |> load(params, opts) end end diff --git a/lib/parameter/schema.ex b/lib/parameter/schema.ex index 88fc76b..196c949 100644 --- a/lib/parameter/schema.ex +++ b/lib/parameter/schema.ex @@ -430,7 +430,7 @@ defmodule Parameter.Schema do end def field_names(fields) when is_list(fields) do - Enum.map(fields, & &1.names) + Enum.map(fields, & &1.name) end def runtime_schema(module) when is_atom(module) do diff --git a/test/parameter/engine_test.exs b/test/parameter/engine_test.exs index e26862e..205de1b 100644 --- a/test/parameter/engine_test.exs +++ b/test/parameter/engine_test.exs @@ -6,76 +6,115 @@ defmodule Parameter.EngineTest do alias Parameter.Factory.NestedSchema alias Parameter.Factory.SimpleSchema - describe "cast/3" do - import Parameter.Engine - - test "cast simple schema" do - params = %{} + 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, - changes: params, - data: params, fields: fields - } == cast(SimpleSchema, params) + } == Engine.build(SimpleSchema) assert %Engine{ schema: nil, cast_fields: field_names, - changes: params, - data: params, fields: fields - } == cast(Schema.runtime_schema(SimpleSchema), params) + } == Engine.build(Schema.runtime_schema(SimpleSchema)) end - test "cast only a few fields" do - params = %{} + test "create engine with only a few fields" do + engine = Engine.build(SimpleSchema) assert %Engine{cast_fields: [:first_name, :last_name]} = - cast(SimpleSchema, params, only: [:first_name, :last_name]) + Engine.cast_only(engine, [:first_name, :last_name]) - assert %Engine{cast_fields: [:age]} = - cast(Schema.runtime_schema(SimpleSchema), params, only: [:age]) - - assert %Engine{cast_fields: []} = - cast(Schema.runtime_schema(SimpleSchema), params, only: [:not_a_field]) + assert %Engine{cast_fields: [:age]} = Engine.cast_only(engine, [:age]) + assert %Engine{cast_fields: []} = Engine.cast_only(engine, [:not_a_field]) end + end - test "cast nested schema" do - params = %{} - field_names = Schema.field_names(NestedSchema) - fields = Schema.fields(NestedSchema) + 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: NestedSchema, - cast_fields: field_names, - changes: params, + schema: SimpleSchema, + changes: %{first_name: "John", last_name: "Doe", age: 40}, data: params, - fields: fields - } == cast(NestedSchema, 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: nil, - cast_fields: field_names, - changes: params, + schema: SimpleSchema, + changes: %{first_name: "John", last_name: "Doe"}, data: params, - fields: fields - } == cast(Schema.runtime_schema(NestedSchema), params) + cast_fields: [:first_name, :last_name], + fields: fields, + operation: :load + } == + SimpleSchema + |> build() + |> cast_only([:first_name, :last_name]) + |> load(params) end - test "cast only a few nested fields" do - params = %{} + test "load fields in a nested schema" do + field_names = Schema.field_names(NestedSchema) + fields = Schema.fields(NestedSchema) - assert %Engine{cast_fields: [{:addresses, [:street, :state]}]} = - cast(NestedSchema, params, only: [{:addresses, [:street, :state]}]) + params = %{ + "addresses" => [ + %{"street" => "some street", "number" => 4, "state" => "state"} + ], + "phone" => %{"code" => 1, "number" => "123123"} + } - assert %Engine{cast_fields: [{:phone, [:number]}]} = - cast(Schema.runtime_schema(NestedSchema), params, - only: [{:phone, [:number, :not_a_field]}] - ) + 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: [:street, :number, :state], + 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 end diff --git a/test/support/schemas/simple_schema.ex b/test/support/schemas/simple_schema.ex index cf5aac6..0127167 100644 --- a/test/support/schemas/simple_schema.ex +++ b/test/support/schemas/simple_schema.ex @@ -2,8 +2,8 @@ defmodule Parameter.Factory.SimpleSchema do use Parameter.Schema param do - field :first_name, :string, required: true - field :last_name, :string + field :first_name, :string, key: "firstName", required: true + field :last_name, :string, key: "lastName" field :age, :integer, default: 0 end end From fc4742e7e8142a9c5040a9fead854ac9f8b36b05 Mon Sep 17 00:00:00 2001 From: phcurado Date: Sun, 23 Jul 2023 12:21:08 +0300 Subject: [PATCH 6/8] fixing nested load --- lib/parameter/engine.ex | 106 +++++++++++++++++++++------------ test/parameter/engine_test.exs | 2 +- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex index 8fb0f86..b35c72c 100644 --- a/lib/parameter/engine.ex +++ b/lib/parameter/engine.ex @@ -1,4 +1,5 @@ defmodule Parameter.Engine do + require Parameter.Schema alias Parameter.Field alias Parameter.Schema alias Parameter.Types @@ -91,7 +92,7 @@ defmodule Parameter.Engine do end end """ - @spec load(t(), map() | list(map()), Keyword.t()) :: t() + @spec load(t() | module() | map(), map() | list(map()), Keyword.t()) :: t() def load(engine, params, opts \\ []) def load(%__MODULE__{} = engine, params, opts) do @@ -247,7 +248,11 @@ defmodule Parameter.Engine do end end - defp fetch_input(%__MODULE__{data: data, operation: :load}, field) do + defp fetch_input(%__MODULE__{data: data, operation: operation}, field) do + do_fetch_input(data, field, operation) + end + + defp do_fetch_input(data, field, :load = _operation) do fetched_input = Map.fetch(data, field.key) if to_string(field.name) == field.key do @@ -257,7 +262,7 @@ defmodule Parameter.Engine do end end - defp fetch_input(%__MODULE__{data: data}, field) do + defp do_fetch_input(data, field, _operation) do Map.fetch(data, field.name) end @@ -339,41 +344,18 @@ defmodule Parameter.Engine do engine end - defp handle_field(engine, %Field{type: {:array, nested_fields}} = field, values, opts) + defp handle_field(engine, %Field{type: {:array, _nested_fields}} = field, values, opts) when is_list(values) do - values - |> Enum.reverse() - |> Enum.reduce(engine, fn value, engine -> - case handle_method(engine, {:array, nested_fields}, value, opts) do - %__MODULE__{valid?: false} = inner_engine -> - field_changes = get_change(engine, field.name) || [] - engine = add_change(engine, field.name, [inner_engine | field_changes]) - %__MODULE__{engine | valid?: false} - - %__MODULE__{valid?: true} = inner_engine -> - field_changes = get_change(engine, field.name) || [] - add_change(engine, field.name, [inner_engine | field_changes]) - end - end) + do_load_assoc(engine, field, values, opts) end defp handle_field(engine, %Field{name: name, type: {:array, _schema}}, _values, _opts) do add_error(engine, name, "invalid array type") end - defp handle_field(engine, %Field{type: {:map, nested_fields}} = field, value, opts) + defp handle_field(engine, %Field{type: {:map, _nested_fields}} = field, value, opts) when is_map(value) do - case handle_method(engine, {:map, nested_fields}, value, opts) do - %__MODULE__{valid?: false} = inner_engine -> - %__MODULE__{ - engine - | changes: Map.put(engine.changes, field.name, inner_engine), - valid?: false - } - - %__MODULE__{valid?: true} = inner_engine -> - add_change(engine, field.name, inner_engine) - end + do_load_assoc(engine, field, value, opts) end defp handle_field(engine, %Field{name: name, type: {:map, _schema}}, _values, _opts) do @@ -395,20 +377,68 @@ defmodule Parameter.Engine do end end - defp handle_method(%__MODULE__{operation: :load}, {_nested_type, schema}, params, opts) do - fields = Schema.fields(schema) + defp do_load_assoc( + %__MODULE__{operation: :load} = engine, + %Field{type: {:map, nested_fields}} = field, + value, + opts + ) do + schema = get_schema_from_nested_assoc(engine, field) %__MODULE__{ schema: Schema.module(schema), - fields: fields, - cast_fields: infer_cast_fields(fields) + fields: nested_fields, + cast_fields: infer_cast_fields(nested_fields) } - |> load(params, opts) + |> load(value, opts) + |> case do + %__MODULE__{valid?: false} = inner_engine -> + %__MODULE__{ + engine + | changes: Map.put(engine.changes, field.name, inner_engine), + valid?: false + } + + %__MODULE__{valid?: true} = inner_engine -> + add_change(engine, field.name, inner_engine) + end end - defp handle_method(%__MODULE__{operation: :load} = engine, schema, params, opts) do - engine - |> load(params, opts) + defp do_load_assoc( + %__MODULE__{operation: :load} = engine, + %Field{type: {:array, nested_fields}} = field, + values, + opts + ) do + schema = get_schema_from_nested_assoc(engine, field) + + values + |> Enum.reverse() + |> Enum.reduce(engine, fn value, engine -> + %__MODULE__{ + schema: schema, + fields: nested_fields, + cast_fields: infer_cast_fields(nested_fields) + } + |> load(value, opts) + |> case do + %__MODULE__{valid?: false} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + engine = add_change(engine, field.name, [inner_engine | field_changes]) + %__MODULE__{engine | valid?: false} + + %__MODULE__{valid?: true} = inner_engine -> + field_changes = get_change(engine, field.name) || [] + add_change(engine, field.name, [inner_engine | field_changes]) + end + end) + end + + defp get_schema_from_nested_assoc(engine, field) do + if runtime_schema = engine.schema && Schema.runtime_schema(engine.schema) do + {_nested, schema} = Map.get(runtime_schema, field.name) |> Keyword.get(:type) + schema + end end end diff --git a/test/parameter/engine_test.exs b/test/parameter/engine_test.exs index 205de1b..dbef4f8 100644 --- a/test/parameter/engine_test.exs +++ b/test/parameter/engine_test.exs @@ -93,7 +93,7 @@ defmodule Parameter.EngineTest do schema: NestedSchema.Address, changes: %{street: "some street", number: 4, state: "state"}, data: %{"street" => "some street", "number" => 4, "state" => "state"}, - cast_fields: [:street, :number, :state], + cast_fields: [:state, :number, :street], fields: Schema.fields(NestedSchema.Address), operation: :load } From 341a02035ab91969109de79a4aeddc294f37e1f7 Mon Sep 17 00:00:00 2001 From: phcurado Date: Mon, 24 Jul 2023 09:34:25 +0300 Subject: [PATCH 7/8] more tests on apply_operation --- test/parameter/engine_test.exs | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/parameter/engine_test.exs b/test/parameter/engine_test.exs index dbef4f8..ff36201 100644 --- a/test/parameter/engine_test.exs +++ b/test/parameter/engine_test.exs @@ -117,4 +117,70 @@ defmodule Parameter.EngineTest do |> 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 From 6faa6105d78edda5ae6ed110002b028929bf7303 Mon Sep 17 00:00:00 2001 From: phcurado Date: Sat, 30 Dec 2023 16:10:04 +0200 Subject: [PATCH 8/8] start documenting --- lib/parameter/engine.ex | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/lib/parameter/engine.ex b/lib/parameter/engine.ex index b35c72c..38cf7a7 100644 --- a/lib/parameter/engine.ex +++ b/lib/parameter/engine.ex @@ -1,4 +1,105 @@ defmodule Parameter.Engine do + @moduledoc """ + `Parameter.Engine` are the building blocks for the serializing and deserializing parameters. + `Parameter.load/3`, `Parameter.validate/3` and `Parameter.dump/3` functions are powered by the + functions of this module. The main `Parameter` API will be enough for most of the cases but for a more + declarative and custom approach, `Parameter.Engine` is highly recommended. + + Let's use `Parameter.Engine` for a given schema: + + defmodule MyApp.UserParams do + use Parameter.Schema + + param do + field :first_name, :string, required: true + field :last_name, :string, required: true + field :age, :integer, default: 0 + has_one :address, AddressParam, required: true do + field :street, :string, required: true + field :number, :integer + end + end + end + + This schema is straightforward to understand how it should behave when parsing but it's also very strict since it + doesn't allow any customization. For example imagine we want to use the same schema but with different parsing logic, + like in a Phoenix application where it's API have one endpoint where `first_name` is a required field but another + endpoint the same schema should have the `first_name` as an optional field. This is possible to do with the Runtime + Schemas by manually modifying a map schema to put required `true` or `false`. It would work but it's not the most + straightforward solution. `Parameter.Engine` helps by making it declarative how the schema should be parsed. + + ## Example + Considering the above example, we can make a more generic schema by dropping the required and default keys: + + defmodule MyApp.UserParams do + use Parameter.Schema + + alias Parameter.Engine + + param do + field :first_name, :string + field :last_name, :string + field :age, :integer + has_one :address, AddressParam do + field :street, :string + field :number, :integer + end + end + + def load(params) do + __MODULE__ + |> Engine.load_params(params) + |> Engine.validate_required([:first_name, :last_name]) + |> Engine.add_default(:age, 0) + |> Engine.load_nested_param(:address, &load_address/1) + |> Engine.load() + end + + defp load_address(params) do + __MODULE__.AddressParam + |> Engine.load_params(params) + |> Engine.validate_required([:street]) + |> Engine.load() + end + end + + + You can also customize a schema with the `required` or `default` options with the declarative approach and the `Parameter.Engine` + will use what's declared under the schema unless it's explicit set in the `Engine` to change the behaviour like + having conflicting `default` option in the schema and in the `Engine`. Parameter will favour what is declared in the `Engine` + when there is conflicting options. + + The example below shows the usage of the `MyApp.UserParams` in a Phoenix controller: + + defmodule MyAppWeb.UserController do + use MyAppWeb, :controller + + alias MyApp.UserParams + alias MyApp.Users + + def create(conn, %{"user" => user_params}) do + with {:ok, user_loaded_params} <- UserParams.load(user_params), + {:ok, user} <- Users.create(user_loaded_params) do + + json(conn + |> put_status(:created) + |> json(%{user: user}) + + else + {:error, %Ecto.Changeset{}} = error -> + # Changeset errors + error + {:error, reason} -> + # Parameter errors + {:error, reason} + end + end + end + + In case we need different ways for loading in different controllers, this is also possible by implementing + the `Engine` parsing logic in the module that requires thhe specific logic. + + """ require Parameter.Schema alias Parameter.Field alias Parameter.Schema