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}