use crate::types::target::{TargetColumnSpec, TargetStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceEngine {
MySql,
Postgres,
SqlServer,
Mongo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Warehouse {
BigQuery,
Snowflake,
}
impl Warehouse {
fn except_keyword(self) -> &'static str {
match self {
Warehouse::BigQuery => "EXCEPT",
Warehouse::Snowflake => "EXCLUDE",
}
}
fn quote_fqtn(self, fqtn: &str) -> String {
match self {
Warehouse::BigQuery => format!("`{fqtn}`"),
Warehouse::Snowflake => fqtn.to_string(),
}
}
fn quote_ident(self, col: &str) -> String {
match self {
Warehouse::BigQuery => format!("`{col}`"),
Warehouse::Snowflake => col.to_string(),
}
}
}
impl SourceEngine {
fn order_exprs(self, warehouse: Warehouse) -> Vec<String> {
let pos: Vec<String> = match (warehouse, self) {
(Warehouse::BigQuery, SourceEngine::MySql) => vec![
"JSON_VALUE(__pos,'$.file')".into(),
"CAST(JSON_VALUE(__pos,'$.pos') AS INT64)".into(),
],
(Warehouse::BigQuery, SourceEngine::Postgres) => vec![
"LPAD(SPLIT(JSON_VALUE(__pos,'$.lsn'),'/')[OFFSET(0)],8,'0')".into(),
"LPAD(SPLIT(JSON_VALUE(__pos,'$.lsn'),'/')[OFFSET(1)],8,'0')".into(),
],
(Warehouse::BigQuery, SourceEngine::SqlServer) => {
vec!["JSON_VALUE(__pos,'$.lsn')".into()]
}
(Warehouse::BigQuery, SourceEngine::Mongo) => {
vec!["JSON_VALUE(__pos,'$._data')".into()]
}
(Warehouse::Snowflake, SourceEngine::MySql) => vec![
"PARSE_JSON(__pos):file::string".into(),
"PARSE_JSON(__pos):pos::integer".into(),
],
(Warehouse::Snowflake, SourceEngine::Postgres) => vec![
"LPAD(SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1),8,'0')".into(),
"LPAD(SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2),8,'0')".into(),
],
(Warehouse::Snowflake, SourceEngine::SqlServer) => {
vec!["PARSE_JSON(__pos):lsn::string".into()]
}
(Warehouse::Snowflake, SourceEngine::Mongo) => {
vec!["PARSE_JSON(__pos):_data::string".into()]
}
};
pos.into_iter()
.chain(std::iter::once("__seq".to_string()))
.collect()
}
}
pub fn meta_column_specs(warehouse: Warehouse) -> Vec<TargetColumnSpec> {
let (str_ty, int_ty) = match warehouse {
Warehouse::BigQuery => ("STRING", "INT64"),
Warehouse::Snowflake => ("VARCHAR", "INTEGER"),
};
["__op", "__pos"]
.into_iter()
.map(|name| meta_spec(name, str_ty))
.chain(std::iter::once(meta_spec("__seq", int_ty)))
.collect()
}
fn meta_spec(name: &str, ty: &str) -> TargetColumnSpec {
TargetColumnSpec {
column_name: name.into(),
target_type: ty.into(),
autoload_type: String::new(),
status: TargetStatus::Ok,
note: None,
cast_sql: None,
}
}
pub const DELETE_FLAG_COLUMN: &str = "__is_deleted";
pub fn dedup_view_sql(
warehouse: Warehouse,
view_fqtn: &str,
changes_fqtn: &str,
pk: &[&str],
engine: SourceEngine,
) -> String {
let partition = quote_partition(warehouse, pk);
let order = std::iter::once("__pos IS NOT NULL".to_string())
.chain(engine.order_exprs(warehouse))
.map(|e| format!("{e} DESC"))
.collect::<Vec<_>>()
.join(", ");
build_dedup_view(
warehouse,
view_fqtn,
changes_fqtn,
&partition,
&order,
"COALESCE(__op = 'delete', FALSE)",
)
}
fn quote_partition(warehouse: Warehouse, pk: &[&str]) -> String {
pk.iter()
.map(|c| warehouse.quote_ident(c))
.collect::<Vec<_>>()
.join(", ")
}
fn build_dedup_view(
warehouse: Warehouse,
view_fqtn: &str,
changes_fqtn: &str,
partition: &str,
order_by: &str,
delete_flag: &str,
) -> String {
format!(
"CREATE OR REPLACE VIEW {view} AS\n\
SELECT * {except} (__op, __pos, __seq, __rn),\n\
\x20 {delete_flag} AS {flag}\n\
FROM (\n\
\x20 SELECT *, ROW_NUMBER() OVER (\n\
\x20 PARTITION BY {partition}\n\
\x20 ORDER BY {order_by}\n\
\x20 ) AS __rn\n\
\x20 FROM {changes}\n\
)\n\
WHERE __rn = 1;",
view = warehouse.quote_fqtn(view_fqtn),
changes = warehouse.quote_fqtn(changes_fqtn),
except = warehouse.except_keyword(),
flag = DELETE_FLAG_COLUMN,
)
}
pub fn inc_dedup_view_sql(
warehouse: Warehouse,
view_fqtn: &str,
changes_fqtn: &str,
pk: &[&str],
cursor_column: &str,
) -> String {
let partition = quote_partition(warehouse, pk);
let order = format!("{} DESC", warehouse.quote_ident(cursor_column));
build_dedup_view(
warehouse,
view_fqtn,
changes_fqtn,
&partition,
&order,
"FALSE",
)
}
#[cfg(test)]
mod tests {
use super::*;
const WAREHOUSES: [Warehouse; 2] = [Warehouse::BigQuery, Warehouse::Snowflake];
const ENGINES: [SourceEngine; 4] = [
SourceEngine::MySql,
SourceEngine::Postgres,
SourceEngine::SqlServer,
SourceEngine::Mongo,
];
#[test]
fn every_warehouse_and_engine_orders_by_seq_last_and_drops_meta_columns() {
for wh in WAREHOUSES {
for engine in ENGINES {
let sql = dedup_view_sql(wh, "p.d.orders", "p.d.orders__changes", &["id"], engine);
assert!(sql.contains("__seq DESC"), "{wh:?}/{engine:?}: {sql}");
let order = sql.split("ORDER BY").nth(1).unwrap();
let seq_at = order.find("__seq DESC").unwrap();
let pos_at = order.find("__pos").unwrap();
assert!(
pos_at < seq_at,
"{wh:?}/{engine:?}: __pos must sort before __seq"
);
assert!(sql.contains("WHERE __rn = 1;"), "{wh:?}/{engine:?}: {sql}");
assert!(
!sql.contains("!= 'delete'"),
"{wh:?}/{engine:?}: deletes must NOT be dropped"
);
assert!(
sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
"{wh:?}/{engine:?}"
);
assert!(
sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
"{wh:?}/{engine:?}"
);
}
}
}
#[test]
fn bigquery_uses_json_value_and_except() {
let sql = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["id"], SourceEngine::MySql);
assert!(sql.contains("JSON_VALUE(__pos,'$.file') DESC"));
assert!(sql.contains("CAST(JSON_VALUE(__pos,'$.pos') AS INT64) DESC"));
assert!(sql.contains("EXCEPT (__op, __pos, __seq, __rn)"));
}
#[test]
fn snowflake_uses_parse_json_and_exclude() {
let sql = dedup_view_sql(Warehouse::Snowflake, "v", "c", &["id"], SourceEngine::MySql);
assert!(sql.contains("PARSE_JSON(__pos):file::string DESC"));
assert!(sql.contains("PARSE_JSON(__pos):pos::integer DESC"));
assert!(sql.contains("EXCLUDE (__op, __pos, __seq, __rn)"));
}
#[test]
fn postgres_zero_pads_each_lsn_half_per_dialect() {
let bq = dedup_view_sql(
Warehouse::BigQuery,
"v",
"c",
&["id"],
SourceEngine::Postgres,
);
assert!(bq.contains("[OFFSET(0)],8,'0')"));
assert!(bq.contains("[OFFSET(1)],8,'0')"));
let sf = dedup_view_sql(
Warehouse::Snowflake,
"v",
"c",
&["id"],
SourceEngine::Postgres,
);
assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',1)"));
assert!(sf.contains("SPLIT_PART(PARSE_JSON(__pos):lsn::string,'/',2)"));
}
#[test]
fn sqlserver_uses_fixed_width_lsn_directly() {
let bq = dedup_view_sql(
Warehouse::BigQuery,
"v",
"c",
&["id"],
SourceEngine::SqlServer,
);
assert!(bq.contains("JSON_VALUE(__pos,'$.lsn') DESC, __seq DESC"));
let sf = dedup_view_sql(
Warehouse::Snowflake,
"v",
"c",
&["id"],
SourceEngine::SqlServer,
);
assert!(sf.contains("PARSE_JSON(__pos):lsn::string DESC, __seq DESC"));
}
#[test]
fn mongo_orders_by_resume_token_data_and_partitions_by_id() {
let bq = dedup_view_sql(Warehouse::BigQuery, "v", "c", &["_id"], SourceEngine::Mongo);
assert!(bq.contains("JSON_VALUE(__pos,'$._data') DESC, __seq DESC"));
assert!(bq.contains("PARTITION BY `_id`"));
let sf = dedup_view_sql(
Warehouse::Snowflake,
"v",
"c",
&["_id"],
SourceEngine::Mongo,
);
assert!(sf.contains("PARSE_JSON(__pos):_data::string DESC, __seq DESC"));
assert!(sf.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"));
}
#[test]
fn snapshot_backfill_rows_are_live_and_rank_oldest_on_every_dialect() {
for wh in WAREHOUSES {
for engine in ENGINES {
let sql = dedup_view_sql(wh, "p.d.t", "p.d.t__changes", &["id"], engine);
assert!(
sql.contains("COALESCE(__op = 'delete', FALSE) AS __is_deleted"),
"{wh:?}/{engine:?}: NULL __op must read as not-deleted (live): {sql}"
);
let order = sql.split("ORDER BY").nth(1).unwrap();
assert!(
order.contains("__pos IS NOT NULL DESC"),
"{wh:?}/{engine:?}: NULL __pos must be ranked, not left to engine default: {sql}"
);
let guard_at = order.find("__pos IS NOT NULL DESC").unwrap();
let parse_at = order.find(if wh == Warehouse::BigQuery {
"JSON_VALUE"
} else {
"PARSE_JSON"
});
if let Some(parse_at) = parse_at {
assert!(
guard_at < parse_at,
"{wh:?}/{engine:?}: null-rank guard must precede the __pos parse: {sql}"
);
}
}
}
}
#[test]
fn identifiers_are_backticked_for_bigquery_and_bare_for_snowflake() {
let bq = dedup_view_sql(
Warehouse::BigQuery,
"p.d.orders",
"p.d.orders__changes",
&["id"],
SourceEngine::MySql,
);
assert!(bq.contains("VIEW `p.d.orders` AS"));
assert!(bq.contains("FROM `p.d.orders__changes`"));
let sf = dedup_view_sql(
Warehouse::Snowflake,
"db.sc.orders",
"db.sc.orders__changes",
&["id"],
SourceEngine::MySql,
);
assert!(!sf.contains('`'), "snowflake view must not back-tick: {sf}");
assert!(sf.contains("VIEW db.sc.orders AS"));
assert!(sf.contains("FROM db.sc.orders__changes"));
}
#[test]
fn inc_dedup_view_orders_by_cursor_and_never_tombstones_on_every_dialect() {
for wh in WAREHOUSES {
let sql = inc_dedup_view_sql(
wh,
"p.d.orders",
"p.d.orders__changes",
&["id"],
"updated_at",
);
assert!(
sql.contains(&format!("PARTITION BY {}", wh.quote_ident("id"))),
"{wh:?}: {sql}"
);
assert!(
sql.contains(&format!("ORDER BY {} DESC", wh.quote_ident("updated_at"))),
"{wh:?}: {sql}"
);
assert!(sql.contains("FALSE AS __is_deleted"), "{wh:?}: {sql}");
assert!(
!sql.contains("'delete'"),
"{wh:?}: no CDC delete logic: {sql}"
);
let kw = match wh {
Warehouse::BigQuery => "EXCEPT",
Warehouse::Snowflake => "EXCLUDE",
};
assert!(
sql.contains(&format!("{kw} (__op, __pos, __seq, __rn)")),
"{wh:?}: drops the (reused) CDC meta columns: {sql}"
);
}
}
#[test]
fn inc_dedup_view_quotes_identifiers_per_dialect() {
let bq = inc_dedup_view_sql(
Warehouse::BigQuery,
"p.d.o",
"p.d.o__changes",
&["id"],
"ts",
);
assert!(bq.contains("VIEW `p.d.o` AS"));
assert!(bq.contains("FROM `p.d.o__changes`"));
let sf = inc_dedup_view_sql(
Warehouse::Snowflake,
"db.sc.o",
"db.sc.o__changes",
&["id"],
"ts",
);
assert!(!sf.contains('`'), "snowflake bare identifiers: {sf}");
assert!(sf.contains("VIEW db.sc.o AS"));
}
#[test]
fn composite_primary_key_partitions_by_all_columns() {
let sql = dedup_view_sql(
Warehouse::BigQuery,
"v",
"c",
&["tenant", "id"],
SourceEngine::MySql,
);
assert!(sql.contains("PARTITION BY `tenant`, `id`"));
}
#[test]
fn identifiers_are_quoted_per_dialect_so_a_reserved_word_column_is_safe() {
let bq = inc_dedup_view_sql(Warehouse::BigQuery, "v", "c", &["order"], "end");
assert!(bq.contains("PARTITION BY `order`"), "{bq}");
assert!(bq.contains("ORDER BY `end` DESC"), "{bq}");
let sf = inc_dedup_view_sql(Warehouse::Snowflake, "v", "c", &["order"], "end");
assert!(sf.contains("PARTITION BY order"), "{sf}");
assert!(sf.contains("ORDER BY end DESC"), "{sf}");
let cdc = dedup_view_sql(
Warehouse::BigQuery,
"v",
"c",
&["a", "b"],
SourceEngine::MySql,
);
assert!(cdc.contains("PARTITION BY `a`, `b`"), "{cdc}");
}
#[test]
fn meta_column_specs_are_typed_per_warehouse_and_ordered() {
let bq = meta_column_specs(Warehouse::BigQuery);
let names: Vec<&str> = bq.iter().map(|s| s.column_name.as_str()).collect();
assert_eq!(
names,
["__op", "__pos", "__seq"],
"meta columns lead the schema, in order"
);
assert_eq!(bq[0].target_type, "STRING");
assert_eq!(bq[2].target_type, "INT64");
let sf = meta_column_specs(Warehouse::Snowflake);
assert_eq!(sf[1].target_type, "VARCHAR");
assert_eq!(sf[2].target_type, "INTEGER");
}
}