Skip to content

Latest commit

 

History

History
474 lines (398 loc) · 22.2 KB

File metadata and controls

474 lines (398 loc) · 22.2 KB

Abstracts and inheritance

Metadata can declare abstract instances — nodes that describe a shape but are never instantiated themselves. Concrete instances reference an abstract via extends: to inherit its children and attrs. This is the author-side mechanism for reuse: no provider code, no new subtype, no codegen change.

Abstracts and extends: are the lightest possible way to share metadata shape. When you find yourself copy-pasting the same field/validator combo across multiple entities, an abstract is the right answer.

For the provider-side mechanism (adding brand-new subtypes or attributes), see extending-with-providers.md. The decision table at the bottom of this page compares both.

What is an abstract?

An abstract node is any metadata node with abstract: true. It loads normally but the loader / codegen treat it specially:

  • It contributes shape but is never emitted as a concrete entity — no table, no API endpoint, no runtime class. Codegen skips it.
  • It cannot be instantiated directly — every concrete instance must declare its own name and reference the abstract via extends:.
  • It can be extended by any number of concrete instances, which all inherit its children + attrs.

abstract and extends are structural keys (bare, no @ prefix) — the same kind as name, package, children. They live alongside the node's type/subtype, not as @-prefixed attributes.

Two canonical use cases

1. Shared base entities (object.entity abstracts)

Common audit fields (id, createdAt, updatedAt) belong on one abstract entity that all concrete entities extend. Change them in one place; every table updates on the next meta gen.

# metaobjects/abstracts/meta-base-entity.yaml
metadata:
  package: acme
  children:
    - object.entity:
        name: BaseEntity
        abstract: true
        children:
          - field.long:
              name: id
          - field.timestamp:
              name: createdAt
              "@required": true
          - field.timestamp:
              name: updatedAt
# metaobjects/meta-author.yaml
- object.entity:
    name: Author
    extends: BaseEntity
    children:
      - source.rdb: { "@table": authors }
      - field.string:
          name: name
          "@required": true
      - identity.primary: { "@fields": id }

The codegen emits authors with id, created_at, updated_at, AND name columns. BaseEntity itself is never a table — it has no source.rdb, no identity, and abstract: true.

This is the pattern entities.md introduces — see entities.md § extends for the full Author example with per-port emission.

2. Shared field shapes (field.<subtype> abstracts)

When the same constraint set shows up on multiple entity fields — e.g., a URL-safe opaque slug ID — declare it as an abstract field once.

# metaobjects/abstracts/meta-slug-fields.yaml
metadata:
  package: acme
  children:
    - field.string:
        name: shortSlug
        abstract: true
        "@maxLength": 8
        children:
          - validator.regex:
              "@pattern": "^[A-Z2-9]{8}$"
          - validator.required: {}
# metaobjects/meta-council.yaml
- object.entity:
    name: Council
    children:
      - source.rdb: { "@table": councils }
      - field.string:
          name: id
          extends: shortSlug    # ← inherits maxLength + regex + required
      - identity.primary: { "@fields": id }

Every port's codegen propagates the constraint:

  • Drizzle column: varchar(CouncilNames.fields.id.column, { length: 8 }).primaryKey() on Postgres (text(...) on SQLite). The regex becomes a DB CHECK via meta migrate and a .regex(...) in the generated Zod schema — Drizzle carries no CHECK for it.
  • Zod validator: z.string().length(8).regex(/^[A-Z2-9]{8}$/)
  • Migration SQL: CHECK (length(id) = 8 AND id REGEXP '^[A-Z2-9]{8}$')

Change the alphabet or length in shortSlug once; every field that extends it updates on the next regen. No provider code, no codegen change in MO itself — pure authored metadata.

How resolution works

The loader runs in passes (see loaders.md):

  1. Parse every metadata file in deterministic ordinal order.
  2. Build tree — every node exists in memory with its declared shape.
  3. Resolve extends — walk every node; if it has extends: "<name>", look up that name in the loaded tree (current package first, then via fully-qualified ref). Merge the abstract's children + attrs into the concrete node.
  4. Validate — the merged node is checked against its subtype's rules (required attrs, child constraints, etc.) just like a flat node would be.

This means:

  • Abstracts can live in any file in the corpus. The loader sees the whole tree before resolving extends — order doesn't matter.
  • Forward references are fine. A field that extends: shortSlug can appear in a file the loader processes before the file that declares shortSlug.
  • Dotted refs to inherited members resolve in any order. A field extends: Owner.member works even when member reaches Owner only through Owner's own extends:. Super-resolution wires the owner's chain on demand before reading its effective members, so file/enumeration order never changes the result (#188).
  • Multi-level chains work. Author extends BaseEntity extends Auditable flattens to one effective shape. Each level can add children or override attrs.
  • Cross-package references use fully-qualified names: extends: "shared::auditable". Same-package references can use the bare name.

If extends: references a name that doesn't resolve, the loader emits ERR_UNRESOLVED_SUPER with the unresolved reference and the source location. The fixture extends-nonexistent covers this.

Discriminator-based inheritance (TPH) with persistence

The two use cases above share shape — an abstract contributes columns to its extenders, but each extender is still its own table. Table-per-hierarchy (TPH) inheritance is the persistence-bearing form: several concrete entities are variants of one thing and share a single physical table, told apart by a discriminator column.

Declare it with two attributes:

  • On the base entity, @discriminator names the discriminator field — a field.enum whose @values are the subtype tags.
  • On each concrete subtype, extends the base and @discriminatorValue gives the tag (one of the base enum's members).
metadata:
  package: acme::auth
  children:
    - object.entity:
        name: Auth                       # the TPH base — owns the single table
        "@discriminator": type
        children:
          - source.rdb: { "@table": auths }
          - field.long: { name: id }
          - field.enum:
              name: type
              "@values": ["Bridge", "Copay", "PriorAuth"]
          - field.string: { name: reference, "@required": true }
          - identity.primary: { "@fields": id }

    - object.entity:
        name: BridgeAuth                 # subtype → folded into `auths`
        extends: Auth
        "@discriminatorValue": Bridge
        children:
          - field.int: { name: quantity, "@required": true }

    - object.entity:
        name: CopayAuth
        extends: Auth
        "@discriminatorValue": Copay
        children:
          - field.decimal: { name: copayAmount, "@precision": 10, "@scale": 2 }

    - object.entity:
        name: PriorAuthAuth
        extends: Auth
        "@discriminatorValue": PriorAuth
        children:
          - field.string: { name: approver }

Single-table persistence

Every subtype persists to the base's one table (auths). Subtype-only columns (quantity, copay_amount, approver) are folded into that table nullable — a row of any other subtype stores NULL there, even when the field is @required on its subtype (a required rule can't hold across the shared table, so it is dropped on the folded column; an enum CHECK still passes because NULL IN (...) is NULL, not false). The same applies to @default — a subtype-only column's default is suppressed on the folded column (an INSERT by another subtype omits the column and must stay NULL, not take a default), though the base entity's own columns keep their defaults because every row has them. The base row shape is the union of all subtype columns. Subtype entities emit no table of their own.

A reference onto a subtype (@references: BridgeAuth) is a foreign key into the base's table, auths. An M:N relationship onto a subtype traverses that same table and returns only rows of the subtype: the junction's FK can only point at the base table, so it can hold a sibling subtype's id, and that row is not a BridgeAuth. A reference declared on a subtype, or on an abstract level between the base and a subtype, is folded onto the base table with its FK, the same way its column is. These three rules are what the TypeScript toolchain (meta migrate, meta verify --db and the generated Drizzle schema and routes) enforces, what the Kotlin port's generated Exposed schema and queries enforce in their own mapping, and — where persistence is consumer-owned — a contract on the repository seam you implement.

What codegen emits (all five ports)

TPH is supported and conformance-gated in all five ports — codegen lowers it to each port's idiomatic single-table mapping:

Port Single-table persistence Polymorphic API
TypeScript one Drizzle table (subtype cols nullable) + discriminated-union type + parse<Base> dispatcher Fastify polymorphic + per-subtype routes; runtime-ts ObjectManager scopes by subtype
C# EF Core .HasDiscriminator(e => e.Type).HasValue<Sub>(...) on the single table ASP.NET minimal-API polymorphic + per-subtype routes
Java union DTO (subtype cols nullable) + polymorphic / per-subtype-scoped repository seam Spring Web MVC polymorphic + per-subtype controller
Python Pydantic subtype pins the discriminator to a Literal; subtype-keyed repository Protocol FastAPI polymorphic + per-subtype router
Kotlin one Exposed Table (subtype cols .nullable()) + union data class Spring polymorphic + per-subtype controller

Where a port owns persistence (TS Drizzle, C# EF Core, Kotlin Exposed) the single-table mapping is generated directly; where persistence is consumer-owned (Java Spring, Python FastAPI) codegen emits the polymorphic controller/router plus a subtype-scoped repository seam you implement (idiomatically a Spring-JPA single-table mapping / a SQLAlchemy polymorphic mapping).

Runtime behavior (the subtype contract)

The generated polymorphic surface enforces a consistent subtype contract across every port:

  • Per-subtype route segment = the @discriminatorValue lowercased: /auths/bridge, /auths/copay, /auths/priorauth (not the subtype entity name). There is intentionally no polymorphic POST /auths — you can't create the abstract base.
  • Create injects the discriminator from the URL — POST /auths/bridge stamps type = "Bridge"; the request body omits it.
  • Reads/updates/deletes are scoped to the subtype — GET|PATCH|DELETE /auths/bridge/{id} on a Copay row → 404 (a different-subtype row is invisible to a subtype-scoped operation).
  • M:N traversal is served inside the hierarchy — a relationship declared on the base mounts at the base path (GET /auths/{id}/tags: every row of the shared table is a legitimate source), and every relationship a subtype resolves — declared on it or inherited, including one declared on an abstract level between base and subtype, which has no path of its own — also mounts under the subtype's segment (GET /auths/bridge/{id}/tags). The overlap is deliberate: only the subtype segment gates the source id. The junction FK points at the shared base table and cannot tell subtypes apart, so the mount reads the source row's own discriminator first: a sibling subtype's id — which names no row of this subtype and so has no relations here — answers 200 with [], where a subtype-scoped CRUD read of the same id answers 404. All five ports serve it today. The ports reach the sibling-id gate by different mechanics: TypeScript reads the source row's own discriminator in a stage 0 before the join, Kotlin runs the same stage 0 against the shared Exposed table ((id eq :id) and (type eq <Enum>.<Value>)) before delegating to the ONE shared query helper every mount of the relation calls, C# proves ownership with db.<Base>.OfType<Sub>().AnyAsync(...), and the two consumer-owned persistence ports compose the repository seam's own lookup as the ownership check — Java's generated controller calls findByIdAndType(id, "<disc>") (the same seam the per-subtype GET gates on) and answers [] on a miss, Python's calls find_by_id(subtype, id). Java's subtype-scoped traversal is a DISTINCT repository method (find<Relation>For<Disc>, e.g. findTagsForBridge) — the interface needs both it and the whole-table finder for an inherited relation, and the two signatures would otherwise be identical. For a TPH-subtype TARGET, TypeScript adds a WHERE clause to its shared Drizzle helper, Java threads a targetSubtype parameter to the finder seam (the repository method declares find<Relation>(Long sourceId, String targetSubtype)), Kotlin ANDs the target's discriminator into the emitted Exposed join — the join binds the base's table, because a subtype has no table or data class of its own, so the AND is what narrows the rows back to the declared target (an abstract mid-level target has no single discriminator value and stays unfiltered) — and Python widens the find_related_<relation> seam with a target_subtype parameter carrying the resolved @discriminatorValue (threaded per relation name across every mount of that name — str | None with an explicit None at non-TPH mounts, since a subtype may shadow a base-declared relationship with a differently-TPH target).
  • The discriminator is immutable — an update can't move a row to another subtype (the field is stripped from update patches).
  • Polymorphic reads — GET /auths and GET /auths/{id} return the union across subtypes, each row tagged by its discriminator value.

Conformance

  • fixtures/api-contract-conformance/tph/ pins the HTTP wire shape (polymorphic + per-subtype URLs, discriminator-by-value, cross-subtype 404), run in two lanes — a reference server AND the generated routes booted over HTTP.
  • fixtures/persistence-conformance/ tph-*.yaml scenarios pin the single-table runtime semantics (create injects the discriminator, subtype-scoped find, no cross-subtype update).

Overlays vs. extends — different concepts

Both look like they're "merging shape" but they do different things:

Mechanism Purpose Wire key
extends: Concrete inherits from abstract — IS-A relationship extends: "BaseName"
overlay: Re-open an existing same-named node and add/override children — same-instance amendment across files overlay: true

Use extends: when two distinct entities share a shape. Use overlay: when the same entity's declaration is split across files (e.g., domain code in one file, persistence overlay in another). See loaders.md for the overlay merge semantics.

The same rule, across a repository boundary

Nothing above changes when the base you extends or the node you overlay lives in a metadata dependency rather than your own tree — dependency files load before yours in the same loader.load(...), so a foreign abstract resolves and a foreign node re-opens exactly like a local one. Neither actually depends on that ordering: extends resolves after every source is parsed (#188) and overlay: true is applied in a deferred pass after that (ADR-0055), so file order is not what makes either work. One rule is stricter across that boundary, though: say overlay: true on every amendment to a node you do not own. Within one project the parser merges a same-(type, package::name) redeclaration whether or not it carries the flag; only the flagged form fails loudly (ERR_OVERLAY_NO_TARGET) when the target disappears. Skip the flag on a dependency's node and an upstream removal turns your amendment into a silent new local object under the same name instead of a build failure — the flag is what makes that loud. Declaring a brand-new top-level node into a dependency's package (rather than amending one it already exports) is refused outright (ERR_DEPENDENCY_PACKAGE_NOT_OWNED); see metadata-dependencies.md for the full rule and what fails when the base you extended, or the node you overlaid, changes upstream.

When to use abstracts vs. new subtypes vs. attr extensions

This is the same decision as extending-with-providers.md § When to add a subtype vs. an attr vs. an abstract, viewed from the metadata author's side:

Use abstracts + extends when:

  • ✅ Multiple metadata instances should share a constraint set that changes together (slug shape across entities; audit columns across tables).
  • ✅ The shape is expressible in existing subtypes — you don't need new attrs or new node classes.
  • ✅ The author wants to opt into the shape per-instance (a Council id field extends shortSlug; a non-shareable Internal id doesn't).
  • ✅ Zero code changes needed in MO itself.

Use attr extension (via a provider's registry.extend) when:

  • ✅ You need a new configuration knob that applies wherever an existing subtype is used (@toolName on template.output; @audit-trail on field.*).
  • ✅ The attr is universally accepted — every instance of that subtype CAN set it, even if most don't.
  • ✅ Codegen needs to branch on the attr's presence (e.g., emit a tool-call wrapper if @toolName is set).

Use new subtype registration (via a provider's registry.register) when:

  • ✅ The new concept needs a different runtime node class (different factory: returning a subclass of MetaData).
  • ✅ It needs different childRules that can't be expressed as additional attrs (e.g., source.rdb accepts @table/@kind/@role but not arbitrary attrs).
  • ✅ The concept is so universal it deserves a name in the closed core vocabulary — added to one of the core providers, not consumer-local.

Decision flow

Need to share shape across multiple instances?
   ├─ YES → Is the shape expressible with existing subtypes?
   │        ├─ YES → Abstract + extends                       ← lightest
   │        └─ NO  → New attrs on existing subtype (extend)
   │                  ├─ Configuration only?       → registry.extend
   │                  └─ Structural divergence?    → registry.register
   └─ NO  → Don't make it shareable. Author it directly.

The marginal cost goes up sharply at each step: abstract (free) < attr-extension (cheap) < subtype-registration (expensive). A new subtype is a permanent commitment in every port's registry; an abstract is pure data the loader resolves at parse time. Default to the lightest mechanism that fits.

What each port emits

Abstracts are invisible to codegen output — the loader merges them into their extenders before any generator runs. Each port emits exactly what it would emit for the merged shape, as if the author had written all the inherited children and attrs inline.

Cross-port reading:

// TypeScript: the loader's effective view
import { MetaDataLoader } from "@metaobjectsdev/metadata";
const loader = await MetaDataLoader.fromDirectory("app", "./metadata");

const author = loader.metaObject("acme::Author");
// author.fields() includes "id" + "createdAt" + "updatedAt" (from BaseEntity)
// AND "name" (declared directly). The abstract origin is transparent.
// Java
MetaObject author = loader.getMetaObjectByName("acme::Author");
// Same — author.getMetaFields() returns the merged set.
# Python
author = loader.meta_object("acme::Author")
# author.fields() returns the merged set.

The extends: chain itself is queryable via node.superRef (TS / Python) / node.getSuperRef() (Java / C#) for tooling that needs to walk the inheritance chain.

Verified by

See also