cicadel

Model forms

forms/model validates submitted fields and saves GORM models in PostgreSQL. Use it for create and edit forms backed by a model.

It does not create tables or change migrations. Declare constraints in recorded migrations before you use a form. Its exported API is part of the stable surface.

#Ask your agent

Text
Read https://cicadel.fauzaanu.com/en/0.2.7/topics/model-forms/index.md.
Implement create and edit forms for [model].
Allow only the intended editable fields, set owner values on the server,
and scope updates to authorized records. Handle validation, conflicts and
database errors. Test that another user cannot edit the record.

#Define, bind and save

Go
import (
    "github.com/fauzaanu/cicadel/forms"
    modelform "github.com/fauzaanu/cicadel/forms/model"
    "gorm.io/gorm"
)

// Define once with an ordinary, unscoped PostgreSQL *gorm.DB.
articleForm, err := modelform.New[Article](db, modelform.Config[Article]{
    Fields: []modelform.Field{
        {Name: "Title", Required: true, MaxRunes: 200},
        {Name: "Published"},
        {Name: "CategoryID", Required: true,
            Check: modelform.Related[Category]("category_id", func(tx *gorm.DB) *gorm.DB {
                return tx.Where("tenant_id = ?", tenantID)
            })},
    },
})
// Handle err during application setup (or request setup for tenant-specific forms).

// In a handler, after authentication, body limiting and CSRF middleware:
posted, err := forms.Parse(r)
// Handle parse failure before accessing posted.
bound := articleForm.Bind(posted.Values)
if errors := bound.Errors(); len(errors) != 0 {
    // Redisplay fields with these errors and bound.Values().
    return
}

// New records receive trusted server values separately from submitted input.
created, err := bound.Create(r.Context(), Article{TenantID: tenantID, OwnerID: userID})

// Editing loads and locks the authorized record. ID is parsed from the route.
updated, err := bound.Update(r.Context(), articleID, func(tx *gorm.DB) *gorm.DB {
    return tx.Where("tenant_id = ? AND owner_id = ?", tenantID, userID)
})

Create and Update are alternative handler paths. Check every returned error.

#Handle errors

ErrorMeaning
modelform.ErrorsSafe display messages, keyed by field. The empty key "" is a form-wide error.
gorm.ErrRecordNotFoundThe target is missing or outside the authorized scope.
Any other errorAn operational failure. Handle it on the server, not in response bodies.

Get display messages with var validation modelform.Errors and errors.As(err, &validation).

A save returns a model only on success. Database work follows the request context.

#Allowed fields

You list every editable field. There is no “all model fields” mode.

  • Name is the Go model field name.
  • Input defaults to GORM’s column name. It respects column tags and the configured naming strategy.
  • Unknown submitted keys are ignored. CSRF fields and submit buttons pass through without allowing mass assignment.
  • Duplicate values for an editable field are rejected. The form never picks one arbitrarily.
  • The binder copies submitted input. Values() returns a copy.

You can share a definition between requests. Parser, check and hook closures must be safe for concurrent use. Do not share tenant-specific closures across tenants.

#Supported conversion

Go typeAccepted input
StringsWhitespace and Unicode are kept. Required tests trimmed emptiness. MaxRunes counts Unicode code points. Invalid UTF-8 is rejected.
Signed and unsigned integers, including named typesDecimal, within the destination type’s overflow limits. Negative input for an unsigned type fails.
Floating pointNaN, infinity and overflow are rejected.
Booleanstrue, 1, on, false, 0 and empty. A missing checkbox is false.
time.TimeRFC3339 timestamps with an explicit offset, normalized to UTC. Locale-specific dates and naive timestamps are not inferred.
A single pointer to a supported typeEmpty input becomes nil.
Anything elseSet Field.Parse.

Empty input for a non-pointer numeric or bool field becomes zero. Empty input for a non-pointer timestamp is invalid.

Warning

Floats do not give decimal precision. For exact money, use a custom parser and a decimal model type.

Required means a nonempty submission, not “must be true”. Use a check for consent requirements.

Field.Parse handles custom column types and normalization:

  • Return exactly the field’s Go type, including its pointer or named type.
  • Parser failures show a generic conversion error. Parser internals never become UI copy.
  • Parsing may run more than once, so parsers must be pure.

#Binding rules

  • Binding is a full form submission, not PATCH. Missing editable fields become zero or nil, or fail Required.
  • Binding errors stop the save before any transaction or hook runs.
  • Errors() checks conversion only. Database checks run at save time.
  • Primary keys, associations, read-only columns and automatic timestamps cannot be input fields.
  • Models need a single primary key.
  • Unsupported column types need an explicit parser.

Cicadel does not generate widgets or derive validation from the schema. Set Required and MaxRunes, and write business checks, yourself.

#Relationships

Related[R](inputName, scope) is a Field.Check for a scalar foreign key or a nullable scalar key.

  • The required scope lists the choices the caller may make.
  • It takes a PostgreSQL share lock on the matching related record until the transaction ends. The record cannot be deleted or changed while the form saves.
  • An unavailable choice and an unauthorized choice give the same field error.
  • A nullable key accepts empty input unless Required is set.

Keep foreign-key constraints in the database even when you use this helper.

Automatic association saving is off. Cicadel infers no relationships or relationship policy from GORM tags. For multiple rows and inline editing, use the first-party formsets module. For explicit join-table changes, use AfterSave. That hook then owns authorization and validation for those changes.

#Checks and hooks

HookSignatureUse it for
Field.Check(context.Context, *gorm.DB, any) errorValidating one field.
Config.BeforeSave(context.Context, *gorm.DB, *T) errorCross-field validation. Setting trusted server-owned fields.
Config.AfterSave(context.Context, *gorm.DB, *T) errorAudit records and explicit join-table changes, in the same transaction.
  • Return modelform.Errors for safe messages. Return any other error to abort.
  • Checks run in definition order and stop at the first error.
  • BeforeSave cannot change the primary key.
  • Framework hooks and normal GORM model hooks both take part in rollback.
  • In hooks, use only the supplied transaction for dependent writes.
  • Do not mutate caller-owned reference values in create seeds.

Warning

Database rollback cannot undo sent mail. Do not perform external side effects in hooks. Register them with database.OnCommit(tx, ...), which runs them only after the outermost transaction commits, or use a transactional outbox. See transactions.

#Constraints

PostgreSQL constraints are the final authority for uniqueness, including simultaneous submissions.

  • A duplicate key becomes a safe form-wide error.
  • Foreign-key, check and not-null violations get generic messages.

This avoids leaking constraint names or values. It also supports composite uniqueness without a misleading preflight query. You can add friendlier field messages in checks, but they must not replace database constraints.

#Transactions and authorization

Update runs SELECT ... FOR UPDATE on the scoped target. It reloads the current state before binding.

  • Concurrent saves on the same row run one after another.
  • A stale caller copy cannot overwrite server-owned fields.
  • Every mutable scalar column on the freshly loaded model is saved, including trusted hook changes and zero values.
  • Updates never fall back to inserts.
  • GORM’s normal soft-delete and field-permission behavior applies.

Create uses GORM’s normal model and database default semantics. The create seed must have a zero primary key. To assign custom primary keys, use a trusted GORM create hook.

Define the form with a plain database handle, not an existing transaction. Scope functions are trusted query filters. Keep them on the supplied transaction and model. Do not change the table, selection, connection or context.

A failure in binding, validation, persistence, hooks or commit returns no saved model.

Your application still owns authorization, CSRF, request size limits, rendering, retries and idempotency.

  • A Bound is not a one-use token. Repeated Create calls are separate attempts.
  • Deadlocks, serialization failures, connection failures and cancellation are operational errors for the caller.
  • Check relationships in the same order across forms to reduce lock conflicts.

#Multiple saves in one transaction

Form.InTransaction(tx) returns a definition that uses an existing PostgreSQL transaction. Its save results stay provisional until the outer transaction commits.

  • Use the same database and naming configuration.
  • You own the outer commit or rollback.
  • Start the outer transaction with database.Atomic. Then database.OnCommit hooks registered by the saves run after the outer commit.
  • A failed save discards its own hooks.

#Stale edits

Set Config.VersionField to an application-owned int64 revision column. The form then rejects stale edits.

  1. Render Form.Version in a hidden _version field.
  2. On save, a stale revision returns ErrConflict and writes nothing.

See forms and validation for the full form flow and admin integration.

Type to search this version.