# 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](https://cicadel.fauzaanu.com/en/dev/topics/migrations/index.md) before you use a form. Its exported API is
part of the [stable surface](https://cicadel.fauzaanu.com/en/dev/releases/releases/index.md#stability-guarantee).

## Ask your agent

```text
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

```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

| 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.

- `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 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 `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](https://cicadel.fauzaanu.com/en/dev/ref/formsets/index.md). 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.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](https://cicadel.fauzaanu.com/en/dev/howto/transactions/index.md).

## 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](https://cicadel.fauzaanu.com/en/dev/topics/forms-completion/index.md) for the full form flow and admin
integration.
