Skip to main content

rivet/load/
cdc.rs

1//! CDC current-state dedup view.
2//!
3//! rivet CDC appends a change log to `<table>__changes` (free `LOAD DATA` /
4//! billed `COPY`); a **view** collapses it to current state. The collapse is one
5//! `ROW_NUMBER` window that keeps the latest change per PK:
6//!
7//! ```sql
8//! ROW_NUMBER() OVER (PARTITION BY <pk> ORDER BY <total change order> DESC) = 1
9//! ```
10//!
11//! A deleted row is kept as a **tombstone**, not dropped: the winning change's
12//! `__op` becomes a boolean `__is_deleted` column, so the row survives with its
13//! last-known values and an auditable delete flag — a delete is never a silent
14//! disappearance. Live current state is `WHERE NOT __is_deleted`.
15//!
16//! The **total change order** is `(__pos, __seq)`:
17//! - `__pos` is the commit position — it orders changes *across* transactions,
18//!   but every change in one transaction shares it (verified live on all three
19//!   engines: 8000 updates of one PK in one transaction → a single `__pos`).
20//! - `__seq` (OSS `TxnSeq`) is the intra-transaction ordinal — it breaks that
21//!   tie. Without it the dedup picked an arbitrary row (live: `counter = 1` for
22//!   a row whose committed value was `8000`).
23//!
24//! `__pos` is a JSON string whose shape is per source engine, so its parse (the
25//! part before `__seq`) is engine-specific — see [`SourceEngine`]. The parse
26//! functions are also *warehouse*-specific ([`Warehouse`]): BigQuery reads JSON
27//! with `JSON_VALUE`, Snowflake with `PARSE_JSON(...):path`.
28
29use crate::types::target::{TargetColumnSpec, TargetStatus};
30
31/// The source engine a change log came from — selects how `__pos` is parsed
32/// into a sortable key.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SourceEngine {
35    /// `{"file":"binlog.000047","pos":10840633}` — order by file, then numeric pos.
36    MySql,
37    /// `{"lsn":"3D/484A4908"}` — hex `hi/lo`; zero-pad each half to fixed width
38    /// so a lexical compare equals a numeric one (raw `"9" > "10"` otherwise).
39    Postgres,
40    /// `{"lsn":"0000002d000000d80194"}` — fixed-width hex; lexical == numeric.
41    SqlServer,
42    /// `{"_data":"826A4E0001..."}` — the change-stream resume token. `_data` is
43    /// an order-preserving hex keystring (compared lexically, like SQL Server's
44    /// lsn); the primary key is the document's `_id` column. See
45    /// `source::cdc::validate::parse_pos`, which keys Mongo `__pos` on `_data`.
46    Mongo,
47}
48
49/// The warehouse the view is defined in — selects the JSON-parse dialect and
50/// the `SELECT * EXCEPT/EXCLUDE` keyword.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Warehouse {
53    BigQuery,
54    Snowflake,
55}
56
57impl Warehouse {
58    /// The `SELECT *`-minus-columns keyword: BigQuery spells it `EXCEPT`,
59    /// Snowflake `EXCLUDE`.
60    fn except_keyword(self) -> &'static str {
61        match self {
62            Warehouse::BigQuery => "EXCEPT",
63            Warehouse::Snowflake => "EXCLUDE",
64        }
65    }
66
67    /// Quote a `project.dataset.table` / `db.schema.table` identifier for this
68    /// warehouse. BigQuery back-ticks the whole path; Snowflake leaves it bare
69    /// (matching the unquoted identifiers the Snowflake loader creates, so a
70    /// lowercase name resolves to the same upper-cased object) — a back-tick
71    /// there is a syntax error.
72    fn quote_fqtn(self, fqtn: &str) -> String {
73        match self {
74            Warehouse::BigQuery => format!("`{fqtn}`"),
75            Warehouse::Snowflake => fqtn.to_string(),
76        }
77    }
78
79    /// Quote a single column identifier for the view's `PARTITION BY`/`ORDER BY`.
80    /// BigQuery back-ticks (case-preserving, so a reserved-word column like
81    /// `order`/`end` is safe). Snowflake is left BARE — the loader creates its
82    /// columns unquoted (upper-cased), and a case-sensitive `"col"` there would
83    /// miss them; a reserved-word column already fails at the Snowflake `__changes`
84    /// DDL, a narrower pre-existing limitation.
85    fn quote_ident(self, col: &str) -> String {
86        match self {
87            Warehouse::BigQuery => format!("`{col}`"),
88            Warehouse::Snowflake => col.to_string(),
89        }
90    }
91}
92
93impl SourceEngine {
94    /// The `ORDER BY` expressions (most-significant first) that totally-order
95    /// the change log for this engine in `warehouse`'s SQL dialect: the parsed
96    /// commit position, then `__seq`.
97    fn order_exprs(self, warehouse: Warehouse) -> Vec<String> {
98        let pos: Vec<String> = match (warehouse, self) {
99            // ── BigQuery: JSON_VALUE + SPLIT(...)[OFFSET(n)] + CAST(... AS INT64)
100            (Warehouse::BigQuery, SourceEngine::MySql) => vec![
101                "JSON_VALUE(__pos,'$.file')".into(),
102                "CAST(JSON_VALUE(__pos,'$.pos') AS INT64)".into(),
103            ],
104            (Warehouse::BigQuery, SourceEngine::Postgres) => vec![
105                "LPAD(SPLIT(JSON_VALUE(__pos,'$.lsn'),'/')[OFFSET(0)],8,'0')".into(),
106                "LPAD(SPLIT(JSON_VALUE(__pos,'$.lsn'),'/')[OFFSET(1)],8,'0')".into(),
107            ],
108            (Warehouse::BigQuery, SourceEngine::SqlServer) => {
109                vec!["JSON_VALUE(__pos,'$.lsn')".into()]
110            }
111            (Warehouse::BigQuery, SourceEngine::Mongo) => {
112                vec!["JSON_VALUE(__pos,'$._data')".into()]
113            }
114            // ── Snowflake: PARSE_JSON(__pos):path::type + SPLIT_PART(...,n)
115            (Warehouse::Snowflake, SourceEngine::MySql) => vec![
116                "PARSE_JSON(__pos):file::string".into(),
117                "PARSE_JSON(__pos):pos::integer".into(),
118            ],
119            (Warehouse::Snowflake, SourceEngine::Postgres) => vec![
120                "LPAD(SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1),8,'0')".into(),
121                "LPAD(SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2),8,'0')".into(),
122            ],
123            (Warehouse::Snowflake, SourceEngine::SqlServer) => {
124                vec!["PARSE_JSON(__pos):lsn::string".into()]
125            }
126            (Warehouse::Snowflake, SourceEngine::Mongo) => {
127                vec!["PARSE_JSON(__pos):_data::string".into()]
128            }
129        };
130        // `__seq` is always the final, least-significant tiebreak: it orders
131        // changes that share a commit position (same transaction).
132        pos.into_iter()
133            .chain(std::iter::once("__seq".to_string()))
134            .collect()
135    }
136}
137
138/// The three CDC meta columns rivet's change log carries, typed for
139/// `warehouse`. rivet CDC writes `__op` (Utf8), `__pos` (Utf8), `__seq` (Int64)
140/// ahead of the after-image columns (OSS `cdc::sink`); `rivet check` reports
141/// only the data columns, so the loader must prepend these to build the
142/// `<table>__changes` schema.
143/// The reserved CDC meta-column vocabulary — the one home for `__op/__pos/__seq`
144/// so a warehouse adapter can't drift from the view builder. Both `bigquery` and
145/// `snowflake` delegate here.
146pub(crate) fn is_meta_column(name: &str) -> bool {
147    matches!(name, "__op" | "__pos" | "__seq")
148}
149
150pub fn meta_column_specs(warehouse: Warehouse) -> Vec<TargetColumnSpec> {
151    let (str_ty, int_ty) = match warehouse {
152        Warehouse::BigQuery => ("STRING", "INT64"),
153        Warehouse::Snowflake => ("VARCHAR", "INTEGER"),
154    };
155    ["__op", "__pos"]
156        .into_iter()
157        .map(|name| meta_spec(name, str_ty))
158        .chain(std::iter::once(meta_spec("__seq", int_ty)))
159        .collect()
160}
161
162fn meta_spec(name: &str, ty: &str) -> TargetColumnSpec {
163    TargetColumnSpec {
164        column_name: name.into(),
165        target_type: ty.into(),
166        autoload_type: String::new(),
167        status: TargetStatus::Ok,
168        note: None,
169        cast_sql: None,
170    }
171}
172
173/// The soft-delete flag column the view exposes: `true` when the latest change
174/// for a PK was a delete. In rivet's reserved `__` namespace so it can never
175/// collide with a source column (a plain `is_deleted` might).
176pub const DELETE_FLAG_COLUMN: &str = "__is_deleted";
177
178/// Build the current-state dedup view over a `<table>__changes` log for
179/// `warehouse`. `pk` is the change log's primary key column(s); `engine`
180/// selects the `__pos` parse. The view is free to define; reading it scans
181/// `__changes` (billed), kept cheap by clustering the log on `pk`.
182///
183/// **Soft delete.** The view keeps the latest change per PK unconditionally and
184/// projects the winning row's `__op` into a boolean [`DELETE_FLAG_COLUMN`]
185/// (`__op = 'delete'`), rather than dropping deleted rows. A tombstone therefore
186/// survives with its last-known column values — an auditable delete instead of
187/// a silent disappearance. Consumers read live state with
188/// `WHERE NOT __is_deleted`.
189///
190/// **Backfill.** `cdc.initial: snapshot` preexisting rows load from a plain
191/// full-snapshot parquet, so their `__op`/`__pos` are NULL in `__changes`. The
192/// flag is `COALESCE(.. , FALSE)` (a NULL `__op` is a live snapshot insert, not a
193/// delete — otherwise `WHERE NOT __is_deleted` drops the whole backfill), and the
194/// order ranks NULL `__pos` last (see below) so a later change always wins.
195///
196/// The subquery + `__rn` structure (rather than a `QUALIFY`) is deliberate: the
197/// flag must reflect the *winning* row per PK, computed **after** `ROW_NUMBER`.
198/// Note `__op` is both dropped from the `*` expansion and referenced by the flag
199/// expression — both BigQuery `EXCEPT` and Snowflake `EXCLUDE` allow that (the
200/// exclusion only affects `*`, not an explicit reference).
201pub fn dedup_view_sql(
202    warehouse: Warehouse,
203    view_fqtn: &str,
204    changes_fqtn: &str,
205    pk: &[&str],
206    engine: SourceEngine,
207) -> String {
208    let partition = quote_partition(warehouse, pk);
209    // `initial: snapshot` backfill rows load as a plain full-snapshot parquet —
210    // no `__op`/`__pos`/`__seq` — so they land in `__changes` with those NULL.
211    // `__pos IS NOT NULL DESC` FIRST in the order ranks any real change above the
212    // snapshot baseline deterministically across dialects: without it BigQuery
213    // sorts a NULL `__pos` last (snapshot loses — correct by luck) but Snowflake
214    // sorts it first (snapshot would WIN a later update → stale current state).
215    let order = std::iter::once("__pos IS NOT NULL".to_string())
216        .chain(engine.order_exprs(warehouse))
217        .map(|e| format!("{e} DESC"))
218        .collect::<Vec<_>>()
219        .join(", ");
220    build_dedup_view(
221        warehouse,
222        view_fqtn,
223        changes_fqtn,
224        &partition,
225        &order,
226        "COALESCE(__op = 'delete', FALSE)",
227    )
228}
229
230/// Quote each PK column for `warehouse` and join for a `PARTITION BY`.
231fn quote_partition(warehouse: Warehouse, pk: &[&str]) -> String {
232    pk.iter()
233        .map(|c| warehouse.quote_ident(c))
234        .collect::<Vec<_>>()
235        .join(", ")
236}
237
238/// The shared current-state view envelope: keep the winning row per PK
239/// (`ROW_NUMBER … WHERE __rn = 1`), drop the meta columns from `*`, and project
240/// `delete_flag` into [`DELETE_FLAG_COLUMN`]. The two public builders differ only
241/// in `order_by` (how "winning" is decided) and `delete_flag` — everything else,
242/// including the `EXCEPT`/`EXCLUDE` dialect keyword and identifier quoting, lives
243/// here so CDC and incremental can never drift on the view shape.
244fn build_dedup_view(
245    warehouse: Warehouse,
246    view_fqtn: &str,
247    changes_fqtn: &str,
248    partition: &str,
249    order_by: &str,
250    delete_flag: &str,
251) -> String {
252    format!(
253        "CREATE OR REPLACE VIEW {view} AS\n\
254         SELECT * {except} (__op, __pos, __seq, __rn),\n\
255         \x20      {delete_flag} AS {flag}\n\
256         FROM (\n\
257         \x20 SELECT *, ROW_NUMBER() OVER (\n\
258         \x20   PARTITION BY {partition}\n\
259         \x20   ORDER BY {order_by}\n\
260         \x20 ) AS __rn\n\
261         \x20 FROM {changes}\n\
262         )\n\
263         WHERE __rn = 1;",
264        view = warehouse.quote_fqtn(view_fqtn),
265        changes = warehouse.quote_fqtn(changes_fqtn),
266        except = warehouse.except_keyword(),
267        flag = DELETE_FLAG_COLUMN,
268    )
269}
270
271/// Build the current-state dedup view for an **incremental** load's change log.
272/// Unlike CDC ([`dedup_view_sql`]), an incremental delta has no `__op`/`__pos`/
273/// `__seq` (the change log reuses the CDC append so those columns exist but are
274/// NULL): current state is simply the row with the greatest `cursor_column` per
275/// PK. Incremental can't observe deletes, so [`DELETE_FLAG_COLUMN`] is a constant
276/// `FALSE` — the view SHAPE matches CDC so downstream reads `WHERE NOT
277/// __is_deleted` uniformly across both modes.
278pub fn inc_dedup_view_sql(
279    warehouse: Warehouse,
280    view_fqtn: &str,
281    changes_fqtn: &str,
282    pk: &[&str],
283    cursor_column: &str,
284) -> String {
285    let partition = quote_partition(warehouse, pk);
286    // `<cursor> IS NOT NULL DESC` FIRST — the same NULL-baseline guard the CDC
287    // view uses for `__pos` (see dedup_view_sql). A nullable cursor lands NULL
288    // rows in `<table>__changes` (the first incremental run has no cursor bound,
289    // so it extracts every row, NULL cursor included). Without the guard the
290    // order is a bare `<cursor> DESC`, and Snowflake sorts NULLs FIRST in DESC —
291    // so a NULL-cursor baseline row would win ROW_NUMBER=1 and HIDE a later
292    // non-NULL-cursor update (stale current state; BigQuery sorts NULLs last, so
293    // it was correct only by luck). Ranking real cursor values above NULL makes
294    // the dedup deterministic across both dialects.
295    let cur = warehouse.quote_ident(cursor_column);
296    let order = format!("{cur} IS NOT NULL DESC, {cur} DESC");
297    build_dedup_view(
298        warehouse,
299        view_fqtn,
300        changes_fqtn,
301        &partition,
302        &order,
303        "FALSE",
304    )
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    const WAREHOUSES: [Warehouse; 2] = [Warehouse::BigQuery, Warehouse::Snowflake];
312    const ENGINES: [SourceEngine; 4] = [
313        SourceEngine::MySql,
314        SourceEngine::Postgres,
315        SourceEngine::SqlServer,
316        SourceEngine::Mongo,
317    ];
318
319    #[test]
320    fn every_warehouse_and_engine_orders_by_seq_last_and_drops_meta_columns() {
321        for wh in WAREHOUSES {
322            for engine in ENGINES {
323                let sql = dedup_view_sql(wh, "p.d.orders", "p.d.orders__changes", &["id"], engine);
324                // The intra-transaction tiebreak is present and LAST in the order.
325                assert!(sql.contains("__seq DESC"), "{wh:?}/{engine:?}: {sql}");
326                let order = sql.split("ORDER BY").nth(1).unwrap();
327                let seq_at = order.find("__seq DESC").unwrap();
328                let pos_at = order.find("__pos").unwrap();
329                assert!(
330                    pos_at < seq_at,
331                    "{wh:?}/{engine:?}: __pos must sort before __seq"
332                );
333                // Soft delete: latest row per PK is kept (no delete filter); the
334                // winning `__op` becomes the boolean tombstone flag.
335                assert!(sql.contains("WHERE __rn = 1;"), "{wh:?}/{engine:?}: {sql}");
336                assert!(
337                    !sql.contains("!= 'delete'"),
338                    "{wh:?}/{engine:?}: deletes must NOT be dropped"
339                );
340                assert!(
341                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
342                    "{wh:?}/{engine:?}"
343                );
344                assert!(
345                    sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
346                    "{wh:?}/{engine:?}"
347                );
348            }
349        }
350    }
351
352    #[test]
353    fn bigquery_uses_json_value_and_except() {
354        let sql = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["id"], SourceEngine::MySql);
355        assert!(sql.contains("JSON_VALUE(__pos,'$.file') DESC"));
356        assert!(sql.contains("CAST(JSON_VALUE(__pos,'$.pos') AS INT64) DESC"));
357        assert!(sql.contains("EXCEPT (__op, __pos, __seq, __rn)"));
358    }
359
360    #[test]
361    fn snowflake_uses_parse_json_and_exclude() {
362        let sql = dedup_view_sql(Warehouse::Snowflake, "v", "c", &["id"], SourceEngine::MySql);
363        assert!(sql.contains("PARSE_JSON(__pos):file::string DESC"));
364        assert!(sql.contains("PARSE_JSON(__pos):pos::integer DESC"));
365        assert!(sql.contains("EXCLUDE (__op, __pos, __seq, __rn)"));
366    }
367
368    #[test]
369    fn postgres_zero_pads_each_lsn_half_per_dialect() {
370        let bq = dedup_view_sql(
371            Warehouse::BigQuery,
372            "v",
373            "c",
374            &["id"],
375            SourceEngine::Postgres,
376        );
377        assert!(bq.contains("[OFFSET(0)],8,'0')"));
378        assert!(bq.contains("[OFFSET(1)],8,'0')"));
379        let sf = dedup_view_sql(
380            Warehouse::Snowflake,
381            "v",
382            "c",
383            &["id"],
384            SourceEngine::Postgres,
385        );
386        // Snowflake splits the LSN with SPLIT_PART (1-indexed), not OFFSET.
387        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1)"));
388        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2)"));
389    }
390
391    #[test]
392    fn sqlserver_uses_fixed_width_lsn_directly() {
393        let bq = dedup_view_sql(
394            Warehouse::BigQuery,
395            "v",
396            "c",
397            &["id"],
398            SourceEngine::SqlServer,
399        );
400        assert!(bq.contains("JSON_VALUE(__pos,'$.lsn') DESC, __seq DESC"));
401        let sf = dedup_view_sql(
402            Warehouse::Snowflake,
403            "v",
404            "c",
405            &["id"],
406            SourceEngine::SqlServer,
407        );
408        assert!(sf.contains("PARSE_JSON(__pos):lsn::string DESC, __seq DESC"));
409    }
410
411    #[test]
412    fn mongo_orders_by_resume_token_data_and_partitions_by_id() {
413        // Mongo's `_id` is the dedup PK; `__pos` orders on the `_data` resume
414        // token (single string key + `__seq` tiebreak, like SQL Server's lsn).
415        let bq = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["_id"], SourceEngine::Mongo);
416        assert!(bq.contains("JSON_VALUE(__pos,'$._data') DESC, __seq DESC"));
417        assert!(bq.contains("PARTITION BY `_id`"));
418        let sf = dedup_view_sql(
419            Warehouse::Snowflake,
420            "v",
421            "c",
422            &["_id"],
423            SourceEngine::Mongo,
424        );
425        assert!(sf.contains("PARSE_JSON(__pos):_data::string DESC, __seq DESC"));
426        // Soft-delete parity holds for Mongo too.
427        assert!(sf.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"));
428    }
429
430    #[test]
431    fn snapshot_backfill_rows_are_live_and_rank_oldest_on_every_dialect() {
432        // `cdc.initial: snapshot` rows carry NULL __op/__pos in `__changes`. The
433        // view must (1) read a NULL __op as a live insert — not a NULL flag that
434        // `WHERE NOT __is_deleted` silently drops — and (2) rank a NULL __pos below
435        // any real change on BOTH dialects, not rely on the engine's NULL-order
436        // default (BigQuery NULLS-last vs Snowflake NULLS-first would disagree).
437        for wh in WAREHOUSES {
438            for engine in ENGINES {
439                let sql = dedup_view_sql(wh, "p.d.t", "p.d.t__changes", &["id"], engine);
440                assert!(
441                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
442                    "{wh:?}/{engine:?}: NULL __op must read as not-deleted (live): {sql}"
443                );
444                // The null-rank guard is the FIRST, most-significant order key.
445                let order = sql.split("ORDER BY").nth(1).unwrap();
446                assert!(
447                    order.contains("__pos IS NOT NULL DESC"),
448                    "{wh:?}/{engine:?}: NULL __pos must be ranked, not left to engine default: {sql}"
449                );
450                let guard_at = order.find("__pos IS NOT NULL DESC").unwrap();
451                let parse_at = order.find(if wh == Warehouse::BigQuery {
452                    "JSON_VALUE"
453                } else {
454                    "PARSE_JSON"
455                });
456                if let Some(parse_at) = parse_at {
457                    assert!(
458                        guard_at < parse_at,
459                        "{wh:?}/{engine:?}: null-rank guard must precede the __pos parse: {sql}"
460                    );
461                }
462            }
463        }
464    }
465
466    #[test]
467    fn identifiers_are_backticked_for_bigquery_and_bare_for_snowflake() {
468        let bq = dedup_view_sql(
469            Warehouse::BigQuery,
470            "p.d.orders",
471            "p.d.orders__changes",
472            &["id"],
473            SourceEngine::MySql,
474        );
475        assert!(bq.contains("VIEW `p.d.orders` AS"));
476        assert!(bq.contains("FROM `p.d.orders__changes`"));
477        let sf = dedup_view_sql(
478            Warehouse::Snowflake,
479            "db.sc.orders",
480            "db.sc.orders__changes",
481            &["id"],
482            SourceEngine::MySql,
483        );
484        // Back-ticks would be a Snowflake syntax error — identifiers stay bare.
485        assert!(!sf.contains('`'), "snowflake view must not back-tick: {sf}");
486        assert!(sf.contains("VIEW db.sc.orders AS"));
487        assert!(sf.contains("FROM db.sc.orders__changes"));
488    }
489
490    #[test]
491    fn inc_dedup_view_orders_by_cursor_and_never_tombstones_on_every_dialect() {
492        for wh in WAREHOUSES {
493            let sql = inc_dedup_view_sql(
494                wh,
495                "p.d.orders",
496                "p.d.orders__changes",
497                &["id"],
498                "updated_at",
499            );
500            assert!(
501                sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
502                "{wh:?}: {sql}"
503            );
504            // Latest-per-PK is the greatest cursor value — but a NULL-cursor
505            // baseline row must NOT win the dedup. Rank real cursor values above
506            // NULL (the same `IS NOT NULL DESC` guard the CDC view uses for
507            // __pos), else Snowflake (NULLs sort FIRST in DESC) keeps the stale
508            // baseline over a later non-NULL-cursor update.
509            let cur = wh.quote_ident("updated_at");
510            assert!(
511                sql.contains(&format!("ORDER BY {cur} IS NOT NULL DESC, {cur} DESC")),
512                "{wh:?}: cursor order must guard NULL-baseline first: {sql}"
513            );
514            // Incremental can't observe deletes → the flag is a constant FALSE,
515            // with none of CDC's `__op = 'delete'` logic.
516            assert!(sql.contains("FALSE AS __is_deleted"), "{wh:?}: {sql}");
517            assert!(
518                !sql.contains("'delete'"),
519                "{wh:?}: no CDC delete logic: {sql}"
520            );
521            let kw = match wh {
522                Warehouse::BigQuery => "EXCEPT",
523                Warehouse::Snowflake => "EXCLUDE",
524            };
525            assert!(
526                sql.contains(&format!("{kw} (__op, __pos, __seq, __rn)")),
527                "{wh:?}: drops the (reused) CDC meta columns: {sql}"
528            );
529        }
530    }
531
532    #[test]
533    fn inc_dedup_view_quotes_identifiers_per_dialect() {
534        let bq = inc_dedup_view_sql(
535            Warehouse::BigQuery,
536            "p.d.o",
537            "p.d.o__changes",
538            &["id"],
539            "ts",
540        );
541        assert!(bq.contains("VIEW `p.d.o` AS"));
542        assert!(bq.contains("FROM `p.d.o__changes`"));
543        let sf = inc_dedup_view_sql(
544            Warehouse::Snowflake,
545            "db.sc.o",
546            "db.sc.o__changes",
547            &["id"],
548            "ts",
549        );
550        assert!(!sf.contains('`'), "snowflake bare identifiers: {sf}");
551        assert!(sf.contains("VIEW db.sc.o AS"));
552    }
553
554    #[test]
555    fn composite_primary_key_partitions_by_all_columns() {
556        let sql = dedup_view_sql(
557            Warehouse::BigQuery,
558            "v",
559            "c",
560            &["tenant", "id"],
561            SourceEngine::MySql,
562        );
563        assert!(sql.contains("PARTITION BY `tenant`, `id`"));
564    }
565
566    #[test]
567    fn identifiers_are_quoted_per_dialect_so_a_reserved_word_column_is_safe() {
568        // BigQuery back-ticks pk + cursor (a column named `order`/`end` would be a
569        // syntax error unquoted); Snowflake leaves them bare (matching its
570        // unquoted/upper-cased loader columns).
571        let bq = inc_dedup_view_sql(Warehouse::BigQuery, "v", "c", &["order"], "end");
572        assert!(bq.contains("PARTITION BY `order`"), "{bq}");
573        assert!(
574            bq.contains("ORDER BY `end` IS NOT NULL DESC, `end` DESC"),
575            "{bq}"
576        );
577        let sf = inc_dedup_view_sql(Warehouse::Snowflake, "v", "c", &["order"], "end");
578        assert!(sf.contains("PARTITION BY order"), "{sf}");
579        assert!(
580            sf.contains("ORDER BY end IS NOT NULL DESC, end DESC"),
581            "{sf}"
582        );
583        // CDC composite pk: each column quoted for BigQuery.
584        let cdc = dedup_view_sql(
585            Warehouse::BigQuery,
586            "v",
587            "c",
588            &["a", "b"],
589            SourceEngine::MySql,
590        );
591        assert!(cdc.contains("PARTITION BY `a`, `b`"), "{cdc}");
592    }
593
594    #[test]
595    fn meta_column_specs_are_typed_per_warehouse_and_ordered() {
596        let bq = meta_column_specs(Warehouse::BigQuery);
597        let names: Vec<&str> = bq.iter().map(|s| s.column_name.as_str()).collect();
598        assert_eq!(
599            names,
600            ["__op", "__pos", "__seq"],
601            "meta columns lead the schema, in order"
602        );
603        assert_eq!(bq[0].target_type, "STRING");
604        assert_eq!(bq[2].target_type, "INT64");
605        let sf = meta_column_specs(Warehouse::Snowflake);
606        assert_eq!(sf[1].target_type, "VARCHAR");
607        assert_eq!(sf[2].target_type, "INTEGER");
608    }
609}