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.
143pub fn meta_column_specs(warehouse: Warehouse) -> Vec<TargetColumnSpec> {
144    let (str_ty, int_ty) = match warehouse {
145        Warehouse::BigQuery => ("STRING", "INT64"),
146        Warehouse::Snowflake => ("VARCHAR", "INTEGER"),
147    };
148    ["__op", "__pos"]
149        .into_iter()
150        .map(|name| meta_spec(name, str_ty))
151        .chain(std::iter::once(meta_spec("__seq", int_ty)))
152        .collect()
153}
154
155fn meta_spec(name: &str, ty: &str) -> TargetColumnSpec {
156    TargetColumnSpec {
157        column_name: name.into(),
158        target_type: ty.into(),
159        autoload_type: String::new(),
160        status: TargetStatus::Ok,
161        note: None,
162        cast_sql: None,
163    }
164}
165
166/// The soft-delete flag column the view exposes: `true` when the latest change
167/// for a PK was a delete. In rivet's reserved `__` namespace so it can never
168/// collide with a source column (a plain `is_deleted` might).
169pub const DELETE_FLAG_COLUMN: &str = "__is_deleted";
170
171/// Build the current-state dedup view over a `<table>__changes` log for
172/// `warehouse`. `pk` is the change log's primary key column(s); `engine`
173/// selects the `__pos` parse. The view is free to define; reading it scans
174/// `__changes` (billed), kept cheap by clustering the log on `pk`.
175///
176/// **Soft delete.** The view keeps the latest change per PK unconditionally and
177/// projects the winning row's `__op` into a boolean [`DELETE_FLAG_COLUMN`]
178/// (`__op = 'delete'`), rather than dropping deleted rows. A tombstone therefore
179/// survives with its last-known column values — an auditable delete instead of
180/// a silent disappearance. Consumers read live state with
181/// `WHERE NOT __is_deleted`.
182///
183/// **Backfill.** `cdc.initial: snapshot` preexisting rows load from a plain
184/// full-snapshot parquet, so their `__op`/`__pos` are NULL in `__changes`. The
185/// flag is `COALESCE(.. , FALSE)` (a NULL `__op` is a live snapshot insert, not a
186/// delete — otherwise `WHERE NOT __is_deleted` drops the whole backfill), and the
187/// order ranks NULL `__pos` last (see below) so a later change always wins.
188///
189/// The subquery + `__rn` structure (rather than a `QUALIFY`) is deliberate: the
190/// flag must reflect the *winning* row per PK, computed **after** `ROW_NUMBER`.
191/// Note `__op` is both dropped from the `*` expansion and referenced by the flag
192/// expression — both BigQuery `EXCEPT` and Snowflake `EXCLUDE` allow that (the
193/// exclusion only affects `*`, not an explicit reference).
194pub fn dedup_view_sql(
195    warehouse: Warehouse,
196    view_fqtn: &str,
197    changes_fqtn: &str,
198    pk: &[&str],
199    engine: SourceEngine,
200) -> String {
201    let partition = quote_partition(warehouse, pk);
202    // `initial: snapshot` backfill rows load as a plain full-snapshot parquet —
203    // no `__op`/`__pos`/`__seq` — so they land in `__changes` with those NULL.
204    // `__pos IS NOT NULL DESC` FIRST in the order ranks any real change above the
205    // snapshot baseline deterministically across dialects: without it BigQuery
206    // sorts a NULL `__pos` last (snapshot loses — correct by luck) but Snowflake
207    // sorts it first (snapshot would WIN a later update → stale current state).
208    let order = std::iter::once("__pos IS NOT NULL".to_string())
209        .chain(engine.order_exprs(warehouse))
210        .map(|e| format!("{e} DESC"))
211        .collect::<Vec<_>>()
212        .join(", ");
213    build_dedup_view(
214        warehouse,
215        view_fqtn,
216        changes_fqtn,
217        &partition,
218        &order,
219        "COALESCE(__op = 'delete', FALSE)",
220    )
221}
222
223/// Quote each PK column for `warehouse` and join for a `PARTITION BY`.
224fn quote_partition(warehouse: Warehouse, pk: &[&str]) -> String {
225    pk.iter()
226        .map(|c| warehouse.quote_ident(c))
227        .collect::<Vec<_>>()
228        .join(", ")
229}
230
231/// The shared current-state view envelope: keep the winning row per PK
232/// (`ROW_NUMBER … WHERE __rn = 1`), drop the meta columns from `*`, and project
233/// `delete_flag` into [`DELETE_FLAG_COLUMN`]. The two public builders differ only
234/// in `order_by` (how "winning" is decided) and `delete_flag` — everything else,
235/// including the `EXCEPT`/`EXCLUDE` dialect keyword and identifier quoting, lives
236/// here so CDC and incremental can never drift on the view shape.
237fn build_dedup_view(
238    warehouse: Warehouse,
239    view_fqtn: &str,
240    changes_fqtn: &str,
241    partition: &str,
242    order_by: &str,
243    delete_flag: &str,
244) -> String {
245    format!(
246        "CREATE OR REPLACE VIEW {view} AS\n\
247         SELECT * {except} (__op, __pos, __seq, __rn),\n\
248         \x20      {delete_flag} AS {flag}\n\
249         FROM (\n\
250         \x20 SELECT *, ROW_NUMBER() OVER (\n\
251         \x20   PARTITION BY {partition}\n\
252         \x20   ORDER BY {order_by}\n\
253         \x20 ) AS __rn\n\
254         \x20 FROM {changes}\n\
255         )\n\
256         WHERE __rn = 1;",
257        view = warehouse.quote_fqtn(view_fqtn),
258        changes = warehouse.quote_fqtn(changes_fqtn),
259        except = warehouse.except_keyword(),
260        flag = DELETE_FLAG_COLUMN,
261    )
262}
263
264/// Build the current-state dedup view for an **incremental** load's change log.
265/// Unlike CDC ([`dedup_view_sql`]), an incremental delta has no `__op`/`__pos`/
266/// `__seq` (the change log reuses the CDC append so those columns exist but are
267/// NULL): current state is simply the row with the greatest `cursor_column` per
268/// PK. Incremental can't observe deletes, so [`DELETE_FLAG_COLUMN`] is a constant
269/// `FALSE` — the view SHAPE matches CDC so downstream reads `WHERE NOT
270/// __is_deleted` uniformly across both modes.
271pub fn inc_dedup_view_sql(
272    warehouse: Warehouse,
273    view_fqtn: &str,
274    changes_fqtn: &str,
275    pk: &[&str],
276    cursor_column: &str,
277) -> String {
278    let partition = quote_partition(warehouse, pk);
279    // `<cursor> IS NOT NULL DESC` FIRST — the same NULL-baseline guard the CDC
280    // view uses for `__pos` (see dedup_view_sql). A nullable cursor lands NULL
281    // rows in `<table>__changes` (the first incremental run has no cursor bound,
282    // so it extracts every row, NULL cursor included). Without the guard the
283    // order is a bare `<cursor> DESC`, and Snowflake sorts NULLs FIRST in DESC —
284    // so a NULL-cursor baseline row would win ROW_NUMBER=1 and HIDE a later
285    // non-NULL-cursor update (stale current state; BigQuery sorts NULLs last, so
286    // it was correct only by luck). Ranking real cursor values above NULL makes
287    // the dedup deterministic across both dialects.
288    let cur = warehouse.quote_ident(cursor_column);
289    let order = format!("{cur} IS NOT NULL DESC, {cur} DESC");
290    build_dedup_view(
291        warehouse,
292        view_fqtn,
293        changes_fqtn,
294        &partition,
295        &order,
296        "FALSE",
297    )
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    const WAREHOUSES: [Warehouse; 2] = [Warehouse::BigQuery, Warehouse::Snowflake];
305    const ENGINES: [SourceEngine; 4] = [
306        SourceEngine::MySql,
307        SourceEngine::Postgres,
308        SourceEngine::SqlServer,
309        SourceEngine::Mongo,
310    ];
311
312    #[test]
313    fn every_warehouse_and_engine_orders_by_seq_last_and_drops_meta_columns() {
314        for wh in WAREHOUSES {
315            for engine in ENGINES {
316                let sql = dedup_view_sql(wh, "p.d.orders", "p.d.orders__changes", &["id"], engine);
317                // The intra-transaction tiebreak is present and LAST in the order.
318                assert!(sql.contains("__seq DESC"), "{wh:?}/{engine:?}: {sql}");
319                let order = sql.split("ORDER BY").nth(1).unwrap();
320                let seq_at = order.find("__seq DESC").unwrap();
321                let pos_at = order.find("__pos").unwrap();
322                assert!(
323                    pos_at < seq_at,
324                    "{wh:?}/{engine:?}: __pos must sort before __seq"
325                );
326                // Soft delete: latest row per PK is kept (no delete filter); the
327                // winning `__op` becomes the boolean tombstone flag.
328                assert!(sql.contains("WHERE __rn = 1;"), "{wh:?}/{engine:?}: {sql}");
329                assert!(
330                    !sql.contains("!= 'delete'"),
331                    "{wh:?}/{engine:?}: deletes must NOT be dropped"
332                );
333                assert!(
334                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
335                    "{wh:?}/{engine:?}"
336                );
337                assert!(
338                    sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
339                    "{wh:?}/{engine:?}"
340                );
341            }
342        }
343    }
344
345    #[test]
346    fn bigquery_uses_json_value_and_except() {
347        let sql = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["id"], SourceEngine::MySql);
348        assert!(sql.contains("JSON_VALUE(__pos,'$.file') DESC"));
349        assert!(sql.contains("CAST(JSON_VALUE(__pos,'$.pos') AS INT64) DESC"));
350        assert!(sql.contains("EXCEPT (__op, __pos, __seq, __rn)"));
351    }
352
353    #[test]
354    fn snowflake_uses_parse_json_and_exclude() {
355        let sql = dedup_view_sql(Warehouse::Snowflake, "v", "c", &["id"], SourceEngine::MySql);
356        assert!(sql.contains("PARSE_JSON(__pos):file::string DESC"));
357        assert!(sql.contains("PARSE_JSON(__pos):pos::integer DESC"));
358        assert!(sql.contains("EXCLUDE (__op, __pos, __seq, __rn)"));
359    }
360
361    #[test]
362    fn postgres_zero_pads_each_lsn_half_per_dialect() {
363        let bq = dedup_view_sql(
364            Warehouse::BigQuery,
365            "v",
366            "c",
367            &["id"],
368            SourceEngine::Postgres,
369        );
370        assert!(bq.contains("[OFFSET(0)],8,'0')"));
371        assert!(bq.contains("[OFFSET(1)],8,'0')"));
372        let sf = dedup_view_sql(
373            Warehouse::Snowflake,
374            "v",
375            "c",
376            &["id"],
377            SourceEngine::Postgres,
378        );
379        // Snowflake splits the LSN with SPLIT_PART (1-indexed), not OFFSET.
380        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1)"));
381        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2)"));
382    }
383
384    #[test]
385    fn sqlserver_uses_fixed_width_lsn_directly() {
386        let bq = dedup_view_sql(
387            Warehouse::BigQuery,
388            "v",
389            "c",
390            &["id"],
391            SourceEngine::SqlServer,
392        );
393        assert!(bq.contains("JSON_VALUE(__pos,'$.lsn') DESC, __seq DESC"));
394        let sf = dedup_view_sql(
395            Warehouse::Snowflake,
396            "v",
397            "c",
398            &["id"],
399            SourceEngine::SqlServer,
400        );
401        assert!(sf.contains("PARSE_JSON(__pos):lsn::string DESC, __seq DESC"));
402    }
403
404    #[test]
405    fn mongo_orders_by_resume_token_data_and_partitions_by_id() {
406        // Mongo's `_id` is the dedup PK; `__pos` orders on the `_data` resume
407        // token (single string key + `__seq` tiebreak, like SQL Server's lsn).
408        let bq = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["_id"], SourceEngine::Mongo);
409        assert!(bq.contains("JSON_VALUE(__pos,'$._data') DESC, __seq DESC"));
410        assert!(bq.contains("PARTITION BY `_id`"));
411        let sf = dedup_view_sql(
412            Warehouse::Snowflake,
413            "v",
414            "c",
415            &["_id"],
416            SourceEngine::Mongo,
417        );
418        assert!(sf.contains("PARSE_JSON(__pos):_data::string DESC, __seq DESC"));
419        // Soft-delete parity holds for Mongo too.
420        assert!(sf.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"));
421    }
422
423    #[test]
424    fn snapshot_backfill_rows_are_live_and_rank_oldest_on_every_dialect() {
425        // `cdc.initial: snapshot` rows carry NULL __op/__pos in `__changes`. The
426        // view must (1) read a NULL __op as a live insert — not a NULL flag that
427        // `WHERE NOT __is_deleted` silently drops — and (2) rank a NULL __pos below
428        // any real change on BOTH dialects, not rely on the engine's NULL-order
429        // default (BigQuery NULLS-last vs Snowflake NULLS-first would disagree).
430        for wh in WAREHOUSES {
431            for engine in ENGINES {
432                let sql = dedup_view_sql(wh, "p.d.t", "p.d.t__changes", &["id"], engine);
433                assert!(
434                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
435                    "{wh:?}/{engine:?}: NULL __op must read as not-deleted (live): {sql}"
436                );
437                // The null-rank guard is the FIRST, most-significant order key.
438                let order = sql.split("ORDER BY").nth(1).unwrap();
439                assert!(
440                    order.contains("__pos IS NOT NULL DESC"),
441                    "{wh:?}/{engine:?}: NULL __pos must be ranked, not left to engine default: {sql}"
442                );
443                let guard_at = order.find("__pos IS NOT NULL DESC").unwrap();
444                let parse_at = order.find(if wh == Warehouse::BigQuery {
445                    "JSON_VALUE"
446                } else {
447                    "PARSE_JSON"
448                });
449                if let Some(parse_at) = parse_at {
450                    assert!(
451                        guard_at < parse_at,
452                        "{wh:?}/{engine:?}: null-rank guard must precede the __pos parse: {sql}"
453                    );
454                }
455            }
456        }
457    }
458
459    #[test]
460    fn identifiers_are_backticked_for_bigquery_and_bare_for_snowflake() {
461        let bq = dedup_view_sql(
462            Warehouse::BigQuery,
463            "p.d.orders",
464            "p.d.orders__changes",
465            &["id"],
466            SourceEngine::MySql,
467        );
468        assert!(bq.contains("VIEW `p.d.orders` AS"));
469        assert!(bq.contains("FROM `p.d.orders__changes`"));
470        let sf = dedup_view_sql(
471            Warehouse::Snowflake,
472            "db.sc.orders",
473            "db.sc.orders__changes",
474            &["id"],
475            SourceEngine::MySql,
476        );
477        // Back-ticks would be a Snowflake syntax error — identifiers stay bare.
478        assert!(!sf.contains('`'), "snowflake view must not back-tick: {sf}");
479        assert!(sf.contains("VIEW db.sc.orders AS"));
480        assert!(sf.contains("FROM db.sc.orders__changes"));
481    }
482
483    #[test]
484    fn inc_dedup_view_orders_by_cursor_and_never_tombstones_on_every_dialect() {
485        for wh in WAREHOUSES {
486            let sql = inc_dedup_view_sql(
487                wh,
488                "p.d.orders",
489                "p.d.orders__changes",
490                &["id"],
491                "updated_at",
492            );
493            assert!(
494                sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
495                "{wh:?}: {sql}"
496            );
497            // Latest-per-PK is the greatest cursor value — but a NULL-cursor
498            // baseline row must NOT win the dedup. Rank real cursor values above
499            // NULL (the same `IS NOT NULL DESC` guard the CDC view uses for
500            // __pos), else Snowflake (NULLs sort FIRST in DESC) keeps the stale
501            // baseline over a later non-NULL-cursor update.
502            let cur = wh.quote_ident("updated_at");
503            assert!(
504                sql.contains(&format!("ORDER BY {cur} IS NOT NULL DESC, {cur} DESC")),
505                "{wh:?}: cursor order must guard NULL-baseline first: {sql}"
506            );
507            // Incremental can't observe deletes → the flag is a constant FALSE,
508            // with none of CDC's `__op = 'delete'` logic.
509            assert!(sql.contains("FALSE AS __is_deleted"), "{wh:?}: {sql}");
510            assert!(
511                !sql.contains("'delete'"),
512                "{wh:?}: no CDC delete logic: {sql}"
513            );
514            let kw = match wh {
515                Warehouse::BigQuery => "EXCEPT",
516                Warehouse::Snowflake => "EXCLUDE",
517            };
518            assert!(
519                sql.contains(&format!("{kw} (__op, __pos, __seq, __rn)")),
520                "{wh:?}: drops the (reused) CDC meta columns: {sql}"
521            );
522        }
523    }
524
525    #[test]
526    fn inc_dedup_view_quotes_identifiers_per_dialect() {
527        let bq = inc_dedup_view_sql(
528            Warehouse::BigQuery,
529            "p.d.o",
530            "p.d.o__changes",
531            &["id"],
532            "ts",
533        );
534        assert!(bq.contains("VIEW `p.d.o` AS"));
535        assert!(bq.contains("FROM `p.d.o__changes`"));
536        let sf = inc_dedup_view_sql(
537            Warehouse::Snowflake,
538            "db.sc.o",
539            "db.sc.o__changes",
540            &["id"],
541            "ts",
542        );
543        assert!(!sf.contains('`'), "snowflake bare identifiers: {sf}");
544        assert!(sf.contains("VIEW db.sc.o AS"));
545    }
546
547    #[test]
548    fn composite_primary_key_partitions_by_all_columns() {
549        let sql = dedup_view_sql(
550            Warehouse::BigQuery,
551            "v",
552            "c",
553            &["tenant", "id"],
554            SourceEngine::MySql,
555        );
556        assert!(sql.contains("PARTITION BY `tenant`, `id`"));
557    }
558
559    #[test]
560    fn identifiers_are_quoted_per_dialect_so_a_reserved_word_column_is_safe() {
561        // BigQuery back-ticks pk + cursor (a column named `order`/`end` would be a
562        // syntax error unquoted); Snowflake leaves them bare (matching its
563        // unquoted/upper-cased loader columns).
564        let bq = inc_dedup_view_sql(Warehouse::BigQuery, "v", "c", &["order"], "end");
565        assert!(bq.contains("PARTITION BY `order`"), "{bq}");
566        assert!(
567            bq.contains("ORDER BY `end` IS NOT NULL DESC, `end` DESC"),
568            "{bq}"
569        );
570        let sf = inc_dedup_view_sql(Warehouse::Snowflake, "v", "c", &["order"], "end");
571        assert!(sf.contains("PARTITION BY order"), "{sf}");
572        assert!(
573            sf.contains("ORDER BY end IS NOT NULL DESC, end DESC"),
574            "{sf}"
575        );
576        // CDC composite pk: each column quoted for BigQuery.
577        let cdc = dedup_view_sql(
578            Warehouse::BigQuery,
579            "v",
580            "c",
581            &["a", "b"],
582            SourceEngine::MySql,
583        );
584        assert!(cdc.contains("PARTITION BY `a`, `b`"), "{cdc}");
585    }
586
587    #[test]
588    fn meta_column_specs_are_typed_per_warehouse_and_ordered() {
589        let bq = meta_column_specs(Warehouse::BigQuery);
590        let names: Vec<&str> = bq.iter().map(|s| s.column_name.as_str()).collect();
591        assert_eq!(
592            names,
593            ["__op", "__pos", "__seq"],
594            "meta columns lead the schema, in order"
595        );
596        assert_eq!(bq[0].target_type, "STRING");
597        assert_eq!(bq[2].target_type, "INT64");
598        let sf = meta_column_specs(Warehouse::Snowflake);
599        assert_eq!(sf[1].target_type, "VARCHAR");
600        assert_eq!(sf[2].target_type, "INTEGER");
601    }
602}