Documentation
¶
Index ¶
- Constants
- Variables
- func AuditOperationFor(ctx context.Context, entity, id string) string
- func AuditPreImageAs[T any](ctx context.Context) (T, bool)
- func AuditPreImageFromContext(ctx context.Context) map[string]any
- func AuditPreImageSnakeFromContext(ctx context.Context) map[string]any
- func AuditRequestFromContext(ctx context.Context) *http.Request
- func CrudRoutePatterns(path string, opts ...CrudRouteOptions) []string
- func EagerLoad(ctx context.Context, db DBExecutor, ent *entity.Entity, ...) (map[string]map[string]any, error)
- func EntityLLMMD(ent *entity.Entity, opts ...LLMMDOptions) string
- func IsBrokeredCall(ctx context.Context) bool
- func IsHookRefusal(err error) bool
- func IsNotFound(err error) bool
- func LLMMDHandler(ent *entity.Entity, opts ...LLMMDOptions) http.Handler
- func LLMMDHandlerFor(ch *CrudHandler, opts ...LLMMDOptions) http.Handler
- func MCPToolNames(ch *CrudHandler) []string
- func MarshalEntity(src any) (map[string]any, error)
- func NormalizePath(path string) string
- func Redispatch(ctx context.Context, router http.Handler, method, path string, body any) (any, error)
- func RegisterCrudRoutes(r *router.Router, handler *CrudHandler, path string, opts ...CrudRouteOptions)
- func RegisterEntityMCPTools(server *mcp.Server, crud *CrudHandler, router http.Handler) error
- func RegistryLLMMD(registry entity.Registry, appName string, ...) string
- func RegistryLLMMDHandler(registry entity.Registry, appName string, ...) http.Handler
- func RoutableTransitions(st *entity.StatesConfig) []entity.Transition
- func StateOverrideReason(ctx context.Context) string
- func TransitionFromContext(ctx context.Context) string
- func UnmarshalEntity(m map[string]any, dest any) error
- func WithAuditPreImage(ctx context.Context, row map[string]any) context.Context
- func WithAuditRequest(ctx context.Context, r *http.Request) context.Context
- func WithBrokeredCall(ctx context.Context) context.Context
- func WithElevation(ctx context.Context, entities ...string) context.Context
- func WithReadHooks(ctx context.Context) context.Context
- func WithServerWrites(ctx context.Context) context.Context
- func WithStateOverride(ctx context.Context, reason string) context.Context
- func WithUploadedKeys(ctx context.Context, keys ...string) context.Context
- func WithoutElevation(ctx context.Context) context.Context
- type BatchResponse
- type CrudHandler
- func (ch *CrudHandler) ApplyOwnerScope(qb *query.QueryBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyOwnerScopeCount(cb *query.CountBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyOwnerScopeDelete(db *query.DeleteBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyOwnerScopeUpdate(ub *query.UpdateBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyReadScope(qb *query.QueryBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyReadScopeCount(cb *query.CountBuilder, r *http.Request)
- func (ch *CrudHandler) ApplySoftDeleteFilter(qb *query.QueryBuilder, r *http.Request)
- func (ch *CrudHandler) ApplySoftDeleteFilterCount(cb *query.CountBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyTenantScope(qb *query.QueryBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyTenantScopeCount(cb *query.CountBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyTenantScopeDelete(db *query.DeleteBuilder, r *http.Request)
- func (ch *CrudHandler) ApplyTenantScopeUpdate(ub *query.UpdateBuilder, r *http.Request)
- func (ch *CrudHandler) BatchCreate() http.HandlerFunc
- func (ch *CrudHandler) BatchCreateMany(ctx context.Context, bodies []map[string]any) ([]map[string]any, error)
- func (ch *CrudHandler) BatchDelete() http.HandlerFunc
- func (ch *CrudHandler) BatchDeleteMany(ctx context.Context, ids []string) ([]string, error)
- func (ch *CrudHandler) BatchUpdate() http.HandlerFunc
- func (ch *CrudHandler) BatchUpdateMany(ctx context.Context, ids []string, bodies []map[string]any) ([]map[string]any, error)
- func (ch *CrudHandler) CanCreateScoped(ctx context.Context) bool
- func (ch *CrudHandler) CanDeleteRecordScoped(ctx context.Context, id string) bool
- func (ch *CrudHandler) CanRead(ctx context.Context) bool
- func (ch *CrudHandler) CanReadRecordScoped(ctx context.Context, id string) bool
- func (ch *CrudHandler) CanReadScoped(ctx context.Context) bool
- func (ch *CrudHandler) CanUpdateRecordScoped(ctx context.Context, id string) bool
- func (ch *CrudHandler) CountAll(ctx context.Context, opts ListOptions) (int, error)
- func (ch *CrudHandler) Create() http.HandlerFunc
- func (ch *CrudHandler) CreateOne(ctx context.Context, body map[string]any) (map[string]any, error)
- func (ch *CrudHandler) Delete() http.HandlerFunc
- func (ch *CrudHandler) DeleteOne(ctx context.Context, id string) error
- func (ch *CrudHandler) EmitEvent(ctx context.Context, eventType string, record any)
- func (ch *CrudHandler) EventStream() http.HandlerFunc
- func (ch *CrudHandler) Get() http.HandlerFunc
- func (ch *CrudHandler) GetOne(ctx context.Context, id string, includes []string) (map[string]any, error)
- func (ch *CrudHandler) GroupCountAll(ctx context.Context, field string, opts ListOptions, limit int) ([]GroupCount, error)
- func (ch *CrudHandler) InjectOwner(data map[string]any, ctx context.Context)
- func (ch *CrudHandler) InjectTenant(data map[string]any, ctx context.Context)
- func (ch *CrudHandler) List() http.HandlerFunc
- func (ch *CrudHandler) ListAll(ctx context.Context, opts ListOptions) ([]map[string]any, error)
- func (ch *CrudHandler) PurgeOne(ctx context.Context, id string) error
- func (ch *CrudHandler) RequireOwner(w http.ResponseWriter, r *http.Request) (id any, ok bool)
- func (ch *CrudHandler) RequireTenant(w http.ResponseWriter, r *http.Request) (ok bool)
- func (ch *CrudHandler) RestoreOne(ctx context.Context, id string) error
- func (ch *CrudHandler) RunTransition(ctx context.Context, id, key string) (map[string]any, error)
- func (ch *CrudHandler) ServeStreamingList(ctx context.Context, w http.ResponseWriter, r *http.Request, cols []string, ...)
- func (ch *CrudHandler) StageEvent(ctx context.Context, eventType string, record any) error
- func (ch *CrudHandler) SumAll(ctx context.Context, field string, opts ListOptions) (string, error)
- func (ch *CrudHandler) Transition() http.HandlerFunc
- func (ch *CrudHandler) Update() http.HandlerFunc
- func (ch *CrudHandler) UpdateOne(ctx context.Context, id string, body map[string]any) (map[string]any, error)
- func (ch *CrudHandler) UpsertOne(ctx context.Context, body map[string]any) (map[string]any, error)
- func (ch *CrudHandler) VisibleFields() []string
- func (ch *CrudHandler) WithEventStreamReauth(d time.Duration) *CrudHandler
- func (ch *CrudHandler) WithEventStreamSeatOverflow(p stream.SeatOverflowPolicy) *CrudHandler
- func (ch *CrudHandler) WithEventStreamSeats(n int) *CrudHandler
- func (ch *CrudHandler) WithJSONCase(c JSONCase) *CrudHandler
- func (ch *CrudHandler) WithMaxOffset(n int) *CrudHandler
- type CrudRouteOptions
- type DBExecutor
- type EventOutbox
- type GroupCount
- type IncludeNode
- type JSONCase
- type LLMMDOptions
- type ListOptions
- type ListResponse
- type MountInfo
- type NestedFilter
- type StateError
- type TransitionConflictError
- type TypedQuery
- func (q *TypedQuery[T]) Count(ctx context.Context) (int, error)
- func (q *TypedQuery[T]) DeleteAll(ctx context.Context) (int, error)
- func (q *TypedQuery[T]) Exists(ctx context.Context) (bool, error)
- func (q *TypedQuery[T]) Find(ctx context.Context) ([]*T, error)
- func (q *TypedQuery[T]) First(ctx context.Context) (*T, error)
- func (q *TypedQuery[T]) Include(rels ...string) *TypedQuery[T]
- func (q *TypedQuery[T]) Limit(n int) *TypedQuery[T]
- func (q *TypedQuery[T]) Offset(n int) *TypedQuery[T]
- func (q *TypedQuery[T]) Order(o entity.Order) *TypedQuery[T]
- func (q *TypedQuery[T]) UpdateAll(ctx context.Context, fields map[string]any) (int, error)
- func (q *TypedQuery[T]) Where(c entity.Condition) *TypedQuery[T]
- func (q *TypedQuery[T]) WhereNested(filters ...NestedFilter) *TypedQuery[T]
- type ValidationError
Constants ¶
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.
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.
const MaxMultipartBodyBytes int64 = 64 << 20 // 64 MiB
MaxMultipartBodyBytes caps the total wire size of a multipart request accepted by the CRUD write handlers. MaxMultipartMemory above is the in-RAM spill threshold, not a body cap, without a wire cap a hostile client can stream an unbounded body into parser temp files (and, via non-file form values, straight into memory). 64 MiB leaves headroom over the 32 MiB per-file ProcessFileField cap for framing and form fields. Requests over the cap are rejected 413.
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 ¶
var ( ErrNoSoftDelete = errors.New("crud: entity does not use soft delete") ErrNotSoftDeleted = errors.New("crud: record is not soft-deleted") )
Sentinels the two soft-delete operations answer with. In-process callers map them to responses the way writeCRUDError maps errNotFound to 404: ErrNoSoftDelete is a programming error (calling restore/purge on an entity that never declared Scope.SoftDelete), ErrNotSoftDeleted is a caller error (restoring a live row, purging a row that must be deleted first).
var ( // ErrNoStates: the entity declares no EntityConfig.States. ErrNoStates = errors.New("crud: entity declares no states") // ErrUnknownTransition: no move has that key (404 on the route, which // also answers it for a System move). ErrUnknownTransition = errors.New("crud: no such move") // ErrStateOverrideUnaudited: WithStateOverride changed a state field // on an entity no audit log records. The override's only trail is // the audit row, so it is refused without one (App.WithAuditLog). ErrStateOverrideUnaudited = errors.New("crud: a state override needs an audit log on the entity") // ErrStateOverrideNoReason: WithStateOverride was given an empty // reason. ErrStateOverrideNoReason = errors.New("crud: a state override needs a reason") // ErrBulkStateWrite: TypedQuery.UpdateAll named the state field or a // stamp. One value written onto many rows is a move per row, so run // RunTransition per record instead. ErrBulkStateWrite = errors.New("crud: a bulk update cannot change the state field or a stamp; run the move per record") // ErrReentrantMove: RunTransition was called, from a hook, on the // record whose update or move is running that hook (409 on the route). // Run the move after the write commits. ErrReentrantMove = errors.New("crud: a move cannot run on a record while a write of that record is in flight") )
Errors RunTransition and the state write check answer with. In-process callers map them the way writeCRUDError does.
var ErrAggregateMasked = errors.New("crud: an aggregate cannot pass through AfterList hooks")
ErrAggregateMasked refuses an aggregate read under WithReadHooks on an entity with AfterList hooks. Those hooks mask rows after the query; a total the database computes never passes through them, so it could count what they hide. Read the rows through ListAll instead.
var ErrForbidden = errors.New("crud: forbidden")
ErrForbidden is wrapped around every RestoreOne/PurgeOne access refusal, so an in-process caller can tell a permission denial from a not-found (the two share a 40x shape but mean different things to an admin screen). Not seen by writeCRUDError: both operations are in-process only, no HTTP route maps their errors.
Functions ¶
func AuditOperationFor ¶ added in v0.87.0
AuditOperationFor returns the operation override stamped for exactly this entity and record id, or "" when none matches. Audit hooks consult it for the op column; empty means "use the hook's own operation". The entity and id match is load-bearing: a hook chain may write other entities' rows inside one restore or purge, and those rows' audit entries must name their own operation, not the outer one.
func AuditPreImageAs ¶ added in v0.29.0
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 ¶
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
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 ¶
AuditRequestFromContext returns the *http.Request that CRUD attached, or nil if none was set (e.g. async hook fired outside a request).
func CrudRoutePatterns ¶ added in v0.55.0
func CrudRoutePatterns(path string, opts ...CrudRouteOptions) []string
CrudRoutePatterns returns the "METHOD /pattern" set RegisterCrudRoutes mounts for path, without mounting anything.
It exists so a caller can pre-flight a collision BEFORE the first route is registered. App.TryEntity needs that: it mounts CRUD routes and then custom endpoints, so an endpoint shadowing one of these (a "_batch" endpoint on an entity at /posts) would panic mid-commit, after the registry entry and the CRUD routes are already published and with no way to un-publish them.
TestCrudRoutePatternsMatchRegistration pins this list against what RegisterCrudRoutes actually registers, a route added to one and not the other fails that test rather than silently reopening the gap.
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 row-scoping the live include path applies (eager_filtered.go + include.go) is applied here. Four scrubs run, each keyed off the resolved target's EntityConfig:
- Soft-delete exclusion: a soft-deletable target gets `deleted_at IS NULL` on its SELECT so trashed rows never resurface.
- Column allow-list: a loaded row keeps only the target's declared, non-Hidden fields plus its primary key, the columns its own read route serves. Hidden fields (e.g. password_hash) and columns the target does not declare (another API version's, a removed field's) are dropped.
- Owner scope: an owner-scoped target (OwnerField set) gets `owner_field = <ctx owner>` ANDed in, exactly like the include path's applyRelatedOwnerScope. With no user in context the predicate becomes `owner_field = ""`, matching no real row (fail-closed).
- Tenant scope: a MultiTenant target gets `tenant_id = <ctx tenant>` ANDed in, exactly like applyRelatedTenantScope. With no tenant in context it likewise matches nothing.
Owner and tenant scopes are ANDed for every caller except one holding an explicit cross-scope grant (owner.AllowCrossOwner, the target's CrossOwnerRead, tenant.AllowCrossTenant), the same exemption applyRelatedOwnerScope and applyRelatedTenantScope make on the include path. For everyone else the cross-table predicate is the IDOR back-door control and is unconditional: lifting it would let an ?include= or an EagerLoad caller reach across tenants and owners.
Resolution is version-aware (entity.ResolveTarget against the source entity) and fails closed: a target that cannot be resolved is an ERROR, not a load with the scrubs turned off. Without a registry the target schema is unknown, so none of the four per-target scrubs can be applied; callers that load relations whose targets are soft-deletable, carry Hidden fields, are owner-scoped, or are multi-tenant MUST pass the registry.
func EntityLLMMD ¶
func EntityLLMMD(ent *entity.Entity, opts ...LLMMDOptions) 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
Pass LLMMDOptions{ReadOnly: true} for an entity mounted with CrudRouteOptions{ReadOnly: true} (App.View does): the write, batch and Create/Update-column sections are omitted so the doc matches the mount.
func IsBrokeredCall ¶ added in v0.64.0
IsBrokeredCall reports whether ctx was stamped by the process-module broker. Exported so the broker test surface can pin the root-cause marker.
func IsHookRefusal ¶ added in v0.87.0
IsHookRefusal reports whether err is a BeforeCreate, BeforeUpdate or BeforeDelete hook's rejection, the error the HTTP handlers answer 400. An in-process caller that serves its own HTTP answer uses it to tell a refusal from a failure.
func IsNotFound ¶
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, opts ...LLMMDOptions) 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 LLMMDHandlerFor ¶ added in v0.46.0
func LLMMDHandlerFor(ch *CrudHandler, opts ...LLMMDOptions) http.Handler
LLMMDHandlerFor is LLMMDHandler with the entity's own access gate applied, the form the CRUD routes register.
LLMMDHandler checks only for a session, while List runs the full scope chain, so an authenticated caller with no `orders:read` grant got 403 on the rows and 200 on the schema: every field name, type and enum of an entity they cannot read. The schema is the disclosure, not the row.
It reuses requireScope, so owner, tenant, baseline-session and RBAC all move together with the read path instead of being restated here.
func MCPToolNames ¶ added in v0.87.0
func MCPToolNames(ch *CrudHandler) []string
MCPToolNames returns the names RegisterEntityMCPTools registers for ch's entity, in registration order: the five CRUD tools, then one per routable move under its own key. A namespace on the handler (MCPNamespace) names them "<ns>.<entity>.<action>" instead of the flat "<entity>_<action>". nil answers nil.
Screens that show an agent (or a developer) how to reach an entity from code build their labels from this, so what they advertise is exactly what the server registers.
func MarshalEntity ¶
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 ¶
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)
POST /path/{id}/transitions/{key} → RunTransition (entities whose
States hold a non-system move)
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 ¶
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, crudMount func(*entity.Entity) MountInfo) string
RegistryLLMMD generates a top-level LLM-friendly markdown index that lists every registered entity with a link to its detailed llm.md page. crudMount reports how the entity's auto-CRUD routes were actually registered (the app passes its mount predicate: DB attached AND Exposure.CRUD, plus ReadOnly for App.View mounts); nil documents standard CRUD for every entity, the pre-#266 behavior. Entities without CRUD routes list only their declared custom endpoints, entities with no routes at all are omitted, and read-only mounts are counted and labelled as the three routes they serve — the index must describe routes that exist. It lists every (routed) entity; callers that serve this to a request (the /api/llm.md index) MUST filter per request via [registryLLMMD] so the index does not disclose entities the caller cannot read.
func RegistryLLMMDHandler ¶
func RegistryLLMMDHandler(registry entity.Registry, appName string, crudMount func(*entity.Entity) MountInfo) 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.
The index is rendered per request, filtered to the entities THIS caller can list, the same read-scope predicate each entity's List route runs (see canListEntity). Serving a construction-time-precomputed document instead disclosed every entity's name, base path, endpoint count and soft-delete/multi-tenant flags to an authenticated caller who would get 403 on the rows. The index is the disclosure, not the row.
func RoutableTransitions ¶ added in v0.87.0
func RoutableTransitions(st *entity.StatesConfig) []entity.Transition
RoutableTransitions returns the moves the transition route and the MCP tools serve: every non-system move, in declared order.
func StateOverrideReason ¶ added in v0.87.0
StateOverrideReason returns the reason WithStateOverride put on ctx, or "" when there is none. The audit log writes it to the row's reason column.
func TransitionFromContext ¶ added in v0.87.0
TransitionFromContext returns the key of the move whose hooks are running, or "" outside RunTransition. A BeforeUpdate hook that needs to tell a move from an edit reads it.
func UnmarshalEntity ¶
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 ¶
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 ¶
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 WithBrokeredCall ¶ added in v0.64.0
WithBrokeredCall stamps ctx as originating from the process-module broker's CRUD re-dispatch. crossOwnerReadGranted returns false unconditionally when it is present, so owner scoping holds by construction for every brokered call regardless of the re-resolved caller's permissions. SECURITY: set ONLY from the broker's resolveCaller.
func WithElevation ¶ added in v0.87.0
WithElevation returns a context that satisfies the EntityConfig.Access permissions (Exposure.Access Read, Create, Update and Delete) of the named entities without the caller's roles holding them. It is how battery/admin lets an admin manage the entities it exposes, whose permissions the admin role was never granted, one call at a time, without putting the Wildcard on the context. An entity it does not name keeps its Access check: a relation picker, a hook or a stat that reaches past the back office's own entities reads as the caller. A call naming no entity lifts nothing, and a later call replaces the set rather than adding to it.
It lifts that one check and nothing else:
- a Decider's deny still refuses (it is asked first, as in access.CanResource; only its abstain is answered by the lift);
- owner and tenant scope still apply, and Scope.CrossOwnerRead is not granted, so an owner-scoped entity still shows only the caller's rows;
- a transition's Permission and the state override permission still need the capability by name;
- held scopes (API tokens, embed grants) and the session gate still bind, and the caller's roles are left as they are.
SECURITY: set it ONLY from server code that has already decided the caller may administer these entities, the way battery/admin does after its Authorize gate. NEVER derive it from request data.
func WithReadHooks ¶ added in v0.48.0
WithReadHooks returns a context in which the in-process read API runs the read hooks the HTTP surface runs, so callers see the rows and values a GET would return: BeforeList/BeforeGet scope the query (ListAll and CountAll run BeforeList, GetOne runs BeforeGet), and AfterList/AfterGet mask the result.
Use it where rows are RENDERED, not where they are read to be written back:
rows, err := ch.ListAll(crud.WithReadHooks(ctx), opts) // a screen row, err := ch.GetOne(ctx, id, nil) // an edit form
Do not set it on a context handed to a hook: an AfterGet hook that reads its own entity through a hook-applying context recurses until the process dies. HTTP request handling is unaffected, those paths always run hooks.
func WithServerWrites ¶ added in v0.18.0
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.
func WithStateOverride ¶ added in v0.87.0
WithStateOverride lets trusted Go code (seeds, imports, repair jobs) write an entity's state field and stamps directly, outside a move. It is set only from Go, never from a request value. Every write that changes a guarded column under it is audited: the audit row (App.WithAuditLog) carries the reason and the operation "state_override" on an update, and the write is refused on an entity with no audit log. WithServerWrites does not release the state field; this does, and only this.
TypedQuery.UpdateAll refuses guarded columns even under the override: a bulk UPDATE runs no hooks, so it would leave no audit row.
func WithUploadedKeys ¶ added in v0.87.0
WithUploadedKeys returns a context recording keys the caller saved to storage for the write it is about to make, so CreateOne, UpdateOne, UpsertOne and the batch helpers accept them in an Image/File field or a `<field>_variants` storage_ref. Call it with the StorageRef that file.ProcessFileField (or your own Storage.Save) returned:
ff, err := file.ProcessFileField(ctx, store, r, name, "profiles", "avatar")
...
row, err := profiles.CreateOne(crud.WithUploadedKeys(ctx, ff.StorageRef),
map[string]any{"avatar": ff.URL})
SECURITY: pass only keys your own code just saved. A key taken from request data is exactly what this check exists to refuse.
func WithoutElevation ¶ added in v0.87.0
WithoutElevation returns ctx with WithElevation's lift removed, for code a back office runs on the caller's behalf but does not vouch for: an app's actions, tabs, view funcs and field kinds run as the caller, so entityui hands them this context.
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
// ImageDeriver, when set, runs over every schema.Image upload to
// produce stored renditions plus a BlurHash/LQIP. Derived values land
// in sibling columns the entity declares. See applyDerivedColumns.
// framework/imagefield provides the implementation.
ImageDeriver file.ImageDeriver
// FieldImageDerivers overrides ImageDeriver for specific image fields,
// keyed by field name. An avatar wants portrait components and animated
// sources rejected; a hero cover wants wide renditions, one app-wide
// config cannot express both.
FieldImageDerivers map[string]file.ImageDeriver
// StripUploadMetadata opts every multipart upload on this handler into
// file.StripMetadata: EXIF, XMP and text segments are removed from the
// stored original (JPEG, PNG, WebP; other bytes pass through). Off by
// default: originals are stored verbatim (maintainer decision,
// 2026-09-04). Set app-wide with framework.WithStripUploadMetadata.
StripUploadMetadata bool
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
// ChildHooks resolves ANOTHER entity's hook registry by name, so rows
// eager-loaded through ?include= run the read hooks of the entity they
// belong to rather than this one's. Without it a redaction on the child
// applies to GET /children but not to the same row one relation hop away.
// Must not create registries, it is called on the request path.
// framework.App wires it; nil leaves includes unhooked.
ChildHooks func(entityName string) *hook.HookRegistry
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".
MCPNamespace string // optional; when set (e.g. "admin"), MCP tools are named "<ns>.<entity>.<action>" instead of the flat "<entity>_<action>". Empty preserves the historical flat tool names.
// MCPRouteScoped marks an entity whose routes sit behind middleware the
// /mcp request does not pass through: a route group's WithAccess or Use,
// which may install a different policy, roles or Decider. The MCP tool
// gate cannot see what the route will apply, so it leaves these tools
// listed and the route decides. framework.App sets it for GroupEntity.
MCPRouteScoped bool
// MaxOffset caps the row skip a list request may ask for, via ?offset=
// or a deep ?page=. Zero (the default) resolves to the page cap × 1000
// — 100,000 at the default Pagination.MaxListLimit, scaling with the
// entity's page cap — so a client cannot force a per-request
// full-table skip scan. See requireBoundedOffset.
MaxOffset int
// EventStreamReauth is how often an established SSE feed re-runs its
// read-permission gate when idle (the gate also runs on every
// delivery). Zero resolves to the default, 30s. The re-check cannot
// be switched off: a stream must not outlive the authority that
// opened it. See EventStream / WithEventStreamReauth.
EventStreamReauth time.Duration
// EventStreamSeats caps how many concurrent EventStream connections
// ONE principal holds on this entity's feed. Each stream pins a
// goroutine, a 32-entry buffer, and three bus subscriptions until it
// disconnects, so without a cap a single authenticated caller can
// exhaust the process's goroutines/FDs/memory for everyone else.
// 0 = 16 (the same default core/stream and core/mcp use); negative
// lifts the cap. See WithEventStreamSeats.
EventStreamSeats int
// EventStreamSeatOverflow selects what a principal at EventStreamSeats
// does with its next stream: stream.SeatOverflowRefuse (the default)
// answers 429 with Retry-After at connect,
// stream.SeatOverflowEvictOldest closes that principal's oldest
// stream and seats the new one. See WithEventStreamSeatOverflow.
EventStreamSeatOverflow stream.SeatOverflowPolicy
// 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. The derived field caches are built here, single-threaded, so the request path never has to fill them (see fieldcache.go).
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. Like the update pair it never consults CrossOwnerRead.
func (*CrudHandler) ApplyOwnerScopeUpdate ¶
func (ch *CrudHandler) ApplyOwnerScopeUpdate(ub *query.UpdateBuilder, r *http.Request)
ApplyOwnerScopeUpdate mirrors ApplyOwnerScope for UPDATE queries. Unlike the read pair it never consults CrossOwnerRead: writes stay owner-scoped even for callers whose cross-owner read was granted.
func (*CrudHandler) ApplyReadScope ¶ added in v0.68.0
func (ch *CrudHandler) ApplyReadScope(qb *query.QueryBuilder, r *http.Request)
ApplyReadScope ANDs the entity's ReadScope predicates into a SELECT the caller is building against this entity's own table: the read-scope mirror of ApplyOwnerScope. No-op when the entity declares no ReadScope, when the caller is unrestricted, or when the handler's entity is not the table being queried (each entity's handler applies its own scope).
READS only. Update/delete/upsert statements deliberately do not carry this predicate in this version: a write is authorized by the write gates (owner, tenant, RBAC), and filtering writes by a read posture would strand rows their own editor cannot save. The asymmetry is documented, not accidental; see the ReadScopeConfig doc comment.
func (*CrudHandler) ApplyReadScopeCount ¶ added in v0.68.0
func (ch *CrudHandler) ApplyReadScopeCount(cb *query.CountBuilder, r *http.Request)
ApplyReadScopeCount mirrors ApplyReadScope for count queries, so a list envelope's total is the count of the rows the caller may see, not the table's.
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 ¶
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) CanCreateScoped ¶ added in v0.87.0
func (ch *CrudHandler) CanCreateScoped(ctx context.Context) bool
CanCreateScoped is CanUpdateRecordScoped for POST /<entity>: the gates a create runs before the write, with the Decider asked about the collection (Ref.ID ""). A screen asks it before drawing New or a create form.
func (*CrudHandler) CanDeleteRecordScoped ¶ added in v0.87.0
func (ch *CrudHandler) CanDeleteRecordScoped(ctx context.Context, id string) bool
CanDeleteRecordScoped is CanUpdateRecordScoped for DELETE /<entity>/{id}.
func (*CrudHandler) CanRead ¶ added in v0.55.0
func (ch *CrudHandler) CanRead(ctx context.Context) bool
CanRead reports whether ctx carries the permission EntityConfig.Access requires to read this entity. It answers true when the entity declares no read permission.
This answers RBAC ONLY. It is not the whole read posture and is almost never what a new caller wants: a default-posture entity (no OwnerField, no Access, no Public) declares no read permission, so this returns true for an anonymous caller while GET /api/<entity> answers 401.
Prefer CanReadScoped, or CanReadRecordScoped for a single record. Those are what framework/entityui gates its screens on. Reach for CanRead directly only when you specifically want the RBAC question in isolation.
func (*CrudHandler) CanReadRecordScoped ¶ added in v0.67.0
func (ch *CrudHandler) CanReadRecordScoped(ctx context.Context, id string) bool
CanReadRecordScoped is CanReadScoped for ONE record.
The difference matters only when a resource-aware Decider is installed (access.WithDecider): the decider is asked about access.Ref{Type, ID}, so it can allow the listing and deny an individual row, "member may read project 42" is the whole point of that seam. The HTTP read-one route already passes r.PathValue("id") into the check; a screen rendering the same record with the collection-level predicate would show a row the API refuses by id.
Pass the record id for a detail or edit view. With no decider installed this answers exactly what CanReadScoped answers.
func (*CrudHandler) CanReadScoped ¶ added in v0.67.0
func (ch *CrudHandler) CanReadScoped(ctx context.Context) bool
CanReadScoped reports whether ctx may read this entity's rows: the baseline session requirement, owner scoping, tenant scoping, and RBAC, as a boolean with no HTTP response written.
It answers the STRICTER of itself and requireScope(opRead) wherever the two could differ. In particular it does not honour owner.AllowCrossOwner, which the in-process requireOwnerContext does and the HTTP route's RequireOwner does not: that marker is exported, so a host could apply it to a request context, and a rendering surface trusting the looser answer would show rows the REST route refuses. Read scoping is still a separate implementation of one rule and will drift if edited without its counterpart.
It is COLLECTION-level: the RBAC check asks about the entity, not a specific record id, so a resource-aware Decider that denies one row is not consulted. Use it to decide whether a caller may see a listing. For a single record, the record's own read must still go through the CRUD route or an equivalent per-id check.
CanRead alone answers only the RBAC question, which is not the whole read posture. Auto-CRUD is secure by default: an entity declaring no OwnerField, no Access, and no Public requires a session for every operation, and that rule lives in requireAuthenticated rather than in Access. A surface that gated on CanRead alone therefore served every row of a default-posture entity to anonymous callers while GET /api/<entity> answered 401, which is exactly what generated list screens did.
Use this from any surface that renders the same rows outside the CRUD routes (a server-rendered table, a report, a dashboard aggregate). See framework/entityui.
func (*CrudHandler) CanUpdateRecordScoped ¶ added in v0.87.0
func (ch *CrudHandler) CanUpdateRecordScoped(ctx context.Context, id string) bool
CanUpdateRecordScoped reports whether ctx may update the record id: the boolean mirror of the gates PUT /<entity>/{id} runs before the write (requireScope with opUpdate), with the Decider asked about this record. A surface that writes outside the routes, such as an entityui bulk action, checks each record with it before the in-process UpdateOne, which applies owner and tenant scope but asks no RBAC question.
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 ¶
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
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) GroupCountAll ¶ added in v0.87.0
func (ch *CrudHandler) GroupCountAll(ctx context.Context, field string, opts ListOptions, limit int) ([]GroupCount, error)
GroupCountAll counts the rows ListAll(ctx, opts) would read per stored value of field, ordered by value, computed by the database. limit, when positive, caps the groups returned; ask for one more than a chart draws to tell a capped result from a complete one. field names a visible column that is not JSON; opts is refused as SumAll refuses it.
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) PurgeOne ¶ added in v0.87.0
func (ch *CrudHandler) PurgeOne(ctx context.Context, id string) error
PurgeOne hard-deletes a record that is ALREADY soft-deleted: the admin's Delete permanently action. The soft delete is never skipped: a live row answers ErrNotSoftDeleted, so a caller cannot turn purge into a hard delete that bypasses the retention window soft delete exists for. Otherwise it mirrors the delete path: the delete access permission and the Decider are asked about the one record, BeforeDelete and AfterDelete hooks run, the audit row carries operation "purge", and the entity.deleted event fires. Owner and tenant scoping answer visibility exactly as RestoreOne's. In-process only; no HTTP route mounts it.
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) RestoreOne ¶ added in v0.87.0
func (ch *CrudHandler) RestoreOne(ctx context.Context, id string) error
RestoreOne clears the soft-delete marker on one record: the admin's Deleted view's Restore action, in-process only. It behaves like an update: the entity's update access permission is asked through the same access.CanResource call the update route makes — so a resource-aware Decider on ctx can deny the one record — and the BeforeUpdate and AfterUpdate hooks, the audit row (operation "restore") and the entity.updated event all fire as they do for UpdateOne. A permission denial is wrapped in ErrForbidden; WithServerWrites skips the permission question, matching every other trusted-write escape hatch; owner and tenant context are still required.
Visibility answers the read question first: a row the caller cannot see under owner and tenant scoping answers errNotFound, the same error a read of that id gives, so another owner's id discloses nothing. A row that is visible but not soft-deleted answers ErrNotSoftDeleted. An entity without Scope.SoftDelete answers ErrNoSoftDelete before any SQL.
func (*CrudHandler) RunTransition ¶ added in v0.87.0
RunTransition runs the move key on record id: the one way an enforced state field changes. In order:
- The caller may move: a System move skips this (no route reaches it; only Go code calls it); any other asks the entity's update permission and, when the move sets one, its Permission, both about this record so a Decider can answer per row. WithServerWrites does not skip it.
- A read of the row under tenant, owner-write and soft-delete scope: not visible answers errNotFound (404), so another owner's id reveals nothing. A visible row whose value is not in From answers a TransitionConflictError (409) naming the open moves.
- BeforeUpdate hooks, with the move's writes as the body and its key on ctx (TransitionFromContext). A hook can veto; changes it makes to the body are not written.
- One conditional UPDATE under the same scope, pinned to From: SET field = To, stamp = <server's UTC date or time>. Zero rows means another write moved the record first: a TransitionConflictError.
- AfterUpdate hooks, the audit row (operation "transition:<key>") and the entity.updated event, in the same transaction.
It works on an Advisory entity too, where the moves stay as calls.
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
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) SumAll ¶ added in v0.87.0
func (ch *CrudHandler) SumAll(ctx context.Context, field string, opts ListOptions) (string, error)
SumAll returns SUM(field) over the rows ListAll(ctx, opts) would read, computed by the database: every match, not a page. The total is the database's own decimal text ("0" when no row matches), so a Decimal column sums without float rounding where the database keeps it exact. field names a visible Int, Float or Decimal column. opts takes the filters and Search; Fields, Sorts, Limit, Offset and Includes are refused, since an aggregate reads every match.
func (*CrudHandler) Transition ¶ added in v0.87.0
func (ch *CrudHandler) Transition() http.HandlerFunc
Transition is the HTTP handler for POST <path>/{id}/transitions/{key}: RunTransition behind the update route's gates (owner, tenant, session, the update permission for this id) and its JSON content-type check, which a cross-site form cannot satisfy. The route takes no body. A System move answers 404, as an unknown key does. The response is the moved record in Update's envelope.
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 ¶
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.
This is the single-threaded schema-read surface: it re-checks the entity's live declarations and republishes the snapshot when they changed, which is how a host's mid-flight field mutation reaches the request path (see fieldcache.go for why the request path itself does not re-read them).
func (*CrudHandler) WithEventStreamReauth ¶ added in v0.82.0
func (ch *CrudHandler) WithEventStreamReauth(d time.Duration) *CrudHandler
WithEventStreamReauth sets how often an established SSE feed re-runs its read-permission gate when idle. Values below one second are ignored (the default is 30s); the re-check itself cannot be disabled, only rescheduled.
func (*CrudHandler) WithEventStreamSeatOverflow ¶ added in v0.85.0
func (ch *CrudHandler) WithEventStreamSeatOverflow(p stream.SeatOverflowPolicy) *CrudHandler
WithEventStreamSeatOverflow selects what a principal at its seat cap does with its next stream: stream.SeatOverflowRefuse (the default) answers 429 with Retry-After at connect, stream.SeatOverflowEvictOldest closes that principal's oldest stream and seats the new one — the multi-tab-friendly policy for apps whose clients reconnect faster than a half-open connection's seat is reclaimed by its re-auth ticker.
func (*CrudHandler) WithEventStreamSeats ¶ added in v0.85.0
func (ch *CrudHandler) WithEventStreamSeats(n int) *CrudHandler
WithEventStreamSeats sets the per-principal cap on concurrent EventStream connections. Zero keeps the default (16); a negative value lifts the cap for deployments that bound seats elsewhere. Values are taken verbatim — the resolution rules live in eventStreamSeatsFor.
func (*CrudHandler) WithJSONCase ¶
func (ch *CrudHandler) WithJSONCase(c JSONCase) *CrudHandler
func (*CrudHandler) WithMaxOffset ¶ added in v0.82.0
func (ch *CrudHandler) WithMaxOffset(n int) *CrudHandler
WithMaxOffset raises or lowers the list-offset ceiling. Zero keeps the default: the page cap × 1000 (100,000 at the default page cap). Values below 1 are ignored, the ceiling cannot be removed, only moved.
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
// States is the entity's states config, read by CrudRoutePatterns:
// POST /path/{id}/transitions/{key} is mounted when it holds a
// non-system move. RegisterCrudRoutes reads the handler's entity
// instead, so it needs no value here.
States *entity.StatesConfig
}
CrudRouteOptions controls which routes are registered by RegisterCrudRoutes.
type DBExecutor ¶
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.
CONTRACT: durable consumers registered via the outbox's Consume receive the RAW staged row — the post-write RETURNING record, with no AfterGet pass at stage or delivery time. Durable consumers are trusted machinery and own their masking; the SSE lane is the one that redacts at delivery (redactEventRecord). See the "Durable consumers receive the raw row" bullet in framework/docs/content/events.md.
type GroupCount ¶ added in v0.87.0
GroupCount is one group GroupCountAll returns: a stored value of the field (nil for NULL) and how many rows hold it.
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
// ReadScopes carries the TARGET's Exposure.ReadScope predicates, set by
// applyRelatedReadScope. It is deliberately a SEPARATE slice from
// Filters: Filters are caller-controlled and rendered with IN-run
// coalescing, so merging a scope `status_in=[published]` with a caller's
// `include=rel(status_in=draft)` would coalesce the two runs into one
// IN list, OR where the declaration demands AND, and serve the drafts
// the scope exists to hide.
ReadScopes []filter.ParsedFilter
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. Operator suffixes work the same way they do for top-level filters (filter.FilterSuffixes, one shared table).
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)
type LLMMDOptions ¶ added in v0.78.0
type LLMMDOptions struct {
// ReadOnly omits every write endpoint (POST, PUT, PATCH, DELETE and
// the _batch family) plus the per-field Create/Update columns: on a
// read-only mount those routes answer 404/405, and the doc must not
// advertise routes the server does not serve (#358, the #266 class
// one layer down: the answer lives in the mount options, not the
// entity declaration).
ReadOnly bool
}
LLMMDOptions controls what EntityLLMMD documents. The zero value keeps the historical full-CRUD document; ReadOnly narrows it to the routes a CrudRouteOptions{ReadOnly: true} mount actually serves.
type ListOptions ¶
type ListOptions struct {
// Filters are flat field/op/value conditions ANDed into the query,
// the in-process equivalent of ?field_<op>=. Hand-built entries pass
// the same rule the URL parser applies: each field must name a real,
// non-Hidden, non-NoQuery column (under its column name or WireName)
// and its operator must suit that column's type, so a `like` on an
// Int column is refused here exactly as ?amount_like= is there.
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
// Where is a boolean predicate tree (filter.Predicate) ANDed with
// everything else ListAll and CountAll apply: the flat Filters,
// nested filters, search, and the owner, tenant, soft-delete and
// read scopes. It is compiled as ONE parenthesized clause, so an OR
// inside it can never widen past a scope. The tree is re-validated
// with filter.ValidatePredicate before use — a caller may have built
// it by hand — and the SQL is built from the resolved copy that
// check returns, so a tree shared across goroutines (a parsed
// Display view) is never written to. A refusal names the offending
// leaf.
//
// Build one with dsl.ParsePredicate (text such as
// `status = "open" and amount >= 10000`) or filter.ParseWhere (the
// ?where= JSON shape); both return a validated tree.
Where *filter.Predicate
// Fields limits the columns read to the named subset plus the primary
// key (always included). Unknown and Hidden names are refused with an
// error, never silently dropped. NoQuery columns may be read: NoQuery
// keeps a field out of filters and sorts, not out of the row. Masking still
// applies: AfterList/AfterGet hooks run on whatever columns the read
// returned. Empty means every visible column.
Fields []string
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
// Deleted lists only soft-deleted rows, the trash view a list draws
// beside its live one: it inverts the soft-delete filter (the read
// answers deleted_at IS NOT NULL) while every other scope — owner,
// tenant, read scope, Where, Filters, Search — applies unchanged, so
// the trashed rows a caller sees are the trashed rows they own. An
// entity without Scope.SoftDelete answers ErrNoSoftDelete, the same
// sentinel RestoreOne and PurgeOne use, before any SQL runs. Applies
// to ListAll and CountAll alike.
Deleted bool
}
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 MountInfo ¶ added in v0.78.0
type MountInfo struct {
// Mounted reports whether the standard auto-CRUD routes are registered
// for this entity at all (the app's route predicate: DB attached AND
// Exposure.CRUD unset-or-true).
Mounted bool
// ReadOnly reports a CrudRouteOptions{ReadOnly: true} mount: only the
// List, Get-by-ID and _events routes exist; write and batch routes
// were never registered.
ReadOnly bool
}
MountInfo reports how an entity's auto-CRUD routes are mounted. The llm.md index asks the app that performed the mounts, not the entity declaration: Exposure cannot express that App.View mounts an entity read-only (#358), just as it could not express DB-less (#266). The index must describe routes that exist.
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 StateError ¶ added in v0.87.0
type StateError struct {
Field string // the state field or stamp the write changed
Current string // the stored state; empty on a create
Moves []string // the non-system moves open from Current
Initial []string // on a create: the values it may set
// contains filtered or unexported fields
}
StateError refuses a write that changes the state field or a stamp outside a move. It answers 422 and names the moves open from the stored value (an update) or the values a create may start at.
func (*StateError) Error ¶ added in v0.87.0
func (e *StateError) Error() string
type TransitionConflictError ¶ added in v0.87.0
type TransitionConflictError struct {
Key string
Current string
Moves []string
// contains filtered or unexported fields
}
TransitionConflictError refuses a move whose From does not hold the record's current value, including the race where another write moved it first. It answers 409 and names the moves that are open.
func (*TransitionConflictError) Error ¶ added in v0.87.0
func (e *TransitionConflictError) Error() string
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(*) internally without mutating query state.
func (*TypedQuery[T]) Find ¶
func (q *TypedQuery[T]) Find(ctx context.Context) ([]*T, error)
Find executes the query and decodes results into []*T.
Under crud.WithReadHooks the AfterList chain runs before decoding, exactly as ListAll does it, a typed repo backing a rendered page must not show stored values the entity's own list endpoint masks. Without the opt-in the rows are stored values, so read-modify-write still round-trips.
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.
The single-row surface mirrors GET /entity/{id}, so under crud.WithReadHooks it runs AfterGet, the same chain GetOne runs, and the same one the HTTP handler runs for a by-id read. It deliberately does NOT also run AfterList: that would redact more than the entity's own routes do and make the two surfaces disagree in the other direction.
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 ¶
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 tenant, owner, soft-delete, and Where/WhereNested predicates applied (mirroring write-side scoping, without read scopes).
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.
func (*TypedQuery[T]) WhereNested ¶ added in v0.87.0
func (q *TypedQuery[T]) WhereNested(filters ...NestedFilter) *TypedQuery[T]
WhereNested appends relation-scoped nested filters (e.g. relation "author.profile", field "bio", op OpLike, value "Alice").
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.
Source Files
¶
- aggregate.go
- audit_ctx.go
- cascade_write.go
- conflict_fields.go
- crud.go
- crud_api.go
- crud_batch.go
- crud_cursor.go
- crud_events.go
- crud_ops.go
- crud_projection.go
- crud_routes.go
- crud_softdelete.go
- crud_stream.go
- crud_upload.go
- crud_upsert.go
- eager.go
- eager_filtered.go
- elevation.go
- eventstream_seats.go
- fieldcache.go
- include.go
- jsoncase.go
- jsonfield.go
- llmmd.go
- mcp.go
- mcp_names.go
- media_provenance.go
- nested_filter.go
- numberexact.go
- owner.go
- pool.go
- read_scope.go
- readhooks.go
- redispatch.go
- serverwrites.go
- states.go
- tx.go
- typed_query.go
- write_gate.go