Skip to content

Rewriting the entire project structure with Spark#13

Merged
shahryarjb merged 45 commits into
masterfrom
spark-mode
May 14, 2026
Merged

Rewriting the entire project structure with Spark#13
shahryarjb merged 45 commits into
masterfrom
spark-mode

Conversation

@shahryarjb
Copy link
Copy Markdown
Member

@shahryarjb shahryarjb commented May 11, 2026

Fixed #12,
Fixed #11
Fixed #1
Fixed #2
Fixed #4
Fixed #5
Fixed #6

Temp

Changelog for GuardedStruct 0.1.0

Major release. The macro core has been rewritten on top of Spark. Public API is fully backward-compatible — every existing call (use GuardedStruct, guardedstruct opts do … end, field, sub_field, conditional_field, MyStruct.builder/1,2, MyStruct.keys/0,1, MyStruct.enforce_keys/0,1, MyStruct.__information__/0) works unchanged.

See MIGRATION.md for the upgrade story.

Architecture

  • Rewrote the 2,910-LOC macro core on Spark.Dsl.Extension. The new core is one :guardedstruct section, four entities (field, sub_field, conditional_field, virtual_field, plus a dynamic_field shorthand), six transformers, and two verifiers.
  • Moved every static-string parse to compile time. Derive op-strings, from:/on: paths, and domain: patterns are now parsed once during compilation; the runtime reads pre-built op-maps from __fields__/0 and never re-parses on each builder/1 call.
  • Pre-evaluated enum=Map[…] / enum=Tuple[…] / equal=Map::… operands at compile time. Zero Code.eval_string calls on the runtime hot path.
  • Editor autocomplete inside guardedstruct do … end blocks via Spark's ElixirSense plugin (closes Can we have VScode extension for auto-completion? #1).

New features

Pattern-keyed maps (closes #11)

A field whose name is a regex declares a pattern-keyed map. The struct's builder/1 returns a plain validated map (no struct generated, since Elixir struct keys are fixed):

defmodule ShardsMap do
  use GuardedStruct
  guardedstruct do
    field ~r/^shard_\d+$/, struct(), struct: Shard, derive: "validate(map, not_empty)"
  end
end

ShardsMap.builder(%{"shard_1" => %{node: "10.0.0.1"}, "shard_2" => %{node: "10.0.0.2"}})
# {:ok, %{"shard_1" => %Shard{...}, "shard_2" => %Shard{...}}}

Mixing atom-keyed and regex-keyed fields in the same guardedstruct raises Spark.Error.DslError at compile time. Keys stay as strings (atom-table-exhaustion safe by default).

Erlang Record support (closes #6)

Two new validate ops:

field :user_record, :tuple, derive: "validate(record=user)"
# accepts {:user, "Alice", 30}; rejects other tags

virtual_field (closes #5)

Validated through the full pipeline but excluded from defstruct. Useful for password_confirm-style fields needed only by main_validator/1.

dynamic_field

Shorthand for a field whose value is a free-form map (default %{}, type map(), derive validate(map)).

dynamic_field values are identity-preserved — whatever you submit (string keys, atom keys, mixed, nested) round-trips byte-identical to builder/1's output. No string-to-atom conversion of inner keys at any depth, to prevent atom-table-exhaustion DoS. See the "Atom-attack safety" section of the GuardedStruct module @moduledoc for details.

GuardedStruct.Validate (closes #2)

Three-tier API for using a schema without going through builder/1:

Validate.run("validate(string, max_len=80, email_r)", "alice@example.com")
# {:ok, "alice@example.com"}

Validate.field(User, :email, "alice@x.com")
# {:ok, "alice@x.com"}

Validate.field(User, :parent_email, "p@x.com", context: %{account_type: "personal"})
# resolves cross-field on:/domain: deps from context

Validate.field(User, :parent_email, "p@x.com", mode: :isolated)
# skips cross-field deps entirely

Validate.partial(User, %{name: "", email: "alice@x.com"})
# subset validation; missing fields skipped (no enforce_keys check)

Custom validators / sanitizers via Spark-native DSL

defmodule MyApp.Derives do
  use GuardedStruct.Derive.Extension

  validator :slug, fn input ->
    is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
  end

  sanitizer :slugify, fn input -> ... end
end

# config/config.exs
config :guarded_struct, derive_extensions: [MyApp.Derives]

Coexists with the legacy Application.put_env(:guarded_struct, :validate_derive, …) plug-in mechanism.

Splode error class

Opt-in wrapper for runtime errors:

{:error, errs} = MyStruct.builder(input)
class = GuardedStruct.Errors.from_tuple(errs)
GuardedStruct.Errors.traverse_errors(class, &Exception.message/1)

Ash extension

defmodule MyApp.Resource do
  use Ash.Resource, extensions: [GuardedStruct.AshResource]

  guardedstruct do
    field :name, :string, enforce: true, derives: "validate(string)"
  end

  changes do
    change GuardedStruct.AshResource.Change
  end
end

The extension generates prefixed functions to avoid clashing with Ash's own callbacks:

  • __guarded_change__/1 — runs the full GuardedStruct pipeline (sanitize → validate → derive → main_validator) and returns {:ok, transformed_attrs} | {:error, errors}. Named change (not validate) because the pipeline can transform values, not just inspect them.
  • __guarded_information__/0 and __guarded_fields__/0 — introspection, mirroring the standalone API.

The companion GuardedStruct.AshResource.Change module is a ready-made Ash.Resource.Change that bridges __guarded_change__/1 into the changeset pipeline. Two wiring modes:

  • Manual (default) — write changes do change GuardedStruct.AshResource.Change end once. Explicit, inspectable via Ash.Resource.Info.changes/1.
  • Auto-wire — set auto_wire true at the top of guardedstruct. A Spark transformer injects the change for you via Ash.Resource.Builder.add_change/3. No changes do ... end block needed. Default is false.

The bridge module also implements batch_change/3, so Ash.bulk_create/3 and Ash.bulk_update/3 (with strategy: :stream) work end-to-end. atomic/3 returns {:not_atomic, …} — sanitize / auto: / main_validator/1 run arbitrary Elixir and can't be atomic SQL. Use the new atomic: true opt for compile-time-verified atomic resources (see below).

atomic: true opt + compile-time VerifyAtomic verifier

guardedstruct do
  atomic true
  field :email,    :string, derives: "sanitize(trim, downcase) validate(email_r, max_len=320)"
  field :role,     :string, derives: "validate(enum=String[admin::user::guest])"
  field :tenant_id, :string, derives: "validate(uuid)"
end

Opt-in flag with default: false. When true, the compile-time GuardedStruct.Verifiers.VerifyAtomic walks every field and rejects (Spark.Error.DslError at the offending field's source line) any op that can't translate to atomic SQL:

  • validate(email) / validate(url) — need DNS / network I/O
  • validator: {Mod, :fn} per-field MFA — arbitrary Elixir
  • auto: {Mod, :fn} — arbitrary Elixir
  • main_validator/1 callback — cross-field Elixir
  • on:, from:, domain: cross-field options
  • Custom ops from GuardedStruct.Derive.Extension

Sanitize ops (trim, downcase, strip_tags, …) are always allowed — they run in Elixir before the atomic SQL fires. The full atomic-safe registry lives in GuardedStruct.AtomicClassifier (one pattern-match clause per op; contributors extend by adding a single def classify_op({:validate, :my_op}), do: :safe).

Soft deprecations

  • derive: option renamed to derives:. Both work in 0.1.0; the legacy derive: emits a compile-time deprecation warning via Spark.Warning.warn_deprecated/4 and will be removed in a future release. The plural form aligns with the @derives decorator. When both are present on the same field, derives: wins silently.

    # new canonical form
    field :email, String.t(), derives: "sanitize(trim) validate(email_r)"
    
    # legacy form, still works but warns
    field :email, String.t(), derive: "sanitize(trim) validate(email_r)"

Bug fixes

  • Closes Support nested conditional fields #7, Predefined validations and sanitizers version 0.1.4 #8, #25: nested conditional_field works to arbitrary depth via recursive_as: :conditional_fields. Three-level deep tested in test/nested_conditional_field_test.exs.
  • Restored i18n via GuardedStruct.Messages.translated_message/1,2 for orchestration-layer errors (authorized_fields, required_fields, :on / :domain core keys, list-builder errors). All 14 message callbacks reachable again.
  • __information__/0 now populates conditional_keys with the actual conditional-field names (was always []).
  • MyStruct.Error.message/1 matches master's format and uses translated_message(:message_exception) for i18n.
  • Unblocked the legacy Parser raise sites that prevented nested conditional_field from compiling.

Other improvements

  • Strict compile-time errors for malformed derive: strings via Spark.Error.DslError with file:line.
  • Op-name registry — single source of truth for built-in ops, lives at lib/guarded_struct/derive/registry.ex.
  • mix lint alias chains mix spark.formatter then mix format.
  • mix spark.formatter and mix spark.cheat_sheets work without the --extensions flag (configured via mix alias).

Internals dropped

These were @doc false internal API in 0.0.x; if any user code reached for them, it was unsupported. They're gone:

  • The builder/4 form on GuardedStruct (with (actions, key, type, error) arity) — replaced by an internal runtime helper
  • register_struct/4, __field__/6, __type__/2, delete_temporary_revaluation/1, create_builder/1, create_error_module/0
  • The 12 gs_* accumulator module attributes (gs_fields, gs_types, gs_enforce_keys, etc.) — replaced by Spark DSL state
  • parser/3 (the conditional variant of Parser.parser), elements_unification/2, find_node_tags/1, add_parent_tags/3, conds_list/2, find_conds_children_recursive/2
  • Derive.pre_derives_check/3, get_derives_from_success_conditional_data/1, error_handler/2, halt_errors/1, the alternate-shape derive/1 clauses
  • Messages.unsupported_conditional_field/0 and Messages.parser_field_value/0 callbacks (dead code after the nested-conditional fix)

Dependencies

  • Added: {:spark, "~> 2.7"}, {:splode, "~> 0.3"}
  • Added (:dev, :test only): {:sourceror, "~> 1.7"}, {:igniter, "~> 0.7"}
  • All optional deps unchanged (html_sanitize_ex, email_checker, ex_url, ex_phone_number, sweet_xml)

Test counts

  • 0.0.4: 146 tests
  • 0.1.0: 280 tests, all passing

Changelog for GuardedStruct 0.0.4

  • Fix deprecated code from Elixir 1.18
  • Support overridable messages for the GuardedStruct module with support for multiple languages

Changelog for GuardedStruct 0.0.3

  • Fix deprecated code from Elixir 1.18.0-rc.0

Changelog for GuardedStruct 0.0.2

  • Fix: Support charlists sigil warning and keep backward compatibility for charlist regex

Changelog for GuardedStruct 0.0.1

  • Detach from the Mishka developer tools library
  • Remove optional libraries (must be enabled by the user)
  • Improvements in some tests

shahryarjb added 5 commits May 8, 2026 22:33
vip

vip

vip

vip

vip

vip
Update TODO.md

vip

vip

vip

vip

Create OPTIONS-0.1.0.md
@shahryarjb shahryarjb self-assigned this May 11, 2026
@shahryarjb shahryarjb added this to the 0.1.0 milestone May 11, 2026
@shahryarjb
Copy link
Copy Markdown
Member Author

Migrating from 0.0.x to 0.1.0

TL;DR — your existing code keeps working. 0.1.0 rewrites the macro core on Spark, but every 0.0.x public API is preserved. Bump the version in mix.exs, run mix deps.get, and your tests should still pass.

This guide covers what's new, what's been deprecated (nothing forced), and a few sharp edges to be aware of.

What's unchanged

  • use GuardedStruct
  • guardedstruct opts do … end
  • field/2,3, sub_field/2,3,4, conditional_field/2,3,4
  • All field options: enforce, default, derive, validator, auto, from, on, domain, struct, structs, hint, priority
  • All section options: enforce, opaque, module, error, authorized_fields, main_validator, validate_derive, sanitize_derive
  • All 50+ validate ops and 11 sanitize ops in derive strings
  • MyStruct.builder/1,2, MyStruct.builder({:root, attrs}), MyStruct.builder({key, attrs, :add | :edit})
  • MyStruct.keys/0,1 and MyStruct.enforce_keys/0,1
  • MyStruct.__information__/0
  • The Application.put_env(:guarded_struct, :validate_derive, [...]) and :sanitize_derive plug-in mechanism
  • GuardedStruct.Messages i18n behaviour and overridable callbacks

If your code only used the documented public API, no changes are needed.

What's new (and worth opting into)

1. Pattern-keyed maps

A field whose name is a regex declares a map shape with no fixed keys:

defmodule Headers do
  use GuardedStruct
  guardedstruct do
    field ~r/^X-[A-Z][A-Za-z\-]*$/, String.t(), derives: "validate(string, max_len=500)"
  end
end

Headers.builder(%{"X-API-Key" => "secret", "X-Tenant-Id" => "abc"})
# {:ok, %{"X-API-Key" => "secret", "X-Tenant-Id" => "abc"}}

Returns a plain map (no struct generated). Keys stay as strings — no atom conversion, atom-table-exhaustion safe.

2. virtual_field

Validated through the full pipeline but excluded from defstruct:

guardedstruct do
  field :password, String.t(), enforce: true
  virtual_field :password_confirm, String.t()
end

def main_validator(attrs) do
  if attrs[:password] == attrs[:password_confirm],
    do: {:ok, attrs},
    else: {:error, [%{field: :password_confirm, action: :match, message: "..."}]}
end

The validated password_confirm value is visible to main_validator/1 then dropped before the struct is built.

3. dynamic_field

Shorthand for a free-form map field:

guardedstruct do
  field :name, String.t()
  dynamic_field :metadata    # type: map(), default: %{}, derives: "validate(map)"
end

Security note: dynamic_field values are identity-preserved — whatever map you submit is exactly what you get back. No string-to-atom conversion of keys at any depth, to prevent atom-table-exhaustion DoS. Read these values with string keys. See the "Atom-attack safety" section of the GuardedStruct module @moduledoc for full details.

4. GuardedStruct.Validate — schema-without-builder

Three-tier API:

# Ad-hoc op-string against a value
GuardedStruct.Validate.run("validate(string, max_len=80, email_r)", "alice@x.com")

# One named field of a module
GuardedStruct.Validate.field(MyStruct, :email, "alice@x.com")
GuardedStruct.Validate.field(MyStruct, :parent_email, "p@x.com",
  context: %{account_type: "personal"}    # cross-field deps from context
)
GuardedStruct.Validate.field(MyStruct, :email, "x", mode: :isolated)

# Subset of fields (e.g. PATCH endpoints, form-as-you-type)
GuardedStruct.Validate.partial(MyStruct, %{name: "Alice", email: "alice@x.com"})
# missing fields skipped — no enforce_keys check

5. Erlang Records

field :user_record, :tuple, derives: "validate(record)"        # any tagged tuple
field :user_record, :tuple, derives: "validate(record=user)"   # specific tag

6. Custom validators / sanitizers via Spark-native DSL

If you'd been using Application.put_env(:guarded_struct, :validate_derive, MyMod) with a hand-rolled validate/3 callback, you can now write:

defmodule MyApp.Derives do
  use GuardedStruct.Derive.Extension

  validator :slug, fn input ->
    is_binary(input) and Regex.match?(~r/^[a-z0-9-]+$/, input)
  end

  sanitizer :slugify, fn input -> ... end
end

# config/config.exs
config :guarded_struct, derive_extensions: [MyApp.Derives]

The legacy Application.put_env mechanism still works — both can coexist.

7. Ash extension

use Ash.Resource, extensions: [GuardedStruct.AshResource]

guardedstruct do
  field :name, :string, enforce: true, derives: "validate(string)"
end

changes do
  change GuardedStruct.AshResource.Change   # wire into create/update
end

Generates __guarded_change__/1, __guarded_information__/0, __guarded_fields__/0 under the __guarded_* namespace (no clash with Ash's own callbacks). The companion GuardedStruct.AshResource.Change module bridges the pipeline into Ash's changeset flow.

Prefer zero wiring? Set auto_wire true at the top of the guardedstruct block and the change is injected for you. See OPTIONS §15.

For Ash resources where every derive op is SQL-translatable (no validate(email) DNS, no validator: MFA, no main_validator/1), set atomic true to get a compile-time guarantee. The VerifyAtomic verifier rejects unsafe ops at the offending field's source line — see the "Atomic mode" section of the README or OPTIONS §15.

8. Splode error wrapping (opt-in)

case MyStruct.builder(input) do
  {:error, errs} -> {:error, GuardedStruct.Errors.from_tuple(errs)}
  ok -> ok
end

Gives you Splode.traverse_errors/2, set_path/2, JSON serialisation. The builder/1 return shape still defaults to the legacy {:error, [%{field, action, message}]} tuple — wrapping is opt-in.

Soft deprecations

derive: option renamed to derives:

The canonical option name is now plural — derives: — aligning with the
@derives decorator. The legacy derive: still works but emits a
compile-time deprecation warning. Bulk-rename in your project with:

# macOS:
grep -rl 'derive: "' lib test | xargs sed -i '' 's/\bderive: "/derives: "/g'

# Linux:
grep -rl 'derive: "' lib test | xargs sed -i 's/\bderive: "/derives: "/g'

When both are set on one field, derives: wins silently and the
deprecation warning does not fire.

Sharp edges to watch for

Compile-time errors for things that previously failed silently

0.0.x's Parser.parser/1 had a rescue _ -> nil that swallowed parse errors and produced no validation. 0.1.0 parses derives at compile time and surfaces malformed strings as Spark.Error.DslError at the user's source line.

Two cases where you'll see new errors:

  • Malformed derives: strings that previously silently became no-ops will now raise. If you've been relying on a typo'd derive being silently ignored, fix the string.
  • derives: on a non-string value (e.g. a transformer-produced atom) now raises Spark.Error.DslError at compile time.

If you want to keep the silent-failure behaviour for a specific case, leave the entire derives: option off the field.

Mixing atom-keyed and regex-keyed fields in one guardedstruct

Compile-time error. The fix: extract the regex part into its own module and reference it via struct::

# Before (won't compile):
guardedstruct do
  field :name, String.t()
  field ~r/^tag_/, String.t()      # ⛔
end

# After:
defmodule Tags do
  use GuardedStruct
  guardedstruct do
    field ~r/^tag_/, String.t()
  end
end

defmodule User do
  use GuardedStruct
  guardedstruct do
    field :name, String.t()
    field :tags, struct(), struct: Tags
  end
end

__information__() shape

Same keys as before, but conditional_keys is now populated (was always [] in pre-0.1.0 transitional builds — never released). If you have introspection code that depended on conditional_keys: [], update it.

MyStruct.Error.message/1 format

Now uses translated_message(:message_exception) and matches master's exact format:

{prefix from i18n callback}
 Term: {inspect(term)}
 Errors: {inspect(errors)}

If you were parsing the message string (rare), update your parser.

Application.put_env(:guarded_struct, :validate_derive, …) interaction with strict mode

Strict op-name verification is automatically disabled when an Application-env plug-in is registered, since the verifier can't introspect the plug-in's op names at compile time. To get strict checking, migrate the plug-in to the Spark-native GuardedStruct.Derive.Extension DSL.

How to upgrade

# mix.exs
defp deps do
  [
    {:guarded_struct, "~> 0.1.0"},
    # All your other deps...
  ]
end
mix deps.get
mix compile
mix test

If mix test is green, you're done. If something fails, the most likely cause is a derives: string that was previously silently broken — check the compile-time error message; it'll point at the offending field.

Anything I've missed?

If your 0.0.x code stops working in 0.1.0 and the failure isn't covered above, please open an issue — that's a bug, not an intended migration step.

@shahryarjb shahryarjb merged commit 685e941 into master May 14, 2026
1 check passed
@shahryarjb shahryarjb deleted the spark-mode branch May 14, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment