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    let order = format!("{} DESC", warehouse.quote_ident(cursor_column));
280    build_dedup_view(
281        warehouse,
282        view_fqtn,
283        changes_fqtn,
284        &partition,
285        &order,
286        "FALSE",
287    )
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    const WAREHOUSES: [Warehouse; 2] = [Warehouse::BigQuery, Warehouse::Snowflake];
295    const ENGINES: [SourceEngine; 4] = [
296        SourceEngine::MySql,
297        SourceEngine::Postgres,
298        SourceEngine::SqlServer,
299        SourceEngine::Mongo,
300    ];
301
302    #[test]
303    fn every_warehouse_and_engine_orders_by_seq_last_and_drops_meta_columns() {
304        for wh in WAREHOUSES {
305            for engine in ENGINES {
306                let sql = dedup_view_sql(wh, "p.d.orders", "p.d.orders__changes", &["id"], engine);
307                // The intra-transaction tiebreak is present and LAST in the order.
308                assert!(sql.contains("__seq DESC"), "{wh:?}/{engine:?}: {sql}");
309                let order = sql.split("ORDER BY").nth(1).unwrap();
310                let seq_at = order.find("__seq DESC").unwrap();
311                let pos_at = order.find("__pos").unwrap();
312                assert!(
313                    pos_at < seq_at,
314                    "{wh:?}/{engine:?}: __pos must sort before __seq"
315                );
316                // Soft delete: latest row per PK is kept (no delete filter); the
317                // winning `__op` becomes the boolean tombstone flag.
318                assert!(sql.contains("WHERE __rn = 1;"), "{wh:?}/{engine:?}: {sql}");
319                assert!(
320                    !sql.contains("!= 'delete'"),
321                    "{wh:?}/{engine:?}: deletes must NOT be dropped"
322                );
323                assert!(
324                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
325                    "{wh:?}/{engine:?}"
326                );
327                assert!(
328                    sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
329                    "{wh:?}/{engine:?}"
330                );
331            }
332        }
333    }
334
335    #[test]
336    fn bigquery_uses_json_value_and_except() {
337        let sql = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["id"], SourceEngine::MySql);
338        assert!(sql.contains("JSON_VALUE(__pos,'$.file') DESC"));
339        assert!(sql.contains("CAST(JSON_VALUE(__pos,'$.pos') AS INT64) DESC"));
340        assert!(sql.contains("EXCEPT (__op, __pos, __seq, __rn)"));
341    }
342
343    #[test]
344    fn snowflake_uses_parse_json_and_exclude() {
345        let sql = dedup_view_sql(Warehouse::Snowflake, "v", "c", &["id"], SourceEngine::MySql);
346        assert!(sql.contains("PARSE_JSON(__pos):file::string DESC"));
347        assert!(sql.contains("PARSE_JSON(__pos):pos::integer DESC"));
348        assert!(sql.contains("EXCLUDE (__op, __pos, __seq, __rn)"));
349    }
350
351    #[test]
352    fn postgres_zero_pads_each_lsn_half_per_dialect() {
353        let bq = dedup_view_sql(
354            Warehouse::BigQuery,
355            "v",
356            "c",
357            &["id"],
358            SourceEngine::Postgres,
359        );
360        assert!(bq.contains("[OFFSET(0)],8,'0')"));
361        assert!(bq.contains("[OFFSET(1)],8,'0')"));
362        let sf = dedup_view_sql(
363            Warehouse::Snowflake,
364            "v",
365            "c",
366            &["id"],
367            SourceEngine::Postgres,
368        );
369        // Snowflake splits the LSN with SPLIT_PART (1-indexed), not OFFSET.
370        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1)"));
371        assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2)"));
372    }
373
374    #[test]
375    fn sqlserver_uses_fixed_width_lsn_directly() {
376        let bq = dedup_view_sql(
377            Warehouse::BigQuery,
378            "v",
379            "c",
380            &["id"],
381            SourceEngine::SqlServer,
382        );
383        assert!(bq.contains("JSON_VALUE(__pos,'$.lsn') DESC, __seq DESC"));
384        let sf = dedup_view_sql(
385            Warehouse::Snowflake,
386            "v",
387            "c",
388            &["id"],
389            SourceEngine::SqlServer,
390        );
391        assert!(sf.contains("PARSE_JSON(__pos):lsn::string DESC, __seq DESC"));
392    }
393
394    #[test]
395    fn mongo_orders_by_resume_token_data_and_partitions_by_id() {
396        // Mongo's `_id` is the dedup PK; `__pos` orders on the `_data` resume
397        // token (single string key + `__seq` tiebreak, like SQL Server's lsn).
398        let bq = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["_id"], SourceEngine::Mongo);
399        assert!(bq.contains("JSON_VALUE(__pos,'$._data') DESC, __seq DESC"));
400        assert!(bq.contains("PARTITION BY `_id`"));
401        let sf = dedup_view_sql(
402            Warehouse::Snowflake,
403            "v",
404            "c",
405            &["_id"],
406            SourceEngine::Mongo,
407        );
408        assert!(sf.contains("PARSE_JSON(__pos):_data::string DESC, __seq DESC"));
409        // Soft-delete parity holds for Mongo too.
410        assert!(sf.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"));
411    }
412
413    #[test]
414    fn snapshot_backfill_rows_are_live_and_rank_oldest_on_every_dialect() {
415        // `cdc.initial: snapshot` rows carry NULL __op/__pos in `__changes`. The
416        // view must (1) read a NULL __op as a live insert — not a NULL flag that
417        // `WHERE NOT __is_deleted` silently drops — and (2) rank a NULL __pos below
418        // any real change on BOTH dialects, not rely on the engine's NULL-order
419        // default (BigQuery NULLS-last vs Snowflake NULLS-first would disagree).
420        for wh in WAREHOUSES {
421            for engine in ENGINES {
422                let sql = dedup_view_sql(wh, "p.d.t", "p.d.t__changes", &["id"], engine);
423                assert!(
424                    sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
425                    "{wh:?}/{engine:?}: NULL __op must read as not-deleted (live): {sql}"
426                );
427                // The null-rank guard is the FIRST, most-significant order key.
428                let order = sql.split("ORDER BY").nth(1).unwrap();
429                assert!(
430                    order.contains("__pos IS NOT NULL DESC"),
431                    "{wh:?}/{engine:?}: NULL __pos must be ranked, not left to engine default: {sql}"
432                );
433                let guard_at = order.find("__pos IS NOT NULL DESC").unwrap();
434                let parse_at = order.find(if wh == Warehouse::BigQuery {
435                    "JSON_VALUE"
436                } else {
437                    "PARSE_JSON"
438                });
439                if let Some(parse_at) = parse_at {
440                    assert!(
441                        guard_at < parse_at,
442                        "{wh:?}/{engine:?}: null-rank guard must precede the __pos parse: {sql}"
443                    );
444                }
445            }
446        }
447    }
448
449    #[test]
450    fn identifiers_are_backticked_for_bigquery_and_bare_for_snowflake() {
451        let bq = dedup_view_sql(
452            Warehouse::BigQuery,
453            "p.d.orders",
454            "p.d.orders__changes",
455            &["id"],
456            SourceEngine::MySql,
457        );
458        assert!(bq.contains("VIEW `p.d.orders` AS"));
459        assert!(bq.contains("FROM `p.d.orders__changes`"));
460        let sf = dedup_view_sql(
461            Warehouse::Snowflake,
462            "db.sc.orders",
463            "db.sc.orders__changes",
464            &["id"],
465            SourceEngine::MySql,
466        );
467        // Back-ticks would be a Snowflake syntax error — identifiers stay bare.
468        assert!(!sf.contains('`'), "snowflake view must not back-tick: {sf}");
469        assert!(sf.contains("VIEW db.sc.orders AS"));
470        assert!(sf.contains("FROM db.sc.orders__changes"));
471    }
472
473    #[test]
474    fn inc_dedup_view_orders_by_cursor_and_never_tombstones_on_every_dialect() {
475        for wh in WAREHOUSES {
476            let sql = inc_dedup_view_sql(
477                wh,
478                "p.d.orders",
479                "p.d.orders__changes",
480                &["id"],
481                "updated_at",
482            );
483            assert!(
484                sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
485                "{wh:?}: {sql}"
486            );
487            // Latest-per-PK is the greatest cursor value.
488            assert!(
489                sql.contains(&format!("ORDER BY {} DESC", wh.quote_ident("updated_at"))),
490                "{wh:?}: {sql}"
491            );
492            // Incremental can't observe deletes → the flag is a constant FALSE,
493            // with none of CDC's `__op = 'delete'` logic.
494            assert!(sql.contains("FALSE AS __is_deleted"), "{wh:?}: {sql}");
495            assert!(
496                !sql.contains("'delete'"),
497                "{wh:?}: no CDC delete logic: {sql}"
498            );
499            let kw = match wh {
500                Warehouse::BigQuery => "EXCEPT",
501                Warehouse::Snowflake => "EXCLUDE",
502            };
503            assert!(
504                sql.contains(&format!("{kw} (__op, __pos, __seq, __rn)")),
505                "{wh:?}: drops the (reused) CDC meta columns: {sql}"
506            );
507        }
508    }
509
510    #[test]
511    fn inc_dedup_view_quotes_identifiers_per_dialect() {
512        let bq = inc_dedup_view_sql(
513            Warehouse::BigQuery,
514            "p.d.o",
515            "p.d.o__changes",
516            &["id"],
517            "ts",
518        );
519        assert!(bq.contains("VIEW `p.d.o` AS"));
520        assert!(bq.contains("FROM `p.d.o__changes`"));
521        let sf = inc_dedup_view_sql(
522            Warehouse::Snowflake,
523            "db.sc.o",
524            "db.sc.o__changes",
525            &["id"],
526            "ts",
527        );
528        assert!(!sf.contains('`'), "snowflake bare identifiers: {sf}");
529        assert!(sf.contains("VIEW db.sc.o AS"));
530    }
531
532    #[test]
533    fn composite_primary_key_partitions_by_all_columns() {
534        let sql = dedup_view_sql(
535            Warehouse::BigQuery,
536            "v",
537            "c",
538            &["tenant", "id"],
539            SourceEngine::MySql,
540        );
541        assert!(sql.contains("PARTITION BY `tenant`, `id`"));
542    }
543
544    #[test]
545    fn identifiers_are_quoted_per_dialect_so_a_reserved_word_column_is_safe() {
546        // BigQuery back-ticks pk + cursor (a column named `order`/`end` would be a
547        // syntax error unquoted); Snowflake leaves them bare (matching its
548        // unquoted/upper-cased loader columns).
549        let bq = inc_dedup_view_sql(Warehouse::BigQuery, "v", "c", &["order"], "end");
550        assert!(bq.contains("PARTITION BY `order`"), "{bq}");
551        assert!(bq.contains("ORDER BY `end` DESC"), "{bq}");
552        let sf = inc_dedup_view_sql(Warehouse::Snowflake, "v", "c", &["order"], "end");
553        assert!(sf.contains("PARTITION BY order"), "{sf}");
554        assert!(sf.contains("ORDER BY end DESC"), "{sf}");
555        // CDC composite pk: each column quoted for BigQuery.
556        let cdc = dedup_view_sql(
557            Warehouse::BigQuery,
558            "v",
559            "c",
560            &["a", "b"],
561            SourceEngine::MySql,
562        );
563        assert!(cdc.contains("PARTITION BY `a`, `b`"), "{cdc}");
564    }
565
566    #[test]
567    fn meta_column_specs_are_typed_per_warehouse_and_ordered() {
568        let bq = meta_column_specs(Warehouse::BigQuery);
569        let names: Vec<&str> = bq.iter().map(|s| s.column_name.as_str()).collect();
570        assert_eq!(
571            names,
572            ["__op", "__pos", "__seq"],
573            "meta columns lead the schema, in order"
574        );
575        assert_eq!(bq[0].target_type, "STRING");
576        assert_eq!(bq[2].target_type, "INT64");
577        let sf = meta_column_specs(Warehouse::Snowflake);
578        assert_eq!(sf[1].target_type, "VARCHAR");
579        assert_eq!(sf[2].target_type, "INTEGER");
580    }
581}