Skip to content
Open
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
34 changes: 33 additions & 1 deletion lib/params.ex
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ defmodule Params do
end
end

@doc false
def nullable(module) when is_atom(module) do
module.__info__(:attributes) |> Keyword.get(:nullable, [])
end

@doc false
def changeset(%Changeset{data: %{__struct__: module}} = changeset, params) do
{required, required_relations} =
Expand All @@ -143,8 +148,10 @@ defmodule Params do
{optional, optional_relations} =
relation_partition(module, optional(module))

nullable_fields = nullable(module)

changeset
|> Changeset.cast(params, required ++ optional)
|> cast_nullable(params, required ++ optional, nullable_fields)
|> Changeset.validate_required(required)
|> cast_relations(required_relations, required: true)
|> cast_relations(optional_relations, [])
Expand Down Expand Up @@ -184,6 +191,31 @@ defmodule Params do
end)
end

defp cast_nullable(changeset, params, allowed_fields, nullable_fields) do
# First cast normally
changeset = Changeset.cast(changeset, params, allowed_fields)

# Then handle nullable fields that were explicitly set to nil
Enum.reduce(nullable_fields, changeset, fn field_name, acc ->
# Check if the field was explicitly set to nil in params
string_key = Atom.to_string(field_name)

cond do
# Explicitly set to nil via atom key
Map.has_key?(params, field_name) and Map.get(params, field_name) == nil ->
Changeset.force_change(acc, field_name, nil)

# Explicitly set to nil via string key
Map.has_key?(params, string_key) and Map.get(params, string_key) == nil ->
Changeset.force_change(acc, field_name, nil)

# Field not present or not nil - use normal casting result
true ->
acc
end
end)
end

defp cast_relations(changeset, relations, opts) do
Enum.reduce(relations, changeset, fn
{name, :assoc}, ch -> Changeset.cast_assoc(ch, name, opts)
Expand Down
17 changes: 15 additions & 2 deletions lib/params/def.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ defmodule Params.Def do
@schema unquote(schema)
@required unquote(field_names(schema, &is_required?/1))
@optional unquote(field_names(schema, &is_optional?/1))
@nullable unquote(field_names(schema, &is_nullable?/1))

schema do
(unquote_splicing(schema_fields(schema)))
Expand All @@ -115,6 +116,10 @@ defmodule Params.Def do
!is_required?(field_schema)
end

defp is_nullable?(field_schema) do
Keyword.get(field_schema, :nullable, false)
end

defp field_names(schema, filter) do
for field <- schema,
apply(filter, [field]),
Expand Down Expand Up @@ -175,7 +180,8 @@ defmodule Params.Def do
:embeds_one,
:embeds_many,
:required,
:cardinality
:cardinality,
:nullable
])
end

Expand Down Expand Up @@ -205,7 +211,14 @@ defmodule Params.Def do
end

defp normalize_field(value, options) when is_atom(value) do
[field: value] ++ options
if String.ends_with?(Atom.to_string(value), "_nullable") do
base_type =
value |> Atom.to_string() |> String.replace_suffix("_nullable", "") |> String.to_atom()

[field: base_type, nullable: true] ++ options
else
[field: value] ++ options
end
end

defp normalize_field({:array, x}, options) do
Expand Down
1 change: 1 addition & 0 deletions lib/params/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ defmodule Params.Schema do
Module.register_attribute(__MODULE__, :required, persist: true)
Module.register_attribute(__MODULE__, :optional, persist: true)
Module.register_attribute(__MODULE__, :schema, persist: true)
Module.register_attribute(__MODULE__, :nullable, persist: true)

@behaviour Params.Behaviour

Expand Down
111 changes: 111 additions & 0 deletions test/params_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -463,4 +463,115 @@ defmodule ParamsTest do
changeset = DefaultCountParams.from(%{})
assert %{count: 1} = Params.to_map(changeset)
end

# Tests for nullable field functionality
defparams(
nullable_params(%{
name: :string,
email: :string_nullable,
age: :integer_nullable
})
)

test "nullable module has list of nullable fields" do
assert [:age, :email] = Params.nullable(Params.ParamsTest.NullableParams) |> Enum.sort()
end

test "nullable fields missing from input are ignored" do
# Only name provided, nullable fields missing
changeset = nullable_params(%{name: "John"})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{name: "John"}
end

test "nullable fields explicitly set to nil are preserved in to_map" do
# Email explicitly set to nil
changeset = nullable_params(%{name: "John", email: nil})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{name: "John", email: nil}
end

test "nullable fields explicitly set to nil are preserved in data" do
# Age explicitly set to nil
changeset = nullable_params(%{name: "John", age: nil})
assert changeset.valid?

result = Params.data(changeset)
assert result.name == "John"
assert result.age == nil
# Not provided, so gets schema default
assert result.email == nil
end

test "nullable fields with string keys work" do
# Using string keys instead of atom keys
changeset = nullable_params(%{"name" => "John", "email" => nil, "age" => nil})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{name: "John", email: nil, age: nil}
end

test "nullable fields with actual values work normally" do
changeset = nullable_params(%{name: "John", email: "john@example.com", age: 25})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{name: "John", email: "john@example.com", age: 25}
end

test "mixed nullable and non-nullable fields" do
# Regular field with nil is ignored, nullable field with nil is preserved
changeset = nullable_params(%{name: nil, email: nil})
assert changeset.valid?

result = Params.to_map(changeset)
# name is ignored because it's not nullable
assert result == %{email: nil}
end

# Test with explicit field syntax
defparams(
explicit_nullable(%{
name: :string,
email: [field: :string_nullable],
settings: [field: :map_nullable]
})
)

test "explicit nullable syntax works" do
changeset = explicit_nullable(%{name: "John", email: nil, settings: nil})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{name: "John", email: nil, settings: nil}
end

defparams(
nullable_with_defaults(%{
name: :string,
email: [field: :string_nullable, default: "default@example.com"],
count: [field: :integer_nullable, default: 0]
})
)

test "nullable with defaults still work" do
# No fields provided - should get defaults
changeset = nullable_with_defaults(%{})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{email: "default@example.com", count: 0}

# Explicitly set to nil - should override defaults
changeset = nullable_with_defaults(%{email: nil, count: nil})
assert changeset.valid?

result = Params.to_map(changeset)
assert result == %{email: nil, count: nil}
end
end