types

package
v0.0.0-...-d283dcf Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package types defines the public contracts between the framework and business projects: the Model, Service, Database, Aggregator, Cache, RBAC, and Logger interfaces, the query building blocks they exchange (Filter, Order, Cursor, Column, aggregate terms), and the per-request ServiceContext.

Index

Examples

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 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.

It is exported because the normalization is a contract of Filter, not a detail of the parser: a service reading a bound back would otherwise have to restate the layout, which no compiler could keep in sync with the parser. Read a bound with TimeValue rather than parsing with this layout directly.

Variables

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

ErrEntryNotFound is returned when a cache entry is not found.

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.

Functions

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. Inside a request it cannot be empty, because authorization refuses anonymous requests before any handler runs.

Types

type AggregateFn

type AggregateFn string

AggregateFn 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 (
	// AggregateNone 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.
	AggregateNone          AggregateFn = ""
	AggregateCount         AggregateFn = "COUNT"
	AggregateCountDistinct AggregateFn = "COUNT_DISTINCT"
	AggregateSum           AggregateFn = "SUM"
	AggregateAvg           AggregateFn = "AVG"
	AggregateMin           AggregateFn = "MIN"
	AggregateMax           AggregateFn = "MAX"
)

func (AggregateFn) Valid

func (f AggregateFn) 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 AggregateOrder

type AggregateOrder struct {
	Term      AggregateTerm
	Direction OrderDirection
}

AggregateOrder is one ORDER BY term of an aggregate query. Unlike Order it sorts by a projection term, which is what a TopN report ranks by.

type AggregateTerm

type AggregateTerm struct {
	// Fn is the aggregate function, or AggregateNone for a group key.
	Fn AggregateFn
	// Column is the snake case column name. It is empty only for COUNT(*).
	Column string
	// Bucket truncates a time group key. It is only meaningful when Fn is
	// AggregateNone and the column is a time column.
	Bucket TimeBucket
	// Conditions restrict a measure to the rows matching them, rendering as a
	// CASE expression inside the aggregate call. They reuse the query filter
	// tree, so conditional aggregation needs no predicate language of its own.
	Conditions []Filter
	// Alias names the term in the SELECT list and binds it to a field of the
	// result row. An empty alias defaults to the column name.
	Alias string
}

AggregateTerm is one term of an aggregate projection: a group key when Fn is AggregateNone, a measure otherwise.

Terms are built through the generated column references (SampleCols.Amount.Sum()) or, for code that cannot name a concrete model, through the package-level string variants (SumOf("amount")). The typed path cannot express a function the column type does not support, because the generated reference does not carry the method; the string path names a column that is 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 Fn and Bucket come from closed sets.

func AvgOf

func AvgOf(column string) AggregateTerm

AvgOf averages a numeric column.

func ByDayOf

func ByDayOf(column string) AggregateTerm

func ByHourOf

func ByHourOf(column string) AggregateTerm

ByHourOf, ByDayOf and ByMonthOf group a time column by a truncated bucket.

func ByMonthOf

func ByMonthOf(column string) AggregateTerm

func Count

func Count() AggregateTerm

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 CountDistinctOf

func CountDistinctOf(column string) AggregateTerm

CountDistinctOf counts distinct non-NULL values of a column.

func CountOf

func CountOf(column string) AggregateTerm

CountOf counts non-NULL values of a column.

func GroupOf

func GroupOf(column string) AggregateTerm

GroupOf groups by the raw value of a column.

func MaxOf

func MaxOf(column string) AggregateTerm

MaxOf returns the largest value of a column.

func MinOf

func MinOf(column string) AggregateTerm

MinOf returns the smallest value of a column.

func SumOf

func SumOf(column string) AggregateTerm

SumOf adds up a numeric column.

func (AggregateTerm) As

func (t AggregateTerm) As(alias string) AggregateTerm

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:

Select(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.

func (AggregateTerm) Asc

func (t AggregateTerm) Asc() AggregateOrder

Asc and Desc sort the result rows by this term. An output alias is legal in ORDER BY on every supported dialect, so these render as the alias.

func (AggregateTerm) Desc

func (t AggregateTerm) Desc() AggregateOrder

func (AggregateTerm) Eq

func (t AggregateTerm) Eq(value any) Having

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

func (AggregateTerm) Gt

func (t AggregateTerm) Gt(value any) Having

func (AggregateTerm) Gte

func (t AggregateTerm) Gte(value any) Having

func (AggregateTerm) IsMeasure

func (t AggregateTerm) IsMeasure() bool

IsMeasure reports whether the term is an aggregate rather than a group key.

func (AggregateTerm) Lt

func (t AggregateTerm) Lt(value any) Having

func (AggregateTerm) Lte

func (t AggregateTerm) Lte(value any) Having

func (AggregateTerm) Ne

func (t AggregateTerm) Ne(value any) Having

func (AggregateTerm) Where

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

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 fail-closed rules and the same renderer apply.

type Aggregator

type Aggregator[M Model, R any] interface {
	// Select declares the projection. A term without an aggregate function is
	// a group key, and GROUP BY is derived from those keys, so the SELECT and
	// GROUP BY lists cannot disagree. At least one aggregate term is required.
	Select(terms ...AggregateTerm) Aggregator[M, R]
	// Where restricts the rows entering the aggregation, using the same filter
	// tree as WithQuery.
	Where(filters ...Filter) Aggregator[M, R]
	// Having restricts the produced groups by their measures.
	Having(conditions ...Having) Aggregator[M, R]
	// OrderBy sorts the result rows by a projection term.
	OrderBy(orders ...AggregateOrder) Aggregator[M, R]
	// Limit caps the number of result rows.
	Limit(n int) Aggregator[M, R]
	// Offset skips result rows, for paginating a grouped report.
	Offset(n int) Aggregator[M, R]

	// Scan runs the query and fills dest with one element per group.
	Scan(dest *[]R) error
	// ScanOne runs an ungrouped aggregation and fills dest with its single
	// row. It fails when the projection declares group keys.
	ScanOne(dest *R) error
	// CountGroups reports how many groups the query produces, which is the
	// total a paginated grouped report needs. OrderBy, Limit and Offset set on
	// the builder do not apply to it.
	CountGroups(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.
	WithDryRun(collector ...*[]SQLStatement) Aggregator[M, R]
}

Aggregator runs an analytical read over the table of M and scans the result rows into R. It is deliberately separate from Database[M]: an aggregate result 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 an aggregate 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 measure that can come back NULL — AVG, MIN or MAX without group keys, carrying conditions, or over a nullable column — must bind to a pointer or sql.Null field, which is again a build error rather than a zero on the report.

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

Row-level access rules are not inherited. A model's List gets its tenant or group scoping from the Filter service hook the controller runs; an aggregate is called straight from service code, so those hooks never run and every scoping condition has to be passed to Where explicitly. Forgetting one aggregates across tenants without any sign that it did.

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, CountGroups ignores OrderBy, Limit and Offset because none of them changes how many groups exist. That is what makes the paginated-report idiom safe — Scan for the page, then CountGroups 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.Aggregate[*Sample, tenantTotal](ctx).
    Select(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 AnyColumnRef

type AnyColumnRef interface {
	// Name returns the database column name resolved by gorm.
	Name() 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 {
	Column string
	Value  any
}

Assignment is one column-value write, the unit UpdateByID accepts. Service code should build 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. The Assign constructor takes a plain column name and exists for code that cannot reference a concrete model: framework internals and dynamic column loops.

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

func Assign

func Assign(column string, value any) Assignment

Assign builds an assignment of value to the named database column.

type Cache

type Cache[T any] interface {
	// Get retrieves a value from the cache by key.
	// Returns ErrEntryNotFound if the key does not exist.
	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.
  • 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 Coder

type Coder interface {
	Code() int
	Status() int
	Msg() string
}

Coder describes an API envelope code, HTTP status, and client-safe message.

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.

The methods are typed front ends for the FilterXxx, Asc and Desc constructors and produce exactly the same Filter and Order values. Code that cannot reference a concrete model (generic helpers, framework internals, URL parsing) keeps using those constructors with a string column name.

Columns whose Go type is numeric or time.Time are generated as NumericColumn or TimeColumn instead, which embed this type and add the aggregate methods that are only meaningful there.

func NewColumn

func NewColumn[T any](name string) Column[T]

NewColumn returns a typed reference to the named database column. gg gen emits the calls in each model's generated file; handwritten code that cannot reference a generated Cols var should keep using the FilterXxx, Asc and Desc constructors with a plain column name instead of minting references. The name is unexported so a shared reference cannot be repointed at another column after construction.

An empty name panics: references are constructed while generated code initializes its package-level Cols vars, so a nameless one must not survive process startup.

func (Column[T]) Asc

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

Asc orders by the column ascending.

func (Column[T]) Correlate

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

Correlate ties the column, on the related model a subquery reads, to parent, a column of the query enclosing that subquery; see FilterCorrelate. Both must be columns of the same Go type. A nil parent leaves the outer side empty, and the predicate then fails closed.

func (Column[T]) Count

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

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() AggregateTerm

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.

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]) Group

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

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.

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]) IsNull

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

IsNull matches rows where the column is NULL.

func (Column[T]) JSONContains

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

JSONContains matches rows whose JSON array column contains value.

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() AggregateTerm

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

func (Column[T]) Min

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

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]) NotNull

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

NotNull matches rows where the column is not NULL.

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 assigning a wrong-typed value or naming a column the model does not have fails to compile.

func (Column[T]) StartsWith

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

StartsWith matches rows where the column starts with value.

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 correlating a string column with an integer column fail to compile. The method is also unexported, so the set of implementations stays closed to this package.

type ControllerConfig

type ControllerConfig[M Model] struct {
	// ParamName names the route parameter that carries the resource ID.
	ParamName string
	// Route is the raw route string the handler is registered under. Controller
	// factories derive the service registry key from it, so it must match the
	// route passed to the corresponding service.Register call. router.Register
	// fills it in automatically; 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 {
	// Order is the feed's stable ordering. An empty column falls back to the
	// primary key in the database layer.
	Order Order
	// Value is the boundary row's column value. An empty value disables
	// cursor pagination, which makes a zero Cursor a no-op.
	Value string
	// Backward travels against Order instead of along it, which is what a
	// request for the previous page means.
	Backward bool
}

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.

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.

func CursorForward

func CursorForward(order Order, value string) Cursor

CursorForward pages along order, starting just past value.

func (Cursor) Enabled

func (c Cursor) Enabled() bool

Enabled reports whether the cursor carries a boundary and should be applied.

type Database

type Database[M Model] interface {
	// Create inserts one or more records (pure INSERT), setting framework IDs
	// and forcing created_at/updated_at to now. A primary or unique key
	// collision fails with database.ErrDuplicatedKey instead of updating the
	// existing row.
	Create(objs ...M) error
	// Delete removes one or more records using WithPurge, the model Purge setting, or soft delete by default.
	Delete(objs ...M) error
	// Update saves one or more full model values by primary key (pure UPDATE,
	// zero values included). 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.
	Update(objs ...M) error
	// Upsert inserts records or, on any unique-key collision, overwrites the
	// conflicting row (INSERT ... ON DUPLICATE KEY UPDATE). 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. Assignments come from
	// the generated column references (SampleCols.Status.Set(v)) or the
	// Assign constructor for dynamic columns; 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, transactions, 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 model fields or raw SQL configuration.
	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]
	// WithOffset skips records before returning read operation results.
	WithOffset(offset 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]
	// 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 Filter

type Filter struct {
	Column string
	Op     FilterOp
	Value  any
}

Filter is one field-level filter to apply as an AND condition. Column must already be validated against the model's queryable columns by the producer (the List controller validates URL input; service code passing filters directly carries the same responsibility). Value holds a normalized typed value and is always bound as a statement parameter:

  • 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.
  • FilterOpCorrelate requires a string value naming the enclosing query's column and only renders inside a subquery; see FilterCorrelate.
  • The comparison operators take a scalar value (string, numeric, time.Time); slices, arrays, and nil are rejected.

A value that violates these rules fails closed in the database layer. Service code should build filters with the FilterEq/FilterIn/... helper constructors: their signatures enforce the value shape at compile time.

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{
    types.FilterEq("tenant_id", tenant),
    types.FilterOr(
        types.FilterAnd(
            types.FilterEq("kind", KindPrimary),
            types.FilterEq("status", StatusDone),
        ),
        types.FilterAnd(
            types.FilterEq("kind", KindSecondary),
            types.FilterEq("status", StatusPending),
        ),
    ),
}
// WHERE tenant_id = ?
//   AND ((kind = ? AND status = ?) OR (kind = ? AND status = ?))

A group with no children fails closed.

func FilterCorrelate

func FilterCorrelate(column, parent string) Filter

FilterCorrelate is the predicate that ties a subquery to the query around it: column, on the related model the subquery reads, equals parent, a column of the enclosing query's model. It only means something inside FilterExists or FilterNotExists, where it renders as `child_table.column = outer_table.parent`; at the top level of a query there is nothing to tie to and it fails closed, 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. Column.Correlate is the typed front end that keeps the two columns of the same Go type.

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 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. Correlate 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.Correlate(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.Correlate(SampleCols.TenantID),
    ItemCols.SampleID.Correlate(SampleCols.ID),
    ItemCols.Status.Eq(StatusDone))

The table names come from C and from the queried model, so a column reference never has to carry a table name. A subquery without any FilterCorrelate 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 Aggregate 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 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 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.

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 FilterCorrelate fails closed here as well: negating "match nothing" would otherwise widen 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 FilterNotNull

func FilterNotNull(column string) Filter

FilterNotNull matches rows whose column is not NULL.

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{
    types.FilterEq("tenant_id", tenant),
    types.FilterOr(
        types.FilterLike("name", keyword),
        types.FilterLike("code", 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 fails closed.

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 (Filter) TimeValue

func (f Filter) TimeValue() (time.Time, bool)

TimeValue returns the filter's value as a time, which is how a service reads back a range it did not build itself, such as one parsed from a request.

Both value shapes a time bound can have are accepted: the canonical string a URL-parsed filter carries, and the time.Time a caller passes to the comparison constructors directly. It reports false for a value that is neither, including a malformed string, so a caller that must distinguish "no bound" from "some other value" can.

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 fails closed on conditions it does not recognize.

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
	FilterOpCorrelate    FilterOp = "correlate"    // inside a subquery: the column equals the enclosing query's column named by the string value
)

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 Having

type Having struct {
	Term  AggregateTerm
	Op    HavingOp
	Value any
}

Having is one post-aggregation condition. It carries the term itself rather than an alias string, which has two consequences: a condition can never name a measure the projection did not declare, and the renderer can emit the full expression instead of the alias, which is required because PostgreSQL and SQL Server do not accept an output alias in HAVING.

type HavingOp

type HavingOp string

HavingOp is a comparison applied to an aggregated value. Only the six orderings exist: the pattern and set operators of FilterOp have no meaning over a measure.

const (
	HavingOpEq  HavingOp = "eq"
	HavingOpNe  HavingOp = "ne"
	HavingOpGt  HavingOp = "gt"
	HavingOpGte HavingOp = "gte"
	HavingOpLt  HavingOp = "lt"
	HavingOpLte HavingOp = "lte"
)

func (HavingOp) Valid

func (o HavingOp) 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 Logger

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

	WithContext(context.Context, consts.Phase) Logger

	StandardLogger
	StructuredLogger
	ZapLogger
}

Logger combines plain, sugared structured, and typed zap logging methods. With attaches string key/value fields; WithContext derives a logger carrying request metadata fields.

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 set 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 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[T any](name string) NumericColumn[T]

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

func (NumericColumn[T]) Avg

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

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() AggregateTerm

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 {
	Column    string
	Direction OrderDirection
}

Order is one ORDER BY term: a column and the direction to sort it by. Column must already be validated against the model's queryable columns by the producer (the List controller validates URL input; service code passing orders directly carries the same responsibility). An Order with an empty column is skipped rather than rendered.

Service code should build orders through the generated column references (SampleCols.CreatedAt.Desc()), which cannot name a column the model does not have. The Asc and Desc constructors take a plain column name and exist for code that cannot reference a concrete model: generic helpers, framework internals, and URL parsing.

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.

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 (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 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. RawQuery and Filters count as
	// real conditions and disable the safety check on their own.
	AllowEmpty bool

	// RawQuery is a raw parameterized SQL fragment added as an extra WHERE
	// condition. It works with a nil model and combines with model-field
	// conditions otherwise.
	RawQuery string

	// RawQueryArgs are the values bound to the RawQuery placeholders.
	RawQueryArgs []any

	// 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 with an unknown operator
	// or empty column fails closed: query construction adds "1 = 0" instead
	// of dropping it.
	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 both questions the pair it replaced answered separately:
	// membership is a non-empty result, and holding one particular role is that
	// role being among them. Neither deserved 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 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.GroupID.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), 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.

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)

func (*ServiceContext) Data

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

func (*ServiceContext) Deadline

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

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)

func (*ServiceContext) Host

func (sc *ServiceContext) Host() string

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

func (*ServiceContext) PostForm

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

func (*ServiceContext) Query

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

func (*ServiceContext) RequiresAuth

func (sc *ServiceContext) RequiresAuth() bool

RequiresAuth returns whether the current API requires authentication.

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.

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).

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
			}
		}
	}
})
Example

ExampleServiceContext_SSE demonstrates streaming a fixed number of events.

package main

import (
	"github.com/hydroan/gst/sse"
	"github.com/hydroan/gst/types"
)

func main() {
	var sc *types.ServiceContext // acquired from a service method in real code

	_ = sc.SSE(func(conn *sse.Conn) error {
		for i := 1; i <= 3; i++ {
			if err := conn.Send(sse.Event{Event: "message", Data: i}); err != nil {
				return err
			}
		}
		return nil
	})
}
Example (WaitForEvents)

ExampleServiceContext_SSE_waitForEvents demonstrates the event-driven shape: the callback blocks on an event source and stops when the client is gone.

package main

import (
	"github.com/hydroan/gst/sse"
	"github.com/hydroan/gst/types"
)

func main() {
	var sc *types.ServiceContext // acquired from a service method in real code
	events := make(chan sse.Event)

	_ = sc.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)

func (*ServiceContext) TenantID

func (sc *ServiceContext) TenantID() string

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 StandardLogger

type StandardLogger interface {
	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)
}

StandardLogger provides plain and printf-style leveled logging methods. Fatal and Fatalf follow the underlying logger's fatal behavior and should terminate the process after writing the log entry.

type StructuredLogger

type StructuredLogger interface {
	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)
}

StructuredLogger provides sugared structured logging with alternating key/value fields. Methods with suffix "w" mean "with fields".

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 FilterCorrelate,
	// directly or inside a group: without one the subquery would be a cross
	// join, so it fails closed 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 FilterCorrelate 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 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 (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 time.Time column. 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(name string) TimeColumn

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

func (TimeColumn) ByDay

func (c TimeColumn) ByDay() AggregateTerm

func (TimeColumn) ByHour

func (c TimeColumn) ByHour() AggregateTerm

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.

func (TimeColumn) ByMonth

func (c TimeColumn) ByMonth() AggregateTerm

type ZapLogger

type ZapLogger interface {
	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)
}

ZapLogger provides structured logging with typed zap.Field values. Methods with suffix "z" are the low-allocation typed-field variants.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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