cicadel
Documentation

Formsets

Core FORM-03 battery for bounded collections of model forms and atomic inline related-object editing. PostgreSQL 18 and GORM are supported. This is a separate Go module, using experimental Cicadel app API 1 and the new forms/model API. The declared app version is v0.1.0; no published module release is claimed.

For local development, require github.com/fauzaanu/cicadel/contrib/formsets v0.0.0 and replace it with this directory; also replace the framework module with the repository root. Go does not inherit a dependency’s replace directives. Consumers must pin a real framework/module release once published. The generated consumer test verifies local source integration, not remote versioned installation.

Standard projects register formsets.App() in project/apps.go for inventory and provider validation. Retain this entry when adding local apps. The package owns no routes, schemas, assets, workers or migrations; removing it never deletes application data. The default project includes this module through the public installable contract.

Define and submit a collection

go
set, err := formsets.New[LineItem](db, formsets.Config[LineItem]{
    Model: model.Config[LineItem]{Fields: []model.Field{
        {Name: "Description", Required: true, MaxRunes: 200},
        {Name: "Quantity", Required: true},
    }},
    Prefix: "lines", MaxForms: 100, MinItems: 1, MaxItems: 50,
    AllowDelete: true,
})
// Handle configuration errors during setup.
collection := formsets.Inline[Order, LineItem](orderID, "OrderID",
    func(tx *gorm.DB) *gorm.DB { return tx.Where("owner_id = ?", userID) })
posted, err := forms.Parse(r)
// Handle parse failure; use body limits, authentication and CSRF middleware.
bound := set.Bind(posted.Values)
result, err := bound.Save(r.Context(), collection)

Use imports github.com/fauzaanu/cicadel/contrib/formsets, github.com/fauzaanu/cicadel/forms, and model "github.com/fauzaanu/cicadel/forms/model". Define parent/child models and recorded PostgreSQL migrations normally, including foreign keys and unique constraints. Never include the parent foreign key in editable fields. The parent identity is trusted route/application input; parent authorization is mandatory.

The URL-encoded wire format is:

text
lines-TOTAL_FORMS=3
lines-0-id=12
lines-0-description=Updated description
lines-0-quantity=2
lines-1-id=
lines-1-description=New item
lines-1-quantity=1
lines-2-id=15
lines-2-DELETE=on

Indexes are zero-based and contiguous. TOTAL_FORMS is required exactly once, canonical decimal, and limited before allocating rows. MaxForms defaults to 100 and must be between 1 and 1000. MaxItems defaults to MaxForms; it limits stored items, including existing rows omitted from this submission. MinItems defaults to zero. IDs must be canonical positive integers within the model key type; blank means new. Integer primary keys are supported; composite/UUID/string keys are explicitly rejected in this first formset contract.

Duplicate identities, repeated control values, malformed/out-of-range indexes, invalid typed input and unauthorized rows fail the whole batch. Unknown model fields cannot cause mass assignment. A deleted row bypasses ordinary required field validation; deleting an unsaved extra row simply discards it. Blank extra rows are otherwise validated normally. Omitting a stored row preserves it. Deletes are disabled unless AllowDelete is true. Boolean delete values are on, 1, true, 0, false, or empty. Prefixes are ASCII identifiers; editable input names cannot be id, DELETE, or contain hyphens.

Bound.Errors() returns nil or an independent *formsets.Errors snapshot. Rows[index] contains field messages (empty field key is row-wide); General contains collection messages. Values(index) and Len() support redisplay in your existing typed components. No widget/HTML generation is added. Save-time validation can also return *Errors; other errors are operational and must not be shown verbatim. Parent authorization failure is gorm.ErrRecordNotFound.

Atomic persistence

Inline locks the authorized parent FOR UPDATE, even for empty collections. Existing submitted child identities are reloaded and locked in key order under the parent scope before any writes. Item limits are calculated from the stored collection, not a client-provided initial count. Explicit deletes run first, then creates/updates run in submission order. This permits replacing a deleted unique value. Swapping two surviving unique values is not implicitly staged; PostgreSQL constraints decide whether it is valid.

Every save/delete, GORM hook, model-form check/hook, and formset hook uses the same outer transaction. Any failure rolls back the entire batch. Results are returned only after commit: Saved contains persisted rows in submission order; Deleted contains removed IDs. GORM soft-delete behavior and authored database cascade rules apply; this package does not invent cascades.

BeforeDelete and AfterDelete accept (context.Context, *gorm.DB, *T). Changing deletion identity is rejected. Validate(context.Context, *gorm.DB) is a final cross-row validation hook: query the transaction and return *Errors for safe collection messages. It should be read-only. Model hooks cannot move saved rows outside the collection; persisted ownership fields are checked. Limits are also rechecked after hooks. Use the supplied transaction for audit or join-table writes; defer external side effects until commit or use an outbox.

For other collection structures, supply a Collection[T] with a trusted Lock, Scope, Seed and Protected field names. Lock must serialize all membership changes (including insertions into an empty collection); Scope filters rows on the supplied transaction; Seed supplies trusted initial values. All writers—including code outside this package—must honor the same collection lock for count guarantees. Normal database deadlock/serialization errors remain operational errors; callers may retry the complete operation. Repeated submissions are separate operations, not implicit idempotent requests. Do not construct sets on an existing transaction; the public Save result assumes ownership of the outer commit.

The core’s Form.InTransaction(tx) is the general composition seam used here. Individual model-form results inside that transaction remain provisional until the outer commit. It accepts PostgreSQL transaction handles only and retains the original model/naming configuration.

Verification and boundaries

Tests cover malformed management data, bounded allocation, identity tampering, scoped parent/child ownership, mixed add/edit/delete, omitted-row preservation, zero values, replacement under uniqueness, limits, concurrent additions, and rollback including deletion/audit hooks. A generated independent project registers the app and exercises the API through HTTP form submissions. Lifecycle checks verify removal does not perform schema/data cleanup. Tests create isolated SQL fixtures, not AutoMigrate-managed schemas.

This completes FORM-03 for the documented integer-key collection contract. Nested inline collections, automatic relation/widget inference, client-side row editors, string/composite identities are excluded. FORM-01 widgets/validators are complete; see forms completion. See TESTING.md for reproducible checks.

Model.VersionField enables per-row _version validation for edits and deletes. IgnoreEmptyNew optionally discards wholly empty new rows. AdminInline[P,T] provides a typed inline editor and transactional adapter for admin.Config[P]. Its parent authorization, ownership and child validation are covered by admin_test.go. See the complete forms guide for configuration.

↑

Type to search this version.