Documentation
¶
Overview ¶
Package crud is the five operations every tenant-owned resource needs, and the schema a generated screen reads.
A module writes a struct with an embedded Base and gets read, list, create, update and delete, each refusing what row-level security would refuse anyway. What it does not write is a repository, a DTO or a mapper.
This half links no web server ¶
A module's contracts/ package imports this one, because the entity is declared there, and a contracts/ package is what every consumer compiles against. So nothing about HTTP is here: the five routes, the PATCH merge and the OpenAPI declarations are kit/rest, which imports this package. The division is the entity and its storage against the entity's projection onto a protocol, and it is what keeps huma, chi and NATS out of the build graph of anything that only wanted to name a Task.
Instantiate with the pointer type ¶
Entity is implemented by *Task and not by Task: base() has a pointer receiver, because the kernel stamps the tenant into it. So every function here is instantiated with the pointer — crud.Get[*Task](tx, id) — and List returns []*Task. The alternative, two type parameters everywhere, costs every call site a repetition the compiler could not check anyway.
The tenant is never a parameter ¶
Create takes the tenant from the transaction, and Update refuses an entity carrying a different one. That is defense in depth rather than the boundary: row-level security refuses the same write in the database, whatever Go believes. See docs/adr/0003.
Index ¶
- Constants
- Variables
- func Classify(err error) error
- func Create[T Entity](ctx context.Context, tx db.Tx[db.Tenant], e T) error
- func Delete[T Entity](tx db.Tx[db.Tenant], id uuid.UUID, soft bool) error
- func Get[T Entity](tx db.Tx[db.Tenant], id uuid.UUID) (T, error)
- func List[T Entity](tx db.Tx[db.Tenant], q Query) ([]T, int64, error)
- func Reset[T Entity](e T)
- func Update[T Entity](ctx context.Context, tx db.Tx[db.Tenant], e T, columns ...string) error
- type Base
- type Entity
- type Field
- type FieldType
- type Query
- type Schema
- type Validator
Constants ¶
const ( DefaultLimit = 50 MaxLimit = 200 )
The page bounds. A caller that asks for nothing gets a screenful; a caller that asks for everything gets the most a single response should carry.
Variables ¶
var ( // ErrNotFound is no such row in this tenant. Another tenant's row is not // found either, which is the only thing the API may say about it. ErrNotFound = errors.New("crud: no such row") // ErrInvalid is the entity's own Validate, or a query naming a field that // does not exist. ErrInvalid = errors.New("crud: invalid") // ErrConflict is a unique constraint the write contradicts. ErrConflict = errors.New("crud: conflict") )
The three failures a caller distinguishes. Everything else is an outage and reads as a 500. Spec.Mount turns these into 404, 422 and 409.
Functions ¶
func Classify ¶
Classify names the two database failures a caller can do something about: a row that is not there, and a unique constraint the write contradicts. Everything else is ours and comes back unchanged, to be logged and answered with a 500.
It is exported because a module that writes rows this package cannot still has to answer with the same errors. A tenant carries no tenant_id, so it is not an Entity and modules/tenant writes it by hand; it used to carry a copy of this function, which was a second opinion about what a 409 means waiting to drift.
func Create ¶
Create writes a new row, stamped with the transaction's tenant.
An entity that already carries a different tenant is refused rather than restamped. Update refuses the same thing, and the two have to agree: code that reads a row in one tenant and creates it in another has a bug either way, and silently rewriting the field means the bug ships as a copy.
func Delete ¶
Delete removes a row: soft sets deleted_at, which hides it from Get and List while keeping it for anything that referenced it; otherwise the row goes.
func List ¶
List reads a page of this tenant's rows and the total the page came from. The order always ends in the id, so two pages of equal-keyed rows do not overlap or skip.
func Reset ¶
func Reset[T Entity](e T)
Reset clears the four fields the server owns, whatever a caller sent for them. A create route calls it on the body it decoded, so a caller can neither choose an id nor backdate a row; base() is unexported, which is what makes this the only door.
func Update ¶
Update writes an existing row back. It refuses an entity carrying another tenant, which row-level security would refuse too; reporting it as not found is the same answer the read would have given.
columns names the database columns to write, and no columns means all of them. The distinction is what makes two concurrent PATCHes of different fields both survive: writing every column means the second request writes the first one's fields back to what they were when it read them, so a change to a field nobody touched is lost. Spec.Mount passes exactly the columns the patch body named. crud.Schema is the only thing that produces these names; a caller that invents one gets whatever GORM makes of it.
A failed write aborts the whole transaction, in Postgres as everywhere: a caller that means to try something else after a conflict needs a new transaction, which for an HTTP handler means a new request.
Types ¶
type Base ¶
type Base struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" required:"false" readOnly:"true"`
TenantID uuid.UUID `gorm:"type:uuid;not null" json:"-"`
CreatedAt time.Time `json:"createdAt" required:"false" readOnly:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"false" readOnly:"true"`
DeletedAt *time.Time `gorm:"index" json:"-"`
}
Base is embedded by every tenant-owned entity. The kernel sets TenantID from the transaction's scope; a module never assigns it, and never sees it in JSON either, because a tenant that a caller could send is a tenant a caller could change.
The three fields a caller may read are marked read-only for OpenAPI and not required in a request body: the server sets all of them, so a create that had to send an id would be a create that could choose one.
type Entity ¶
type Entity interface {
TableName() string
// contains filtered or unexported methods
}
Entity is a tenant-owned row: a table name and an embedded Base.
type Field ¶
type Field struct {
// Name is the JSON name, which is the only name a caller ever uses.
Name string `json:"name"`
// Column is the database column, and the only string this package ever
// interpolates into SQL. It comes from the struct, never from a request.
Column string `json:"-"`
Type FieldType `json:"type"`
// Elem is what a TypeList holds, and empty for everything else.
Elem FieldType `json:"elem,omitempty"`
// Widget overrides the control a screen would pick from Type: `ui:"widget:select"`.
Widget string `json:"widget,omitempty"`
// Enum is the closed set of values, from `enum:"open,done"`.
Enum []string `json:"enum,omitempty"`
// Required comes from `validate:"required"`.
Required bool `json:"required,omitempty"`
// ReadOnly marks the fields Base contributes: a caller may read them and
// may not write them, so they are skipped by the PATCH merge.
ReadOnly bool `json:"readOnly,omitempty"`
// HideList keeps a field off the list screen, from `ui:"hide:list"`.
HideList bool `json:"hideList,omitempty"`
// Default is the value the entity declares for a field a caller may leave
// out, from `default:"open"` — the same tag huma reads, so the form and the
// API document agree about what happens when nothing is sent. A form
// preselects it, and a select that has one needs no "Choose a …" placeholder
// because there is no unchosen state to name.
Default string `json:"default,omitempty"`
// Doc is what the field is for, from `doc:"Lifecycle state"` — again huma's
// own tag, so the sentence in the OpenAPI document is the sentence under the
// control. It is a description and not a label: the entities here write
// "Short summary of the task", which reads under an input and not on it.
Doc string `json:"doc,omitempty"`
// Index locates the field in the struct. It is exported for one caller,
// kit/rest's PATCH merge, which decodes a body into the field this names;
// json:"-" because a screen has no use for it and a caller none at all.
Index []int `json:"-"`
}
Field is one column, as the API and a screen see it.
func FieldNamed ¶
FieldNamed is the field with this JSON name, which is the only name a caller ever uses. It is exported for kit/rest, whose PATCH merge and query parsing resolve a caller's field names through the same schema the SQL here does.
type FieldType ¶
type FieldType string
FieldType is the closed set of shapes a screen knows how to render and a query knows how to compare. A field of any other Go type is left out of the schema entirely, so it is neither rendered nor sortable nor filterable — it is still stored, and still in the JSON, because that is encoding/json's business and not this package's.
const ( TypeString FieldType = "string" TypeText FieldType = "text" TypeInt FieldType = "int" TypeFloat FieldType = "float" TypeBool FieldType = "bool" TypeTime FieldType = "time" TypeUUID FieldType = "uuid" // TypeList is a slice of one of the above, which Field.Elem names. A user's // roles is the case that made it necessary: without it the field was in no // schema, so it rendered nowhere, no filter could refuse it and Immutable // could not name it — a PATCH could not reach it either, but only because // the field did not exist, which is the right answer for the wrong reason. TypeList FieldType = "list" )
type Query ¶
Query is a list request: a page, an order and a set of equality filters. Sort is "field" or "-field" and every name is checked against the entity's schema, so a column name never comes from a caller.
type Schema ¶
type Schema struct {
Module string `json:"module"`
Entity string `json:"entity"`
Path string `json:"path"`
Fields []Field `json:"fields"`
}
Schema is what an entity looks like to something that did not compile against it: the generated screens of stage E4, and the sort and filter checks here.