RFC 0019: Scalar and table macros
- Date: 2026-07-13
Summary
Section titled “Summary”DuckLake persists CREATE MACRO (scalar and table, with parameters,
defaults, and arity overloads) across three catalog tables:
ducklake_macro (the versioned identity row), ducklake_macro_impl (one
row per overload), and ducklake_macro_parameters (one row per parameter
per overload). moraine models all three as one versioned macro kind
keyed by macro_id — an exact sibling of view — with the impl and
parameter rows embedded in the value. Creation folds the child-table
inserts under their parent row in the staged batch; DROP MACRO is the
generic end-snapshot update; CREATE OR REPLACE is upstream’s own
drop-plus-create under a fresh macro_id, so no new lifecycle machinery
exists. The verb API gains create_macro/drop_macro, and conflict
classification learns the four macro tokens DuckLake writes into
changes_made.
- The full DuckLake macro SQL surface, verified live: scalar and table
macros, multiple arity overloads per macro, typed and untyped parameters,
parameter defaults,
CREATE OR REPLACE,DROP,DROP SCHEMA CASCADE, and time travel to a snapshot where a since-dropped macro was live. - Row-faithful storage: every column of the three tables maps to exactly one stored field; moraine never parses or interprets the macro body SQL.
- The ordering obligation met by construction. DuckLake reconstructs
macros with correlated
LIST({...})subqueries that have noORDER BY— it consumes impl rows inimpl_idorder and parameter rows incolumn_idorder as served. Embedding preserves insertion order, so the projections satisfy this without sort logic. - Verb-path parity with views (RFC 0003): macros are genuine catalog DDL, so the embedding API can express them.
Non-goals:
- Validating macro semantics. Body SQL, parameter types, and default values are stored and served verbatim; parsing them back into functions is DuckLake’s reader’s job.
- Physical deletion of orphaned impl/parameter rows. Upstream sweeps
them during snapshot expiry (a referential check against live
ducklake_macrorows). moraine’s embedding makes the sweep implicit — children go when their record goes — which is RFC 0007’s concern.
Background: the DuckLake contract
Section titled “Background: the DuckLake contract”Verified against the tracked DuckLake sources (target/ducklake-src).
- Writes.
CREATE MACROinserts oneducklake_macro(schema_id, macro_id, macro_name, begin_snapshot, end_snapshot=NULL)row, oneducklake_macro_impl(macro_id, impl_id, dialect, sql, type)row per overload, and oneducklake_macro_parameters(macro_id, impl_id, column_id, parameter_name, parameter_type, default_value, default_value_type)row per parameter — positional VALUES, table definition order, one staged batch. - Id allocation.
macro_iddraws from the snapshot’snext_catalog_id, the counter shared with schemas/tables/views (ducklake_transaction_state.cpp:450).impl_idis the 0-based position of the overload within the statement;column_idis the 0-based position of the parameter within its overload. Neither child id is global. - Literals.
typeis'scalar'or'table', uniform across one macro’s impls — the reader takesimplementations.front().typefor the whole macro.dialectis always'duckdb'.sqlis DuckDB’s canonical re-rendering of the body (expression orSELECTstatement), not the user’s original text. An untyped parameter storesparameter_type = 'unknown'; an absent default storesdefault_value = NULLwithdefault_value_type = 'unknown'. - Lifecycle.
DROP MACRO(andDROP SCHEMA CASCADE, which end-stamps each contained macro) issues onlyUPDATE ducklake_macro SET end_snapshot = <id> WHERE end_snapshot IS NULL AND macro_id IN (...). Impl and parameter rows are never updated and are not touched at drop — upstream leaves them orphaned until snapshot expiry sweeps rows whosemacro_idno longer exists.CREATE OR REPLACEis a drop plus a create under a newmacro_id(impl ids restart at 0). - Reads. Macros load inside the per-snapshot catalog query: visibility
is filtered on
ducklake_macro.begin/end_snapshotonly (time travel applies), and each visible macro pulls its impls and parameters through correlatedLIST({...})subqueries with noORDER BY— served row order is the reconstruction order. Ended macros’ impl rows must therefore remain servable for as long as any snapshot can see the macro. changes_madetokens:created_scalar_macro:"schema"."name",created_table_macro:"schema"."name",dropped_scalar_macro:<id>,dropped_table_macro:<id>.
Design
Section titled “Design”Keyspace and value
Section titled “Keyspace and value”One new versioned kind, keyed like view by its DuckLake-allocated id
(RFC 0002’s map gains its row; history mirroring applies):
| Kind | Key components | DuckLake table(s) |
|---|---|---|
macro |
macro_id |
ducklake_macro (+ ducklake_macro_impl, ducklake_macro_parameters embedded) |
macro_type stays per-implementation because that is where the table
carries it; uniformity across one macro’s impls is validated at write
(load-bearing for the reader), but the literal set is not — a future
DuckLake macro type must not be rejected here. Impl and parameter rows
embed because they have no independent lifecycle: never updated, dropped
only with (or, upstream, after) their macro.
The embedded children ride the version lifecycle for free: ending a macro
mirrors the whole record — children included — into history, so the
impl/parameter projections keep serving rows for macros that time travel
can still see, which is exactly the upstream orphans-until-expiry
semantics without the orphans.
Staged write: fold and lifecycle
Section titled “Staged write: fold and lifecycle”ducklake_macro_impl and ducklake_macro_parameters are fold-only staged
tables: a pre-pass drains their insert rows into groups keyed by
macro_id (parameters sub-grouped by impl_id); each ducklake_macro
insert consumes its group into one MacroValue. Validated, not trusted:
- a macro insert with zero impl rows (invisible to its own reader);
- impl or parameter groups left unconsumed (orphans);
- non-contiguous 0-based
impl_ids, or non-contiguous 0-basedcolumn_ids within an impl (upstream assigns positions; the reader consumes positionally, so gaps or duplicates would misalign reconstruction); - mixed
macro_typeacross one macro’s impls.
DROP MACRO needs nothing new: the shim’s generic
UPDATE ... SET end_snapshot path ends the record, and the diff engine
mirrors it to history. Replace arrives as that update plus a fresh insert
in the same batch, already ordered ends-before-inserts.
Verbs and change tracking
Section titled “Verbs and change tracking”The verb API (RFC 0003’s table) gains:
create_macro(schema_id, name, implementations) -> MacroId— allocates from the catalog id counter; the name must be free among live macros in the schema only (DuckDB catalogs functions separately from relations, so macros do not join the table/view namespace check);drop_macro(macro_id)— ends the live version.
DROP SCHEMA on the verb path keeps requiring an empty schema for
relations and now macros alike; the cascade is DuckLake’s (it end-stamps
each macro explicitly in the same commit, which the staged path already
expresses).
Operations and ChangeSet learn CreateMacro/DropMacro and the four
tokens above, classified as schema-changing like views. Today those tokens
parse as unknown, which is safe but maximally conservative in conflict
classification; recognition makes a concurrent macro create/drop classify
precisely instead of conflicting with everything.
Three dump projections: ducklake_macro (one row per record, current and
history), ducklake_macro_impl and ducklake_macro_parameters (flat-maps
of the embedded children of every record, history included — see above).
The shim’s always-empty stand-ins are replaced; the ducklake_macro spec
row gains the write kind, end-key (macro_id), and end-snapshot column;
the two child tables are insert-only.
Test obligations
Section titled “Test obligations”- Store: proptest roundtrip for
MacroValue; golden key vectors for themacrokind incurrentandhistory. - Verbs (integration): create a scalar macro with overloads and a
defaulted parameter and a table macro; drop; recreate under a new id;
time travel resolves
macro_by_id/macro_by_nameat each snapshot; name collisions rejected among live macros only. - Staged: happy-path fold; one test per constraint above; drop ends the
record with children intact in
history. - e2e (
ducklake_load.rs):CREATE MACROwith two overloads and a default parameter plus a table macro, called through DuckLake SQL;CREATE OR REPLACEre-binds;DROP MACROremoves it; a time-travel attach at the pre-drop snapshot calls the old macro; row-faithfulducklake_macro*projections verified through a standalonemoraine:attach, impl/parameter rows in served order.
Alternatives considered
Section titled “Alternatives considered”- Separate
macro_impl/macro_parameterskinds keyed(macro_id, impl_id[, column_id]). Rejected: no independent lifecycle (RFC 0002’s embed criterion), three kinds’ worth of keys/codecs/goldens, and the read side must reassemble exactly the nesting the embed already stores — plus the orphaned-children state upstream tolerates would become representable in the keyspace instead of impossible. - A macro-level
typefield (collapse the per-impl column). Rejected: not row-faithful — the servedducklake_macro_impl.typecolumn would be synthesized rather than stored, and a future upstream that varies type per impl would be unrepresentable. - Reusing the
viewkind with a discriminator (both are “SQL text entities”). Rejected: different tables, different columns, different namespaces, different child structure; the shared machinery (versioning, end-snapshot updates, diff) is already generic, so the merge would save nothing and cost a discriminator in every view read. - An
alter_macroverb. Rejected: DuckLake models none; replacement is drop + create under a new id, and inventing an in-place alteration would create version transitions upstream can never produce.