Help us shape the future of Go! Take the 2026 Go Developer Survey

relations

package
v1.20.0-alpha.2 Latest Latest
Warning

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

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package relations hosts the relation builder objects Orm.Related / Query.Related return — the Go form of Eloquent's Relation classes as first-class query builders.

The dependency arrow is one-way: database/gorm imports this package (never the reverse). Everything gorm-specific the object layer needs — column reads, table resolution, single-row reads, the WithDefault builder, the pending attributes — travels through the plain-data RelationInfo projection and the Hooks closures gorm binds at wrap time, so this package depends only on the contracts.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AllPivotIDs

func AllPivotIDs(b Builder, info RelationInfo, parentVal any) ([]any, error)

AllPivotIDs returns the set of all currently-attached related ids for parent. Used by Sync / Toggle to compute the diff.

func ApplyAssociate

func ApplyAssociate(b Builder, info RelationInfo, parent, owner any, isMorph bool) error

ApplyAssociate writes owner's PK into parent's FK column (and the morph_type column for MorphTo, resolved from the morph map / MorphClass()), then persists parent — BelongsTo::associate followed by the parent save the Go shells perform (the corpus ports' documented divergence).

func ApplyAttrMap

func ApplyAttrMap(b Builder, dest any, attrs map[string]any) error

ApplyAttrMap overlays attrs onto dest through the parsed schema, mapping column names to struct fields. dest must be a pointer to a struct. Skips columns that don't map to a field.

func ApplyDissociate

func ApplyDissociate(b Builder, info RelationInfo, parent any, isMorph bool) error

ApplyDissociate sets parent's FK to the zero value (and morph_type to "" for MorphTo), then persists parent — BelongsTo::dissociate followed by the parent save.

func ApplyInverseRelationToCreated

func ApplyInverseRelationToCreated(b Builder, info RelationInfo, child any, parent any) error

ApplyInverseRelationToCreated stores parent on the created (or freshly made) child under the relation's inverse name — SupportsInverseRelations::applyInverseRelationToModel, the call HasOneOrMany::make (vendor v12.55.1, HasOneOrMany.php:65) and ::create (:393) make after the foreign key stamp, and the trailing call of setForeignAttributesForCreate (:506) that every FK-stamping path shares (save :334, findOrNew :233, firstOrNew :251, createOrFirst via create, upsert excepted — upsert stamps the key per row and applies no inverse, HasOneOrMany.php:313). In memory only: no statement runs, and the stored pointer is the parent instance the relation was resolved from — the oracle's assertSame. The inverse name is the live chaperone state when the builder carries one (WithoutChaperone / WithoutInverse cleared it to ""), the declaration otherwise (Builder.InverseWriteBackName). Oracle (v12.55.1, executed, /tmp/lalign-defaults-inverse/inverse_writeback_runtime.php): create / make / save / forceCreate / the many variants / findOrNew / firstOrNew / firstOrCreate / updateOrCreate / createOrFirst all report relationLoaded true with the parent identical; withoutChaperone()->create()/make() report relationLoaded false.

func Attach

func Attach(b Builder, parent any, relation string, ids []any) error

Attach inserts pivot rows linking parent to each id in ids. Skips ids that already have a pivot row. Supported kinds: Many2Many, MorphToMany, MorphedByMany. The body of the AttachRelation shell (RelationWriter.Attach, contracts/database/orm/relation_writer.go:134).

func AttachOrFail

func AttachOrFail(b Builder, parent any, relation string, ids []any) error

AttachOrFail is Attach inside a transaction — InteractsWithPivotTable::attachOrFail (InteractsWithPivotTable.php:365). The parent guard runs before the transaction opens, the same order every shell validates in.

func AttachWithPivot

func AttachWithPivot(b Builder, parent any, relation string, idsWithAttrs map[any]map[string]any) error

AttachWithPivot is Attach with per-row pivot column values.

func BasePivotRow

func BasePivotRow(info RelationInfo, parentVal, relatedID any, attrs map[string]any) map[string]any

BasePivotRow builds the column map for one pivot INSERT row. Always includes the parent FK and the related FK; for MorphToMany also includes the morph_type column. When the relation's resolved created_at / updated_at columns are non-empty, stamps those columns with time.Now(). Caller-supplied attrs are merged on top — the caller wins on column-name conflicts.

func CastKey

func CastKey(v any, t reflect.Type) any

CastKey converts v to the Go type t, handling the common cross-type cases (int/uint/float numeric widening + narrowing, string ↔ numeric). Returns v unchanged when t is nil, when v is already the right type, or when conversion isn't safely representable. database/gorm's flat pluck shares it (builder_shortcuts.go pluckValue, through the castKey forwarder).

func CastKeys

func CastKeys(ids []any, keyType reflect.Type) []any

CastKeys returns a copy of ids with each value normalised to keyType (the related model's PK type). Used by the sync cores to ensure SyncResult elements carry a stable Go type regardless of what the caller passed in or what the driver scanned out of the pivot table.

Mirrors fedaco's _castKeys / _getTypeSwapValue. Returns nil for a nil input slice (preserving the "no rows touched" signal).

func CreateOrFirstEngine

func CreateOrFirstEngine(b Builder, info RelationInfo, parent any, relation string, attrs, values map[string]any, dest any, relookupAttrs map[string]any) (bool, error)

CreateOrFirstEngine is the shared insert-first engine of the create-or-first family: attrs and values are overlaid on dest (values win), the parent key (and morph type) is pre-set for the FK-on-related kinds, the insert runs inside WithSavepoint, and a unique-constraint violation reads the row relookupAttrs names back — over the write connection for the HasOneOrMany kinds (Laravel's useWritePdo), on the default connection for the through kinds (HasOneOrManyThrough::createOrFirst). It reports whether the violation was resolved by reading an existing row back (wasRecentlyCreated false in Laravel).

func Detach

func Detach(b Builder, parent any, relation string, ids []any) (int64, error)

Detach removes pivot rows linking parent to the given ids. With nil ids, removes all pivot rows for parent (and morph type, for polymorphic); an explicitly empty (non-nil) id list is Laravel's detach([]) no-op — 0, no statement (InteractsWithPivotTable.php:530-535; oracle v12.55.1, executed, /tmp/lalign-cleanups/detach_empty.php: "detach([]) return => 0", "detach([]) queries => []"). Returns the number of rows removed. The body of the DetachRelation shell.

func DetachOrFail

func DetachOrFail(b Builder, parent any, relation string, ids []any) (int64, error)

DetachOrFail is Detach inside a transaction — InteractsWithPivotTable::detachOrFail (:562).

func DictionaryKey

func DictionaryKey(v any) string

DictionaryKey reduces any value to a canonical string for use as a map key, paving over the type mismatch between Go field types (uint, int64, string) and database-layer scan types (often int64 or []byte). Pointer values unwrap to the value they address first, so a pointer FK keys the dictionary by value rather than by address, and a driver.Valuer keys on its Value(). It is database/gorm's dictKey, duplicated — the Go form of Eloquent's InteractsWithDictionary::getDictionaryKey the attach / sync diffs key their sets with.

func DoAttach

func DoAttach(b Builder, info RelationInfo, parentVal any, ids []any, idsWithAttrs map[any]map[string]any) (int64, error)

DoAttach is the shared work-horse for Attach / AttachWithPivot. It does not trigger TouchIfTouching — callers (the public entries, and SyncCore) own when to touch. Returns the number of newly-inserted pivot rows; zero when every id was already attached.

idsWithAttrs is optional: when non-nil, attrs from this map are merged into each new row; when nil, ids alone drive the insert.

func DoAttachWithPivot

func DoAttachWithPivot(b Builder, info RelationInfo, parentVal any, idsWithAttrs map[any]map[string]any) (int64, error)

DoAttachWithPivot is DoAttach for the with-attrs entry shape (map keyed by id). Equivalent in outcome to DoAttach with the same attrs; kept separate to avoid materialising an ids slice just to satisfy the DoAttach signature when callers already have a map.

func DoDetach

func DoDetach(b Builder, info RelationInfo, parentVal any, ids []any) (int64, error)

DoDetach is the no-touch worker behind Detach. Returns rows affected by the DELETE.

func DoUpdateExistingPivot

func DoUpdateExistingPivot(b Builder, info RelationInfo, parentVal any, id any, attrs map[string]any) (int64, error)

DoUpdateExistingPivot is the no-touch worker behind UpdateExistingPivot. Returns the number of pivot rows actually updated. When the relation has a resolved updated_at column and attrs doesn't already set it, injects time.Now() into the UPDATE map.

func ExistingPivotIDs

func ExistingPivotIDs(b Builder, info RelationInfo, parentVal any, ids []any) (map[string]struct{}, error)

ExistingPivotIDs returns the set of already-attached related ids among ids. Used by Attach to skip duplicates.

func FindOrNew

func FindOrNew(b Builder, info RelationInfo, parent any, relation string, id any, dest any) error

FindOrNew is the HasOneOrMany arm of FindOrNewRelation: finds the related row with primary key id through the relation read; when absent, pre-sets the FK (and morph type) on the zero-valued dest — HasOneOrMany::findOrNew (vendor v12.55.1, HasOneOrMany.php:228-236) — and does NOT persist. Whether Find populated dest is read off dest's primary key.

func FirstOrCreate

func FirstOrCreate(b Builder, info RelationInfo, parent any, relation string, attrs map[string]any, values map[string]any, dest any) error

FirstOrCreate is the HasOneOrMany / through arm of FirstOrCreateRelation: the lookup runs first, and a miss routes through CreateOrFirstEngine — the insert is attempted, and a unique-constraint violation reads the row back instead of failing. HasOneOrMany::firstOrCreate's catch re-reads where(attrs) — attrs only; the values live on the insert — while HasOneOrManyThrough::firstOrCreate funnels attrs+values through createOrFirst as a single attribute set, so its violation re-read carries both.

func FirstOrNew

func FirstOrNew(b Builder, info RelationInfo, parent any, relation string, attrs map[string]any, values map[string]any, dest any) error

FirstOrNew is the HasOneOrMany / through arm of FirstOrNewRelation: the first related row matching attrs, or dest filled with attrs+values (values win) and — for the HasOneOrMany kinds — the pre-set FK (and morph type); nothing is persisted. HasOneOrMany::firstOrNew (HasOneOrMany.php:244-254) and HasOneOrManyThrough::firstOrNew, whose through kinds carry no key on the related row, so newInstance() alone is the whole response.

func IsValidParent

func IsValidParent(v any) bool

IsValidParent reports whether v is a non-nil pointer to a struct — the parent / child guard every write shell opens with (database/gorm isValidParent, duplicated: pure reflection).

func KindName

func KindName(k Kind) string

KindName returns a human-friendly name for a Kind, used in error messages — database/gorm's kindName over the descriptor kind, duplicated so the kind-not-supported message a ported worker reports carries the same word.

func MergeAttrMaps

func MergeAttrMaps(attrs, values map[string]any) map[string]any

MergeAttrMaps merges values over attrs — the array_merge($attributes, $values) order the createOrFirst path receives.

func ModelTypeName

func ModelTypeName(model any) string

ModelTypeName reports the Go type name of the struct a model value leads to — pointers, slices and arrays unwrapped — the modelSchema.Name database/gorm's parseSchema records and the OrmRelationUnsupported messages of the FK writers carry ("no FK field ..." on model X). "" when the value leads to no struct.

func MutateAssociate

func MutateAssociate(b Builder, info RelationInfo, parent, owner any, isMorph bool) error

MutateAssociate is the pure-mutation half of ApplyAssociate. Writes owner's PK into parent's FK column (and the morph_type column for MorphTo). No persistence.

func MutateDissociate

func MutateDissociate(b Builder, info RelationInfo, parent any, isMorph bool) error

MutateDissociate is the pure-mutation half of ApplyDissociate.

func RelationFirst

func RelationFirst(b Builder, parent any, relation string, attrs map[string]any, dest any, writePdo bool) (bool, error)

RelationFirst runs the relation query as a single-row read and reports whether a row was found — the flag Query.First discards. writePdo forces the read onto the write connection, which the createOrFirst catch does for the HasOneOrMany kinds (Laravel's useWritePdo) so the row a concurrent writer just committed is visible; the through kinds read on the default connection, as HasOneOrManyThrough::createOrFirst does. The attrs map is applied in sorted key order so the compiled where list is stable across runs (Go map iteration is unordered).

func RelationTransaction

func RelationTransaction(b Builder, fn func(session Builder) error) error

RelationTransaction runs fn inside a transaction — Eloquent's `$this->parent->getConnection()->transaction(...)` around each OrFail method (InteractsWithPivotTable.php:75/:157/:170/:200/:299/:365/:562). The transaction opens on the builder the relation writes run on, the parent's connection in the single-connection relation model the port supports; a call made while that connection already holds a transaction runs inside a savepoint, the nesting rule Connection::transaction applies. The base call's error rolls the transaction back and propagates. The session fn receives is the transaction's own builder, which is why the OrFail entries re-dispatch the base shell on it.

func ResolveMorphAlias

func ResolveMorphAlias(model any) (string, bool, error)

ResolveMorphAlias returns the morph alias for model from MorphClass() / morph map only — without falling back to the table name. Used by Associate when we want to know whether the owner has an explicit registered alias before defaulting to its table. Under strict morph mode an unmapped owner raises errors.OrmClassMorphViolation through the returned error.

func ResolveMorphValue

func ResolveMorphValue(parent any, gormDefault string) (string, error)

ResolveMorphValue picks the value to use for a polymorphic *_type column. The model-level MorphClass() method takes precedence, then the global morph map (registered via orm.MorphMap), then GORM's parsed PrimaryValue (which is either a `polymorphicValue:` tag or the parent's table name). Under strict morph mode (orm.RequireMorphMap / orm.EnforceMorphMap) an unmapped model raises errors.OrmClassMorphViolation instead of reaching the fallback — the Go reading of getMorphClass's ClassMorphViolationException throw (HasRelationships.php:1019); the base relations.Pivot stays exempt (HasRelationships.php:1014).

func ResolvePivotTimestamps

func ResolvePivotTimestamps(relatedModel any, pivotFieldName string, fallbackEnabled bool) (createdCol, updatedCol string, err error)

ResolvePivotTimestamps decides which pivot-table columns should be auto-stamped with the current time on INSERT (created) and INSERT/UPDATE (updated). Detection priority:

  1. Pivot struct field with `gorm:"autoCreateTime"` / `gorm:"autoUpdateTime"` tag — column name from the field's GORM schema (respects `gorm:"column:..."`).
  2. Pivot struct field named CreatedAt / UpdatedAt of type time.Time (GORM convention).
  3. fallbackEnabled (relation-level PivotTimestamps: true) — defaults to "created_at" / "updated_at" for whichever column the pivot struct didn't already provide.

Empty string for either column means "don't auto-stamp on that op". The pivot struct does not need to declare both — declaring only CreatedAt is fine and disables update-side stamping.

func SetRelationFKOnChild

func SetRelationFKOnChild(b Builder, info RelationInfo, parent, child any) error

SetRelationFKOnChild reads parent's local key, then writes that value into child's FK column (and the morph_type column for MorphOne/MorphMany) — HasOneOrMany::setForeignAttributesForCreate (vendor v12.55.1, HasOneOrMany.php:506). Mutates child in place; child must be a pointer to a struct. The trailing inverse call links the child back to the parent in memory.

func Sync

func Sync(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

Sync replaces parent's pivot rows so they exactly match ids: detaches missing entries, attaches new ones, leaves existing untouched. Returns the per-id outcome. The body of the SyncRelation shell (RelationWriter.Sync, contracts/database/orm/relation_writer.go:150).

func SyncCore

func SyncCore(b Builder, parent any, relation string, ids []any, detachMissing bool, toggle bool, op string) (*dbcontract.SyncResult, error)

SyncCore is the shared engine for Sync / SyncWithoutDetaching / Toggle.

func SyncCoreWithPivot

func SyncCoreWithPivot(b Builder, parent any, relation string, idsWithAttrs map[any]map[string]any, detachMissing bool, toggle bool, op string) (*dbcontract.SyncResult, error)

SyncCoreWithPivot is the shared engine for SyncWithPivot / SyncWithPivotValues / SyncWithoutDetachingWithPivot / ToggleWithPivot. Similar to SyncCore but accepts a map of IDs to pivot attributes and updates existing pivot rows when attrs are non-empty.

func SyncOrFail

func SyncOrFail(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

SyncOrFail is Sync inside a transaction — InteractsWithPivotTable::syncOrFail (:157).

func SyncResultChanged

func SyncResultChanged(out *dbcontract.SyncResult) bool

SyncResultChanged reports whether out indicates any actual pivot-table mutation. Used by SyncCore / SyncCoreWithPivot to decide whether to call TouchIfTouching at the end.

func SyncWithPivot

func SyncWithPivot(b Builder, parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)

SyncWithPivot is Sync with per-ID pivot column values. The map key is the related id; the map value is the column-name-to-value map applied to that pivot row. For existing pivot rows with non-empty attrs, updates the pivot columns (reported in SyncResult.Updated). Mirrors fedaco's sync(map).

func SyncWithPivotValues

func SyncWithPivotValues(b Builder, parent any, relation string, ids []any, pivotValues map[string]any) (*dbcontract.SyncResult, error)

SyncWithPivotValues applies the same pivot column values to all ids. Mirrors fedaco's syncWithPivotValues.

func SyncWithPivotValuesOrFail

func SyncWithPivotValuesOrFail(b Builder, parent any, relation string, ids []any, pivotValues map[string]any) (*dbcontract.SyncResult, error)

SyncWithPivotValuesOrFail is SyncWithPivotValues inside a transaction — InteractsWithPivotTable::syncWithPivotValuesOrFail (:200).

func SyncWithoutDetaching

func SyncWithoutDetaching(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

SyncWithoutDetaching is Sync minus the detach step.

func SyncWithoutDetachingOrFail

func SyncWithoutDetachingOrFail(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

SyncWithoutDetachingOrFail is SyncWithoutDetaching inside a transaction — InteractsWithPivotTable::syncWithoutDetachingOrFail (:170, `$this->syncOrFail($ids, false)`).

func SyncWithoutDetachingWithPivot

func SyncWithoutDetachingWithPivot(b Builder, parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)

SyncWithoutDetachingWithPivot is SyncWithPivot minus the detach step.

func Toggle

func Toggle(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

Toggle attaches missing entries and detaches existing ones.

func ToggleOrFail

func ToggleOrFail(b Builder, parent any, relation string, ids []any) (*dbcontract.SyncResult, error)

ToggleOrFail is Toggle inside a transaction — InteractsWithPivotTable::toggleOrFail (:75).

func ToggleWithPivot

func ToggleWithPivot(b Builder, parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)

ToggleWithPivot is Toggle with per-ID pivot column values for newly attached rows.

func TouchIfTouching

func TouchIfTouching(b Builder, info RelationInfo, parent any, parentVal any) error

TouchIfTouching bumps parent's updated_at column when the relation declares Touches. Silently no-ops when it does not, when the parent's class ignores touches — Builder.IsIgnoringTouch, the gate Laravel's per-pivot touches ride through Relation::touch: a custom pivot's save or delete walks its $touches one row at a time, while this writer bumps once after the batch, and both are suppressed while the bumped class sits in a withoutTouching scope — when the parent's schema doesn't expose an updated_at field, or when the parent has no primary-key column to anchor the WHERE clause. Mirrors fedaco's touchIfTouching on BelongsToMany.

Called at the tail end of the public Sync / Attach / Detach / Toggle / UpdateExistingPivot shells, and only when the operation actually affected pivot rows. The internal doAttach / doDetach / doUpdateExistingPivot helpers do NOT touch — sync* paths chain multiple internal calls and touch at most once at the end through this helper.

The stamp comes from the framework clock — carbon.Now(), the value every other write path stamps — so a frozen test clock (carbon.SetTestNow) observes the touch. The statement is the one UpdateRows compiles with no pivot scope: `update <parent table> set updated_at = ? where <key> = ?`.

func TryGetRelations

func TryGetRelations(parent any) (map[string]contractsorm.Relation, bool)

TryGetRelations returns the parent model's Relations() map regardless of whether the method is declared with a value receiver (`func (Foo) Relations()`) or a pointer receiver (`func (*Foo) Relations()`). Returns ok=false if neither form satisfies ModelWithRelations.

Mirrors the dual-receiver detection in morphmap.tryMorphClass; both helpers exist because Go doesn't pick a receiver style for users — and real-world models freely mix value and pointer receivers across methods on the same struct.

func UpdateExistingPivot

func UpdateExistingPivot(b Builder, parent any, relation string, id any, attrs map[string]any) (int64, error)

UpdateExistingPivot updates pivot columns for an already-attached id. When pivot timestamps are resolved and attrs doesn't already set updated_at, injects time.Now() into the update map. No-op (returns 0) if no matching pivot row exists. The body of the UpdateExistingPivotRelation shell.

func UpdateExistingPivotOrFail

func UpdateExistingPivotOrFail(b Builder, parent any, relation string, id any, attrs map[string]any) (int64, error)

UpdateExistingPivotOrFail is UpdateExistingPivot inside a transaction — InteractsWithPivotTable::updateExistingPivotOrFail (:299).

func UpdateOrCreate

func UpdateOrCreate(b Builder, info RelationInfo, parent any, relation string, attrs map[string]any, values map[string]any, dest any) error

UpdateOrCreate is the HasOneOrMany / through arm of UpdateOrCreateRelation: the first related row matching attrs (or one created through CreateOrFirstEngine), overlaid with values and persisted. A row read back from a unique-constraint race is filled with the values and saved too — Laravel's wasRecentlyCreated() branch. Always saves dest on the found and race-read paths.

func UseDescriptorSeam

func UseDescriptorSeam(seam DescriptorSeam)

UseDescriptorSeam installs the gorm-side readers the descriptor resolver runs on.

func ValidWithDefault

func ValidWithDefault(value any) bool

ValidWithDefault reports whether a WithDefault declaration uses one of the accepted forms.

func Wrap

func Wrap(builder Builder, parent any, name string, info RelationInfo, hooks Hooks) contractsorm.RelationQuery

Wrap builds the relation object for the given descriptor projection: the per-kind leaf for the known kinds — the morph kinds build their dedicated leaves (morphOneRelationQuery / morphManyRelationQuery / morphToRelationQuery / morphToManyRelationQuery, mirroring the Laravel class tree) — the bare core otherwise.

func WrapBase

func WrapBase(builder Builder, parent any, name string, hooks Hooks) contractsorm.RelationQuery

WrapBase wraps a builder in the bare relation object — the shape the gorm error paths use: a relation that could not be resolved still returns a RelationQuery handle whose promoted terminals (Get / First / ...) report the guarded builder's error.

Types

type Builder

type Builder interface {
	contractsorm.Query

	// GroupLimit sets the query builder's "group limit" — Query\Builder::groupLimit
	// (Builder.php:3128), reached from the relation objects' limit() through
	// Eloquent\Builder::__call when the parent does not exist: the select compiles as the
	// row_number() over (partition by column) derived table bounded at value rows per group.
	GroupLimit(value int, column string) contractsorm.Query
	// UseLegacyGroupLimit reports whether the bound grammar compiles the group limit through
	// the legacy rewrite (MySqlGrammar::useLegacyGroupLimit — MySQL before 8.0.11), the
	// decision HasOneOrManyThrough::limit (:779-783) and BelongsToMany::limit (:1510-1514)
	// consult before choosing the partition column.
	UseLegacyGroupLimit() bool

	SaveRelation(parent any, relation string, child any) error
	SaveManyRelation(parent any, relation string, children any) error
	SaveRelationWithPivot(parent any, relation string, child any, attrs map[string]any) error
	SaveManyRelationWithPivot(parent any, relation string, children any, attrsPerChild map[any]map[string]any) error
	AssociateRelation(parent any, relation string, owner any) error
	DissociateRelation(parent any, relation string) error
	CreateRelation(parent any, relation string, dest any) error
	CreateManyRelation(parent any, relation string, dests any) error
	FindOrNewRelation(parent any, relation string, id any, dest any) error
	FirstOrNewRelation(parent any, relation string, attrs map[string]any, values map[string]any, dest any) error
	FirstOrCreateRelation(parent any, relation string, attrs map[string]any, values map[string]any, dest any) error
	UpdateOrCreateRelation(parent any, relation string, attrs map[string]any, values map[string]any, dest any) error
	CreateOrFirstRelation(parent any, relation string, attrs map[string]any, values map[string]any, dest any) error
	MakeRelation(parent any, relation string, attrs map[string]any, dest any) error
	MakeManyRelation(parent any, relation string, records []map[string]any, dests any) error
	UpsertRelation(parent any, relation string, values []map[string]any, uniqueBy []string, update ...any) (*dbcontract.Result, error)
	AttachRelation(parent any, relation string, ids []any) error
	AttachWithPivotRelation(parent any, relation string, idsWithAttrs map[any]map[string]any) error
	DetachRelation(parent any, relation string, ids []any) (int64, error)
	SyncRelation(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	SyncRelationWithPivot(parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)
	SyncRelationWithPivotValues(parent any, relation string, ids []any, pivotValues map[string]any) (*dbcontract.SyncResult, error)
	SyncWithoutDetachingRelation(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	SyncWithoutDetachingRelationWithPivot(parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)
	ToggleRelation(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	ToggleRelationWithPivot(parent any, relation string, idsWithAttrs map[any]map[string]any) (*dbcontract.SyncResult, error)
	UpdateExistingPivotRelation(parent any, relation string, id any, attrs map[string]any) (int64, error)

	// Quiet and OrFail write families: the shells the relation objects' quiet / forceCreate /
	// OrFail methods forward to — Laravel's HasOneOrMany quiet family
	// (HasOneOrMany.php:345/373/403/455; forceCreateQuietly :427) and InteractsWithPivotTable's
	// OrFail family (InteractsWithPivotTable.php:75/157/170/200/299/365/562). The quiet shells
	// run the same write with the without-events flag set on the fresh persistence session; the
	// OrFail shells wrap the base shell in a transaction on this builder's connection.
	SaveRelationQuietly(parent any, relation string, child any) error
	SaveManyRelationQuietly(parent any, relation string, children any) error
	CreateRelationQuietly(parent any, relation string, dest any) error
	CreateManyRelationQuietly(parent any, relation string, dests any) error
	AttachRelationOrFail(parent any, relation string, ids []any) error
	DetachRelationOrFail(parent any, relation string, ids []any) (int64, error)
	SyncRelationOrFail(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	SyncWithoutDetachingRelationOrFail(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	SyncRelationWithPivotValuesOrFail(parent any, relation string, ids []any, pivotValues map[string]any) (*dbcontract.SyncResult, error)
	ToggleRelationOrFail(parent any, relation string, ids []any) (*dbcontract.SyncResult, error)
	UpdateExistingPivotRelationOrFail(parent any, relation string, id any, attrs map[string]any) (int64, error)

	// Pivot execution surface — the frozen widening relations.Pivot runs on (the complete design
	// note, justified call by call against AsPivot, lives in relations/pivot.go):
	//
	// NewPivotQuery builds a fresh builder bound to the pivot's own table on the pivot's
	// connection ("" keeps the current one) — the handle every select / update / delete / insert
	// the pivot runs goes through. ReadPivotParent reads the parent model's pivot metadata (the
	// declared connection name and the created-at / updated-at column names). PivotTouchOwners is
	// AsPivot's touchOwners() call. FirePivotEvent dispatches one model event on the pivot when a
	// handler is registered for the pivot's type.
	NewPivotQuery(table string, connection string) (contractsorm.Query, error)
	ReadPivotParent(parent any) (PivotParentInfo, error)
	PivotTouchOwners(pivot any) error
	FirePivotEvent(name string, pivot any) error

	// ColumnValue reads the value of a DB column off a model — W2, the promoted Hooks.ReadColumn:
	// the schema-resolved read readParentColumn performs, with its "no parent field" error for a
	// column the model's schema lacks.
	ColumnValue(model any, column string) (any, error)
	// SetColumn writes value into the model's column through the parsed schema — W1, the
	// schemaField.Set call mutateAssociate / mutateDissociate / setRelationFKOnChild / applyAttrMap
	// / touchIfTouching make. model must be a non-nil pointer to a struct. The bool reports
	// whether the schema carries the column: false (with no error) lets a caller keep the
	// workers' own absent-column handling — applyAttrMap skips, the FK writers raise
	// OrmRelationUnsupported with their relation-specific message.
	SetColumn(model any, column string, value any) (bool, error)
	// ColumnType reports the Go type of the model's column — W3, the schemaField.FieldType the
	// dissociate writers zero and castKeys targets. false when the schema carries no such column.
	ColumnType(model any, column string) (reflect.Type, bool, error)
	// TableOf resolves a model's declared table name without the connection prefix — tableNameFor,
	// the error-reporting form of Hooks.ModelTable.
	TableOf(model any) (string, error)
	// RelationInfoOf resolves parent's relation by name and hands back its projection — the
	// resolveRelation call every write shell opens with, projected through the same bridge Wrap
	// receives (relationInfoFromDescriptor), including the W9 fields.
	RelationInfoOf(parent any, relation string) (RelationInfo, error)
	// NewQuery builds the same query with no state of its own — fresh(), the session
	// applyAssociate / applyDissociate / createOrFirstRelation persist through.
	NewQuery() contractsorm.Query
	// WriteSession is the fresh session a relation write persists through, keeping the caller's
	// event suppression — relationWriteSession(), what the Save / Create shells spawn.
	WriteSession() contractsorm.Query
	// WithSavepoint runs scope directly outside a transaction and inside a savepoint within one —
	// W8, withSavepointIfNeeded, Eloquent's Builder::withSavepointIfNeeded.
	WithSavepoint(scope func() error) error
	// FirstFound runs this query as a single-row read into dest and reports whether a row was
	// found — firstRow, the flag Query.First discards and the find-then-write family branches on.
	FirstFound(dest any) (bool, error)
	// Ident renders a dotted identifier with the connection's own quoting — ident, the column
	// side of every equality fragment the pivot workers hand pivotWhere.
	Ident(parts ...string) string
	// InsertRows inserts rows into table through the compiled insert — W4, insertPivotRows: the
	// rows are given the union of their columns first (uniformInsertRecords) and the statement
	// runs on the connection pool. No pivot scope is replayed — Laravel's attach writes the
	// pivot row without the wherePivot constraints as well.
	InsertRows(table string, rows []map[string]any) error
	// UpdateRows compiles and runs an update of values on table — W5, the
	// doUpdateExistingPivot / touchIfTouching statement: a fresh builder bound to table, each
	// RowWhere appended in order through addWhere, the pivot scope replayed last (nil skips it),
	// then compileUpdate + execStatement. Reports the rows affected.
	UpdateRows(table string, wheres []RowWhere, scope contractsorm.PivotCallback, values map[string]any) (int64, error)
	// DeleteRows compiles and runs a delete on table — W6, the doDetach statement, assembled the
	// way UpdateRows assembles its update and run through execDelete. Reports the rows affected.
	DeleteRows(table string, wheres []RowWhere, scope contractsorm.PivotCallback) (int64, error)
	// SelectRows reads the given columns of table into column-keyed maps — W7, the
	// existingPivotIDs / allPivotIDs read: the select list set on the fresh builder, each RowWhere
	// appended in order, the pivot scope replayed and the statement read through findMapsCompiled.
	SelectRows(table string, columns []string, wheres []RowWhere, scope contractsorm.PivotCallback) ([]map[string]any, error)
	// IsIgnoringTouch reports whether a model's class ignores touches — database/orm's
	// IsIgnoringTouch, Eloquent's Model::isIgnoringTouch, read per call because a WithoutTouching
	// scope toggles the ignore list only for the span of its callback.
	IsIgnoringTouch(model any) bool
	// MorphAliasOf reports the morph alias a model registers through MorphClass() or the morph
	// map, without the table-name fallback — resolveMorphAlias, the Associate owner-type read.
	// ok is false for an unmapped model; strict morph mode reports the violation through err.
	MorphAliasOf(model any) (string, bool, error)
	// IsUniqueConstraintViolation reports whether err is a unique-constraint violation the way
	// each supported connection classifies one — isUniqueConstraintViolation, Eloquent's
	// Connection::isUniqueConstraintError, the createOrFirst catch.
	IsUniqueConstraintViolation(err error) bool
	// InverseWriteBackName reports the inverse relation name a relation write links the child
	// back to — inverseWriteBackName: the live chaperone state when this builder carries one (a
	// relation object installed it at wrap time and WithoutChaperone / WithoutInverse cleared it
	// to ""), the declared name otherwise. "" means no write-back.
	InverseWriteBackName(declared string) string
	// SetRelation stores value on the model's relation field by name — the setRelationField call
	// applyInverseRelationToCreated makes with the parent pointer (inverseParentRef). In memory
	// only. model and value must both be non-nil pointers; any other shape is the guard's no-op.
	SetRelation(model any, relation string, value any) error
	// PrimaryKey reads the model's primary key through the parsed schema — W10, the
	// parseSchema(model).PrimaryFields[0] read the has-family findOrNew found-check and the
	// touchIfTouching WHERE anchor make: the key column, whether the field holds its Go zero
	// (schemaField.ValueOf's flag — the "did Find populate dest" test) and whether the schema
	// carries a primary key at all (ok false: column "" and isZero true, the "no primary key"
	// branch). err is the schema parse error.
	PrimaryKey(model any) (column string, isZero bool, ok bool, err error)
}

Builder is the structural interface the object layer forwards to: the full query builder — *gorm.OrmBuilder satisfies it — plus the relation write shells, which resolve the relation descriptor and run each write on a fresh session themselves. It is declared here so this package never imports database/gorm.

func BuilderOf

func BuilderOf(query any) (Builder, bool)

BuilderOf unwraps a query handle to the Builder behind it: a relation object reports the builder it forwards to through UnwrapBuilder — walked first, because the object embeds Builder and would otherwise satisfy the assertion itself — and a Builder is returned as it stands. It reports false for any other implementation (a mock, a foreign driver, a nil handle), the way database/gorm's ormBuilderOf does for the concrete *OrmBuilder; the write workers' (*OrmBuilder) casts re-home onto this assertion.

type Descriptor

type Descriptor struct {
	Name         string
	Kind         Kind
	ParentTable  string
	RelatedTable string
	RelatedModel any
	References   []ReferenceKey

	// many-to-many specifics
	PivotTable      string
	PivotParentRef  ReferenceKey
	PivotRelatedRef ReferenceKey
	// PivotField is the name of the field on the related model that the eager loader hydrates
	// with pivot column values. Sourced from Many2Many.PivotField (or the morph variants);
	// defaults to "Pivot". When the related model has no field by this name, no Pivot hydration
	// happens. The field's Go type drives both the SELECT list and hydration target.
	PivotField string
	// PivotCreatedAtColumn is the pivot-table column to auto-stamp with the current time on
	// INSERT. Empty string means "don't auto-stamp on INSERT". Resolved by the descriptor builder
	// from (priority): Pivot struct's autoCreateTime field → Pivot struct's CreatedAt field →
	// PivotTimestamps: true fallback (defaults to "created_at").
	PivotCreatedAtColumn string
	// PivotUpdatedAtColumn is the pivot-table column to auto-stamp with the current time on
	// INSERT and UPDATE. Empty string means "don't auto-stamp on INSERT/UPDATE". Same resolution
	// rules as pivotCreatedAtColumn but using autoUpdateTime / UpdatedAt.
	PivotUpdatedAtColumn string

	// RelatedKeyType is the Go type of the related model's PK field — used by castKey to normalise
	// SyncResult ids back to the related model's native key type, irrespective of what the caller
	// passed (string, int, uint, etc.) and what GORM scanned from the pivot table (often int64).
	// nil for kinds that don't have a related-side pivot key (HasOne family, BelongsTo, etc.).
	RelatedKeyType reflect.Type

	// polymorphic specifics
	MorphTypeColumn string // e.g. "imageable_type" — on parent table for MorphTo, on pivot for MorphToMany
	MorphIDColumn   string // e.g. "imageable_id"  — on parent table for MorphTo, on pivot for MorphToMany
	MorphValue      string // e.g. "post"          — used in WHERE *_type = ? filters
	MorphOwnerKey   string // PK on each related model for MorphTo (defaults to "id")
	MorphInverse    bool   // true for MorphedByMany — flips morph value source from parent to related

	// MorphTo per-type eager config and trashed-scope family — contractsorm.MorphTo's MorphWith /
	// MorphWithCount / Constrain / WithTrashed / WithoutTrashed / OnlyTrashed in declaration form.
	// loadMorphTo consumes them inside each bucket (the per-type query MorphTo::getResultsByType
	// builds) and newMorphToQuery applies the trashed family to the object-path read.
	MorphWith           map[string][]string
	MorphWithCount      map[string][]any
	MorphConstrain      map[string]contractsorm.RelationCallback
	MorphWithTrashed    bool
	MorphWithoutTrashed bool
	MorphOnlyTrashed    bool

	// through specifics
	ThroughTable   string
	FirstKey       string // FK on through pointing at parent
	SecondKey      string // FK on related pointing at through
	LocalKey       string // PK on parent
	SecondLocalKey string // PK on through

	// OnQuery is the per-relation default scope from Relation.OnQuery. Applied by every code
	// path that builds an inner query for this relation (eager loaders, existence builders,
	// Related), *before* any caller-supplied callback.
	OnQuery contractsorm.RelationCallback

	// WithDefault carries the WithDefault option of the BelongsTo / HasOne / MorphOne / MorphTo /
	// HasOneThrough descriptors:
	// nil (no default), bool, map[string]any, or func(related, parent any) any. The relation
	// default helpers in relation_defaults.go materialise it wherever a read finds no row.
	WithDefault any

	// Inverse is the related model's relation name that receives the parent instance after this
	// relation is read — HasOne / HasMany / MorphOne / MorphMany's Inverse field, i.e. Laravel's
	// ->Inverse('user') / ->chaperone(). The loaders write the parent back onto every related
	// model they match (and onto a WithDefault instance) under this name, and the relation read
	// path (Related) applies the same write-back to every model its reads return — the after-query
	// callback chaperone() registers, which runs on every execution of the relation query. An
	// empty string means no write-back. Validated at resolution against the related model's
	// Relations() map, like Eloquent's isRelation() check inside chaperone().
	Inverse string

	// OnPivotQuery is the per-relation default scope for pivot-table SELECT / UPDATE / DELETE,
	// from Many2Many.OnPivotQuery / MorphToMany.OnPivotQuery / MorphedByMany.OnPivotQuery.
	// Applied by existingPivotIDs, allPivotIDs, DetachRelation, UpdateExistingPivotRelation.
	OnPivotQuery contractsorm.PivotCallback

	// Touches, when true, makes Sync / Attach / Detach / Toggle / UpdateExistingPivot bump the
	// parent's updated_at after the pivot write succeeds (and only when pivot rows actually
	// changed). Source: Many2Many.Touches / MorphToMany.Touches / MorphedByMany.Touches.
	Touches bool

	// next link for Nested resolution (e.g. "Books.Author")
	Nested *Descriptor
}

Descriptor is the resolver's normalised view of a relationship. It lets the queries-relationships builder construct correlated subqueries without ever calling back into GORM's relation internals.

func BuildMorphPivotDescriptor

func BuildMorphPivotDescriptor(parent any, parentTable, name string, related any, morphName, table, typeCol, foreignPivot, relatedPivot, parentKey, relatedKey string, pivotField string, pivotTimestamps bool, inverse bool) (*Descriptor, error)

func DescriptorFromMorphOneOrMany

func DescriptorFromMorphOneOrMany(parent any, parentTable, name string, related any, morphName, typeCol, idCol, localKey string, kind Kind) (*Descriptor, error)

func ResolveRelation

func ResolveRelation(parent any, relation string) (*Descriptor, error)

ResolveRelation walks a (possibly dotted) relation path and returns a chain of descriptors rooted at the given parent model. The returned descriptor's nested field points at the next hop, so callers can recurse to build subqueries for "User.Books.Author"-style queries.

All relations are declared via the parent's Relations() method (ModelWithRelations). GORM relation tags (`foreignKey`, `references`, `many2many`, `polymorphic`) are forbidden — if detected the resolver returns OrmRelationTagForbidden pointing the user at Relations().

func (*Descriptor) Info

func (d *Descriptor) Info() RelationInfo

Info projects the descriptor onto the plain-data RelationInfo the object layer and the write workers read — the descriptor half of database/gorm's relationInfoFromDescriptor. The facts bound to the built query or to the parent model (OneOfMany, ParentPrimaryKeyColumn, RelatedConnection and the MorphTo related-table fallback) stay gorm's to fill in.

type DescriptorSeam

type DescriptorSeam struct {
	// ParseModel parses a model's schema — parseSchema, with its OrmQueryInvalidModel /
	// OrmQueryTableNotSet errors.
	ParseModel func(model any) (ModelSchema, error)
	// TableOf resolves a model's declared table name without the connection prefix —
	// tableNameFor.
	TableOf func(model any) (string, error)
	// PivotFieldStructType reflects the related model for the struct field the pivot hydrates
	// into — pivotFieldStructType; ok is false when there is no such struct field.
	PivotFieldStructType func(relatedModel any, pivotFieldName string) (reflect.Type, bool)
	// HasRelationTag reports whether the model's field of the given name declares a GORM
	// relation tag — hasRelationTag.
	HasRelationTag func(model any, name string) bool
	// MorphValue resolves a model's morph alias through MorphClass() and the morph map, raising
	// under strict morph mode — database/orm/morphmap.MorphValue.
	MorphValue func(model any) (string, bool, error)
}

DescriptorSeam is the gorm-side half of the descriptor resolver: the model-metadata readers this package cannot reach itself. Every member is a thin exported twin of the gorm-internal helper it names, so a descriptor built here carries the values the gorm original built.

type EagerLoadEntry

type EagerLoadEntry struct {
	Relation string                        // e.g. "Books" or "Books.Author"
	Columns  []string                      // pruned column list parsed from "Books:id,name"; nil = SELECT *
	Callback contractsorm.RelationCallback // nil for the synthetic noop entries that _addNestedWiths inserts
}

EagerLoadEntry is the normalised representation of one entry on the eager-load list. It is the Go equivalent of fedaco's Record<string, FedacoBuilderCallBack>, but uses a slice in the caller to preserve insertion order (Go maps don't).

func CombineEagerLoadEntry

func CombineEagerLoadEntry(out []EagerLoadEntry, entry EagerLoadEntry) []EagerLoadEntry

CombineEagerLoadEntry folds a leaf onto an entry already present in the same parse the way Eloquent's combineConstraints folds duplicate relation keys inside one with() call (Builder.php, illuminate/database v12.55.1): both constraints survive — the later-listed entry runs first, the earlier-listed entry second — and the earlier-listed entry keeps its select list and its position. Replacement happens only when a later with() call merges into the accumulated list, see MergeEagerLoadEntry. Oracle-pinned (v12.55.1, behavioral): with('posts:id,user_id,name', 'posts') still selects the pruned list; with(['posts' => cb, 'posts']) still loads only the constrained row; with(['posts' => cb, 'posts:id,user_id,name']) runs both; with('posts:id,user_id,name', 'posts:id,user_id') keeps the first list.

func DirectNestedEntries

func DirectNestedEntries(list []EagerLoadEntry, parent string) []EagerLoadEntry

DirectNestedEntries returns the entries from list whose relation is strictly nested under parent (i.e. starts with "parent."), with the "parent." prefix stripped. Used when recursing into a child query: "Books.Author" under parent "Books" becomes "Author".

func MergeEagerLoadEntry

func MergeEagerLoadEntry(out []EagerLoadEntry, entry EagerLoadEntry) []EagerLoadEntry

MergeEagerLoadEntry folds one entry from a freshly parsed with-list into the list a builder has accumulated. Eloquent's Builder::with finishes with the array_merge of eagerLoads (Builder.php, illuminate/database v12.55.1): the later entry wins by relation KEY unconditionally — a plain entry replaces a constrained one exactly as a constrained entry replaces a plain one — while the relation keeps the position it first got. Oracle-pinned: with('posts', cb)->with('posts') loads every related row, with('posts')->with('posts', cb) keeps only the constrained read, and with('posts', cb)->with('posts.comments') rewrites the parent key as well — the dotted walk's fill-missing rule applies within one parse, not across merges.

func ParseEagerLoad

func ParseEagerLoad(args []any) ([]EagerLoadEntry, error)

ParseEagerLoad normalises the variadic args accepted by OrmBuilder.With into an ordered slice of EagerLoadEntry. Mirrors the union of fedaco's _parseWithRelations, _addNestedWiths and _createSelectWithConstraint, expressed as Go runtime type-dispatch since Go doesn't have TypeScript-style overloads.

Accepted shapes (any of which may also appear inside a single []any):

  • "Books"
  • "Books:id,name" (column-pruned)
  • "Books.Author" (nested; auto-fills "Books" as a noop entry)
  • "Books", callback (string + callback as the only two args)
  • "Books", "Roles", "Address" (multiple strings)
  • map[string]contractsorm.RelationCallback{...} (relation -> callback)
  • []string{"Books", "Roles"}
  • []any{"Books", map[string]contractsorm.RelationCallback{"Roles": cb}}

type Hooks

type Hooks struct {
	// ReadColumn reads the value of a DB column off the parent model. Builder.ColumnValue is its
	// Builder-level twin (W2) for the free functions the write workers port into.
	ReadColumn func(parent any, column string) (any, error)
	// ReadModelColumn reads the value of a DB column off a model instance the caller compares.
	ReadModelColumn func(model any, column string) (any, error)
	// ModelTable resolves a model instance's table name without the connection prefix ("" when
	// the model declares none).
	ModelTable func(model any) string
	// FirstRow runs the query as a single-row read into dest, reporting whether a row was found.
	FirstRow func(query contractsorm.Query, dest any) (bool, error)
	// BuildDefault materialises a WithDefault value into the default related instance for a
	// parent, reporting whether the value enables one. value is the relation's effective default —
	// the declaration's option as seeded into the projection (RelationInfo.WithDefault), replaced
	// by the object's call-time WithDefault call. nil reports false, the Go reading of Laravel's
	// falsy `if (! $this->withDefault) return;` gate.
	BuildDefault func(value any, parent any) (any, bool, error)
	// PendingAttributes reports the attributes the builder's WithAttributes recorded.
	PendingAttributes func() map[string]any
	// ModelConnection reads a model instance's declared connection name without connecting — the
	// value Eloquent's getConnectionName() reports ("" when the model declares none).
	ModelConnection func(model any) string
	// ModelKey reads a model instance's primary key value — the value Eloquent's getKey() reports —
	// for the one-of-many partial-relation guard.
	ModelKey func(model any) any
	// UpdatedAtColumn resolves the column the relation's Touch stamps: the related model's
	// automatic updated-at column, or "" when the model declares none.
	UpdatedAtColumn func() (string, error)
	// ModelCreatedAtColumn resolves a model's created-at column from its schema — the field the
	// write path stamps on insert. Relation::createdAt reads it off the parent model
	// (`$this->parent->getCreatedAtColumn()`, Relations/Relation.php:373; Eloquent's
	// Model::getCreatedAtColumn reports static::CREATED_AT, default "created_at"). "" when the
	// model declares none.
	ModelCreatedAtColumn func(model any) (string, error)
	// ModelUpdatedAtColumn resolves a model's updated-at column from its schema.
	// Relation::updatedAt reads it off the parent model (Relations/Relation.php:383; Eloquent's
	// Model::getUpdatedAtColumn reports static::UPDATED_AT). "" when the model declares none.
	ModelUpdatedAtColumn func(model any) (string, error)
	// IgnoringTouch reports whether the related model's class ignores touches — Eloquent's
	// Model::isIgnoringTouch() (no automatic updated-at column, or sitting in a WithoutTouching
	// ignore list).
	IgnoringTouch func() bool
}

Hooks carries the GORM-internal services the object layer cannot express through the contracts: descriptor-driven column reads, table resolution, the single-row read and the WithDefault builder. Every closure is bound, at wrap time, to the builder the object wraps.

type Kind

type Kind int

Kind identifies the relationship flavour of a wrapped relation. The values mirror the internal descriptor kinds; Wrap switches on them to build the per-kind leaf object.

const (
	KindHasOne Kind = iota
	KindHasMany
	KindBelongsTo
	KindMany2Many
	KindMorphOne
	KindMorphMany
	KindMorphTo
	KindMorphToMany
	KindHasOneThrough
	KindHasManyThrough
)

type ModelSchema

type ModelSchema interface {
	// Table is the table the model maps to, without the connection prefix.
	Table() string
	// TypeName is the Go type name of the model, used in diagnostics.
	TypeName() string
	// ColumnType reports the Go type of the column and whether the schema carries it.
	ColumnType(column string) (reflect.Type, bool)
	// Columns lists the model's columns in declaration order.
	Columns() []SchemaColumn
	// ColumnByName looks a column up by its Go field name.
	ColumnByName(name string) (SchemaColumn, bool)
}

ModelSchema is the slice of a parsed model schema the descriptor resolver reads — the view database/gorm hands back for its modelSchema through DescriptorSeam.ParseModel.

type MorphPivot

type MorphPivot struct {
	Pivot
	// contains filtered or unexported fields
}

This file ports Illuminate\Database\Eloquent\Relations\MorphPivot — vendor, illuminate/database v12.55.1; MorphPivot.php:5 `class MorphPivot extends Pivot`; byte-identical in the user checkout at /Users/dog/OpenSource/framework (diff=0), so the vendor text is the single source. The Go embedding (MorphPivot embedding its Pivot) mirrors the PHP extends: the methods PHP defines on the class live here, everything else is promoted from the base the way inheritance promotes it.

Class mapping. morphType / morphClass are plain Go fields. PHP declares the two protected properties explicitly "so it's not included in saved attributes" (MorphPivot.php:7-23) — Go fields are out of the attribute bag by construction. Oracle v12.55.1, executed (/tmp/morphpivotprobes/state_probe.php): after setMorphType/setMorphClass the attribute bag reads keys ["foreign","other"], the morph key is absent from getAttributes(), and getDirty() is [] — the setters dirty nothing. The write path carries the same receipt (/tmp/morphpivotprobes/sql_probe.php: the insert reads `insert into "role_user" ("foreign", "other") values (?, ?)` — no morph column).

The static constructors become package functions: NewMorphPivot is the Go form of `new static` and FromMorphAttributes / FromMorphRawAttributes are `MorphPivot::fromAttributes` / `MorphPivot::fromRawAttributes` — the base constructor runs first and the fully built state is embedded (PHP's late static binding reads the same: the AsPivot statics run on the subclass and return it).

Dynamic dispatch. PHP's save / delete reach setKeysForSaveQuery virtually (Model::performUpdate and Model::performDeleteOnModel call `$this->setKeysForSaveQuery`), so a MorphPivot update and own-key delete run through the morph override; Go method promotion is static, so Save and Delete are re-stated on *MorphPivot with the base flow and the morph-aware key call wired in. The morph clause appears TWICE on those paths because the PHP source itself writes it twice: MorphPivot::setKeysForSaveQuery (MorphPivot.php:31) adds the morph where and then delegates to parent::setKeysForSaveQuery — Pivot's — whose body is `return $this->setKeysForSelectQuery($query)`, the MORPH override again — before the base key where is written. Oracle v12.55.1, executed (SQL probe): save no PK -> `update "role_user" set "note" = ? where "taggable_type" = ? and "taggable_type" = ? and "foreign" = ? and "other" = ?` (bindings ["changed","App\Models\Post", "App\Models\Post","fv","ov"]); save with PK -> `... where "taggable_type" = ? and "taggable_type" = ? and "id" = ?`; delete with PK -> `delete from "role_user" where "taggable_type" = ? and "taggable_type" = ? and "id" = ?`.

Event dispatch. PHP's fireModelEvent names the event after the instance class (static::class) and runs it on `$this`, so listeners and observers registered for MorphPivot (or a custom subclass) see a morph pivot's events. Go's observer matcher compares the concrete type (getObserver: package path + name), so this port re-states fireEvent — and the insert flow it is called from — to hand the *MorphPivot to FirePivotEvent; the promoted base method would pass the embedded base pointer and dispatch under relations.Pivot. Oracle v12.55.1, executed (/tmp/morphpivotprobes/events_probe.php): a MorphPivot save logs ["morph.saving"] with a Pivot-registered listener silent, and a custom subclass logs ["evprobe.saving"] alone.

The no-key delete quirk. MorphPivot::delete (MorphPivot.php:56-75) overrides the keyless branch WITHOUT the touchOwners() call AsPivot::delete makes (AsPivot.php:130,140), and appends the morph where after the key-pair group: oracle, executed -> `delete from "role_user" where ("foreign" = ? and "other" = ?) and "taggable_type" = ?` (bindings ["fv","ov", "App\Models\Post"]). The own-key branch keeps Model::delete's touchOwners (it delegates with `return (int) parent::delete()`), as does finishSave's on save.

Divergences from the PHP class, all deliberate:

  • A segmented restoration id with fewer than six segments reports the named errors.OrmMorphPivotRestorationIDInvalid. PHP reads the missing offsets as warnings and the null segment makes the where clause vanish (Arr::wrap(null) adds nothing) — oracle, executed (/tmp/morphpivotprobes/short_id_probe.php): 'foreign:f1:other:o1' on a MorphPivot warns "Undefined array key 4/5" and compiles `select * from "taggables" where "foreign" = ? and "other" = ?` with bindings ["f1","o1"], silently dropping the morph segment; a collection whose later id is short warns per missing offset and drops the null segments the same way. The Go port follows the base Pivot port's named-error policy instead of reproducing the silent drop.

  • A collection restoration whose first id carries no ":" (or an empty list) runs the Model whereKey path directly. Upstream v12.55.1 fatally recurses there — MorphPivot:: newQueryForCollectionRestoration's `parent::newQueryForRestoration($ids)` re-dispatches `$this->newQueryForCollectionRestoration` back into the override until the stack dies. Oracle, executed (state probe): `newQueryForRestoration([7, 8])` aborts with "Maximum call stack size ... Infinite recursion?" at MorphPivot.php:162. The Go port completes the branch the code intends — the base restoreByKey (Model::newQueryForRestoration over the qualified pivot key) — the same destination the scalar path reaches.

  • An id with MORE than six segments reads the first six (the extra segments are ignored) — oracle, executed (/tmp/morphpivotprobes/short_id_probe.php): 'foreign:f1:other:o1: taggable_type:A:EXTRA' compiles the same three wheres with bindings ["f1","o1","A"].

  • The morph-class where reads the Go string's zero value as PHP's null default: a class-unset pivot compiles the null check the probe shows (`... and "taggable_type" is null`, bindings ["fv","ov"]); an explicitly empty string class is not distinguishable from unset in Go.

  • GetTable() is re-stated for the type-name fallback: a table-less MorphPivot derives "morph_pivot" from the Go type name (oracle: `(new MorphPivot)->getTable()` reports "morph_pivot"), where promoted base-receiver internals would still read the base name. Every constructor stamps a table, so the fallback only matters for a bare `new MorphPivot`-shaped instance.

  • PivotKeys reports the morph column first — the order setKeysForSelectQuery writes the keys (contracts PivotKeyed; oracle, executed: `$morphPivot->fresh()` compiles `select * from "taggables" where "taggable_type" = ? and "taggable_id" = ? and "tag_id" = ? limit 1`).

Oracle probes executed against illuminate/database v12.55.1 (each prints "illuminate/database: v12.55.1" on stderr):

php .claude/laravel-oracle/oracle.php /tmp/morphpivotprobes/state_probe.php
php .claude/laravel-oracle/oracle.php /tmp/morphpivotprobes/sql_probe.php
php .claude/laravel-oracle/oracle.php /tmp/morphpivotprobes/short_id_probe.php
php .claude/laravel-oracle/oracle.php /tmp/morphpivotprobes/events_probe.php

State probe (key outputs cited above): a fresh pivot's getMorphType() is null; the fluent setters return the same instance; attributes keys ["foreign","other"]; dirty [] after the setters; queueable ids "foreign:fv:other:ov:taggable_type:App\Models\Post", 7 (own key), "foreign::other::t:C" (null values), "foreign:f:other::t:" (unset class); bare table "morph_pivot"; restoration SQLs quoted in the method comments; the [7, 8] fatal recursion.

SQL probe (executed against in-memory sqlite): the save shapes quoted above, the timestamped save (`set "note" = ?, "updated_at" = ?` before the double morph clause), the insert without morph columns, the keyless delete with the appended morph clause, the class-unset delete rendering `and "taggable_type" is null`, the own-key delete (double morph clause, affected 1), the absent-row keyless delete still running, and the not-existing own-key delete emitting nothing.

func FromMorphAttributes

func FromMorphAttributes(builder Builder, parent any, attributes map[string]any, table string, exists bool) (*MorphPivot, error)

FromMorphAttributes mirrors MorphPivot::fromAttributes($parent, $attributes, $table, $exists = false): the base AsPivot constructor runs first (timestamps flag, connection stamp, table, attribute and original bags, parent link, exists) and the built state is embedded — the Go reading of the trait's statics running on the late-bound subclass.

func FromMorphRawAttributes

func FromMorphRawAttributes(builder Builder, parent any, attributes map[string]any, table string, exists bool) (*MorphPivot, error)

FromMorphRawAttributes mirrors MorphPivot::fromRawAttributes($parent, $attributes, $table, $exists = false): the base raw constructor runs first, exactly as above.

func NewMorphPivot

func NewMorphPivot(builder Builder) *MorphPivot

NewMorphPivot builds an empty morph pivot bound to builder — the Go form of `new static` inside MorphPivot::fromAttributes: no attributes, no original bag, no table, no parent, not existing, no morph state.

func (*MorphPivot) Delete

func (m *MorphPivot) Delete() (int64, error)

Delete mirrors MorphPivot::delete (MorphPivot.php:56-75).

With an own primary key present the call is Model::delete (the PHP branch `return (int) parent::delete()`): not-existing short-circuits first, then the deleting event, touchOwners, the morph-aware key delete, exists = false and the deleted event — where the morph clause the key call adds is written twice, see SetKeysForSaveQuery. Without an own key the MorphPivot override runs: the deleting event, NO touchOwners (the quirk — AsPivot's keyless delete calls it, this override drops it), the key-pair delete with the morph where appended after the group, then exists = false and the deleted event. Like the base, this path has NO exists check: a never-existing keyless pivot still runs the statement.

Oracle v12.55.1, executed (SQL probe): `delete from "role_user" where ("foreign" = ? and "other" = ?) and "taggable_type" = ?` on the keyless path; `delete from "role_user" where "taggable_type" = ? and "taggable_type" = ? and "id" = ?` on the own-key path (affected 1); a not-existing own-key pivot emits nothing and reports 0; exists turns false after either deletion.

func (*MorphPivot) GetQueueableID

func (m *MorphPivot) GetQueueableID() any

GetQueueableID is MorphPivot::getQueueableId (MorphPivot.php:118): the own key's current value when the attributes carry one, else the SIX-segment foreignKey:value:relatedKey:value:morphType:morphClass string (nil attribute values format as empty segments — PHP's sprintf %s).

Oracle v12.55.1, executed (state probe): {foreign: fv, other: ov} -> "foreign:fv:other:ov: taggable_type:App\Models\Post"; an own id 7 -> 7; null values -> "foreign::other::t:C"; a null own id falls back to the segmented form -> "foreign:f:other::t:".

func (*MorphPivot) GetTable

func (m *MorphPivot) GetTable() string

GetTable is the AsPivot getTable read with the morph type name in the fallback: a table-less MorphPivot derives "morph_pivot" (Str::snake(Str::singular(class_basename(MorphPivot)))), the oracle-pinned result. The promoted base method would name the base Pivot instead, so the read is re-stated here; every constructor stamps a table, and base-receiver internals still see the base fallback for a table-less instance.

func (*MorphPivot) MorphType

func (m *MorphPivot) MorphType() string

MorphType is MorphPivot::getMorphType (MorphPivot.php:82). Oracle v12.55.1, executed: null on a fresh pivot — the Go zero value.

func (*MorphPivot) NewQueryForRestoration

func (m *MorphPivot) NewQueryForRestoration(ids any) (contractsorm.Query, error)

NewQueryForRestoration is MorphPivot::newQueryForRestoration (MorphPivot.php:138): a list of ids goes through the collection variant; a string carrying ":" parses as the six-segment restoration id; anything else falls back to the base (Model::newQueryForRestoration — whereKey over the pivot's own qualified key).

Oracle v12.55.1, executed (state probe): "foreign:f1:other:o1:taggable_type:A" -> `select * from "taggables" where "foreign" = ? and "other" = ? and "taggable_type" = ?` (bindings ["f1","o1","A"] — the morph where LAST here; the method explodes the id and writes the three pair wheres in segment order); the scalar 7 -> `select * from "taggables" where "taggables"."id" = ?`.

A segmented id with fewer than six segments reports the named errors.OrmMorphPivotRestorationIDInvalid (PHP warns and drops the null segments — the file comment quotes the executed evidence); more than six segments use the first six.

func (*MorphPivot) PivotKeys

func (m *MorphPivot) PivotKeys() []string

PivotKeys satisfies contracts PivotKeyed with the morph column first — AsPivot's pair plus the morph type, the order MorphPivot::setKeysForSelectQuery writes the keys (the contracts comment and the executed fresh() oracle in the file header).

func (*MorphPivot) Save

func (m *MorphPivot) Save() error

Save mirrors the inherited Model::save (Model.php:1215) with the morph-aware key call on the update path: PHP's performUpdate reaches setKeysForSaveQuery virtually, so a MorphPivot update carries the morph filter; the Go flow is re-stated here because the promoted base save would call the base keys statically. The insert path is identical to the base — an insert runs no key where — and is delegated to the morph saveNew below (re-stated so the insert events dispatch on the concrete type).

func (*MorphPivot) SetKeysForSaveQuery

func (m *MorphPivot) SetKeysForSaveQuery(query contractsorm.Query) contractsorm.Query

SetKeysForSaveQuery is MorphPivot::setKeysForSaveQuery (MorphPivot.php:31): the morph where first, then the parent call. The parent (Pivot's) body is `return $this->setKeysForSelectQuery ($query)` — in PHP that re-dispatches into THIS type's SetKeysForSelectQuery, which writes the morph clause a second time before the base key where; this Go port mirrors the same two writes (see the file comment; oracle SQL quoted there).

func (*MorphPivot) SetKeysForSelectQuery

func (m *MorphPivot) SetKeysForSelectQuery(query contractsorm.Query) contractsorm.Query

SetKeysForSelectQuery is MorphPivot::setKeysForSelectQuery (MorphPivot.php:44): the morph where first (MorphPivot.php:49), then the base AsPivot key logic — the own primary key when the attributes carry one, else the foreign key / related key pair. The method is protected in PHP; the observable SQL shapes are the save / delete statements probed below, which reach it through the override chain.

func (*MorphPivot) SetMorphClass

func (m *MorphPivot) SetMorphClass(morphClass string) *MorphPivot

SetMorphClass is MorphPivot::setMorphClass (MorphPivot.php:106), fluent. Oracle v12.55.1, executed: returns the same instance; the value stays out of the attribute bag.

func (*MorphPivot) SetMorphType

func (m *MorphPivot) SetMorphType(morphType string) *MorphPivot

SetMorphType is MorphPivot::setMorphType (MorphPivot.php:93), fluent; the morph state is not an attribute, so the pivot does not dirty. Oracle v12.55.1, executed: returns the same instance.

type Pivot

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

This file ports Illuminate\Database\Eloquent\Relations\Pivot — vendor, illuminate/database v12.55.1; Pivot.php:8 `class Pivot extends Model` — together with the AsPivot trait its whole API lives in (Relations/Concerns/AsPivot.php). It is the first user-facing pivot model of this ORM: an attribute bag with dirty tracking, a table and a connection, the pivot key pair, the pivotParent link the timestamp columns are delegated to, and the save / delete / queueable / restoration behaviours of the trait.

Class mapping. PHP's `class Pivot extends Model` is mirrored by this struct carrying the Model facets the trait actually uses — the attribute and original bags, exists, the key name, the per-instance timestamps flag — instead of a Model base type, and `use AsPivot` by the AsPivot methods living on the struct itself. The static constructors become package functions taking the execution surface first: FromAttributes / FromRawAttributes mirror `static::fromAttributes` / `static::fromRawAttributes` and NewPivot mirrors `new static` (the instance the statics start from). Pivot.php's `public $incrementing = false` is reflected by the insert path not reading back a generated key (the table-scoped insert carries no key column), and `protected $guarded = []` by the attribute bag accepting every column.

The frozen Builder widening (declared on relations.Builder in wrap.go — this is the complete shape, no other Builder change):

  • NewPivotQuery(table string, connection string) (contractsorm.Query, error) AsPivot's newQueryWithoutRelationships() / newQueryWithoutScopes() / newModelQuery(): a fresh builder bound to the pivot's own table on the pivot's connection — "" keeps the current connection — and every select / update / delete / insert the pivot runs goes through the handle it returns (AsPivot.php:154 getDeleteQuery, AsPivot.php:120 setKeysForSaveQuery, Model.php:1294 performUpdate, Model.php:1378 performInsert, Model.php:1669 newQueryForRestoration).
  • ReadPivotParent(parent any) (PivotParentInfo, error) The parent reads AsPivot performs through the pivotParent link: the declared connection name stamped at construction (AsPivot.php:56 `setConnection($parent->getConnectionName())`) and the getCreatedAtColumn()/getUpdatedAtColumn() delegation (AsPivot.php:254,266).
  • PivotTouchOwners(pivot any) error AsPivot.php:140's touchOwners() call (Model::touchOwners) on the no-key delete path.
  • FirePivotEvent(name string, pivot any) error fireModelEvent over the pivot's event family: deleting/deleted (AsPivot.php:136,145) and saving/updating/updated/saved (Model::save / performUpdate). A handler registered for the pivot's type receives the event; with none registered the call is Laravel's no-op fireModelEvent without a dispatcher. The PHP listener's `return false` cancel has no Go form — a handler reports failure by returning an error, which aborts the operation.

Divergences from the PHP class, all deliberate (this ORM has no cast/mutator pipeline and no in-memory relation registry):

  • Attribute values are stored exactly as written: fromAttributes does not date-normalize. Oracle v12.55.1, executed (/tmp/pivotprobes/state_probe.php): fromAttributes with created_at "2015-09-12" reports a Carbon dumped as "2015-09-12 00:00:00"; the Go port keeps the string the caller passed.

  • WithoutRelations / UnsetRelations clear the pivotParent / pivotRelated links only; there is no relations registry to clone or clear.

  • GetTable()'s no-table fallback derives from the Go type name — the Go stand-in for class_basename(Pivot) — through Str::snake(Str::singular(...)): oracle, executed, `(new Pivot)->getTable()` reports "pivot".

  • UsesTimestamps() reports the per-instance flag only (no withoutTimestampsOn registry).

  • ReadPivotParent resolves the parent's timestamp columns from the parent's declared Go schema (the automatic field's column, else the conventional created_at / updated_at column, else ""); Laravel returns a class constant, and "" is the Go reading of PHP's null.

  • The own-key delete path reports the statement's affected rows; Laravel reports (int) true.

  • Model::save's insert branch adopts the query connection's name when the model declares none (Model.php:1242-1245); the Go save keeps the constructor-stamped "" because the frozen widening exposes no connection-name read. Oracle v12.55.1, executed (verifier probe /tmp/vpivot/sql_probe.php I1): the keyless insert reports connectionAfter 'default'; the Go path (verifier overlay probe) reports "" — later statements still run on the same connection, so only the reported name differs.

Oracle probes executed against illuminate/database v12.55.1 (both print "illuminate/database: v12.55.1" on stderr):

php .claude/laravel-oracle/oracle.php /tmp/pivotprobes/state_probe.php
php .claude/laravel-oracle/oracle.php /tmp/pivotprobes/sql_probe.php

State probe (key outputs the comments below cite): fromAttributes -> attributes {foo: bar, created_at: Carbon "2015-09-12 00:00:00"}, connection "connection", table "table", exists true, pivotParent identical, timestamps true, dirty []; no created_at -> timestamps false; fromRawAttributes (exists false) -> timestamps true with parent column created_at, dirty {foo, created_at}; raw + exists true -> dirty []; dirtied attr -> dirty {shimy: changed}; int 1 vs string "1" -> clean; setPivotKeys("foreign","other") -> foreign/other/other; custom-columns parent -> parent_created_at/parent_updated_at, bare pivot -> created_at/updated_at, table "pivot"; queueable ids "foreign:foreign.value:other:other.value", 7, "foreign::other:", "foreign:f:other:"; restoration SQLs `select * from "table" where "foreign" = ? and "other" = ?`, `select * from "table" where ("foreign" = ? and "other" = ?) or ("foreign" = ? and "other" = ?)`, `select * from "table" where "table"."id" = ?`, `select * from "table" where "table"."id" in (7, 8)`; keyName "id".

SQL probe (executed against in-memory sqlite; the comments on Save / Delete cite these): save no key -> `update "role_user" set "note" = ? where "foreign" = ? and "other" = ?`; timestamps flag on -> `update "role_user" set "note" = ?, "updated_at" = ? where "foreign" = ? and "other" = ?`; own key -> `update "role_user" set "note" = ? where "id" = ?`; key changed after construction -> `update "role_user" set "id" = ?, "note" = ? where "id" = ?`; delete no key -> `delete from "role_user" where ("foreign" = ? and "other" = ?)`; delete with key -> `delete from "role_user" where "id" = ?`; a not-existing pivot with an own key emits no statement; save of a new pivot -> `insert into "role_user" ("foreign", "other", "created_at", "updated_at") values (?, ?, ?, ?)` re-stamping both timestamps.

func FromAttributes

func FromAttributes(builder Builder, parent any, attributes map[string]any, table string, exists bool) (*Pivot, error)

FromAttributes mirrors AsPivot::fromAttributes($parent, $attributes, $table, $exists = false) (AsPivot.php:47): the per-instance timestamps flag is set from hasTimestampAttributes BEFORE the parent link exists — so it weighs the pivot's own created-at column name, which is what Laravel's parent::getCreatedAtColumn() returns at that moment — then the connection name and table are stamped, the attributes fill the bag, the original bag is synced, and the parent link plus the exists flag are recorded.

Oracle v12.55.1, executed (state probe): fromAttributes(..., ["foo" => "bar", "created_at" => "2015-09-12"], "table", true) reports connection "connection", table "table", exists true, pivotParent identical, timestamps true, dirty []; without created_at timestamps is false; with a custom-columns parent the flag still comes from the default "created_at" name (observed true), while getCreatedAtColumn() afterwards reports the parent's column.

func FromRawAttributes

func FromRawAttributes(builder Builder, parent any, attributes map[string]any, table string, exists bool) (*Pivot, error)

FromRawAttributes mirrors AsPivot::fromRawAttributes($parent, $attributes, $table, $exists = false) (AsPivot.php:80): a fromAttributes instance over an empty bag — which records the parent link first — then the timestamps flag is re-evaluated from the raw attribute names against the parent-delegated created-at column, and the raw attributes merge onto the raw original bag (empty at this point). The merge replaces setRawAttributes($attributes, $exists): with $exists true the bag syncs into the original bag, so the instance is NOT dirty; with false the original bag stays empty, so every attribute reads dirty.

Oracle v12.55.1, executed (state probe): raw + exists false reports timestamps true (with the parent's created_at name present) and dirty {foo, created_at}; raw + exists true reports dirty []; a custom-columns parent makes the flag follow parent_created_at.

func NewPivot

func NewPivot(builder Builder) *Pivot

NewPivot builds an empty pivot bound to builder — the Go form of `new static` inside AsPivot::fromAttributes: no attributes, no original bag, no table, no parent, not existing.

func (*Pivot) Attributes

func (p *Pivot) Attributes() map[string]any

Attributes returns a copy of the attribute bag — Model::getAttributes' by-value semantics.

func (*Pivot) Delete

func (p *Pivot) Delete() (int64, error)

Delete mirrors AsPivot::delete (AsPivot.php:130).

With an own primary key present the call is Model::delete (Model.php:1486): a pivot that does not exist is a no-op before any event; otherwise the deleting event fires, touchOwners runs, the row is deleted through setKeysForSaveQuery (Model::performDeleteOnModel), exists turns false and the deleted event fires. Without an own key the AsPivot override runs: the deleting event, touchOwners, then the key-pair delete — where(["foreign" => ..., "other" => ...]), the nested-parens form — followed by exists = false and the deleted event. This path has NO exists check: a never-existing pivot still runs the statement (both oracle-pinned below). Note the key-pair read: the foreign/related values come from the original bag with the current attribute as the fallback.

Oracle v12.55.1, executed (SQL probe): `delete from "role_user" where ("foreign" = ? and "other" = ?)` on the key-pair path; `delete from "role_user" where "id" = ?` on the own-key path; a not-existing pivot with an own key emits no statement and reports 0; exists turns false after either deletion.

func (*Pivot) Exists

func (p *Pivot) Exists() bool

Exists reports the instance flag the constructors set and Save / Delete maintain — Eloquent's public $exists.

func (*Pivot) GetAttribute

func (p *Pivot) GetAttribute(key string) any

GetAttribute is Model::getAttribute's raw read for the shapes a pivot carries.

func (*Pivot) GetConnectionName

func (p *Pivot) GetConnectionName() string

GetConnectionName is Model::getConnectionName: the connection name stamped at construction ("" declares none).

func (*Pivot) GetCreatedAtColumn

func (p *Pivot) GetCreatedAtColumn() string

GetCreatedAtColumn is AsPivot::getCreatedAtColumn (AsPivot.php:254): the parent's column when the parent link is set, else the pivot's own default. The parent's column resolves through the frozen ReadPivotParent widening; a read failure reports "" the way an undeclared column does.

Oracle v12.55.1, executed: a custom-columns parent reports "parent_created_at"; no parent reports "created_at".

func (*Pivot) GetDirty

func (p *Pivot) GetDirty() map[string]any

GetDirty is Model::getDirty (HasAttributes.php:2277): every attribute whose current value is not equivalent to the original — a key the original bag does not carry is dirty. Oracle v12.55.1, executed: a freshly built pivot reads []; one assignment reads exactly that key; 1 against "1" stays clean (the numeric strcmp branch of originalIsEquivalent).

func (*Pivot) GetForeignKey

func (p *Pivot) GetForeignKey() string

GetForeignKey is AsPivot::getForeignKey (AsPivot.php:183).

func (*Pivot) GetKey

func (p *Pivot) GetKey() any

GetKey is Model::getKey: the current value of the pivot's key attribute.

func (*Pivot) GetKeyName

func (p *Pivot) GetKeyName() string

GetKeyName is Model::getKeyName; the default is "id" (Model::$primaryKey).

func (*Pivot) GetOriginal

func (p *Pivot) GetOriginal(key string, def ...any) any

GetOriginal is Model::getOriginal over the raw original bag: the stored value when the key is present (a stored nil included), else the default.

func (*Pivot) GetOtherKey

func (p *Pivot) GetOtherKey() string

GetOtherKey is AsPivot::getOtherKey (AsPivot.php:203), the related key's alias.

func (*Pivot) GetPivotParent

func (p *Pivot) GetPivotParent() any

GetPivotParent reports the parent link — AsPivot's public $pivotParent.

func (*Pivot) GetPivotRelated

func (p *Pivot) GetPivotRelated() any

GetPivotRelated reports the related-model link — AsPivot's public $pivotRelated.

func (*Pivot) GetQueueableID

func (p *Pivot) GetQueueableID() any

GetQueueableID is AsPivot::getQueueableId (AsPivot.php:278): the own key's current value when the attributes carry one, else the four-segment foreignKey:value:relatedKey:value string (a nil value formats as an empty segment — PHP's sprintf %s).

Oracle v12.55.1, executed: {foreign, other} -> "foreign:foreign.value:other:other.value"; an own id 7 -> 7 (the key value itself); null values -> "foreign::other:"; a null id -> "foreign:f:other:".

func (*Pivot) GetRelatedKey

func (p *Pivot) GetRelatedKey() string

GetRelatedKey is AsPivot::getRelatedKey (AsPivot.php:193).

func (*Pivot) GetTable

func (p *Pivot) GetTable() string

GetTable is AsPivot::getTable (AsPivot.php:167): the table stamped at construction, or — when none was — the name derived from the type the way Laravel derives it from the class basename (Str::snake(Str::singular(class_basename(...)))). Oracle v12.55.1, executed: `(new Pivot)->getTable()` reports "pivot"; a MorphPivot derives "morph_pivot".

func (*Pivot) GetUpdatedAtColumn

func (p *Pivot) GetUpdatedAtColumn() string

GetUpdatedAtColumn is AsPivot::getUpdatedAtColumn (AsPivot.php:266). Oracle v12.55.1, executed: a custom-columns parent reports "parent_updated_at"; no parent reports "updated_at".

func (*Pivot) HasTimestampAttributes

func (p *Pivot) HasTimestampAttributes(attributes ...map[string]any) bool

HasTimestampAttributes is AsPivot::hasTimestampAttributes (AsPivot.php:243): the created-at column name is non-null and the given attribute bag — the pivot's own bag when the argument is nil — carries it. The custom columns the parent delegates are honoured because GetCreatedAtColumn() reads through the parent link.

func (*Pivot) IsDirty

func (p *Pivot) IsDirty(columns ...string) bool

IsDirty reports whether any — or any of the named — attributes are dirty.

func (*Pivot) NewQueryForRestoration

func (p *Pivot) NewQueryForRestoration(ids any) (contractsorm.Query, error)

NewQueryForRestoration is AsPivot::newQueryForRestoration (AsPivot.php:297): a list of ids goes through the collection variant; a string carrying ":" parses as the four-segment restoration id; anything else falls back to Model::newQueryForRestoration — whereKey over the pivot's own qualified key (Model::whereKey: an inline list constrains with whereIn).

The query is the pivot's table-scoped handle, the Go stand-in for newQueryWithoutScopes. A segmented id with fewer than four segments reports the named errors.OrmPivotRestorationIDInvalid — PHP's undefined-offset error on the same input.

Oracle v12.55.1, executed (state probe): "foreign:f1:other:o1" -> `select * from "table" where "foreign" = ? and "other" = ?`; a list of segmented ids -> `select * from "table" where ("foreign" = ? and "other" = ?) or ("foreign" = ? and "other" = ?)`; the scalar 7 -> `select * from "table" where "table"."id" = ?`; the list [7, 8] -> `select * from "table" where "table"."id" in (7, 8)` (Laravel inlines integer key lists; the Go port binds them through WhereIn — the framework-wide key-list rendering).

func (*Pivot) PivotKeys

func (p *Pivot) PivotKeys() []string

PivotKeys satisfies contracts/database/orm.PivotKeyed: the (foreignKey, relatedKey) pair, in the order SetKeysForSelectQuery writes the clauses — the columns Fresh/Refresh match a keyless pivot row by.

func (*Pivot) Save

func (p *Pivot) Save() error

Save mirrors AsPivot's Model::save (Model.php:1215), including performUpdate (Model.php:1294) and the insert branch (Model.php:1378):

  • the saving event fires first;
  • an existing pivot: the updating event, then — when the per-instance timestamps flag is on — the update stamp (the updated-at column is set to now unless already dirty; the created-at column is not re-stamped because the pivot exists). When anything is dirty the update runs through setKeysForSaveQuery on the pivot's own table and the updated event fires; then the saved event, a touchOwners call when something was dirty (finishSave) and a syncOriginal;
  • a new pivot: the creating event, the timestamps stamp (now BOTH created-at, unless already dirty, and updated-at — the oracle re-stamps a provided created_at), a plain insert of the attribute bag through the table-scoped handle (Pivot::$incrementing is false, so no key is read back), then created / saved, touchOwners and syncOriginal.

Oracle v12.55.1, executed (SQL probe): existing, no key, one dirty column -> `update "role_user" set "note" = ? where "foreign" = ? and "other" = ?`; timestamps flag on -> `update "role_user" set "note" = ?, "updated_at" = ? where "foreign" = ? and "other" = ?`; own key -> `update "role_user" set "note" = ? where "id" = ?`; the key attribute changed after construction -> `update "role_user" set "id" = ?, "note" = ? where "id" = ?` (the where keeps the original value); a new pivot -> `insert into "role_user" ("foreign", "other", "created_at", "updated_at") values (?, ?, ?, ?)`.

func (*Pivot) SetAttribute

func (p *Pivot) SetAttribute(key string, value any) *Pivot

SetAttribute writes one attribute; the cast and mutator pipeline Model::setAttribute runs does not exist in Go, so the value is stored exactly as given.

func (*Pivot) SetConnection

func (p *Pivot) SetConnection(name string) *Pivot

SetConnection is Model::setConnection; the fluent return mirrors the constructor chain.

func (*Pivot) SetExists

func (p *Pivot) SetExists(exists bool) *Pivot

SetExists stamps the exists flag; Save / Delete maintain it themselves, this is the testing and wiring handle for the flag Eloquent exposes as a property.

func (*Pivot) SetKeysForSaveQuery

func (p *Pivot) SetKeysForSaveQuery(query contractsorm.Query) contractsorm.Query

SetKeysForSaveQuery is AsPivot::setKeysForSaveQuery (AsPivot.php:120), the select variant.

func (*Pivot) SetKeysForSelectQuery

func (p *Pivot) SetKeysForSelectQuery(query contractsorm.Query) contractsorm.Query

SetKeysForSelectQuery is AsPivot::setKeysForSelectQuery (AsPivot.php:99): an own primary key present in the attributes constrains the query to it — Model::setKeysForSelectQuery, where the key value is the original bag's value falling back to the current one — and otherwise the foreign key / related key pair constrains it, each read from the original bag with the current attribute as the fallback (getOriginal($key, $this->getAttribute($key)): a null original is used as-is).

func (*Pivot) SetPivotKeys

func (p *Pivot) SetPivotKeys(foreignKey, relatedKey string) *Pivot

SetPivotKeys is AsPivot::setPivotKeys (AsPivot.php:215). Oracle v12.55.1, executed: setPivotKeys("foreign", "other") reports getForeignKey() "foreign" and getOtherKey() "other".

func (*Pivot) SetPivotParent

func (p *Pivot) SetPivotParent(parent any) *Pivot

SetPivotParent stamps the parent link (the constructors set it; Eloquent exposes it as a public property).

func (*Pivot) SetRelatedModel

func (p *Pivot) SetRelatedModel(related any) *Pivot

SetRelatedModel is AsPivot::setRelatedModel (AsPivot.php:230).

func (*Pivot) SetTable

func (p *Pivot) SetTable(table string) *Pivot

SetTable stamps the table name, mirroring Model::setTable; the fluent return mirrors the constructor chain AsPivot::fromAttributes builds.

func (*Pivot) SyncOriginal

func (p *Pivot) SyncOriginal() *Pivot

SyncOriginal is Model::syncOriginal: the original bag becomes a copy of the attribute bag, so nothing reads dirty.

func (*Pivot) UnsetRelations

func (p *Pivot) UnsetRelations() *Pivot

UnsetRelations is AsPivot::unsetRelations (AsPivot.php:347): the parent and related links clear and the instance returns. The registry clear Laravel performs has no Go counterpart — there is no in-memory relation registry on a pivot.

func (*Pivot) UsesTimestamps

func (p *Pivot) UsesTimestamps() bool

UsesTimestamps is Model::usesTimestamps over the per-instance flag the constructors set — the Go reading of AsPivot's public $timestamps. The class-level withoutTimestampsOn registry has no Go counterpart.

func (*Pivot) WithoutRelations

func (p *Pivot) WithoutRelations() *Pivot

WithoutRelations returns a copy with the pivotParent / pivotRelated links cleared — AsPivot's inherited Model::withoutRelations for the facets a pivot carries. Oracle v12.55.1, executed: the copy's parent is null, the original's kept; the copy is a different instance.

type PivotParentInfo

type PivotParentInfo struct {
	Connection      string
	CreatedAtColumn string
	UpdatedAtColumn string
}

PivotParentInfo is the parent-model metadata AsPivot reads through its pivotParent link: the declared connection name and the created-at / updated-at column names. ReadPivotParent (the frozen widening) projects it; "" for a column is the Go reading of PHP's null (AsPivot.php:245 `($createdAt = $this->getCreatedAtColumn()) !== null` gates the timestamps flag).

type RefKey

type RefKey struct {
	PrimaryColumn string
	ForeignColumn string
}

RefKey projects one reference-column pair of the GORM-side descriptor: the primary side (the key the row is identified by) and the foreign side referencing it.

type ReferenceKey

type ReferenceKey struct {
	PrimaryTable  string
	PrimaryColumn string
	ForeignTable  string
	ForeignColumn string
}

ReferenceKey describes one column-pair from a GORM Reference, with each side already qualified by table name. PrimaryKey/ForeignKey naming follows GORM's convention.

type RelationInfo

type RelationInfo struct {
	Kind        Kind
	ParentTable string
	// ParentPrimaryKeyColumn is the parent (child, for the belongs-to kinds) model's own primary
	// key column — Eloquent's getKeyName() — the column BelongsTo / MorphTo
	// getQualifiedParentKeyName qualifies alongside ParentTable. Empty when the parent's schema
	// carries no primary key.
	ParentPrimaryKeyColumn string
	RelatedTable           string
	RelatedModel           any
	// RelatedConnection is the related model's declared connection name ("" when it declares
	// none) — the reference side of Eloquent's getConnectionName() comparison in Is. For MorphTo
	// it names the resolved morph class.
	RelatedConnection string
	References        []RefKey

	// WithDefault carries the WithDefault option of the single-result kinds: nil (no default),
	// bool, map[string]any or func(related, parent any) any. The field is the object's single
	// default slot — Eloquent's SupportsDefaultModels::$withDefault: the declaration seeds it, and
	// the object's call-time WithDefault call replaces it (setWithDefault).
	WithDefault any
	// Inverse carries the relation's declared inverse relation name — the related model's
	// relation that receives the parent instance on every read and on every write-created child
	// (Eloquent's ->inverse('user') / ->chaperone(); the name
	// SupportsInverseRelations::$inverseRelationship holds). "" when the relation was declared
	// without one. The write-back mechanics are engine-side: database/gorm installs the state on
	// the wrapped builder at wrap time. The projection seeds the object's GetInverseRelationship
	// answer and its WithoutChaperone / WithoutInverse clearing.
	Inverse string
	// OneOfMany reports that the relation's own scope turned it into a one-of-many relation.
	OneOfMany bool

	// polymorphic specifics
	MorphTypeColumn string
	MorphIDColumn   string
	MorphValue      string
	MorphOwnerKey   string
	MorphInverse    bool

	// through specifics
	ThroughTable string
	// FirstKey is the through table's foreign key to the far parent — HasOneOrManyThrough's
	// $firstKey, the column getQualifiedFirstKeyName qualifies with ThroughTable and the
	// non-existing-parent limit() partitions its group limit by.
	FirstKey       string
	SecondKey      string
	LocalKey       string
	SecondLocalKey string

	// many-to-many specifics
	PivotTable      string
	PivotParentRef  RefKey
	PivotRelatedRef RefKey

	// PivotField is the declared name of the struct field on the related model that m2m reads
	// hydrate with pivot column values — desc.pivotField (Many2Many.PivotField / the morph
	// variants, default "Pivot"). It is the Go stand-in for Eloquent's pivot accessor
	// (BelongsToMany::$accessor — 'pivot' in the class, BelongsToMany.php:145): GetPivotAccessor
	// reports it and As overrides it for the object's own reads. "" for the kinds that carry no
	// pivot.
	PivotField string

	// Name is the relation's declared name on the parent model — desc.name, the head segment of
	// the relation path the caller resolved: the relation method name Eloquent's
	// BelongsTo::getRelationName reports and the kind / FK error messages carry.
	Name string
	// Touches reports that the many-to-many declaration asked the pivot writes to bump the
	// parent's updated_at after a Sync / Attach / Detach / Toggle / UpdateExistingPivot that
	// changed rows — desc.touches (Many2Many.Touches / MorphToMany.Touches / MorphedByMany.Touches),
	// the declaration-time answer to BelongsToMany::touchIfTouching's touchingParent().
	Touches bool
	// PivotCreatedAtColumn / PivotUpdatedAtColumn name the pivot-table columns the pivot writes
	// stamp with the current time — both on insert, the updated one on update —
	// desc.pivotCreatedAtColumn / desc.pivotUpdatedAtColumn, resolved at declaration from the
	// Pivot struct's automatic timestamp fields or PivotTimestamps: true. "" leaves the column
	// unstamped (BelongsToMany::$withTimestamps false).
	PivotCreatedAtColumn string
	PivotUpdatedAtColumn string
	// RelatedKeyType is the Go type of the related model's primary key — desc.relatedKeyType —
	// the type the sync family casts its SyncResult ids back to (castKeys). nil for the kinds
	// without a related-side pivot key.
	RelatedKeyType reflect.Type
	// OnPivotQuery is the declaration's pivot-table scope (Many2Many.OnPivotQuery and the morph
	// variants) — desc.onPivotQuery, the callback the pivot-table SELECT / UPDATE / DELETE
	// primitives replay before compiling (applyOnPivotQueryToQuery). nil when none is declared.
	OnPivotQuery contractsorm.PivotCallback
}

RelationInfo is the plain-data projection of the internal relation descriptor the object layer reads: the kind, the tables and models involved, the reference pairs, the morph columns and the through / pivot specifics. It carries no GORM types.

func ResolvePivot

func ResolvePivot(b Builder, parent any, relation, op string) (RelationInfo, any, error)

ResolvePivot is the shared front-half for all pivot operations: validates parent, resolves the relation's projection, asserts a pivot-friendly kind, and reads the parent's PK that anchors every pivot row. Returns the projection + parent's PK value. op names the operation the kind-not-supported error reports.

type RowWhere

type RowWhere struct {
	Query string
	Args  []any
}

RowWhere is one raw predicate of a row primitive (UpdateRows / DeleteRows / SelectRows): the fragment and its bindings, appended to the table-scoped query exactly the way pivotWhere hands them to addWhere. The two shapes the pivot workers use: an equality passes the dialect-quoted column (Builder.Ident) followed by " = ?" with the value as its single argument; an IN list passes the unquoted qualified column (table.column) with the id slice as its single argument, which the compiler renders as a typed IN clause.

type SchemaColumn

type SchemaColumn struct {
	DBName         string
	FieldType      reflect.Type
	AutoCreateTime bool
	AutoUpdateTime bool
}

SchemaColumn is one column of a ModelSchema: its database name, its Go type and whether it is stamped automatically on insert (AutoCreateTime) and on update as well (AutoUpdateTime).

Jump to

Keyboard shortcuts

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