Skip to content

Support URL-encoded/multipart HTML form values for deeply nested SaveOperations - #1156

Draft
paulcsmith with Copilot wants to merge 15 commits into
mainfrom
copilot/nested-attributes-solution
Draft

paulcsmith with Copilot wants to merge 15 commits into
mainfrom
copilot/nested-attributes-solution

Conversation

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown

Summary

This PR adds full support for nested has_one/has_many SaveOperations -- similar to Rails' accepts_nested_attributes_for -- so you can save/update/destroy an entire tree of records (e.g. a Post and all of its Comments) through a single parent SaveOperation, in a type-safe way:

  • has_many/has_one macros for declaring nested child operations, nestable arbitrarily deep (a has_many child can itself declare its own has_one/has_many)
  • Automatic Lucky form-helper rendering support: nested operations get the right Avram::ParamKeyOverride#param_key so text_input, nested_id_input, etc. render the exact name=/id= needed for round-tripping through a form
  • Avram::Params#nested/#many_nested decode both JSON payloads and URL-encoded/multipart HTML form field naming conventions
  • Each nested operation validates itself with its own normal rules, and a failure anywhere rolls back the entire parent transaction
  • allow_destroy: true on has_many lets a submitted item delete its associated record instead of saving it

Example

class SavePost < Post::SaveOperation
  class SaveComment < Comment::SaveOperation
    permit_columns body

    default_validations do
      validate_required body
    end
  end

  permit_columns title
  has_many comments : SaveComment, allow_destroy: true
end

Forms, with Lucky helpers

text_input(operation.title)

operation.comments.each do |comment|
  nested_id_input(comment) # keeps updates on resubmission from creating duplicates
  text_input(comment.body)
end

This renders name="comments[0]:body" / id="comments_0_body" for the first comment, name="comments[1]:body" for the second, and so on -- and pre-fills from the post's existing comments when editing a persisted Post with no submitted params.

JSON requests

The same data works from a JSON API client, no special encoding required:

{
  "title": "My Post",
  "comments": [
    { "body": "First" },
    { "id": "42", "body": "Updated existing comment" }
  ]
}

This is equivalent to submitting "comments[0]:body" and "comments[1]:id"/"comments[1]:body" as separate form fields -- Avram::Params decodes both conventions into the same nested params.

Validations

If any comment fails validation (e.g. a blank body), the parent operation.valid? is false and nothing is saved -- not the post, and not any of the other comments, even ones that are individually valid. An error is added to the parent under :comments, while each comment's own errors remain available for re-rendering the form:

operation.valid?                    # => false
operation.errors[:comments]         # => ["failed"]
operation.comments.last.body.errors # => ["is required"]

Create vs. update

Whether an item in a has_many creates a brand-new record or updates an existing one is decided per item, based on whether its hash includes an id that matches one of the parent's already-associated records -- not by whether create or update is called on the parent operation:

# Creates the post and every comment in `comments` as new records.
SavePost.create(params) do |operation, post|
  # ...
end

# Updates `existing_post`. Any comment hash whose `id` matches one of
# `existing_post`'s current comments updates that comment in place; any
# comment hash with no `id` (or an unrecognized one) creates a new
# comment instead; a comment hash with a truthy `_destroy` is deleted.
SavePost.update(existing_post, params) do |operation, post|
  # ...
end

has_one works the same way, minus the id matching -- since there's only ever one associated record, it's always that one that gets updated (or created, if none exists yet).

This PR's specific fix: URL-encoded/multipart HTML form values for deeply nested SaveOperations

Deeply nested has_one/has_many SaveOperation support only worked for JSON request bodies. has_many's macro re-wraps each array item's flat params into a fresh Avram::Params for its own child SaveOperation, but that re-wrapped params object only knew how to decode the JSON-string nesting convention. Real HTML forms (and Lucky::Params itself) encode nested fields differently, so grandchild-level associations submitted via a form silently received wrong or empty data. None of the existing nested-save-operation specs exercised the real Avram::Params class, so this gap went uncaught.

Fix

  • Avram::Params#nested/#many_nested now also recognize the form/URL-encoded naming convention used by Lucky::Params: "key:field" for a nested has_one-style value, and "key[index]:field" for has_many-style items.
  • Resolution order is: JSON-string decode → form-key decode → legacy flat fallback (unchanged for backward compatibility).
  • many_nested groups form keys by index and sorts numerically, so submission order doesn't affect item ordering.
params = Avram::Params.new({
  "name"              => "Employee One",
  "customers[0]:name" => "Customer One",
  "customers[1]:name" => "Customer Two",
})

params.many_nested("customers")
# => [{"name" => "Customer One"}, {"name" => "Customer Two"}]

Tests

  • Added unit tests in params_spec.cr covering form-key extraction, grouping, index ordering, and JSON-over-form-key precedence.
  • Added spec/avram/operations/nested_save_operation_real_params_spec.cr, an integration spec that builds a real Avram::Params (rather than the existing FakeDeeplyNestedParams test double) from both JSON and URL-encoded-style flat hashes, proving has_many-in-has_many, has_one-in-has_many, and has_many-in-has_one save correctly end-to-end, including a rollback case.
  • Added spec/lucky/ext/nested_operation_rendering_spec.cr covering the rendering side (correct name=/id= for forms, including deeply-nested cases).

Docs

  • Updated doc comments on Avram::Params#nested/#many_nested, the has_many/has_one macros, and FakeDeeplyNestedParams to describe the JSON and form-key conventions, validations, JSON request examples, and create-vs-update semantics.

Copilot AI and others added 7 commits August 19, 2026 03:17
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
…lper

Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
…Operations

Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Copilot AI and others added 4 commits August 19, 2026 13:04
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Copilot AI and others added 2 commits August 19, 2026 14:15
…mantics

Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Copilot AI and others added 2 commits August 19, 2026 16:39
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>
Co-authored-by: paulcsmith <22394+paulcsmith@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants