model

package
v1.29.0 Latest Latest
Warning

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

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

Documentation

Overview

Package model provides the model registry, metadata extraction, and generic CRUD operations for the Nucleus framework. It uses reflection to extract struct metadata at registration time and uses the native SQL runtime.

Index

Constants

View Source
const (
	HookEngineSQL = "sql"
)

Variables

View Source
var ErrClientAssignedPK = errors.New("entity carries a caller-assigned primary key and the model rejects them (ModelConfig.RejectClientPK)")

ErrClientAssignedPK is returned by Create when the model is registered with ModelConfig.RejectClientPK and the entity arrives carrying a non-zero primary key. It exists for handlers that decode a request body straight into the entity: without it, the HTTP client picks the row's key. Check with errors.Is.

View Source
var ErrDuplicateModelName = errors.New("duplicate model name")

ErrDuplicateModelName reports a second, different type registered under a name the registry already holds.

View Source
var ErrNoPrimaryKey = errors.New("model has no primary key")

ErrNoPrimaryKey is returned by the by-id operations (FindByID, Update, Delete) when the model declares no primary key. Before NU6-2 those operations guessed a phantom `id` column and emitted `WHERE id = ?` against tables that need not have one — an engine error at best, the wrong row at worst. Check with errors.Is.

Functions

func BuildMSSQLMigrationScaffold

func BuildMSSQLMigrationScaffold(meta *ModelMeta) (string, string, error)

BuildMSSQLMigrationScaffold renders deterministic CREATE/DROP migration SQL from extracted model metadata, in SQL Server (T-SQL) dialect.

Differences from the Postgres scaffold:

  • Identifier quoting uses square brackets (`[name]`) — the SQL Server convention. The bracketed form is independent of the QUOTED_IDENTIFIER session setting.
  • Auto-increment PK uses `BIGINT IDENTITY(1,1) PRIMARY KEY`.
  • Type mapping: `BIT` (booleans), `NVARCHAR(MAX)` (unbounded text), `VARBINARY(MAX)` ([]byte), `DATETIME2` (time.Time, no time zone), `FLOAT(53)` (float64), `REAL` (float32). Integers map directly to `INT` / `BIGINT`.
  • SQL Server has no `CREATE TABLE IF NOT EXISTS`. The UP wraps the CREATE in `IF OBJECT_ID('table','U') IS NULL CREATE TABLE …`.
  • SQL Server has no `CREATE INDEX IF NOT EXISTS`. Each index is guarded by `IF NOT EXISTS (SELECT 1 FROM sys.indexes …)`.
  • DROP TABLE uses the 2016+ `DROP TABLE IF EXISTS [name]` form.
  • DROP INDEX uses `DROP INDEX [name] ON [table]`.

The output targets SQL Server 2016 or newer. Earlier versions lack `DROP TABLE IF EXISTS`; operators on legacy versions need to edit the generated DROP section.

func BuildMigrationScaffoldForSystem

func BuildMigrationScaffoldForSystem(system string, meta *ModelMeta) (string, string, error)

BuildMigrationScaffoldForSystem dispatches to the dialect-specific migration scaffold builder for a resolved SQL system name — the values db.SystemFromURL / (*db.DB).System report: "sqlite", "postgresql", "mysql", "mssql", "oracle". The CLI scaffolders use it so `generate resource` emits DDL for the database the project is actually configured against instead of unconditional SQLite (QCD-CLI-4).

func BuildMySQLMigrationScaffold

func BuildMySQLMigrationScaffold(meta *ModelMeta) (string, string, error)

BuildMySQLMigrationScaffold renders deterministic CREATE/DROP migration SQL from extracted model metadata, in MySQL dialect.

Differences from the SQLite scaffold:

  • Identifier quoting uses backticks (`name`).
  • Auto-increment PK uses `BIGINT AUTO_INCREMENT PRIMARY KEY`.
  • Type mapping: `LONGBLOB` (not BLOB), `DATETIME(6)` (not DATETIME), `TINYINT(1)` for booleans, `DOUBLE` for float64.
  • `CREATE INDEX IF NOT EXISTS` is NOT supported on older MySQL versions; this scaffold emits plain `CREATE INDEX`. Idempotency comes from the surrounding `IF NOT EXISTS` on the table — if the table already exists the migrator won't re-run the script.
  • `DROP INDEX name ON table` (MySQL syntax) rather than the stand-alone `DROP INDEX name`.

func BuildOracleMigrationScaffold

func BuildOracleMigrationScaffold(meta *ModelMeta) (string, string, error)

BuildOracleMigrationScaffold renders deterministic CREATE/DROP migration SQL from extracted model metadata, in Oracle (PL/SQL) dialect.

Differences from the Postgres scaffold:

  • Identifiers are emitted UNQUOTED (ADR-011). Oracle folds unquoted identifiers to upper case at parse time, which is the convention the rest of the framework's Oracle path relies on (the CRUD layer emits bare identifiers, the migrations bootstrap creates unquoted tables, and introspection matches via `UPPER(...)`). Quoting would create case-sensitive lower-case tables those layers could not resolve.
  • Auto-increment PK uses `NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY` (Oracle 12c+).
  • Type mapping: `NUMBER(1)` (booleans — Oracle has no native BOOLEAN before 23c), `BLOB` ([]byte), `TIMESTAMP(6) WITH TIME ZONE` (time.Time), `BINARY_DOUBLE` (float64), `BINARY_FLOAT` (float32), `NUMBER(10)` / `NUMBER(19)` (int / int64), `VARCHAR2(4000)` (strings; the maximum without `MAX_STRING_SIZE = EXTENDED`).
  • Oracle has no `CREATE TABLE IF NOT EXISTS`. Every CREATE is wrapped in a PL/SQL block that swallows ORA-00955 ("name is already used by an existing object"), the same pattern used by the migrations-table bootstrap in `pkg/db.migrate.go`.
  • Index creation is also wrapped in a PL/SQL block swallowing ORA-00955.
  • DROP TABLE is wrapped in a PL/SQL block swallowing ORA-00942 ("table or view does not exist").
  • DROP INDEX is wrapped in a PL/SQL block swallowing ORA-01418 ("specified index does not exist").

Targets Oracle 12.2 or newer (longer identifier limit + IDENTITY columns). Earlier versions need sequence-based primary keys and a 30-character identifier budget.

func BuildPostgresMigrationScaffold

func BuildPostgresMigrationScaffold(meta *ModelMeta) (string, string, error)

BuildPostgresMigrationScaffold renders deterministic CREATE/DROP migration SQL from extracted model metadata, in PostgreSQL dialect.

Differences from the SQLite scaffold:

  • Identifiers stay double-quoted (`"name"`) — same as SQLite, so case-folding is avoided.
  • Integer auto-increment primary keys use `BIGSERIAL` rather than SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`.
  • Type mapping: `BYTEA` (not `BLOB`), `TIMESTAMPTZ` (not `DATETIME`), `BOOLEAN` (native), `DOUBLE PRECISION` for float64.
  • `CREATE INDEX IF NOT EXISTS` is supported natively (since 9.5).
  • `DROP TABLE IF EXISTS table CASCADE` to remove dependent foreign keys deterministically.

func BuildSQLiteMigrationScaffold

func BuildSQLiteMigrationScaffold(meta *ModelMeta) (string, string, error)

BuildSQLiteMigrationScaffold renders deterministic CREATE/DROP migration SQL from extracted model metadata.

func ResetDefaultSQLObservers

func ResetDefaultSQLObservers()

ResetDefaultSQLObservers removes every subscriber. It exists for tests that must not leak a subscriber into the next one.

func SanitizeOrderBy

func SanitizeOrderBy(meta *ModelMeta, raw string) (string, error)

SanitizeOrderBy validates a user-supplied ORDER BY expression against a model's known columns and returns a safe "col dir[, col dir ...]" clause. It is the single order-by allow-list shared by the CRUD layer and the admin API (audit LOW-B), so the two cannot drift — it is the SQL-injection barrier for ordering, not quoting (see ADR-011). Column keys match a field's storage column or Go name (case-insensitive); the synthetic primary key "id" is always accepted. Empty input yields an empty clause and no error. Unknown columns and bad directions are rejected.

func SetDefaultSQLObserver

func SetDefaultSQLObserver(obs SQLQueryObserver)

SetDefaultSQLObserver SUBSCRIBES a process-wide SQL observer, or clears every subscriber when obs is nil. It is safe for concurrent use.

It adds rather than replaces. The name is kept because the signature and the nil-clears behaviour are unchanged, and because every existing call site means "I want to see SQL events", which is what it now does without taking that away from anybody else.

Calls after CRUD instances have been constructed are honoured: the subscribers are read on each emit, not captured at construction.

Types

type BaseModel

type BaseModel struct {
	ID        uint       `db:"pk" json:"id"`
	CreatedAt time.Time  `db:"readonly" json:"created_at"`
	UpdatedAt time.Time  `db:"readonly" json:"updated_at"`
	DeletedAt *time.Time `db:"column:deleted_at" json:"-"`
}

BaseModel provides the standard fields that all Nucleus models should embed. It is the equivalent of Django's models.Model.

type CRUD

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

CRUD provides generic create/read/update/delete operations for a registered model. It uses reflection to create model instances dynamically so the admin panel can operate on any registered model without compile-time type knowledge.

func NewCRUD

func NewCRUD(db *sql.DB, meta *ModelMeta, bus *signals.Bus) *CRUD

NewCRUD creates a CRUD operator for the given model metadata. The signals bus is optional (pass nil to disable signal emission).

func (*CRUD) Create

func (c *CRUD) Create(ctx context.Context, entity interface{}) error

Create inserts a new record. Emits PreCreate and PostCreate signals.

A non-zero primary key on the entity travels in the INSERT (the caller's key is respected — see insertColumnsAndArgs). Models registered with ModelConfig.RejectClientPK opt out of that: Create then returns ErrClientAssignedPK for entities that arrive with a non-zero key. The check runs before signals and hooks, so it applies to the entity as the caller handed it over; a BeforeCreate hook that assigns a server-side key is not affected.

func (*CRUD) Delete

func (c *CRUD) Delete(ctx context.Context, id interface{}) error

Delete removes a record by primary key. If the model has a DeletedAt field, performs a soft delete; otherwise a hard delete. Emits PreDelete and PostDelete signals.

func (*CRUD) FindAll

func (c *CRUD) FindAll(ctx context.Context, opts QueryOpts) (*PaginatedResult, error)

FindAll retrieves a paginated, searchable, filterable list of records. It uses an "estimate first" strategy for performance and supports infinite scroll by fetching one extra record to detect if more data exists.

func (*CRUD) FindByID

func (c *CRUD) FindByID(ctx context.Context, id interface{}) (interface{}, error)

FindByID retrieves a single record by primary key.

func (*CRUD) SetDialect

func (c *CRUD) SetDialect(dialect string)

SetDialect sets the database dialect for this CRUD instance. The value drives per-engine placeholder rebinding (see rebind) and the getEstimate count queries, so it is normalised to a single canonical token. The codebase has two dialect-naming conventions — some callers pass "postgres"/"sqlserver" while db.DB.System() emits "postgresql"/"mssql" — and callers pass either; this collapses both to the canonical form so neither convention slips through as an unrebound `?` (F-3, ADR-013).

func (*CRUD) SetSQLQueryObserver

func (c *CRUD) SetSQLQueryObserver(observer SQLQueryObserver)

SetSQLQueryObserver registers a SQL observer for this CRUD instance. Passing nil disables SQL observation.

func (*CRUD) Update

func (c *CRUD) Update(ctx context.Context, id interface{}, updates map[string]interface{}) error

Update modifies an existing record by primary key. Emits PreUpdate and PostUpdate signals.

type CRUDOperator

type CRUDOperator interface {
	FindAll(ctx context.Context, opts QueryOpts) (*PaginatedResult, error)
	FindByID(ctx context.Context, id interface{}) (interface{}, error)
	Create(ctx context.Context, entity interface{}) error
	Update(ctx context.Context, id interface{}, updates map[string]interface{}) error
	Delete(ctx context.Context, id interface{}) error
}

CRUDOperator defines the generic CRUD behavior used by higher layers. CRUD (SQL-backed) implements this contract.

type Choice

type Choice struct {
	Value string `json:"value"`
	Label string `json:"label"`
}

Choice represents a selectable option for enum/select fields.

type FieldMeta

type FieldMeta struct {
	Name          string // Go field name (e.g. "Email")
	Column        string // SQL column name (e.g. "email")
	Label         string // Human-readable label (e.g. "Correo electrónico")
	GoType        string // Go type as string (e.g. "string", "int", "bool")
	HTMLType      string // HTML input type (e.g. "text", "email", "number")
	IsPK          bool   // Is primary key
	IsRequired    bool   // Required field (not null / validate:"required")
	IsReadOnly    bool   // Read-only in admin forms
	IsList        bool   // Shown in list view
	IsSearch      bool   // Included in search queries
	IsFilter      bool   // Shown as filter option
	IsExcluded    bool   // Excluded from admin entirely
	IsForeignKey  bool   // This field is a foreign key reference
	IsTenantField bool   // This field holds the tenant ID for multi-tenant isolation
	ForeignModel  string // Name of the related model (e.g. "User" for UserID)
	ForeignTable  string // Name of the related table (e.g. "users")
	ForeignColumn string // Name of the referenced column (e.g. "id")
	IndexRefs     []IndexRef
	MaxLength     int      // Max length from validate tag
	Choices       []Choice // Enum/select options

	// UnknownDBTokens records `db:` tag directives the parser did not
	// recognize. They change nothing at runtime, but a silently ignored
	// token means the developer believes a constraint exists that was
	// never applied — so App.Run surfaces them as a boot-time WARN.
	UnknownDBTokens []string
}

FieldMeta holds all metadata extracted from a single struct field.

type FieldMetaUpdate

type FieldMetaUpdate struct {
	IsList     *bool   `json:"is_list,omitempty"`
	IsSearch   *bool   `json:"is_search,omitempty"`
	IsFilter   *bool   `json:"is_filter,omitempty"`
	IsExcluded *bool   `json:"is_excluded,omitempty"`
	IsReadOnly *bool   `json:"is_readonly,omitempty"`
	Label      *string `json:"label,omitempty"`
	HTMLType   *string `json:"html_type,omitempty"`
}

FieldMetaUpdate holds the mutable field properties that can be changed at runtime via the admin panel (like Django's ModelAdmin configuration).

type Filter added in v1.29.0

type Filter struct {
	Column string
	Op     FilterOp
	Value  string
	Values []string
}

Filter is one comparison against one column. Value is the text form (the same shape Filters uses) and is normalized to the column's Go type; Values carries the set for in / not_in.

type FilterOp added in v1.29.0

type FilterOp string

FilterOp is the comparison a Filter applies. The set is closed on purpose: every operator maps to one SQL form built from allow-listed tokens, so a caller's input never reaches the query string.

const (
	OpEqual        FilterOp = "eq"
	OpNotEqual     FilterOp = "ne"
	OpGreater      FilterOp = "gt"
	OpGreaterEqual FilterOp = "gte"
	OpLess         FilterOp = "lt"
	OpLessEqual    FilterOp = "lte"
	OpContains     FilterOp = "contains"
	OpStartsWith   FilterOp = "startswith"
	OpEndsWith     FilterOp = "endswith"
	OpIn           FilterOp = "in"
	OpNotIn        FilterOp = "not_in"
	OpIsNull       FilterOp = "isnull"
)

func ParseFilterOp added in v1.29.0

func ParseFilterOp(raw string) (FilterOp, bool)

ParseFilterOp maps the text form of an operator to a FilterOp, reporting whether it is one the model layer knows. An unknown operator is refused by the caller rather than silently treated as equality — a filter that is quietly dropped shows every row and looks like a result.

type ForeignKey

type ForeignKey struct {
	FieldName     string // The FK field (e.g. "UserID")
	Column        string // The FK column (e.g. "user_id")
	ForeignModel  string // The related model name (e.g. "User")
	ForeignTable  string // The related table name (e.g. "users")
	ForeignColumn string // The related column name (e.g. "id")
}

ForeignKey describes a detected foreign key relationship.

type HookContext

type HookContext struct {
	Context context.Context
	Engine  string
	DB      *sql.DB
	Tx      *sql.Tx
}

HookContext exposes runtime information to lifecycle hooks in an engine-agnostic way.

type HookFunc

type HookFunc func(ctx HookContext, entity interface{}) error

HookFunc is the signature for model lifecycle hooks.

type IndexMeta

type IndexMeta struct {
	Name    string   // SQL index name
	Columns []string // Ordered indexed columns
	Unique  bool     // Unique index/constraint
}

IndexMeta describes an index extracted from one or more model fields.

type IndexRef

type IndexRef struct {
	Name   string `json:"name"`
	Unique bool   `json:"unique"`
}

IndexRef captures a field-level index declaration from db tags.

type ModelConfig

type ModelConfig struct {
	Icon          string            // Emoji or icon identifier for the admin sidebar
	ListFields    []string          // Fields shown in the list view
	SearchFields  []string          // Fields included in search queries
	Filters       []string          // Fields shown as filters
	OrderBy       string            // Default ordering (e.g. "created_at desc")
	PageSize      int               // Default page size (0 = framework default of 25)
	ReadOnly      bool              // If true, no create/update/delete in admin
	ExcludeFields []string          // Fields excluded from admin
	FieldLabels   map[string]string // Custom labels: field name -> label

	// RejectClientPK makes CRUD.Create return ErrClientAssignedPK when the
	// entity arrives with a non-zero primary key, instead of inserting that
	// key. Off by default: a caller-assigned key travels in the INSERT
	// (client-generated UUIDs, natural keys).
	//
	// Turn it on for models whose HTTP handlers decode a request body
	// straight into the entity (BindJSON + Create): with the default, that
	// pattern lets the HTTP client choose the row's key. The check runs at
	// the top of Create, before hooks — a BeforeCreate hook that assigns a
	// server-generated key still works with this enabled.
	RejectClientPK bool

	// Database affinity
	DatabaseAlias string // Optional database alias for this model (default "default")

	// Lifecycle hooks
	BeforeCreate HookFunc
	AfterCreate  HookFunc
	BeforeUpdate HookFunc
	AfterUpdate  HookFunc
	BeforeDelete HookFunc
}

ModelConfig holds user-provided configuration for a registered model.

type ModelMeta

type ModelMeta struct {
	Name          string       // Go struct name (e.g. "User")
	Plural        string       // Plural name (e.g. "Users")
	Table         string       // SQL table name (e.g. "users")
	Fields        []FieldMeta  // Extracted field metadata
	PrimaryKey    string       // Name of the PK field (e.g. "ID")
	ForeignKeys   []ForeignKey // Detected foreign key relationships
	Indexes       []IndexMeta  // Declared simple/composite indexes
	Config        ModelConfig  // User-provided configuration
	DatabaseAlias string       // Database alias affinity
	Type          reflect.Type // The reflect.Type of the struct
}

ModelMeta holds all metadata extracted from a registered model struct.

func ExtractMeta

func ExtractMeta(model interface{}) (*ModelMeta, error)

ExtractMeta uses reflection to extract metadata from a model struct. It reads storage tags (db), json, validate, and admin tags to populate FieldMeta. Embedded structs (like BaseModel) are flattened into the parent fields list.

func (*ModelMeta) TenantFieldName

func (m *ModelMeta) TenantFieldName() string

TenantFieldName returns the name of the tenant field column for a model, if declared.

type PaginatedResult

type PaginatedResult struct {
	Items       interface{} `json:"items"`
	Total       int64       `json:"total"`
	Page        int         `json:"page"`
	PageSize    int         `json:"page_size"`
	TotalPages  int         `json:"total_pages"`
	IsEstimated bool        `json:"is_estimated"` // Whether the Total count is an estimate
	HasMore     bool        `json:"has_more"`     // Whether there are more pages (useful for infinite scroll)
}

PaginatedResult wraps a paginated query response.

type QueryOpts

type QueryOpts struct {
	Page     int               // 1-based page number (default: 1)
	PageSize int               // Items per page (default: from ModelConfig)
	Search   string            // Free-text search across SearchFields
	Filters  map[string]string // Exact-match filters: column -> value
	OrderBy  string            // Sort clauses: comma-separated "<column> [asc|desc]" (e.g. "created_at desc, name asc"). Each column must be a known model column; invalid input is rejected with an error (not raw SQL).
	Fields   []string          // SELECT specific columns (empty = all)

	// Where holds the filters that need an operator — a range, a prefix, a
	// set, a null check. Filters stays what it always was (column → value,
	// equality), and the two are ANDed: a caller that never sets Where
	// behaves exactly as before.
	Where []Filter

	// ExactTotal asks for a real COUNT of the matching rows instead of the
	// cheap answer. FindAll never counted a FILTERED query: it answered
	// Total -1 with IsEstimated true, so no pager could say how many pages
	// there were. Counting is a second query, so it is the caller's choice —
	// a screen with a pager asks for it, a background sweep does not.
	ExactTotal bool
}

QueryOpts controls filtering, searching, sorting, and pagination.

type Registry

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

Registry stores registered models and their metadata. It is the central model catalog, equivalent to Django's AppRegistry.

func NewRegistry

func NewRegistry() *Registry

NewRegistry creates an empty model registry.

func (*Registry) All

func (r *Registry) All() []*ModelMeta

All returns all registered models sorted alphabetically by name.

func (*Registry) BulkUpdateFieldMeta

func (r *Registry) BulkUpdateFieldMeta(modelName string, updates map[string]FieldMetaUpdate) error

BulkUpdateFieldMeta updates multiple fields at once for a model.

func (*Registry) Count

func (r *Registry) Count() int

Count returns the number of registered models.

func (*Registry) Get

func (r *Registry) Get(name string) (*ModelMeta, bool)

Get returns the metadata for a model by name (case-sensitive).

func (*Registry) Register

func (r *Registry) Register(model interface{}, cfg ...ModelConfig) error

Register extracts metadata from a model struct and stores it in the registry. Optional ModelConfig overrides can customize admin behavior.

func (*Registry) UpdateFieldMeta

func (r *Registry) UpdateFieldMeta(modelName, fieldName string, update FieldMetaUpdate) error

UpdateFieldMeta updates mutable properties of a field at runtime. Returns an error if the model or field is not found.

type SQLQueryEvent

type SQLQueryEvent struct {
	ModelName string
	Operation string
	Query     string
	Args      []interface{}
	Duration  time.Duration
	Error     error
	// RowsAffected is the driver-reported row count for exec-style
	// operations (INSERT/UPDATE/DELETE). 0 means "not reported": SELECT
	// paths cannot know it without consuming the rows, and some drivers
	// do not support it.
	RowsAffected int64
}

SQLQueryEvent represents one SQL operation executed by CRUD. Values in Args are raw runtime arguments from the query execution call site.

type SQLQueryObserver

type SQLQueryObserver func(ctx context.Context, event SQLQueryEvent)

SQLQueryObserver receives SQLQueryEvent notifications emitted by CRUD operations. It is optional and disabled by default.

Jump to

Keyboard shortcuts

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