Sublimate. ProjectionStrategy behaviour
(Sublimate v0.1.0)
Copy Markdown
The contract a projection implements: what to derive from the source, and how to apply it to the destination table.
By implementing this behaviour, a consuming application or library defines a projection: a derivation that keeps a destination table synchronized with a source table as the source and its joined tables change. The strategy supplies everything specific to that projection - which tables its values are read from, the shape of the deltas table, the SQL each trigger writes, and the body of the merge that applies staged deltas to the destination.
For example, a strategy might derive a facet index - mapping each value to the set of source rows that have it - or a denormalized, searchable document table that flattens a source row together with its joined tables.
What a strategy defines
Beyond prepare/3 and chains/1, a strategy defines three things:
- The deltas table shape -
deltas_table_columns_sql/2anddeltas_table_unique_index_columns/1define the staging table the engine creates. - The trigger bodies -
declarations/3supplies any PL/pgSQL locals, and the branch callbacks (source_insert/2,source_update/2,source_delete/2,leaf_update/4,leaf_delete/4,link_insert/4,link_update/4,link_delete/4) return the SQL for each trigger branch. The engine supplies the surrounding scaffold - theDECLARE/BEGIN/END, theTG_OPbranching, and theRETURN- so a body is just the statements that stage the change. - The merge body -
merge_function_body/2returns the SQL that applies staged deltas to the destination.
Chain primitives
Callbacks that must reach affected source rows across joins build their SQL
around Sublimate.Projection's public chain primitives rather than walking the
chain themselves. Which chain a callback needs depends on the leaf table a value comes
from, so the primitives are invoked per value, from within the strategy.
Summary
Callbacks: deltas table shape
Column definitions for the deltas table - the part inside CREATE TABLE (...).
Columns forming the deltas table's unique index - what triggers' ON CONFLICT targets.
Callbacks: generation
The chains this projection maintains - one per field whose value is read across joins.
Runs once at the start of installation. Fetches or computes whatever the later callbacks need, and validates the strategy's config against the database.
Callbacks: merge
Body of the merge function: applies pending deltas into the destination table.
Sublimate.Projection supplies the CREATE FUNCTION wrapper and the BEGIN/END.
Callbacks: trigger bodies
DECLARE contents for a trigger function, or "" when the strategy needs no
locals there. Sublimate.Projection omits the DECLARE block entirely when empty.
Returns the SQL that records the removal of a joined (leaf) row's contribution when that row is deleted.
Body of a leaf table's AFTER UPDATE trigger function.
Body of an intermediate table's BEFORE DELETE trigger function.
Body of an intermediate table's TG_OP = 'INSERT' branch.
Body of an intermediate table's TG_OP = 'UPDATE' branch.
Body of the source trigger's TG_OP = 'DELETE' branch.
Body of the source trigger's TG_OP = 'INSERT' branch.
Body of the source trigger's TG_OP = 'UPDATE' branch.
Types
SQL for one trigger branch. May contain several statements: a facet update
emits a remove-old and an add-new. Sublimate.Projection splices it into the branch
as-is, between THEN and the RETURN.
One field's linear chain: the field it serves, the joins from the source to the table its value is read from, and that leaf table.
Opaque, strategy-specific data built by prepare/3.
Which trigger function's DECLARE block is being built.
Callbacks: deltas table shape
Column definitions for the deltas table - the part inside CREATE TABLE (...).
Columns forming the deltas table's unique index - what triggers' ON CONFLICT targets.
Callbacks: generation
@callback chains(projection_data :: struct()) :: [chain_spec()]
The chains this projection maintains - one per field whose value is read across joins.
The engine unions these into per-table triggers (see Sublimate.ChainUnion):
a table appearing in several chains, possibly in different roles, gets a
single trigger that serves all of them.
Fields whose value is on the source table need not appear here; the source trigger covers them. A strategy may include them with an empty chain or omit them.
Examples
def chains(strategy_data) do
strategy_data.facets
|> Enum.filter(&Map.get(&1, :join_table))
|> Enum.map(fn facet ->
%{
field: facet.facet_name,
joins: facet.joins,
leaf_table: facet.join_table
}
end)
end
@callback prepare( projection_data :: struct(), repo :: module(), postgrex_options :: keyword() ) :: {:ok, prepared()} | {:error, term()} | {:error, atom(), term()}
Runs once at the start of installation. Fetches or computes whatever the later callbacks need, and validates the strategy's config against the database.
Returning an error tuple aborts installation and is passed through to the caller unchanged.
Callbacks: merge
Body of the merge function: applies pending deltas into the destination table.
Sublimate.Projection supplies the CREATE FUNCTION wrapper and the BEGIN/END.
Callbacks: trigger bodies
@callback declarations( trigger_kind(), projection_data :: struct(), prepared() ) :: String.t()
DECLARE contents for a trigger function, or "" when the strategy needs no
locals there. Sublimate.Projection omits the DECLARE block entirely when empty.
Examples
def declarations(:source, _projection_data, _prepared),
do: "v_chunk_id integer;"
@callback leaf_delete( projection_data :: struct(), chain_spec(), join :: join_spec(), prepared() ) :: branch_sql()
Returns the SQL that records the removal of a joined (leaf) row's contribution when that row is deleted.
This callback's SQL runs from a BEFORE DELETE trigger the engine installs on
the leaf table - but only when the leaf's deletion is not already observable
through the source triggers.
The engine decides this from the leaf table's inbound foreign key:
- A reference that cascades (
ON DELETE CASCADE) or nullifies (ON DELETE SET NULL) propagates the deletion back to the source, where the sourcedelete/updatecallbacks already record it — so this callback is not wired up, avoiding a double-count. - A reference that does neither, or no foreign key, leaves the source's link dangling on delete, invisible to the source triggers - so this callback is wired up and is the sole recorder of the removal.
A strategy therefore does not decide whether leaf deletes are tracked;
it only supplies the SQL for the case where they are. The chain gives the join path
from source to leaf, and join is the leaf's own join, so the SQL can walk back
from the deleted leaf row (OLD) to the affected source rows.
@callback leaf_update( projection_data :: struct(), chain_spec(), join :: join_spec(), prepared() ) :: branch_sql()
Body of a leaf table's AFTER UPDATE trigger function.
@callback link_delete( projection_data :: struct(), chain_spec(), join :: join_spec(), prepared() ) :: branch_sql()
Body of an intermediate table's BEFORE DELETE trigger function.
@callback link_insert( projection_data :: struct(), chain_spec(), join :: join_spec(), prepared() ) :: branch_sql()
Body of an intermediate table's TG_OP = 'INSERT' branch.
@callback link_update( projection_data :: struct(), chain_spec(), join :: join_spec(), prepared() ) :: branch_sql()
Body of an intermediate table's TG_OP = 'UPDATE' branch.
@callback source_delete(projection_data :: struct(), prepared()) :: branch_sql()
Body of the source trigger's TG_OP = 'DELETE' branch.
@callback source_insert(projection_data :: struct(), prepared()) :: branch_sql()
Body of the source trigger's TG_OP = 'INSERT' branch.
@callback source_update(projection_data :: struct(), prepared()) :: branch_sql()
Body of the source trigger's TG_OP = 'UPDATE' branch.
Types
@type branch_sql() :: String.t()
SQL for one trigger branch. May contain several statements: a facet update
emits a remove-old and an add-new. Sublimate.Projection splices it into the branch
as-is, between THEN and the RETURN.
One field's linear chain: the field it serves, the joins from the source to the table its value is read from, and that leaf table.
A field whose value is on the source table itself has an empty joins list
and its leaf_table is the source; it contributes no joined-table triggers.
@type prepared() :: term()
Opaque, strategy-specific data built by prepare/3.
@type trigger_kind() :: :source | :leaf | :link
Which trigger function's DECLARE block is being built.