A relationship declares a typed connection from one entity to another. The
metamodel has two relationship-shaped node types: relationship.composition (the
"this entity owns / aggregates instances of that entity" direction) and
identity.reference (the "this entity has an FK column pointing at that entity"
direction). They are the two sides of the same FK; codegen + persistence + drift
detection all flow from them.
The named example: an Author has many Posts. Composition lives on the parent
side; an identity.reference lives on the child side. Codegen on each side knows
how to find its counterpart.
{
"metadata.root": {
"package": "acme::blog",
"children": [
{
"object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true } },
{ "relationship.composition": {
"name": "posts",
"@objectRef": "Post",
"@cardinality": "many",
"@onDelete": "cascade"
}},
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
]
}
},
{
"object.entity": {
"name": "Post",
"children": [
{ "source.rdb": { "@table": "posts" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "title", "@required": true } },
{ "field.long": { "name": "authorId", "@required": true } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } },
{ "identity.reference": {
"name": "fkAuthor",
"@fields": "authorId",
"@references": "Author",
"@onDelete": "cascade"
}}
]
}
}
]
}
}metadata:
package: acme::blog
children:
- object.entity:
name: Author
children:
- source.rdb: { table: authors }
- field.long: { name: id }
- field.string: { name: name, required: true }
- relationship.composition:
name: posts
objectRef: Post
cardinality: many
onDelete: cascade
- identity.primary: { fields: id, generation: increment }
- object.entity:
name: Post
children:
- source.rdb: { table: posts }
- field.long: { name: id }
- field.string: { name: title, required: true }
- field.long: { name: authorId, required: true }
- identity.primary: { fields: id, generation: increment }
- identity.reference:
name: fkAuthor
fields: authorId
references: Author
onDelete: cascade| Attr | Where | Values | Purpose |
|---|---|---|---|
@objectRef |
relationship.composition |
Entity name | Target entity |
@cardinality |
relationship.composition |
one / many |
Multiplicity on the target side |
@fields |
identity.reference |
One field name or array | The FK column(s) on this entity |
@references |
identity.reference |
Entity name | The target entity (PK on the other side) |
@through |
relationship.* |
Junction entity name | Makes the relationship M:N. With @cardinality: many, names the junction entity whose two identity.reference children the FK columns are DERIVED from — the relationship never restates them. |
@sourceRefField |
relationship.* |
FK field name | On an M:N, names the source-side FK field on the junction (a DIRECTED self-join). On a @cardinality: one relationship, picks which of several identity.reference nodes onto the same target it navigates (see below). Mutually exclusive with @symmetric. |
@symmetric |
relationship.* |
true |
Marks an UNDIRECTED M:N self-join (union-on-read). Valid only when @objectRef is the relationship's own subject. Mutually exclusive with @sourceRefField. |
@onDelete |
relationship.* and identity.reference |
cascade / set-null / restrict / no-action |
RDB referential action. Default derives from the relationship subtype: composition -> cascade, aggregation -> set-null, association -> restrict. |
@onUpdate |
relationship.* and identity.reference |
same as @onDelete (default cascade when a relationship correlates) |
RDB referential action |
@onDelete / @onUpdate are registered on relationship.association /
aggregation / composition and — as the explicit per-FK override
(ADR-0047)
— on identity.reference itself. Resolution precedence for the FK's emitted
action, highest first:
@onDelete/@onUpdatedeclared directly on theidentity.reference— the reference IS the FK, so the action may be declared right where the FK is. This is the only way to put an action on a reference-only FK (no relationship node) or on an M:N junction's FK sides (no relationship ever correlates with a junction FK).- A relationship declared on the FK-owning (child) entity targeting the
referenced entity (matched package-aware, so bare and FQN spellings pair
correctly; an M:N
@throughrelationship never correlates with a direct FK): its explicit action, else its subtype default. - A relationship declared on the referenced (parent) entity pointing back
at the FK-owning entity — the canonical parent-side authoring above
(
Authordeclaresposts): its explicit action, else its subtype default. Guards: the M:N exclusion again; when the child holds more than one FK to the same parent the parent-side relationship contributes to none of them (it cannot say which FK carries the ownership edge); and an inferred set-null default (aggregation, no explicit@onDelete) on a NOT NULL FK contributes nothing — SET NULL cannot fire there (an explicitset-nullinstead fails migration generation loudly, telling you to make the FK nullable). - None of the above → the FK is emitted with no
ON DELETE/ON UPDATEclause (SQLNO ACTION).
Prefer declaring the action on the relationship (the subtype carries the
semantics and the default); reach for the reference-level attr only when no
relationship exists or a single FK needs to deviate. The values are
load-validated everywhere — a misspelling (e.g. the retired setnull alias)
fails load with ERR_BAD_ATTR_VALUE.
The kebab-case metamodel values map to the SCREAMING_SNAKE forms in Exposed / EF Core / the target ORM or DDL at codegen time.
A projection's origin.* @via join path (FR-024) may name either a
relationship.* or an identity.reference. A reference-only FK is a navigable
edge in its own right — it declares the target (@references) and the join column
(@fields), and codegen derives the join key from the reference for every hop (a
correlated relationship only adds a name + cardinality). So a FK-only or
reverse-engineered model can @via its reference directly:
# Enrollment has `identity.reference: { name: refProgram, references: Program }`
# and NO relationship — the projection still joins Program:
- origin.passthrough: { from: "Program.title", via: "Enrollment.refProgram" }A reference hop is inherently to-one (a child names the parent it points at), so it
is valid in a passthrough and rejected in an aggregate. Inverse navigation
(parent → many children) still needs a relationship.composition on the parent — a bare
FK has no inverse edge. Explicit @via resolves either kind; single-hop-unique
inference stays relationship-only.
An entity may legitimately declare more than one identity.reference onto the same
target — a Match entity with both alphaRef and betaRef pointing at Team. A
@cardinality: one relationship names only the target, via @objectRef; it does not
say which reference it means. With two candidates, the target alone is not enough to
pick the FK (#368).
Resolution follows a ladder, checked in order:
- Exactly one candidate
identity.referencetargeting the relationship's@objectRef→ that one. This is the common case (one reference per target) and needs no extra authoring. @sourceRefFielddeclared on the relationship → the candidate whose first FK field it names. This is a short-circuit: a declared value that names no candidate's FK field is a load error regardless of how many candidates exist — it never falls through to name-pairing.- Exactly one candidate name-pairs with the relationship → that one. A candidate
pairs when its own name or its FK field — lowercased, with one trailing suffix from
reference/ref/id/keyoptionally stripped — equals the relationship's name (lowercased, never stripped). A relationship namedawayTeampairs with a reference namedawayTeamRefor with one whose FK field isawayTeamId, with no extra authoring. - Otherwise →
ERR_INVALID_RELATIONSHIPat load, naming every candidate. Fix it by declaring@sourceRefFieldwith the FK field this relationship means, or by naming the relationship so it pairs with exactly one candidate.
# Match declares TWO references onto Team: alphaRef (alphaFk) and betaRef (betaFk).
# "winner" pairs with neither name, so it must be disambiguated explicitly.
- relationship.association:
name: winner
objectRef: Team
cardinality: one
sourceRefField: alphaFk # picks alphaRef; drop this and the load fails,
# naming alphaRef(alphaFk) and betaRef(betaFk)
- relationship.association:
name: loser
objectRef: Team
cardinality: one
sourceRefField: betaFkLimitations, documented rather than fixed:
- The ladder matches a candidate's first FK field only, so two composite
references sharing a first column are indistinguishable from each other — and this
does not refuse. Rule (e) accepts a
@sourceRefFieldthat matches any candidate's first column (candidates.some(c => c.fields[0] === declared)), and the ladder's.find()then returns the first such candidate. The model loads clean and resolves to whichever composite reference is declared first, which may not be the one meant. Where an error is raised, it still renders each candidate's full field tuple (name(fieldA, fieldB)) so the collision is at least visible. - The load-time gate covers
@cardinality: onerelationships only. Amany-cardinality relationship, and a bareidentity.referencepair with no relationship wrapper at all, reach codegen unvalidated — an ambiguous reference set in either shape is not caught at load. - The load-time gate covers a
@cardinality: onerelationship only when its holder declares at least oneidentity.referenceat the target. Rule (e)'scandidates.length <= 1skip (validation-passes.ts, and its three ports) skips zero as well as one, so an inverted shape — the relationship on one entity, both FKs on the far side — loads clean, and codegen then silently drops the relation: no join, no error, no diagnostic. - A projection's
@viahop (above) resolves the identical ambiguity for the hop it names, butorigin.first's own@viais never consulted for its base↔child correlation — an ambiguous target there has no@via-based fix; the only escape is removing the second reference. @viaand@sourceRefFieldare different mechanisms on different node types:@vialives onorigin.*and names a projection join hop;@sourceRefFieldlives onrelationship.*and names an FK field. A projection ambiguity error points you at@via; a relationship ambiguity error points you at@sourceRefField— don't reach for one to fix the other.
See ADR-0029 Amendment 1 for the full ladder specification, including why suffix-stripping applies to candidates only.
An M:N relationship declared on an abstract base is visible on every entity that
extends it — relationship accessors are RESOLVING, so Post sees the tags
relationship its PostBase declared. The junction's two identity.reference
children are what give the FK direction, and under inheritance there are two
defensible entities for the source-side reference to name:
- the declaring base (
PostBase) — the entity the relationship is written on, and what@objectRefnames for a self-join hoisted onto a base; or - the concrete child (
Post) — usually what the FK actually references, because an abstract base has no table for a foreign key to point at.
Both are accepted, and only those two. The FK derivation treats the declaring
entity and the entity you are navigating from as the relationship's subject: the
source-side junction reference may name either, and @objectRef naming either makes
the relationship a self-join. Nothing else counts — in particular an entity lying
strictly between the declaring base and the navigating entity in a deeper hierarchy
is not accepted, and a junction reference naming one fails derivation with
ERR_INVALID_RELATIONSHIP.
# PostBase (abstract) declares the M:N; Post extends it. The junction may reference
# EITHER PostBase or Post — both derive postId/tagId for Post.tags.
- object.entity:
name: PostBase
isAbstract: true
children:
- relationship.association:
name: tags
objectRef: Tag
cardinality: many
through: PostTag
- object.entity:
name: Post
extends: PostBase
- object.entity:
name: PostTag
children:
- identity.reference:
name: fkPost
fields: postId
references: Post # or PostBase — either resolves
- identity.reference:
name: fkTag
fields: tagId
references: TagThe same rule governs an inherited self-join: a base declaring
@objectRef: <itself> with @symmetric or @sourceRefField derives the same two FK
sides whichever subclass you reach it through. The derivation's answer never depends
on which entity's effective view got there first — that independence is the point, and
it is what
#368's loader fix
established for validation and this rule extends to FK derivation.
Cross-package targets are safe. All five ports resolve @objectRef and each junction
identity.reference to an ENTITY and compare identity — on both sides of the
derivation, the source-side match and the target-side match alike. So a genuine
cross-package hetero M:N whose target's short name happens to match the source's
(a::Account relating to b::Account, or a::NodeBase to b::NodeBase) binds each
junction reference to its own entity instead of matching one of them twice. A
package-qualified name resolves exactly
(ADR-0041); a bare
name matches a short name, where a collision across packages is the deferred follow-up
#174, the same as everywhere
else a bare reference is resolved. Two consequences worth knowing when authoring: a
junction @references that is package-qualified must resolve exactly — a
partially-qualified or stale package no longer falls back to a bare-tail match — and a
bare @references whose short name exists in more than one package binds the first
declared, which is #174 and not specific to M:N.
@metaobjectsdev/codegen-ts emits Drizzle references() on the FK column and (with
queriesFile()) a typed finder for the relationship.
// generated/acme/blog/Post.ts
import { AnyPgColumn } from "drizzle-orm/pg-core";
import { PostNames } from "./Post.names";
export const post = pgTable(PostNames.sources.primary.table, {
id: bigint(PostNames.fields.id.column, { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(),
title: varchar(PostNames.fields.title.column, { length: 255 }).notNull(),
authorId: bigint(PostNames.fields.authorId.column, { mode: "number" })
.notNull()
.references((): AnyPgColumn => author.id, { onDelete: "cascade" }),
});
// generated/acme/blog/Author.queries.ts (excerpt)
export async function findPostsForAuthor(db: Db, authorId: number): Promise<Post[]> {
return db.select().from(post).where(eq(post.authorId, authorId));
}Every .references() callback carries the explicit (): AnyPgColumn return type
(AnySQLiteColumn for the sqlite dialect). Drizzle requires this to break circular
type inference — not only for self-referential FKs but also for cross-module circular
references (table A → B while B → A), which otherwise surface as TS7022 (implicitly has type 'any' … referenced … in its own initializer) under strict. It is a harmless
explicit supertype for acyclic FKs, so codegen emits it unconditionally.
OMDB resolves relationships at runtime via the same metadata; FK columns are
applied through the identity.reference child of the target entity. CRUD on the
parent cascades per @onDelete if the ObjectManager is configured to honor it.
// runtime usage
Author author = om.getObjectById(Author.class, 42L);
List<Post> posts = om.getObjectsBy(Post.class, new ValueObject().set("authorId", author.getId()));KotlinExposedTableGenerator emits FK columns with Exposed references(..., onDelete = ReferenceOption.CASCADE). For the to-many side it skips the column on
that table and emits an ergonomic query helper in <Entity>Relations.kt.
// generated/acme/blog/PostTable.kt
object PostTable : Table("posts") {
val id = long("id").autoIncrement()
val title = varchar("title", 255)
val authorId = long("author_id")
.references(AuthorTable.id, onDelete = ReferenceOption.CASCADE)
override val primaryKey = PrimaryKey(id)
}
// generated/acme/blog/AuthorRelations.kt
fun AuthorTable.postsQuery(authorId: Long): Query =
PostTable.selectAll().where { PostTable.authorId eq authorId }Consumer code reads as AuthorTable.postsQuery(author.id).toList().
MetaObjects.Codegen emits EF Core HasOne(...).WithMany(...).HasForeignKey(...)
in the generated AppDbContext, and meta migrate emits CONSTRAINT ... FOREIGN KEY ... ON DELETE CASCADE in the Postgres DDL.
// generated/AppDbContext.cs (excerpt)
modelBuilder.Entity<Post>().HasOne<Author>().WithMany()
.HasForeignKey(e => e.AuthorId).OnDelete(DeleteBehavior.Cascade);The FK property is named by a typed lambda, not nameof(Post.AuthorId). This line sits
inside the generated DbContext class body, where C# simple-name lookup binds Post to a
member of the context before it considers the type — and the context declares one DbSet
property per entity. So an entity whose type name equals any DbSet property name made
nameof read the DbSet and fail to compile (CS1061), which stock metadata can reach:
Pluralize("Address") == "Addresses", so a model with both an Address and an Addresses
entity broke. A lambda parameter is local, so nothing can shadow it, and unlike a bare string
literal it keeps the property name compile-checked. A composite reference emits
.HasForeignKey(e => new { e.OrgId, e.SiteId }).
The action rides on the call that establishes the foreign key, never a later
GetForeignKeys(...) mutation: EF Core reconciles TPH relationships after
OnModelCreating returns and can replace the foreign-key metadata object, which
silently discards a post-hoc assignment (#294).
WithMany() is inverse-less because the port emits no reverse collection
navigations at all — reverse traversal is the explicit FK finders of ADR-0038.
That premise is load-bearing, and it is worth stating as a premise: the
navigation-less HasOne<Target>() overload is correct because the stock entity
generator emits no reference navigations. If you substitute your own entity generator and it
does emit navigation properties, EF's conventions discover them and build their own
relationship over the same FK column — and this navigation-less configuration claiming that
column leaves the convention-built one unable to identify its dependent
(The dependent side could not be determined for the one-to-one relationship between 'X.Y' and 'Y.X'). That fails model validation, and a failed model takes down every query in the
application, not just the one relationship. The generator cannot detect the situation, so it
is an opt-out: override EmitsReferenceForeignKeys to false on DbContextGenerator and
configure those relationships yourself against the navigations only you know about. Nothing
else the generator emits is affected.
sealed class AppDbContextGenerator : DbContextGenerator
{
protected override bool EmitsReferenceForeignKeys => false;
}@onUpdate has no EF Core representation (DeleteBehavior covers deletes only),
so it stays a DDL-level fact emitted by the TypeScript-owned migration engine.
-- emitted by `meta migrate`
ALTER TABLE "posts"
ADD CONSTRAINT "fk_posts_author"
FOREIGN KEY ("authorId") REFERENCES "authors" ("id")
ON DELETE CASCADE;The Python loader recognizes relationship.composition and identity.reference
and exposes them on the navigation API. Relationship-navigation codegen (emitting
the FK-derived accessors into the generated Pydantic + FastAPI code) is still
pending; the runtime is the shipped DB-API-2 ObjectManager.
# generated/acme/blog/post.py — current state
@dataclass
class Post:
id: int
title: str
author_id: int # FK to Author.id — relationship-as-runtime is on the roadmapThe following conformance fixtures gate this feature's behavior across ports:
fixtures/conformance/relationship-one-to-many/—relationship.composition1:N with the parent owning the collectionfixtures/conformance/identity-reference-simple/—identity.referencedeclares the FK column-set on the childfixtures/conformance/source-rdb-referential-actions/—@onDelete/@onUpdateon relationshipsfixtures/conformance/identity-reference-referential-actions/— the parent-side@cardinality: manycomposition (subtype-default cascade) +@onDelete/@onUpdatedeclared directly onidentity.reference(ADR-0047)fixtures/conformance/error-unknown-relationship-subtype/— unknownrelationship.<subtype>rejectedfixtures/conformance/relationship-one-two-refs-sourcerefield/— twoidentity.referencenodes onto the same target, disambiguated by@sourceRefField(ladder stage 2)fixtures/conformance/relationship-one-two-refs-name-pairing/— the same shape resolved by name-pairing alone (ladder stage 3)fixtures/conformance/error-relationship-one-refs-ambiguous/— neither@sourceRefFieldnor a pairing name given:ERR_INVALID_RELATIONSHIPat load (#368, ADR-0029 Amendment 1)fixtures/conformance/relationship-one-two-refs-dotted-references/— the same two-reference shape with@referencesin the dottedEntity.fieldform: the entity half is the segment before the first., so the ladder resolves identically to the bare form
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See docs/CONFORMANCE.md
for the per-port pass/skip ledger.
Not fixture-gated, and why. The M:N junction-FK DERIVATION — including the
inherited-relationship rule above — cannot be expressed in fixtures/conformance/:
that corpus is a load→canonical-serialize round-trip, and the serializer preserves the
declared @objectRef / @through / @sourceRefField strings without ever surfacing
which junction column the derivation picked. Two models that derive differently
serialize identically. It is gated instead by per-port unit tests over the shared
derivation helper (relationship-m2m.test.ts, M2MSlimVocabularyTest.java,
M2MInheritedDeclaringEntityTests.cs, test_derive_m2m_declaring_entity.py,
KotlinM2mCodegenTest.kt), which is the same call the M:N FQN-collision cases already
made.
- entities.md — host node
object.entity - field-types.md — all field subtypes (incl.
field.objectfor embedded VOs) - source-kinds.md —
source.rdb@kindcontrols FK emission - migrations-and-drift.md — FK clause in
meta migrateoutput