crud

package
v0.38.1 Latest Latest
Warning

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

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

Documentation

Index

Constants

View Source
const MaxBatchSize = 100

MaxBatchSize caps how many items a single _batch request may contain. The cap exists to bound transaction duration and protect against pathological payloads.

View Source
const MaxJSONBodyBytes int64 = 1 << 20 // 1 MiB

MaxJSONBodyBytes caps the size of a JSON request body the CRUD handlers will read. 1 MiB is large enough for any realistic single record or batch envelope, while bounding the memory an unauthenticated caller can pin per request.

View Source
const MaxMultipartMemory = 32 << 20 // 32 MiB

MaxMultipartMemory is the in-memory portion of a multipart upload before spilling to disk. Files larger than this still upload — they just stream through a temp file.

Variables

This section is empty.

Functions

func AuditPreImageAs added in v0.29.0

func AuditPreImageAs[T any](ctx context.Context) (T, bool)

AuditPreImageAs decodes the pre-update/pre-delete snapshot into T.

It reuses the exact casing translation typed hooks and typed queries already use (see UnmarshalEntity / unmarshalRowToStruct): the row's keys are normalized to camelCase before json.Unmarshal, so T's fields should carry ordinary camelCase `json:"..."` tags — the same tags a generated entity struct already has. This makes the pre-image and a typed hook's payload "speak the same language" regardless of the handler's configured JSONCase.

Returns the zero value and false when no pre-image was captured for this context, or when decoding into T fails (shape mismatch). Use AuditPreImageFromContext directly if you need to distinguish those cases or need the raw map.

func AuditPreImageFromContext

func AuditPreImageFromContext(ctx context.Context) map[string]any

AuditPreImageFromContext returns the pre-image set by doUpdate or doDelete, or nil when no snapshot was captured (legacy callers / async hooks).

Casing contract

The returned map is keyed by the handler's configured JSONCase — camelCase by default (e.g. "statusId"), NOT the underlying snake_case DB column name ("status_id"). This is the same casing every CRUD response uses, but it differs from the BeforeCreate/BeforeUpdate hook body, which the framework unconverts back to snake_case before hooks run. A hook that does `pre["status_id"]` against a default (camelCase) handler silently gets nothing back — no panic, no error, just a missing key — because the actual key is "statusId". Casing-identical keys (e.g. "version", "key") happen to work either way, which is what makes this easy to miss in review.

Two ways to avoid the mismatch:

  • AuditPreImageAs[T] decodes into a struct with camelCase json tags, matching the same shape typed hooks (framework.OnAfterUpdate etc.) already receive.
  • AuditPreImageSnakeFromContext returns the row re-keyed to snake_case DB column names, for callers that want map access without a struct.

func AuditPreImageSnakeFromContext added in v0.29.0

func AuditPreImageSnakeFromContext(ctx context.Context) map[string]any

AuditPreImageSnakeFromContext returns the pre-image re-keyed to snake_case DB column names, regardless of the handler's configured JSONCase. It applies the same casing.MapToSnake translation CrudHandler.unconvertMapKeys uses to turn a camelCase request body back into DB columns — no second translator, just the shared helper.

Prefer AuditPreImageAs[T] when a typed shape is available; this exists for hooks that want plain map[string]any access keyed the way every other hook-adjacent surface (columns, typed-query API, blueprint fields) already speaks.

Returns nil when no pre-image was captured for this context.

func AuditRequestFromContext

func AuditRequestFromContext(ctx context.Context) *http.Request

AuditRequestFromContext returns the *http.Request that CRUD attached, or nil if none was set (e.g. async hook fired outside a request).

func EagerLoad

func EagerLoad(ctx context.Context, db DBExecutor, ent *entity.Entity, relations []entity.Relation, ids []string, reg ...entity.Registry) (map[string]map[string]any, error)

EagerLoad fetches related data for a set of parent IDs in batched queries, avoiding N+1 problems. Returns a map keyed by parent ID to relation name to related data.

SECURITY: when an optional entity.Registry is supplied, each relation's target entity is resolved and the same scrubbing the live include path applies (eager_filtered.go) is applied here too — soft-deleted target rows are excluded (`deleted_at IS NULL`) and Hidden columns (e.g. password_hash) are never populated. Without a registry the target schema is unknown, so no per-target scrub can be applied; callers that load relations whose targets are soft-deletable or carry Hidden fields MUST pass the registry.

func EntityLLMMD

func EntityLLMMD(ent *entity.Entity) string

EntityLLMMD generates an LLM-friendly markdown document describing all CRUD endpoints for a single entity. The output is designed to be immediately useful as context for an LLM agent — concise, structured, and example-rich.

The document covers:

  • Resource overview (entity name, table, primary key)
  • List endpoint with filter operators, pagination (offset + cursor), includes
  • Get by ID with includes
  • Create with required/writable fields
  • Update with writable fields
  • Delete
  • Batch create / update / delete
  • SSE event stream
  • Custom endpoints declared on the entity

func IsNotFound

func IsNotFound(err error) bool

IsNotFound reports whether err corresponds to a not-found result on a typed CRUD operation. Treats sql.ErrNoRows and the framework's internal errNotFound (from Update/Delete misses) as equivalent.

func LLMMDHandler

func LLMMDHandler(ent *entity.Entity) http.Handler

LLMMDHandler returns an http.Handler that serves the LLM-friendly markdown for a single entity. The schema-disclosure surface is broad (every field, validator, relation), so the handler requires an authenticated context — the framework's auth chain must have set a user before this fires.

func MarshalEntity

func MarshalEntity(src any) (map[string]any, error)

MarshalEntity is the inverse — converts a typed entity struct into the snake-cased map[string]any the framework's CRUD primitives expect. Skips fields whose JSON tag carries omitempty if the value is the type's zero, per encoding/json semantics.

func NormalizePath

func NormalizePath(path string) string

NormalizePath strips trailing slashes from a path.

func Redispatch added in v0.31.0

func Redispatch(ctx context.Context, router http.Handler, method, path string, body any) (any, error)

Redispatch is the exported re-dispatch seam the process-module capability broker (framework/processmodule_broker.go, design #37 §5) uses to re-enter the CRUD chokepoint for a reverse host.entity.* call.

It is the exact machinery RegisterEntityMCPTools uses (runToolRequest): it builds an in-process http.Request, copies the originating caller's Cookie/Authorization headers from the request stashed on ctx via mcp.WithRequest (without which owner-scoped CRUD re-resolution returns 401), ServeHTTP's it through router so the FULL middleware chain (auth/recovery/owner/tenant/permission + token-scope) re-runs, and returns the parsed JSON envelope. A status >= 400 is returned as an error so the broker can surface the CRUD chokepoint's 401/403 as a reverse-call denial.

The broker owns the capability pre-filter (access.ScopeMatch module-grant + the CrossOwnerRead carve-out); this function is deliberately ONLY the caller-authority half of the module-grant ∩ caller-authority intersection. It performs no capability reasoning of its own.

func RegisterCrudRoutes

func RegisterCrudRoutes(r *router.Router, handler *CrudHandler, path string, opts ...CrudRouteOptions)

RegisterCrudRoutes registers the standard CRUD routes plus batch endpoints on the given router.

GET    /path             → List
GET    /path/{id}        → Get
POST   /path             → Create
PUT    /path/{id}        → Update
PATCH  /path/{id}        → Update (sparse)
DELETE /path/{id}        → Delete
POST   /path/_batch      → BatchCreate (atomic; all-or-nothing)
PATCH  /path/_batch      → BatchUpdate (atomic; all-or-nothing)
DELETE /path/_batch      → BatchDelete (atomic; all-or-nothing)

The batch routes use a "_batch" segment, which Go 1.22's ServeMux ranks above the wildcard /{id} so they take precedence over Get/Update/Delete on the same prefix.

func RegisterEntityMCPTools

func RegisterEntityMCPTools(server *mcp.Server, crud *CrudHandler, router http.Handler) error

RegisterEntityMCPTools exposes a CRUD handler through MCP tools.

router is the http.Handler that owns the entity's CRUD routes — typically app.Router. MCP tool calls are dispatched through router.ServeHTTP so they share the exact same middleware chain (auth, recovery, logging, security headers, etc.) as live HTTP traffic. Passing the bare CRUD handler would silently bypass that middleware.

func RegistryLLMMD

func RegistryLLMMD(registry entity.Registry, appName string) string

RegistryLLMMD generates a top-level LLM-friendly markdown index that lists every registered entity with a link to its detailed llm.md page.

func RegistryLLMMDHandler

func RegistryLLMMDHandler(registry entity.Registry, appName string) http.Handler

RegistryLLMMDHandler returns an http.Handler that serves the top-level LLM-friendly markdown index for all registered entities. Auth-required for the same reason as LLMMDHandler.

func UnmarshalEntity

func UnmarshalEntity(m map[string]any, dest any) error

UnmarshalEntity is the public entry point for converting a snake-cased row map into a typed entity struct (json tags in camelCase). Exposed so generated repository code outside this package can call it.

func WithAuditPreImage

func WithAuditPreImage(ctx context.Context, row map[string]any) context.Context

WithAuditPreImage stores the pre-change row snapshot in ctx so the AfterUpdate / AfterDelete hooks can diff it against the new state. doUpdate / doDelete populate this with a freshly SELECTed copy before the mutating statement runs.

Casing: row is whatever selectPreImage produced, which runs the SELECT results through the handler's own scanRow/convertKey pipeline — the same key-casing step that shapes every CRUD response. Callers of this function (doUpdate, doDelete) must pass a row that already carries the handler's configured JSONCase; do not pass a raw snake_case DB row here.

func WithAuditRequest

func WithAuditRequest(ctx context.Context, r *http.Request) context.Context

WithAuditRequest returns ctx with r attached so audit hooks can read client-IP / user-agent / headers in their AfterCreate/Update/Delete callbacks. CRUD's HTTP entry points call this before any DB work.

func WithServerWrites added in v0.18.0

func WithServerWrites(ctx context.Context) context.Context

WithServerWrites returns a context marked as trusted server-origin. CreateOne / UpdateOne / UpsertOne / batch methods that receive it will persist ReadOnly and Hidden fields present in the body, instead of the default behaviour of silently skipping them.

SECURITY:

  • Set this ONLY from your own server-side Go (a service method, cron job, seed script, or admin action you have already gated with a permission check). There is NO built-in role check here.
  • NEVER derive this flag from request data (headers, query params, JSON body). Hosts pipe near-raw maps into CreateOne via factories and seeders; an unguarded derivation there becomes a mass-assignment bypass.
  • The owner and tenant columns remain protected regardless: InjectOwner still stamps/overwrites the owner value, and the tenant column is always taken from the context-derived tenant id — never the body.

Note: Hidden fields stay absent from the RETURNING clause / response maps even when persisted. visibleFields shapes the projection, not the write set.

Types

type BatchResponse

type BatchResponse struct {
	Committed bool          `json:"committed"`
	Results   []batchResult `json:"results"`
}

BatchResponse is the standard envelope for all _batch endpoints.

type CrudHandler

type CrudHandler struct {
	Entity     *entity.Entity
	DB         DBExecutor
	PrimaryKey string             // defaults to "id"
	JSONCase   JSONCase           // casing strategy for JSON keys
	Hooks      *hook.HookRegistry // optional lifecycle hooks
	Storage    upload.Storage     // optional; enables multipart uploads for Image/File fields
	Events     *event.EventBus    // optional; receives entity.created/updated/deleted on commit
	Outbox     EventOutbox        // optional; when set, lifecycle events are staged in-tx (transactional outbox) and delivered to declared consumers by the relay. EmitEvent still notifies Events (real-time lane); the relay does not, so there is no double delivery.
	Registry   entity.Registry    // optional; required for nested ?include=author.profile resolution
	BasePath   string             // optional; URL prefix where this entity's routes are mounted (e.g. "/api/v1"). Used by MCP tools to dispatch against the same path the HTTP routes live at; empty = bare "/table".
	// contains filtered or unexported fields
}

CrudHandler provides auto-generated CRUD HTTP handlers for an Entity.

func NewCrudHandler

func NewCrudHandler(ent *entity.Entity, db DBExecutor) *CrudHandler

NewCrudHandler creates a new CrudHandler for the given entity and database.

func RegisterCrudRoutesFunc

func RegisterCrudRoutesFunc(r *router.Router, ent *entity.Entity, db DBExecutor, path string, opts ...CrudRouteOptions) *CrudHandler

RegisterCrudRoutesFunc is a convenience that creates a CrudHandler and registers routes in one call.

func (*CrudHandler) ApplyOwnerScope

func (ch *CrudHandler) ApplyOwnerScope(qb *query.QueryBuilder, r *http.Request)

ApplyOwnerScope adds an `<owner_field> = ?` predicate to a SELECT query when the entity declares OwnerField and the request context carries an owner id (registered via framework/owner.SetExtractor — typically by battery/auth's init()). No-op when either condition is missing.

Uses PostgreSQL-style $N placeholders, matching ApplyTenantScope.

func (*CrudHandler) ApplyOwnerScopeCount

func (ch *CrudHandler) ApplyOwnerScopeCount(cb *query.CountBuilder, r *http.Request)

ApplyOwnerScopeCount mirrors ApplyOwnerScope for count queries.

func (*CrudHandler) ApplyOwnerScopeDelete

func (ch *CrudHandler) ApplyOwnerScopeDelete(db *query.DeleteBuilder, r *http.Request)

ApplyOwnerScopeDelete mirrors ApplyOwnerScope for DELETE queries.

func (*CrudHandler) ApplyOwnerScopeUpdate

func (ch *CrudHandler) ApplyOwnerScopeUpdate(ub *query.UpdateBuilder, r *http.Request)

ApplyOwnerScopeUpdate mirrors ApplyOwnerScope for UPDATE queries.

func (*CrudHandler) ApplySoftDeleteFilter

func (ch *CrudHandler) ApplySoftDeleteFilter(qb *query.QueryBuilder, r *http.Request)

ApplySoftDeleteFilter adds a deleted_at IS NULL filter unless the caller requests trashed records via ?trashed=true AND the request is authenticated. An anonymous caller passing ?trashed=true on a public list endpoint must not be allowed to enumerate soft-deleted rows — that's an information-disclosure path. The query param is honoured only when a user is present in the request context.

func (*CrudHandler) ApplySoftDeleteFilterCount

func (ch *CrudHandler) ApplySoftDeleteFilterCount(cb *query.CountBuilder, r *http.Request)

ApplySoftDeleteFilterCount adds a deleted_at IS NULL filter to a count query. Same authentication gate as ApplySoftDeleteFilter.

func (*CrudHandler) ApplyTenantScope

func (ch *CrudHandler) ApplyTenantScope(qb *query.QueryBuilder, r *http.Request)

ApplyTenantScope adds a tenant_id filter to the query when the entity is configured for multi-tenancy and a tenant ID is present in the context. Note: uses PostgreSQL-style $1 placeholders.

func (*CrudHandler) ApplyTenantScopeCount

func (ch *CrudHandler) ApplyTenantScopeCount(cb *query.CountBuilder, r *http.Request)

ApplyTenantScopeCount adds a tenant_id filter to a count query builder. Note: uses PostgreSQL-style $1 placeholders.

func (*CrudHandler) ApplyTenantScopeDelete

func (ch *CrudHandler) ApplyTenantScopeDelete(db *query.DeleteBuilder, r *http.Request)

ApplyTenantScopeDelete adds a tenant_id filter to a delete query builder. Note: uses PostgreSQL-style $1 placeholders.

func (*CrudHandler) ApplyTenantScopeUpdate

func (ch *CrudHandler) ApplyTenantScopeUpdate(ub *query.UpdateBuilder, r *http.Request)

ApplyTenantScopeUpdate adds a tenant_id filter to an update query builder. Note: uses PostgreSQL-style $1 placeholders.

func (*CrudHandler) BatchCreate

func (ch *CrudHandler) BatchCreate() http.HandlerFunc

BatchCreate returns an http.HandlerFunc accepting POST /{table}/_batch with {items:[...]}. All items run in one transaction; the first per-item error rolls everything back. The response always includes a results array in input order.

func (*CrudHandler) BatchCreateMany

func (ch *CrudHandler) BatchCreateMany(ctx context.Context, bodies []map[string]any) ([]map[string]any, error)

BatchCreateMany runs CreateOne for each body in a single transaction. Events fire after commit (per item, in input order). Any per-item error rolls back the whole batch — same semantics as the HTTP _batch endpoint. Wrap ctx with WithServerWrites to persist ReadOnly/Hidden fields.

func (*CrudHandler) BatchDelete

func (ch *CrudHandler) BatchDelete() http.HandlerFunc

BatchDelete returns an http.HandlerFunc accepting DELETE /{table}/_batch with {ids:[...]}. All deletes share one transaction.

func (*CrudHandler) BatchDeleteMany

func (ch *CrudHandler) BatchDeleteMany(ctx context.Context, ids []string) ([]string, error)

BatchDeleteMany deletes (or soft-deletes) each id atomically. Returns the ids that were successfully removed.

func (*CrudHandler) BatchUpdate

func (ch *CrudHandler) BatchUpdate() http.HandlerFunc

BatchUpdate returns an http.HandlerFunc accepting PATCH /{table}/_batch. Each item must contain an "id" field naming the record to patch; remaining fields are the partial update. All items share one transaction.

func (*CrudHandler) BatchUpdateMany

func (ch *CrudHandler) BatchUpdateMany(ctx context.Context, ids []string, bodies []map[string]any) ([]map[string]any, error)

BatchUpdateMany runs UpdateOne for each (id, body) pair atomically. Wrap ctx with WithServerWrites to persist ReadOnly/Hidden fields.

func (*CrudHandler) CountAll

func (ch *CrudHandler) CountAll(ctx context.Context, opts ListOptions) (int, error)

CountAll returns COUNT(*) for the given filters (no pagination). Cheap helper for typed repos that want a totals figure without hitting List.

func (*CrudHandler) Create

func (ch *CrudHandler) Create() http.HandlerFunc

Create returns an http.HandlerFunc that creates a new entity record. Auto-generated fields are populated server-side and excluded from the request body. The hook chain (BeforeCreate → INSERT → AfterCreate) runs inside a single transaction; if any step errors the write is rolled back.

Accepts application/json or multipart/form-data. When the request is multipart, parts whose name matches an Image/File field are streamed through the handler's Storage backend and persisted as a URL string.

func (*CrudHandler) CreateOne

func (ch *CrudHandler) CreateOne(ctx context.Context, body map[string]any) (map[string]any, error)

CreateOne runs the full Create pipeline (BeforeCreate hooks → INSERT → AfterCreate hooks) inside a single transaction and emits entity.created on commit. Returns the created row as a snake_cased map; convert to a typed struct in your caller.

By default ReadOnly and Hidden fields in body are silently skipped (matching the HTTP Create path). To persist them from trusted server code, wrap ctx with WithServerWrites — the owner and tenant columns remain context-stamped regardless. Hidden fields stay absent from the returned map (visibleFields shapes the projection, not the write set).

func (*CrudHandler) Delete

func (ch *CrudHandler) Delete() http.HandlerFunc

Delete returns an http.HandlerFunc that deletes an entity by ID. If the entity has SoftDelete=true, it sets deleted_at instead. The hook chain (BeforeDelete → DELETE/UPDATE → AfterDelete) runs inside a transaction.

func (*CrudHandler) DeleteOne

func (ch *CrudHandler) DeleteOne(ctx context.Context, id string) error

DeleteOne deletes (or soft-deletes) a record by id.

func (*CrudHandler) EmitEvent

func (ch *CrudHandler) EmitEvent(ctx context.Context, eventType string, record any)

EmitEvent fires an entity lifecycle event after the operation's transaction has committed. Delivery is split across two disjoint lanes:

  • Real-time lane: the live bus is notified always (best-effort, async), feeding SSE EventStream and ephemeral On/Subscribe handlers. Lossy by design — a crash here drops the in-memory signal, but the durable lane still guarantees delivery.
  • Durable lane: when an outbox is configured, the row staged in-tx by StageEvent is delivered to declared consumers by the relay. The relay no longer touches the bus, so there is no double delivery.

func (*CrudHandler) EventStream

func (ch *CrudHandler) EventStream() http.HandlerFunc

EventStream returns an http.HandlerFunc that serves a Server-Sent Events stream of EntityCreated / EntityUpdated / EntityDeleted events scoped to this entity. When the entity is multi-tenant, events are further filtered to the tenant ID extracted from the request context.

Each accepted event is written as:

event: entity.created (or entity.updated / entity.deleted)
data:  {<full Event JSON>}

Disconnects from the client unsubscribe automatically. A backpressure buffer of 32 is enforced — if the client cannot keep up, events are dropped rather than blocking emitters.

func (*CrudHandler) Get

func (ch *CrudHandler) Get() http.HandlerFunc

Get returns an http.HandlerFunc that fetches a single entity by ID. Honours ?include= eager-loaded relations.

Hook chain: BeforeGet → SELECT → AfterGet. The BeforeGet hook can append WHERE predicates via *hook.GetPayload.AddWhere to scope the lookup (mismatches return 404). AfterGet may mutate the result map (redact, transform).

func (*CrudHandler) GetOne

func (ch *CrudHandler) GetOne(ctx context.Context, id string, includes []string) (map[string]any, error)

GetOne fetches a single record by id, optionally eager-loading the named includes. Returns sql.ErrNoRows when the record doesn't exist (or is soft-deleted, unless options ask otherwise).

func (*CrudHandler) InjectOwner

func (ch *CrudHandler) InjectOwner(data map[string]any, ctx context.Context)

InjectOwner stamps the owner id into a Create payload when the entity declares OwnerField. Mirrors InjectTenant's shape.

func (*CrudHandler) InjectTenant

func (ch *CrudHandler) InjectTenant(data map[string]any, ctx context.Context)

InjectTenant injects the tenant_id into a data map when multi-tenancy is enabled. It reads the tenant ID from ctx so it works whether the caller is outside or inside an in-tx context derived from the request.

func (*CrudHandler) List

func (ch *CrudHandler) List() http.HandlerFunc

List returns an http.HandlerFunc that lists entity records with filtering, sorting, pagination, and optional ?include= eager-loaded relations.

Hook chain: BeforeList → SELECT → AfterList. The BeforeList hook can append WHERE predicates via the *hook.ListPayload.AddWhere helper; appended clauses apply to both the data query and the count query. AfterList receives the fetched results and may mutate them in place.

func (*CrudHandler) ListAll

func (ch *CrudHandler) ListAll(ctx context.Context, opts ListOptions) ([]map[string]any, error)

ListAll runs a list query with optional filters/sort/limit/offset/includes and returns the matching rows. Caller is responsible for paging if the result set is large.

func (*CrudHandler) RequireOwner

func (ch *CrudHandler) RequireOwner(w http.ResponseWriter, r *http.Request) (id any, ok bool)

RequireOwner returns the current owner id when the entity declares OwnerField. ok=true means: either no owner is required (entity has no OwnerField), or an owner was extracted. ok=false means: the entity requires an owner but none is available — the caller MUST refuse the request. Writes 401 to w and returns ok=false in that case so handlers can `if _, ok := ch.RequireOwner(w, r); !ok { return }`.

This is the secure-by-default seam: without it, ApplyOwnerScope would silently no-op for anonymous requests on OwnerField entities, returning every row in the table. With OwnerField set the framework refuses requests that can't produce an owner id, regardless of whether the caller mounted auth middleware in front of the route.

func (*CrudHandler) RequireTenant

func (ch *CrudHandler) RequireTenant(w http.ResponseWriter, r *http.Request) (ok bool)

RequireTenant is the HTTP mirror of RequireOwner for multi-tenant entities. ok=true means: either the entity is not MultiTenant, or a tenant id is present in the request context. ok=false means the entity is MultiTenant but the request carries no tenant id — the caller MUST refuse the request. Writes 401 to w and returns ok=false in that case so handlers can `if !ch.RequireTenant(w, r) { return }`.

This is the secure-by-default seam matching requireTenantContext (the in-process mirror). Without it, ApplyTenantScope* silently no-op when no tenant is in context, leaking every tenant's rows on read and permitting cross-tenant update/delete-by-id. Hosts that genuinely need cross-tenant access (admin tooling) must set a tenant id deliberately rather than rely on an empty context.

func (*CrudHandler) ServeStreamingList

func (ch *CrudHandler) ServeStreamingList(ctx context.Context, w http.ResponseWriter, r *http.Request, cols []string, filters []filter.ParsedFilter, nested []nestedFilter, sorts []filter.ParsedSort, page, limit int, extraWhere []hook.WhereClause)

ServeStreamingList writes the list response row-by-row through a json.Encoder rather than buffering everything into a slice first. Used for very large pages (limit ≥ streamListThreshold) or when the caller asks for it via ?stream=true.

The wire shape is identical to the regular list envelope so existing clients keep working: {"data": [...], "total": N, "page": P, "perPage": N, "totalPages": T}. Streaming applies only to the data array — the envelope fields are written before the rows start flowing.

`page` is honoured the same way the non-stream List handler honours it: OFFSET (page-1)*limit. Without this, ?page=2&stream=true would re-stream page 1 while reporting page 1 — silently dropping the offset. An explicit ?offset= overrides the page-derived offset, matching the buffered path.

func (*CrudHandler) StageEvent added in v0.15.0

func (ch *CrudHandler) StageEvent(ctx context.Context, eventType string, record any) error

StageEvent durably stages an entity lifecycle event when an outbox is configured. It MUST be called from inside the operation's transaction (ch.DB is the tx-scoped executor there — doCreate/doUpdate/doDelete and the upsert closure call it), so the event row commits or rolls back with the write. No-op without an outbox: the live-bus EmitEvent covers that mode post-commit.

func (*CrudHandler) Update

func (ch *CrudHandler) Update() http.HandlerFunc

Update returns an http.HandlerFunc that updates an entity by ID. The hook chain (BeforeUpdate → UPDATE → AfterUpdate) runs inside a transaction. Accepts application/json or multipart/form-data (same rules as Create).

func (*CrudHandler) UpdateOne

func (ch *CrudHandler) UpdateOne(ctx context.Context, id string, body map[string]any) (map[string]any, error)

UpdateOne updates a record by id with the partial body. Hooks + tx + event emission all fire as in the HTTP path.

By default ReadOnly and Hidden fields in body are silently skipped. Wrap ctx with WithServerWrites to persist them from trusted server code; the owner and tenant columns are never writable from the body.

func (*CrudHandler) UpsertOne

func (ch *CrudHandler) UpsertOne(ctx context.Context, body map[string]any) (map[string]any, error)

UpsertOne performs an INSERT ... ON CONFLICT DO UPDATE on the entity's primary key. If a row with the same PK already exists, every writable field in body overwrites the existing row; otherwise a new row is inserted. Returns the resulting row (post-insert or post-update).

Both Postgres and SQLite 3.24+ support this exact syntax via the EXCLUDED pseudo-table. The framework's BeforeCreate/AfterCreate hooks fire (an upsert that turns into an INSERT is semantically a Create); no Update hooks fire — if you need to distinguish, route through the regular Create/Update endpoints.

func (*CrudHandler) VisibleFields

func (ch *CrudHandler) VisibleFields() []string

VisibleFields returns field names that are not Hidden.

func (*CrudHandler) WithJSONCase

func (ch *CrudHandler) WithJSONCase(c JSONCase) *CrudHandler

WithJSONCase sets the JSON casing strategy for the handler.

type CrudRouteOptions

type CrudRouteOptions struct {
	NoLLMMD  bool // disable auto-generated /path/llm.md
	ReadOnly bool // register only the read routes (List/Get/events) — for views and other read-only objects
}

CrudRouteOptions controls which routes are registered by RegisterCrudRoutes.

type DBExecutor

type DBExecutor = db.Executor

DBExecutor is an alias for db.Executor, retained so existing callers keep using framework.DBExecutor. New code should reference framework/db directly.

type EventOutbox added in v0.15.0

type EventOutbox interface {
	// Append writes an event row using the passed executor — inside a CRUD
	// transaction that is the *sql.Tx, so the row commits or rolls back
	// with the business write.
	Append(ctx context.Context, ex DBExecutor, eventType string, data any) (string, error)
	// Nudge wakes the relay so post-commit delivery isn't bound to its
	// poll interval.
	Nudge()
}

EventOutbox is the transactional-outbox surface CRUD needs — satisfied by *outbox.Outbox (framework/outbox). An interface rather than the concrete type so crud carries no outbox import and tests can record staging calls.

type IncludeNode

type IncludeNode struct {
	Name     string                // segment name (matches the relation's Name)
	Relation entity.Relation       // relation declared on the parent entity
	Target   *entity.Entity        // the entity Reached by following Relation
	Filters  []filter.ParsedFilter // scoped filters applied during eager-load
	Children []*IncludeNode        // deeper includes, e.g. for "author.profile" the "profile" child of "author"
	// contains filtered or unexported fields
}

IncludeNode represents one segment of a (possibly nested) ?include= expression. The tree is rooted at the request's entity; each node carries the relation taken to reach it and any deeper child includes.

Filters narrows the eager-load to rows on the target that match every scoped predicate, e.g. include=comments(status=published) attaches only published comments. Suffixes (_gt/_gte/_lt/_lte/_like/_in) work the same way they do for top-level filters.

type JSONCase

type JSONCase string

JSONCase defines the casing convention for JSON keys in API responses.

CaseCamel — camelCase (default, web standard) CaseSnake — snake_case (database-style)

const (
	CaseCamel JSONCase = "camelCase"
	CaseSnake JSONCase = "snake_case"
)

type ListOptions

type ListOptions struct {
	Filters []filter.ParsedFilter
	// NestedFilters express relation-scoped predicates (the in-process
	// equivalent of HTTP ?author.name=alice). Applied as EXISTS subqueries
	// to both ListAll and CountAll, mirroring the HTTP List handler.
	NestedFilters []NestedFilter
	Sorts         []filter.ParsedSort
	Limit         int
	Offset        int
	Includes      []string
	// Search carries a free-text ?q= term for in-process parity with the
	// HTTP List handler. When non-empty AND the entity declares
	// SearchFields, it produces AND-composed LOWER(col) LIKE conditions.
	// Returns an error when set on an entity without SearchFields (fail
	// loud, matching unknown-sort policy).
	Search string
}

ListOptions controls ListAll.

type ListResponse

type ListResponse struct {
	Data       []map[string]any `json:"data"`
	Total      int              `json:"total"`
	Page       int              `json:"page"`
	PerPage    int              `json:"perPage"`
	TotalPages int              `json:"totalPages"`
}

ListResponse is the standard JSON response for list endpoints.

type NestedFilter added in v0.3.2

type NestedFilter struct {
	Relation string
	Field    string
	Op       filter.FilterOp
	Value    string
	Values   []string
}

NestedFilter is the in-process (ListOptions) equivalent of a single `?author.name=alice` HTTP query param. Typed repositories construct these directly instead of synthesising a URL. Relation names the declared relation on the parent entity; Field is the column on the target entity; Op/Value mirror ParsedFilter semantics. For Op==OpIn, set Values (Value is ignored).

type TypedQuery

type TypedQuery[T any] struct {
	// contains filtered or unexported fields
}

TypedQuery is a generic, fluent query builder that returns []*T from the underlying CrudHandler's table. Generated typed repositories construct one via NewTypedQuery; the type parameter T is the entity's struct.

The builder wraps the existing core/query.QueryBuilder (so filters, sorts, limits, tenant scope, and soft-delete behave identically to the HTTP path) and runs the same eager-load helpers when Include() is set.

func NewTypedQuery

func NewTypedQuery[T any](h *CrudHandler) *TypedQuery[T]

NewTypedQuery starts a new query against the handler's entity. Callers should chain Where/Order/Limit/Include and finish with Find/First/Count.

func (*TypedQuery[T]) Count

func (q *TypedQuery[T]) Count(ctx context.Context) (int, error)

Count returns the number of rows matching the current filters. Ignores limit/offset/order — pure SELECT COUNT(*) over the same WHERE predicate.

func (*TypedQuery[T]) DeleteAll

func (q *TypedQuery[T]) DeleteAll(ctx context.Context) (int, error)

DeleteAll removes every row matching the current Where chain. For SoftDelete entities, sets deleted_at instead of issuing a DELETE.

func (*TypedQuery[T]) Exists

func (q *TypedQuery[T]) Exists(ctx context.Context) (bool, error)

Exists returns true if at least one row matches the current WHERE chain. Cheaper than First+IsNotFound for the "do any match?" question because it runs a COUNT(*) with LIMIT 1 internally.

func (*TypedQuery[T]) Find

func (q *TypedQuery[T]) Find(ctx context.Context) ([]*T, error)

Find executes the query and decodes results into []*T.

func (*TypedQuery[T]) First

func (q *TypedQuery[T]) First(ctx context.Context) (*T, error)

First runs the query with Limit(1) and returns the lone result. Returns sql.ErrNoRows when the query yields zero rows so callers can detect "not found" with errors.Is.

func (*TypedQuery[T]) Include

func (q *TypedQuery[T]) Include(rels ...string) *TypedQuery[T]

Include eager-loads the named relations on the result rows. Names follow the same dotted-path syntax as ?include= (e.g. "author.profile").

func (*TypedQuery[T]) Limit

func (q *TypedQuery[T]) Limit(n int) *TypedQuery[T]

Limit caps the result size.

func (*TypedQuery[T]) Offset

func (q *TypedQuery[T]) Offset(n int) *TypedQuery[T]

Offset skips the first n rows.

func (*TypedQuery[T]) Order

func (q *TypedQuery[T]) Order(o entity.Order) *TypedQuery[T]

Order appends an ORDER BY clause. Multiple Order calls compose in input order (first declared = primary sort).

func (*TypedQuery[T]) UpdateAll

func (q *TypedQuery[T]) UpdateAll(ctx context.Context, fields map[string]any) (int, error)

UpdateAll applies the same field updates to every row matching the current Where chain and returns the number of rows touched. Ignores Limit/Offset/Order — the underlying SQL is a plain UPDATE with the same WHERE predicate that Find would use.

Fields are snake-cased map[string]any (the framework's wire shape). For type-safe updates, marshal a partial struct with framework.MarshalEntity and pass the resulting map.

func (*TypedQuery[T]) Where

func (q *TypedQuery[T]) Where(c entity.Condition) *TypedQuery[T]

Where appends an AND condition. Multiple Where calls AND together.

type ValidationError added in v0.18.0

type ValidationError struct {
	// contains filtered or unexported fields
}

ValidationError carries field-level validation errors from inside inTx out to the response writer. It is the error type returned by Create / Update / Upsert when schema validation rejects the body. Callers branch on it with errors.As:

var ve *crud.ValidationError
if errors.As(err, &ve) { ... ve.Fields() ... }

The fields map is exposed read-only via Fields; mutating it has no effect on the handler or the wire response.

func NewValidationError added in v0.18.0

func NewValidationError(fields map[string][]string) *ValidationError

NewValidationError constructs a ValidationError from a field→messages map. Intended for tests and host code that needs to synthesize a validation failure (e.g. from a custom BeforeCreate hook).

func (*ValidationError) Error added in v0.18.0

func (e *ValidationError) Error() string

Error implements the error interface. The string is deliberately generic ("validation failed"); per-field detail lives in Fields().

func (*ValidationError) Fields added in v0.18.0

func (e *ValidationError) Fields() map[string][]string

Fields returns the per-field validation messages keyed by column name. The returned map is the handler's internal copy; callers MUST treat it as read-only.

Jump to

Keyboard shortcuts

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