Linearity: BILINEAR (what does this mean?)
CREATE TABLE customers (id INT, name VARCHAR);
CREATE TABLE orders (customer_id INT, product VARCHAR, amount INT);
INSERT INTO customers VALUES (1, 'Alice'), (2, 'Bob'), (3, 'Charlie');
INSERT INTO orders VALUES (1, 'Widget', 100), (1, 'Gadget', 200);
CREATE MATERIALIZED VIEW customer_orders AS
SELECT c.name, o.product, o.amount
FROM customers c LEFT JOIN orders o ON c.id = o.customer_id;Initial result:
| name | product | amount |
|---|---|---|
| Alice | Widget | 100 |
| Alice | Gadget | 200 |
| Bob | NULL | NULL |
| Charlie | NULL | NULL |
-- Bob gets an order: his NULL row is replaced with real data
INSERT INTO orders VALUES (2, 'Bolt', 50);
PRAGMA refresh('customer_orders');| name | product | amount |
|---|---|---|
| Alice | Widget | 100 |
| Alice | Gadget | 200 |
| Bob | Bolt | 50 |
| Charlie | NULL | NULL |
Algebraic rule:
delta(L ⟕ R) uses inclusion-exclusion with LEFT→INNER demotion:
Term with only right-side deltas: L INNER JOIN delta(R) [demoted from LEFT]
Term with left-side deltas: delta(L) LEFT JOIN R [preserves LEFT semantics]
Cross-delta term: delta(L) LEFT JOIN delta(R)
The delta query uses inclusion-exclusion (same as inner join), with one key difference: terms where only right-side leaves have deltas demote LEFT JOIN to INNER JOIN. This prevents spurious NULL-extended rows — when you join the full left table against only the changed right rows, you only want rows that actually match.
The upsert uses partial recompute instead of counting-based consolidation:
- Find all affected left keys from the delta view.
- DELETE from the MV all rows matching those keys.
- Re-INSERT from the original LEFT JOIN query, filtered to those keys.
This avoids the complexity of tracking NULL↔non-NULL transitions incrementally. The cost is proportional to the number of affected left keys, not the total table size.
The parser injects a hidden openivm_left_key column containing the preserved-side join key (see Metadata Columns).
For grouped aggregates over LEFT/RIGHT JOINs, OpenIVM uses the Larson & Zhou MERGE path by default (openivm_left_join_merge=true) when the aggregate shape is safe. Unsafe aggregate shapes use affected-group recompute instead.
-- Term 1: new/deleted customers matched against current orders
-- Left-side has deltas → keep LEFT JOIN semantics
-- New customers with no orders correctly produce NULL-extended rows
scan_0 = SELECT id, name, mul FROM openivm_delta_customers WHERE ts >= '...'
scan_1 = SELECT customer_id, product, amount FROM orders
join_2 = scan_0 LEFT JOIN scan_1 ON (id = customer_id)
-- Term 2: current customers matched against new/deleted orders
-- Only right-side has deltas → demote LEFT→INNER
-- We only want rows where the new order actually matches a customer
-- (not every customer NULL-extended against the empty right side)
scan_4 = SELECT id, name FROM customers
scan_5 = SELECT customer_id, product, amount, mul FROM openivm_delta_orders WHERE ts >= '...'
join_6 = scan_4 INNER JOIN scan_5 ON (id = customer_id)
-- Term 3: openivm_delta_customers ⨝ openivm_delta_orders (cross-delta correction)
-- Left-side has deltas → keep LEFT JOIN semantics
-- Combined multiplicity = (-1)^(k-1) * w1 * w2 with k=2 → (-1) * w1 * w2
-- (Möbius inclusion-exclusion sign × Z-set bilinear product — see inner-join.md)
join_11 = openivm_delta_customers LEFT JOIN openivm_delta_orders ON (id = customer_id)
projection_12 = SELECT ..., (-1) * mul1 * mul2 AS combined_mul FROM join_11
-- Combine all terms into a single delta stream
union_13 = term1 UNION ALL term2 UNION ALL term3
INSERT INTO openivm_delta_customer_orders (..., openivm_multiplicity)
SELECT ... FROM union_13;-- Step 1: find all left keys affected by this refresh cycle
-- These are the customers whose rows may have changed
-- Step 2: delete those customers' entire set of rows from the MV
DELETE FROM customer_orders WHERE (openivm_left_key) IN (
SELECT DISTINCT openivm_left_key FROM openivm_delta_customer_orders
WHERE openivm_timestamp >= '{ts}'
);
-- Step 3: re-insert from the original query, filtered to affected keys only
-- This correctly handles all transitions:
-- NULL→value (customer got their first order)
-- value→NULL (customer's last order was deleted)
-- value→value (order amount changed)
INSERT INTO customer_orders
SELECT * FROM (
SELECT c.name, o.product, o.amount, c.id AS openivm_left_key
FROM customers c LEFT JOIN orders o ON c.id = o.customer_id
) openivm_lj
WHERE (openivm_left_key) IN (
SELECT DISTINCT openivm_left_key FROM openivm_delta_customer_orders
WHERE openivm_timestamp >= '{ts}'
);DuckDB rewrites RIGHT JOIN to LEFT JOIN internally (swapping the table order). OpenIVM handles them identically. The parser extracts the preserved-side key from the right table before DuckDB applies the rewrite.
-- This RIGHT JOIN:
SELECT p.name, s.qty FROM sales s RIGHT JOIN products p ON s.product_id = p.id;
-- Is maintained the same way as:
SELECT p.name, s.qty FROM products p LEFT JOIN sales s ON p.id = s.product_id;You can combine INNER and LEFT joins in the same query:
CREATE MATERIALIZED VIEW emp_bonus AS
SELECT e.name, d.dept_name, b.bonus
FROM employees e
INNER JOIN departments d ON e.dept_id = d.id
LEFT JOIN bonuses b ON e.id = b.emp_id;The inclusion-exclusion generates 2^3 - 1 = 7 terms. Terms where only bonuses (the right side of the LEFT JOIN) has deltas will demote the LEFT JOIN to INNER for that term.
- Partial recompute is proportional to the number of affected left keys, not the number of changed rows. If many left keys are affected, the recompute may scan a large portion of the base tables.
AGGREGATE_GROUPviews with LEFT JOIN sources use MERGE only for supported aggregate shapes. Ifopenivm_left_join_merge=false, or if the aggregate shape is unsafe, OpenIVM uses affected-group recompute.- LEFT JOIN with a computed aggregate argument (anything other than a plain bound column reference —
COALESCE,CASE, an arithmetic expression, a constant) is also forced onto the group-recompute path even when the delta is insert-only. The Larson-Zhou MERGE template doesn't correctly handle the case where a new right-side row converts an existing NULL-padded row into a match for these expressions, so the classifier setshas_minmax=true(overloaded as a "use group-recompute" signal) and skips the MERGE fast path. Seesrc/upsert/refresh_compiler.cpp:289–315. - Maximum 16 tables in the join (same limit as inner join).
| Setting | Default | Description |
|---|---|---|
openivm_left_join_merge |
true |
Use incremental MERGE for supported LEFT/RIGHT JOIN aggregate views |