You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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):
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).
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.
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).
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
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 formfield:email,String.t(),derives: "sanitize(trim) validate(email_r)"# legacy form, still works but warnsfield:email,String.t(),derive: "sanitize(trim) validate(email_r)"
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.
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.
# Ad-hoc op-string against a valueGuardedStruct.Validate.run("validate(string, max_len=80, email_r)","alice@x.com")# One named field of a moduleGuardedStruct.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 tuplefield: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:
The legacy Application.put_env mechanism still works — both can coexist.
7. Ash extension
useAsh.Resource,extensions: [GuardedStruct.AshResource]guardedstructdofield:name,:string,enforce: true,derives: "validate(string)"endchangesdochangeGuardedStruct.AshResource.Change# wire into create/updateend
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.
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:
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):guardedstructdofield:name,String.t()field~r/^tag_/,String.t()# ⛔end# After:defmoduleTagsdouseGuardedStructguardedstructdofield~r/^tag_/,String.t()endenddefmoduleUserdouseGuardedStructguardedstructdofield:name,String.t()field:tags,struct(),struct: Tagsendend
__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.exsdefpdepsdo[{: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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdfor the upgrade story.Architecture
Spark.Dsl.Extension. The new core is one:guardedstructsection, four entities (field,sub_field,conditional_field,virtual_field, plus adynamic_fieldshorthand), six transformers, and two verifiers.from:/on:paths, anddomain:patterns are now parsed once during compilation; the runtime reads pre-built op-maps from__fields__/0and never re-parses on eachbuilder/1call.enum=Map[…]/enum=Tuple[…]/equal=Map::…operands at compile time. ZeroCode.eval_stringcalls on the runtime hot path.guardedstruct do … endblocks via Spark's ElixirSense plugin (closes Can we have VScode extension for auto-completion? #1).New features
Pattern-keyed maps (closes #11)
A
fieldwhose name is a regex declares a pattern-keyed map. The struct'sbuilder/1returns a plain validated map (no struct generated, since Elixir struct keys are fixed):Mixing atom-keyed and regex-keyed
fields in the sameguardedstructraisesSpark.Error.DslErrorat compile time. Keys stay as strings (atom-table-exhaustion safe by default).Erlang Record support (closes #6)
Two new validate ops:
virtual_field(closes #5)Validated through the full pipeline but excluded from
defstruct. Useful forpassword_confirm-style fields needed only bymain_validator/1.dynamic_fieldShorthand for a
fieldwhose value is a free-form map (default%{}, typemap(), derivevalidate(map)).dynamic_fieldvalues are identity-preserved — whatever you submit (string keys, atom keys, mixed, nested) round-trips byte-identical tobuilder/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 theGuardedStructmodule @moduledoc for details.GuardedStruct.Validate(closes #2)Three-tier API for using a schema without going through
builder/1:Custom validators / sanitizers via Spark-native DSL
Coexists with the legacy
Application.put_env(:guarded_struct, :validate_derive, …)plug-in mechanism.Splode error class
Opt-in wrapper for runtime errors:
Ash extension
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}. Namedchange(notvalidate) because the pipeline can transform values, not just inspect them.__guarded_information__/0and__guarded_fields__/0— introspection, mirroring the standalone API.The companion
GuardedStruct.AshResource.Changemodule is a ready-madeAsh.Resource.Changethat bridges__guarded_change__/1into the changeset pipeline. Two wiring modes:changes do change GuardedStruct.AshResource.Change endonce. Explicit, inspectable viaAsh.Resource.Info.changes/1.auto_wire trueat the top ofguardedstruct. A Spark transformer injects the change for you viaAsh.Resource.Builder.add_change/3. Nochanges do ... endblock needed. Default isfalse.The bridge module also implements
batch_change/3, soAsh.bulk_create/3andAsh.bulk_update/3(withstrategy: :stream) work end-to-end.atomic/3returns{:not_atomic, …}— sanitize /auto:/main_validator/1run arbitrary Elixir and can't be atomic SQL. Use the newatomic: trueopt for compile-time-verified atomic resources (see below).atomic: trueopt + compile-timeVerifyAtomicverifierOpt-in flag with
default: false. Whentrue, the compile-timeGuardedStruct.Verifiers.VerifyAtomicwalks every field and rejects (Spark.Error.DslErrorat the offending field's source line) any op that can't translate to atomic SQL:validate(email)/validate(url)— need DNS / network I/Ovalidator: {Mod, :fn}per-field MFA — arbitrary Elixirauto: {Mod, :fn}— arbitrary Elixirmain_validator/1callback — cross-field Elixiron:,from:,domain:cross-field optionsGuardedStruct.Derive.ExtensionSanitize ops (
trim,downcase,strip_tags, …) are always allowed — they run in Elixir before the atomic SQL fires. The full atomic-safe registry lives inGuardedStruct.AtomicClassifier(one pattern-match clause per op; contributors extend by adding a singledef classify_op({:validate, :my_op}), do: :safe).Soft deprecations
derive:option renamed toderives:. Both work in0.1.0; the legacyderive:emits a compile-time deprecation warning viaSpark.Warning.warn_deprecated/4and will be removed in a future release. The plural form aligns with the@derivesdecorator. When both are present on the same field,derives:wins silently.Bug fixes
conditional_fieldworks to arbitrary depth viarecursive_as: :conditional_fields. Three-level deep tested intest/nested_conditional_field_test.exs.GuardedStruct.Messages.translated_message/1,2for orchestration-layer errors (authorized_fields,required_fields,:on/:domaincore keys, list-builder errors). All 14 message callbacks reachable again.__information__/0now populatesconditional_keyswith the actual conditional-field names (was always[]).MyStruct.Error.message/1matches master's format and usestranslated_message(:message_exception)for i18n.Parserraise sites that prevented nested conditional_field from compiling.Other improvements
derive:strings viaSpark.Error.DslErrorwith file:line.lib/guarded_struct/derive/registry.ex.mix lintalias chainsmix spark.formatterthenmix format.mix spark.formatterandmix spark.cheat_sheetswork without the--extensionsflag (configured via mix alias).Internals dropped
These were
@doc falseinternal API in0.0.x; if any user code reached for them, it was unsupported. They're gone:builder/4form onGuardedStruct(with(actions, key, type, error)arity) — replaced by an internal runtime helperregister_struct/4,__field__/6,__type__/2,delete_temporary_revaluation/1,create_builder/1,create_error_module/0gs_*accumulator module attributes (gs_fields,gs_types,gs_enforce_keys, etc.) — replaced by Spark DSL stateparser/3(the conditional variant ofParser.parser),elements_unification/2,find_node_tags/1,add_parent_tags/3,conds_list/2,find_conds_children_recursive/2Derive.pre_derives_check/3,get_derives_from_success_conditional_data/1,error_handler/2,halt_errors/1, the alternate-shapederive/1clausesMessages.unsupported_conditional_field/0andMessages.parser_field_value/0callbacks (dead code after the nested-conditional fix)Dependencies
{:spark, "~> 2.7"},{:splode, "~> 0.3"}:dev, :testonly):{:sourceror, "~> 1.7"},{:igniter, "~> 0.7"}html_sanitize_ex,email_checker,ex_url,ex_phone_number,sweet_xml)Test counts
0.0.4: 146 tests0.1.0: 280 tests, all passingChangelog for GuardedStruct 0.0.4
GuardedStructmodule with support for multiple languagesChangelog for GuardedStruct 0.0.3
Changelog for GuardedStruct 0.0.2
Changelog for GuardedStruct 0.0.1