types

package
v0.0.0-...-dca2bd9 Latest Latest
Warning

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

Go to latest
Published: Oct 17, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package types defines the contracts between the framework and business projects: the Model, Service, Database, Selector, Cache, RBAC, and Logger interfaces, the query building blocks they exchange (Filter, Order, Cursor, Column, Term, Window), and the per-request ServiceContext. Framework code imports this package directly; business projects reach its contracts through the public gst package, which forwards them under the same names.

Index

Constants

View Source
const DefaultCountAlias = "count"

DefaultCountAlias is the alias COUNT(*) projects under when the caller does not rename it. A column term defaults to its column name, but COUNT(*) names no column, so without a default of its own it would be the one term that always had to be renamed.

View Source
const FailureMsg = "The server could not process the request."

FailureMsg is the message a failure carrying no Error is answered with on every transport, the server's own failure: 500 with it in the HTTP envelope (see response.Error), Internal with it over gRPC (see grpcserver.StatusError). What went wrong stays in the log.

View Source
const FilterTimeLayout = "2006-01-02 15:04:05.999999999"

FilterTimeLayout is the canonical layout a time-typed filter value parsed from a URL is normalized to. The value travels as a string rather than a time.Time on purpose: binding a time.Time would let the driver re-render it in its own location, while the string pins the wall-clock time the parser resolved. The pinned wall clock is UTC, the one wall clock the framework stores on every dialect.

The URL parser writes the layout and Values, ExcludedValues and Bounds on a column reference read it back, so a service reading a time goes through them rather than parsing with the layout directly.

View Source
const StatusClientClosedRequest = 499

StatusClientClosedRequest is the HTTP status a request is answered with once its client went away before the answer: nginx's 499, which the gRPC mapping answers Canceled (see grpcserver.StatusError), as google.rpc's canonical mapping of its Canceled code does. The standard library names no status for it.

Variables

View Source
var ErrEntryNotFound = errors.New("cache entry not found")

ErrEntryNotFound is returned when a cache entry is not found, or when the stored value cannot be decoded as the handle's type and the entry is dropped.

View Source
var ErrTTLNotSupported = errors.New("cache backend does not support the requested ttl")

ErrTTLNotSupported is returned by Cache.Set when the backend cannot honor the requested ttl semantics, such as a per-entry lifetime on a backend without per-entry expiration.

View Source
var HTTPOnlyMethods = []string{"Cookie", "Data", "FormFile", "PostForm", "SSE", "SetCookie"}

HTTPOnlyMethods names the methods of ServiceContext that only an HTTP request can serve, sorted: Cookie, PostForm and FormFile read what only an HTTP request carries, Data and SSE write the response themselves, and SetCookie writes a response header. Over gRPC they have nothing to work on, so the services of a model declaring GRPC() must not call them; gg check holds them to it (its check named gRPC service context), and a call that reaches one at run time records it for HTTPOnlyMethodCalled, which the transport answers as a failure once the hook or the service method returns.

Functions

func HTTPOnlyMethodCalled

func HTTPOnlyMethodCalled(sc *ServiceContext) bool

HTTPOnlyMethodCalled reports whether a service called a method of sc only an HTTP request can serve (see HTTPOnlyMethods) -- wrote a body, a stream or a cookie, or read a cookie, a form value or a file -- while sc carried no HTTP request or response. The call itself does nothing, answering the zero value where it reads; the transport behind such a context reads the flag once the hook or the service method returns and refuses the request, because what the service meant to send cannot be carried and what it meant to read was never there. It is a package function rather than a method so it stays out of the public alias of ServiceContext: only the framework's transports read it.

func RequestUserID

func RequestUserID(ctx context.Context) string

RequestUserID reports the authenticated subject of the request ctx descends from, or "" when no request is behind it.

It exists for code that receives a plain context and still has to know who is acting — a model hook guarding an operation, like tenant.From for a model deriving its key. An empty answer means machinery rather than a person: seeding, a scheduled job, framework code; or a request nothing authenticated, a public action's or one of a project that mounts no session check (see iam.Register).

func TermAliasOf

func TermAliasOf(t Term) string

TermAliasOf returns the alias recorded on the term.

func TermColumnOf

func TermColumnOf(t Term) string

TermColumnOf returns the snake case name of the column the term reads.

func TermConditionValueOf

func TermConditionValueOf(c TermCondition) any

TermConditionValueOf returns the value a condition compares the term with.

func TermLiteralOf

func TermLiteralOf(t Term) string

TermLiteralOf returns the constant a FnLiteral term projects.

func TermPlainOf

func TermPlainOf(t Term) bool

TermPlainOf reports whether the term is marked as a column projected as it is stored.

func TermTableOf

func TermTableOf(t Term) string

TermTableOf returns the table of the column the term reads.

Types

type AnyColumnRef

type AnyColumnRef interface {
	// Name returns the database column name resolved by gorm.
	Name() string
	// Table returns the table the column belongs to, read from the model's
	// TableName when the reference was built.
	Table() string
	// contains filtered or unexported methods
}

AnyColumnRef is the type-erased view of every generated column reference, for options that take a heterogeneous column list: WithSelect accepts columns of different Go types in one call, which the parameterized ColumnRef cannot express. The unexported method keeps the set of implementations closed to this package, so a stray type that happens to carry a Name method cannot slip into a column list.

type Assignment

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

Assignment is one column-value write, the unit UpdateByID accepts. Service code builds assignments through the generated column references (SampleCols.Status.Set(v)), whose typed front end stops a wrong-typed value or a misspelled column at compile time; generic code assigns through a reference minted for its type parameter. The NewAssignment constructor takes plain table and column names for framework code that learns the column only at run time. The fields are unexported so an assignment only comes from those constructors; Table, Column and Value read it back.

An Assignment never holds SQL. Column names are quoted by the database layer and values bind as statement parameters.

func NewAssignment

func NewAssignment(table, column string, value any) Assignment

NewAssignment builds an assignment of value to the named column of table; an empty table names the chain's own model. It serves framework code that learns the column only at run time and the framework's own tests; the public gst package does not forward it.

func (Assignment) Column

func (a Assignment) Column() string

Column returns the snake case name of the assigned column.

func (Assignment) Table

func (a Assignment) Table() string

Table returns the table the assigned column belongs to, or "" when the assignment names the chain's own model's column by name alone.

func (Assignment) Value

func (a Assignment) Value() any

Value returns the value the assignment writes to the column.

type BidiStream

type BidiStream[REQ Request, RSP Response] struct {
	// contains filtered or unexported fields
}

BidiStream is the request and response stream of a Stream action, a ClientStream and a ServerStream in one: Recv reads the requests the way ClientStream.Recv does, Send sends responses the way ServerStream.Send does, and the two are independent, so responses may go out before the requests are all in.

func NewBidiStream

func NewBidiStream[REQ Request, RSP Response](recv func() (REQ, error), send func(RSP) error) *BidiStream[REQ, RSP]

NewBidiStream builds a BidiStream reading requests through recv and sending responses through send.

func (*BidiStream[REQ, RSP]) Recv

func (s *BidiStream[REQ, RSP]) Recv() (REQ, error)

Recv returns the next request, io.EOF once the client finished sending.

func (*BidiStream[REQ, RSP]) Send

func (s *BidiStream[REQ, RSP]) Send(rsp RSP) error

Send sends rsp to the client.

type BidiStreamer

type BidiStreamer[REQ Request, RSP Response] interface {
	Stream(ctx *ServiceContext, stream *BidiStream[REQ, RSP]) error
}

BidiStreamer is the service of a Stream action declaring a StreamingPayload and a StreamingResult: Stream reads requests off stream and sends responses on it in whatever order the action wants, and returns once done.

type Bound

type Bound[T any] struct {
	Value     T
	Inclusive bool
	Present   bool
}

Bound is one end of the range a column's comparison filters confine it to: the value at that end, whether the range includes it, and whether any filter gave that end at all. An absent end leaves the range open on that side.

type Cache

type Cache[T any] interface {
	// Get retrieves a value from the cache by key.
	// Returns ErrEntryNotFound if the key does not exist or its value cannot
	// be decoded as T; an undecodable entry is dropped and logged.
	Get(ctx context.Context, key string) (T, error)

	// Set stores a value in the cache with the specified TTL.
	Set(ctx context.Context, key string, value T, ttl time.Duration) error

	// Delete removes a key from the cache.
	// Deleting a key that does not exist is not an error.
	Delete(ctx context.Context, key string) error

	// Exists checks if a key exists in the cache.
	// Returns true if the key exists, false otherwise.
	Exists(ctx context.Context, key string) bool
}

Cache provides a typed key/value cache abstraction.

Type Parameters:

  • T: Cached value type

Error Handling:

  • Get returns ErrEntryNotFound when the key does not exist, and when the stored value cannot be decoded as T: such an entry is dropped and logged at Warn, so the caller falls back to its source of truth and rewrites it exactly as it does for a missing key.
  • Delete is idempotent: deleting a missing key returns nil.
  • Set returns ErrTTLNotSupported when the backend cannot honor the requested ttl semantics.

TTL Semantics:

  • ttl == 0 means the entry never expires.
  • ttl > 0 sets a per-entry lifetime; backends that cannot honor it must return ErrTTLNotSupported instead of silently mis-honoring the request.
  • ttl < 0 is invalid and returns an error.

The ctx parameter carries cancellation and tracing for backends that talk to remote systems; in-memory backends may ignore it. A nil ctx is treated as context.Background(), so callers never need to normalize it themselves.

type ClientStream

type ClientStream[REQ Request] struct {
	// contains filtered or unexported fields
}

ClientStream is the request stream of a Stream action: Recv returns the next request the client sent, io.EOF once the client finished sending, and the error the transport reports otherwise, a request the framework refused among them (the call answers the refusal whatever the service returns then).

func NewClientStream

func NewClientStream[REQ Request](recv func() (REQ, error)) *ClientStream[REQ]

NewClientStream builds a ClientStream reading each request through recv.

func (*ClientStream[REQ]) Recv

func (s *ClientStream[REQ]) Recv() (REQ, error)

Recv returns the next request, io.EOF once the client finished sending.

type ClientStreamer

type ClientStreamer[REQ Request, RSP Response] interface {
	Stream(ctx *ServiceContext, stream *ClientStream[REQ]) (RSP, error)
}

ClientStreamer is the service of a Stream action declaring a StreamingPayload and a Result: Stream reads the requests off stream, one by one until Recv answers io.EOF, and answers the response.

type Column

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

Column is a typed reference to a database column, generated per model by gg gen. T is the Go type of the column, so a filter built through a Column cannot name a column that does not exist nor bind a value of the wrong type: both mistakes stop at compile time instead of surfacing as a SQL error or a silently wrong result set.

A reference also knows the table it was generated for. That is what lets a read reject a column of another model up front: two models often share a column name, and a query reading the wrong one is valid SQL over the wrong table, which no schema check would catch.

The methods are typed front ends for the FilterXxx, Asc, Desc and NewAssignment constructors and produce the same Filter, Order and Assignment values, with the table the reference was built for filled in. Generic code builds its references with its type parameter as the model, see NewColumn; the plain-name constructors are left to code that learns the column only at run time, such as framework internals reading it from a request.

Columns whose Go type is numeric, time.Time, or a JSON array of strings are generated as NumericColumn, TimeColumn or JSONStringsColumn instead, which embed this type and add the functions that are only meaningful there.

func NewColumn

func NewColumn[M TableNamer, T any](name string) Column[T]

NewColumn returns a typed reference to the named column of M's table. gg gen emits the calls in each model's generated file, naming the model as the first type argument, so the table comes from the model's own TableName and is never restated as a literal. Handwritten code, model hooks included, reads those generated Cols vars; gg check flags project code that mints a reference instead, with two exceptions. Generic code has no concrete model and so no Cols var: it names its type parameter as the model, and NewColumn[M, string]("id").In(ids...) keeps the value type checked where a plain column name would not. Module sources have no generated file and name their model the way gg gen does. The fields are unexported so a shared reference cannot be repointed at another column after construction.

A virtual model, one embedding model.Empty, has no table and reports an empty TableName; its references carry no table and resolve to the table of whichever query reads them, the way the plain-name constructors do, which is all a resource without a table of its own can mean. An empty column name panics: references are constructed while generated code initializes its package-level Cols vars, so an incomplete one must not survive process startup.

func (Column[T]) As

func (c Column[T]) As(alias string) Term

As projects the column as it is stored under alias, the way a column reference passed to Select directly projects under its own name. It aligns a column with a differently named field of the result row, which a union branch needs when the models it stacks spell a column differently:

RefundCols.SettledAt.As("created_at")
// `settled_at` AS `created_at`

A group key is renamed on the term instead: Cols.X.Group().As("y"). An empty alias changes nothing, as on a term.

func (Column[T]) Asc

func (c Column[T]) Asc() Order

Asc orders by the column ascending. The order carries the table the reference was built for, which the chain's reads and a select check: a select that joins tells two tables' columns of one name apart by it, and a chain refuses an order of another model; see Order.

func (Column[T]) Bounds

func (c Column[T]) Bounds(filters []Filter) (lower, upper Bound[T], err error)

Bounds returns the ends of the range this column's comparison filters confine it to: gt and gte give the lower end, lt and lte the upper one, each converted to the column's type. Filters on other columns are ignored; Split tells which filters are this column's.

Any other operator on this column is an error. So is a second filter for an end already given, which would leave the caller to work out the tighter of the two, and a value that does not convert.

func (Column[T]) Count

func (c Column[T]) Count() Term

Count counts the rows whose value of this column is not NULL. Use the package-level Count for COUNT(*), which counts every row.

func (Column[T]) CountDistinct

func (c Column[T]) CountDistinct() Term

CountDistinct counts the distinct non-NULL values of this column.

func (Column[T]) Desc

func (c Column[T]) Desc() Order

Desc orders by the column descending; see Asc.

func (Column[T]) EndsWith

func (c Column[T]) EndsWith(value string) Filter

EndsWith matches rows where the column ends with value.

func (Column[T]) Eq

func (c Column[T]) Eq(value T) Filter

Eq matches rows where the column equals value.

func (Column[T]) EqCol

func (c Column[T]) EqCol(parent ColumnRef[T]) Filter

EqCol ties this column to parent, a column of another table of the query: the enclosing query's model inside a subquery, the queried model or an earlier joined one inside a join; see FilterEqCol. Both must be columns of the same Go type, and the predicate carries both tables, so inside a join either column may be written first. A nil parent leaves the other side empty, and the predicate then fails the query.

The Col suffix says that the argument is a column rather than a value, which Eq takes; Go has no overloading to tell the two apart by type, and gorm gen spells the same comparison the same way.

func (Column[T]) ExcludedValues

func (c Column[T]) ExcludedValues(filters []Filter) ([]T, error)

ExcludedValues returns the values this column's exclusion filters keep it away from: the value of each ne filter and the members of each notin filter, in filter order and converted to the column's type. It is the counterpart of Values: filters on other columns are ignored, and any other operator on this column is an error, as is a value that does not convert.

func (Column[T]) Group

func (c Column[T]) Group() Term

Group makes this column a group key of the projection. The framework derives GROUP BY from the group keys, so a projection cannot disagree with its own GROUP BY list. Over a nullable column the rows without a value form a group of their own, keyed NULL, so the result field is a pointer or sql.Null type unless a condition on the column in Where keeps those rows out.

func (Column[T]) Gt

func (c Column[T]) Gt(value T) Filter

Gt matches rows where the column is greater than value.

func (Column[T]) Gte

func (c Column[T]) Gte(value T) Filter

Gte matches rows where the column is greater than or equal to value.

func (Column[T]) In

func (c Column[T]) In(values ...T) Filter

In matches rows where the column is one of values. Calling it without any value matches nothing, mirroring SQL list semantics.

func (Column[T]) IsNotNull

func (c Column[T]) IsNotNull() Filter

IsNotNull matches rows where the column is not NULL.

func (Column[T]) IsNull

func (c Column[T]) IsNull() Filter

IsNull matches rows where the column is NULL.

func (Column[T]) Lag

func (c Column[T]) Lag() Term

Lag reads this column from the previous row of the window, in the window's order; the first row of each partition has no previous row and yields NULL, so the result field must be a pointer. It only exists over an ordered window; see Term.Over.

func (Column[T]) Lead

func (c Column[T]) Lead() Term

Lead reads this column from the next row of the window; the last row of each partition yields NULL. The rules match Lag.

func (Column[T]) Like

func (c Column[T]) Like(value string) Filter

Like matches rows where the column contains value as a substring. The pattern is a string on every column type, because substring matching runs against the database's string rendering of the value.

func (Column[T]) Lt

func (c Column[T]) Lt(value T) Filter

Lt matches rows where the column is less than value.

func (Column[T]) Lte

func (c Column[T]) Lte(value T) Filter

Lte matches rows where the column is less than or equal to value.

func (Column[T]) Max

func (c Column[T]) Max() Term

Max returns the largest value of this column. The NULL rules match Min.

func (Column[T]) Min

func (c Column[T]) Min() Term

Min returns the smallest value of this column. It yields NULL for a group with no non-NULL value, so the result row field must be a pointer.

func (Column[T]) Name

func (c Column[T]) Name() string

Name returns the database column name resolved by gorm. It is also what the order and cursor constructors taking a plain column name expect.

func (Column[T]) Ne

func (c Column[T]) Ne(value T) Filter

Ne matches rows where the column does not equal value.

func (Column[T]) NotIn

func (c Column[T]) NotIn(values ...T) Filter

NotIn matches rows where the column is none of values. Calling it without any value matches nothing; it does not mean "exclude nothing".

func (Column[T]) NotLike

func (c Column[T]) NotLike(value string) Filter

NotLike matches rows where the column does not contain value as a substring.

func (Column[T]) NotRegex

func (c Column[T]) NotRegex(expr string) Filter

NotRegex matches rows where the column does not match the regular expression expr.

func (Column[T]) Regex

func (c Column[T]) Regex(expr string) Filter

Regex matches rows where the column matches the regular expression expr.

func (Column[T]) Set

func (c Column[T]) Set(value T) Assignment

Set assigns value to the column, the unit UpdateByID accepts. The value is typed by the column, so a wrong-typed value or a misspelled column fails to compile; the assignment carries the table the reference was built for, so a column of another model, which may well share the name, is refused when the write is built.

func (Column[T]) Split

func (c Column[T]) Split(filters []Filter) (own, rest []Filter)

Split separates the filters that apply to this column from the rest, keeping the order of both. A service that takes one column's conditions over from a request, such as a time window it resolves itself, splits them off before passing the rest on to the query.

A filter parsed from a request names no table and applies by column name alone; a filter built from a column reference names its table, which has to be this column's table as well. Groups and the other column-less filters are never this column's.

func (Column[T]) StartsWith

func (c Column[T]) StartsWith(value string) Filter

StartsWith matches rows where the column starts with value.

func (Column[T]) Table

func (c Column[T]) Table() string

Table returns the table the column belongs to, as the model's TableName reported it when the reference was built.

func (Column[T]) Values

func (c Column[T]) Values(filters []Filter) ([]T, error)

Values returns the values this column's equality filters hold it to: the value of each eq filter and the members of each in filter, in filter order and converted to the column's type. Filters on other columns are ignored; Split tells which filters are this column's.

Any other operator on this column is an error, and so is a value that does not convert: a condition the caller cannot honor is refused rather than dropped, which would widen the result.

type ColumnRef

type ColumnRef[T any] interface {
	AnyColumnRef
	// contains filtered or unexported methods
}

ColumnRef is the shared typed view of every generated column reference. Helpers that accept a column take this interface rather than a concrete struct, because embedding is not subtyping in Go: NumericColumn[T] cannot be passed where Column[T] is expected, so a helper typed on the struct would reject exactly the numeric and time columns it is most often used with.

The type parameter is load-bearing. sealedColumn mentions T, so two column references only satisfy the same ColumnRef[T] when their Go types match, which is what makes tying a string column to an integer column with EqCol fail to compile. The method is also unexported, so the set of implementations stays closed to this package.

type CompareOp

type CompareOp string

CompareOp is a comparison applied to a projected term. Only the six orderings exist: the pattern and set operators of FilterOp have no meaning over a measure or a window function.

const (
	CompareEq  CompareOp = "eq"
	CompareNe  CompareOp = "ne"
	CompareGt  CompareOp = "gt"
	CompareGte CompareOp = "gte"
	CompareLt  CompareOp = "lt"
	CompareLte CompareOp = "lte"
)

func TermConditionOpOf

func TermConditionOpOf(c TermCondition) CompareOp

TermConditionOpOf returns the comparison a condition applies.

func (CompareOp) Valid

func (o CompareOp) Valid() bool

Valid reports whether the comparison is one this package defines. An unknown operator would otherwise fall through to equality and silently filter by the wrong comparison.

type ControllerConfig

type ControllerConfig[M Model] struct {
	// ParamName names the route parameter that carries the resource ID.
	ParamName string
	// Route is the path the handler is registered at, /api/records/:rec, as
	// consts.APIPath spells it. The controller handlers derive the service
	// registry key from it, the key the corresponding service.Register call
	// registers under. router.Register fills it in; an empty route resolves
	// no service and the handler falls back to the no-op default service.
	Route string
}

ControllerConfig customizes how router.Register builds an internal handler for a route. It is the public configuration surface for controller behavior; the concrete controller handlers and their runtime state remain framework-owned.

type Cursor

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

Cursor is where a cursor-paginated read starts and which way it goes: the feed's stable ordering, the boundary row, and whether the read travels along that ordering or back down it.

This is the database argument. model.Cursor is the separate struct a model embeds to opt in to the cursor URL parameters that produce it. The fields are unexported so a cursor only comes from CursorForward, CursorBackward and the framework's URL parsing; Order, Value and Backward read it back.

The cursor orders the feed on every page, the first one included: a cursor without a boundary value reads the feed from its start, in its order, so the row a client pages on from is the row the order puts last. Traveling backward reverses both the boundary comparison and the ORDER BY, and List reverses the returned rows afterwards, so a backward page comes back in the feed's own order rather than upside down:

feed ASC,  forward   -> column > value, ORDER BY column ASC
feed ASC,  backward  -> column < value, ORDER BY column DESC, rows reversed
feed DESC, forward   -> column < value, ORDER BY column DESC
feed DESC, backward  -> column > value, ORDER BY column ASC,  rows reversed

URL-driven cursors always page an ascending feed; a descending feed is a service-side cursor, built with CursorForward on a Desc order.

func CursorBackward

func CursorBackward(order Order, value string) Cursor

CursorBackward pages against order, starting just before value, or at the feed's last row when value is empty.

func CursorForward

func CursorForward(order Order, value string) Cursor

CursorForward pages along order, starting just past value, or at the feed's first row when value is empty.

func (Cursor) Backward

func (c Cursor) Backward() bool

Backward reports whether the read travels against the ordering, which is what a request for the previous page means.

func (Cursor) Bounded

func (c Cursor) Bounded() bool

Bounded reports whether the cursor carries a boundary value to start past; the first page of a feed has none.

func (Cursor) Enabled

func (c Cursor) Enabled() bool

Enabled reports whether the cursor pages the read: it orders the feed by its column and, when Bounded, starts past the boundary. The zero Cursor reports false and leaves the query as it is.

func (Cursor) Order

func (c Cursor) Order() Order

Order returns the feed's stable ordering. An empty column in it falls back to the primary key in the database layer.

func (Cursor) Value

func (c Cursor) Value() string

Value returns the boundary row's column value; an empty value starts the read at the feed's own start, or at its end when traveling backward (see Bounded).

type Database

type Database[M Model] interface {
	// Create inserts one or more records (pure INSERT), setting framework IDs,
	// forcing created_at/updated_at to now and created_by/updated_by to the id
	// of the caller the context carries, when it carries one. A primary or
	// unique key collision fails with database.ErrDuplicatedKey instead of
	// updating the existing row. It writes the model's own table alone: the
	// associations an object carries are zeroed, not written.
	Create(objs ...M) error
	// Delete removes one or more records using WithPurge, the model Purge
	// setting, or soft delete by default. Every record named by id must
	// exist: a missing or soft-deleted one fails with
	// database.ErrRecordNotFound and the batch deletes nothing, unless
	// WithAllowMissing is set. Passed no record, it removes the rows the
	// WithQuery conditions match, every row of the table when AllowEmpty
	// declared it; records and conditions do not combine.
	Delete(objs ...M) error
	// Update saves one or more full model values by primary key (pure UPDATE,
	// zero values included), the model's own table alone: the associations an
	// object carries are zeroed, not written. Objects without an ID fail with
	// database.ErrIDRequired; records without a live row fail with
	// database.ErrRecordNotFound. created_at/created_by/deleted_at are never
	// written; updated_at is always refreshed by the framework, and updated_by
	// set to the id of the caller the context carries, when it carries one.
	// Concurrent updates of one record resolve as last writer wins: each
	// writes the whole value it holds, including columns another writer
	// changed in between. A model declares model.Version to have the stale
	// write refused instead.
	Update(objs ...M) error
	// Upsert inserts records or, on any unique-key collision, overwrites the
	// conflicting row (INSERT ... ON DUPLICATE KEY UPDATE), keeping the row's
	// creation facts and naming the caller the context carries as updater. It
	// runs no model hooks and re-syncs caller objects with the persisted rows;
	// reserve it for deliberate merge writes such as imports and sync jobs.
	Upsert(objs ...M) error
	// UpdateByID updates database columns of a record by its ID in one
	// UPDATE statement, without running model hooks, refreshing updated_at and
	// naming the caller the context carries as updater. Assignments come from
	// column references, such as SampleCols.Status.Set(v); at least one is
	// required, and empty columns, nil values and a column assigned twice are
	// rejected.
	UpdateByID(id string, assignments ...Assignment) error
	// List retrieves multiple records matching the query conditions.
	// dest must be a non-nil pointer to a slice; the slice value itself may be
	// nil or preallocated with make. List fully replaces the slice contents with
	// the query result: any pre-existing elements are discarded, never merged or
	// appended to. After a successful call len(*dest) equals the number of rows
	// returned, so a "dirty" dest does not leak stale rows into the result.
	List(dest *[]M) error
	// Get retrieves a single record by its ID.
	// The destination must be a non-nil pointer matching M. When M is *T,
	// both &value and new(T) are valid destinations; a nil *T returns ErrNilDest.
	// Get returns database.ErrRecordNotFound when no matching record exists.
	Get(dest M, id string) error
	// First retrieves the first record matching the current query conditions.
	// First returns database.ErrRecordNotFound when no matching record exists.
	First(dest M) error
	// Last retrieves the last record matching the current query conditions.
	// Last returns database.ErrRecordNotFound when no matching record exists.
	Last(dest M) error
	// Take retrieves the first record in no specified order.
	// Take returns database.ErrRecordNotFound when no matching record exists.
	Take(dest M) error
	// Count returns the total number of records matching the query conditions.
	Count(*int) error

	DatabaseOption[M]
}

Database defines the model-scoped database operation contract. It provides CRUD operations, query builders, and optional dry-run behavior for a single Model type.

Type Parameters:

  • M: Model type that implements Model interface

The interface embeds DatabaseOption[M] to provide chainable query building. A chain is expected to end with one terminal operation, such as Create, List, Get, or Count.

Implementations share an underlying GORM session. Call database.Database[M](ctx) again for each independent operation chain. Keeping the returned value in a variable and running another independent operation on it (for example, List then Get or Update) is incorrect usage; see database.Database.

Transactions are started with the package-level database.Transaction function; the context it passes to fn makes every chain started from that context join the transaction automatically.

type DatabaseOption

type DatabaseOption[M Model] interface {
	// WithQuery adds query conditions from the model's non-zero fields and
	// the operator filters carried by opts.
	WithQuery(query M, opts ...QueryOptions) Database[M]
	// WithCursor enables cursor-based pagination for List operations.
	WithCursor(cursor Cursor) Database[M]
	// WithSelect specifies columns for SELECT and Update column selection
	// where supported, through the generated column references.
	WithSelect(columns ...AnyColumnRef) Database[M]
	// WithLock adds row-level locking to SELECT queries (must be used within a transaction).
	WithLock(mode ...consts.LockMode) Database[M]
	// WithBatchSize sets the batch size for Create, Update, and Delete.
	WithBatchSize(size int) Database[M]
	// WithPagination applies pagination parameters (page, size) to the query.
	WithPagination(page, size int) Database[M]
	// WithLimit restricts the number of returned records for read operations.
	WithLimit(limit int) Database[M]
	// WithOrder adds ORDER BY terms to sort query results.
	WithOrder(orders ...Order) Database[M]
	// WithExpand enables eager loading of specified associations.
	WithExpand(expand []string, orders ...Order) Database[M]
	// WithPurge controls whether Delete permanently removes records instead of soft deleting them.
	WithPurge(...bool) Database[M]
	// WithAllowMissing lets Delete skip the records that are already gone
	// instead of failing with database.ErrRecordNotFound.
	WithAllowMissing() Database[M]
	// WithDeleted includes soft-deleted records in read operations (List, Get,
	// First, Last, Take, Count). Only the soft-delete condition is lifted;
	// combining it with a write operation or Cleanup fails the chain.
	WithDeleted() Database[M]
	// WithReplica routes this read to a configured read replica —
	// WithReplica(false) forces the primary instead, overriding a model's
	// PreferReplica default. Reads only; a write operation fails the chain.
	WithReplica(prefer ...bool) Database[M]
	// WithDryRun builds SQL without database I/O, framework hooks, cache
	// mutation, or object field filling. An optional collector receives the
	// generated Query, Args, and RenderedSQL of the next terminal operation.
	WithDryRun(collector ...*[]SQLStatement) Database[M]
	// WithoutHook disables model hooks for the operation.
	WithoutHook() Database[M]
}

DatabaseOption provides chainable options for a single Database operation chain. Options apply to the next terminal operation and are reset afterward. Start a new chain with database.Database[M](ctx) for each independent operation.

type Decision

type Decision struct {
	Allowed     bool
	Source      consts.GrantSource
	Reason      consts.DenyReason
	MatchedRule []string
}

Decision is the outcome of one authorization check.

Source names the strongest rule that allowed the request and is empty on a denial, because a denial has no granting rule. MatchedRule is the policy row that allowed it, and is nil unless Source names a policy: the rules that allow without consulting one leave the engine free to report an unrelated row, which would read as the reason for access while being nothing of the kind.

Reason is the mirror of Source and is set only on a denial, where naming a rule is not possible and what an operator needs instead is which step is missing. It is empty when the implementation could not tell, so an empty reason on a denial means unknown rather than none.

type ESDocumenter

type ESDocumenter interface {
	// Document returns a map representing an Elasticsearch document.
	// The returned map should contain all fields to be indexed, where:
	//   - keys are field names (string type)
	//   - values are field values (any type)
	//
	// Implementation notes:
	//   1. The returned map should only contain JSON-serializable values.
	//   2. Field names should match those defined in the Elasticsearch mapping.
	//   3. Complex types (like nested objects or arrays) should be correctly
	//      represented in the returned map.
	//
	// Example:
	//   return map[string]any{
	//       "id":    "1234",
	//       "title": "Sample Document",
	//       "tags":  []string{"tag1", "tag2"},
	//   }
	Document() map[string]any

	// GetID returns a string that uniquely identifies the document.
	// This ID is typically used as the Elasticsearch document ID.
	//
	// Implementation notes:
	//   1. The ID should be unique within the index.
	//   2. If no custom ID is needed, consider returning an empty string
	//      to let Elasticsearch auto-generate an ID.
	//   3. The ID should be a string, even if it's originally a numeric value.
	//
	// Example:
	//   return "user_12345"
	GetID() string
}

ESDocumenter represents a document that can be indexed into Elasticsearch. Types implementing this interface should be able to convert themselves into a document format suitable for Elasticsearch indexing.

type Error

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

Error is the error a request is answered with: a status and a client-safe message, with an internal cause for the logs. Service methods, model hooks, middleware and interceptors build it; the root gst package re-exports it and its constructors to application code.

func NewError

func NewError(status int, msg string) *Error

NewError creates the error a request is answered with: status and a client-safe message.

The status must be a 4xx or 5xx HTTP status code. Invalid statuses, including 2xx/3xx success or redirect statuses such as http.StatusOK, are normalized to http.StatusInternalServerError and the provided message is discarded.

func NewErrorWithCause

func NewErrorWithCause(status int, msg string, cause error) *Error

NewErrorWithCause creates the error a request is answered with, carrying an internal cause.

The status must be a 4xx or 5xx HTTP status code. Invalid statuses, including 2xx/3xx success or redirect statuses such as http.StatusOK, are normalized to http.StatusInternalServerError and the provided message is discarded.

The cause is reported by Error for logs and available through Unwrap, but is never exposed as the response message.

func NewInvalidFields

func NewInvalidFields(violations []FieldViolation, cause error) *Error

NewInvalidFields creates the 400 of a request whose fields the validator refused: the message joins the description of each violation with a semicolon, the violations stay readable (see FieldViolations), and cause, the validator's error, is reported by Error for logs and available through Unwrap.

func NewInvalidValue

func NewInvalidValue(field, description string, cause error) *Error

NewInvalidValue creates the 400 of a request whose field carries a value the field cannot hold, an integer out of its range, a string where a number is due: the message names the field by its JSON key path, "invalid value for field 'items[1].rank'", the one violation carries field and description for the gRPC details (see FieldViolations), and cause, the decoder's error, is reported by Error for logs and available through Unwrap.

func (*Error) Error

func (e *Error) Error() string

Error reports the client-safe message followed by the cause chain, so log consumers rendering err.Error() (zap's error field, sugared positional logging) capture the internal cause. The response envelope renders Msg instead, keeping the cause out of API responses.

func (*Error) Msg

func (e *Error) Msg() string

Msg returns the client-safe message the error answers with; a nil receiver answers FailureMsg, the message of every other failure of the server's own, so the envelope and the gRPC status word the two alike.

func (*Error) StackTrace

func (e *Error) StackTrace() errbase.StackTrace

StackTrace implements errbase.StackTraceProvider, exposing the stack captured at the construction site in the github.com/pkg/errors format that errors.GetReportableStackTrace recognizes. When the error also wraps a cause carrying its own stack trace, consumers that pick the deepest stack in the unwrap chain keep preferring the cause's origin.

func (*Error) Status

func (e *Error) Status() int

Status returns the HTTP status the error answers with; a nil receiver, the typed nil a `var e *Error; return e` hands its caller, answers the default, 500.

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Expr

type Expr interface {
	// contains filtered or unexported methods
}

Expr is what a projection selects and a window partitions by: a column reference, projected as it is stored, or a Term. The set is closed to this package, so a projection can never carry SQL text.

type FieldViolation

type FieldViolation struct {
	Field       string
	Description string
}

FieldViolation is one field of a request the validator refused: the field by its JSON key path, address.city or items[1].name, and the client-safe description of what it failed, the shape of google.rpc.BadRequest's FieldViolation.

func FieldViolations

func FieldViolations(err *Error) []FieldViolation

FieldViolations returns the fields the request err answers for failed on, which only an error NewInvalidFields created carries; nil for any other error, and for nil. It is a package function rather than a method so it stays out of the public alias of Error: only the framework's gRPC answer reads the fields, to list them as its details, and the type they come as is the framework's own.

type Filter

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

Filter is one field-level filter to apply as an AND condition: the column it compares, the table that column belongs to, the operator and the value.

The fields are unexported so a filter only comes from the constructors that keep it well formed: the generated column references, a reference minted for a type parameter in generic code, the grouping, subquery and constant constructors, and the plain-name constructors the framework parses requests with. Table, Column, Op and Value read a filter back; Split, Values, ExcludedValues and Bounds on a column reference read one column's filters converted to the column's type.

Table is filled in by a column reference and left empty by the plain-name constructors, which names the queried model's own table. A filter carrying another table is applied to that table when the query joins it and fails closed otherwise. The value is always bound as a statement parameter, in the shape its operator requires:

  • FilterOpIn and FilterOpNotIn require a slice or array value.
  • FilterOpIsNull requires a bool value.
  • FilterOpLike, FilterOpNotLike, FilterOpStartsWith, FilterOpEndsWith, FilterOpRegex, FilterOpNotRegex, and FilterOpJSONContains require a string value.
  • FilterOpOr and FilterOpAnd require a non-empty []Filter value and carry no column: they group their children instead of naming one themselves.
  • FilterOpExists requires a Subquery value and carries no column; see FilterExists.
  • FilterOpEqCol requires the other column: its name as a string, or the column reference itself, which also carries its table. It renders inside a subquery and inside a join; see FilterEqCol.
  • FilterOpFalse carries neither column nor value; see FilterFalse.
  • The comparison operators take a scalar value (string, numeric, time.Time); slices, arrays, and nil are rejected.

A value that violates these rules makes the filter unusable in the database layer, which never widens a query on it: a filter of a URL-exposed operator narrows the query to an empty result, one of a service-only operator fails it (see FilterOp).

func FilterAnd

func FilterAnd(filters ...Filter) Filter

FilterAnd groups filters that are AND-combined with each other. Filters are already AND-combined at the top level, so the group exists to nest an AND inside an OR group:

Filters: []types.Filter{
    SampleCols.TenantID.Eq(tenant),
    types.FilterOr(
        types.FilterAnd(
            SampleCols.Kind.Eq(KindPrimary),
            SampleCols.Status.Eq(StatusDone),
        ),
        types.FilterAnd(
            SampleCols.Kind.Eq(KindSecondary),
            SampleCols.Status.Eq(StatusPending),
        ),
    ),
}
// WHERE tenant_id = ?
//   AND ((kind = ? AND status = ?) OR (kind = ? AND status = ?))

A group with no children is unusable and fails the query.

func FilterEndsWith

func FilterEndsWith(column, value string) Filter

FilterEndsWith matches rows where column ends with value; value is escaped and matches literally.

func FilterEq

func FilterEq(column string, value any) Filter

FilterEq matches rows where column equals value.

func FilterEqCol

func FilterEqCol(column, parent string) Filter

FilterEqCol is the predicate that ties two tables together by a column each: inside FilterExists or FilterNotExists, column on the related model equals parent on the enclosing query's model, rendered as `child_table.column = outer_table.parent`; inside a Join, it is the ON condition. At the top level of a query without a join there is nothing to tie to and it fails the query, as does an empty name on either side or a name the related or the enclosing model does not have. Several of them express a composite key, and one inside a FilterOr group matches on any of its pairs. Inside a join only the pairs at the top level of the ON count toward the key the join is proved unique on: one inside a FilterOr group narrows the match but proves nothing.

The string form names the columns alone, which a subquery can place because both of its tables are known; a join needs the tables too, so its predicates are written with Column.EqCol, the typed front end that keeps the two columns of the same Go type and carries both tables.

func FilterExists

func FilterExists[C Model](filters ...Filter) Filter

FilterExists matches rows of the queried model that have at least one related row in C satisfying filters. EqCol predicates tie the related rows to the queried row, one per column pair, next to the ordinary conditions narrowing them:

types.FilterExists[*Item](
    ItemCols.SampleID.EqCol(SampleCols.ID),
    ItemCols.Status.Eq(StatusDone))
// EXISTS (SELECT 1 FROM `items`
//         WHERE `items`.`sample_id` = `samples`.`id`
//           AND `items`.`status` = ? AND `items`.`deleted_at` IS NULL)

A composite key is just more pairs, rendered in the order given:

types.FilterExists[*Item](
    ItemCols.TenantID.EqCol(SampleCols.TenantID),
    ItemCols.SampleID.EqCol(SampleCols.ID),
    ItemCols.Status.Eq(StatusDone))

The table names come from C and from the queried model; the predicate carries only the two column names. A subquery without any FilterEqCol fails closed rather than matching every row: nothing to correlate on is a mistake, not a request for a cross join.

It is an ordinary Filter, so List, Count, Export and Select all accept it; it is service-only and has no URL spelling, because a client-supplied subquery is an unbounded read of a table the endpoint never named.

func FilterFalse

func FilterFalse() Filter

FilterFalse matches nothing. It is the condition a permission hook returns when the caller may see no row at all. Unlike an empty filter list it is a real condition, so it disables the empty-query safety check; unlike a filter the renderer cannot apply it is deliberate, so nothing is logged. It renders as 1 = 0 on every dialect and composes like any other filter, inside groups, subqueries and conditional measures included.

func FilterGt

func FilterGt(column string, value any) Filter

FilterGt matches rows where column is greater than value.

func FilterGte

func FilterGte(column string, value any) Filter

FilterGte matches rows where column is greater than or equal to value.

func FilterIn

func FilterIn[T any](column string, values []T) Filter

FilterIn matches rows where column is one of values. The slice is bound as a whole; an empty slice matches nothing.

func FilterIsNotNull

func FilterIsNotNull(column string) Filter

FilterIsNotNull matches rows whose column is not NULL.

func FilterIsNull

func FilterIsNull(column string) Filter

FilterIsNull matches rows whose column is NULL.

func FilterJSONContains

func FilterJSONContains(column, value string) Filter

FilterJSONContains matches rows whose JSON array column contains value as a member; on a column storing no JSON document the filter is unusable and the query fails.

func FilterLike

func FilterLike(column, value string) Filter

FilterLike matches rows where column contains value as a substring; value is escaped and matches literally.

func FilterLt

func FilterLt(column string, value any) Filter

FilterLt matches rows where column is less than value.

func FilterLte

func FilterLte(column string, value any) Filter

FilterLte matches rows where column is less than or equal to value.

func FilterNe

func FilterNe(column string, value any) Filter

FilterNe matches rows where column does not equal value.

func FilterNotExists

func FilterNotExists[C Model](filters ...Filter) Filter

FilterNotExists matches rows that have no related row in C satisfying filters. Note that it is not the negation of a filtered FilterExists over the same rows: a row whose related rows all fail filters matches, and so does a row with no related rows at all. A subquery without any FilterEqCol fails the query here as well, so that negating "match nothing" cannot widen it into "match everything".

func FilterNotIn

func FilterNotIn[T any](column string, values []T) Filter

FilterNotIn matches rows where column is none of values. The slice is bound as a whole; an empty slice matches nothing (SQL NOT IN over an empty list never holds), it does not mean "exclude nothing".

func FilterNotLike

func FilterNotLike(column, value string) Filter

FilterNotLike matches rows where column does not contain value as a substring; value is escaped and matches literally.

func FilterNotRegex

func FilterNotRegex(column, expr string) Filter

FilterNotRegex matches rows where column does not match the regular expression expr.

func FilterOr

func FilterOr(filters ...Filter) Filter

FilterOr groups filters that are OR-combined with each other. The group as a whole stays AND-combined with every other condition of the query, so a mandatory condition such as tenant scoping can never be absorbed into the alternatives:

Filters: []types.Filter{
    SampleCols.TenantID.Eq(tenant),
    types.FilterOr(
        SampleCols.Name.Like(keyword),
        SampleCols.Code.Like(keyword),
    ),
}
// WHERE tenant_id = ? AND (name LIKE ? OR code LIKE ?)

Children may themselves be groups, which is how nesting is expressed; see FilterAnd for the "(a AND b) OR (c AND d)" shape. A group with no children is unusable and fails the query.

func FilterRegex

func FilterRegex(column, expr string) Filter

FilterRegex matches rows where column matches the regular expression expr (dialect-aware REGEXP).

func FilterStartsWith

func FilterStartsWith(column, value string) Filter

FilterStartsWith matches rows where column starts with value; value is escaped and matches literally, and the prefix form can use an index.

func NewFilter

func NewFilter(table, column string, op FilterOp, value any) Filter

NewFilter builds a filter from its parts, without the shape checks the other constructors lock in at compile time. It serves the framework's own tests, which exercise how the database layer treats a malformed filter; the public gst package does not forward it.

func TermConditionsOf

func TermConditionsOf(t Term) []Filter

TermConditionsOf returns the filters a conditional measure restricts its rows to.

func (Filter) Column

func (f Filter) Column() string

Column returns the snake case name of the column the filter compares, or "" for a group, a subquery and the constant predicate, which name no column.

func (Filter) Op

func (f Filter) Op() FilterOp

Op returns the filter's operator.

func (Filter) Table

func (f Filter) Table() string

Table returns the table the filter's column belongs to, or "" when the filter names the queried model's own column or no column at all.

func (Filter) Value

func (f Filter) Value() any

Value returns the filter's value in the shape its operator requires; see Filter. A filter parsed from a request carries the value the parser normalized: the string spelling of a number, the UTC wall clock in FilterTimeLayout for a time, and a []string for the members of in and notin. Values, ExcludedValues and Bounds on a column reference convert it to the column's type. A slice, group or subquery value is shared with the filter and must not be modified.

type FilterOp

type FilterOp string

FilterOp is a field-level filter operator applied by WithQuery as an additional AND condition. Operators never widen a query: unknown values are rejected during parsing, and the database layer never drops a condition it cannot apply, narrowing the query to an empty result on one of a URL-exposed operator and failing it on one of a service-only operator, which no client can have sent.

Operators come in two tiers, and the split is load-bearing:

  • URL-exposed operators are registered in the filterOps parse map and carried by the List/Export query parameter syntax "field[op]=value"; FilterOps returns them for API documentation.
  • Service-only operators exist as constants and execute in the database layer, but are intentionally absent from the parse map: they never validate column types or values at the URL boundary, so exposing one requires adding that validation first, not just registering the name.
const (
	FilterOpEq         FilterOp = "eq"         // equal: column = value
	FilterOpNe         FilterOp = "ne"         // not equal: column <> value
	FilterOpGt         FilterOp = "gt"         // greater than: column > value
	FilterOpGte        FilterOp = "gte"        // greater than or equal: column >= value
	FilterOpLt         FilterOp = "lt"         // less than: column < value
	FilterOpLte        FilterOp = "lte"        // less than or equal: column <= value
	FilterOpIn         FilterOp = "in"         // set membership: column IN (comma-separated values)
	FilterOpNotIn      FilterOp = "notin"      // set exclusion: column NOT IN (comma-separated values)
	FilterOpLike       FilterOp = "like"       // substring match: column LIKE %value%
	FilterOpNotLike    FilterOp = "notlike"    // substring exclusion: column NOT LIKE %value%
	FilterOpStartsWith FilterOp = "startswith" // prefix match: column LIKE value% (can use an index)
	FilterOpEndsWith   FilterOp = "endswith"   // suffix match: column LIKE %value
	FilterOpIsNull     FilterOp = "isnull"     // null check: value true means IS NULL, false means IS NOT NULL
)

URL-exposed operators.

const (
	FilterOpRegex        FilterOp = "regex"        // regular expression match: column REGEXP value (dialect-aware)
	FilterOpNotRegex     FilterOp = "notregex"     // regular expression exclusion: NOT (column REGEXP value)
	FilterOpJSONContains FilterOp = "jsoncontains" // JSON array membership: value is a member of the JSON array column
	FilterOpOr           FilterOp = "or"           // group: the []Filter value is OR-combined, the group itself AND-combined
	FilterOpAnd          FilterOp = "and"          // group: the []Filter value is AND-combined, for nesting inside an OR group
	FilterOpExists       FilterOp = "exists"       // correlated subquery: the Subquery value becomes EXISTS or NOT EXISTS
	FilterOpEqCol        FilterOp = "eqcol"        // column equals another column, named by the value as a plain name or a column reference: the enclosing query's inside a subquery, a table read beside it inside a join
	FilterOpFalse        FilterOp = "false"        // constant predicate: matches nothing, see FilterFalse
)

Service-only operators: for service code building Filters directly, reusable and injection-safe alternatives to raw SQL fragments.

func FilterOps

func FilterOps() []FilterOp

FilterOps returns every URL-exposed operator in a stable order, for API documentation surfaces such as the generated OpenAPI parameter notes. Service-only operators are excluded on purpose: they are not part of the URL contract.

func ParseFilterOp

func ParseFilterOp(s string) (FilterOp, bool)

ParseFilterOp converts an operator token from a "field[op]" query key into a FilterOp, reporting whether the token is a known operator.

type JSONStringsColumn

type JSONStringsColumn struct {
	Column[datatypes.JSONSlice[string]]
}

JSONStringsColumn is the reference generated for a column storing a JSON array of strings. It embeds Column over datatypes.JSONSlice[string], the type that binds such an array as a JSON document — a document held in a type of its own converts to it, a plain []string would bind as a list of parameters — and adds the membership test, which only such a column answers alike on every dialect: PostgreSQL's jsonb ? operator matches a string element only, so over an array of numbers the test is silently empty, and over a column storing no JSON document it is a SQL error.

func NewJSONStringsColumn

func NewJSONStringsColumn[M TableNamer](name string) JSONStringsColumn

NewJSONStringsColumn returns the reference to the named column of M's table storing a JSON array of strings, carrying JSONContains on top of everything Column has.

func (JSONStringsColumn) JSONContains

func (c JSONStringsColumn) JSONContains(value string) Filter

JSONContains matches rows whose array contains value as a member.

type JoinSource

type JoinSource interface {
	// contains filtered or unexported methods
}

JoinSource is a source a select joins to its model. The set is closed to this package: a ModelJoin, built by Join and LeftJoin, joins a model on a unique key; a SelectJoin, built by JoinSelect and LeftJoinSelect, joins a grouped select on its group keys.

A join is allowed only where it cannot multiply rows. The ON predicates must pin the joined model's primary key or one of its unique indexes — or every group key of the joined select — with equalities, each to a column of the queried model or of an earlier joined one, written with Column.EqCol, or to a value, so that every row of the query matches at most one joined row; the framework proves this from the model's declarations when the query is built and rejects a join it cannot prove. A one-to-many relation is read through a joined select that groups the many side by the key, or with FilterExists when only the existence of related rows matters.

The joined model's soft-delete condition is written into the ON, so a join never reads a row a List on that model hides. Inside a grouped projection a joined column may be a group key or carry MIN, MAX or COUNT DISTINCT, the aggregates that answer the same however many rows of a group share the joined row; a SUM or COUNT over it would count that row once per row of the group. A column of a model joined with LeftJoin can come back NULL, so its result field must be a pointer or a sql.Null type.

type paymentWithAccount struct {
    ID          string
    Amount      int64
    AccountName *string // LEFT JOIN: NULL when the account is missing
}
err := database.Select[*Payment, paymentWithAccount](ctx,
    PaymentCols.ID, PaymentCols.Amount, AccountCols.Name.As("account_name")).
    Join(types.LeftJoin[*Account](AccountCols.Code.EqCol(PaymentCols.Account))).
    Scan(&rows)

Rendered, every column qualified by its table because two tables are read:

SELECT `payments`.`id` AS `id`, `payments`.`amount` AS `amount`, `accounts`.`name` AS `account_name`
FROM `payments`
LEFT JOIN `accounts` ON `accounts`.`code` = `payments`.`account` AND `accounts`.`deleted_at` IS NULL
WHERE `payments`.`deleted_at` IS NULL

Inside the ON a filter built from a plain name, FilterEq say, names a column of the joined model; a column of the query is named through its reference, which carries its table.

ClickHouse carries no unique constraints, so a model join cannot be proved there and is rejected on a ClickHouse instance.

func Join

func Join[C Model](on ...Filter) JoinSource

Join joins model C on a unique key: JOIN, keeping only the rows of the query that match a row of C. The predicates are the ON condition:

types.Join[*Account](AccountCols.Code.EqCol(PaymentCols.Account), AccountCols.Tier.Eq("gold"))
// JOIN `accounts` ON `accounts`.`code` = `payments`.`account` AND `accounts`.`tier` = ?
//                    AND `accounts`.`deleted_at` IS NULL

func JoinSelect

func JoinSelect[R any](sub SelectBranch[R], on ...Filter) JoinSource

JoinSelect joins a grouped select as a derived table, JOIN (SELECT ...) AS jN ON ..., keeping only the rows of the query that match one of its groups. This is how a one-to-many relation is read beside its one side: the many side is grouped by the key first, so every key has one row, and that row is joined.

The ON pins every group key of the select, each named through the select's model column reference and tied to a column of the query with Column.EqCol, or to a value. The query projects the select's terms by passing the very same terms, which then read as columns of the derived table:

orderTotal := OrderCols.Amount.Sum().As("order_total")
orders := database.Select[*Order, orderTotals](ctx, OrderCols.CustomerID.Group(), orderTotal)

err := database.Select[*Customer, customerOrders](ctx, CustomerCols.ID, CustomerCols.Name, orderTotal).
    Join(types.LeftJoinSelect(orders, OrderCols.CustomerID.EqCol(CustomerCols.ID))).
    Scan(&rows)
// SELECT `customers`.`id` AS `id`, `customers`.`name` AS `name`, `j0`.`order_total` AS `order_total`
// FROM `customers`
// LEFT JOIN (SELECT `customer_id` AS `customer_id`, COALESCE(SUM(`amount`), 0) AS `order_total`
//            FROM `orders` WHERE `orders`.`deleted_at` IS NULL GROUP BY `customer_id`) AS `j0`
//   ON `j0`.`customer_id` = `customers`.`id`
// WHERE `customers`.`deleted_at` IS NULL

Only the select's terms are readable this way, passed as they are: its model's other columns are not columns of the derived table, and the term altered — under another alias, or with a window or conditions of its own — is not the term. A term the query could compute itself — one carrying no table, Count() say, or one of the queried table or of a model the query joins — is one spelling whether the query or the select wrote it. Under its default alias, the one its constructor gave it and two authors write independently, it is refused when the select projects it too, since the query's own reading of it would silently become the select's; an alias is written on purpose, so the select's aliased term passed to the query reads it through, and the query's own term keeps apart under an alias different from the select's. A constant, Literal, is the query's own under any alias, so a select's constant is never read through; whether a row of the select matched is read from one of its keys, NULL where none did. A term read through may also key a window's partition or order it. In a grouped query a joined select's term is projected as a group key of the query, which is exact only when the query groups by the columns the select is joined on — the term is then constant within a group — and the framework requires it; a query with no measure of its own reads row-level, every row beside the select's terms. A condition on a joined select's measure belongs to that select's Having, and a condition on its rows to its Where: the ON names its keys, and an EXISTS subquery there has no row of the select's model to correlate with, so it is refused. The select carries no OrderBy, Limit or Page of its own: a derived table has no use for them.

One table backs at most one source of a query. A joined select is addressed through its model's column references, so a select over the queried table, or a second select over a table that already backs a source of the query, could not be told apart and is refused; the tables a select reads through joins of its own are its own to read, and a select never joins itself, directly or through the selects it joins. A per-row total over the queried table's own groups is a window instead, Sum().Over(PartitionBy(key)). A select grouped by a time bucket cannot be joined either: the bucket is a label of the column, not a value a column of the query equals. The select is tied by its own model's group keys, each column once: a key of a table the select joins has no spelling from outside and is refused, while a term the select reads from a select of its own is a column of the derived table, not a key.

The derived table is materialized by the database, its rows being the groups of the select: a select narrowed by Where materializes fewer of them, so the conditions belong inside it rather than on the query around it.

func LeftJoin

func LeftJoin[C Model](on ...Filter) JoinSource

LeftJoin joins model C on a unique key, keeping the rows of the query that match no row of C with the joined columns NULL: LEFT JOIN. The rules match Join; the result fields the joined columns bind to must hold NULL.

func LeftJoinSelect

func LeftJoinSelect[R any](sub SelectBranch[R], on ...Filter) JoinSource

LeftJoinSelect joins a grouped select as a derived table, keeping the rows of the query that match none of its groups with the select's terms NULL: LEFT JOIN. The rules match JoinSelect; the result fields the select's terms bind to must hold NULL.

type Logger

type Logger interface {
	With(fields ...string) Logger

	WithContext(context.Context, consts.Phase) Logger

	Debug(args ...any)
	Info(args ...any)
	Warn(args ...any)
	Error(args ...any)
	Fatal(args ...any)

	Debugf(format string, args ...any)
	Infof(format string, args ...any)
	Warnf(format string, args ...any)
	Errorf(format string, args ...any)
	Fatalf(format string, args ...any)

	Debugw(msg string, keysAndValues ...any)
	Infow(msg string, keysAndValues ...any)
	Warnw(msg string, keysAndValues ...any)
	Errorw(msg string, keysAndValues ...any)
	Fatalw(msg string, keysAndValues ...any)

	Debugz(msg string, fields ...zap.Field)
	Infoz(msg string, fields ...zap.Field)
	Warnz(msg string, fields ...zap.Field)
	Errorz(msg string, fields ...zap.Field)
	Fatalz(msg string, fields ...zap.Field)
}

Logger is the logger the framework hands to services and modules and keeps in the logger package's streams. It writes an entry three ways: plain and printf-style, sugared with alternating key/value fields (the methods with suffix "w", with fields), and with typed zap.Field values (the methods with suffix "z", the low-allocation variants). With attaches string key/value fields; WithContext derives a logger carrying request metadata fields. Fatal and its variants follow the underlying logger's fatal behavior and terminate the process after writing the entry.

type Model

type Model interface {
	TableName() string  // TableName returns the explicit table name; gorm's Tabler reads the same method.
	GetID() string      // GetID returns the string form of the id, or "" when the id is unset.
	SetID(id ...string) // SetID sets the id when unset; Base generates a UUID without an argument while AutoBase leaves generation to the database.
	ClearID()           // ClearID always sets the id to empty.
	GetCreatedBy() string
	GetUpdatedBy() string
	GetCreatedAt() time.Time
	GetUpdatedAt() time.Time
	SetCreatedBy(string)
	SetUpdatedBy(string)
	SetCreatedAt(time.Time)
	SetUpdatedAt(time.Time)
	Expands() []string // Expands returns association paths that should be preloaded by default.
	Purge() bool       // Purge indicates whether to permanently delete records (hard delete). Default is false (soft delete).

	CreateBefore(context.Context) error
	CreateAfter(context.Context) error
	DeleteBefore(context.Context) error
	DeleteAfter(context.Context) error
	UpdateBefore(context.Context) error
	UpdateAfter(context.Context) error
	ListBefore(context.Context) error
	ListAfter(context.Context) error
	GetBefore(context.Context) error
	GetAfter(context.Context) error
}

Model defines the framework contract for database-backed and action models. Typical database resources embed model.Base (UUIDv7 string primary key) or model.AutoBase (auto-increment integer primary key). Action-only models may use model.Empty when they do not represent persistent rows.

Type Requirements:

  • Must be a pointer to struct (e.g., *User)
  • Database resources should expose an ID primary key through GetID/SetID/ClearID
  • Database resources must override TableName with an explicit non-empty name: gorm's Tabler reads the same method, and the base default "" is rejected at table preparation and inside gg migrate
  • Hooks should be idempotent enough to run as part of framework CRUD phases

type ModelJoin

type ModelJoin struct {
	// Model is an allocated instance of the joined model. It carries the
	// table name, the columns and the soft-delete scope, and the unique keys
	// the ON predicates are proved against.
	Model Model
	// Left keeps the rows of the query that match no joined row, with the
	// joined columns NULL: LEFT JOIN rather than JOIN.
	Left bool
	// On holds the ON predicates: the EqCol pairs tying the joined model to
	// the query, next to any condition narrowing the joined rows.
	On []Filter
}

ModelJoin is a model joined on a unique key; see JoinSource. Join and LeftJoin build it.

type Module

type Module[M Model, REQ Request, RSP Response] interface {
	// Service returns the service instance that handles business logic for this module.
	Service() Service[M, REQ, RSP]

	// Route returns the base API path for this module's endpoints.
	Route() string

	// Pub determines whether the API endpoints are public or require authentication.
	Pub() bool

	// Param returns the URL parameter name used for resource identification.
	Param() string
}

Module describes a registered API module: route metadata, auth exposure, resource parameter name, and the service implementation used by controllers.

Type Parameters:

  • M: Model type that implements Model interface
  • REQ: Request type for API operations
  • RSP: Response type for API operations

type NumericColumn

type NumericColumn[T any] struct {
	Column[T]
}

NumericColumn is the reference generated for a column whose Go type is numeric. It embeds Column, so every filter and order stays available, and adds the aggregate functions that only make sense over a number.

The specialization exists because of how the mistake fails, not because of tidiness: SUM over a text column returns 0 with a warning on MySQL and SQLite, so it surfaces as a plausible-looking wrong number on a dashboard rather than as an error. Functions whose misuse is merely useless rather than silently wrong stay on Column.

func NewNumericColumn

func NewNumericColumn[M TableNamer, T any](name string) NumericColumn[T]

NewNumericColumn returns the numeric reference to the named column of M's table, carrying Sum and Avg on top of everything Column has.

func (NumericColumn[T]) Avg

func (c NumericColumn[T]) Avg() Term

Avg averages this column. It yields NULL for a group with no non-NULL value and is never coalesced, because a zero average and no data are different answers; the result row field must be a pointer.

func (NumericColumn[T]) Sum

func (c NumericColumn[T]) Sum() Term

Sum adds up this column. The renderer wraps it in COALESCE(..., 0) so an empty group sums to zero rather than scanning NULL into the result row.

type Order

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

Order is one ORDER BY term: a column and the direction to sort it by.

The fields are unexported so an order only comes from the constructors: the generated column references (SampleCols.CreatedAt.Desc()), which cannot name a column the model does not have, a reference minted for a type parameter in generic code, and the Asc and Desc constructors, which take a plain column name for code that learns the column only at run time: framework internals and URL parsing. Table, Column and Descending read an order back, and SortsBy tells whether it sorts by a given column.

Table is filled in by a column reference and left empty by Asc and Desc. The chain's reads and a select check it: a select that joins tells two tables' columns of one name apart by it, and a chain refuses an order of another model. A union orders its result columns by name and reads no table, and WithExpand orders the associated table, so neither checks it. An Order with an empty column is skipped rather than rendered.

func Asc

func Asc(column string) Order

Asc builds an ascending order term for column.

func Desc

func Desc(column string) Order

Desc builds a descending order term for column.

func NewOrder

func NewOrder(table, column string, direction OrderDirection) Order

NewOrder builds an order from its parts. It serves the framework's own code and tests, which need an order on a named table or with a direction outside the set; the public gst package does not forward it.

func (Order) Column

func (o Order) Column() string

Column returns the snake case name of the column the order sorts by.

func (Order) Descending

func (o Order) Descending() bool

Descending reports whether the order sorts descending. Any other direction, the zero one included, sorts ascending.

func (Order) SortsBy

func (o Order) SortsBy(column AnyColumnRef) bool

SortsBy reports whether the order sorts by column. An order parsed from a request names no table and matches by column name alone; an order built from a column reference names its table, which has to be the column's table too.

func (Order) Table

func (o Order) Table() string

Table returns the table the order's column belongs to, or "" when the order names the queried model's column by name alone.

type OrderDirection

type OrderDirection string

OrderDirection is the sort direction of one ORDER BY term. The set is closed, so a direction can never carry SQL the way a free-form order string could.

const (
	// OrderAsc sorts ascending. The zero OrderDirection is also read as
	// ascending, matching SQL's own default for an ORDER BY term.
	OrderAsc OrderDirection = "ASC"
	// OrderDesc sorts descending.
	OrderDesc OrderDirection = "DESC"
)

func OrderDirectionOf

func OrderDirectionOf(o Order) OrderDirection

OrderDirectionOf returns the direction an order sorts in, for the database layer, which validates the direction and flips it for a backward cursor read; the public gst package does not forward it.

func TermOrderDirectionOf

func TermOrderDirectionOf(o TermOrder) OrderDirection

TermOrderDirectionOf returns the direction an order on a term sorts in.

func (OrderDirection) Flip

func (d OrderDirection) Flip() OrderDirection

Flip returns the opposite direction. Cursor pagination uses it to read a feed backwards: traveling against the feed reverses both the boundary comparison and the ORDER BY.

func (OrderDirection) Valid

func (d OrderDirection) Valid() bool

Valid reports whether the direction is one this package defines. A value from outside the set would otherwise fall through to ascending and sort a report the opposite of what the caller asked for.

type Ordering

type Ordering interface {
	// contains filtered or unexported methods
}

Ordering is what the OrderBy methods of a select, a window and a union accept: an Order sorting by a column reference, or a TermOrder sorting by a projection term. The set is closed, so an ordering can never carry SQL the way a free-form string could.

func WindowOrdersOf

func WindowOrdersOf(w Window) []Ordering

WindowOrdersOf returns the orderings each partition of a window is sorted by.

type Permission

type Permission struct {
	// Object is the protected resource, usually a route path template such as
	// /api/things/{id}.
	Object string

	// Action is the operation on that object, usually an HTTP method.
	Action string
}

Permission is one operation a role is allowed to perform on one object. It is the unit the whole-set replacement methods on RBAC take, so a caller states a role's permissions as a set rather than as a sequence of grants.

type QueryOptions

type QueryOptions struct {
	// AllowEmpty allows a query without any condition to match all records.
	// By default a nil model, a zero-value model, or all-empty field values
	// add the "1 = 0" safety condition instead, so a forgotten filter cannot
	// return or delete the whole table. Filters count as real conditions
	// and disable the safety check on their own. With it, a Delete passed no
	// record removes every row of the model's table (see Database.Delete).
	AllowEmpty bool

	// PresentFields marks columns whose filter values were explicitly provided
	// by the caller, keyed by snake case column name. Query construction treats
	// zero values (false, 0) of these columns as real conditions instead of
	// dropping them as unset, so a filter like "enabled=false" works. Columns
	// not listed here keep the default zero-value skip.
	PresentFields map[string]struct{}

	// Filters are field-level operator filters ("field[op]=value"). They
	// apply in every WithQuery path, including nil/empty model queries, so
	// List and Count stay consistent. A condition that cannot be applied is
	// never dropped: one of a URL-exposed operator with an empty column or a
	// mismatched value adds "1 = 0", one of a service-only operator fails
	// the query (see FilterOp).
	Filters []Filter
}

QueryOptions tunes how WithQuery turns a model value into WHERE conditions. Every condition it produces is AND-combined; the zero value means exact matching with the empty-query safety check enabled. See the WithQuery method for usage examples.

type RBAC

type RBAC interface {
	// Authorize reports whether subject may perform action on object inside
	// tenant, and what allowed it.
	//
	// Implementations should treat tenant as the authorization domain, subject as
	// the authenticated identity, object as the protected route or resource, and
	// action as the operation being checked, such as an HTTP method.
	//
	// The reason is answered alongside the decision rather than by a second
	// method. Deriving it costs a handful of allocations against the thousands
	// the decision itself takes, so a decision-only entry point would be a
	// second way to ask one question, distinguished by a saving too small to
	// measure.
	Authorize(ctx context.Context, tenant string, subject string, object string, action string) (Decision, error)

	// RemoveRole removes role from tenant, including its permission policies and
	// subject assignments. Callers should use this when deleting a role record so
	// authorization state does not retain stale grants.
	RemoveRole(ctx context.Context, tenant string, role string) error

	// SetRolePermissions replaces the entire permission set held by role inside
	// tenant with permissions, leaving the role's subject assignments untouched.
	//
	// It replaces rather than adds on purpose: the argument is the whole truth,
	// so an entry the caller drops stops allowing requests, and passing an empty
	// set revokes everything. A grant-only API would leave a removed entry
	// allowing requests forever, with nothing left in the source to show it.
	//
	// Implementations must apply the whole set as one step. Revoking and then
	// granting back one permission at a time exposes the role's members to an
	// empty or partial set while the replacement is in flight, which denies
	// requests the role is entitled to.
	//
	// It is the only way to write a role's permissions, which is why it takes
	// the whole set: an interface offering a single grant beside it would let a
	// caller build one up a row at a time and never learn that the entry it
	// dropped is still allowing requests.
	SetRolePermissions(ctx context.Context, tenant string, role string, permissions []Permission) error

	// SetPermissionsForAuthenticated replaces the entire set of permissions every
	// authenticated subject holds. The grant is bound to neither a tenant nor a
	// role, so it reaches subjects that hold no role at all, in every tenant.
	//
	// It is SetRolePermissions for the implicit role every authenticated subject
	// carries, and shares its contract: the argument is the whole truth, an empty
	// set revokes everything, and the whole set is applied as one step.
	//
	// Reserve it for objects that answer only about the caller and already narrow
	// their result to what the caller may see; anything else granted this way
	// becomes reachable by every subject that can log in. Unauthenticated requests
	// are unaffected, because authorization runs only after authentication.
	SetPermissionsForAuthenticated(ctx context.Context, permissions []Permission) error

	// AssignRole assigns subject to role inside tenant.
	// This creates tenant membership for subject and makes the role's
	// tenant-scoped permissions available to that subject.
	AssignRole(ctx context.Context, tenant string, subject string, role string) error

	// UnassignRole removes subject's assignment to role inside tenant.
	// Other roles held by the same subject in the same tenant are left unchanged.
	UnassignRole(ctx context.Context, tenant string, subject string, role string) error

	// RolesForSubject returns the roles subject holds inside tenant.
	//
	// It answers two questions at once: holding any role here is a non-empty
	// result, and holding one particular role is that role being among them.
	// Neither deserves an entry point of its own, and keeping the general one
	// leaves this and SubjectsInTenant as the two directions of a single
	// relation.
	RolesForSubject(ctx context.Context, tenant string, subject string) ([]string, error)

	// SubjectsInTenant returns subjects with at least one role assignment in
	// tenant. It checks membership, not whether any specific route is authorized.
	SubjectsInTenant(ctx context.Context, tenant string) ([]string, error)

	// AssignSystemRole assigns subject to a system-level role outside any tenant.
	// System roles are intended for cross-tenant framework privileges and should
	// not be used for ordinary tenant-local authorization.
	AssignSystemRole(ctx context.Context, subject string, role string) error

	// UnassignSystemRole removes subject's assignment to a system-level role.
	UnassignSystemRole(ctx context.Context, subject string, role string) error

	// HasSystemRole reports whether subject holds a system-level role.
	// This check is separate from Authorize because system roles are not scoped to
	// tenant route policies.
	HasSystemRole(ctx context.Context, subject string, role string) (bool, error)

	// RemoveSubject removes every role assignment held by subject, both
	// tenant-scoped and system-level, across all tenants. Use this when a
	// subject is deleted or deactivated so no orphaned role bindings remain.
	RemoveSubject(ctx context.Context, subject string) error

	// ReloadPolicies discards the authorization state the process holds in
	// memory and rebuilds it from storage.
	//
	// Implementations answer from memory and keep it in step as they write, so
	// the two agree as long as this process is the only writer. They stop
	// agreeing when the stored rules change behind its back: another replica
	// writing them, an operator repairing them by hand, a restore. Nothing
	// detects that on its own, so this is the lever that puts a process back
	// onto the stored state without restarting it.
	//
	// It reads every rule and is not part of the write path, which maintains
	// memory itself. Reserve it for recovery and for the moment a change is
	// known to have happened elsewhere.
	ReloadPolicies(ctx context.Context) error
}

RBAC provides tenant-scoped role, permission, and subject assignment operations. A process holding no policy set — RBAC disabled, or not initialized — answers reads as the deployment they describe, denying every request and reporting no roles, and refuses every write rather than reporting a change it did not make.

RBAC Model:

  • Tenant: Authorization domain for roles, permissions, and assignments
  • Subject: Users or entities that need access
  • Role: Named collection of permissions
  • Object: Protected resources or endpoints
  • Action: Operations on resources

type Request

type Request any

Request and Response are the framework-facing types of one action's request and response payloads. They constrain the REQ and RSP type parameters of Service and Module; the concrete types are declared per action by the model layer.

type Response

type Response any

Request and Response are the framework-facing types of one action's request and response payloads. They constrain the REQ and RSP type parameters of Service and Module; the concrete types are declared per action by the model layer.

type SQLStatement

type SQLStatement struct {
	// Query is the parameterized SQL with placeholders.
	Query string
	// Args contains the values bound to Query.
	Args []any
	// RenderedSQL is dialect-rendered SQL for logging, inspection, and manual debugging.
	RenderedSQL string
}

SQLStatement contains a generated SQL statement in executable and rendered forms.

type SelectBranch

type SelectBranch[R any] interface {
	Scan(dest *[]R) error
	Count(count *int) error
}

SelectBranch is a select in the role of a branch of a union: every Selector is one, with its model type erased, so that selects over different models stack into one result as long as they scan into the same row type. The role is what UnionAll takes. Only the selects the database layer builds can fill it; UnionAll fails when handed anything else, a union among them.

type SelectJoin

type SelectJoin struct {
	// Select is the joined select, a SelectBranch of some row type. It must
	// be grouped, and its group keys are what the ON pins.
	Select any
	// Left keeps the rows of the query that match no group, with the
	// select's terms NULL: LEFT JOIN rather than JOIN.
	Left bool
	// On holds the ON predicates: the EqCol pairs tying the select's group
	// keys, named through its model's column references, to the query.
	On []Filter
}

SelectJoin is a grouped select joined on its group keys as a derived table; see JoinSource. JoinSelect and LeftJoinSelect build it.

type Selector

type Selector[M Model, R any] interface {
	// Where restricts the rows entering the projection, using the same filter
	// tree as WithQuery.
	Where(filters ...Filter) Selector[M, R]
	// Join adds the sources the select joins: models joined on a unique key,
	// built by Join and LeftJoin, and grouped selects joined on their group
	// keys as derived tables, built by JoinSelect and LeftJoinSelect; see
	// JoinSource. A joined model's columns are referenced through its own
	// Cols everywhere in the select, a joined select's terms by passing the
	// very terms it projects.
	Join(sources ...JoinSource) Selector[M, R]
	// Having restricts the produced groups by their measures.
	Having(conditions ...TermCondition) Selector[M, R]
	// Qualify restricts the result rows by their window functions, which a
	// WHERE cannot see: the framework wraps the projection in a derived
	// table and filters that. A condition must name a window term the
	// projection declares.
	Qualify(conditions ...TermCondition) Selector[M, R]
	// OrderBy sorts the result rows by a projected column or term.
	OrderBy(orders ...Ordering) Selector[M, R]
	// Limit caps the number of result rows, read from the first; it drops
	// the skip of a Page set before it.
	Limit(n int) Selector[M, R]
	// Page keeps one page of the result rows: size rows after the first
	// page-1 pages. A page below 1 is the first, so a request's page passes
	// straight through; a size below 1 pages nothing, as a Limit below 1
	// caps nothing. The page is read in a total order: the ORDER BY is
	// completed with the group keys of a grouped projection, or the primary
	// key of a row-level one, in the direction of its last term, unless it
	// tells every row apart already, so the pages hold still between
	// queries.
	Page(page, size int) Selector[M, R]

	// Scan runs the query and fills dest with one element per result row: a
	// group of a grouped projection, a row of a row-level one.
	Scan(dest *[]R) error
	// ScanOne runs a projection of measures alone, which is one row by
	// definition, and fills dest with it. A group key, a plain column, a
	// window function or a joined select's term makes the read grouped or
	// row-level and fails it, as do Having, Limit and Page, which could
	// only turn the one row into none.
	ScanOne(dest *R) error
	// Count reports how many rows the query produces after Having and
	// Qualify — the groups of a grouped projection, the rows of a row-level
	// one — which is the total a paginated report needs. OrderBy, Limit and
	// Page set on the builder do not apply to it.
	Count(count *int) error

	// WithDryRun builds the SQL without database I/O. An optional collector
	// receives the generated Query, Args, and RenderedSQL of the next
	// terminal operation instead of executing it; that terminal consumes the
	// option, so the builder read again executes. The terminal writes
	// nothing: Scan leaves dest as it was and Count leaves count untouched.
	// A builder used as a union member or a joined select is run by the
	// enclosing query's terminal, for real, which consumes the option
	// whether or not the query builds.
	WithDryRun(collector ...*[]SQLStatement) Selector[M, R]
}

Selector runs an analytical read over the table of M and scans the result rows into R. It is deliberately separate from Database[M]: a projected row is not a model row, so model hooks, association preloading and cursor pagination have nothing to act on and are absent here rather than present and inert.

Scoping comes from M — the table name, the soft-delete condition and the dialect — so a select can never read rows a List on the same model hides. R is an ordinary struct the caller declares; its fields bind to the projection aliases, and a mismatch on either side is a build error rather than a silently zero column. A term that can come back NULL — AVG, MIN or MAX without group keys, carrying conditions, or over a nullable column; LAG and LEAD; a plain column, group key or time bucket over a nullable column, whose rows without a value form a group of their own keyed NULL — must bind to a pointer or sql.Null field, which is again a build error rather than a zero on the report. A condition in Where on the column itself keeps its NULLs out — IS NULL apart, and outside any OR group — and the field may then be plain.

The entry point is the package-level database.Select[M, R] rather than a method, because a Go method cannot introduce the result type parameter.

The rules a model declares on its rows travel with it: soft deletion and the tenant scope a model embeds apply to a select as they do to List, and a joined model carries them in its ON. What a select does not inherit is the scoping a service adds in the Filter hook the controller runs for List; a select is called straight from service code, so those hooks never run and every such condition has to be passed to Where explicitly. Forgetting one reads across the boundary the hook draws without any sign that it did.

The projection is declared at the entry point and takes one of two shapes. A grouped projection carries aggregates: a term without a function is a group key, and GROUP BY is derived from those keys, so the SELECT and GROUP BY lists cannot disagree. A row-level projection carries window functions and no aggregate: every row keeps its place, a column reference passed directly is projected as stored, and the window functions add their value per row. A projection with neither an aggregate nor a window function is a plain read and belongs to List.

A select joins other models on a unique key with Join: the joined model's columns then project, filter, group, partition and order like the queried model's own, and the projection may be plain columns alone, which List cannot read across tables; see JoinSource for the rules.

A Selector is also a SelectBranch: UnionAll stacks several of them, over different models, into one result. In that role a plain projection of columns is allowed, and OrderBy, Limit and Page belong to the union; see Union.

A builder is a specification, not a live statement: it can be read more than once, and each terminal renders the spec afresh, taking only the parts that are meaningful to it — Scan and ScanOne render everything, Count ignores OrderBy, Limit and Page because none of them changes how many rows exist. That is what makes the paginated-report idiom safe — Scan for the page, then Count for the total, off the same builder, with the pagination never skewing the count.

Example:

type tenantTotal struct {
    TenantID string
    Total    int64
    // ClosedAt is nullable on Sample, so MAX over it can be NULL and
    // needs a field that can hold NULL.
    LastClosed *time.Time
}
total := SampleCols.Amount.Sum().As("total")
rows := make([]tenantTotal, 0)
err := database.Select[*Sample, tenantTotal](ctx,
    SampleCols.TenantID.Group(), total, SampleCols.ClosedAt.Max().As("last_closed")).
    Where(SampleCols.Status.Eq(StatusDone)).
    Having(total.Gte(1000)).
    OrderBy(total.Desc()).
    Limit(10).
    Scan(&rows)

type ServerStream

type ServerStream[RSP Response] struct {
	// contains filtered or unexported fields
}

ServerStream is the response stream of a Stream action: Send sends one response to the client, and fails once the client went away or the call was canceled, with the error the transport reports.

func NewServerStream

func NewServerStream[RSP Response](send func(RSP) error) *ServerStream[RSP]

NewServerStream builds a ServerStream sending each response through send.

func (*ServerStream[RSP]) Send

func (s *ServerStream[RSP]) Send(rsp RSP) error

Send sends rsp to the client.

type ServerStreamer

type ServerStreamer[REQ Request, RSP Response] interface {
	Stream(ctx *ServiceContext, req REQ, stream *ServerStream[RSP]) error
}

ServerStreamer is the service of a Stream action declaring a Payload and a StreamingResult: Stream answers req with the responses it sends on stream, one by one, and returns once the stream is over; an error ends the stream with the status the error maps to.

type Service

type Service[M Model, REQ Request, RSP Response] interface {
	Create(*ServiceContext, REQ) (RSP, error)
	Delete(*ServiceContext, REQ) (RSP, error)
	Update(*ServiceContext, REQ) (RSP, error)
	Patch(*ServiceContext, REQ) (RSP, error)
	List(*ServiceContext, REQ) (RSP, error)
	Get(*ServiceContext, REQ) (RSP, error)

	CreateMany(*ServiceContext, REQ) (RSP, error)
	DeleteMany(*ServiceContext, REQ) (RSP, error)
	UpdateMany(*ServiceContext, REQ) (RSP, error)
	PatchMany(*ServiceContext, REQ) (RSP, error)

	CreateBefore(*ServiceContext, M) error
	CreateAfter(*ServiceContext, M) error
	DeleteBefore(*ServiceContext, M) error
	DeleteAfter(*ServiceContext, M) error
	UpdateBefore(*ServiceContext, M) error
	UpdateAfter(*ServiceContext, M) error
	PatchBefore(*ServiceContext, M) error
	PatchAfter(*ServiceContext, M) error
	ListBefore(*ServiceContext, *[]M) error
	ListAfter(*ServiceContext, *[]M) error
	GetBefore(*ServiceContext, M) error
	GetAfter(*ServiceContext, M) error

	CreateManyBefore(*ServiceContext, ...M) error
	CreateManyAfter(*ServiceContext, ...M) error
	DeleteManyBefore(*ServiceContext, ...M) error
	DeleteManyAfter(*ServiceContext, ...M) error
	UpdateManyBefore(*ServiceContext, ...M) error
	UpdateManyAfter(*ServiceContext, ...M) error
	PatchManyBefore(*ServiceContext, ...M) error
	PatchManyAfter(*ServiceContext, ...M) error

	Import(*ServiceContext, io.Reader) ([]M, error)
	Export(*ServiceContext, ...M) ([]byte, error)

	// SSE streams Server-Sent Events for the route: the implementation opens
	// the stream via ServiceContext.SSE and blocks until it is over. Query
	// parameters are read from ServiceContext.Query(). The action never binds
	// Payload or Result types, so the method carries no REQ or RSP.
	SSE(*ServiceContext) error

	// Filter lets a service rewrite the query condition before the
	// controller-side listing runs (List and Export). The model carries the
	// URL-decoded equality condition and the options carry the parsed operator
	// filters; the typical use is row-level data scoping: append typed filters
	// (e.g. Cols.TenantID.In(...)) to options.Filters or narrow the model
	// condition, then return both. Returning an error aborts the request — the
	// correct behavior when loading the caller's data scope fails. The
	// controller calls Filter once and shares the result between List and
	// Count, so both always see the same condition set.
	Filter(*ServiceContext, M, QueryOptions) (M, QueryOptions, error)

	Logger
}

Service defines the controller-facing business operation contract for a model. Generated controllers call these methods for CRUD, batch CRUD, lifecycle hooks, import/export, filtering, and logging.

Type Parameters:

  • M: Model type that implements Model interface
  • REQ: Request type for the current action or resource operation
  • RSP: Response type for the current action or resource operation

Custom actions should use action-specific REQ/RSP types instead of reusing types from other endpoints, even when the fields are identical.

Nil-safety contract: when invoked by the generated controllers, ctx is never nil and req is never a nil pointer — the controller constructs a fresh *ServiceContext per call and instantiates REQ via reflection before binding, so implementations do not need defensive nil checks on ctx or req.

Non-nil does not mean populated: List/Get never bind a request body, and Create/Update tolerate an empty body, so req may point to a zero-value struct. Validate required business fields instead of checking for nil.

The contract only covers framework-invoked calls. Code that calls a service method directly (tests, jobs, or code bypassing the controller layer) must supply non-nil arguments itself.

type ServiceContext

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

ServiceContext is the per-request context the framework hands to every service method. It implements context.Context by delegating to the request context, exposes request metadata (route, params, user identity, trace, whether the action requires authentication), and carries the response helpers a service needs without touching Gin directly.

func NewServiceContext

func NewServiceContext(c *gin.Context, ctx context.Context, phase consts.Phase) *ServiceContext

NewServiceContext builds a ServiceContext from the Gin request, capturing request details, phase, and user metadata.

A non-nil ctx overrides the base context, which is how span tracing is propagated; when ctx is nil, the request context is used when available.

Without a Gin request (c == nil) the context answers request metadata from ctx alone: a transport other than HTTP attaches requestctx.Metadata to ctx before building the context, and a context built on a bare ctx reads empty metadata.

NewServiceContext always returns a non-nil *ServiceContext, even when both c and ctx are nil. ServiceContext methods are also nil-receiver safe and return zero values on a nil receiver, so callers never need defensive nil checks around the returned context.

func (*ServiceContext) ClientIP

func (sc *ServiceContext) ClientIP() string

func (*ServiceContext) Cookie

func (sc *ServiceContext) Cookie(name string) (string, error)

Cookie returns the value of the named request cookie. Without a request to read it answers an error and records the call for HTTPOnlyMethodCalled.

func (*ServiceContext) Data

func (sc *ServiceContext) Data(code int, contentType string, data []byte)

Data writes data as the response body with the given status and content type. Without a response to write to it writes nothing and records the call for HTTPOnlyMethodCalled.

func (*ServiceContext) Deadline

func (sc *ServiceContext) Deadline() (time.Time, bool)

Deadline, Done, Err and Value delegate to the request context, which is what lets a ServiceContext stand wherever a context.Context is expected.

func (*ServiceContext) Done

func (sc *ServiceContext) Done() <-chan struct{}

func (*ServiceContext) Err

func (sc *ServiceContext) Err() error

func (*ServiceContext) FormFile

func (sc *ServiceContext) FormFile(name string) (*multipart.FileHeader, error)

FormFile returns the named file of the request's multipart form. Without a request to read it answers an error and records the call for HTTPOnlyMethodCalled.

func (*ServiceContext) Host

func (sc *ServiceContext) Host() string

Host, ClientIP, UserAgent and IsHTTPS describe the connection the request arrived on, read from the same metadata: the host the request was addressed to, the client address Gin resolved (forwarding headers included), the User-Agent header, and whether the request arrived over TLS, either directly or as the forwarding headers of a proxy in front declare.

func (*ServiceContext) IsHTTPS

func (sc *ServiceContext) IsHTTPS() bool

func (*ServiceContext) Method

func (sc *ServiceContext) Method() string

func (*ServiceContext) Param

func (sc *ServiceContext) Param(key string) string

func (*ServiceContext) Path

func (sc *ServiceContext) Path() string

func (*ServiceContext) Phase

func (sc *ServiceContext) Phase() consts.Phase

Phase returns the action phase the service is invoked for.

func (*ServiceContext) PostForm

func (sc *ServiceContext) PostForm(key string) string

PostForm returns the named value of the request's form body. Without a request to read it answers "" and records the call for HTTPOnlyMethodCalled.

func (*ServiceContext) Query

func (sc *ServiceContext) Query() url.Values

Query, Param, Route, Path, Method, Username, UserID, SessionID and RequiresAuth read the request metadata captured when the context was built. Query returns a copy, so mutating the result never changes what a later read sees; RequiresAuth answers whether the action requires authentication, as the transport marked the request or the call. Over gRPC, Route and Method name the action the way the registration described it, the route and HTTP method it is served at over HTTP, Path is the full method of the call, and Query is empty for every action but a List or a Get, whose query the request message carries: the input of any other action travels in its payload.

func (*ServiceContext) RequiresAuth

func (sc *ServiceContext) RequiresAuth() bool

func (*ServiceContext) Route

func (sc *ServiceContext) Route() string

func (*ServiceContext) SSE

func (sc *ServiceContext) SSE(fn func(conn *sse.Conn) error, opts ...sse.Option) error

SSE turns the response into a Server-Sent Events stream and runs fn with the live connection.

The framework owns the connection lifecycle: it clears the server's per-request deadlines so the stream outlives the global WriteTimeout, writes and flushes the SSE response headers, sends keep-alive comment frames until fn returns, and invalidates the connection afterwards. fn blocks until the stream is over; a callback that waits for events must select on conn.Context().Done() to notice the client disconnecting or the server shutting down.

The error is fn's own error, or the setup failure that prevented streaming (reported before anything was written, so it still surfaces as a regular error response). Without a response to stream on it records the attempt for HTTPOnlyMethodCalled and reports the failure.

Example:

return nil, ctx.SSE(func(conn *sse.Conn) error {
	for {
		select {
		case <-conn.Context().Done():
			return nil
		case event := <-events:
			if err := conn.Send(event); err != nil {
				return err
			}
		}
	}
})

func (*ServiceContext) SessionID

func (sc *ServiceContext) SessionID() string

func (*ServiceContext) SetCookie

func (sc *ServiceContext) SetCookie(cookie *http.Cookie)

SetCookie adds cookie to the response headers. Without a response to write to it writes nothing and records the call for HTTPOnlyMethodCalled; a nil cookie is nothing to write on any transport and records nothing.

func (*ServiceContext) TenantID

func (sc *ServiceContext) TenantID() string

TenantID reads the tenant the request was authenticated into, and TraceID the trace id of the execution the context belongs to.

func (*ServiceContext) TraceID

func (sc *ServiceContext) TraceID() string

func (*ServiceContext) UserAgent

func (sc *ServiceContext) UserAgent() string

func (*ServiceContext) UserID

func (sc *ServiceContext) UserID() string

func (*ServiceContext) Username

func (sc *ServiceContext) Username() string

func (*ServiceContext) Value

func (sc *ServiceContext) Value(key any) any

type Subquery

type Subquery struct {
	// Model is an allocated instance of the related model. It carries the
	// child table name and its soft-delete scope, so a subquery hides the same
	// rows a List on that model hides.
	Model Model
	// Filters narrow the related rows. They must include a FilterEqCol,
	// directly or inside a group: without one the subquery would be a cross
	// join, so it fails the query instead.
	Filters []Filter
	// Negate turns the condition into NOT EXISTS.
	Negate bool
}

Subquery is the correlated EXISTS subquery carried by FilterOpExists. It names the related model and the predicates narrowing its rows, at least one of which must be a FilterEqCol tying them to the enclosing query.

A semi join is used rather than a real join on purpose: EXISTS matches a row at most once, so an aggregate over the outer table keeps counting each row once. A join to a one-to-many child multiplies the outer rows instead, and a SUM over that silently doubles.

type TableNamer

type TableNamer interface {
	TableName() string
}

TableNamer is the one method a column reference needs from its model: the table the column belongs to. Every Model satisfies it; the narrower interface lets a reference name its model as a type argument alone, with nothing else of the model contract in play.

type Term

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

Term is one term of a projection: a group key, a plain column, a constant, a measure, or a window function, which IsGroupKey, IsPlain, IsLiteral, IsMeasure and IsWindowed tell apart.

Terms are built through the column references: the generated Cols vars, or references minted with NewColumn and its siblings by code without a Cols var to read, which is generic code naming its type parameter and framework module sources. A generated reference cannot express a function its column type does not support, because it does not carry the method; a minted reference names whatever type its author chose, so the column and its type are checked against the model schema when the query is built.

A term never holds SQL. Column names are quoted by the database layer, values bind as statement parameters, and the function and the time bucket come from closed sets. The fields are unexported so a term only comes from those constructors; the database layer reads them through the TermXxxOf functions, which the public gst package does not forward.

func Count

func Count() Term

Count counts rows: COUNT(*). It counts a row even when every column is NULL, which is what a plain row count means; use a column reference's Count for COUNT(column), which skips NULLs.

It projects as "count" unless renamed with As.

func DenseRank

func DenseRank() Term

DenseRank ranks like Rank without skipping: 1, 2, 2, 3.

func Literal

func Literal(value string) Term

Literal projects a constant, which is how the branches of a union tell their rows apart:

Literal("payment").As("kind")
// 'payment' AS `kind`

The value must be a plain identifier — letters, digits and underscores, not starting with a digit — and the term needs an alias, which As gives it; both are checked when the query is built. The constant is written into the statement as a string literal rather than bound as a parameter, because a UNION cannot infer the type of an untyped parameter on every dialect, and the identifier rule is what keeps that inlining safe. A constant is neither a group key nor a measure: it stays out of GROUP BY and never comes back NULL.

func NewTerm

func NewTerm(fn TermFn, table, column string, bucket TimeBucket, alias string) Term

NewTerm builds a term from its parts, without the checks the column references and the constructors above keep. It serves the framework's own tests, which exercise how the database layer treats a malformed term; the public gst package does not forward it.

func PlainTerm

func PlainTerm(table, column string) Term

PlainTerm returns the named column projected as it is stored, with no alias. The database layer breaks window ties with it on a key the projection may not select, where an alias would let the breaker read as a projected term.

func Rank

func Rank() Term

Rank ranks the rows of each partition in the window's order. Rows sorting equal share a rank and the next rank skips past them: 1, 2, 2, 4.

func RowNumber

func RowNumber() Term

RowNumber numbers the rows of each partition from 1 in the window's order, with no ties: two rows sorting equal still get consecutive numbers, in a stable order the framework completes with the primary key. Like Rank and DenseRank it only exists over a window whose OrderBy is set, which Over declares: without an order there is no first row to number.

func TermBase

func TermBase(t Term) Term

TermBase returns the term without its alias, window and conditions: what two spellings of one term share, which is how a union finds the branch term an ordering names.

func TermConditionTermOf

func TermConditionTermOf(c TermCondition) Term

TermConditionTermOf returns the term a condition constrains.

func TermOf

func TermOf(expr Expr) Term

TermOf returns the term an expression selects as: a term unchanged, a column reference as the plain projection of that column.

It is the one way a renderer outside this package reads an Expr, and it exists for two reasons. The interface is sealed through an unexported method so the set of expressions stays closed, which also puts that method out of reach of the database package. And Column is generic, so a type switch there cannot enumerate its instantiations the way it enumerates the two Ordering types. It is an accessor, not a constructor: removing it would leave the database layer no way to turn a projection into terms.

func TermOrderTermOf

func TermOrderTermOf(o TermOrder) Term

TermOrderTermOf returns the term an order sorts by.

func WindowPartitionOf

func WindowPartitionOf(w Window) []Term

WindowPartitionOf returns the partition keys of a window.

func (Term) As

func (t Term) As(alias string) Term

As renames the term in the SELECT list.

It is optional. Every term already carries a default alias — the column name for a column term, "count" for COUNT(*) — so a projection whose result row fields are named after the columns needs no As at all:

database.Select[*Sample, row](ctx, SampleCols.TenantID.Group(), SampleCols.Amount.Sum())
// scans into struct{ TenantID string; Amount int64 }

Reach for As in the two cases the default cannot cover: when the result row field is named differently from the column, and when one projection carries two terms over the same column, whose default aliases would collide.

The alias belongs to the result contract rather than to the column, which is why it is applied here instead of being a parameter of the constructors.

An empty alias changes nothing: As("") leaves the term under the alias it had.

func (Term) Asc

func (t Term) Asc() TermOrder

Asc and Desc sort the rows by this term. In the select's ORDER BY the term renders as its alias, which every supported dialect accepts there; inside a window it renders as its full expression, the only form OVER accepts.

func (Term) Desc

func (t Term) Desc() TermOrder

func (Term) Eq

func (t Term) Eq(value any) TermCondition

Eq, Ne, Gt, Gte, Lt and Lte build a Having or Qualify condition on the term. The value type is checked when the query is built, because a function's value type follows the function rather than its column: COUNT always yields an integer, AVG a float, and SUM widens.

func (Term) Gt

func (t Term) Gt(value any) TermCondition

func (Term) Gte

func (t Term) Gte(value any) TermCondition

func (Term) IsGroupKey

func (t Term) IsGroupKey() bool

IsGroupKey reports whether the term is a group key: a column or time bucket projected next to aggregates, which the framework derives GROUP BY from.

func (Term) IsLiteral

func (t Term) IsLiteral() bool

IsLiteral reports whether the term projects a constant.

func (Term) IsMeasure

func (t Term) IsMeasure() bool

IsMeasure reports whether the term applies a function rather than naming a column, a window function included; a constant is neither.

func (Term) IsPlain

func (t Term) IsPlain() bool

IsPlain reports whether the term is a column projected as it is stored.

func (Term) IsWindowed

func (t Term) IsWindowed() bool

IsWindowed reports whether the term is evaluated over a window.

func (Term) Lt

func (t Term) Lt(value any) TermCondition

func (Term) Lte

func (t Term) Lte(value any) TermCondition

func (Term) Ne

func (t Term) Ne(value any) TermCondition

func (Term) Over

func (t Term) Over(window Window) Term

Over evaluates the term over a window instead of collapsing the rows it reads: every row keeps its place and gains the function's value computed over the rows the window names. It applies to the aggregate functions and to the window functions; a group key, a time bucket and COUNT DISTINCT cannot be windowed and fail when the query is built.

With an ordered window the aggregate functions accumulate: SUM becomes a running total, COUNT a running count, and so on, always over the rows from the partition's first up to the current one — the frame is fixed to that, so two rows sorting equal never fold into one step the way the SQL default frame would fold them.

SampleCols.Amount.Sum().Over(PartitionBy(SampleCols.TenantID).OrderBy(SampleCols.CreatedAt.Asc()))
// COALESCE(SUM(`amount`) OVER (PARTITION BY `tenant_id` ORDER BY `created_at` ASC, `id` ASC
//   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 0)

func (Term) Where

func (t Term) Where(filters ...Filter) Term

Where restricts a measure to the rows matching filters, which is how a report projects several measures over different subsets in a single scan:

Count().Where(SampleCols.Status.Eq(StatusFailed)).As("failed")
// COUNT(CASE WHEN `status` = ? THEN 1 END) AS `failed`

The filters are the ordinary query filters, including nested groups, so the same rules and the same renderer apply, every unusable filter failing the select.

type TermCondition

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

TermCondition is one condition on a projected term: a Having condition on a measure, or a Qualify condition on a window function. It carries the term itself rather than an alias string, which has two consequences: a condition can never name a term the projection did not declare, and the renderer can emit the full expression instead of the alias, which HAVING requires because PostgreSQL does not accept an output alias there.

func NewTermCondition

func NewTermCondition(term Term, op CompareOp, value any) TermCondition

NewTermCondition builds a condition from its parts, without the checks the comparison methods keep. It serves the framework's own tests; the public gst package does not forward it.

type TermFn

type TermFn string

TermFn is the function applied to one projection term. The set is closed, so a projection can never carry SQL the way a free-form select string could: the renderer maps each constant to a fixed expression and rejects anything else.

const (
	// FnNone marks a group key rather than a measure. A projection term
	// without an aggregate function is what the framework derives GROUP BY
	// from, so the SELECT and GROUP BY lists can never disagree.
	FnNone          TermFn = ""
	FnCount         TermFn = "COUNT"
	FnCountDistinct TermFn = "COUNT_DISTINCT"
	FnSum           TermFn = "SUM"
	FnAvg           TermFn = "AVG"
	FnMin           TermFn = "MIN"
	FnMax           TermFn = "MAX"

	// FnRowNumber, FnRank, FnDenseRank, FnLag and FnLead are the window
	// functions, only meaningful over a window, which Term.Over declares. A
	// term carrying one of them without a window is a build error, as is
	// COUNT DISTINCT over a window, which no dialect supports.
	FnRowNumber TermFn = "ROW_NUMBER"
	FnRank      TermFn = "RANK"
	FnDenseRank TermFn = "DENSE_RANK"
	FnLag       TermFn = "LAG"
	FnLead      TermFn = "LEAD"

	// FnLiteral marks a constant projected as a column, which Literal
	// builds. Like FnNone it is a kind rather than a function: the term
	// carries its value in Literal and names no column.
	FnLiteral TermFn = "LITERAL"
)

func TermFnOf

func TermFnOf(t Term) TermFn

TermFnOf returns the function the term applies: FnNone for a group key or a plain column, FnLiteral for a constant.

func (TermFn) Valid

func (f TermFn) Valid() bool

Valid reports whether the function is one this package defines. The renderer composes SQL from the constant, so a value from outside the set would reach the statement as text; the query builder rejects it instead.

type TermOrder

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

TermOrder is one ORDER BY term of a select or of a window. Unlike Order it sorts by a projection term, which is what a TopN report ranks by.

func NewTermOrder

func NewTermOrder(term Term, direction OrderDirection) TermOrder

NewTermOrder builds an order on a term from its parts, without the checks Asc and Desc keep. It serves the framework's own tests; the public gst package does not forward it.

type TimeBucket

type TimeBucket string

TimeBucket is the truncation granularity of a time group key. Bucketing is the one place where the same intent needs a different expression per dialect, so the constant travels through the builder and the database layer renders it; callers never see a format string.

const (
	// TimeBucketNone groups by the raw column value.
	TimeBucketNone  TimeBucket = ""
	TimeBucketHour  TimeBucket = "hour"
	TimeBucketDay   TimeBucket = "day"
	TimeBucketMonth TimeBucket = "month"
)

func TermBucketOf

func TermBucketOf(t Term) TimeBucket

TermBucketOf returns the truncation of a time group key.

func (TimeBucket) Valid

func (b TimeBucket) Valid() bool

Valid reports whether the bucket is one this package defines. An unknown bucket would otherwise fall through to the day granularity and silently report the wrong period.

type TimeColumn

type TimeColumn struct {
	Column[time.Time]
}

TimeColumn is the reference generated for a column holding a time value: time.Time, or datatypes.Date, the calendar day a date column stores and binds as a time at midnight. It embeds Column and adds time bucketing, which is only meaningful over a time value and produces garbage rather than an error on some dialects when it is not.

func NewTimeColumn

func NewTimeColumn[M TableNamer](name string) TimeColumn

NewTimeColumn returns the time reference to the named column of M's table, carrying the bucketing group keys on top of everything Column has.

func (TimeColumn) ByDay

func (c TimeColumn) ByDay() Term

func (TimeColumn) ByHour

func (c TimeColumn) ByHour() Term

ByHour, ByDay and ByMonth make this column a group key truncated to the bucket, which is what a trend report groups by. The truncation expression differs per dialect and is rendered by the database layer, so callers never deal with a format string. The bucket of NULL is NULL: over a nullable column the rows without a value form a group of their own, so the result field is a pointer or sql.Null type unless a condition on the column in Where keeps those rows out.

func (TimeColumn) ByMonth

func (c TimeColumn) ByMonth() Term

type Union

type Union[R any] interface {
	// OrderBy sorts the stacked rows by their columns. An ordering names a
	// column of the result row, through a column reference of that name or
	// through a term a branch projects under it. The name alone decides: the
	// union reads no table of its own, so the table a reference carries is
	// not checked here.
	OrderBy(orders ...Ordering) Union[R]
	// Limit caps the number of stacked rows, read from the first; it drops
	// the skip of a Page set before it.
	Limit(n int) Union[R]
	// Page keeps one page of the stacked rows, as Page does on a select. The
	// union has no key of its own to complete the order with, so an ordering
	// that pages should end in a column no two stacked rows share, or the
	// pages may skip and repeat rows that sort equal.
	Page(page, size int) Union[R]

	// Scan runs the union and fills dest with the stacked rows.
	Scan(dest *[]R) error
	// Count reports how many rows the branches produce together.
	Count(count *int) error

	// WithDryRun builds the SQL without database I/O; see
	// Selector.WithDryRun. A dry run set on a branch is the branch's own:
	// the union's terminal runs the branch for real and consumes it,
	// whether or not the union builds.
	WithDryRun(collector ...*[]SQLStatement) Union[R]
}

Union stacks the rows of several selects into one result: UNION ALL, the one set operation the framework offers. UNION proper would fold two rows that happen to be equal — two payments of the same amount on the same day — into one, which no report wants; INTERSECT and EXCEPT are the semi joins FilterExists and FilterNotExists already express.

The branches are ordinary selects, and the alignment a hand-written UNION gets wrong by position is done by name: every branch scans into R, so its aliases are checked against R's fields, and the framework renders every branch's SELECT list in R's field order. A column spelled differently in one model is aligned with Column.As; a constant that tells the rows apart is projected with Literal:

type flow struct {
    Kind    string
    ID      string
    Amount  int64
    At      time.Time
}
payments := database.Select[*Payment, flow](ctx,
    types.Literal("payment").As("kind"), PaymentCols.ID, PaymentCols.Amount, PaymentCols.PaidAt.As("at")).
    Where(PaymentCols.TenantID.Eq(tenant))
refunds := database.Select[*Refund, flow](ctx,
    types.Literal("refund").As("kind"), RefundCols.ID, RefundCols.Amount, RefundCols.SettledAt.As("at")).
    Where(RefundCols.TenantID.Eq(tenant))
feed := database.UnionAll[flow](ctx, payments, refunds).
    OrderBy(PaymentCols.PaidAt.As("at").Desc(), PaymentCols.ID.Desc()).
    Page(3, 20)
err := feed.Scan(&rows)
err = feed.Count(&total)

Rendered, with the ordering and the page pushed into every branch — the bound limit is the rows before the page plus the page, 60 here — so each branch reads its first sixty rows by its own index and the union sorts a hundred and twenty rows at most:

SELECT * FROM (
  SELECT * FROM (SELECT 'payment' AS `kind`, `id` AS `id`, `amount` AS `amount`, `paid_at` AS `at`
                 FROM `payments` WHERE `tenant_id` = ? AND `payments`.`deleted_at` IS NULL
                 ORDER BY `at` DESC,`id` DESC LIMIT ?) AS `b0`
  UNION ALL
  SELECT * FROM (SELECT 'refund' AS `kind`, `id` AS `id`, `amount` AS `amount`, `settled_at` AS `at`
                 FROM `refunds` WHERE `tenant_id` = ? AND `refunds`.`deleted_at` IS NULL
                 ORDER BY `at` DESC,`id` DESC LIMIT ?) AS `b1`
) AS `u` ORDER BY `at` DESC,`id` DESC LIMIT ? OFFSET ?

A branch keeps its own Where, Having and Qualify, and may be a plain projection of columns, which a select on its own rejects: stacked, the rows are a report rather than a List. OrderBy, Limit and Page belong to the union and are a build error on a branch. The union has no Where: a condition belongs to the branch whose index serves it.

Count adds up the counts of the branches and materializes no row. Like a select's Count it ignores OrderBy, Limit and Page.

Every dialect renders the same statement; on ClickHouse the union is opened with UnionAllOn, on the instance the branches were opened on.

type Window

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

Window names the rows a window function reads for each row: PartitionBy splits the rows into partitions, and OrderBy orders each partition, which is what gives a running total its direction and a row number its sequence.

The window is rendered by the database layer, which completes an ordered window with a tie breaker — the primary key of a row-level projection, the group keys of a grouped one — so two rows sorting equal still take a stable order, and fixes the frame of an ordered aggregate to the rows from the partition's first up to the current one. Neither is a knob: the SQL defaults they replace are the two classic ways a running total goes wrong. Rank and DenseRank take no tie breaker: ranking peers equally is what they are for.

The fields are unexported so a window only comes from PartitionBy and OrderBy; the database layer reads them through WindowPartitionOf and WindowOrdersOf, which the public gst package does not forward.

RowNumber().Over(PartitionBy(SampleCols.TenantID).OrderBy(SampleCols.CreatedAt.Desc()))
// ROW_NUMBER() OVER (PARTITION BY `tenant_id` ORDER BY `created_at` DESC, `id` ASC)
Rank().Over(OrderBy(total.Desc()))
// RANK() OVER (ORDER BY COALESCE(SUM(`amount`), 0) DESC)

func OrderBy

func OrderBy(orders ...Ordering) Window

OrderBy returns a Window with no partition and the given orders: the window a ranking across every row reads. It is the short spelling of PartitionBy().OrderBy(orders...), the two building the same window; a window with keys starts from PartitionBy. It orders the window, not the result: the result is ordered by the Selector's OrderBy.

func PartitionBy

func PartitionBy(keys ...Expr) Window

PartitionBy opens a window partitioned by keys. Without keys the whole result is one partition, which is what a ranking over every row wants, and the window is the one OrderBy opens: the two spellings build the same value, so a term declared with one is found by the other.

func TermWindowOf

func TermWindowOf(t Term) *Window

TermWindowOf returns the window the term is evaluated over, or nil.

func (Window) OrderBy

func (w Window) OrderBy(orders ...Ordering) Window

OrderBy orders each partition of the window.

Jump to

Keyboard shortcuts

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