use faucet_core::idempotency::{
COMMIT_TOKEN_SCOPE_COL, COMMIT_TOKEN_TABLE, COMMIT_TOKEN_TOKEN_COL,
};
use gcp_bigquery_client::model::field_type::FieldType;
use gcp_bigquery_client::model::table_field_schema::TableFieldSchema;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldSpec {
pub name: String,
pub ty: BqType,
pub repeated: bool,
pub fields: Vec<FieldSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BqType {
String,
Bytes,
Int64,
Float64,
Numeric,
BigNumeric,
Bool,
Timestamp,
Date,
Time,
Datetime,
Interval,
Geography,
Json,
Struct,
}
impl BqType {
pub fn from_field_type(ft: &FieldType) -> Self {
match ft {
FieldType::String => BqType::String,
FieldType::Bytes => BqType::Bytes,
FieldType::Integer | FieldType::Int64 => BqType::Int64,
FieldType::Float | FieldType::Float64 => BqType::Float64,
FieldType::Numeric => BqType::Numeric,
FieldType::Bignumeric => BqType::BigNumeric,
FieldType::Boolean | FieldType::Bool => BqType::Bool,
FieldType::Timestamp => BqType::Timestamp,
FieldType::Date => BqType::Date,
FieldType::Time => BqType::Time,
FieldType::Datetime => BqType::Datetime,
FieldType::Interval => BqType::Interval,
FieldType::Geography => BqType::Geography,
FieldType::Json => BqType::Json,
FieldType::Record | FieldType::Struct => BqType::Struct,
}
}
fn sql_keyword(&self) -> &'static str {
match self {
BqType::String => "STRING",
BqType::Bytes => "BYTES",
BqType::Int64 => "INT64",
BqType::Float64 => "FLOAT64",
BqType::Numeric => "NUMERIC",
BqType::BigNumeric => "BIGNUMERIC",
BqType::Bool => "BOOL",
BqType::Timestamp => "TIMESTAMP",
BqType::Date => "DATE",
BqType::Time => "TIME",
BqType::Datetime => "DATETIME",
BqType::Interval => "INTERVAL",
BqType::Geography => "GEOGRAPHY",
BqType::Json => "JSON",
BqType::Struct => "STRUCT",
}
}
}
impl FieldSpec {
pub fn from_table_field(f: &TableFieldSchema) -> Self {
let repeated = f.mode.as_deref() == Some("REPEATED");
let ty = BqType::from_field_type(&f.r#type);
let fields = f
.fields
.as_ref()
.map(|sub| sub.iter().map(FieldSpec::from_table_field).collect())
.unwrap_or_default();
FieldSpec {
name: f.name.clone(),
ty,
repeated,
fields,
}
}
}
pub(crate) fn sql_str(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
pub(crate) fn quote_ident(name: &str) -> String {
format!("`{}`", name.replace('`', ""))
}
pub(crate) fn json_path_segment(name: &str) -> String {
let safe = match name.chars().next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
};
if safe {
format!(".{name}")
} else {
let esc = name.replace('\\', "\\\\").replace('\'', "\\'");
format!("['{esc}']")
}
}
pub(crate) fn wrap_scalar(ty: &BqType, var: &str) -> String {
match ty {
BqType::String => var.to_string(),
BqType::Bytes => format!("FROM_BASE64({var})"),
BqType::Geography => format!("ST_GEOGFROMTEXT({var})"),
BqType::Json => format!("PARSE_JSON({var})"),
BqType::Struct => unreachable!("struct is handled by column_expr"),
other => format!("CAST({var} AS {})", other.sql_keyword()),
}
}
fn struct_field_list(
fields: &[FieldSpec],
json_var: &str,
base_path: &str,
depth: usize,
) -> String {
fields
.iter()
.map(|f| {
let child_path = format!("{base_path}{}", json_path_segment(&f.name));
let expr = column_expr(f, json_var, &child_path, depth);
format!("{expr} AS {}", quote_ident(&f.name))
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn column_expr(field: &FieldSpec, json_var: &str, path: &str, depth: usize) -> String {
if field.repeated {
if field.ty == BqType::Struct {
let elem = format!("e{depth}");
let fields = struct_field_list(&field.fields, &elem, "$", depth + 1);
format!(
"ARRAY(SELECT AS STRUCT {fields} FROM UNNEST(JSON_QUERY_ARRAY({json_var}, {p})) AS {elem})",
p = sql_str(path)
)
} else {
let x = format!("x{depth}");
let src = if field.ty == BqType::Json {
format!("JSON_QUERY_ARRAY({json_var}, {p})", p = sql_str(path))
} else {
format!("JSON_VALUE_ARRAY({json_var}, {p})", p = sql_str(path))
};
let elem = wrap_scalar(&field.ty, &x);
format!("ARRAY(SELECT {elem} FROM UNNEST({src}) AS {x})")
}
} else if field.ty == BqType::Struct {
let fields = struct_field_list(&field.fields, json_var, path, depth + 1);
format!("STRUCT({fields})")
} else {
let raw = if field.ty == BqType::Json {
format!("JSON_QUERY({json_var}, {p})", p = sql_str(path))
} else {
format!("JSON_VALUE({json_var}, {p})", p = sql_str(path))
};
wrap_scalar(&field.ty, &raw)
}
}
pub(crate) fn table_ref(project: &str, dataset: &str, table: &str) -> String {
format!("`{project}.{dataset}.{table}`")
}
fn commit_table_ref(project: &str, dataset: &str) -> String {
format!("`{project}.{dataset}.{COMMIT_TOKEN_TABLE}`")
}
pub fn build_create_commit_table(project: &str, dataset: &str) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {t} ({scope} STRING NOT NULL, {token} STRING NOT NULL, updated_at TIMESTAMP)",
t = commit_table_ref(project, dataset),
scope = COMMIT_TOKEN_SCOPE_COL,
token = COMMIT_TOKEN_TOKEN_COL,
)
}
pub fn build_select_token(project: &str, dataset: &str) -> String {
format!(
"SELECT {token} FROM {t} WHERE {scope} = @scope LIMIT 1",
token = COMMIT_TOKEN_TOKEN_COL,
t = commit_table_ref(project, dataset),
scope = COMMIT_TOKEN_SCOPE_COL,
)
}
pub fn build_merge_token(project: &str, dataset: &str) -> String {
format!(
"MERGE {t} T USING (SELECT @scope AS {scope}, @token AS {token}) S ON T.{scope} = S.{scope} \
WHEN MATCHED THEN UPDATE SET {token} = S.{token}, updated_at = CURRENT_TIMESTAMP() \
WHEN NOT MATCHED THEN INSERT ({scope}, {token}, updated_at) VALUES (S.{scope}, S.{token}, CURRENT_TIMESTAMP())",
t = commit_table_ref(project, dataset),
scope = COMMIT_TOKEN_SCOPE_COL,
token = COMMIT_TOKEN_TOKEN_COL,
)
}
fn build_insert_select(columns: &[FieldSpec], project: &str, dataset: &str, table: &str) -> String {
let col_list = columns
.iter()
.map(|f| quote_ident(&f.name))
.collect::<Vec<_>>()
.join(", ");
let exprs = columns
.iter()
.map(|f| {
let path = format!("${}", json_path_segment(&f.name));
column_expr(f, "r", &path, 0)
})
.collect::<Vec<_>>()
.join(",\n ");
format!(
"INSERT INTO {t} ({col_list})\nSELECT\n {exprs}\nFROM UNNEST(JSON_QUERY_ARRAY(@payload)) AS r",
t = table_ref(project, dataset, table),
)
}
pub fn build_transaction_sql(
columns: &[FieldSpec],
project: &str,
dataset: &str,
table: &str,
) -> String {
format!(
"BEGIN TRANSACTION;\n{insert};\n{merge};\nCOMMIT TRANSACTION;",
insert = build_insert_select(columns, project, dataset, table),
merge = build_merge_token(project, dataset),
)
}
pub fn build_request_id(scope: &str, token: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in scope.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
let safe_scope: String = scope
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.take(64)
.collect();
format!("faucet_eo_{safe_scope}_{h:016x}_{token}")
}
use serde_json::{Map, Value, json};
fn bq_to_json_base(ty: &BqType) -> &'static str {
match ty {
BqType::Int64 => "integer",
BqType::Float64 | BqType::Numeric | BqType::BigNumeric => "number",
BqType::Bool => "boolean",
BqType::Struct => "object",
_ => "string",
}
}
pub fn fieldspecs_to_json_schema(fields: &[FieldSpec]) -> Value {
let mut props = Map::new();
for f in fields {
let fragment = if f.repeated {
json!({ "type": ["array", "null"] })
} else {
json!({ "type": [bq_to_json_base(&f.ty), "null"] })
};
props.insert(f.name.clone(), fragment);
}
json!({ "type": "object", "properties": Value::Object(props) })
}
pub fn base_to_bq(t: faucet_core::SqlBaseType) -> &'static str {
use faucet_core::SqlBaseType::*;
match t {
Integer => "INT64",
Double => "FLOAT64",
Boolean => "BOOL",
Text => "STRING",
Json => "JSON",
}
}
pub fn build_add_column_ddl(table_ref: &str, col: &str, bq_type: &str) -> String {
format!(
"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS {} {bq_type}",
quote_ident(col)
)
}
pub fn build_alter_type_ddl(table_ref: &str, col: &str, bq_type: &str) -> String {
format!(
"ALTER TABLE {table_ref} ALTER COLUMN {} SET DATA TYPE {bq_type}",
quote_ident(col)
)
}
pub fn build_drop_not_null_ddl(table_ref: &str, col: &str) -> String {
format!(
"ALTER TABLE {table_ref} ALTER COLUMN {} DROP NOT NULL",
quote_ident(col)
)
}
#[cfg(test)]
mod tests {
use super::*;
fn scalar(name: &str, ty: BqType) -> FieldSpec {
FieldSpec {
name: name.into(),
ty,
repeated: false,
fields: vec![],
}
}
#[test]
fn field_type_aliases_collapse() {
assert_eq!(BqType::from_field_type(&FieldType::Integer), BqType::Int64);
assert_eq!(BqType::from_field_type(&FieldType::Int64), BqType::Int64);
assert_eq!(BqType::from_field_type(&FieldType::Float), BqType::Float64);
assert_eq!(BqType::from_field_type(&FieldType::Boolean), BqType::Bool);
assert_eq!(BqType::from_field_type(&FieldType::Record), BqType::Struct);
assert_eq!(BqType::from_field_type(&FieldType::Struct), BqType::Struct);
}
#[test]
fn from_table_field_reads_mode_and_nested_fields() {
let tf = TableFieldSchema {
name: "addr".into(),
r#type: FieldType::Record,
mode: Some("REPEATED".into()),
fields: Some(vec![TableFieldSchema::string("city")]),
categories: None,
description: None,
policy_tags: None,
};
let fs = FieldSpec::from_table_field(&tf);
assert_eq!(
fs,
FieldSpec {
name: "addr".into(),
ty: BqType::Struct,
repeated: true,
fields: vec![scalar("city", BqType::String)],
}
);
}
fn repeated(name: &str, ty: BqType) -> FieldSpec {
FieldSpec {
name: name.into(),
ty,
repeated: true,
fields: vec![],
}
}
fn record(name: &str, repeated: bool, fields: Vec<FieldSpec>) -> FieldSpec {
FieldSpec {
name: name.into(),
ty: BqType::Struct,
repeated,
fields,
}
}
#[test]
fn scalar_exprs_per_type() {
assert_eq!(
column_expr(&scalar("s", BqType::String), "r", "$.s", 0),
"JSON_VALUE(r, '$.s')"
);
assert_eq!(
column_expr(&scalar("n", BqType::Int64), "r", "$.n", 0),
"CAST(JSON_VALUE(r, '$.n') AS INT64)"
);
assert_eq!(
column_expr(&scalar("f", BqType::Float64), "r", "$.f", 0),
"CAST(JSON_VALUE(r, '$.f') AS FLOAT64)"
);
assert_eq!(
column_expr(&scalar("b", BqType::Bool), "r", "$.b", 0),
"CAST(JSON_VALUE(r, '$.b') AS BOOL)"
);
assert_eq!(
column_expr(&scalar("ts", BqType::Timestamp), "r", "$.ts", 0),
"CAST(JSON_VALUE(r, '$.ts') AS TIMESTAMP)"
);
assert_eq!(
column_expr(&scalar("by", BqType::Bytes), "r", "$.by", 0),
"FROM_BASE64(JSON_VALUE(r, '$.by'))"
);
assert_eq!(
column_expr(&scalar("g", BqType::Geography), "r", "$.g", 0),
"ST_GEOGFROMTEXT(JSON_VALUE(r, '$.g'))"
);
assert_eq!(
column_expr(&scalar("j", BqType::Json), "r", "$.j", 0),
"PARSE_JSON(JSON_QUERY(r, '$.j'))"
);
}
#[test]
fn repeated_scalar_exprs() {
assert_eq!(
column_expr(&repeated("xs", BqType::String), "r", "$.xs", 0),
"ARRAY(SELECT x0 FROM UNNEST(JSON_VALUE_ARRAY(r, '$.xs')) AS x0)"
);
assert_eq!(
column_expr(&repeated("ns", BqType::Int64), "r", "$.ns", 0),
"ARRAY(SELECT CAST(x0 AS INT64) FROM UNNEST(JSON_VALUE_ARRAY(r, '$.ns')) AS x0)"
);
assert_eq!(
column_expr(&repeated("js", BqType::Json), "r", "$.js", 0),
"ARRAY(SELECT PARSE_JSON(x0) FROM UNNEST(JSON_QUERY_ARRAY(r, '$.js')) AS x0)"
);
}
#[test]
fn nested_struct_expr() {
let f = record(
"addr",
false,
vec![scalar("city", BqType::String), scalar("zip", BqType::Int64)],
);
assert_eq!(
column_expr(&f, "r", "$.addr", 0),
"STRUCT(JSON_VALUE(r, '$.addr.city') AS `city`, CAST(JSON_VALUE(r, '$.addr.zip') AS INT64) AS `zip`)"
);
}
#[test]
fn repeated_record_expr_uses_unnest_element() {
let f = record(
"items",
true,
vec![scalar("sku", BqType::String), scalar("qty", BqType::Int64)],
);
assert_eq!(
column_expr(&f, "r", "$.items", 0),
"ARRAY(SELECT AS STRUCT JSON_VALUE(e0, '$.sku') AS `sku`, CAST(JSON_VALUE(e0, '$.qty') AS INT64) AS `qty` FROM UNNEST(JSON_QUERY_ARRAY(r, '$.items')) AS e0)"
);
}
#[test]
fn nested_repeated_record_aliases_are_unique() {
let inner = repeated("tags", BqType::String);
let f = record("groups", true, vec![inner]);
let sql = column_expr(&f, "r", "$.groups", 0);
assert_eq!(
sql,
"ARRAY(SELECT AS STRUCT ARRAY(SELECT x1 FROM UNNEST(JSON_VALUE_ARRAY(e0, '$.tags')) AS x1) AS `tags` FROM UNNEST(JSON_QUERY_ARRAY(r, '$.groups')) AS e0)"
);
}
#[test]
fn unsafe_member_name_uses_bracket_path() {
assert_eq!(json_path_segment("a.b"), "['a.b']");
assert_eq!(json_path_segment("ok_name"), ".ok_name");
assert_eq!(json_path_segment("_lead"), "._lead");
assert_eq!(json_path_segment("1bad"), "['1bad']");
}
#[test]
fn create_commit_table_sql() {
assert_eq!(
build_create_commit_table("p", "d"),
"CREATE TABLE IF NOT EXISTS `p.d._faucet_commit_token` (scope STRING NOT NULL, token STRING NOT NULL, updated_at TIMESTAMP)"
);
}
#[test]
fn select_token_sql() {
assert_eq!(
build_select_token("p", "d"),
"SELECT token FROM `p.d._faucet_commit_token` WHERE scope = @scope LIMIT 1"
);
}
#[test]
fn merge_token_sql() {
assert_eq!(
build_merge_token("p", "d"),
"MERGE `p.d._faucet_commit_token` T USING (SELECT @scope AS scope, @token AS token) S ON T.scope = S.scope WHEN MATCHED THEN UPDATE SET token = S.token, updated_at = CURRENT_TIMESTAMP() WHEN NOT MATCHED THEN INSERT (scope, token, updated_at) VALUES (S.scope, S.token, CURRENT_TIMESTAMP())"
);
}
#[test]
fn transaction_sql_wraps_insert_and_merge() {
let cols = vec![scalar("id", BqType::Int64), scalar("name", BqType::String)];
let sql = build_transaction_sql(&cols, "p", "d", "t");
assert!(sql.starts_with("BEGIN TRANSACTION;\n"), "got: {sql}");
assert!(
sql.contains("INSERT INTO `p.d.t` (`id`, `name`)"),
"got: {sql}"
);
assert!(
sql.contains("FROM UNNEST(JSON_QUERY_ARRAY(@payload)) AS r"),
"got: {sql}"
);
assert!(
sql.contains("MERGE `p.d._faucet_commit_token` T"),
"got: {sql}"
);
assert!(
sql.trim_end().ends_with("COMMIT TRANSACTION;"),
"got: {sql}"
);
let i = sql.find("INSERT INTO").unwrap();
let m = sql.find("MERGE").unwrap();
let c = sql.find("COMMIT TRANSACTION").unwrap();
assert!(i < m && m < c, "statement order wrong: {sql}");
}
#[test]
fn request_id_is_deterministic_and_sanitized() {
let a = build_request_id("pipe::row1", "00000000000000000007");
let b = build_request_id("pipe::row1", "00000000000000000007");
assert_eq!(a, b, "must be deterministic across calls/processes");
assert!(
a.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"request_id must be sanitized: {a}"
);
assert!(a.ends_with("_00000000000000000007"), "got: {a}");
assert_ne!(a, build_request_id("pipe::row2", "00000000000000000007"));
}
#[test]
fn sql_str_escapes_backslash_then_quote() {
assert_eq!(sql_str("a'b"), "'a\\'b'");
assert_eq!(sql_str("a\\b"), "'a\\\\b'");
assert_eq!(sql_str("$.ok"), "'$.ok'");
}
#[test]
fn fieldspec_to_json_schema_maps_types_and_nullability() {
let fields = vec![
scalar("id", BqType::Int64),
scalar("score", BqType::Float64),
scalar("amount", BqType::Numeric),
scalar("flag", BqType::Bool),
scalar("name", BqType::String),
scalar("ts", BqType::Timestamp),
repeated("tags", BqType::String),
record("addr", false, vec![scalar("city", BqType::String)]),
];
let js = fieldspecs_to_json_schema(&fields);
assert_eq!(js["type"], "object");
let p = &js["properties"];
assert_eq!(p["id"]["type"], json!(["integer", "null"]));
assert_eq!(p["score"]["type"], json!(["number", "null"]));
assert_eq!(p["amount"]["type"], json!(["number", "null"]));
assert_eq!(p["flag"]["type"], json!(["boolean", "null"]));
assert_eq!(p["name"]["type"], json!(["string", "null"]));
assert_eq!(p["ts"]["type"], json!(["string", "null"]));
assert_eq!(p["tags"]["type"], json!(["array", "null"]));
assert_eq!(p["addr"]["type"], json!(["object", "null"]));
}
#[test]
fn fieldspec_to_json_schema_empty_fields() {
let js = fieldspecs_to_json_schema(&[]);
assert_eq!(js, json!({ "type": "object", "properties": {} }));
}
#[test]
fn json_schema_to_bq_type_per_base() {
use faucet_core::SqlBaseType::*;
assert_eq!(base_to_bq(Integer), "INT64");
assert_eq!(base_to_bq(Double), "FLOAT64");
assert_eq!(base_to_bq(Boolean), "BOOL");
assert_eq!(base_to_bq(Text), "STRING");
assert_eq!(base_to_bq(Json), "JSON");
}
#[test]
fn add_column_ddl_is_idempotent_and_quoted() {
assert_eq!(
build_add_column_ddl("`p.d.t`", "email", "STRING"),
"ALTER TABLE `p.d.t` ADD COLUMN IF NOT EXISTS `email` STRING"
);
}
#[test]
fn alter_type_ddl_sets_data_type() {
assert_eq!(
build_alter_type_ddl("`p.d.t`", "score", "FLOAT64"),
"ALTER TABLE `p.d.t` ALTER COLUMN `score` SET DATA TYPE FLOAT64"
);
}
#[test]
fn drop_not_null_ddl() {
assert_eq!(
build_drop_not_null_ddl("`p.d.t`", "created_at"),
"ALTER TABLE `p.d.t` ALTER COLUMN `created_at` DROP NOT NULL"
);
}
}