resourcepolicy

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 20, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package resourcepolicy is the foundational slice of the model-first resource generator (ADR-016, issue #352). It defines the `gombit` field-policy tag and resolves, per model field, the API behavior the generator derives DTOs, mappers, and CRUD plumbing from.

Ownership of facts is deliberately split (ADR-016): the GORM schema owns persistence facts (DB-backed / primary key / auto-increment / DB default / nullability / auto timestamp / soft-delete / column name) and policy MUST NOT restate them. This resolver carries those facts losslessly — it never collapses them — because create-source correctness depends on the exact combination (a manual string key is not auto-generated; a NOT NULL column with no default must be supplied on create). The `gombit` tag owns only API behavior:

read     the field appears in the response DTO
write    the field is settable via the create request DTO (create source = request)
server   the field is set server-side (a hook), not from the request (create source = server)
-        the field is hidden from the API (no request, no response)

and the list endpoint's declared query surface (all opt-in, all requiring the field to be response-visible):

filterable    exact-match ?<field>= query param
sortable      ?ordering=<field> (- prefix for DESC)
searchable    matched by ?search=
aggregatable  ?aggregate=sum:<field>,… (numeric columns)

`-` may combine with `server` (hidden from the API but set by a hook); it may not combine with `read`/`write`, and `write`+`server` conflict. A query capability requires the field to be response-visible: a hidden or write-only column with a query surface leaks through result membership, counts, and ordering, so Resolve rejects it. A field with no `gombit` tag defaults by kind: a regular content column and a manual primary key are read+write from the request; an auto-increment key and auto timestamps are read-only; soft-delete is hidden. Resolve rejects a required column (NOT NULL / primary key, no `default:` clause, not DB-generated) that ends up with no create source — the silent zero-fill #352 exists to eliminate. It does not evaluate whether a default's SQL yields a non-null value; the database enforces that and fails loudly, so a `default:` clause defers to the database.

Persistence facts are supplied by the caller (slice 2 reads them from the model); this slice is pure and side-effect free.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func RequestColumns

func RequestColumns(resolved []Resolved) []string

RequestColumns returns the DB column names settable via the create request, sorted — the write surface the generated request DTO must expose.

func ResponseColumns

func ResponseColumns(resolved []Resolved) []string

ResponseColumns returns the DB column names surfaced in responses, sorted.

func ServerColumns

func ServerColumns(resolved []Resolved) []string

ServerColumns returns the DB column names whose create value is set server-side (a hook), sorted — the create-time non-request source.

Types

type CreateSource

type CreateSource int

CreateSource is where a persisted field's create value comes from.

const (
	// CreateSourceNone is for fields the create path does not set (auto-generated,
	// or a nullable/defaulted column left unset, or not DB-backed).
	CreateSourceNone CreateSource = iota
	// CreateSourceRequest: the value comes from the create request body.
	CreateSourceRequest
	// CreateSourceServer: the value is set server-side (a BeforeCreate hook).
	CreateSourceServer
)

func (CreateSource) String

func (s CreateSource) String() string

type Field

type Field struct {
	FieldFacts
	Tag string
}

Field pairs a field's persistence facts with its raw `gombit` tag. It keeps the association 1:1: GORM can flatten two embedded fields to the same GoName with different columns and tags, so policy is never keyed by name.

func FromModel

func FromModel(model any) ([]Field, error)

FromModel parses a GORM model (a pointer to a value, e.g. &Book{}) and returns the resolver input for its effective persisted columns, after validating that no relationship field carries a gombit policy (see FromSchema).

func FromSchema

func FromSchema(sch *schema.Schema) ([]Field, error)

FromSchema maps a parsed GORM schema to resolver input over its EFFECTIVE persisted columns — ordered sch.DBNames → sch.FieldsByDBName — so a duplicate-column or shadowed field is included once, each carrying its own `gombit` tag. A belongs-to FK column is a persisted column and is included; the association object/slice fields (belongs-to/has-one/has-many/many2many) are not columns and are NOT emitted.

Selection and validation are separate. An explicit `gombit` policy on a field that is not an effective persisted column (a relationship, a `gorm:"-"` ignored field, or a column shadowed by another field) would otherwise be silently dropped — it lives outside the emitted set. FromSchema therefore validates EVERY policy-bearing field from GORM's authoritative per-field set (sch.Fields), keyed by field identity, not a name-keyed secondary index that collapses same-named embedded fields. A policy that cannot be honored fails closed. Then it emits the effective persisted columns.

type FieldFacts

type FieldFacts struct {
	GoName string // Go field name, e.g. "Title"
	// Column is the DB column name (e.g. "title"). It is EMPTY exactly for a
	// relationship/association field (has_many / many2many / belongs-to object) —
	// there is no separate "DBBacked" flag to disagree with it; db-backedness is
	// derived from Column != "" so an included field always has a column.
	Column        string
	PrimaryKey    bool
	AutoIncrement bool // database-generated key (serial/identity)
	AutoTime      bool // auto create/update timestamp (autoCreateTime/autoUpdateTime)
	SoftDelete    bool // gorm.DeletedAt
	NotNull       bool // NOT NULL constraint
	HasDefault    bool // a `default:` clause is present. Whether that default's SQL expression actually yields a non-null value is the database's concern (it enforces NOT NULL and rejects a bad default loudly at migrate/insert), NOT something this pure layer evaluates.

	// Creatable / Readable mirror GORM's per-field permissions (schema.Field
	// Creatable/Readable, driven by the `->`/`<-` permission tags), independent of
	// whether the field has a column. GORM OMITS non-creatable fields from INSERT
	// and skips non-readable fields on SELECT, so policy must honor them or the API
	// promises an operation persistence will not perform. Normal columns are both
	// true; a `gorm:"->"` read-only/computed column is Creatable=false, and a
	// `gorm:"->:false;<-:create"` column is Readable=false.
	Creatable bool
	Readable  bool
}

FieldFacts are the persistence facts about a model field, owned by the GORM schema. Policy never restates these; it only layers API behavior on top. The facts are kept independent (not collapsed) because create-source correctness depends on their exact combination.

func FactsFromSchema

func FactsFromSchema(f *schema.Field) FieldFacts

FactsFromSchema is the lossless projection of a parsed GORM field into FieldFacts. Soft-delete is read from the clauses schema.Parse stored on the field's own owning schema (f.Schema, which GORM sets during parse) — its authoritative parsed result — so gorm.DeletedAt, *gorm.DeletedAt, and any wrapper whose DeleteClauses rewrite DELETE into an UPDATE are recognized, in either the value or pointer clause form, while a generic clause provider that hard-deletes is not. Create/read capability comes from GORM's own Creatable/Readable (the `->`/`<-` permission tags); nothing is re-derived by hand. The field carries its own owner, so callers pass only the field.

type Resolved

type Resolved struct {
	FieldFacts
	InRequest    bool         // settable via the create request DTO
	InResponse   bool         // surfaced in the response DTO
	CreateSource CreateSource // where a persisted create value comes from
	// The list endpoint's declared query surface for this column. Each is opt-in
	// via the gombit tag and requires the column to be response-visible.
	Filterable   bool
	Sortable     bool
	Searchable   bool
	Aggregatable bool
}

Resolved is a field's resolved API behavior: its persistence facts plus where it appears in the API and where its create value comes from.

func Resolve

func Resolve(facts FieldFacts, gombitTag string) (Resolved, error)

Resolve combines a field's persistence facts with its `gombit` tag into its API behavior. It errors on a contradictory tag (writing/server-setting a managed or non-creatable column, reading a non-readable one, write+server, tagging a relationship) and on a required column that no create source can satisfy — whether because the policy gives it none or because GORM never writes a valid value for it (a non-creatable, `<-:update` auto-time, or NOT NULL soft-delete column). The two questions "may the API write this?" (managed / capability) and "does a valid value reach the INSERT?" (dbSuppliesCreateValue) are kept apart.

func ResolveAll

func ResolveAll(fields []Field) ([]Resolved, error)

ResolveAll resolves every field, in input order, preserving each field's own facts+tag pairing (never keyed by the possibly-ambiguous Go name). It fails on the first contradictory field so the generator never emits from a bad policy.

func ResolvedFromModel

func ResolvedFromModel(model any) ([]Resolved, error)

ResolvedFromModel parses a model and resolves the API policy for each of its effective persisted columns. It returns the first policy error (a contradictory tag or an unsatisfiable required column), so a bad model fails closed.

Jump to

Keyboard shortcuts

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