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 "ARRAY of unsupported element: {}",
448 inner_r.target_type
449 ));
450 }
451 Resolved::warn(
461 format!("ARRAY<STRUCT<item {}>>", inner_r.target_type),
462 "arrays load as ARRAY<STRUCT<item T>> (nested, element named `item`); \
463 flatten to a scalar array with ARRAY(SELECT el.item FROM UNNEST(col) AS el)",
464 )
465 }
466}
467
468mod duckdb {
471 use super::*;
472
473 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
474 native(input.rivet_type).into_spec(input)
475 }
476
477 fn native(t: &RivetType) -> Resolved {
480 match t {
481 RivetType::Bool => Resolved::ok("BOOLEAN"),
482 RivetType::Int16 => Resolved::ok("SMALLINT"),
483 RivetType::Int32 => Resolved::ok("INTEGER"),
484 RivetType::Int64 => Resolved::ok("BIGINT"),
485 RivetType::UInt64 => Resolved::ok("UBIGINT"),
486 RivetType::Float32 => Resolved::ok("FLOAT"),
487 RivetType::Float64 => Resolved::ok("DOUBLE"),
488 RivetType::Decimal { precision, scale } => {
489 if *scale < 0 {
490 Resolved::warn(
491 "DECIMAL",
492 format!(
493 "DuckDB has no negative scale; decimal({precision},{scale}) loads via cast"
494 ),
495 )
496 } else if *precision <= 38 {
497 Resolved::ok(format!("DECIMAL({precision},{scale})"))
498 } else {
499 Resolved::diverge(
504 "DECIMAL(38,*)",
505 "DOUBLE",
506 format!(
507 "decimal({precision},{scale}) exceeds DuckDB DECIMAL(38); autoloads \
508 as DOUBLE (lossy past 2^53) — narrow the source precision if exact \
509 decimals matter"
510 ),
511 None,
512 )
513 }
514 }
515 RivetType::Date => Resolved::ok("DATE"),
516 RivetType::Time { .. } => Resolved::ok("TIME"),
517 RivetType::Timestamp {
518 timezone: Some(_), ..
519 } => Resolved::ok("TIMESTAMPTZ"),
520 RivetType::Timestamp {
523 unit: TimeUnit::Nanosecond,
524 timezone: None,
525 } => Resolved::ok("TIMESTAMP_NS"),
526 RivetType::Timestamp { timezone: None, .. } => Resolved::ok("TIMESTAMP"),
527 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("VARCHAR"),
528 RivetType::Binary => Resolved::ok("BLOB"),
529 RivetType::Json => Resolved::ok("JSON"),
530 RivetType::Uuid => Resolved::ok("UUID"),
531 RivetType::Interval => Resolved::ok("INTERVAL"),
532 RivetType::List { inner } => {
533 let inner_r = native(inner);
534 if inner_r.status == TargetStatus::Fail {
535 Resolved::fail(format!(
536 "LIST of unsupported element: {}",
537 inner_r.target_type
538 ))
539 } else {
540 Resolved::ok(format!("{}[]", inner_r.target_type))
541 }
542 }
543 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
544 }
545 }
546}
547
548mod snowflake {
551 use super::*;
552
553 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
554 native(input.rivet_type).into_spec(input)
555 }
556
557 fn native(t: &RivetType) -> Resolved {
562 match t {
563 RivetType::Bool => Resolved::ok("BOOLEAN"),
564 RivetType::Int16 | RivetType::Int32 | RivetType::Int64 => Resolved::ok("NUMBER(38,0)"),
565 RivetType::UInt64 => Resolved::diverge(
567 "NUMBER(20,0)",
568 "NUMBER(38,0)",
569 "UINT64 > INT64_MAX overflows the Parquet read; map to decimal(20,0) at source",
570 None,
571 ),
572 RivetType::Float32 | RivetType::Float64 => Resolved::ok("FLOAT"),
573 RivetType::Decimal { precision, scale } => {
574 if *scale < 0 {
575 Resolved::warn(
576 "NUMBER",
577 format!(
578 "Snowflake NUMBER has no negative scale; decimal({precision},{scale}) loads via cast"
579 ),
580 )
581 } else if *precision > 38 {
582 Resolved::fail(format!(
587 "decimal({precision},{scale}) exceeds Snowflake NUMBER (max precision 38); \
588 narrow the source precision, or load as FLOAT via a declared schema (lossy)"
589 ))
590 } else {
591 Resolved::ok(format!("NUMBER({precision},{scale})"))
592 }
593 }
594 RivetType::Date => Resolved::ok("DATE"),
595 RivetType::Time { .. } => Resolved::diverge(
597 "TIME",
598 "NUMBER(38,0)",
599 "TIME autoloads as NUMBER (µs of day); recover with TIME_FROM_PARTS after load",
600 Some(r#"TIME_FROM_PARTS(0,0,FLOOR("{col}"/1000000),MOD("{col}",1000000)*1000)"#),
601 ),
602 RivetType::Timestamp {
604 timezone: Some(_), ..
605 } => Resolved::diverge(
606 "TIMESTAMP_TZ",
607 "TIMESTAMP_NTZ",
608 "tz timestamp autoloads as TIMESTAMP_NTZ — ALTER SESSION SET TIMEZONE='UTC' before COPY so the instant matches",
609 None,
610 ),
611 RivetType::Timestamp {
617 unit: TimeUnit::Nanosecond,
618 timezone: None,
619 } => Resolved::diverge(
620 "TIMESTAMP_NTZ",
621 "NUMBER(38,0)",
622 "nanosecond timestamp autoloads as NUMBER (ns since epoch); recover with \
623 TO_TIMESTAMP_NTZ(col, 9) after load — Snowflake TIMESTAMP_NTZ holds full ns precision",
624 Some(r#"TO_TIMESTAMP_NTZ("{col}", 9)"#),
625 ),
626 RivetType::Timestamp { timezone: None, .. } => Resolved::diverge(
628 "TIMESTAMP_NTZ",
629 "NUMBER(38,0)",
630 "naive timestamp autoloads as NUMBER (µs since epoch); recover with TO_TIMESTAMP_NTZ after load",
631 Some(r#"TO_TIMESTAMP_NTZ("{col}", 6)"#),
632 ),
633 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("TEXT"),
634 RivetType::Binary => Resolved::warn(
636 "BINARY",
637 "set BINARY_AS_TEXT=FALSE in the Parquet FILE FORMAT or non-UTF8 bytes fail to load",
638 ),
639 RivetType::Json => Resolved::diverge(
641 "VARIANT",
642 "TEXT",
643 "JSON autoloads as TEXT; recover native VARIANT with PARSE_JSON after load",
644 Some(r#"PARSE_JSON("{col}")"#),
645 ),
646 RivetType::Uuid => Resolved::diverge(
648 "TEXT",
649 "BINARY",
650 "UUID autoloads as 16-byte BINARY; recover canonical text with HEX_ENCODE + REGEXP after load",
651 Some(
652 r#"REGEXP_REPLACE(LOWER(HEX_ENCODE("{col}")),'^(.{8})(.{4})(.{4})(.{4})(.{12})$','\\1-\\2-\\3-\\4-\\5')"#,
653 ),
654 ),
655 RivetType::Interval => Resolved::ok("TEXT"),
656 RivetType::List { inner } => {
661 let inner_r = native(inner);
662 if inner_r.status == TargetStatus::Fail {
663 Resolved::fail(format!(
664 "ARRAY of unsupported element: {}",
665 inner_r.target_type
666 ))
667 } else {
668 Resolved::diverge(
669 "ARRAY",
670 "VARIANT",
671 "list autoloads as VARIANT (the JSON array); recover native ARRAY with ::ARRAY after load",
672 Some(r#""{col}"::ARRAY"#),
673 )
674 }
675 }
676 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
677 }
678 }
679}
680
681fn clickhouse_recovery_sql(specs: &[TargetColumnSpec], table: &str) -> String {
684 let cols = recovery_projection(specs, |name| format!(" {name}"));
685 format!(
686 "-- 1) load the Parquet into a staging table, e.g.\n\
687 -- CREATE TABLE {table}__staging ENGINE = MergeTree ORDER BY tuple() AS\n\
688 -- SELECT * FROM file('<parquet>', 'Parquet');\n\
689 -- 2) recover native types:\n\
690 CREATE TABLE {table} ENGINE = MergeTree ORDER BY tuple() AS\n\
691 SELECT\n{cols}\n\
692 FROM {table}__staging;"
693 )
694}
695
696mod clickhouse {
697 use super::*;
698
699 pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
700 native(input.rivet_type).into_spec(input)
701 }
702
703 fn native(t: &RivetType) -> Resolved {
709 match t {
710 RivetType::Bool => Resolved::ok("Bool"),
711 RivetType::Int16 => Resolved::ok("Int16"),
712 RivetType::Int32 => Resolved::ok("Int32"),
713 RivetType::Int64 => Resolved::ok("Int64"),
714 RivetType::UInt64 => Resolved::ok("UInt64"),
718 RivetType::Float32 => Resolved::ok("Float32"),
719 RivetType::Float64 => Resolved::ok("Float64"),
720 RivetType::Decimal { precision, scale } => {
721 if *scale < 0 {
722 Resolved::warn(
723 "Decimal",
724 format!(
725 "ClickHouse Decimal has no negative scale; decimal({precision},{scale}) needs a declared schema"
726 ),
727 )
728 } else if *precision > 76 {
729 Resolved::fail(format!(
733 "decimal({precision},{scale}) exceeds ClickHouse Decimal (max precision 76); \
734 narrow the source precision"
735 ))
736 } else {
737 Resolved::ok(format!("Decimal({precision}, {scale})"))
738 }
739 }
740 RivetType::Date => Resolved::ok("Date32"),
741 RivetType::Time { .. } => Resolved::warn(
744 "Int64",
745 "ClickHouse has no TIME type; time-of-day autoloads as Int64 (µs of day)",
746 ),
747 RivetType::Timestamp { unit, timezone } => {
750 let p = match unit {
751 TimeUnit::Second => 0,
752 TimeUnit::Millisecond => 3,
753 TimeUnit::Microsecond => 6,
754 TimeUnit::Nanosecond => 9,
755 };
756 match timezone {
757 Some(tz) => Resolved::ok(format!("DateTime64({p}, '{tz}')")),
758 None => Resolved::ok(format!("DateTime64({p})")),
759 }
760 }
761 RivetType::String | RivetType::Text | RivetType::Enum => Resolved::ok("String"),
762 RivetType::Binary => Resolved::ok("String"),
765 RivetType::Json => Resolved::diverge(
770 "JSON",
771 "String",
772 "JSON autoloads as String (valid JSON text); declare a JSON column at load for the native type",
773 None,
774 ),
775 RivetType::Uuid => Resolved::diverge(
779 "UUID",
780 "FixedString(16)",
781 "UUID autoloads as FixedString(16); recover the native UUID with toUUID after load",
782 Some(
783 "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)))",
784 ),
785 ),
786 RivetType::Interval => Resolved::ok("String"),
787 RivetType::List { inner } => {
790 let inner_r = native(inner);
791 if inner_r.status == TargetStatus::Fail {
792 Resolved::fail(format!(
793 "Array of unsupported element: {}",
794 inner_r.target_type
795 ))
796 } else {
797 Resolved::ok(format!("Array(Nullable({}))", inner_r.target_type))
798 }
799 }
800 RivetType::Unsupported { .. } => Resolved::fail(unsupported_reason(t)),
801 }
802 }
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808
809 fn input<'a>(rt: &'a RivetType) -> TargetInput<'a> {
810 TargetInput {
811 column_name: "c",
812 rivet_type: rt,
813 arrow_type: None,
814 fidelity: TypeFidelity::Exact,
815 }
816 }
817
818 fn bq(rt: &RivetType) -> TargetColumnSpec {
819 ExportTarget::BigQuery.resolve_column(input(rt))
820 }
821 fn duck(rt: &RivetType) -> TargetColumnSpec {
822 ExportTarget::DuckDb.resolve_column(input(rt))
823 }
824 fn sf(rt: &RivetType) -> TargetColumnSpec {
825 ExportTarget::Snowflake.resolve_column(input(rt))
826 }
827 fn ch(rt: &RivetType) -> TargetColumnSpec {
828 ExportTarget::ClickHouse.resolve_column(input(rt))
829 }
830
831 #[test]
837 fn bq_nanosecond_timestamp_autoloads_as_int64() {
838 let ns = RivetType::Timestamp {
839 unit: super::super::TimeUnit::Nanosecond,
840 timezone: None,
841 };
842 let s = bq(&ns);
843 assert_eq!(s.target_type, "INT64");
844 assert_eq!(s.autoload_type, "INT64");
845 assert_eq!(s.status, TargetStatus::Warn);
846 assert!(s.cast_sql.is_none(), "ns→BQ has no lossless temporal cast");
848 }
849
850 #[test]
851 fn duckdb_nanosecond_timestamp_is_native_timestamp_ns() {
852 let ns = RivetType::Timestamp {
853 unit: super::super::TimeUnit::Nanosecond,
854 timezone: None,
855 };
856 let s = duck(&ns);
857 assert_eq!(s.target_type, "TIMESTAMP_NS");
858 assert_eq!(s.status, TargetStatus::Ok);
859 }
860
861 #[test]
862 fn snowflake_nanosecond_timestamp_recovers_losslessly_at_scale_9() {
863 let ns = RivetType::Timestamp {
864 unit: super::super::TimeUnit::Nanosecond,
865 timezone: None,
866 };
867 let s = sf(&ns);
868 assert_eq!(s.target_type, "TIMESTAMP_NTZ");
869 assert_eq!(s.autoload_type, "NUMBER(38,0)");
870 assert_eq!(s.cast_sql.as_deref(), Some(r#"TO_TIMESTAMP_NTZ("c", 9)"#));
873 }
874
875 #[test]
878 fn bq_uuid_resolves_not_fails() {
879 let s = bq(&RivetType::Uuid);
883 assert_eq!(s.target_type, "STRING");
884 assert_eq!(s.autoload_type, "BYTES");
885 assert_eq!(s.status, TargetStatus::Warn);
886 assert!(s.cast_sql.unwrap().contains("c"));
887 }
888
889 #[test]
890 fn bq_json_native_is_json_autoload_is_bytes() {
891 let s = bq(&RivetType::Json);
892 assert_eq!(s.target_type, "JSON");
893 assert_eq!(s.autoload_type, "BYTES");
894 assert_eq!(s.status, TargetStatus::Warn);
895 assert!(s.cast_sql.unwrap().starts_with("PARSE_JSON"));
896 }
897
898 #[test]
899 fn bq_naive_timestamp_is_datetime_native_timestamp_autoload() {
900 let naive = RivetType::Timestamp {
901 unit: super::super::TimeUnit::Microsecond,
902 timezone: None,
903 };
904 let s = bq(&naive);
905 assert_eq!(s.target_type, "DATETIME");
906 assert_eq!(s.autoload_type, "TIMESTAMP");
907 assert_eq!(s.status, TargetStatus::Warn);
908 }
909
910 #[test]
911 fn bq_tz_timestamp_is_timestamp_ok() {
912 let tz = RivetType::Timestamp {
913 unit: super::super::TimeUnit::Microsecond,
914 timezone: Some("UTC".into()),
915 };
916 let s = bq(&tz);
917 assert_eq!(s.target_type, "TIMESTAMP");
918 assert_eq!(s.autoload_type, "TIMESTAMP");
919 assert_eq!(s.status, TargetStatus::Ok);
920 }
921
922 #[test]
923 fn bq_decimal_within_numeric_is_numeric() {
924 let s = bq(&RivetType::Decimal {
925 precision: 18,
926 scale: 2,
927 });
928 assert_eq!(s.target_type, "NUMERIC");
929 assert_eq!(s.status, TargetStatus::Ok);
930 }
931
932 #[test]
933 fn bq_decimal_escalates_to_bignumeric() {
934 let s = bq(&RivetType::Decimal {
935 precision: 38,
936 scale: 9,
937 });
938 assert_eq!(s.target_type, "BIGNUMERIC");
939 assert_eq!(s.status, TargetStatus::Ok);
940 }
941
942 #[test]
943 fn bq_decimal_negative_scale_fails() {
944 let s = bq(&RivetType::Decimal {
945 precision: 5,
946 scale: -2,
947 });
948 assert_eq!(s.status, TargetStatus::Fail);
949 }
950
951 #[test]
952 fn bq_uint64_recommends_numeric_warns_overflow() {
953 let s = bq(&RivetType::UInt64);
954 assert_eq!(s.target_type, "NUMERIC");
955 assert_eq!(s.autoload_type, "INT64");
956 assert_eq!(s.status, TargetStatus::Warn);
957 }
958
959 #[test]
960 fn bq_list_declares_loadable_array_struct_item_ddl() {
961 let t = RivetType::List {
966 inner: Box::new(RivetType::String),
967 };
968 let s = bq(&t);
969 assert_eq!(s.target_type, "ARRAY<STRUCT<item STRING>>");
970 assert_eq!(s.autoload_type, "ARRAY<STRUCT<item STRING>>");
972 assert!(
973 !s.target_type.starts_with("REPEATED "),
974 "must not emit invalid REPEATED DDL"
975 );
976 assert_eq!(s.status, TargetStatus::Warn);
977 }
978
979 #[test]
980 fn bq_unsupported_is_fail_row_not_panic() {
981 let t = RivetType::Unsupported {
982 native_type: "geometry".into(),
983 reason: "no mapping".into(),
984 };
985 let s = bq(&t);
986 assert_eq!(s.status, TargetStatus::Fail);
987 assert_eq!(s.target_type, "-");
988 }
989
990 #[test]
991 fn bq_standard_scalars_ok() {
992 for (rt, native) in [
993 (RivetType::Bool, "BOOL"),
994 (RivetType::Int64, "INT64"),
995 (RivetType::Float64, "FLOAT64"),
996 (RivetType::Date, "DATE"),
997 (RivetType::String, "STRING"),
998 (RivetType::Binary, "BYTES"),
999 (RivetType::Enum, "STRING"),
1000 ] {
1001 let s = bq(&rt);
1002 assert_eq!(s.target_type, native, "{rt:?}");
1003 assert_eq!(s.autoload_type, native, "{rt:?}");
1004 assert_eq!(s.status, TargetStatus::Ok, "{rt:?}");
1005 }
1006 }
1007
1008 #[test]
1011 fn duckdb_reads_everything_natively() {
1012 let naive = RivetType::Timestamp {
1013 unit: super::super::TimeUnit::Microsecond,
1014 timezone: None,
1015 };
1016 for rt in [
1017 RivetType::Json,
1018 RivetType::Uuid,
1019 RivetType::UInt64,
1020 naive,
1021 RivetType::List {
1022 inner: Box::new(RivetType::Int64),
1023 },
1024 ] {
1025 let s = duck(&rt);
1026 assert_eq!(
1027 s.target_type, s.autoload_type,
1028 "DuckDB autoload must equal native for {rt:?}"
1029 );
1030 assert_ne!(s.status, TargetStatus::Fail, "{rt:?}");
1031 }
1032 }
1033
1034 #[test]
1035 fn duckdb_native_type_names() {
1036 assert_eq!(duck(&RivetType::Json).target_type, "JSON");
1037 assert_eq!(duck(&RivetType::Uuid).target_type, "UUID");
1038 assert_eq!(duck(&RivetType::UInt64).target_type, "UBIGINT");
1039 assert_eq!(
1040 duck(&RivetType::Decimal {
1041 precision: 18,
1042 scale: 2
1043 })
1044 .target_type,
1045 "DECIMAL(18,2)"
1046 );
1047 assert_eq!(
1048 duck(&RivetType::List {
1049 inner: Box::new(RivetType::Int64)
1050 })
1051 .target_type,
1052 "BIGINT[]"
1053 );
1054 }
1055
1056 #[test]
1057 fn parse_accepts_aliases() {
1058 assert_eq!(ExportTarget::parse("bq"), Some(ExportTarget::BigQuery));
1059 assert_eq!(
1060 ExportTarget::parse("BigQuery"),
1061 Some(ExportTarget::BigQuery)
1062 );
1063 assert_eq!(ExportTarget::parse("duckdb"), Some(ExportTarget::DuckDb));
1064 assert_eq!(ExportTarget::parse("nope"), None);
1065 }
1066
1067 #[test]
1068 fn resolve_table_preserves_order_and_names() {
1069 use super::super::SourceColumn;
1070 let mappings = vec![
1071 TypeMapping::from_source(&SourceColumn::simple("a", "int8", true), RivetType::Int64),
1072 TypeMapping::from_source(&SourceColumn::simple("b", "jsonb", true), RivetType::Json),
1073 ];
1074 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1075 assert_eq!(specs.len(), 2);
1076 assert_eq!(specs[0].column_name, "a");
1077 assert_eq!(specs[1].column_name, "b");
1078 assert_eq!(specs[1].target_type, "JSON");
1079 }
1080
1081 #[test]
1088 fn cast_sql_is_none_when_post_load_recovery_is_impossible() {
1089 let u = bq(&RivetType::UInt64);
1094 assert!(
1095 u.cast_sql.is_none(),
1096 "overflowed UINT64 has no lossless post-load recovery"
1097 );
1098 let note = u.note.unwrap().to_lowercase();
1099 assert!(
1100 note.contains("override"),
1101 "UINT64 note must point to the source-side override, got: {note}"
1102 );
1103 }
1104
1105 #[test]
1106 fn cast_sql_present_only_when_lossless_post_load() {
1107 assert!(
1110 bq(&RivetType::Json)
1111 .cast_sql
1112 .unwrap()
1113 .contains("PARSE_JSON")
1114 );
1115 assert!(bq(&RivetType::Uuid).cast_sql.unwrap().contains("TO_HEX"));
1116 let naive = RivetType::Timestamp {
1117 unit: super::super::TimeUnit::Microsecond,
1118 timezone: None,
1119 };
1120 assert!(bq(&naive).cast_sql.unwrap().contains("DATETIME"));
1121 }
1122
1123 #[test]
1124 fn every_divergence_offers_a_recovery_path() {
1125 let naive = RivetType::Timestamp {
1130 unit: super::super::TimeUnit::Microsecond,
1131 timezone: None,
1132 };
1133 let cases = [RivetType::Json, RivetType::Uuid, RivetType::UInt64, naive];
1137 for rt in cases {
1138 let s = bq(&rt);
1139 assert_ne!(s.autoload_type, s.target_type, "case must diverge: {rt:?}");
1140 let has_cast = s.cast_sql.is_some();
1141 let note = s.note.as_deref().unwrap_or("").to_lowercase();
1142 let describes_recovery = note.contains("after load") || note.contains("override");
1143 assert!(
1144 has_cast || describes_recovery,
1145 "divergent {rt:?} must offer a recovery (cast_sql or a recovery note)"
1146 );
1147 }
1148 }
1149
1150 #[test]
1153 fn bq_decimal_limit_boundaries() {
1154 assert_eq!(
1156 bq(&RivetType::Decimal {
1157 precision: 76,
1158 scale: 38
1159 })
1160 .status,
1161 TargetStatus::Ok
1162 );
1163 assert_eq!(
1165 bq(&RivetType::Decimal {
1166 precision: 77,
1167 scale: 38
1168 })
1169 .status,
1170 TargetStatus::Fail
1171 );
1172 assert_eq!(
1174 bq(&RivetType::Decimal {
1175 precision: 76,
1176 scale: 39
1177 })
1178 .status,
1179 TargetStatus::Fail
1180 );
1181 assert_eq!(
1183 bq(&RivetType::Decimal {
1184 precision: 30,
1185 scale: 0
1186 })
1187 .target_type,
1188 "BIGNUMERIC"
1189 );
1190 }
1191
1192 #[test]
1193 fn duckdb_decimal_over_38_autoloads_as_double_not_a_false_native_decimal() {
1194 let s = duck(&RivetType::Decimal {
1201 precision: 40,
1202 scale: 2,
1203 });
1204 assert_eq!(s.status, TargetStatus::Warn);
1205 assert_eq!(
1206 s.autoload_type, "DOUBLE",
1207 "autoload_type must tell the truth (real DuckDB autoloads wide decimals as DOUBLE)"
1208 );
1209 assert_ne!(
1210 s.target_type, s.autoload_type,
1211 "a lossy autoload must be flagged as a divergence, not autoload==target"
1212 );
1213 assert!(
1214 s.cast_sql.is_none(),
1215 "DOUBLE is already lossy — no post-load cast recovers the dropped precision"
1216 );
1217 }
1218
1219 #[test]
1220 fn snowflake_decimal_over_38_fails_not_falsely_ok() {
1221 assert_eq!(
1226 sf(&RivetType::Decimal {
1227 precision: 50,
1228 scale: 10
1229 })
1230 .status,
1231 TargetStatus::Fail,
1232 "p>38 has no exact Snowflake NUMBER type"
1233 );
1234 assert_eq!(
1236 sf(&RivetType::Decimal {
1237 precision: 38,
1238 scale: 10
1239 })
1240 .status,
1241 TargetStatus::Ok
1242 );
1243 }
1244
1245 #[test]
1246 fn clickhouse_decimal_over_76_fails() {
1247 assert_eq!(
1250 ch(&RivetType::Decimal {
1251 precision: 80,
1252 scale: 2
1253 })
1254 .status,
1255 TargetStatus::Fail
1256 );
1257 assert_eq!(
1259 ch(&RivetType::Decimal {
1260 precision: 76,
1261 scale: 2
1262 })
1263 .status,
1264 TargetStatus::Ok
1265 );
1266 }
1267
1268 #[test]
1269 fn clickhouse_time_autoloads_as_int64() {
1270 let s = ch(&RivetType::Time {
1272 unit: super::super::TimeUnit::Microsecond,
1273 });
1274 assert_eq!(s.autoload_type, "Int64");
1275 assert_eq!(s.status, TargetStatus::Warn);
1276 }
1277
1278 #[test]
1279 fn clickhouse_nanosecond_timestamp_is_datetime64_9() {
1280 let s = ch(&RivetType::Timestamp {
1282 unit: super::super::TimeUnit::Nanosecond,
1283 timezone: None,
1284 });
1285 assert_eq!(s.target_type, "DateTime64(9)");
1286 assert_eq!(s.status, TargetStatus::Ok);
1287 }
1288
1289 #[test]
1290 fn clickhouse_enum_autoloads_as_text() {
1291 let s = ch(&RivetType::Enum);
1293 assert_eq!(s.target_type, "String");
1294 assert_eq!(s.status, TargetStatus::Ok);
1295 }
1296
1297 #[test]
1298 fn snowflake_enum_autoloads_as_text() {
1299 let s = sf(&RivetType::Enum);
1302 assert_eq!(
1303 s.status,
1304 TargetStatus::Ok,
1305 "enum labels are a clean text autoload"
1306 );
1307 assert_eq!(
1308 s.autoload_type, s.target_type,
1309 "a text enum has no autoload divergence"
1310 );
1311 }
1312
1313 #[test]
1314 fn list_of_unsupported_element_fails_on_every_target() {
1315 let bad = RivetType::List {
1319 inner: Box::new(RivetType::Unsupported {
1320 native_type: "geometry".into(),
1321 reason: "no Arrow mapping".into(),
1322 }),
1323 };
1324 for spec in [bq(&bad), duck(&bad), sf(&bad), ch(&bad)] {
1325 assert_eq!(
1326 spec.status,
1327 TargetStatus::Fail,
1328 "a list of an unsupported element must fail cleanly"
1329 );
1330 }
1331 }
1332
1333 #[test]
1334 fn interval_resolves_to_a_text_or_native_type_per_target() {
1335 assert_eq!(bq(&RivetType::Interval).target_type, "STRING");
1338 assert_eq!(duck(&RivetType::Interval).target_type, "INTERVAL");
1339 assert_eq!(sf(&RivetType::Interval).target_type, "TEXT");
1340 assert_eq!(ch(&RivetType::Interval).target_type, "String");
1341 for spec in [
1342 bq(&RivetType::Interval),
1343 duck(&RivetType::Interval),
1344 sf(&RivetType::Interval),
1345 ch(&RivetType::Interval),
1346 ] {
1347 assert_eq!(spec.status, TargetStatus::Ok);
1348 }
1349 }
1350
1351 #[test]
1352 fn decimal_negative_scale_is_handled_not_dropped_per_target() {
1353 let neg = RivetType::Decimal {
1358 precision: 10,
1359 scale: -2,
1360 };
1361 assert_eq!(bq(&neg).status, TargetStatus::Fail);
1362 assert_eq!(duck(&neg).status, TargetStatus::Warn);
1363 assert_eq!(sf(&neg).status, TargetStatus::Warn);
1364 assert_eq!(ch(&neg).status, TargetStatus::Warn);
1365 }
1366
1367 #[test]
1370 fn bq_recovery_sql_casts_native_types() {
1371 use super::super::{SourceColumn, TimeUnit};
1372 let naive = RivetType::Timestamp {
1373 unit: TimeUnit::Microsecond,
1374 timezone: None,
1375 };
1376 let mappings = vec![
1377 TypeMapping::from_source(&SourceColumn::simple("id", "int8", true), RivetType::Int64),
1378 TypeMapping::from_source(
1379 &SourceColumn::simple("attrs", "jsonb", true),
1380 RivetType::Json,
1381 ),
1382 TypeMapping::from_source(&SourceColumn::simple("uid", "uuid", true), RivetType::Uuid),
1383 TypeMapping::from_source(
1384 &SourceColumn::simple("created_at", "timestamp", true),
1385 naive,
1386 ),
1387 TypeMapping::from_source(
1388 &SourceColumn::simple("tags", "_text", true),
1389 RivetType::List {
1390 inner: Box::new(RivetType::String),
1391 },
1392 ),
1393 ];
1394 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1395 let sql = ExportTarget::BigQuery
1396 .recovery_sql(&specs, "payments")
1397 .expect("BigQuery has a recovery SQL");
1398 assert!(sql.contains("PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING(attrs)) AS attrs"));
1401 assert!(sql.contains("TO_HEX(uid) AS uid"));
1402 assert!(sql.contains("DATETIME(created_at) AS created_at"));
1403 assert!(!sql.contains("AS tags") && !sql.contains("UNNEST(tags)"));
1407 assert!(sql.contains("SELECT\n id"));
1409 assert!(sql.contains("CREATE OR REPLACE TABLE `payments`"));
1411 assert!(sql.contains("FROM `payments__staging`"));
1412 }
1413
1414 #[test]
1415 fn duckdb_needs_no_recovery() {
1416 let mappings = vec![TypeMapping::from_source(
1417 &super::super::SourceColumn::simple("attrs", "json", true),
1418 RivetType::Json,
1419 )];
1420 let specs = ExportTarget::DuckDb.resolve_table(&mappings);
1421 assert!(
1422 ExportTarget::DuckDb.recovery_sql(&specs, "t").is_none(),
1423 "DuckDB autoloads every logical type natively — no recovery needed"
1424 );
1425 }
1426
1427 #[test]
1428 fn recovery_sql_projects_every_column_once_and_only_casts_divergent() {
1429 use super::super::{SourceColumn, TimeUnit};
1430 let naive = RivetType::Timestamp {
1431 unit: TimeUnit::Microsecond,
1432 timezone: None,
1433 };
1434 let cols: [(&str, RivetType); 6] = [
1435 ("id", RivetType::Int64), (
1437 "amount",
1438 RivetType::Decimal {
1439 precision: 18,
1440 scale: 2,
1441 },
1442 ), ("attrs", RivetType::Json), ("uid", RivetType::Uuid), ("created_at", naive), (
1447 "tags",
1448 RivetType::List {
1449 inner: Box::new(RivetType::String),
1450 },
1451 ), ];
1453 let mappings: Vec<_> = cols
1454 .iter()
1455 .cloned()
1456 .map(|(n, rt)| TypeMapping::from_source(&SourceColumn::simple(n, "x", true), rt))
1457 .collect();
1458 let specs = ExportTarget::BigQuery.resolve_table(&mappings);
1459 let sql = ExportTarget::BigQuery.recovery_sql(&specs, "t").unwrap();
1460
1461 let body = sql
1464 .split("SELECT\n")
1465 .nth(1)
1466 .and_then(|s| s.split("\nFROM").next())
1467 .expect("recovery SQL has a SELECT … FROM body");
1468 assert_eq!(
1469 body.split(",\n").count(),
1470 cols.len(),
1471 "one projection per column, got:\n{body}"
1472 );
1473 for (name, _) in &cols {
1474 assert!(body.contains(name), "column {name} missing:\n{body}");
1475 }
1476 assert!(body.contains(" id,") && !body.contains("AS id"));
1479 assert!(body.contains(" amount,") && !body.contains("AS amount"));
1480 assert!(body.contains("PARSE_JSON(SAFE_CONVERT_BYTES_TO_STRING(attrs)) AS attrs"));
1481 assert!(body.contains("TO_HEX(uid) AS uid"));
1482 assert!(body.contains("DATETIME(created_at) AS created_at"));
1483 assert!(!body.contains("AS tags") && !body.contains("UNNEST(tags)"));
1486 }
1487
1488 #[test]
1489 fn clickhouse_recovery_sql_casts_uuid_from_its_own_cast_sql() {
1490 use super::super::SourceColumn;
1491 let cols: [(&str, RivetType); 4] = [
1496 ("id", RivetType::Int64),
1497 ("attrs", RivetType::Json),
1498 ("uid", RivetType::Uuid),
1499 ("k", RivetType::Int32),
1500 ];
1501 let mappings: Vec<_> = cols
1502 .iter()
1503 .cloned()
1504 .map(|(n, rt)| TypeMapping::from_source(&SourceColumn::simple(n, "x", true), rt))
1505 .collect();
1506 let specs = ExportTarget::ClickHouse.resolve_table(&mappings);
1507 let sql = ExportTarget::ClickHouse
1508 .recovery_sql(&specs, "events")
1509 .expect("ClickHouse has a recovery SQL");
1510
1511 assert!(
1513 sql.contains("toUUID(concat(") && sql.contains("hex(uid)") && sql.contains("AS uid"),
1514 "uuid must recover via the emitted toUUID cast:\n{sql}"
1515 );
1516 assert!(sql.contains(" attrs") && !sql.contains("AS attrs"));
1518 assert!(sql.contains(" id") && !sql.contains("AS id"));
1520 assert!(sql.contains("CREATE TABLE events ENGINE = MergeTree"));
1522 assert!(sql.contains("FROM events__staging"));
1523 let body = sql
1525 .split("SELECT\n")
1526 .nth(1)
1527 .and_then(|s| s.split("\nFROM").next())
1528 .expect("recovery SQL has a SELECT … FROM body");
1529 assert_eq!(
1530 body.split(",\n").count(),
1531 cols.len(),
1532 "one projection per column:\n{body}"
1533 );
1534 }
1535
1536 #[test]
1539 fn snowflake_autoload_degradations_and_native_casts() {
1540 let j = sf(&RivetType::Json);
1542 assert_eq!(j.target_type, "VARIANT");
1543 assert_eq!(j.autoload_type, "TEXT");
1544 assert!(j.cast_sql.unwrap().starts_with("PARSE_JSON"));
1545 let u = sf(&RivetType::Uuid);
1547 assert_eq!(u.target_type, "TEXT");
1548 assert_eq!(u.autoload_type, "BINARY");
1549 assert!(u.cast_sql.unwrap().contains("HEX_ENCODE"));
1550 let naive = RivetType::Timestamp {
1552 unit: super::super::TimeUnit::Microsecond,
1553 timezone: None,
1554 };
1555 let t = sf(&naive);
1556 assert_eq!(t.target_type, "TIMESTAMP_NTZ");
1557 assert_eq!(t.autoload_type, "NUMBER(38,0)");
1558 assert!(t.cast_sql.unwrap().contains("TO_TIMESTAMP_NTZ"));
1559 let tm = sf(&RivetType::Time {
1561 unit: super::super::TimeUnit::Microsecond,
1562 });
1563 assert_eq!(tm.target_type, "TIME");
1564 assert!(tm.cast_sql.unwrap().contains("TIME_FROM_PARTS"));
1565 let d = sf(&RivetType::Decimal {
1567 precision: 18,
1568 scale: 2,
1569 });
1570 assert_eq!(d.target_type, "NUMBER(18,2)");
1571 assert!(d.cast_sql.is_none());
1572 let l = sf(&RivetType::List {
1574 inner: Box::new(RivetType::Int64),
1575 });
1576 assert_eq!(l.target_type, "ARRAY");
1577 assert_eq!(l.autoload_type, "VARIANT");
1578 assert!(l.cast_sql.unwrap().ends_with("::ARRAY"));
1579 }
1580
1581 #[test]
1582 fn snowflake_recovery_sql_quotes_columns_and_casts() {
1583 use super::super::{SourceColumn, TimeUnit};
1584 let naive = RivetType::Timestamp {
1585 unit: TimeUnit::Microsecond,
1586 timezone: None,
1587 };
1588 let mappings = vec![
1589 TypeMapping::from_source(&SourceColumn::simple("id", "int8", true), RivetType::Int64),
1590 TypeMapping::from_source(
1591 &SourceColumn::simple("attrs", "jsonb", true),
1592 RivetType::Json,
1593 ),
1594 TypeMapping::from_source(&SourceColumn::simple("uid", "uuid", true), RivetType::Uuid),
1595 TypeMapping::from_source(
1596 &SourceColumn::simple("created_at", "timestamp", true),
1597 naive,
1598 ),
1599 ];
1600 let specs = ExportTarget::Snowflake.resolve_table(&mappings);
1601 let sql = ExportTarget::Snowflake.recovery_sql(&specs, "t").unwrap();
1602 assert!(sql.contains("\"id\" AS id"));
1604 assert!(sql.contains("PARSE_JSON(\"attrs\") AS attrs"));
1605 assert!(sql.contains("HEX_ENCODE(\"uid\")"));
1606 assert!(sql.contains("TO_TIMESTAMP_NTZ(\"created_at\", 6) AS created_at"));
1607 assert!(sql.contains("BINARY_AS_TEXT=FALSE"));
1609 assert!(sql.contains("MATCH_BY_COLUMN_NAME"));
1610 assert!(sql.contains("FROM t__staging"));
1611 }
1612
1613 #[test]
1614 fn parse_accepts_snowflake() {
1615 assert_eq!(
1616 ExportTarget::parse("snowflake"),
1617 Some(ExportTarget::Snowflake)
1618 );
1619 assert_eq!(ExportTarget::parse("sf"), Some(ExportTarget::Snowflake));
1620 }
1621
1622 #[test]
1623 fn parse_accepts_clickhouse() {
1624 assert_eq!(
1625 ExportTarget::parse("clickhouse"),
1626 Some(ExportTarget::ClickHouse)
1627 );
1628 assert_eq!(ExportTarget::parse("ch"), Some(ExportTarget::ClickHouse));
1629 }
1630
1631 #[test]
1632 fn valid_target_names_lists_every_parseable_target() {
1633 let names = ExportTarget::valid_target_names();
1637 assert!(names.contains("snowflake"), "got: {names}");
1638 assert!(names.contains("bigquery"), "got: {names}");
1639 assert!(names.contains("duckdb"), "got: {names}");
1640 assert!(names.contains("clickhouse"), "got: {names}");
1641 }
1642}