Model Accessor
The generated REST routes expose a model over HTTP. Application code (action handlers, middlewares, event listeners, etc.) frequently needs to read or write a different model than the one the request targets. The model accessor is the in-process door for that work.
ctx.GetModel(name) returns an object exposing the five standard CRUD
operations bound to one registered model. Every call routes through the active
transaction, respects per-model database overrides, and returns
map[string]any rows without the HTTP round-trip. These are the same underlying
rows the REST layer reads, but keyed by DB column name and not run through
response marshalling — so hidden / writeonly columns are present and the
result is not the REST envelope shape. The main difference is the interaction
with middlewares, more on that below.
row, err := ctx.GetModel("User").Read(userID)
if err != nil {
return err
}
name := row["name"].(string)
The ModelAccessor interface
ctx.GetModel(name) yields a *ModelAccessor. All methods use
map[string]any, keyed by DB column name (each field’s mfx DB name), not the
JSON name — see Relationship to pipeline middleware
below.
| Method | Signature | Returns |
|---|---|---|
List | List(q *QueryParams) ([]map[string]any, error) | A page of rows |
Read | Read(id string) (map[string]any, error) | One row, or ErrNotFound |
Create | Create(data map[string]any) (map[string]any, error) | The stored row |
Update | Update(id string, data map[string]any) (map[string]any, error) | The updated row |
Increment | Increment(id string, deltas map[string]any) (map[string]any, error) | The updated row |
Delete | Delete(id string) error | — |
// List - q may be nil (page 1, limit 20, no filters or sorts).
admins, err := ctx.GetModel("User").List(&maniflex.QueryParams{
Filters: []*maniflex.FilterExpr{
{Field: "role", Operator: maniflex.OpEq, Value: "admin"},
},
Page: 1,
Limit: 100,
})
A FilterExpr you build in Go is not parsed the way a ?filter= is, but it is
resolved and normalised the same way once it reaches the query builder:
Fieldmay be either the column’s DB name or itsjsonname. A field the model does not have is a programming error, not client input, and aborts the request with500 INVALID_FILTERnaming the field. (The adapters also degrade such a filter to a false predicate, so a misspelt forced filter narrows to nothing rather than silently widening to everything.)Valueis coerced against the column like a URL value is: a realtime.Timeor*time.Timeis written in the canonical form the write path stores, and a boolean column accepts thetrue/falseand1/0spellings. You do not need to pre-format either.Valueforin,not_inandbetweenmay be a[]stringor[]anyas well as the comma-separated string a URL carries. A slice is taken as written, so a value containing a comma stays one element.Operatoris a bare string type, so a typo compiles. Use theOp*constants; an operator no adapter implements is refused rather than dropped.
filters := []*maniflex.FilterExpr{
// Both of these are correct, and both find the same rows.
{Field: "owner_id", Operator: maniflex.OpEq, Value: ownerID},
{Field: "ownerId", Operator: maniflex.OpEq, Value: ownerID},
// A time value needs no formatting.
{Field: "created_at", Operator: maniflex.OpLte, Value: time.Now()},
// A set may be a slice or the CSV spelling.
{Field: "status", Operator: maniflex.OpIn, Value: []string{"open", "held"}},
{Field: "status", Operator: maniflex.OpIn, Value: "open,held"},
}
// Create - returns the stored representation, id and defaults populated.
created, err := ctx.GetModel("User").Create(map[string]any{
"name": "Carol",
"email": "[email protected]",
"role": "viewer",
"password": "secret",
})
// Update - a partial patch; only the supplied keys are written.
updated, err := ctx.GetModel("User").Update(userID, map[string]any{
"role": "admin",
})
// Delete - soft-deletes when the model opts into soft delete, else hard-deletes.
err := ctx.GetModel("User").Delete(userID)
List accepts the same QueryParams the query
parser builds from ?filter= / ?sort= / ?page=; filters, sorts, and
pagination all apply. Read, Update, and Delete return
maniflex.ErrNotFound when the id is absent. Create and Update return a
*maniflex.ErrConstraint on unique or check violations, allowing DB errors to
be mapped to HTTP responses the same way the pipeline does (see
Error Handling).
Increment — arithmetic the database does
Use Increment wherever the new value is a function of the old one. Reading a
counter, adding in Go, and writing the sum back is a lost update waiting to
happen: two requests that read before either writes both store the same value,
one of the increments disappears, and both writes report success.
row, err := ctx.GetModel("Item").Increment(id, map[string]any{
"stock": -3, "reserved": 3,
})
Keys are DB column names, values are amounts to add — negative subtracts. All the columns move in one statement, so a transfer between two of them is never observable half-done and never left inconsistent by a partial failure.
Bounds are enforced in the same statement
“Decrement, but never below zero” is the shape most counters have, and checking
it in Go before calling Increment puts the race straight back. So the column’s
existing mfx:"min:" / mfx:"max:" bounds become conditions on the UPDATE
itself:
type Item struct {
maniflex.BaseModel
Stock int `json:"stock" db:"stock" mfx:"min:0"`
}
An increment that would cross a bound writes nothing and returns
maniflex.ErrIncrementOutOfBounds. That is deliberately distinct from
ErrNotFound, which the same zero-rows-matched result would otherwise be
indistinguishable from — one means “no such row” and the other means “not right
now”, and only the second is worth retrying:
switch {
case errors.Is(err, maniflex.ErrIncrementOutOfBounds):
ctx.Abort(http.StatusConflict, "OUT_OF_STOCK", "not enough stock")
case errors.Is(err, maniflex.ErrNotFound):
ctx.Abort(http.StatusNotFound, "NOT_FOUND", "no such item")
}
The guard applies to the value after the increment, so a positive delta is never judged against a minimum it is moving away from.
What it will not do
- Non-numeric or unknown columns are refused, rather than dropped. A silently
ignored column would bump
updated_at, report success, and leave the counter where it was. mfx:"encrypted"columns are refused. The database holds ciphertext; adding to it would produce a number unrelated to the plaintext.- It never falls back to read-then-write. An adapter without atomic increment
returns
maniflex.ErrIncrementNotSupported. A fallback would be correct under test and lossy under load, with no way for the caller to tell which they got.
Inside a transaction it routes through ctx.Tx like every other accessor call,
so it rolls back with the rest of the request. When you need to read other
fields under the same lock and compute from them, ctx.LockForUpdate is still
the tool — Increment replaces the read-then-write, not the row lock.
Errors surface on first use
GetModel never returns an error directly. When the name is not registered — or
the registry is not wired onto the context — it returns an error accessor that
surfaces the failure on the first method call rather than at construction:
_, err := ctx.GetModel("NoSuchModel").List(nil)
// err: maniflex: model "NoSuchModel" is not registered
This keeps call sites terse (ctx.GetModel("User").Read(id)) without a nil check
on the accessor; the error is handled at the method call as usual.
Transactions
Accessor operations route through ctx.Tx whenever a transaction is active, so
work performed through the accessor commits or rolls back atomically with the
rest of the request:
tx, err := ctx.BeginTx(ctx.Ctx, nil)
if err != nil {
return err
}
ctx.Tx = tx
defer tx.Rollback() // no-op after a successful Commit
if _, err := ctx.GetModel("Order").Create(order); err != nil {
return err // the deferred Rollback undoes everything
}
if _, err := ctx.GetModel("Inventory").Update(sku, dec); err != nil {
return err
}
return tx.Commit()
The accessor must be obtained after
ctx.Txis set. An accessor captures whateverctx.Txholds at the momentGetModelis called. Obtaining it beforeBeginTxleaves its writes outside the transaction — and under SQLite they can deadlock against the open tx. Always callctx.GetModel(...)afterctx.Tx = tx:ctx.Tx = tx orders := ctx.GetModel("Order") // bound to ctx.Tx — safe
Per-model database routing
When a model is pinned to a non-default adapter (see Database Backends), the accessor resolves that model’s own adapter, so a cross-model read reaches the correct database automatically. The request transaction, however, belongs to the request’s adapter. When the target model lives on a different adapter, the accessor cannot enlist that transaction and instead runs the operation outside it against the correct adapter. Within a single adapter, transactional routing behaves exactly as above.
Relationship to pipeline middleware
The accessor talks directly to the database adapter (through the active
transaction when one is set). It does not run the request pipeline. Response
transforms, dynamic redaction, field-visibility rules, and any other registered
middleware never observe an accessor read or write — they operate on
ctx.Response/ctx.Body, which the accessor does not populate.
The one exception is the model’s own value constraints. enum, min and
max are checked on accessor and typed writes just as they are on an HTTP
request, and a violation returns an error before anything reaches the database:
_, err := ctx.GetModel("Account").Create(map[string]any{"role": "superuser"})
// err: maniflex: Account: field "role" must be one of: [user admin]
Pass maniflex.SkipValidation() where the violation is deliberate — backfilling
rows that predate an enum, say:
_, err := ctx.GetModel("Account").Create(legacyRow, maniflex.SkipValidation())
readonly and immutable are not applied here, deliberately. Those tags
say a value may not come from a client, and the accessor is not a client —
stamping such a column from a background job is much of why these APIs exist.
The split is between data integrity, which is a bug whoever writes it, and
access control, which depends on who is asking.
Before v0.2.5 no constraint was checked on these paths, so the same model accepted a value in Go that it answered
422for over HTTP.
This is not a substitute for validating untrusted input. Value constraints
are the model’s own rules, not an authorization boundary: decoding a request
body into a struct and handing it to maniflex.Create still writes whatever
fields it contains, including readonly ones. Untrusted bodies belong on the
HTTP path, or must be checked before they reach here.
The practical consequence: an accessor returns the record as stored, before any response-layer shaping. Given a redacting transform on the Response stage —
s.Pipeline.Response.Register(
response.TransformField("secret", func(any) any { return "[redacted]" }),
maniflex.ForModel("Account"),
maniflex.AtPosition(maniflex.After),
)
— a REST read of Account returns "secret": "[redacted]", because the
transform rewrites ctx.Response.Data after the DB step. An accessor read does
not:
row, _ := ctx.GetModel("Account").Read(id)
row["secret"] // the raw stored value — NOT "[redacted]"
Two further differences follow from bypassing the response marshaller:
- Keys are DB column names. Accessor maps are keyed by each field’s
mfxDB name, not the JSON name that response middleware and the API envelope use. Where the two names differ, the DB name is the one present in the map. hiddenandwriteonlyfields are included. The response marshaller stripsmfx:"hidden"andmfx:"writeonly"columns from every payload; the accessor does not. Their raw values are present in accessor maps.
This is by design — the accessor is trusted, in-process access for application logic, not a client-facing surface. It does mean that sanitisation implemented in response middleware (redaction, field hiding, CDN rewriting) is not inherited by data read through the accessor. When accessor output is forwarded outside the process, that shaping is the caller’s responsibility.
Typed accessor: map[string]any → *T
The string-named accessor is dynamic — suited to cases where the model name is data, or where maps are the natural representation. When the type is known at compile time, the typed CRUD free functions provide the same five operations against concrete structs. They resolve the model from the type parameter, so no name string must be kept in sync:
users, err := maniflex.List[User](ctx, nil) // []*User
u, err := maniflex.Read[User](ctx, id) // *User
created, err := maniflex.Create(ctx, &User{Name: "Jane"}) // *User
updated, err := maniflex.Update(ctx, id, &User{Name: "J."}) // *User
err := maniflex.Delete[User](ctx, id)
These route through ctx.Tx, honour per-model adapters, and return the same
ErrNotFound / ErrConstraint errors as the string-named accessor — they are
its typed counterpart, not a separate code path.
One difference is significant: maniflex.Update[T] performs a full-record
update. Every writable column except id is written from the supplied struct,
so any zero-valued field overwrites the stored value. For a partial patch — only
the specified keys — use the map-based ctx.GetModel(name).Update(id, data)
instead.
Columns the model marks mfx:"readonly" or mfx:"immutable" are not among
them, and neither are id, created_at and updated_at. Those tags used to be
enforced only by the Validate step, which the typed helpers do not run, so a
caller building a fresh struct to change one field stamped the zero time over the
row’s real created_at and blanked every readonly column along with it. Note
this is a guard against accidental blanking on a full-struct update, not an
access rule: maniflex.Create writes readonly columns from the struct, and
ctx.GetModel(name).Update writes whatever key you name. To write such a column
deliberately, name it:
ctx.GetModel("User").Update(id, map[string]any{"origin": "import"})
An explicit key is a different statement of intent from a struct that happens to carry a zero value — which is the whole distinction the two accessors draw.
Choosing between the accessors
| Requirement | Accessor |
|---|---|
| Dynamic access where the model name is a variable | ctx.GetModel(name) |
| Concrete structs and compile-time field names | maniflex.List[T] / Read[T] / … |
| A partial patch (only some fields) | ctx.GetModel(name).Update(id, data) |
| A full-record replace from a struct | maniflex.Update[T](ctx, id, &rec) |
| Joins, aggregates, custom SQL | Raw Queries & Aggregates |
Both accessors serve in-process cross-model work inside a request. Exposing a model over HTTP is a matter of registering it and letting the generated routes handle it; the accessor is the tool a handler reaches for once execution is already inside the pipeline.