modelregistry

package
v0.0.0-...-8c930d8 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package modelregistry contains the internal model infrastructure behind the public model package.

Application code should use github.com/hydroan/gst/model. This package keeps the framework-owned implementations for Base, AutoBase and Empty, the query parameter structs Query, Pagination and Cursor, the type rules used by controllers and schema generation, the detection of the hooks a model overrides and of the replica its reads prefer, and the database initialization queues consumed by the database runtime.

Index

Constants

This section is empty.

Variables

View Source
var TableChan = make(chan types.Model, 10240)

TableChan is the internal queue for default-database table registration. It receives the models Register queues, for dbruntime.InitDatabase to process.

Functions

func AreTypesEqual

func AreTypesEqual[M types.Model, REQ types.Request, RSP types.Response]() bool

AreTypesEqual reports whether M, REQ, and RSP are the same concrete type.

Empty models always return false so custom controller operations are used.

func HookMethodNames

func HookMethodNames() []string

HookMethodNames returns the Go method names of the lifecycle hooks of types.Model, CreateBefore to GetAfter in the order the interface declares them: the one list of what a hook is, read by gg check to tell a model's hooks from its other methods. The slice is the caller's own.

func IsCursorable

func IsCursorable(m any) bool

IsCursorable reports whether m opted in to cursor query parameters by embedding Cursor directly or through Query.

func IsEmpty

func IsEmpty[T any]() bool

IsEmpty reports whether T carries no data of its own, so there is nothing to bind a request into or describe as a body: T, its pointers removed, is a struct without fields or a struct whose every field is an Empty marker. A type of any other kind carries its value — a string, a number, the elements of a slice or of a map — and is never empty. The controllers ask it to hand an action model to its service instead of the default CRUD flow (see AreTypesEqual), and the OpenAPI generator asks it to leave a request or response body out.

For example, it reports true for Login and Logout, and false for Signup, which carries a field beside the marker, and for Signups, a slice:

type Login struct {
	model.Empty
}

type Logout struct{}

type Signup struct {
	Email string
	model.Empty
}

type Signups []*Signup

Models embed Empty by value; Register and gg gen reject the pointer form of every base type.

func IsFrameworkBase

func IsFrameworkBase(typ reflect.Type) bool

IsFrameworkBase reports whether typ is one of the framework's model bases, Base and AutoBase, whose fields the framework writes itself: the primary key, the audit columns and the timestamps. A patch applies none of them, and the request body a write documents holds none but the primary key a create may name. The bases are embedded by value, so *Base is none.

func IsPaginatable

func IsPaginatable(m any) bool

IsPaginatable reports whether m opted in to page and size query parameters by embedding Pagination directly or through Query.

func IsQueryable

func IsQueryable(m any) bool

IsQueryable reports whether m opted in to general framework query parameters by embedding Query.

func IsTableModel

func IsTableModel[T any]() bool

IsTableModel reports whether T is a database-backed model, the kind Register queues for table setup: T is a pointer to a struct that has fields, none of them an Empty marker. For example, with

type Record struct {
	Name string
	model.Base
}

type Login struct {
	Name string
	model.Empty
}

IsTableModel reports true for *Record, and false for Record, which is not a pointer, for *Login, a virtual model, and for a pointer to a struct without fields.

func IsVirtual

func IsVirtual(m any) bool

IsVirtual reports whether m is a virtual, table-less resource: whether its type embeds Empty. m must be a model pointer, which is the only shape models flow through the framework in. An embedded *Empty would promote the marker as well, but models embed Empty by value: Register and gg gen reject the pointer form.

func OverridesCreateHooks

func OverridesCreateHooks(m any) bool

OverridesCreateHooks reports whether the model overrides CreateBefore or CreateAfter beyond the framework base's no-op implementations.

func OverridesDeleteHooks

func OverridesDeleteHooks(m any) bool

OverridesDeleteHooks reports whether the model overrides DeleteBefore or DeleteAfter beyond the framework base's no-op implementations.

func OverridesHook

func OverridesHook(m any, phase apiaction.Phase) bool

OverridesHook reports whether the model overrides the hook that phase names beyond the framework base's no-op implementation. Only the hook phases of types.Model are tracked; any other phase reports true, so a caller gating work on the answer keeps that work rather than skipping it for a hook the detection does not know.

func OverridesUpdateHooks

func OverridesUpdateHooks(m any) bool

OverridesUpdateHooks reports whether the model overrides UpdateBefore or UpdateAfter beyond the framework base's no-op implementations.

func PrefersReplica

func PrefersReplica(m any) bool

PrefersReplica reports whether m's model type declared PreferReplica() true. m may be a nil pointer — only its type is consulted, and the answer is resolved once per type on a fresh instance, so the method body may safely read receiver fields.

func Register

func Register[M types.Model]()

Register records M as a registered model and queues it for table setup. Models that embed Empty are ignored: they map to no table. A model embedding a base type through a pointer — *Base, *AutoBase or *Empty — panics, telling the author to embed it by value: the framework recognizes the base types embedded by value only, and gg gen rejects the pointer form as well (see dsl.Validate).

It is the single entry point for both steps, and the one place a model takes TableChan, so the recorded set and the queue can never disagree about what was registered: a model that took the queue without being recorded would have a table nothing knows it declared, which a fingerprint of the registered models (see modelschema.Fingerprint) would then leave out. The model counts as pending before it takes the queue, so the pending count never misses one.

func RegisteredModels

func RegisteredModels() []any

RegisteredModels returns independent values of the models registered through Register. Mutating them does not change what is registered.

func TableDone

func TableDone()

TableDone reports that one queued model now has its table, and is called by the database runtime once table preparation returns.

func TablesChanged

func TablesChanged() <-chan struct{}

TablesChanged returns the channel a waiter blocks on until a table finishes preparing. A receive means the pending count is worth reading again, never that it reached zero: TablesPending stays the only authority on that, which is what keeps a stale or coalesced wakeup harmless.

func TablesPending

func TablesPending() int

TablesPending returns how many models are queued or still having their table prepared.

func ValidateIDAccessors

func ValidateIDAccessors(typ reflect.Type) error

ValidateIDAccessors refuses a model declaring an ID of its own over the one of the framework base it embeds, a wider key say, that takes any of GetID, SetID and ClearID from the base: the base's read and write the shadowed ID, which no column maps, while the framework reads and writes a record's ID through them. A field of the model's own, or of a struct it embeds, shadows the base's when it is the ID Go resolves on the model.

Table preparation fails the start with the error, and the column inspection gg builds into a project hands it to gg check; the public inspect.ValidateIDAccessors forwards to it. For a model Record embedding model.Base beside an ID of its own and declaring GetID alone the error reads:

model Record: its ID shadows model.Base's; declare its own SetID and ClearID too: model.Base's read and write the shadowed field, which no column maps

Types

type AutoBase

type AutoBase struct {
	ID uint64 `json:"id" gorm:"primaryKey;autoIncrement" query:"id" url:"-"` // Auto-increment identifier assigned by the database

	CreatedBy string         `json:"created_by,omitempty" gorm:"size:36;<-:create" query:"created_by" url:"-"` // ID of the user who created the record
	UpdatedBy string         `json:"updated_by,omitempty" gorm:"size:36" query:"updated_by" url:"-"`           // ID of the user who last updated the record
	CreatedAt time.Time      `json:"created_at,omitzero" gorm:"not null;<-:create" query:"created_at" url:"-"` // Timestamp when the record was created
	UpdatedAt time.Time      `json:"updated_at,omitzero" gorm:"not null" query:"updated_at" url:"-"`           // Timestamp when the record was last updated
	DeletedAt gorm.DeletedAt `json:"-" query:"-" url:"-"`                                                      // Timestamp when the record was deleted
}

AutoBase implements types.Model for database-backed resources that use an auto-increment integer primary key instead of Base's UUIDv7 string key. A narrow monotonic primary key keeps the clustered index append-only and keeps every secondary index entry small, which suits high-growth tables.

Key behavior differences from Base:

  • SetID never generates an ID; the database assigns one on insert.
  • GetID returns "" while the ID is unset (0) so framework emptiness checks such as not-found detection keep working.

Caveat: updating a record whose ID is unset fails with database.ErrIDRequired; use database.Upsert for insert-or-update semantics.

A model declaring an ID of its own over AutoBase's declares its own GetID, SetID and ClearID too, as on Base.

created_at/updated_at follow the same NOT NULL, no-database-default contract as Base: every writer provides both explicitly, in UTC. created_at and created_by are create-only, as on Base.

The uuid columns take a size instead of an explicit char type for the same reason as Base: postgres blank-pads char values on read. The note belongs here rather than above the field, for the reason Base's comment spells out.

func (*AutoBase) ClearID

func (b *AutoBase) ClearID()

func (*AutoBase) CreateAfter

func (*AutoBase) CreateAfter(context.Context) error

func (*AutoBase) CreateBefore

func (*AutoBase) CreateBefore(context.Context) error

func (*AutoBase) DeleteAfter

func (*AutoBase) DeleteAfter(context.Context) error

func (*AutoBase) DeleteBefore

func (*AutoBase) DeleteBefore(context.Context) error

func (*AutoBase) Expands

func (b *AutoBase) Expands() []string

func (*AutoBase) GetAfter

func (*AutoBase) GetAfter(context.Context) error

func (*AutoBase) GetBefore

func (*AutoBase) GetBefore(context.Context) error

func (*AutoBase) GetCreatedAt

func (b *AutoBase) GetCreatedAt() time.Time

func (*AutoBase) GetCreatedBy

func (b *AutoBase) GetCreatedBy() string

func (*AutoBase) GetID

func (b *AutoBase) GetID() string

GetID returns the decimal form of the ID, or "" while the ID is unset.

func (*AutoBase) GetUpdatedAt

func (b *AutoBase) GetUpdatedAt() time.Time

func (*AutoBase) GetUpdatedBy

func (b *AutoBase) GetUpdatedBy() string

func (*AutoBase) ListAfter

func (*AutoBase) ListAfter(context.Context) error

func (*AutoBase) ListBefore

func (*AutoBase) ListBefore(context.Context) error

func (*AutoBase) Purge

func (b *AutoBase) Purge() bool

func (*AutoBase) SetCreatedAt

func (b *AutoBase) SetCreatedAt(t time.Time)

func (*AutoBase) SetCreatedBy

func (b *AutoBase) SetCreatedBy(s string)

func (*AutoBase) SetID

func (b *AutoBase) SetID(id ...string)

SetID parses the given decimal id into the ID field. Unlike Base it never generates an ID: without a parsable argument the ID stays unset and the database assigns one on insert. An already set ID is kept.

func (*AutoBase) SetUpdatedAt

func (b *AutoBase) SetUpdatedAt(t time.Time)

func (*AutoBase) SetUpdatedBy

func (b *AutoBase) SetUpdatedBy(s string)

func (*AutoBase) TableName

func (b *AutoBase) TableName() string

func (*AutoBase) UpdateAfter

func (*AutoBase) UpdateAfter(context.Context) error

func (*AutoBase) UpdateBefore

func (*AutoBase) UpdateBefore(context.Context) error

type Base

type Base struct {
	ID string `json:"id" gorm:"primaryKey;size:36" query:"id" url:"-"` // UUIDv7 identifier for the record

	CreatedBy string         `json:"created_by,omitempty" gorm:"size:36;<-:create" query:"created_by" url:"-"` // ID of the user who created the record
	UpdatedBy string         `json:"updated_by,omitempty" gorm:"size:36" query:"updated_by" url:"-"`           // ID of the user who last updated the record
	CreatedAt time.Time      `json:"created_at,omitzero" gorm:"not null;<-:create" query:"created_at" url:"-"` // Timestamp when the record was created
	UpdatedAt time.Time      `json:"updated_at,omitzero" gorm:"not null" query:"updated_at" url:"-"`           // Timestamp when the record was last updated
	DeletedAt gorm.DeletedAt `json:"-" query:"-" url:"-"`                                                      // Timestamp when the record was deleted
}

Base implements types.Model for database-backed resources. Custom models can override these default methods when needed.

A model may declare an ID of its own, a wider key say, which shadows Base's. Base's GetID, SetID and ClearID read and write the shadowed field, which no column maps, so such a model declares its own three: table preparation refuses one that does not at startup, and gg check reports it.

Indexes are not declared through gorm tags; models declare them through the optional Indexes() []Index method instead.

Associations are declared so queries can preload them, not to create physical foreign keys, so they carry constraint:- and leave referential integrity to the model hooks; a write zeroes them and touches the model's own table alone (see database.Create). Their gorm tags read like

gorm:"foreignKey:ParentID;constraint:-"
gorm:"foreignKey:ParentID;references:ID;constraint:-"

Timestamps: created_at/updated_at are NOT NULL and carry no database default. The framework always provides both, in UTC via dbruntime.NowUTC (Create/Upsert force them, Update refreshes updated_at). A writer that bypasses the framework must set both columns itself, in UTC; omitting them fails fast under strict SQL mode instead of silently storing NULL, which would break created_at ordering, cursor pagination, and time-range filtering. A database default is ruled out on purpose: CURRENT_TIMESTAMP fills in the session-timezone wall clock, which silently diverges from the framework's UTC time base. deleted_at stays nullable: NULL marks the live row under soft deletion.

created_at and created_by are creation facts: gorm's create-only permission writes them on insert and keeps every later write off them, Update and the conflict update of an Upsert included.

The uuid columns take a size instead of an explicit char type: postgres blank-pads char values, so an id shorter than 36 would come back with trailing spaces there. varchar stores every id verbatim on all dialects. The note belongs here rather than above the field: doc comment extraction prefers a field's doc comment over its trailing one, so an implementation note placed above a field becomes that field's API-facing description.

func (*Base) ClearID

func (b *Base) ClearID()

func (*Base) CreateAfter

func (*Base) CreateAfter(context.Context) error

func (*Base) CreateBefore

func (*Base) CreateBefore(context.Context) error

func (*Base) DeleteAfter

func (*Base) DeleteAfter(context.Context) error

func (*Base) DeleteBefore

func (*Base) DeleteBefore(context.Context) error

func (*Base) Expands

func (b *Base) Expands() []string

func (*Base) GetAfter

func (*Base) GetAfter(context.Context) error

func (*Base) GetBefore

func (*Base) GetBefore(context.Context) error

func (*Base) GetCreatedAt

func (b *Base) GetCreatedAt() time.Time

func (*Base) GetCreatedBy

func (b *Base) GetCreatedBy() string

func (*Base) GetID

func (b *Base) GetID() string

func (*Base) GetUpdatedAt

func (b *Base) GetUpdatedAt() time.Time

func (*Base) GetUpdatedBy

func (b *Base) GetUpdatedBy() string

func (*Base) ListAfter

func (*Base) ListAfter(context.Context) error

func (*Base) ListBefore

func (*Base) ListBefore(context.Context) error

func (*Base) Purge

func (b *Base) Purge() bool

func (*Base) SetCreatedAt

func (b *Base) SetCreatedAt(t time.Time)

func (*Base) SetCreatedBy

func (b *Base) SetCreatedBy(s string)

func (*Base) SetID

func (b *Base) SetID(id ...string)

SetID sets the ID to the given id, or to a new UUIDv7 when none or an empty one is given. An already set ID is kept.

func (*Base) SetUpdatedAt

func (b *Base) SetUpdatedAt(t time.Time)

func (*Base) SetUpdatedBy

func (b *Base) SetUpdatedBy(s string)

func (*Base) TableName

func (b *Base) TableName() string

func (*Base) UpdateAfter

func (*Base) UpdateAfter(context.Context) error

func (*Base) UpdateBefore

func (*Base) UpdateBefore(context.Context) error

type Cursor

type Cursor struct {
	CursorValue *string `json:"-" gorm:"-" query:"_cursor_value" url:"_cursor_value,omitempty"` // CursorValue is the current cursor token; it must parse as the cursor column's Go type. Unset, the request is the feed's first page.
	CursorField string  `json:"-" gorm:"-" query:"_cursor_field" url:"_cursor_field,omitempty"` // CursorField names the single field the cursor orders by: the primary key, which an unset one means, or a NOT NULL field with a unique index of its own.
	CursorNext  bool    `json:"-" gorm:"-" query:"_cursor_next" url:"_cursor_next,omitempty"`   // CursorNext chooses the cursor direction; false requests the previous page.
}

Cursor declares cursor-pagination query parameters for List actions.

Cursor owns cursor position and direction only. Ordering for cursor pagination is derived from CursorField and CursorNext, so SortBy intentionally remains outside this struct to avoid multiple competing order sources. The field named must be one no two rows share — the primary key, or a NOT NULL field carrying a unique index of its own — because the cursor is a single boundary value: on a shared one the rows holding it are split between pages and the ones a page had no room for are never read. Any other field is a 400. The feed is ordered from its first page on: a request with a cursor parameter but no _cursor_value reads the first page in the feed's order, and a model embedding Cursor alone reads it from a request naming no cursor parameter at all. Embedding Cursor also lets the client tune the batch size via _size (the field lives in Pagination; the controller reads it from the URL directly), while _page stays rejected: offset paging conflicts with cursor semantics.

type Empty

type Empty struct{}

Empty is a no-op types.Model implementation for non-persistent actions.

Key characteristics:

  • Structs with an anonymous model.Empty field are never migrated to the database
  • All interface methods return zero values or no-op implementations
  • IsEmpty reports true for structs containing only model.Empty markers
  • IsVirtual reports true for any struct embedding model.Empty, however many other fields it declares
  • Service hooks are bypassed when AreTypesEqual returns false for Empty types
  • Commonly used for request/response DTOs that don't require persistence

Usage example:

type LoginRequest struct {
    model.Empty
    Username string `json:"username"`
    Password string `json:"password"`
}

func (*Empty) ClearID

func (*Empty) ClearID()

func (*Empty) CreateAfter

func (*Empty) CreateAfter(context.Context) error

func (*Empty) CreateBefore

func (*Empty) CreateBefore(context.Context) error

func (*Empty) DeleteAfter

func (*Empty) DeleteAfter(context.Context) error

func (*Empty) DeleteBefore

func (*Empty) DeleteBefore(context.Context) error

func (*Empty) Expands

func (*Empty) Expands() []string

func (*Empty) GetAfter

func (*Empty) GetAfter(context.Context) error

func (*Empty) GetBefore

func (*Empty) GetBefore(context.Context) error

func (*Empty) GetCreatedAt

func (*Empty) GetCreatedAt() time.Time

func (*Empty) GetCreatedBy

func (*Empty) GetCreatedBy() string

func (*Empty) GetID

func (*Empty) GetID() string

func (*Empty) GetUpdatedAt

func (*Empty) GetUpdatedAt() time.Time

func (*Empty) GetUpdatedBy

func (*Empty) GetUpdatedBy() string

func (*Empty) ListAfter

func (*Empty) ListAfter(context.Context) error

func (*Empty) ListBefore

func (*Empty) ListBefore(context.Context) error

func (*Empty) Purge

func (*Empty) Purge() bool

func (*Empty) SetCreatedAt

func (*Empty) SetCreatedAt(t time.Time)

func (*Empty) SetCreatedBy

func (*Empty) SetCreatedBy(s string)

func (*Empty) SetID

func (*Empty) SetID(id ...string)

func (*Empty) SetUpdatedAt

func (*Empty) SetUpdatedAt(t time.Time)

func (*Empty) SetUpdatedBy

func (*Empty) SetUpdatedBy(s string)

func (*Empty) TableName

func (*Empty) TableName() string

func (*Empty) UpdateAfter

func (*Empty) UpdateAfter(context.Context) error

func (*Empty) UpdateBefore

func (*Empty) UpdateBefore(context.Context) error

type Pagination

type Pagination struct {
	Page int `json:"-" gorm:"-" query:"_page" url:"_page,omitempty"` // Page is the one-based page number; non-positive values use the first page.
	Size int `json:"-" gorm:"-" query:"_size" url:"_size,omitempty"` // Size is the page size; non-positive values use the default page size and oversized ones clamp to the cap.
}

Pagination declares offset-pagination query parameters for List actions.

Embedding Pagination only enables _page and _size. It does not imply sorting, fuzzy matching, cursor pagination, or any other List query controls.

Like every framework-owned query parameter, _page and _size live in the "_" prefix namespace, so bare names such as page and size stay available as business filter fields on embedding models.

Neither parameter can be used to retrieve a whole table. A size the client does not set, or sets to zero or less, becomes the default page size; a size above the cap is clamped to it rather than refused, which is what keeps a client asking for everything from being served everything. Bulk retrieval is the Export action's job, and it is the only one that answers unbounded.

type Query

type Query struct {
	Pagination
	Cursor

	Expand *string `json:"-" gorm:"-" query:"_expand" url:"_expand,omitempty"`   // Expand lists model associations to preload, separated by commas.
	Depth  *uint   `json:"-" gorm:"-" query:"_depth" url:"_depth,omitempty"`     // Depth is how many levels an expanded association holding the model itself, the children of a tree, is preloaded to; any other association is one level.
	SortBy string  `json:"-" gorm:"-" query:"_sort_by" url:"_sort_by,omitempty"` // SortBy is the comma-separated ORDER BY expression; the List controller validates its columns and turns it into typed orders.
}

Query declares framework-owned HTTP query parameters for list-style controller actions.

Embedding Query in a model is an explicit opt-in: the model can still expose its own fields as query filters through query tags, while Query enables the standard List controls, including offset pagination, cursor pagination, sorting, expansion, and field-level operator filters ("field[op]=value", covering substring matching via like/notlike and time or numeric ranges via the comparison operators). Query already embeds Pagination and Cursor, so models that embed Query must not embed those structs again.

Query covers every framework List control; there is no second opt-in to add.

Every field is ignored by JSON and GORM. The query tags are intentionally kept here because the List controller decodes URL query parameters into the request model before applying those controls; GORM ignoring the fields is what keeps these controller-only values out of the WHERE clauses a query model builds.

Jump to

Keyboard shortcuts

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