Model forms ¶
forms/model validates submitted fields and saves GORM models in PostgreSQL. It is experimental, like the framework’s other public APIs.
It does not create tables or change migrations. Declare constraints in recorded
migrations before using a form.
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.Definition and binding ¶
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)
})The create and update calls above are alternative handler paths. Check every
returned error. Use errors.As(err, &validation) with var validation modelform.Errors for safe display messages. The empty key is a form-wide error;
gorm.ErrRecordNotFound means the target is missing or outside the authorized
scope. Other errors belong in server-side handling, not response bodies. A save
returns a model only on success. Database work follows the request context.
Name is the Go model field name. Input defaults to GORM’s database column
name, respecting column tags and the configured naming strategy. There is no
implicit “all model fields” mode. Unknown submitted keys are ignored, allowing
CSRF fields and submit buttons without permitting mass assignment. Duplicate
values for an editable field are rejected rather than choosing one arbitrarily.
The binder copies submitted input, and Values() returns a copy. Definitions
can be shared; parser/check/hook closures must themselves be concurrency-safe.
Tenant-specific closures must not be shared across tenants.
Supported conversion ¶
- Strings preserve whitespace and Unicode. Required tests trimmed emptiness; maximum length counts Unicode code points. Invalid UTF-8 is rejected.
- Signed and unsigned integers, including named types, use decimal conversion with the destination type’s overflow limits. Negative unsigned input fails.
- Floating point values reject NaN, infinity and overflow. Use a custom parser and a decimal model type for exact money; floats do not provide decimal precision.
- Booleans accept
true,1,on,false,0and empty. A missing checkbox is false. Required means a nonempty submission, not “must be true”; use a check for consent requirements. time.Timeaccepts RFC3339 timestamps with explicit offsets and normalizes to UTC. Locale-specific dates and naive timestamps are not inferred.- A single pointer to a supported type maps empty input to nil. Empty nonpointer numeric/bool fields map to zero; empty nonpointer timestamps are invalid.
Field.Parsesupports custom column types and normalization. Return exactly the field’s Go type, including its pointer or named type. Parser failures are displayed as a generic conversion error; parser internals never become UI copy.
Binding is a full form submission, not PATCH: missing editable fields become
zero/nil or fail Required. Binding errors prevent any transaction or hook from
running. Errors() checks conversion only; database checks run at save time.
Parsing may run more than once, so parsers must be pure. Primary keys,
associations, read-only columns and automatic timestamps cannot be input fields.
Models require a single primary key. Unsupported column types require an explicit
parser. No automatic widget generation or schema-derived validation policy is
claimed: define Required/MaxRunes and business checks explicitly.
Relationships, constraints and hooks ¶
Related[R](inputName, scope) is a Field.Check for a scalar foreign key or
nullable scalar key. Its required scope expresses the caller’s permitted choices.
It takes a PostgreSQL share lock on the matching related record for the duration
of the transaction, preventing deletion or changes while the form is saved.
An unavailable or unauthorized choice produces the same field error. A nullable
key accepts empty input unless Required is set. Foreign-key constraints remain
necessary even when using this helper.
Custom field checks receive (context.Context, *gorm.DB, any); custom cross-field
validation belongs in BeforeSave(context.Context, *gorm.DB, *T). Return
modelform.Errors for safe messages, or an operational error to abort. Checks
run in definition order and stop at the first error. BeforeSave can set trusted
server-owned fields but cannot change the primary key. AfterSave can write audit
records or explicit join-table changes using the same transaction. Both
framework hooks and normal GORM model hooks participate in rollback.
Automatic association saving is disabled. Inline editing and formsets are now available in the first-party formsets module. Automatic relationship inference remains excluded. Explicit join changes can be made in AfterSave; authorization and validation for those changes belong to that hook. No relationship policy is inferred from GORM tags.
PostgreSQL constraints are the final authority for uniqueness, including simultaneous submissions. Duplicate keys become a safe form-wide error; foreign key, check and not-null violations have generic messages. This deliberately avoids leaking constraint names or values and supports composite uniqueness without a misleading preflight query. Custom friendly field messages can be implemented in checks, but must not replace database constraints.
Transaction and authorization contract ¶
Updates use SELECT ... FOR UPDATE on the scoped target and reload its current
state before binding. Concurrent saves serialize on the target row; server-owned
fields cannot be overwritten by a stale caller copy. All mutable scalar columns
on the freshly loaded model are persisted, 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/database
default semantics; custom primary-key assignment can use a trusted GORM create
hook. The supplied create seed itself must have a zero primary key.
Use a plain database handle, not an existing transaction, when defining a form. Scope functions are trusted query filters; keep them on the supplied transaction and model, without changing table, selection, connection or context. In hooks, use only the supplied transaction for dependent writes. A failure in binding, validation, persistence, hooks or commit returns no saved model. Do not perform external side effects in hooks: database rollback cannot undo sent mail. Publish external effects after success or use a transactional outbox. Hook code must also avoid mutating caller-owned reference values in create seeds.
Authorization, CSRF, request size limits, rendering, retries and idempotency remain explicit application concerns. A Bound is not a one-use token: repeated Create calls represent separate attempts. PostgreSQL deadlocks, serialization failures, connection failures and cancellation remain operational errors for the caller. Use a consistent relationship-check order across forms to reduce lock conflicts.
Multiple saves and stale edits ¶
For explicit multi-form transactions, Form.InTransaction(tx) returns a definition
using an existing PostgreSQL transaction. Its save results remain provisional
until the outer transaction commits. The caller must use the same database and
naming configuration and owns the outer commit/rollback.
Set Config.VersionField to an application-owned int64 revision column to reject
stale edits. Render Form.Version as _version; conflicts return ErrConflict
and no writes. See complete forms for adoption and admin
integration.