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}