Skip to content

Commit 2a90eeb

Browse files
committed
refactor: remove enforce support and whitelist typed options
Replace explicit enforce rejection with a whitelist approach that only passes supported typed option keys (type, null, default) to TypedStructor. Unsupported options like enforce are silently ignored.
1 parent b42ff02 commit 2a90eeb

16 files changed

Lines changed: 45 additions & 700 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ EctoTypedSchema generates `@type` specs from Ecto schema definitions by combinin
5252

5353
## Supported Features You Must Preserve
5454

55-
- Schema-level defaults: `null`, `enforce`, plus `type_kind` and `type_name`
55+
- Schema-level defaults: `null`, plus `type_kind` and `type_name`
5656
- Type parameters via `parameter/2` with declaration-order preservation
5757
- TypedStructor plugin forwarding via `plugin/2` with declaration-order preservation
5858
- Through-association type generation for fields absent from `__changeset__/0`

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ end
5050
- **Zero-annotation inference** -- Ecto types mapped to typespecs automatically (`:string` -> `String.t()`, `:integer` -> `integer()`, etc.)
5151
- **Association-aware** -- `belongs_to`, `has_many`, `has_one`, `many_to_many`, and embeds all generate correct types
5252
- **Ecto runtime semantics** -- primary keys non-nullable, `has_many`/`embeds_many` default to `[]`, and most other fields nullable
53-
- **Fine-grained control** -- override per-field with `typed: [null: false]`, `typed: [type: ...]`, or `typed: [enforce: true]` (type-level requiredness)
54-
- **Schema-level defaults** -- set `null:`, `enforce:`, `type_kind:`, `type_name:` for all fields at once
53+
- **Fine-grained control** -- override per-field with `typed: [null: false]` or `typed: [type: ...]`
54+
- **Schema-level defaults** -- set `null:`, `type_kind:`, `type_name:` for all fields at once
5555
- **Through associations** -- resolved at compile time with fallback warning
5656
- **Plugin system** -- forward [TypedStructor plugins](https://hexdocs.pm/typed_structor/TypedStructor.Plugin.html) into the generated type block
5757
- **Embedded schemas** -- `typed_embedded_schema` works the same way, without `__meta__`

lib/ecto_typed_schema.ex

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,13 @@ defmodule EctoTypedSchema do
1515
| --- | --- |
1616
| `type:` | Override the inferred type entirely |
1717
| `null:` | `false` removes `\\| nil` from the type |
18-
| `enforce:` | `true` treats the field as required for type generation (non-nullable unless `default:` is set) |
1918
| `default:` | Type metadata default; non-nil defaults imply non-nullable (runtime defaults come from Ecto `default:`) |
2019
2120
```elixir
22-
field :email, :string, typed: [null: false, enforce: true]
21+
field :email, :string, typed: [null: false]
2322
field :role, :string, typed: [type: :admin | :user]
2423
```
2524
26-
`EctoTypedSchema` uses `TypedStructor` in `define_struct: false` mode, so
27-
`typed: [enforce: true]` affects generated types but does not set runtime
28-
`@enforce_keys`.
29-
3025
For `belongs_to`, the `:foreign_key` sub-option controls the FK field's type:
3126
3227
```elixir
@@ -41,7 +36,7 @@ defmodule EctoTypedSchema do
4136
defaults to every field (per-field `typed:` options override):
4237
4338
```elixir
44-
typed_schema "users", null: false, enforce: true do
39+
typed_schema "users", null: false do
4540
field :name, :string
4641
field :bio, :string, typed: [null: true] # override: nullable
4742
end
@@ -50,7 +45,6 @@ defmodule EctoTypedSchema do
5045
| Option | Effect |
5146
| --- | --- |
5247
| `null:` | Default nullability for all fields |
53-
| `enforce:` | Default type-level requiredness for all fields (does not set runtime `@enforce_keys`) |
5448
| `type_kind:` | `:opaque`, `:typep`, etc. (default `:type`) |
5549
| `type_name:` | Custom type name (default `:t`) |
5650
""")
@@ -103,7 +97,8 @@ defmodule EctoTypedSchema do
10397
|> Map.new()
10498

10599
schema_opts = Module.get_attribute(env.module, :ecto_typed_schema_opts, [])
106-
schema_defaults = Keyword.take(schema_opts, [:enforce, :null])
100+
101+
schema_defaults = Keyword.take(schema_opts, [:null])
107102
primary_keys = Module.get_attribute(env.module, :ecto_primary_keys, [])
108103

109104
fields_ast = build_fields_ast(changeset_info, override_map, schema_defaults, primary_keys)
@@ -253,15 +248,15 @@ defmodule EctoTypedSchema do
253248
typed_structor unquote(opts) do
254249
unquote(plugins)
255250
unquote(parameters)
256-
field :__meta__, Ecto.Schema.Metadata.t(__MODULE__), enforce: true
251+
field :__meta__, Ecto.Schema.Metadata.t(__MODULE__), null: false
257252
unquote(fields)
258253
unquote(additional_fields)
259254
end
260255
end
261256
end
262257

263-
# Merges precomputed schema-level defaults (`:enforce`, `:null`) with
264-
# per-field options. Field-level options take precedence.
258+
# Merges precomputed schema-level defaults (`:null`) with per-field options.
259+
# Field-level options take precedence.
265260
@spec merge_schema_opts(keyword(), keyword()) :: keyword()
266261
defp merge_schema_opts(field_opts, schema_defaults) do
267262
Keyword.merge(schema_defaults, field_opts)
@@ -477,7 +472,7 @@ defmodule EctoTypedSchema do
477472
## Examples
478473
479474
field :name, :string
480-
field :email, :string, typed: [null: false, enforce: true]
475+
field :email, :string, typed: [null: false]
481476
field :role, Ecto.Enum, values: [:admin, :user, :guest]
482477
"""
483478
defmacro field(name, type \\ :string, opts \\ []),
@@ -605,7 +600,7 @@ defmodule EctoTypedSchema do
605600
606601
## Examples
607602
608-
embeds_many :addresses, Address, typed: [enforce: true]
603+
embeds_many :addresses, Address
609604
610605
embeds_many :line_items, LineItem, primary_key: false do
611606
field :product_name, :string
@@ -670,8 +665,6 @@ defmodule EctoTypedSchema do
670665
Options that apply as defaults to every field (individual fields
671666
can override via their `:typed` option):
672667
673-
* `:enforce` - if `true`, marks fields as required for type generation
674-
(non-nullable unless a default is present)
675668
* `:null` - if `false`, makes all field types non-nullable
676669
* `:type_kind` - the kind of type to generate (e.g., `:opaque`)
677670
* `:type_name` - custom name for the generated type (default: `:t`)
@@ -755,8 +748,6 @@ defmodule EctoTypedSchema do
755748
Options that apply as defaults to every field (individual fields
756749
can override via their `:typed` option):
757750
758-
* `:enforce` - if `true`, marks fields as required for type generation
759-
(non-nullable unless a default is present)
760751
* `:null` - if `false`, makes all field types non-nullable
761752
* `:type_kind` - the kind of type to generate (e.g., `:opaque`)
762753
* `:type_name` - custom name for the generated type (default: `:t`)

lib/ecto_typed_schema/field_macros.ex

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ defmodule EctoTypedSchema.FieldMacros do
1818
@spec field(atom(), atom() | module(), keyword()) :: Macro.t()
1919
defmacro field(name, type \\ :string, opts \\ []) do
2020
{typed, opts} = Keyword.pop(opts, :typed, [])
21-
validate_typed_option!(name, typed)
21+
typed = sanitize_typed_opts(typed)
2222

2323
# Propagate non-nil Ecto defaults to typed opts so TypedStructor sees them,
2424
# unless the field is explicitly nullable (null: true).
@@ -50,34 +50,31 @@ defmodule EctoTypedSchema.FieldMacros do
5050
end
5151
end
5252

53-
@spec validate_typed_option!(atom(), term()) :: :ok
54-
defp validate_typed_option!(field_name, typed) do
55-
unless Keyword.keyword?(typed) do
56-
raise ArgumentError,
57-
"field :#{field_name} option :typed must be a keyword list, got: #{inspect(typed)}"
58-
end
53+
@supported_typed_keys [:type, :null, :default, :enum_values, :through, :foreign_key]
5954

60-
:ok
61-
end
55+
@spec sanitize_typed_opts(keyword()) :: keyword()
56+
defp sanitize_typed_opts(typed), do: Keyword.take(typed, @supported_typed_keys)
6257

6358
@spec belongs_to(atom(), module(), keyword()) :: Macro.t()
6459
defmacro belongs_to(name, schema, opts \\ []) do
6560
{typed, opts} = Keyword.pop(opts, :typed, [])
66-
validate_typed_option!(name, typed)
61+
typed = sanitize_typed_opts(typed)
6762
{foreign_key_typed, typed} = Keyword.pop(typed, :foreign_key, [])
6863

69-
# Propagate null/enforce from association to FK unless FK explicitly overrides
70-
foreign_key_typed =
71-
typed
72-
|> Keyword.take([:null, :enforce])
73-
|> Keyword.merge(foreign_key_typed)
74-
7564
foreign_key =
7665
case Keyword.fetch(opts, :foreign_key) do
7766
:error -> :"#{name}_id"
7867
{:ok, value} -> value
7968
end
8069

70+
foreign_key_typed = sanitize_typed_opts(foreign_key_typed)
71+
72+
# Propagate null from association to FK unless FK explicitly overrides
73+
foreign_key_typed =
74+
typed
75+
|> Keyword.take([:null])
76+
|> Keyword.merge(foreign_key_typed)
77+
8178
define_field = Keyword.get(opts, :define_field, true)
8279

8380
quote location: :keep do
@@ -94,7 +91,7 @@ defmodule EctoTypedSchema.FieldMacros do
9491
@spec has_one(atom(), module() | keyword(), keyword()) :: Macro.t()
9592
defmacro has_one(name, schema, opts \\ []) do
9693
{typed, schema, opts} = extract_has_typed_opts(schema, opts)
97-
validate_typed_option!(name, typed)
94+
typed = sanitize_typed_opts(typed)
9895

9996
quote location: :keep do
10097
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -106,7 +103,7 @@ defmodule EctoTypedSchema.FieldMacros do
106103
@spec embeds_one(atom(), module(), keyword()) :: Macro.t()
107104
defmacro embeds_one(name, schema, opts \\ []) do
108105
{typed, opts} = Keyword.pop(opts, :typed, [])
109-
validate_typed_option!(name, typed)
106+
typed = sanitize_typed_opts(typed)
110107

111108
quote location: :keep do
112109
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -118,7 +115,7 @@ defmodule EctoTypedSchema.FieldMacros do
118115
@spec embeds_one(atom(), module(), keyword(), keyword()) :: Macro.t()
119116
defmacro embeds_one(name, schema, opts, do: block) do
120117
{typed, opts} = Keyword.pop(opts, :typed, [])
121-
validate_typed_option!(name, typed)
118+
typed = sanitize_typed_opts(typed)
122119

123120
quote location: :keep do
124121
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -130,7 +127,7 @@ defmodule EctoTypedSchema.FieldMacros do
130127
@spec embeds_many(atom(), module(), keyword()) :: Macro.t()
131128
defmacro embeds_many(name, schema, opts \\ []) do
132129
{typed, opts} = Keyword.pop(opts, :typed, [])
133-
validate_typed_option!(name, typed)
130+
typed = sanitize_typed_opts(typed)
134131

135132
quote location: :keep do
136133
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -142,7 +139,7 @@ defmodule EctoTypedSchema.FieldMacros do
142139
@spec embeds_many(atom(), module(), keyword(), keyword()) :: Macro.t()
143140
defmacro embeds_many(name, schema, opts, do: block) do
144141
{typed, opts} = Keyword.pop(opts, :typed, [])
145-
validate_typed_option!(name, typed)
142+
typed = sanitize_typed_opts(typed)
146143

147144
quote location: :keep do
148145
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -154,7 +151,7 @@ defmodule EctoTypedSchema.FieldMacros do
154151
@spec has_many(atom(), module() | keyword(), keyword()) :: Macro.t()
155152
defmacro has_many(name, schema, opts \\ []) do
156153
{typed, schema, opts} = extract_has_typed_opts(schema, opts)
157-
validate_typed_option!(name, typed)
154+
typed = sanitize_typed_opts(typed)
158155

159156
quote location: :keep do
160157
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -166,7 +163,7 @@ defmodule EctoTypedSchema.FieldMacros do
166163
@spec many_to_many(atom(), module(), keyword()) :: Macro.t()
167164
defmacro many_to_many(name, schema, opts \\ []) do
168165
{typed, opts} = Keyword.pop(opts, :typed, [])
169-
validate_typed_option!(name, typed)
166+
typed = sanitize_typed_opts(typed)
170167

171168
quote location: :keep do
172169
@ecto_typed_schema_typed {unquote(name), unquote(Macro.escape(typed))}
@@ -178,7 +175,7 @@ defmodule EctoTypedSchema.FieldMacros do
178175
@spec timestamps(keyword()) :: Macro.t()
179176
defmacro timestamps(opts \\ []) do
180177
{typed, opts} = Keyword.pop(opts, :typed, [])
181-
validate_typed_option!(:timestamps, typed)
178+
typed = sanitize_typed_opts(typed)
182179

183180
quote location: :keep do
184181
# Merge @timestamps_opts (if defined) with explicit opts.

test/ecto_typed_schema/types/belongs_to_test.exs

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -118,41 +118,6 @@ defmodule EctoTypedSchema.Types.BelongsToTest do
118118
end
119119
end
120120

121-
describe "with enforce: true" do
122-
test "makes association non-nullable", ctx do
123-
expected_types =
124-
with_tmpmodule Schema, ctx do
125-
use Ecto.Schema
126-
127-
schema "posts" do
128-
belongs_to :author, User
129-
end
130-
131-
@type t() :: %__MODULE__{
132-
__meta__: Ecto.Schema.Metadata.t(__MODULE__),
133-
id: integer(),
134-
author: Ecto.Schema.belongs_to(User.t()),
135-
author_id: integer()
136-
}
137-
after
138-
fetch_types!(Schema)
139-
end
140-
141-
generated_types =
142-
with_tmpmodule Schema, ctx do
143-
use EctoTypedSchema
144-
145-
typed_schema "posts" do
146-
belongs_to :author, User, typed: [enforce: true]
147-
end
148-
after
149-
fetch_types!(Schema)
150-
end
151-
152-
assert_type(expected_types, generated_types)
153-
end
154-
end
155-
156121
describe "with custom FK type" do
157122
test "overrides foreign key type", ctx do
158123
expected_types =

test/ecto_typed_schema/types/embedded_schema_test.exs

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -39,42 +39,6 @@ defmodule EctoTypedSchema.Types.EmbeddedSchemaTest do
3939
end
4040
end
4141

42-
describe "with schema-level enforce: true" do
43-
test "all fields are non-nullable", ctx do
44-
expected_types =
45-
with_tmpmodule Schema, ctx do
46-
use Ecto.Schema
47-
48-
embedded_schema do
49-
field :name, :string
50-
field :age, :integer
51-
end
52-
53-
@type t() :: %__MODULE__{
54-
id: Ecto.UUID.t(),
55-
name: String.t(),
56-
age: integer()
57-
}
58-
after
59-
fetch_types!(Schema)
60-
end
61-
62-
generated_types =
63-
with_tmpmodule Schema, ctx do
64-
use EctoTypedSchema
65-
66-
typed_embedded_schema enforce: true do
67-
field :name, :string
68-
field :age, :integer
69-
end
70-
after
71-
fetch_types!(Schema)
72-
end
73-
74-
assert_type(expected_types, generated_types)
75-
end
76-
end
77-
7842
describe "with schema-level null: false" do
7943
test "all fields are non-nullable", ctx do
8044
expected_types =
@@ -332,40 +296,4 @@ defmodule EctoTypedSchema.Types.EmbeddedSchemaTest do
332296
assert_type(expected_types, generated_types)
333297
end
334298
end
335-
336-
describe "combined schema-level options" do
337-
test "enforce: true and null: false together", ctx do
338-
expected_types =
339-
with_tmpmodule Schema, ctx do
340-
use Ecto.Schema
341-
342-
embedded_schema do
343-
field :name, :string
344-
field :age, :integer
345-
end
346-
347-
@type t() :: %__MODULE__{
348-
id: Ecto.UUID.t(),
349-
name: String.t(),
350-
age: integer()
351-
}
352-
after
353-
fetch_types!(Schema)
354-
end
355-
356-
generated_types =
357-
with_tmpmodule Schema, ctx do
358-
use EctoTypedSchema
359-
360-
typed_embedded_schema enforce: true, null: false do
361-
field :name, :string
362-
field :age, :integer
363-
end
364-
after
365-
fetch_types!(Schema)
366-
end
367-
368-
assert_type(expected_types, generated_types)
369-
end
370-
end
371299
end

0 commit comments

Comments
 (0)