1use crate::types::target::{TargetColumnSpec, TargetStatus};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum SourceEngine {
35 MySql,
37 Postgres,
40 SqlServer,
42 Mongo,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Warehouse {
53 BigQuery,
54 Snowflake,
55}
56
57impl Warehouse {
58 fn except_keyword(self) -> &'static str {
61 match self {
62 Warehouse::BigQuery => "EXCEPT",
63 Warehouse::Snowflake => "EXCLUDE",
64 }
65 }
66
67 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 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 fn order_exprs(self, warehouse: Warehouse) -> Vec<String> {
98 let pos: Vec<String> = match (warehouse, self) {
99 (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 (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 pos.into_iter()
133 .chain(std::iter::once("__seq".to_string()))
134 .collect()
135 }
136}
137
138pub 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
166pub const DELETE_FLAG_COLUMN: &str = "__is_deleted";
170
171pub 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 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
223fn quote_partition(warehouse: Warehouse, pk: &[&str]) -> String {
225 pk.iter()
226 .map(|c| warehouse.quote_ident(c))
227 .collect::<Vec<_>>()
228 .join(", ")
229}
230
231fn 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
264pub 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 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 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 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 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 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 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 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 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 assert!(
489 sql.contains(&format!("ORDER BY {} DESC", wh.quote_ident("updated_at"))),
490 "{wh:?}: {sql}"
491 );
492 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 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 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}