crud

package
v0.0.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 16, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package crud provides runtime helpers used by the per-service handlers_crud_gen.go file forge generates from a service's CRUD RPCs.

Pattern

Forge's CRUD generator emits a thin per-RPC shim that delegates to one of HandleCreate, HandleGet, HandleList, HandleUpdate, or HandleDelete. The shim carries the only things forge can know that this library cannot:

  • The RPC signature (req/resp connect types).
  • The proto -> entity field copy for Create.
  • The repository call site (db.<Name> function).
  • The response packing (proto field that holds the entity).

Everything else — auth check, tenant check, error mapping, cursor encoding/decoding, pagination clamping, list bookkeeping — moves into this package.

Auth and tenant

The library is decoupled from the per-project middleware package. Auth and tenant checks are expressed as closures the shim passes in. This avoids a hard import dependency on the user project's middleware surface (which is itself generated and project-specific).

In a generated shim, the auth closure looks like this:

func(ctx context.Context) error {
    claims, err := middleware.GetUser(ctx)
    if err != nil { return err }
    return s.deps.Authorizer.Can(ctx, claims, middleware.ActionCreate, "user")
}

The library invokes the closure (if non-nil) and returns its error wrapped as connect.CodePermissionDenied — preserving the previous generated behaviour.

Behavioural fingerprint

The pre-existing per-method generator wrote three observable strings:

  • "<op> <entity_lower>: <wrapped error>" for Create/Get/List/Update/Delete.
  • "invalid page token" for an undecodable PageToken.
  • "<op> <entity_lower>: <field> is required" when an Update request has a nil entity field.

All three are preserved verbatim by this package and locked by tests in [crud_test.go].

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func HandleCreate

func HandleCreate[Req, Resp, Ent any](op CreateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)

HandleCreate runs the canonical Create lifecycle:

auth -> tenant -> build entity -> persist -> pack response.

All error-mapping is fixed; the shim only carries data shape.

func HandleDelete

func HandleDelete[Req, Resp any](op DeleteOp[Req, Resp]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)

HandleDelete runs auth -> tenant -> persist -> empty response.

func HandleGet

func HandleGet[Req, Resp, Ent any](op GetOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)

HandleGet runs auth -> tenant -> fetch -> pack. Repository errors are mapped to CodeNotFound; the legacy generator did the same.

func HandleList

func HandleList[Req, Resp, Ent any](op ListOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)

HandleList runs auth -> tenant -> page/order/filter assembly -> repository call -> trim -> pack.

func HandleUpdate

func HandleUpdate[Req, Resp, Ent any](op UpdateOp[Req, Resp, Ent]) func(context.Context, *connect.Request[Req]) (*connect.Response[Resp], error)

HandleUpdate runs auth -> tenant -> validate-required -> persist -> pack.

AIP-134 update_mask semantics (when op.Mask is wired):

  • mask absent/empty, or containing "*" → full-object replace via op.Persist. AIP-134 permits full replacement when the behavior is documented — this is that documentation. Callers that want a partial update MUST send a mask.
  • mask with concrete paths → op.PersistMasked writes only those fields. Paths are proto field names (snake_case, == column names).
  • unknown or immutable path → CodeInvalidArgument naming the path (mapped from orm.UnknownFieldError by mapRepoErr).

After a masked write the response echoes the request entity: masked fields hold their new values, unmasked fields hold whatever the caller sent (NOT necessarily the stored values). Re-read with Get for the authoritative row.

Types

type Authorize

type Authorize func(ctx context.Context) error

Authorize is a hook the shim passes in to perform the per-RPC authorization check. The library runs it before the repository call and wraps its error as connect.CodePermissionDenied.

Returning nil allows the call. Returning a non-nil error fails the call. The hook may be nil — in which case authorization is skipped (mirroring methods declared without auth_required: true in proto).

type CreateOp

type CreateOp[Req, Resp, Ent any] struct {
	EntityLower string
	Auth        Authorize
	Tenant      RequireTenant
	Entity      func(req *Req) Ent
	Persist     func(ctx context.Context, tenantID string, entity Ent) error
	Pack        func(entity Ent) *Resp
}

CreateOp wires the per-RPC concerns of a Create handler.

Create returns the constructed entity in the response. The shim supplies:

  • Entity — proto request -> internal entity constructor.
  • Persist — repository call. tenantID is empty when Tenant is nil.
  • Pack — internal entity -> connect.Response.
  • EntityLower — lowercase entity name used in the error envelope.
  • Auth/Tenant — optional hooks.

type DeleteOp

type DeleteOp[Req, Resp any] struct {
	EntityLower string
	Auth        Authorize
	Tenant      RequireTenant
	ID          func(req *Req) string
	Persist     func(ctx context.Context, tenantID string, id string) error
	// Pack is optional. When nil, HandleDelete returns the proto's
	// zero-value response (matching the legacy DeleteResponse{} shape).
	Pack func() *Resp
}

DeleteOp wires the per-RPC concerns of a Delete handler.

type GetOp

type GetOp[Req, Resp, Ent any] struct {
	EntityLower string
	Auth        Authorize
	Tenant      RequireTenant
	ID          func(req *Req) string
	Fetch       func(ctx context.Context, tenantID string, id string) (Ent, error)
	Pack        func(entity Ent) *Resp
}

GetOp wires the per-RPC concerns of a Get handler.

type ListOp

type ListOp[Req, Resp, Ent any] struct {
	EntityLower  string
	Auth         Authorize
	Tenant       RequireTenant
	PkColumnName string // empty disables PK-cursor pagination

	// Columns is the entity's declared column allowlist (the generated
	// db.<Entity>Columns var). User-supplied order_by columns are
	// validated against it — identifier-shape validation alone lets an
	// undeclared column reach the database, where some engines silently
	// treat it as a constant (an ordering no-op).
	Columns []string

	// Pagination knobs. The library applies the same defaults the legacy
	// template did when these are zero.
	HasPagination   bool
	DefaultPageSize int // 0 -> 50
	MaxPageSize     int // 0 -> 100

	// HasOrderBy enables req.Msg.OrderBy / req.Msg.Descending handling
	// via the OrderBy/Descending closures.
	HasOrderBy bool
	OrderBy    func(req *Req) (clause string, descending bool)

	// Filters returns extra orm.QueryOption values built from per-field
	// filter logic. The shim implements this as a static sequence of
	// "if req.Msg.X != nil { opts = append(opts, orm.WhereILike(...)) }"
	// statements — same as the legacy template, just lifted into a
	// closure.
	Filters func(req *Req) []orm.QueryOption

	// PageToken / PageSize accessors. PageSize is clamped by the
	// library; PageToken is decoded by the library.
	PageToken func(req *Req) string
	PageSize  func(req *Req) int

	// Query runs the repository call. tenantID is "" when Tenant is nil.
	// Returns slice + error; the library handles the +1 fetch and
	// trim-to-pageSize.
	Query func(ctx context.Context, tenantID string, opts []orm.QueryOption) ([]Ent, error)

	// EntityID extracts the cursor key from the last-of-page entity.
	// Required when HasPagination is true.
	EntityID func(entity Ent) string

	// Pack receives the trimmed item slice and the next page token (empty
	// when no further page). Shim assembles the response with the right
	// repeated-field name.
	Pack func(items []Ent, nextPageToken string) *Resp
}

ListOp wires the per-RPC concerns of a List handler. Pagination, filter, and order-by bookkeeping live in the library; the per-RPC shim still provides the per-field filter -> orm.QueryOption mapping (this is data, not lifecycle, and reflection-free Go can't generalize it without a code-gen table).

type Repo

type Repo[M any] struct {
	// contains filtered or unexported fields
}

Repo is the generic data-access layer over a Bun-tagged model M. One Repo per entity replaces the ~250 LOC of per-entity Create/Get/List/ Count/ListAll/Update/UpdateMasked/Delete the generator used to emit; the generated code now supplies only the Bun-tagged struct, the ToProto/ FromProto pair, and a single crud.NewRepo[Model](Spec{...}) line.

All lifecycle semantics the pre-generic code carried are preserved exactly: tenant scoping, Bun-native + legacy-TEXT soft delete, the deleted_at IS NULL update-guard (Bun auto-scopes SELECT/DELETE to live rows but NOT UPDATE), AIP-134 masked updates with an updatable allowlist → orm.UnknownFieldError, managed-timestamp stamping, server-allocated / ULID PKs, and array nil→{} normalization. The QueryOption escape hatch (orm.QueryOption func(*bun.SelectQuery)) is threaded straight through to List/Count, and bun.IDB (db.Bun()) remains the raw-SQL escape hatch.

func NewRepo

func NewRepo[M any](spec Spec) *Repo[M]

NewRepo constructs a Repo for model M. Metadata derivation is deferred to the first call (it needs a live bun.IDB to reach the dialect's table cache); spec carries the forge conventions Bun's schema can't infer.

func (*Repo[M]) Columns

func (r *Repo[M]) Columns(db orm.Context) []string

Columns is the entity's declared column allowlist — the value the List handler shim used to pull from the generated db.<Entity>Columns var and hand to pkg/crud for order_by validation. Derived from Bun's schema.

func (*Repo[M]) Count

func (r *Repo[M]) Count(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) (int64, error)

Count returns the number of matching rows under the same scope as List.

func (*Repo[M]) Create

func (r *Repo[M]) Create(ctx context.Context, db orm.Context, entity *M, tenantID string) error

Create inserts a new row. Plain INSERT, never an upsert: a duplicate PK is a real error. Chokepoint invariants (matching the pre-generic emitter): a string PK is ULID-generated when empty; a server-allocated integer PK is excluded from the INSERT and read back via RETURNING; managed timestamps are stamped; nil array fields are normalized to {}.

func (*Repo[M]) Delete

func (r *Repo[M]) Delete(ctx context.Context, db orm.Context, id any, tenantID string) error

Delete removes a row by PK. With Bun-native soft delete a plain NewDelete stamps deleted_at (Bun rewrites it to UPDATE) and auto-scopes to live rows. With legacy-TEXT soft delete the repo hand-rolls the CURRENT_TIMESTAMP stamp + deleted_at IS NULL guard (Bun's time.Time stamp can't round-trip a TEXT column). Otherwise it is a hard DELETE.

func (*Repo[M]) Get

func (r *Repo[M]) Get(ctx context.Context, db orm.Context, id any, tenantID string) (*M, error)

Get retrieves a row by primary key. A missing row satisfies errors.Is(err, orm.ErrNoRows). Tenant scope and the legacy-TEXT deleted_at filter are applied; Bun-native soft delete auto-excludes tombstones from the SELECT.

func (*Repo[M]) List

func (r *Repo[M]) List(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)

List retrieves rows with optional QueryOption filtering/ordering/limit. Tenant scope and the legacy-TEXT soft-delete filter are applied; Bun's ,soft_delete excludes tombstones natively.

func (*Repo[M]) ListAll

func (r *Repo[M]) ListAll(ctx context.Context, db orm.Context, tenantID string, opts ...orm.QueryOption) ([]*M, error)

ListAll retrieves rows INCLUDING soft-deleted ones (tenant scope still applies). Bun-native soft delete needs an explicit WhereAllWithDeleted to see tombstones; the legacy-TEXT path simply omits the deleted_at filter.

func (*Repo[M]) PkColumn

func (r *Repo[M]) PkColumn(db orm.Context) string

PkColumn is the primary-key column name (the List handler's cursor column). Derived from Bun's schema.

func (*Repo[M]) Update

func (r *Repo[M]) Update(ctx context.Context, db orm.Context, entity *M, tenantID string) error

Update writes the full updatable column set of an existing row by PK. updated_at is re-stamped under managed timestamps; created_at, the PK, the tenant key, and deleted_at are excluded from the SET clause. The deleted_at IS NULL guard applies to BOTH soft-delete modes: Bun auto-scopes SELECT/DELETE to live rows but NOT UPDATE, so without the guard an UPDATE could mutate a tombstoned row.

func (*Repo[M]) UpdateMasked

func (r *Repo[M]) UpdateMasked(ctx context.Context, db orm.Context, entity *M, fields []string, tenantID string) error

UpdateMasked writes ONLY the named columns (AIP-134 update_mask paths; proto field names == column names). Paths outside the updatable allowlist return *orm.UnknownFieldError, which pkg/crud maps to a clean InvalidArgument. updated_at is stamped on masked writes too.

type RequireTenant

type RequireTenant func(ctx context.Context) (string, error)

RequireTenant is a hook the shim passes in for tenant-scoped operations. The library invokes it after the auth check, expects a tenant ID string back, and forwards it into the repository call. The returned error is wrapped as connect.CodeUnauthenticated.

Methods on tenant-aware entities pass a real closure that delegates to the project's middleware.RequireTenantID. Methods on global entities pass nil and the library treats the call as un-tenanted.

type Spec

type Spec struct {
	TenantColumn        string
	Timestamps          bool
	LegacyTextDeletedAt bool
}

Spec is the IRREDUCIBLE per-entity descriptor the generator emits for a Repo. Everything Bun's own table schema can answer — table name, primary key column + Go field, autoincrement/server-allocated PK, the native (,soft_delete) deleted-at field, the full column set, array columns — is derived by reflection from the Bun-tagged model at first use (once per entity, never in the hot path). Spec carries ONLY the forge conventions Bun cannot infer from struct tags:

  • TenantColumn — the multi-tenancy scoping column ("" = global entity, no tenant scoping). Bun has no concept of a tenant key.
  • Timestamps — forge's managed created_at/updated_at stamping (set on Create, re-stamped on every Update/UpdateMasked). Distinct from a DB DEFAULT: forge stamps in Go so the value is identical across the row and visible to the caller without a re-read.
  • LegacyTextDeletedAt — soft delete whose deleted_at column is a legacy TEXT column (Go type string) that Bun's time.Time-based ,soft_delete cannot round-trip. When true the repo hand-rolls the deleted_at IS NULL filter and the CURRENT_TIMESTAMP stamp (kalshi fr-3fba9166ba). Bun-native soft delete (proper time deleted_at) is detected from the schema and needs no flag.

type UpdateOp

type UpdateOp[Req, Resp, Ent any] struct {
	EntityLower    string
	EntityFieldLow string // lowercase form of the proto field that holds the entity, e.g. "user"
	Auth           Authorize
	Tenant         RequireTenant
	Entity         func(req *Req) (entity Ent, ok bool)
	Persist        func(ctx context.Context, tenantID string, entity Ent) error
	Pack           func(entity Ent) *Resp

	// Mask extracts the AIP-134 update_mask paths from the request
	// (req.GetUpdateMask().GetPaths()). nil when the proto's update
	// request has no update_mask field — HandleUpdate then behaves
	// exactly as before this field existed (full replace via Persist).
	Mask func(req *Req) []string

	// PersistMasked writes ONLY the named fields (proto field names ==
	// column names, snake_case). The generator wires it whenever it
	// wires Mask. If Mask is set but PersistMasked is nil and a request
	// arrives with concrete paths, HandleUpdate fails CodeInternal —
	// silently widening a masked write to a full replace is the
	// data-loss bug this hook exists to prevent.
	PersistMasked func(ctx context.Context, tenantID string, entity Ent, fields []string) error
}

UpdateOp wires the per-RPC concerns of an Update handler. The shim supplies an EntityFromReq closure that returns (entity, ok). The library treats ok == false as "request missing entity" and returns CodeInvalidArgument with the same wording the legacy generator used.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL