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
- Variables
- func BuildMSSQLMigrationScaffold(meta *ModelMeta) (string, string, error)
- func BuildMigrationScaffoldForSystem(system string, meta *ModelMeta) (string, string, error)
- func BuildMySQLMigrationScaffold(meta *ModelMeta) (string, string, error)
- func BuildOracleMigrationScaffold(meta *ModelMeta) (string, string, error)
- func BuildPostgresMigrationScaffold(meta *ModelMeta) (string, string, error)
- func BuildSQLiteMigrationScaffold(meta *ModelMeta) (string, string, error)
- func ResetDefaultSQLObservers()
- func SanitizeOrderBy(meta *ModelMeta, raw string) (string, error)
- func SetDefaultSQLObserver(obs SQLQueryObserver)
- type BaseModel
- type CRUD
- func (c *CRUD) Create(ctx context.Context, entity interface{}) error
- func (c *CRUD) Delete(ctx context.Context, id interface{}) error
- func (c *CRUD) FindAll(ctx context.Context, opts QueryOpts) (*PaginatedResult, error)
- func (c *CRUD) FindByID(ctx context.Context, id interface{}) (interface{}, error)
- func (c *CRUD) SetDialect(dialect string)
- func (c *CRUD) SetSQLQueryObserver(observer SQLQueryObserver)
- func (c *CRUD) Update(ctx context.Context, id interface{}, updates map[string]interface{}) error
- type CRUDOperator
- type Choice
- type FieldMeta
- type FieldMetaUpdate
- type ForeignKey
- type HookContext
- type HookFunc
- type IndexMeta
- type IndexRef
- type ModelConfig
- type ModelMeta
- type PaginatedResult
- type QueryOpts
- type Registry
- func (r *Registry) All() []*ModelMeta
- func (r *Registry) BulkUpdateFieldMeta(modelName string, updates map[string]FieldMetaUpdate) error
- func (r *Registry) Count() int
- func (r *Registry) Get(name string) (*ModelMeta, bool)
- func (r *Registry) Register(model interface{}, cfg ...ModelConfig) error
- func (r *Registry) UpdateFieldMeta(modelName, fieldName string, update FieldMetaUpdate) error
- type SQLQueryEvent
- type SQLQueryObserver
Constants ¶
const (
HookEngineSQL = "sql"
)
Variables ¶
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.
var ErrDuplicateModelName = errors.New("duplicate model name")
ErrDuplicateModelName reports a second, different type registered under a name the registry already holds.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
NewCRUD creates a CRUD operator for the given model metadata. The signals bus is optional (pass nil to disable signal emission).
func (*CRUD) Create ¶
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 ¶
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 ¶
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) SetDialect ¶
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.
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 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 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 ¶
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 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 ¶
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 ¶
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)
}
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 (*Registry) BulkUpdateFieldMeta ¶
func (r *Registry) BulkUpdateFieldMeta(modelName string, updates map[string]FieldMetaUpdate) error
BulkUpdateFieldMeta updates multiple fields at once for a model.
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.