|
| 1 | +# Abstracts and inheritance |
| 2 | + |
| 3 | +Metadata can declare **abstract** instances — nodes that describe a shape but |
| 4 | +are never instantiated themselves. Concrete instances reference an abstract |
| 5 | +via `extends:` to inherit its children and attrs. This is the **author-side** |
| 6 | +mechanism for reuse: no provider code, no new subtype, no codegen change. |
| 7 | + |
| 8 | +Abstracts and `extends:` are the lightest possible way to share metadata |
| 9 | +shape. When you find yourself copy-pasting the same field/validator combo |
| 10 | +across multiple entities, an abstract is the right answer. |
| 11 | + |
| 12 | +For the **provider-side** mechanism (adding brand-new subtypes or |
| 13 | +attributes), see [`extending-with-providers.md`](extending-with-providers.md). |
| 14 | +The decision table at the bottom of this page compares both. |
| 15 | + |
| 16 | +## What is an abstract? |
| 17 | + |
| 18 | +An **abstract node** is any metadata node with `abstract: true`. It loads |
| 19 | +normally but the loader / codegen treat it specially: |
| 20 | + |
| 21 | +- It contributes shape but is **never emitted as a concrete entity** — no |
| 22 | + table, no API endpoint, no runtime class. Codegen skips it. |
| 23 | +- It **cannot be instantiated directly** — every concrete instance must |
| 24 | + declare its own `name` and reference the abstract via `extends:`. |
| 25 | +- It can be `extended` by any number of concrete instances, which all inherit |
| 26 | + its children + attrs. |
| 27 | + |
| 28 | +`abstract` and `extends` are **structural keys** (bare, no `@` prefix) — the |
| 29 | +same kind as `name`, `package`, `children`. They live alongside the node's |
| 30 | +type/subtype, not as `@`-prefixed attributes. |
| 31 | + |
| 32 | +## Two canonical use cases |
| 33 | + |
| 34 | +### 1. Shared base entities (`object.entity` abstracts) |
| 35 | + |
| 36 | +Common audit fields (`id`, `createdAt`, `updatedAt`) belong on one abstract |
| 37 | +entity that all concrete entities extend. Change them in one place; every |
| 38 | +table updates on the next `meta gen`. |
| 39 | + |
| 40 | +```yaml |
| 41 | +# metaobjects/abstracts/meta-base-entity.yaml |
| 42 | +metadata: |
| 43 | + package: acme |
| 44 | + children: |
| 45 | + - object.entity: |
| 46 | + name: BaseEntity |
| 47 | + abstract: true |
| 48 | + children: |
| 49 | + - field.long: |
| 50 | + name: id |
| 51 | + - field.timestamp: |
| 52 | + name: createdAt |
| 53 | + "@required": true |
| 54 | + - field.timestamp: |
| 55 | + name: updatedAt |
| 56 | +``` |
| 57 | +
|
| 58 | +```yaml |
| 59 | +# metaobjects/meta-author.yaml |
| 60 | +- object.entity: |
| 61 | + name: Author |
| 62 | + extends: BaseEntity |
| 63 | + children: |
| 64 | + - source.rdb: { "@table": authors } |
| 65 | + - field.string: |
| 66 | + name: name |
| 67 | + "@required": true |
| 68 | + - identity.primary: { "@fields": id } |
| 69 | +``` |
| 70 | +
|
| 71 | +The codegen emits `authors` with `id`, `created_at`, `updated_at`, AND |
| 72 | +`name` columns. `BaseEntity` itself is never a table — it has no |
| 73 | +`source.rdb`, no identity, and `abstract: true`. |
| 74 | + |
| 75 | +This is the pattern entities.md introduces — see |
| 76 | +[`entities.md § extends`](entities.md#extends-for-shared-abstract-bases) for |
| 77 | +the full Author example with per-port emission. |
| 78 | + |
| 79 | +### 2. Shared field shapes (`field.<subtype>` abstracts) |
| 80 | + |
| 81 | +When the same constraint set shows up on multiple entity fields — e.g., a |
| 82 | +URL-safe opaque slug ID — declare it as an abstract field once. |
| 83 | + |
| 84 | +```yaml |
| 85 | +# metaobjects/abstracts/meta-slug-fields.yaml |
| 86 | +metadata: |
| 87 | + package: acme |
| 88 | + children: |
| 89 | + - field.string: |
| 90 | + name: shortSlug |
| 91 | + abstract: true |
| 92 | + "@maxLength": 8 |
| 93 | + children: |
| 94 | + - validator.regex: |
| 95 | + "@pattern": "^[A-Z2-9]{8}$" |
| 96 | + - validator.required: {} |
| 97 | +``` |
| 98 | + |
| 99 | +```yaml |
| 100 | +# metaobjects/meta-council.yaml |
| 101 | +- object.entity: |
| 102 | + name: Council |
| 103 | + children: |
| 104 | + - source.rdb: { "@table": councils } |
| 105 | + - field.string: |
| 106 | + name: id |
| 107 | + extends: shortSlug # ← inherits maxLength + regex + required |
| 108 | + - identity.primary: { "@fields": id } |
| 109 | +``` |
| 110 | + |
| 111 | +Every port's codegen propagates the constraint: |
| 112 | + |
| 113 | +- Drizzle column: `text("id").notNull()` + a CHECK constraint matching the |
| 114 | + regex |
| 115 | +- Zod validator: `z.string().length(8).regex(/^[A-Z2-9]{8}$/)` |
| 116 | +- Migration SQL: `CHECK (length(id) = 8 AND id REGEXP '^[A-Z2-9]{8}$')` |
| 117 | + |
| 118 | +Change the alphabet or length in `shortSlug` once; every field that extends |
| 119 | +it updates on the next regen. **No provider code, no codegen change in MO |
| 120 | +itself — pure authored metadata.** |
| 121 | + |
| 122 | +## How resolution works |
| 123 | + |
| 124 | +The loader runs in passes (see [`loaders.md`](loaders.md)): |
| 125 | + |
| 126 | +1. **Parse** every metadata file in deterministic ordinal order. |
| 127 | +2. **Build tree** — every node exists in memory with its declared shape. |
| 128 | +3. **Resolve `extends`** — walk every node; if it has `extends: "<name>"`, |
| 129 | + look up that name in the loaded tree (current package first, then via |
| 130 | + fully-qualified ref). Merge the abstract's children + attrs into the |
| 131 | + concrete node. |
| 132 | +4. **Validate** — the merged node is checked against its subtype's rules |
| 133 | + (required attrs, child constraints, etc.) just like a flat node would be. |
| 134 | +
|
| 135 | +This means: |
| 136 | +
|
| 137 | +- **Abstracts can live in any file** in the corpus. The loader sees the |
| 138 | + whole tree before resolving extends — order doesn't matter. |
| 139 | +- **Forward references are fine.** A field that `extends: shortSlug` can |
| 140 | + appear in a file the loader processes before the file that declares |
| 141 | + `shortSlug`. |
| 142 | +- **Multi-level chains work.** `Author extends BaseEntity extends Auditable` |
| 143 | + flattens to one effective shape. Each level can add children or override |
| 144 | + attrs. |
| 145 | +- **Cross-package references** use fully-qualified names: |
| 146 | + `extends: "shared::auditable"`. Same-package references can use the bare |
| 147 | + name. |
| 148 | +
|
| 149 | +If `extends:` references a name that doesn't resolve, the loader emits |
| 150 | +`ERR_UNKNOWN_EXTENDS` with the unresolved reference and the source location. |
| 151 | +The fixture |
| 152 | +[`extends-nonexistent`](../../fixtures/conformance/error-extends-nonexistent/) |
| 153 | +covers this. |
| 154 | + |
| 155 | +## Overlays vs. extends — different concepts |
| 156 | + |
| 157 | +Both look like they're "merging shape" but they do different things: |
| 158 | + |
| 159 | +| Mechanism | Purpose | Wire key | |
| 160 | +|---|---|---| |
| 161 | +| `extends:` | Concrete inherits from abstract — IS-A relationship | `extends: "BaseName"` | |
| 162 | +| `overlay:` | Re-open an existing same-named node and add/override children — same-instance amendment across files | `overlay: true` | |
| 163 | + |
| 164 | +Use `extends:` when two distinct entities share a shape. Use `overlay:` when |
| 165 | +the same entity's declaration is split across files (e.g., domain code in |
| 166 | +one file, persistence overlay in another). See |
| 167 | +[`loaders.md`](loaders.md) for the overlay merge semantics. |
| 168 | + |
| 169 | +## When to use abstracts vs. new subtypes vs. attr extensions |
| 170 | + |
| 171 | +This is the same decision as |
| 172 | +[`extending-with-providers.md § When to add a subtype vs. an attr vs. an |
| 173 | +abstract`](extending-with-providers.md#when-to-add-a-subtype-vs-an-attr-vs-an-abstract), |
| 174 | +viewed from the metadata author's side: |
| 175 | + |
| 176 | +### Use **abstracts + `extends`** when: |
| 177 | + |
| 178 | +- ✅ Multiple metadata instances should share a constraint set that changes |
| 179 | + together (slug shape across entities; audit columns across tables). |
| 180 | +- ✅ The shape is expressible in existing subtypes — you don't need new |
| 181 | + attrs or new node classes. |
| 182 | +- ✅ The author wants to opt into the shape per-instance (a Council `id` |
| 183 | + field extends `shortSlug`; a non-shareable Internal `id` doesn't). |
| 184 | +- ✅ Zero code changes needed in MO itself. |
| 185 | + |
| 186 | +### Use **attr extension** (via a provider's `registry.extend`) when: |
| 187 | + |
| 188 | +- ✅ You need a new **configuration knob** that applies wherever an existing |
| 189 | + subtype is used (`@toolName` on `template.output`; `@audit-trail` on |
| 190 | + `field.*`). |
| 191 | +- ✅ The attr is universally accepted — every instance of that subtype CAN |
| 192 | + set it, even if most don't. |
| 193 | +- ✅ Codegen needs to branch on the attr's presence (e.g., emit a tool-call |
| 194 | + wrapper if `@toolName` is set). |
| 195 | + |
| 196 | +### Use **new subtype registration** (via a provider's `registry.register`) when: |
| 197 | + |
| 198 | +- ✅ The new concept needs a **different runtime node class** (different |
| 199 | + `factory:` returning a subclass of `MetaData`). |
| 200 | +- ✅ It needs **different `childRules`** that can't be expressed as |
| 201 | + additional attrs (e.g., `source.rdb` accepts `@table`/`@kind`/`@role` |
| 202 | + but not arbitrary attrs). |
| 203 | +- ✅ The concept is so universal it deserves a name in the closed core |
| 204 | + vocabulary — added to one of the core providers, not consumer-local. |
| 205 | + |
| 206 | +### Decision flow |
| 207 | + |
| 208 | +``` |
| 209 | +Need to share shape across multiple instances? |
| 210 | + ├─ YES → Is the shape expressible with existing subtypes? |
| 211 | + │ ├─ YES → Abstract + extends ← lightest |
| 212 | + │ └─ NO → New attrs on existing subtype (extend) |
| 213 | + │ ├─ Configuration only? → registry.extend |
| 214 | + │ └─ Structural divergence? → registry.register |
| 215 | + └─ NO → Don't make it shareable. Author it directly. |
| 216 | +``` |
| 217 | +
|
| 218 | +The marginal cost goes up sharply at each step: |
| 219 | +**abstract (free) < attr-extension (cheap) < subtype-registration (expensive)**. |
| 220 | +A new subtype is a permanent commitment in every port's registry; an |
| 221 | +abstract is pure data the loader resolves at parse time. **Default to the |
| 222 | +lightest mechanism that fits.** |
| 223 | +
|
| 224 | +## What each port emits |
| 225 | +
|
| 226 | +Abstracts are **invisible to codegen output** — the loader merges them into |
| 227 | +their extenders before any generator runs. Each port emits exactly what it |
| 228 | +would emit for the merged shape, as if the author had written all the |
| 229 | +inherited children and attrs inline. |
| 230 | +
|
| 231 | +Cross-port reading: |
| 232 | +
|
| 233 | +```ts |
| 234 | +// TypeScript: the loader's effective view |
| 235 | +import { MetaDataLoader } from "@metaobjectsdev/metadata"; |
| 236 | +const loader = await MetaDataLoader.fromDirectory("app", "./metadata"); |
| 237 | +
|
| 238 | +const author = loader.metaObject("acme::Author"); |
| 239 | +// author.fields() includes "id" + "createdAt" + "updatedAt" (from BaseEntity) |
| 240 | +// AND "name" (declared directly). The abstract origin is transparent. |
| 241 | +``` |
| 242 | + |
| 243 | +```java |
| 244 | +// Java |
| 245 | +MetaObject author = loader.getMetaObjectByName("acme::Author"); |
| 246 | +// Same — author.getMetaFields() returns the merged set. |
| 247 | +``` |
| 248 | + |
| 249 | +```python |
| 250 | +# Python |
| 251 | +author = loader.meta_object("acme::Author") |
| 252 | +# author.fields() returns the merged set. |
| 253 | +``` |
| 254 | + |
| 255 | +The `extends:` chain itself is queryable via `node.superRef` (TS / Python) |
| 256 | +/ `node.getSuperRef()` (Java / C#) for tooling that needs to walk the |
| 257 | +inheritance chain. |
| 258 | + |
| 259 | +## Verified by |
| 260 | + |
| 261 | +- [`extends-single-level`](../../fixtures/conformance/extends-single-level/) — one |
| 262 | + level of inheritance, basic resolution |
| 263 | +- [`extends-multi-level`](../../fixtures/conformance/extends-multi-level/) — A |
| 264 | + extends B extends C, full chain flattening |
| 265 | +- [`extends-cross-file`](../../fixtures/conformance/extends-cross-file/) — forward |
| 266 | + reference across files |
| 267 | +- [`extends-abstract-base`](../../fixtures/conformance/extends-abstract-base/) — |
| 268 | + abstract entities aren't instantiable; codegen skips them |
| 269 | +- [`error-extends-nonexistent`](../../fixtures/conformance/error-extends-nonexistent/) |
| 270 | + — `ERR_UNKNOWN_EXTENDS` when the reference doesn't resolve |
| 271 | +- [`enum-abstract-extends`](../../fixtures/conformance/enum-abstract-extends/) — |
| 272 | + abstracts on enum fields specifically |
| 273 | + |
| 274 | +## See also |
| 275 | + |
| 276 | +- [`entities.md`](entities.md) — the canonical `BaseEntity` extends example |
| 277 | +- [`extending-with-providers.md`](extending-with-providers.md) — the |
| 278 | + provider-side reference (when abstracts aren't enough) |
| 279 | +- [`field-types.md`](field-types.md) — the field subtypes you can abstract over |
| 280 | +- [`loaders.md`](loaders.md) — load order, deferred resolution, overlay semantics |
0 commit comments