Documentation
¶
Overview ¶
Package crud @notice Five generic helpers for the bodies of go-pg-backed gqlgen resolvers.
@dev The point of them is the error classification, not the query. Writing db.ModelContext(ctx, u).Insert() was never hard; knowing that a duplicate key must become a *luimaerr.CustomError or luimaerr.PresentError will redact it to "internal server error" is the part that takes a production incident to learn.
Every helper takes orm.DB rather than *pg.DB, so *pg.DB, *pg.Conn and *pg.Tx all satisfy it — pass the tx inside db.RunInTransaction(...) and nothing else changes.
Your model carries the schema in its go-pg tags, and three things about it are load-bearing:
type User struct {
tableName struct{} `pg:"app_users"`
PersonalID string `pg:"personal_id,pk"` // a pk is mandatory: Get/Update/Delete call WherePK
Name string `pg:"name"` // exported: gqlgen, go-pg and encoding/json all read it
Projects []string `pg:"projects,array"` // ,array or go-pg encodes it as JSONB and text[] rejects it
}
Index ¶
- func Create[T any](ctx context.Context, db orm.DB, m *T, label string, ...) (*T, error)
- func Delete[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (bool, error)
- func Get[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (*T, error)
- func List[T any](ctx context.Context, db orm.DB, opts ...func(*orm.Query) *orm.Query) ([]*T, error)
- func Update[T any](ctx context.Context, db orm.DB, m *T, label string, ...) (*T, error)
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Create ¶
func Create[T any](ctx context.Context, db orm.DB, m *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)
Create @notice Inserts m and returns the stored row.
@dev A unique violation (23505) becomes a *CustomError, which is what makes it reach the client at all; every other driver error is returned bare and PresentError redacts it.
Absence is the exception, and it is the one this used to get wrong — see below.
RETURNING * is deliberate, and is the one place this differs from the server it was lifted from. That server could skip it after auditing its single table for defaults, triggers and generated columns. luima serves tables it has never seen: without RETURNING, a table with a DEFAULT now(), a BEFORE INSERT trigger, an identity column or a generated column makes this answer with the value the client *sent* rather than the value Postgres *stored* — silently, forever, with a passing test suite. It is the same statement and the same round trip.
An insert the database declined to perform is absence, not an error: (nil, nil), the same convention as Get. Two things reach it. q.OnConflict("DO NOTHING") is the one the caller asks for — and the reason the opts exist here at all, since a suppressed conflict does not abort the surrounding transaction where a real 23505 does, making it the only way to attempt an insert inside one without risking the whole thing. A BEFORE INSERT trigger returning NULL is the one the caller does not: it is the ordinary way to write a soft-ignore, and it needs no options.
Check the result. A caller who assumes non-nil nil-dereferences the second time the same key is inserted:
u, err := luima.Create(ctx, db, user, "user "+id, func(q *orm.Query) *orm.Query {
return q.OnConflict("DO NOTHING")
})
if err != nil { return nil, err }
if u == nil { /* it was already there */ }
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param m the model to insert; it is overwritten with the stored row and returned @param label names the thing in the conflict message — Create(ctx, db, u, "user "+id) yields "user E-1042 already exists" @param opts query modifiers applied left to right, after Returning("*") @return *T the stored row with defaults, triggers and generated columns applied, or nil if the insert was suppressed @return error a *luimaerr.CustomError with Code "CONFLICT" on 23505, nil on a suppressed insert, the bare driver error otherwise
func Delete ¶
func Delete[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (bool, error)
Delete @notice Removes the row with key's primary key, reporting whether one was there.
@dev Nothing to classify: absence is false, not an error.
The opts exist so authorization is expressible. luima ships no auth and does not intend to, but "no auth" and "you cannot write the WHERE clause auth needs" are different things — without a second predicate there is no way to say
DELETE FROM app_users WHERE personal_id = $1 AND owner_id = $2
short of dropping to raw go-pg and hand-rolling the SQLSTATE classification that this package exists to provide. So the helpers' happy path was IDOR by construction:
luima.Delete(ctx, r.DB, &model.User{PersonalID: id}, func(q *orm.Query) *orm.Query {
return q.Where("owner_id = ?", callerID(ctx))
})
A row that exists but is not yours then reports as absent, which is the right answer to give an unauthorized caller anyway — it leaks no existence.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param key a model with only its primary key populated @param opts query modifiers applied left to right, after WherePK @return bool true when a row was deleted, false when none matched @return error any driver error
func Get ¶
func Get[T any](ctx context.Context, db orm.DB, key *T, opts ...func(*orm.Query) *orm.Query) (*T, error)
Get @notice Selects one row by primary key.
@dev A missing row is (nil, nil), not an error, so a nullable GraphQL field renders as null. That translation exists because a single-row Select reports absence as pg.ErrNoRows — a list Select just comes back empty, which is why List never needs it.
The opts are what make an ownership predicate expressible — see Delete.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param key a model with only its primary key populated; it is filled in and returned @param opts query modifiers applied left to right, after WherePK @return *T the stored row, or nil when no row matched @return error any driver error other than pg.ErrNoRows
func List ¶
List @notice Selects rows, applying each opt to the query in order.
crud.List[model.User](ctx, r.DB, func(q *orm.Query) *orm.Query {
return q.Order("personal_id")
})
@dev Do order your lists. Postgres gives no stable row order without ORDER BY, so an unordered List produces intermittently reordered GraphQL responses that look like a caching bug.
One closure against go-pg's own documented API covers Where, Relation, Column, Limit, Offset and everything else, so the library ships no wrapper zoo of named options.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it Do bound them, too. Passing no options selects every row in the table, and that is a denial of service rather than a default: the rows are materialized into []*T, gqlgen marshals the whole response into memory, and the fasthttp adaptor buffers it once more before writing — three copies, no ceiling, reachable by anyone who can send `{ users { id } }`. ComplexityLimit does not help, because the row count is not an input to the complexity calculation; a list field costs the same whether it returns one row or ten million. Pagination is out of scope, so a q.Limit(n) in the resolver is what stands in for it.
@param opts query modifiers applied left to right; none means "select every row" — see above @return []*T the rows, never nil — an empty table yields an empty slice @return error any driver error
func Update ¶
func Update[T any](ctx context.Context, db orm.DB, m *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)
Update @notice Replaces every column of the row with m's primary key, and returns the stored row.
@dev A full replace: there are no partial updates. Update, not UpdateNotZero — UpdateNotZero skips zero-valued fields, so an empty []string could not clear an array column and an empty string could not clear a text column. That is not a partial-update feature, it is a silent data-retention bug. Real partial updates need nullable input fields and a Column allowlist, and are out of scope for v1.
Absence has two spellings here and which one you get depends on the RETURNING clause, so both are checked:
- A plain UPDATE *succeeds* with res.RowsAffected() == 0 when the WHERE matched nothing. Checking only errors.Is(err, pg.ErrNoRows) is a bug that stays invisible until someone updates a row that does not exist.
- With RETURNING *, go-pg is scanning a result set back into m, so zero rows comes back as pg.ErrNoRows instead. Checking only RowsAffected() would let that surface as a redacted "internal server error".
See Create for why RETURNING * is here.
The opts are the escape hatch from both of the above. q.Column(...) narrows the SET clause to the named columns, which is the partial update the full replace otherwise rules out — and it is the answer to the failure mode the full replace creates, where a column present on the struct but not set by your input mapper is written as its zero value on every update:
luima.Update(ctx, db, u, "user "+id, func(q *orm.Query) *orm.Query {
return q.Column("name", "email") // SET name = ?, email = ? — nothing else touched
})
And a q.Where(...) is what scopes the update to rows the caller owns; see Delete.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param m the complete model, primary key included; every column is written @param label names the thing in the not-found message @param opts query modifiers applied left to right, after WherePK @return *T the stored row @return error a *luimaerr.CustomError with Code "NOT_FOUND" when no row matched, the bare driver error otherwise
Types ¶
This section is empty.