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
Read https://cicadel.fauzaanu.com/en/dev/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
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
| Error | Meaning |
|---|---|
modelform.Errors | Safe display messages, keyed by field. The empty key "" is a form-wide error. |
gorm.ErrRecordNotFound | The target is missing or outside the authorized scope. |
| Any other error | An 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.
Nameis the Go model field name.Inputdefaults 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 type | Accepted input |
|---|---|
| Strings | Whitespace and Unicode are kept. Required tests trimmed emptiness. MaxRunes counts Unicode code points. Invalid UTF-8 is rejected. |
| Signed and unsigned integers, including named types | Decimal, within the destination type’s overflow limits. Negative input for an unsigned type fails. |
| Floating point | NaN, infinity and overflow are rejected. |
| Booleans | true, 1, on, false, 0 and empty. A missing checkbox is false. |
time.Time | RFC3339 timestamps with an explicit offset, normalized to UTC. Locale-specific dates and naive timestamps are not inferred. |
| A single pointer to a supported type | Empty input becomes nil. |
| Anything else | Set 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
Requiredis 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
| Hook | Signature | Use it for |
|---|---|---|
Field.Check | (context.Context, *gorm.DB, any) error | Validating one field. |
Config.BeforeSave | (context.Context, *gorm.DB, *T) error | Cross-field validation. Setting trusted server-owned fields. |
Config.AfterSave | (context.Context, *gorm.DB, *T) error | Audit records and explicit join-table changes, in the same transaction. |
- Return
modelform.Errorsfor safe messages. Return any other error to abort. - Checks run in definition order and stop at the first error.
BeforeSavecannot 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
Boundis not a one-use token. RepeatedCreatecalls 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. Thendatabase.OnCommithooks 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.
- Render
Form.Versionin a hidden_versionfield. - On save, a stale revision returns
ErrConflictand writes nothing.
See forms and validation for the full form flow and admin integration.