1use arrow::datatypes::DataType;
32use serde::Serialize;
33
34use super::{RivetType, TimeUnit, TypeFidelity, TypeMapping};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ExportTarget {
40 DuckDb,
43 BigQuery,
46 Snowflake,
49 ClickHouse,
55}
56
57impl ExportTarget {
58 pub fn parse(s: &str) -> Option<Self> {
59 match s.to_lowercase().as_str() {
60 "bigquery" | "bq" => Some(Self::BigQuery),
61 "duckdb" | "duck" => Some(Self::DuckDb),
62 "snowflake" | "sf" => Some(Self::Snowflake),
63 "clickhouse" | "ch" => Some(Self::ClickHouse),
64 _ => None,
65 }
66 }
67
68 pub fn valid_target_names() -> &'static str {
73 "bigquery (bq), duckdb (duck), snowflake (sf), clickhouse (ch)"
74 }
75
76 pub fn label(self) -> &'static str {
77 match self {
78 Self::BigQuery => "bigquery",
79 Self::DuckDb => "duckdb",
80 Self::Snowflake => "snowflake",
81 Self::ClickHouse => "clickhouse",
82 }
83 }
84
85 pub fn resolve_column(self, input: TargetInput<'_>) -> TargetColumnSpec {
87 let mut spec = match self {
88 ExportTarget::BigQuery => bigquery::resolve(&input),
89 ExportTarget::DuckDb => duckdb::resolve(&input),
90 ExportTarget::Snowflake => snowflake::resolve(&input),
91 ExportTarget::ClickHouse => clickhouse::resolve(&input),
92 };
93 if input.fidelity.is_unsafe_for_strict_mode() && spec.status == TargetStatus::Ok {
97 spec.status = TargetStatus::Warn;
98 }
99 spec
100 }
101
102 pub fn resolve_table(self, mappings: &[TypeMapping]) -> Vec<TargetColumnSpec> {
107 mappings
108 .iter()
109 .map(|m| self.resolve_column(TargetInput::from(m)))
110 .collect()
111 }
112
113 pub fn recovery_sql(self, specs: &[TargetColumnSpec], table: &str) -> Option<String> {
121 match self {
122 ExportTarget::BigQuery => Some(bigquery_recovery_sql(specs, table)),
123 ExportTarget::Snowflake => Some(snowflake_recovery_sql(specs, table)),
124 ExportTarget::ClickHouse => Some(clickhouse_recovery_sql(specs, table)),
125 ExportTarget::DuckDb => None,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
132#[serde(rename_all = "snake_case")]
133pub enum TargetStatus {
134 Ok,
135 Warn,
136 Fail,
137}
138
139impl TargetStatus {
140 pub fn label(&self) -> &'static str {
141 match self {
142 Self::Ok => "ok",
143 Self::Warn => "warn",
144 Self::Fail => "fail",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy)]
154pub struct TargetInput<'a> {
155 pub column_name: &'a str,
156 pub rivet_type: &'a RivetType,
157 #[allow(dead_code)]
160 pub arrow_type: Option<&'a DataType>,
161 pub fidelity: TypeFidelity,
162}
163
164impl<'a> From<&'a TypeMapping> for TargetInput<'a> {
165 fn from(m: &'a TypeMapping) -> Self {
166 TargetInput {
167 column_name: &m.column_name,
168 rivet_type: &m.rivet_type,
169 arrow_type: m.arrow_type.as_ref(),
170 fidelity: m.fidelity,
171 }
172 }
173}
174
175#[derive(Debug, Clone, Serialize)]
179pub struct TargetColumnSpec {
180 pub column_name: String,
182 pub target_type: String,
184 pub autoload_type: String,
187 pub status: TargetStatus,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub note: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
193 pub cast_sql: Option<String>,
194}
195
196struct Resolved {
199 target_type: String,
200 autoload_type: String,
201 status: TargetStatus,
202 note: Option<String>,
203 cast: Option<String>,
205}
206
207impl Resolved {
208 fn ok(t: impl Into<String>) -> Self {
209 let t = t.into();
210 Self {
211 autoload_type: t.clone(),
212 target_type: t,
213 status: TargetStatus::Ok,
214 note: None,
215 cast: None,
216 }
217 }
218 fn diverge(
221 native: impl Into<String>,
222 autoload: impl Into<String>,
223 note: impl Into<String>,
224 cast: Option<&str>,
225 ) -> Self {
226 Self {
227 target_type: native.into(),
228 autoload_type: autoload.into(),
229 status: TargetStatus::Warn,
230 note: Some(note.into()),
231 cast: cast.map(str::to_string),
232 }
233 }
234 fn warn(t: impl Into<String>, note: impl Into<String>) -> Self {
235 let t = t.into();
236 Self {
237 autoload_type: t.clone(),
238 target_type: t,
239 status: TargetStatus::Warn,
240 note: Some(note.into()),
241 cast: None,
242 }
243 }
244 fn fail(note: impl Into<String>) -> Self {
245 Self {
246 target_type: "-".into(),
247 autoload_type: "-".into(),
248 status: TargetStatus::Fail,
249 note: Some(note.into()),
250 cast: None,
251 }
252 }
253 fn into_spec(self, input: &TargetInput<'_>) -> TargetColumnSpec {
254 TargetColumnSpec {
255 column_name: input.column_name.to_string(),
256 target_type: self.target_type,
257 autoload_type: self.autoload_type,
258 status: self.status,
259 note: self.note,
260 cast_sql: self.cast.map(|t| t.replace("{col}", input.column_name)),
261 }
262 }
263}
264
265fn unsupported_reason(t: &RivetType) -> String {
266 match t {
267 RivetType::Unsupported { reason, .. } => reason.clone(),
268 _ => "no target mapping".into(),
269 }
270}
271
272fn recovery_projection(specs: &[TargetColumnSpec], passthrough: impl Fn(&str) -> String) -> String {
287 specs
288 .iter()
289 .map(|s| match &s.cast_sql {
290 Some(cast) => format!(" {cast} AS {name}", name = s.column_name),
291 None => passthrough(&s.column_name),
292 })
293 .collect::<Vec<_>>()
294 .join(",\n")
295}
296
297fn bigquery_recovery_sql(specs: &[TargetColumnSpec], table: &str) -> String {
298 let cols = recovery_projection(specs, |name| format!(" {name}"));
299 format!(
300 "-- 1) bq load --autodetect --parquet_enable_list_inference \
301 --source_format=PARQUET {table}__staging <parquet>\n\
302 -- 2) recover native types:\n\
303 CREATE OR REPLACE TABLE `{table}` AS\n\
304 SELECT\n{cols}\n\
305 FROM `{table}__staging`;"
306 )
307}
308
309fn snowflake_recovery_sql(specs: &[TargetColumnSpec], table: &str) -> String {
315 let cols = recovery_projection(specs, |name| format!(" \"{name}\" AS {name}"));
316 format!(
317 "-- 1) ALTER SESSION SET TIMEZONE='UTC';\n\
318 -- 2) CREATE OR REPLACE FILE FORMAT rivet_pq TYPE=PARQUET BINARY_AS_TEXT=FALSE;\n\
319 -- 3) PUT file://<parquet> @<stage> AUTO_COMPRESS=FALSE;\n\
320 -- 4) CREATE OR REPLACE TABLE {table}__staging USING TEMPLATE (SELECT ARRAY_AGG(\n\
321 -- OBJECT_CONSTRUCT(*)) FROM TABLE(INFER_SCHEMA(LOCATION=>'@<stage>', FILE_FORMAT=>'rivet_pq')));\n\
322 -- COPY INTO {table}__staging FROM @<stage> FILE_FORMAT=(FORMAT_NAME='rivet_pq') MATCH_BY_COLUMN_NAME=CASE_INSENSITIVE;\n\
323 -- 5) recover native types:\n\
324 CREATE OR REPLACE TABLE {table} AS\n\
325 SELECT\n{cols}\n\
326 FROM {table}__staging;"
327 )
328}
329
330mod bigquery {
333 use super::*;
334
335 const NUMERIC_MAX_P: u8 = 29;
337 const NUMERIC_MAX_S: i8 = 9;
338 const BIGNUMERIC_MAX_P: u8 = 76;
340 const BIGNUMERIC_MAX_S: i8 = 38;
341
342 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
343 native(input.rivet_type).into_spec(input)
344 }
345
346 fn native(t: &RivetType) -> Resolved {
347 match t {
348 RivetType::Bool => Resolved::ok("BOOL"),
349 RivetType::Int16 | RivetType::Int32 | RivetType::Int64 => Resolved::ok("INT64"),
350 RivetType::UInt64 => Resolved::diverge(
355 "NUMERIC",
356 "INT64",
357 "UINT64 > INT64_MAX overflows the INT64 autoload and cannot be recovered after \
358 load — map the column to decimal(20,0) with a source column override",
359 None,
360 ),
361 RivetType::Float32 | RivetType::Float64 => Resolved::ok("FLOAT64"),
362 RivetType::Decimal { precision, scale } => decimal(*precision, *scale),
363 RivetType::Date => Resolved::ok("DATE"),
364 RivetType::Time { .. } => Resolved::ok("TIME"),
365 RivetType::Timestamp {
367 timezone: Some(_), ..
368 } => Resolved::ok("TIMESTAMP"),
369 RivetType::Timestamp {
378 unit: TimeUnit::Nanosecond,
379 timezone: None,
380 } => Resolved::diverge(
381 "INT64",
382 "INT64",
383 "nanosecond timestamp has no BigQuery native type — autoloads as INT64 (raw \
384 nanos, lossless); a native TIMESTAMP via TIMESTAMP_MICROS(DIV(col,1000)) drops \
385 sub-µs precision. Prefer `timestamp` (microsecond) for BigQuery targets.",
386 None,
387 ),
388 RivetType::Timestamp { timezone: None, .. } => Resolved::diverge(
392 "DATETIME",
393 "TIMESTAMP",
394 "naive timestamp autoloads as TIMESTAMP (an instant); recover wall-clock with \
395 DATETIME(col) after load",
396 Some("DATETIME({col})"),
397 ),
398 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("STRING"),
399 RivetType::Binary => Resolved::ok("BYTES"),
400 RivetType::Json => Resolved::diverge(
403 "JSON",
404 "BYTES",
405 "Parquet JSON logical type autoloads as BYTES in BigQuery; recover native JSON \
406 with PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING(col)) after load",
407 Some("PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING({col}))"),
408 ),
409 RivetType::Uuid => Resolved::diverge(
413 "STRING",
414 "BYTES",
415 "UUID autoloads as 16-byte BYTES in BigQuery; recover hex text with TO_HEX(col) \
416 after load (or keep BYTES)",
417 Some("TO_HEX({col})"),
418 ),
419 RivetType::Interval => Resolved::ok("STRING"),
420 RivetType::List { inner } => list(inner),
421 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
422 }
423 }
424
425 fn decimal(p: u8, s: i8) -> Resolved {
426 if s < 0 {
427 return Resolved::fail(format!(
428 "BigQuery has no negative scale; decimal({p},{s}) needs a STRING/INT64 cast"
429 ));
430 }
431 let native = if p <= NUMERIC_MAX_P && s <= NUMERIC_MAX_S {
432 "NUMERIC"
433 } else if p <= BIGNUMERIC_MAX_P && s <= BIGNUMERIC_MAX_S {
434 "BIGNUMERIC"
435 } else {
436 return Resolved::fail(format!(
437 "decimal({p},{s}) exceeds BigQuery BIGNUMERIC limits (max 76,38)"
438 ));
439 };
440 Resolved::ok(native)
441 }
442
443 fn list(inner: &RivetType) -> Resolved {
444 let inner_r = native(inner);
445 if inner_r.status == TargetStatus::Fail {
446 return Resolved::fail(format!(
447 "REPEATED of unsupported element: {}",
448 inner_r.target_type
449 ));
450 }
451 Resolved::diverge(
458 format!("REPEATED {}", inner_r.target_type),
459 format!("REPEATED RECORD{{item {}}}", inner_r.autoload_type),
460 "arrays load as REPEATED RECORD{item}; load the staging table with \
461 --parquet_enable_list_inference, then flatten with UNNEST after load",
462 Some("ARRAY(SELECT el.item FROM UNNEST({col}) AS el)"),
463 )
464 }
465}
466
467mod duckdb {
470 use super::*;
471
472 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
473 native(input.rivet_type).into_spec(input)
474 }
475
476 fn native(t: &RivetType) -> Resolved {
479 match t {
480 RivetType::Bool => Resolved::ok("BOOLEAN"),
481 RivetType::Int16 => Resolved::ok("SMALLINT"),
482 RivetType::Int32 => Resolved::ok("INTEGER"),
483 RivetType::Int64 => Resolved::ok("BIGINT"),
484 RivetType::UInt64 => Resolved::ok("UBIGINT"),
485 RivetType::Float32 => Resolved::ok("FLOAT"),
486 RivetType::Float64 => Resolved::ok("DOUBLE"),
487 RivetType::Decimal { precision, scale } => {
488 if *scale < 0 {
489 Resolved::warn(
490 "DECIMAL",
491 format!(
492 "DuckDB has no negative scale; decimal({precision},{scale}) loads via cast"
493 ),
494 )
495 } else if *precision <= 38 {
496 Resolved::ok(format!("DECIMAL({precision},{scale})"))
497 } else {
498 Resolved::diverge(
503 "DECIMAL(38,*)",
504 "DOUBLE",
505 format!(
506 "decimal({precision},{scale}) exceeds DuckDB DECIMAL(38); autoloads \
507 as DOUBLE (lossy past 2^53) — narrow the source precision if exact \
508 decimals matter"
509 ),
510 None,
511 )
512 }
513 }
514 RivetType::Date => Resolved::ok("DATE"),
515 RivetType::Time { .. } => Resolved::ok("TIME"),
516 RivetType::Timestamp {
517 timezone: Some(_), ..
518 } => Resolved::ok("TIMESTAMPTZ"),
519 RivetType::Timestamp {
522 unit: TimeUnit::Nanosecond,
523 timezone: None,
524 } => Resolved::ok("TIMESTAMP_NS"),
525 RivetType::Timestamp { timezone: None, .. } => Resolved::ok("TIMESTAMP"),
526 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("VARCHAR"),
527 RivetType::Binary => Resolved::ok("BLOB"),
528 RivetType::Json => Resolved::ok("JSON"),
529 RivetType::Uuid => Resolved::ok("UUID"),
530 RivetType::Interval => Resolved::ok("INTERVAL"),
531 RivetType::List { inner } => {
532 let inner_r = native(inner);
533 if inner_r.status == TargetStatus::Fail {
534 Resolved::fail(format!(
535 "LIST of unsupported element: {}",
536 inner_r.target_type
537 ))
538 } else {
539 Resolved::ok(format!("{}[]", inner_r.target_type))
540 }
541 }
542 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
543 }
544 }
545}
546
547mod snowflake {
550 use super::*;
551
552 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
553 native(input.rivet_type).into_spec(input)
554 }
555
556 fn native(t: &RivetType) -> Resolved {
561 match t {
562 RivetType::Bool => Resolved::ok("BOOLEAN"),
563 RivetType::Int16 | RivetType::Int32 | RivetType::Int64 => Resolved::ok("NUMBER(38,0)"),
564 RivetType::UInt64 => Resolved::diverge(
566 "NUMBER(20,0)",
567 "NUMBER(38,0)",
568 "UINT64 > INT64_MAX overflows the Parquet read; map to decimal(20,0) at source",
569 None,
570 ),
571 RivetType::Float32 | RivetType::Float64 => Resolved::ok("FLOAT"),
572 RivetType::Decimal { precision, scale } => {
573 if *scale < 0 {
574 Resolved::warn(
575 "NUMBER",
576 format!(
577 "Snowflake NUMBER has no negative scale; decimal({precision},{scale}) loads via cast"
578 ),
579 )
580 } else if *precision > 38 {
581 Resolved::fail(format!(
586 "decimal({precision},{scale}) exceeds Snowflake NUMBER (max precision 38); \
587 narrow the source precision, or load as FLOAT via a declared schema (lossy)"
588 ))
589 } else {
590 Resolved::ok(format!("NUMBER({precision},{scale})"))
591 }
592 }
593 RivetType::Date => Resolved::ok("DATE"),
594 RivetType::Time { .. } => Resolved::diverge(
596 "TIME",
597 "NUMBER(38,0)",
598 "TIME autoloads as NUMBER (µs of day); recover with TIME_FROM_PARTS after load",
599 Some(r#"TIME_FROM_PARTS(0,0,FLOOR("{col}"/1000000),MOD("{col}",1000000)*1000)"#),
600 ),
601 RivetType::Timestamp {
603 timezone: Some(_), ..
604 } => Resolved::diverge(
605 "TIMESTAMP_TZ",
606 "TIMESTAMP_NTZ",
607 "tz timestamp autoloads as TIMESTAMP_NTZ — ALTER SESSION SET TIMEZONE='UTC' before COPY so the instant matches",
608 None,
609 ),
610 RivetType::Timestamp {
616 unit: TimeUnit::Nanosecond,
617 timezone: None,
618 } => Resolved::diverge(
619 "TIMESTAMP_NTZ",
620 "NUMBER(38,0)",
621 "nanosecond timestamp autoloads as NUMBER (ns since epoch); recover with \
622 TO_TIMESTAMP_NTZ(col, 9) after load — Snowflake TIMESTAMP_NTZ holds full ns precision",
623 Some(r#"TO_TIMESTAMP_NTZ("{col}", 9)"#),
624 ),
625 RivetType::Timestamp { timezone: None, .. } => Resolved::diverge(
627 "TIMESTAMP_NTZ",
628 "NUMBER(38,0)",
629 "naive timestamp autoloads as NUMBER (µs since epoch); recover with TO_TIMESTAMP_NTZ after load",
630 Some(r#"TO_TIMESTAMP_NTZ("{col}", 6)"#),
631 ),
632 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("TEXT"),
633 RivetType::Binary => Resolved::warn(
635 "BINARY",
636 "set BINARY_AS_TEXT=FALSE in the Parquet FILE FORMAT or non-UTF8 bytes fail to load",
637 ),
638 RivetType::Json => Resolved::diverge(
640 "VARIANT",
641 "TEXT",
642 "JSON autoloads as TEXT; recover native VARIANT with PARSE_JSON after load",
643 Some(r#"PARSE_JSON("{col}")"#),
644 ),
645 RivetType::Uuid => Resolved::diverge(
647 "TEXT",
648 "BINARY",
649 "UUID autoloads as 16-byte BINARY; recover canonical text with HEX_ENCODE + REGEXP after load",
650 Some(
651 r#"REGEXP_REPLACE(LOWER(HEX_ENCODE("{col}")),'^(.{8})(.{4})(.{4})(.{4})(.{12})$','\\1-\\2-\\3-\\4-\\5')"#,
652 ),
653 ),
654 RivetType::Interval => Resolved::ok("TEXT"),
655 RivetType::List { inner } => {
660 let inner_r = native(inner);
661 if inner_r.status == TargetStatus::Fail {
662 Resolved::fail(format!(
663 "ARRAY of unsupported element: {}",
664 inner_r.target_type
665 ))
666 } else {
667 Resolved::diverge(
668 "ARRAY",
669 "VARIANT",
670 "list autoloads as VARIANT (the JSON array); recover native ARRAY with ::ARRAY after load",
671 Some(r#""{col}"::ARRAY"#),
672 )
673 }
674 }
675 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
676 }
677 }
678}
679
680fn clickhouse_recovery_sql(specs: &[TargetColumnSpec], table: &str) -> String {
683 let cols = recovery_projection(specs, |name| format!(" {name}"));
684 format!(
685 "-- 1) load the Parquet into a staging table, e.g.\n\
686 -- CREATE TABLE {table}__staging ENGINE = MergeTree ORDER BY tuple() AS\n\
687 -- SELECT * FROM file('<parquet>', 'Parquet');\n\
688 -- 2) recover native types:\n\
689 CREATE TABLE {table} ENGINE = MergeTree ORDER BY tuple() AS\n\
690 SELECT\n{cols}\n\
691 FROM {table}__staging;"
692 )
693}
694
695mod clickhouse {
696 use super::*;
697
698 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
699 native(input.rivet_type).into_spec(input)
700 }
701
702 fn native(t: &RivetType) -> Resolved {
708 match t {
709 RivetType::Bool => Resolved::ok("Bool"),
710 RivetType::Int16 => Resolved::ok("Int16"),
711 RivetType::Int32 => Resolved::ok("Int32"),
712 RivetType::Int64 => Resolved::ok("Int64"),
713 RivetType::UInt64 => Resolved::ok("UInt64"),
717 RivetType::Float32 => Resolved::ok("Float32"),
718 RivetType::Float64 => Resolved::ok("Float64"),
719 RivetType::Decimal { precision, scale } => {
720 if *scale < 0 {
721 Resolved::warn(
722 "Decimal",
723 format!(
724 "ClickHouse Decimal has no negative scale; decimal({precision},{scale}) needs a declared schema"
725 ),
726 )
727 } else if *precision > 76 {
728 Resolved::fail(format!(
732 "decimal({precision},{scale}) exceeds ClickHouse Decimal (max precision 76); \
733 narrow the source precision"
734 ))
735 } else {
736 Resolved::ok(format!("Decimal({precision}, {scale})"))
737 }
738 }
739 RivetType::Date => Resolved::ok("Date32"),
740 RivetType::Time { .. } => Resolved::warn(
743 "Int64",
744 "ClickHouse has no TIME type; time-of-day autoloads as Int64 (µs of day)",
745 ),
746 RivetType::Timestamp { unit, timezone } => {
749 let p = match unit {
750 TimeUnit::Second => 0,
751 TimeUnit::Millisecond => 3,
752 TimeUnit::Microsecond => 6,
753 TimeUnit::Nanosecond => 9,
754 };
755 match timezone {
756 Some(tz) => Resolved::ok(format!("DateTime64({p}, '{tz}')")),
757 None => Resolved::ok(format!("DateTime64({p})")),
758 }
759 }
760 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("String"),
761 RivetType::Binary => Resolved::ok("String"),
764 RivetType::Json => Resolved::diverge(
769 "JSON",
770 "String",
771 "JSON autoloads as String (valid JSON text); declare a JSON column at load for the native type",
772 None,
773 ),
774 RivetType::Uuid => Resolved::diverge(
778 "UUID",
779 "FixedString(16)",
780 "UUID autoloads as FixedString(16); recover the native UUID with toUUID after load",
781 Some(
782 "toUUID(concat(substring(lower(hex({col})),1,8),'-',substring(lower(hex({col})),9,4),'-',substring(lower(hex({col})),13,4),'-',substring(lower(hex({col})),17,4),'-',substring(lower(hex({col})),21,12)))",
783 ),
784 ),
785 RivetType::Interval => Resolved::ok("String"),
786 RivetType::List { inner } => {
789 let inner_r = native(inner);
790 if inner_r.status == TargetStatus::Fail {
791 Resolved::fail(format!(
792 "Array of unsupported element: {}",
793 inner_r.target_type
794 ))
795 } else {
796 Resolved::ok(format!("Array(Nullable({}))", inner_r.target_type))
797 }
798 }
799 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
800 }
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 fn input<'a>(rt: &'a RivetType) -> TargetInput<'a> {
809 TargetInput {
810 column_name: "c",
811 rivet_type: rt,
812 arrow_type: None,
813 fidelity: TypeFidelity::Exact,
814 }
815 }
816
817 fn bq(rt: &RivetType) -> TargetColumnSpec {
818 ExportTarget::BigQuery.resolve_column(input(rt))
819 }
820 fn duck(rt: &RivetType) -> TargetColumnSpec {
821 ExportTarget::DuckDb.resolve_column(input(rt))
822 }
823 fn sf(rt: &RivetType) -> TargetColumnSpec {
824 ExportTarget::Snowflake.resolve_column(input(rt))
825 }
826 fn ch(rt: &RivetType) -> TargetColumnSpec {
827 ExportTarget::ClickHouse.resolve_column(input(rt))
828 }
829
830 #[test]
836 fn bq_nanosecond_timestamp_autoloads_as_int64() {
837 let ns = RivetType::Timestamp {
838 unit: super::super::TimeUnit::Nanosecond,
839 timezone: None,
840 };
841 let s = bq(&ns);
842 assert_eq!(s.target_type, "INT64");
843 assert_eq!(s.autoload_type, "INT64");
844 assert_eq!(s.status, TargetStatus::Warn);
845 assert!(s.cast_sql.is_none(), "ns→BQ has no lossless temporal cast");
847 }
848
849 #[test]
850 fn duckdb_nanosecond_timestamp_is_native_timestamp_ns() {
851 let ns = RivetType::Timestamp {
852 unit: super::super::TimeUnit::Nanosecond,
853 timezone: None,
854 };
855 let s = duck(&ns);
856 assert_eq!(s.target_type, "TIMESTAMP_NS");
857 assert_eq!(s.status, TargetStatus::Ok);
858 }
859
860 #[test]
861 fn snowflake_nanosecond_timestamp_recovers_losslessly_at_scale_9() {
862 let ns = RivetType::Timestamp {
863 unit: super::super::TimeUnit::Nanosecond,
864 timezone: None,
865 };
866 let s = sf(&ns);
867 assert_eq!(s.target_type, "TIMESTAMP_NTZ");
868 assert_eq!(s.autoload_type, "NUMBER(38,0)");
869 assert_eq!(s.cast_sql.as_deref(), Some(r#"TO_TIMESTAMP_NTZ("c", 9)"#));
872 }
873
874 #[test]
877 fn bq_uuid_resolves_not_fails() {
878 let s = bq(&RivetType::Uuid);
882 assert_eq!(s.target_type, "STRING");
883 assert_eq!(s.autoload_type, "BYTES");
884 assert_eq!(s.status, TargetStatus::Warn);
885 assert!(s.cast_sql.unwrap().contains("c"));
886 }
887
888 #[test]
889 fn bq_json_native_is_json_autoload_is_bytes() {
890 let s = bq(&RivetType::Json);
891 assert_eq!(s.target_type, "JSON");
892 assert_eq!(s.autoload_type, "BYTES");
893 assert_eq!(s.status, TargetStatus::Warn);
894 assert!(s.cast_sql.unwrap().starts_with("PARSE_JSON"));
895 }
896
897 #[test]
898 fn bq_naive_timestamp_is_datetime_native_timestamp_autoload() {
899 let naive = RivetType::Timestamp {
900 unit: super::super::TimeUnit::Microsecond,
901 timezone: None,
902 };
903 let s = bq(&naive);
904 assert_eq!(s.target_type, "DATETIME");
905 assert_eq!(s.autoload_type, "TIMESTAMP");
906 assert_eq!(s.status, TargetStatus::Warn);
907 }
908
909 #[test]
910 fn bq_tz_timestamp_is_timestamp_ok() {
911 let tz = RivetType::Timestamp {
912 unit: super::super::TimeUnit::Microsecond,
913 timezone: Some("UTC".into()),
914 };
915 let s = bq(&tz);
916 assert_eq!(s.target_type, "TIMESTAMP");
917 assert_eq!(s.autoload_type, "TIMESTAMP");
918 assert_eq!(s.status, TargetStatus::Ok);
919 }
920
921 #[test]
922 fn bq_decimal_within_numeric_is_numeric() {
923 let s = bq(&RivetType::Decimal {
924 precision: 18,
925 scale: 2,
926 });
927 assert_eq!(s.target_type, "NUMERIC");
928 assert_eq!(s.status, TargetStatus::Ok);
929 }
930
931 #[test]
932 fn bq_decimal_escalates_to_bignumeric() {
933 let s = bq(&RivetType::Decimal {
934 precision: 38,
935 scale: 9,
936 });
937 assert_eq!(s.target_type, "BIGNUMERIC");
938 assert_eq!(s.status, TargetStatus::Ok);
939 }
940
941 #[test]
942 fn bq_decimal_negative_scale_fails() {
943 let s = bq(&RivetType::Decimal {
944 precision: 5,
945 scale: -2,
946 });
947 assert_eq!(s.status, TargetStatus::Fail);
948 }
949
950 #[test]
951 fn bq_uint64_recommends_numeric_warns_overflow() {
952 let s = bq(&RivetType::UInt64);
953 assert_eq!(s.target_type, "NUMERIC");
954 assert_eq!(s.autoload_type, "INT64");
955 assert_eq!(s.status, TargetStatus::Warn);
956 }
957
958 #[test]
959 fn bq_list_is_repeated_native_record_autoload() {
960 let t = RivetType::List {
961 inner: Box::new(RivetType::String),
962 };
963 let s = bq(&t);
964 assert_eq!(s.target_type, "REPEATED STRING");
965 assert!(s.autoload_type.contains("REPEATED RECORD"));
966 assert_eq!(s.status, TargetStatus::Warn);
967 }
968
969 #[test]
970 fn bq_unsupported_is_fail_row_not_panic() {
971 let t = RivetType::Unsupported {
972 native_type: "geometry".into(),
973 reason: "no mapping".into(),
974 };
975 let s = bq(&t);
976 assert_eq!(s.status, TargetStatus::Fail);
977 assert_eq!(s.target_type, "-");
978 }
979
980 #[test]
981 fn bq_standard_scalars_ok() {
982 for (rt, native) in [
983 (RivetType::Bool, "BOOL"),
984 (RivetType::Int64, "INT64"),
985 (RivetType::Float64, "FLOAT64"),
986 (RivetType::Date, "DATE"),
987 (RivetType::String, "STRING"),
988 (RivetType::Binary, "BYTES"),
989 (RivetType::Enum, "STRING"),
990 ] {
991 let s = bq(&rt);
992 assert_eq!(s.target_type, native, "{rt:?}");
993 assert_eq!(s.autoload_type, native, "{rt:?}");
994 assert_eq!(s.status, TargetStatus::Ok, "{rt:?}");
995 }
996 }
997
998 #[test]
1001 fn duckdb_reads_everything_natively() {
1002 let naive = RivetType::Timestamp {
1003 unit: super::super::TimeUnit::Microsecond,
1004 timezone: None,
1005 };
1006 for rt in [
1007 RivetType::Json,
1008 RivetType::Uuid,
1009 RivetType::UInt64,
1010 naive,
1011 RivetType::List {
1012 inner: Box::new(RivetType::Int64),
1013 },
1014 ] {
1015 let s = duck(&rt);
1016 assert_eq!(
1017 s.target_type, s.autoload_type,
1018 "DuckDB autoload must equal native for {rt:?}"
1019 );
1020 assert_ne!(s.status, TargetStatus::Fail, "{rt:?}");
1021 }
1022 }
1023
1024 #[test]
1025 fn duckdb_native_type_names() {
1026 assert_eq!(duck(&RivetType::Json).target_type, "JSON");
1027 assert_eq!(duck(&RivetType::Uuid).target_type, "UUID");
1028 assert_eq!(duck(&RivetType::UInt64).target_type, "UBIGINT");
1029 assert_eq!(
1030 duck(&RivetType::Decimal {
1031 precision: 18,
1032 scale: 2
1033 })
1034 .target_type,
1035 "DECIMAL(18,2)"
1036 );
1037 assert_eq!(
1038 duck(&RivetType::List {
1039 inner: Box::new(RivetType::Int64)
1040 })
1041 .target_type,
1042 "BIGINT[]"
1043 );
1044 }
1045
1046 #[test]
1047 fn parse_accepts_aliases() {
1048 assert_eq!(ExportTarget::parse("bq"), Some(ExportTarget::BigQuery));
1049 assert_eq!(
1050 ExportTarget::parse("BigQuery"),
1051 Some(ExportTarget::BigQuery)
1052 );
1053 assert_eq!(ExportTarget::parse("duckdb"), Some(ExportTarget::DuckDb));
1054 assert_eq!(ExportTarget::parse("nope"), None);
1055 }
1056
1057 #[test]
1058 fn resolve_table_preserves_order_and_names() {
1059 use super::super::SourceColumn;
1060 let mappings = vec![
1061 TypeMapping::from_source(&SourceColumn::simple("a", "int8", true), RivetType::Int64),
1062 TypeMapping::from_source(&SourceColumn::simple("b", "jsonb", true), RivetType::Json),
1063 ];
1064 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1065 assert_eq!(specs.len(), 2);
1066 assert_eq!(specs[0].column_name, "a");
1067 assert_eq!(specs[1].column_name, "b");
1068 assert_eq!(specs[1].target_type, "JSON");
1069 }
1070
1071 #[test]
1078 fn cast_sql_is_none_when_post_load_recovery_is_impossible() {
1079 let u = bq(&RivetType::UInt64);
1084 assert!(
1085 u.cast_sql.is_none(),
1086 "overflowed UINT64 has no lossless post-load recovery"
1087 );
1088 let note = u.note.unwrap().to_lowercase();
1089 assert!(
1090 note.contains("override"),
1091 "UINT64 note must point to the source-side override, got: {note}"
1092 );
1093 }
1094
1095 #[test]
1096 fn cast_sql_present_only_when_lossless_post_load() {
1097 assert!(
1100 bq(&RivetType::Json)
1101 .cast_sql
1102 .unwrap()
1103 .contains("PARSE_JSON")
1104 );
1105 assert!(bq(&RivetType::Uuid).cast_sql.unwrap().contains("TO_HEX"));
1106 let naive = RivetType::Timestamp {
1107 unit: super::super::TimeUnit::Microsecond,
1108 timezone: None,
1109 };
1110 assert!(bq(&naive).cast_sql.unwrap().contains("DATETIME"));
1111 }
1112
1113 #[test]
1114 fn every_divergence_offers_a_recovery_path() {
1115 let naive = RivetType::Timestamp {
1120 unit: super::super::TimeUnit::Microsecond,
1121 timezone: None,
1122 };
1123 let cases = [
1124 RivetType::Json,
1125 RivetType::Uuid,
1126 RivetType::UInt64,
1127 naive,
1128 RivetType::List {
1129 inner: Box::new(RivetType::String),
1130 },
1131 ];
1132 for rt in cases {
1133 let s = bq(&rt);
1134 assert_ne!(s.autoload_type, s.target_type, "case must diverge: {rt:?}");
1135 let has_cast = s.cast_sql.is_some();
1136 let note = s.note.as_deref().unwrap_or("").to_lowercase();
1137 let describes_recovery = note.contains("after load") || note.contains("override");
1138 assert!(
1139 has_cast || describes_recovery,
1140 "divergent {rt:?} must offer a recovery (cast_sql or a recovery note)"
1141 );
1142 }
1143 }
1144
1145 #[test]
1148 fn bq_decimal_limit_boundaries() {
1149 assert_eq!(
1151 bq(&RivetType::Decimal {
1152 precision: 76,
1153 scale: 38
1154 })
1155 .status,
1156 TargetStatus::Ok
1157 );
1158 assert_eq!(
1160 bq(&RivetType::Decimal {
1161 precision: 77,
1162 scale: 38
1163 })
1164 .status,
1165 TargetStatus::Fail
1166 );
1167 assert_eq!(
1169 bq(&RivetType::Decimal {
1170 precision: 76,
1171 scale: 39
1172 })
1173 .status,
1174 TargetStatus::Fail
1175 );
1176 assert_eq!(
1178 bq(&RivetType::Decimal {
1179 precision: 30,
1180 scale: 0
1181 })
1182 .target_type,
1183 "BIGNUMERIC"
1184 );
1185 }
1186
1187 #[test]
1188 fn duckdb_decimal_over_38_autoloads_as_double_not_a_false_native_decimal() {
1189 let s = duck(&RivetType::Decimal {
1196 precision: 40,
1197 scale: 2,
1198 });
1199 assert_eq!(s.status, TargetStatus::Warn);
1200 assert_eq!(
1201 s.autoload_type, "DOUBLE",
1202 "autoload_type must tell the truth (real DuckDB autoloads wide decimals as DOUBLE)"
1203 );
1204 assert_ne!(
1205 s.target_type, s.autoload_type,
1206 "a lossy autoload must be flagged as a divergence, not autoload==target"
1207 );
1208 assert!(
1209 s.cast_sql.is_none(),
1210 "DOUBLE is already lossy — no post-load cast recovers the dropped precision"
1211 );
1212 }
1213
1214 #[test]
1215 fn snowflake_decimal_over_38_fails_not_falsely_ok() {
1216 assert_eq!(
1221 sf(&RivetType::Decimal {
1222 precision: 50,
1223 scale: 10
1224 })
1225 .status,
1226 TargetStatus::Fail,
1227 "p>38 has no exact Snowflake NUMBER type"
1228 );
1229 assert_eq!(
1231 sf(&RivetType::Decimal {
1232 precision: 38,
1233 scale: 10
1234 })
1235 .status,
1236 TargetStatus::Ok
1237 );
1238 }
1239
1240 #[test]
1241 fn clickhouse_decimal_over_76_fails() {
1242 assert_eq!(
1245 ch(&RivetType::Decimal {
1246 precision: 80,
1247 scale: 2
1248 })
1249 .status,
1250 TargetStatus::Fail
1251 );
1252 assert_eq!(
1254 ch(&RivetType::Decimal {
1255 precision: 76,
1256 scale: 2
1257 })
1258 .status,
1259 TargetStatus::Ok
1260 );
1261 }
1262
1263 #[test]
1264 fn clickhouse_time_autoloads_as_int64() {
1265 let s = ch(&RivetType::Time {
1267 unit: super::super::TimeUnit::Microsecond,
1268 });
1269 assert_eq!(s.autoload_type, "Int64");
1270 assert_eq!(s.status, TargetStatus::Warn);
1271 }
1272
1273 #[test]
1274 fn clickhouse_nanosecond_timestamp_is_datetime64_9() {
1275 let s = ch(&RivetType::Timestamp {
1277 unit: super::super::TimeUnit::Nanosecond,
1278 timezone: None,
1279 });
1280 assert_eq!(s.target_type, "DateTime64(9)");
1281 assert_eq!(s.status, TargetStatus::Ok);
1282 }
1283
1284 #[test]
1285 fn clickhouse_enum_autoloads_as_text() {
1286 let s = ch(&RivetType::Enum);
1288 assert_eq!(s.target_type, "String");
1289 assert_eq!(s.status, TargetStatus::Ok);
1290 }
1291
1292 #[test]
1293 fn snowflake_enum_autoloads_as_text() {
1294 let s = sf(&RivetType::Enum);
1297 assert_eq!(
1298 s.status,
1299 TargetStatus::Ok,
1300 "enum labels are a clean text autoload"
1301 );
1302 assert_eq!(
1303 s.autoload_type, s.target_type,
1304 "a text enum has no autoload divergence"
1305 );
1306 }
1307
1308 #[test]
1309 fn list_of_unsupported_element_fails_on_every_target() {
1310 let bad = RivetType::List {
1314 inner: Box::new(RivetType::Unsupported {
1315 native_type: "geometry".into(),
1316 reason: "no Arrow mapping".into(),
1317 }),
1318 };
1319 for spec in [bq(&bad), duck(&bad), sf(&bad), ch(&bad)] {
1320 assert_eq!(
1321 spec.status,
1322 TargetStatus::Fail,
1323 "a list of an unsupported element must fail cleanly"
1324 );
1325 }
1326 }
1327
1328 #[test]
1329 fn interval_resolves_to_a_text_or_native_type_per_target() {
1330 assert_eq!(bq(&RivetType::Interval).target_type, "STRING");
1333 assert_eq!(duck(&RivetType::Interval).target_type, "INTERVAL");
1334 assert_eq!(sf(&RivetType::Interval).target_type, "TEXT");
1335 assert_eq!(ch(&RivetType::Interval).target_type, "String");
1336 for spec in [
1337 bq(&RivetType::Interval),
1338 duck(&RivetType::Interval),
1339 sf(&RivetType::Interval),
1340 ch(&RivetType::Interval),
1341 ] {
1342 assert_eq!(spec.status, TargetStatus::Ok);
1343 }
1344 }
1345
1346 #[test]
1347 fn decimal_negative_scale_is_handled_not_dropped_per_target() {
1348 let neg = RivetType::Decimal {
1353 precision: 10,
1354 scale: -2,
1355 };
1356 assert_eq!(bq(&neg).status, TargetStatus::Fail);
1357 assert_eq!(duck(&neg).status, TargetStatus::Warn);
1358 assert_eq!(sf(&neg).status, TargetStatus::Warn);
1359 assert_eq!(ch(&neg).status, TargetStatus::Warn);
1360 }
1361
1362 #[test]
1365 fn bq_recovery_sql_casts_native_types() {
1366 use super::super::{SourceColumn, TimeUnit};
1367 let naive = RivetType::Timestamp {
1368 unit: TimeUnit::Microsecond,
1369 timezone: None,
1370 };
1371 let mappings = vec![
1372 TypeMapping::from_source(&SourceColumn::simple("id", "int8", true), RivetType::Int64),
1373 TypeMapping::from_source(
1374 &SourceColumn::simple("attrs", "jsonb", true),
1375 RivetType::Json,
1376 ),
1377 TypeMapping::from_source(&SourceColumn::simple("uid", "uuid", true), RivetType::Uuid),
1378 TypeMapping::from_source(
1379 &SourceColumn::simple("created_at", "timestamp", true),
1380 naive,
1381 ),
1382 TypeMapping::from_source(
1383 &SourceColumn::simple("tags", "_text", true),
1384 RivetType::List {
1385 inner: Box::new(RivetType::String),
1386 },
1387 ),
1388 ];
1389 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1390 let sql = ExportTarget::BigQuery
1391 .recovery_sql(&specs, "payments")
1392 .expect("BigQuery has a recovery SQL");
1393 assert!(sql.contains("PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING(attrs)) AS attrs"));
1396 assert!(sql.contains("TO_HEX(uid) AS uid"));
1397 assert!(sql.contains("DATETIME(created_at) AS created_at"));
1398 assert!(sql.contains("ARRAY(SELECT el.item FROM UNNEST(tags) AS el) AS tags"));
1401 assert!(sql.contains("--parquet_enable_list_inference"));
1402 assert!(sql.contains("SELECT\n id"));
1404 assert!(sql.contains("CREATE OR REPLACE TABLE `payments`"));
1406 assert!(sql.contains("FROM `payments__staging`"));
1407 }
1408
1409 #[test]
1410 fn duckdb_needs_no_recovery() {
1411 let mappings = vec![TypeMapping::from_source(
1412 &super::super::SourceColumn::simple("attrs", "json", true),
1413 RivetType::Json,
1414 )];
1415 let specs = ExportTarget::DuckDb.resolve_table(&mappings);
1416 assert!(
1417 ExportTarget::DuckDb.recovery_sql(&specs, "t").is_none(),
1418 "DuckDB autoloads every logical type natively — no recovery needed"
1419 );
1420 }
1421
1422 #[test]
1423 fn recovery_sql_projects_every_column_once_and_only_casts_divergent() {
1424 use super::super::{SourceColumn, TimeUnit};
1425 let naive = RivetType::Timestamp {
1426 unit: TimeUnit::Microsecond,
1427 timezone: None,
1428 };
1429 let cols: [(&str, RivetType); 6] = [
1430 ("id", RivetType::Int64), (
1432 "amount",
1433 RivetType::Decimal {
1434 precision: 18,
1435 scale: 2,
1436 },
1437 ), ("attrs", RivetType::Json), ("uid", RivetType::Uuid), ("created_at", naive), (
1442 "tags",
1443 RivetType::List {
1444 inner: Box::new(RivetType::String),
1445 },
1446 ), ];
1448 let mappings: Vec<_> = cols
1449 .iter()
1450 .cloned()
1451 .map(|(n, rt)| TypeMapping::from_source(&SourceColumn::simple(n, "x", true), rt))
1452 .collect();
1453 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1454 let sql = ExportTarget::BigQuery.recovery_sql(&specs, "t").unwrap();
1455
1456 let body = sql
1459 .split("SELECT\n")
1460 .nth(1)
1461 .and_then(|s| s.split("\nFROM").next())
1462 .expect("recovery SQL has a SELECT … FROM body");
1463 assert_eq!(
1464 body.split(",\n").count(),
1465 cols.len(),
1466 "one projection per column, got:\n{body}"
1467 );
1468 for (name, _) in &cols {
1469 assert!(body.contains(name), "column {name} missing:\n{body}");
1470 }
1471 assert!(body.contains(" id,") && !body.contains("AS id"));
1474 assert!(body.contains(" amount,") && !body.contains("AS amount"));
1475 assert!(body.contains("PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING(attrs)) AS attrs"));
1476 assert!(body.contains("TO_HEX(uid) AS uid"));
1477 assert!(body.contains("DATETIME(created_at) AS created_at"));
1478 assert!(body.contains("UNNEST(tags) AS el) AS tags"));
1479 }
1480
1481 #[test]
1482 fn clickhouse_recovery_sql_casts_uuid_from_its_own_cast_sql() {
1483 use super::super::SourceColumn;
1484 let cols: [(&str, RivetType); 4] = [
1489 ("id", RivetType::Int64),
1490 ("attrs", RivetType::Json),
1491 ("uid", RivetType::Uuid),
1492 ("k", RivetType::Int32),
1493 ];
1494 let mappings: Vec<_> = cols
1495 .iter()
1496 .cloned()
1497 .map(|(n, rt)| TypeMapping::from_source(&SourceColumn::simple(n, "x", true), rt))
1498 .collect();
1499 let specs = ExportTarget::ClickHouse.resolve_table(&mappings);
1500 let sql = ExportTarget::ClickHouse
1501 .recovery_sql(&specs, "events")
1502 .expect("ClickHouse has a recovery SQL");
1503
1504 assert!(
1506 sql.contains("toUUID(concat(") && sql.contains("hex(uid)") && sql.contains("AS uid"),
1507 "uuid must recover via the emitted toUUID cast:\n{sql}"
1508 );
1509 assert!(sql.contains(" attrs") && !sql.contains("AS attrs"));
1511 assert!(sql.contains(" id") && !sql.contains("AS id"));
1513 assert!(sql.contains("CREATE TABLE events ENGINE = MergeTree"));
1515 assert!(sql.contains("FROM events__staging"));
1516 let body = sql
1518 .split("SELECT\n")
1519 .nth(1)
1520 .and_then(|s| s.split("\nFROM").next())
1521 .expect("recovery SQL has a SELECT … FROM body");
1522 assert_eq!(
1523 body.split(",\n").count(),
1524 cols.len(),
1525 "one projection per column:\n{body}"
1526 );
1527 }
1528
1529 #[test]
1532 fn snowflake_autoload_degradations_and_native_casts() {
1533 let j = sf(&RivetType::Json);
1535 assert_eq!(j.target_type, "VARIANT");
1536 assert_eq!(j.autoload_type, "TEXT");
1537 assert!(j.cast_sql.unwrap().starts_with("PARSE_JSON"));
1538 let u = sf(&RivetType::Uuid);
1540 assert_eq!(u.target_type, "TEXT");
1541 assert_eq!(u.autoload_type, "BINARY");
1542 assert!(u.cast_sql.unwrap().contains("HEX_ENCODE"));
1543 let naive = RivetType::Timestamp {
1545 unit: super::super::TimeUnit::Microsecond,
1546 timezone: None,
1547 };
1548 let t = sf(&naive);
1549 assert_eq!(t.target_type, "TIMESTAMP_NTZ");
1550 assert_eq!(t.autoload_type, "NUMBER(38,0)");
1551 assert!(t.cast_sql.unwrap().contains("TO_TIMESTAMP_NTZ"));
1552 let tm = sf(&RivetType::Time {
1554 unit: super::super::TimeUnit::Microsecond,
1555 });
1556 assert_eq!(tm.target_type, "TIME");
1557 assert!(tm.cast_sql.unwrap().contains("TIME_FROM_PARTS"));
1558 let d = sf(&RivetType::Decimal {
1560 precision: 18,
1561 scale: 2,
1562 });
1563 assert_eq!(d.target_type, "NUMBER(18,2)");
1564 assert!(d.cast_sql.is_none());
1565 let l = sf(&RivetType::List {
1567 inner: Box::new(RivetType::Int64),
1568 });
1569 assert_eq!(l.target_type, "ARRAY");
1570 assert_eq!(l.autoload_type, "VARIANT");
1571 assert!(l.cast_sql.unwrap().ends_with("::ARRAY"));
1572 }
1573
1574 #[test]
1575 fn snowflake_recovery_sql_quotes_columns_and_casts() {
1576 use super::super::{SourceColumn, TimeUnit};
1577 let naive = RivetType::Timestamp {
1578 unit: TimeUnit::Microsecond,
1579 timezone: None,
1580 };
1581 let mappings = vec![
1582 TypeMapping::from_source(&SourceColumn::simple("id", "int8", true), RivetType::Int64),
1583 TypeMapping::from_source(
1584 &SourceColumn::simple("attrs", "jsonb", true),
1585 RivetType::Json,
1586 ),
1587 TypeMapping::from_source(&SourceColumn::simple("uid", "uuid", true), RivetType::Uuid),
1588 TypeMapping::from_source(
1589 &SourceColumn::simple("created_at", "timestamp", true),
1590 naive,
1591 ),
1592 ];
1593 let specs = ExportTarget::Snowflake.resolve_table(&mappings);
1594 let sql = ExportTarget::Snowflake.recovery_sql(&specs, "t").unwrap();
1595 assert!(sql.contains("\"id\" AS id"));
1597 assert!(sql.contains("PARSE_JSON(\"attrs\") AS attrs"));
1598 assert!(sql.contains("HEX_ENCODE(\"uid\")"));
1599 assert!(sql.contains("TO_TIMESTAMP_NTZ(\"created_at\", 6) AS created_at"));
1600 assert!(sql.contains("BINARY_AS_TEXT=FALSE"));
1602 assert!(sql.contains("MATCH_BY_COLUMN_NAME"));
1603 assert!(sql.contains("FROM t__staging"));
1604 }
1605
1606 #[test]
1607 fn parse_accepts_snowflake() {
1608 assert_eq!(
1609 ExportTarget::parse("snowflake"),
1610 Some(ExportTarget::Snowflake)
1611 );
1612 assert_eq!(ExportTarget::parse("sf"), Some(ExportTarget::Snowflake));
1613 }
1614
1615 #[test]
1616 fn parse_accepts_clickhouse() {
1617 assert_eq!(
1618 ExportTarget::parse("clickhouse"),
1619 Some(ExportTarget::ClickHouse)
1620 );
1621 assert_eq!(ExportTarget::parse("ch"), Some(ExportTarget::ClickHouse));
1622 }
1623
1624 #[test]
1625 fn valid_target_names_lists_every_parseable_target() {
1626 let names = ExportTarget::valid_target_names();
1630 assert!(names.contains("snowflake"), "got: {names}");
1631 assert!(names.contains("bigquery"), "got: {names}");
1632 assert!(names.contains("duckdb"), "got: {names}");
1633 assert!(names.contains("clickhouse"), "got: {names}");
1634 }
1635}