Mixins
When several tables share the same columns, a mixin defines the group once and each table spreads it: mixin holds member definitions — fields, indexes, and events — and ...name; copies them into the table at compile time.
The mechanism
namespace acme;
database shop;
mixin timestamped {
created_at: datetime { default: time::now(); };
updated_at: datetime { default: time::now(); };
};
table post schemafull {
...timestamped;
title: string;
};
table comment schemafull {
...timestamped;
body: string;
};DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
DEFINE TABLE OVERWRITE `comment` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `comment` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updated_at ON `comment` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE body ON `comment` TYPE string;
The database never sees a mixin. At emission each spread expands to the members as if you had written them in the table — in place, at the spread's position. A member is an ordinary nested definition, so everything in the fields page applies to field members, and an index or event member follows its own grammar — on: never appears on a member; the spreading table becomes the on at expansion:
namespace acme;
database shop;
mixin slug_source {
slug: string;
index slug_u {
fields: slug;
concurrently;
};
event slug_touched {
when: slug != '';
then: 1;
};
};
table article schemafull {
...slug_source;
body: string;
};DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE article TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE slug ON article TYPE string;
DEFINE INDEX OVERWRITE slug_u ON article FIELDS slug CONCURRENTLY;
DEFINE EVENT OVERWRITE slug_touched ON article WHEN slug != '' THEN 1;
DEFINE FIELD OVERWRITE body ON article TYPE string;
Explicit members overshadow
An explicit field, index, or event with the same name as a member replaces that member in that table — completely, wherever the two sit in the block, with no clause merging:
namespace acme;
database shop;
mixin stamp {
created_at: datetime { default: time::now(); };
updated_at: datetime;
};
table invoice schemafull {
...stamp;
// the explicit field wins on this table — the mixin's
// `created_at` (with its default) never lands here:
created_at: datetime;
amount: decimal;
};DEFINE NAMESPACE OVERWRITE acme;
USE NS acme;
DEFINE DATABASE OVERWRITE shop;
USE NS acme DB shop;
DEFINE TABLE OVERWRITE invoice TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE updated_at ON invoice TYPE datetime;
DEFINE FIELD OVERWRITE created_at ON invoice TYPE datetime;
DEFINE FIELD OVERWRITE amount ON invoice TYPE decimal;
A field x and an index x never collide — fields, indexes, and events override per kind. Other tables spreading the same mixin are unaffected — overriding is per-table.
Conflicts between spreads resolve last-wins
Two spreads carrying the same member name compile to the LATER spread's definition — the earlier one is skipped at expansion, never defined-then-overwritten. A silent winner between two third-party mixins is easy to miss, so the linter flags every such shadow:
namespace acme;
database shop;
mixin short_id {
id_hint: int;
};
mixin long_id {
id_hint: string;
};
table post schemafull {
...short_id;
// `id_hint` compiles to `string` — long_id is the later spread:
...long_id;
};SDL-L0022 (spec/mixin.md)
! table `post` in database `acme/shop`: the `long_id` spread's field `id_hint` shadows `short_id`'s — the later spread wins at expansion
,-[15:3]
14 | // `id_hint` compiles to `string` — long_id is the later spread:
15 | ...long_id;
: ^^^^^|^^^^
: `-- the shadowing spread
16 | };
`----
help: the later spread's field wholly wins at expansion — rename one member, or add an explicit field on the table to decide
An explicit member decides instead — wherever it sits — and its overshadow is deliberate, so it stays unlinted. Spreading the same mixin twice is still an error (SDL-E1064).
The rules mixins play by
- Top level only, resolved document-wide. A
mixinis a top-level statement and a spread lives in a non-viewtableblock; position is free — a spread resolves against every mixin the document defines, above or below it, in the file or composed into it. - No doc comments.
///is rejected on the statement (it emits nothing, so there is no COMMENT to hang one on) and on members (the nested-statement rule). - Conflicts resolve last-wins. Between spreads, the later spread's member wholly wins (
SDL-L0022advises on the shadow); an explicit member decides position-independently; spreading the same mixin twice is an error. - An unspread mixin emits nothing. The linter flags it (
SDL-L0021) — dead weight in the file. - One key per body. The same member key twice in one body — a mixin's or a table's — is an error (
SDL-E1067): the engine's last-wins would silently hide one of them. normalizekeeps your spreads. Adopting a database into a mixin-carrying file merges per member: a member whose database state matches the definition stays covered by its spread, and a diverged one materializes as an explicit statement on the table — the override you would have written by hand:
mixin stamp {
created_at: datetime;
updated_at: datetime;
};
table post schemafull {
...stamp;
title: string;
};DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime;
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime;
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
namespace doc;
database doc;
mixin stamp {
field created_at: datetime;
field updated_at: datetime;
};
table `post` {
schemafull;
field title: string;
...stamp;
};
The info panel's canonical readback is the same file — the spread survives the round trip. When the database's created_at has drifted (a default added by hand), surform normalize materializes the diverged member as the explicit override:
mixin stamp {
created_at: datetime;
updated_at: datetime;
};
table post schemafull {
created_at: datetime {
default: time::now();
};
title: string;
...stamp;
};DEFINE TABLE OVERWRITE `post` TYPE NORMAL SCHEMAFULL;
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE title ON `post` TYPE string;
DEFINE FIELD OVERWRITE updated_at ON `post` TYPE datetime;
The spread survives — updated_at is still covered by it — and the override is exactly the overshadow rule performing the adoption. migrate generate reports the drift on the member itself, never on the mixin:
mixin stamp {
created_at: datetime;
updated_at: datetime;
};
table post schemafull {
...stamp;
title: string;
};DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime DEFAULT time::now();1 finding - 1 define, 0 removals, 0 data-migration slots, 0 staged tables
~ db:doc/doc field created_at
default: (only in database) `default: time::now();`
-- generated by `surform migrate generate` - the
-- convergence script.
-- Re-runs are safe: OVERWRITE redefines; IF EXISTS
-- no-ops on absence.
-- Lines marked SURFORM WARNING destroy data;
-- SURFORM REQUIRED marks fill-in data-migration
-- slots; SURFORM NOTE lines are advice.
-- summary: 1 finding - 1 define, 0 removals, 0
-- data-migration slots, 0 staged tables, 1 frame
USE NS doc DB doc;
-- ------------------------------------------------------------
-- ## Scope db:doc/doc
-- section: db:doc/doc (1 define, 0 removals)
BEGIN TRANSACTION;
-- ------------------------------------------------------------
-- ## Defines - the state the schema file describes
--
-- Each define carries OVERWRITE: a re-run redefines it.
DEFINE FIELD OVERWRITE created_at ON `post` TYPE datetime;
COMMIT;
The reference errors
namespace acme;
database shop;
table post schemafull {
...timestamped;
title: string;
};SDL-E1061 (spec/mixin.md)
x spread names no mixin of the document: `timestamped`
,-[5:3]
4 | table post schemafull {
5 | ...timestamped;
: ^^^^^^^|^^^^^^
: `-- no mixin of this name in the document
6 | title: string;
`----
help: define the mixin anywhere in this document — spreads resolve against the whole composition, any file, any position; a spread that names nothing is an error, never a silent empty expansion
The spread names no mixin of the document (SDL-E1062 duplicate name, across composed files too; SDL-E1064 double spread, SDL-E1065 a spread in the wrong block, and SDL-E1067 a duplicate member key within one body complete the family — see the mixin spec).
Where next
- Fields — the grammar a mixin's members follow.
- The linter — the unspread-mixin advisory.