Skip to main content

rivet/types/
target.rs

1//! Target-type resolver (ADR-0014 L4, roadmap §16).
2//!
3//! Given a column's canonical [`RivetType`] and a runtime-chosen
4//! [`ExportTarget`], resolve what the column becomes in a downstream
5//! warehouse: the **native** target type (full fidelity), the **autoload**
6//! type a generic Parquet reader infers without a declared schema, a safety
7//! [`TargetStatus`], a note, and an optional materialization (`cast_sql` /
8//! load-schema hint).
9//!
10//! Design (locked in the type-support architecture review):
11//!
12//! - **Dispatch on `RivetType`, never the physical Arrow type.** The previous
13//!   `bq_compat` matched on `arrow::DataType` and so was blind to `json` /
14//!   `uuid` / `enum` (all `Utf8` / `FixedSizeBinary` by then) and hard-failed
15//!   UUID. The resolver keys off the semantic type; Arrow is consulted only
16//!   for decimal precision (and `RivetType::Decimal` already carries `p,s`).
17//! - **Total & infallible.** Every `(RivetType, ExportTarget)` pair yields a
18//!   populated [`TargetColumnSpec`]; an unmappable column is a `status: Fail`
19//!   row, never an `Err`. This keeps the type-report table and `--json` shape
20//!   stable.
21//! - **`autoload_type` tells the truth.** It encodes the *empirically
22//!   verified* behavior of each target's Parquet autoloader — notably that
23//!   BigQuery autoload degrades Parquet `JsonType`/`UUIDType` to `BYTES`,
24//!   `isAdjustedToUTC=false` timestamps to `TIMESTAMP` (not `DATETIME`), and
25//!   3-level lists to `REPEATED RECORD{item}`. DuckDB honors all of them.
26//!
27//! BigQuery numeric limits (as of 2025):
28//!   NUMERIC    — precision 1–29, scale 0–9
29//!   BIGNUMERIC — precision 1–76, scale 0–38
30
31use arrow::datatypes::DataType;
32use serde::Serialize;
33
34use super::{RivetType, TimeUnit, TypeFidelity, TypeMapping};
35
36/// A supported downstream warehouse target. Closed, in-tree, contract-tested
37/// set; chosen at runtime from `--target X`, one per run.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ExportTarget {
40    /// Reference consumer of Rivet Parquet — honors every native logical type
41    /// (JSON, UUID, decimal, list) on autoload.
42    DuckDb,
43    /// Cloud warehouse. Its Parquet autoloader is weaker than DuckDB's: see
44    /// the per-type `autoload_type` notes in [`bigquery`].
45    BigQuery,
46    /// Cloud warehouse. Like BigQuery, its Parquet autoload degrades JSON,
47    /// UUID, naive timestamps and TIME — see [`snowflake`]. Verified live.
48    Snowflake,
49    /// Columnar warehouse with the most faithful Parquet autoload of the
50    /// warehouse targets: native `UInt64`, `Decimal`, `DateTime64` (naive *and*
51    /// tz), and `Array`, so most types autoload exactly. Diverges only on `UUID`
52    /// (lands as `FixedString(16)`), `JSON` (lands as `String`) and `TIME` (no
53    /// native type) — see [`clickhouse`]. Verified live against `clickhouse_load`.
54    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    /// Human-readable list of every spelling [`parse`](Self::parse) accepts,
69    /// for "unknown target" error messages. Single source so the message can't
70    /// drift from `parse` (it once said "bigquery, duckdb" and missed
71    /// snowflake). Aliases are shown in parens after each canonical name.
72    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    /// Resolve one already-mapped column against this target.
86    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        // Fidelity floor (ADR-0014 T6): the target status must not be rosier
94        // than the source fidelity warrants — a lossy/unsupported source column
95        // can never resolve to a clean `Ok`.
96        if input.fidelity.is_unsafe_for_strict_mode() && spec.status == TargetStatus::Ok {
97            spec.status = TargetStatus::Warn;
98        }
99        spec
100    }
101
102    /// Resolve a whole table's worth of columns, one spec per column in order.
103    /// Consumed today by the type-report's recovery-SQL emission
104    /// (`preflight::type_report`); `plan-load` (ADR-0014 Phase B) would be a
105    /// second consumer.
106    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    /// SQL that recovers target-native types after a bare autoload degraded
114    /// them (ADR-0014 L5). For BigQuery this is a `CREATE TABLE … AS SELECT`
115    /// over the autoloaded staging table applying per-column casts — BigQuery's
116    /// Parquet loader will NOT coerce a *declared* native type on load (verified
117    /// against live BQ: BYTES→JSON, TIMESTAMP→DATETIME loads are rejected), so
118    /// the recovery has to be a post-load transform, not a load schema. `None`
119    /// when the target reads the interchange Parquet faithfully (DuckDB).
120    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/// Status of a column's resolution against a specific target.
131#[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/// The borrowed subset a resolver is allowed to read. Built from a
150/// [`TypeMapping`] via [`From`]. Dispatch is on `rivet_type`; `arrow_type` is
151/// retained for callers that want it but the resolver reads precision from
152/// `RivetType::Decimal` directly.
153#[derive(Debug, Clone, Copy)]
154pub struct TargetInput<'a> {
155    pub column_name: &'a str,
156    pub rivet_type: &'a RivetType,
157    /// Retained for callers and future precision-sensitive targets; the
158    /// resolver reads precision from `RivetType::Decimal` directly today.
159    #[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/// One column's per-target materialization spec (ADR-0014 L4). Uniform across
176/// targets so the type-report table and `--json` stay stable; an unmappable
177/// column is a `status: Fail` row, not an error.
178#[derive(Debug, Clone, Serialize)]
179pub struct TargetColumnSpec {
180    /// Name copied through so a `Vec<TargetColumnSpec>` is self-describing.
181    pub column_name: String,
182    /// Native warehouse type for full fidelity, e.g. "JSON", "UBIGINT", "NUMERIC".
183    pub target_type: String,
184    /// Type a generic Parquet reader infers without a declared schema. May
185    /// differ from `target_type` (e.g. BigQuery autoloads JSON as "BYTES").
186    pub autoload_type: String,
187    pub status: TargetStatus,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub note: Option<String>,
190    /// Materialization snippet / load-schema hint (L5) to recover the native
191    /// type when autoload diverges. `None` when `autoload_type == target_type`.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub cast_sql: Option<String>,
194}
195
196/// Internal per-type resolution result, before the column name and cast
197/// substitution are applied.
198struct Resolved {
199    target_type: String,
200    autoload_type: String,
201    status: TargetStatus,
202    note: Option<String>,
203    /// `cast_sql` template with a `{col}` placeholder, or `None`.
204    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    /// Native type that *autoloads as something else* — the divergence the
219    /// resolver exists to surface.
220    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
272/// Emit a BigQuery type-recovery statement (ADR-0014 L5): load the interchange
273/// Parquet with `--autodetect` into `<table>__staging`, then run this CTAS to
274/// materialise the native types that bare autoload degrades (JSON/UUID→BYTES,
275/// naive timestamp→TIMESTAMP). Columns with a `cast_sql` get that cast; the
276/// rest pass through unchanged.
277///
278/// Verified against live BigQuery: a load schema that *declares* native types
279/// is rejected (the Parquet loader won't coerce a column's type on load), so
280/// the recovery must be this post-load transform.
281/// The recovery `SELECT` body, shared by every degrading target. The cast
282/// branch *is* the materialization contract (ADR-0014 L5) and is identical
283/// across targets — only the passthrough form (identifier quoting, alias)
284/// differs, so each target supplies that as `passthrough`. Deleting this and
285/// inlining the fold would re-scatter the cast logic across N targets.
286fn 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
309/// Emit a Snowflake type-recovery script (ADR-0014 L5). Snowflake's Parquet
310/// autoload degrades JSON→TEXT, UUID→BINARY, naive timestamp→NUMBER (µs) and
311/// TIME→NUMBER, so the recovery is a post-load CTAS over a `MATCH_BY_COLUMN_NAME`
312/// staging table. INFER_SCHEMA emits lowercase, case-sensitive names, so every
313/// reference is double-quoted. Verified live (2026-06-01) via the `snow` CLI.
314fn 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
330// ── BigQuery ─────────────────────────────────────────────────────────────────
331
332mod bigquery {
333    use super::*;
334
335    /// BigQuery NUMERIC precision/scale limits.
336    const NUMERIC_MAX_P: u8 = 29;
337    const NUMERIC_MAX_S: i8 = 9;
338    /// BigQuery BIGNUMERIC precision/scale limits.
339    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            // u64 > i64::MAX overflows the INT64 autoload and cannot be
351            // recovered post-load (the bits are already wrong). The only fix is
352            // source-side: map the column to decimal(20,0) with a column
353            // override so it rides as Parquet DECIMAL → BigQuery NUMERIC.
354            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            // tz-aware timestamp → instant → TIMESTAMP, autoloads cleanly.
366            RivetType::Timestamp {
367                timezone: Some(_), ..
368            } => Resolved::ok("TIMESTAMP"),
369            // Nanosecond naive timestamp (the `timestamp_ns` override, e.g. SQL
370            // Server datetime2(7)) has no BigQuery native temporal type: the loader
371            // does not recognise the Parquet TIMESTAMP(NANOS) logical type and
372            // autoloads the raw INT64 nanoseconds-since-epoch (verified live
373            // 2026-06-07 — lossless as an integer). A native TIMESTAMP needs
374            // TIMESTAMP_MICROS(DIV(col,1000)), which drops the sub-µs digits
375            // (BigQuery TIMESTAMP is microsecond), so no lossless temporal cast
376            // exists — keep INT64, or use the default `timestamp` for BigQuery.
377            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            // naive timestamp → wall-clock → DATETIME, but BigQuery autoload
389            // ignores Parquet isAdjustedToUTC=false and yields TIMESTAMP
390            // (verified). `DATETIME(ts)` recovers the wall-clock after load.
391            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            // Parquet JSON logical type autoloads as BYTES in BigQuery
401            // (verified). Declare JSON in the load schema for native JSON.
402            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            // UUID rides as FixedSizeBinary(16) + UUIDType; BigQuery has no UUID
410            // type and autoloads it as 16-byte BYTES (verified). Native is
411            // STRING (canonical text).
412            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        // Rivet writes the Parquet list element as `item` (arrow-rs default, not
452        // the spec's `element`), so with `enable_list_inference` BigQuery loads an
453        // array as ARRAY<STRUCT<item T>> (== REPEATED RECORD{item}). Declare THAT
454        // exact shape in the LOAD DATA schema so `rivet load` succeeds: a bare
455        // `REPEATED T` is invalid standard-SQL DDL, and a clean `ARRAY<T>` loads
456        // EMPTY (BigQuery can't map the `item`-named element without the struct).
457        // autoload == target here (BigQuery autodetect produces the same nested
458        // shape), so this is a warn, not a divergence — flatten to a scalar array
459        // after load with `ARRAY(SELECT el.item FROM UNNEST(col) AS el)`.
460        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
468// ── DuckDB ───────────────────────────────────────────────────────────────────
469
470mod duckdb {
471    use super::*;
472
473    pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
474        native(input.rivet_type).into_spec(input)
475    }
476
477    /// DuckDB honors every native Parquet logical type Rivet writes, so
478    /// `autoload_type == target_type` for all supported variants (verified).
479    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                    // DuckDB DECIMAL maxes at precision 38; a wider decimal autoloads
500                    // as DOUBLE (lossy past 2^53, verified live). Tell that truth as a
501                    // divergence, not a same-type warn — and no cast recovers a DOUBLE,
502                    // so the recovery is upstream (narrow the source precision).
503                    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            // DuckDB has a native nanosecond timestamp; the `timestamp_ns` override
521            // round-trips losslessly (verified live 2026-06-07).
522            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
548// ── Snowflake ────────────────────────────────────────────────────────────────
549
550mod snowflake {
551    use super::*;
552
553    pub(super) fn resolve(input: &TargetInput<'_>) -> TargetColumnSpec {
554        native(input.rivet_type).into_spec(input)
555    }
556
557    /// Snowflake autoload (INFER_SCHEMA / COPY) + recovery casts — verified live
558    /// (2026-06-01). Needs `BINARY_AS_TEXT=FALSE` in the file format; cast column
559    /// refs are double-quoted because INFER_SCHEMA names are lowercase and
560    /// case-sensitive.
561    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            // u64 > INT64_MAX overflows the Parquet read; fix at source.
566            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                    // Snowflake NUMBER maxes at precision 38 — NUMBER(50,10) is not a
583                    // valid type. Never claim Ok for something the warehouse rejects;
584                    // past 38 is a Fail, the same discipline BigQuery applies past its
585                    // BIGNUMERIC ceiling.
586                    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            // TIME autoloads as NUMBER (µs of day); rebuild with TIME_FROM_PARTS.
596            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            // tz timestamp lands as TIMESTAMP_NTZ holding the UTC instant.
603            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            // Nanosecond naive timestamp autoloads as NUMBER (ns since epoch),
612            // like the µs case but at scale 9. Snowflake TIMESTAMP_NTZ holds full
613            // 9-digit precision, so TO_TIMESTAMP_NTZ(col, 9) recovers it
614            // losslessly — verified live 2026-06-07 (NUMBER 1717761600123456700 →
615            // 2024-06-07 12:00:00.123456700, the 7th digit intact).
616            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            // naive timestamp autoloads as NUMBER (µs since epoch).
627            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            // bytea/blob needs BINARY_AS_TEXT=FALSE or non-UTF8 bytes fail.
635            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            // JSON autoloads as TEXT; PARSE_JSON recovers native VARIANT.
640            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            // UUID (FixedSizeBinary 16) autoloads as 16-byte BINARY.
647            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            // A Parquet list autoloads as VARIANT (holding the JSON array), not
657            // native ARRAY — verified live 2026-06-01: INFER_SCHEMA reports
658            // VARIANT for both `tags` (text[]) and `nums` (int[]). Recover the
659            // native ARRAY with `::ARRAY` after load.
660            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
681// ── ClickHouse ───────────────────────────────────────────────────────────────
682
683fn 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    /// ClickHouse Parquet autoload (`file(..., 'Parquet')`) — pinned against the
704    /// live `clickhouse_load` matrix. The most faithful warehouse target: native
705    /// `UInt64`, `Decimal`, `DateTime64` (naive *and* tz) and `Array`, so most
706    /// types autoload exactly. Divergences: `UUID` -> `FixedString(16)`, `JSON`
707    /// -> `String`, and `TIME` (no native type -> `Int64`, µs of day).
708    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            // Native unsigned 64-bit — ClickHouse holds the full UInt64 range, so
715            // the overflow that forces BigQuery/Snowflake to a load-schema note
716            // never happens here. The headline difference from the cloud warehouses.
717            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                    // ClickHouse Decimal caps at precision 76 (Decimal256) — the same
730                    // silent-Ok class as Snowflake past 38: never claim a type the
731                    // engine rejects. Fail past the ceiling.
732                    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            // No time-of-day type; Time64 autoloads as Int64 (µs of day). There is
742            // no native type to recover to — fix at source if a real time matters.
743            RivetType::Time { .. } => Resolved::warn(
744                "Int64",
745                "ClickHouse has no TIME type; time-of-day autoloads as Int64 (µs of day)",
746            ),
747            // DateTime64 holds both naive and tz timestamps natively (verified:
748            // naive -> DateTime64(6), tz -> DateTime64(6, 'UTC')).
749            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            // ClickHouse String holds arbitrary bytes, so bytea/blob round-trips
763            // losslessly — no BINARY_AS_TEXT caveat like Snowflake.
764            RivetType::Binary => Resolved::ok("String"),
765            // ClickHouse's native JSON type is a declared column (still settling
766            // across versions); Parquet JSON autoloads as String holding the valid
767            // JSON text. Recover by declaring a JSON column at load — there is no
768            // safe SELECT-time cast, so the recovery is upstream (a note).
769            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            // The 16-byte UUID field autoloads as FixedString(16) (verified live).
776            // The bytes are the canonical UUID; recover the native UUID with the
777            // hex -> dashed-text -> toUUID round-trip clickhouse_load pins.
778            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            // A Parquet list autoloads as a native Array (verified: tags ->
788            // Array(Nullable(String)), nums -> Array(Nullable(Int32))).
789            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    // ── nanosecond timestamp (`timestamp_ns` override) per-target autoload ────
832    // The default `timestamp` is microsecond; ns is opt-in (datetime2(7)). These
833    // pin the autoload truth verified live on 2026-06-07 so the preflight report
834    // doesn't claim the microsecond behaviour for a ns column.
835
836    #[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        // No lossless temporal recovery (ns→µs is lossy) — like the UINT64 case.
847        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        // Lossless recovery (TIMESTAMP_NTZ holds 9 digits) → cast_sql is Some at
871        // scale 9 (not the µs scale 6); `{col}` is substituted with the column.
872        assert_eq!(s.cast_sql.as_deref(), Some(r#"TO_TIMESTAMP_NTZ("c", 9)"#));
873    }
874
875    // ── dispatch on RivetType, not Arrow — the headline fix ──────────────────
876
877    #[test]
878    fn bq_uuid_resolves_not_fails() {
879        // The old arrow-dispatch `bq_compat` hard-failed UUID (FixedSizeBinary
880        // had no arm). Now it resolves on RivetType: native STRING, BYTES on
881        // autoload.
882        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        // The array target_type MUST be a valid, loadable standard-SQL DDL that
962        // matches rivet's `item`-named Parquet list element. Old code emitted
963        // `REPEATED STRING` — invalid DDL, `rivet load` → BigQuery syntax error
964        // ("Expected ) or , but got identifier STRING"). Guard the exact shape.
965        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        // warn: BigQuery autodetect produces the same nested shape → no divergence.
971        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    // ── DuckDB honors every logical type — autoload == native ────────────────
1009
1010    #[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    // ── edge cases: remediation hints must recover from the DEGRADED state ────
1082    // Regression guard for the bug class where the resolver proposes a post-load
1083    // cast that cannot actually recover an already-lossy value (e.g. a UINT64
1084    // that overflowed into INT64). See CLAUDE.md "Remediation hints must recover
1085    // from the degraded state".
1086
1087    #[test]
1088    fn cast_sql_is_none_when_post_load_recovery_is_impossible() {
1089        // UINT64 > INT64_MAX has already overflowed by the time it autoloads as
1090        // INT64; a SELECT-time cast would operate on corrupted bits. The only
1091        // fix is source-side (a decimal override) — cast_sql MUST be None and
1092        // the note must point there, not promise a post-load cast.
1093        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        // JSON/UUID/naive-timestamp autoload to a degraded type but still hold
1108        // the value losslessly, so a post-load cast genuinely recovers it.
1109        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        // Invariant: whenever BigQuery autoload diverges from the native type the
1126        // operator gets SOME recovery — a lossless post-load `cast_sql`, or a
1127        // note describing the fix (post-load transform, or a source override).
1128        // Never a silent no-op (the bug class).
1129        let naive = RivetType::Timestamp {
1130            unit: super::super::TimeUnit::Microsecond,
1131            timezone: None,
1132        };
1133        // NOTE: List is NOT here — arrays load natively as ARRAY<STRUCT<item T>>
1134        // (target == autoload, a warn), so they are no longer a divergence. See
1135        // `bq_list_declares_loadable_array_struct_item_ddl`.
1136        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    // ── edge cases: decimal precision/scale overflow at the target boundary ───
1151
1152    #[test]
1153    fn bq_decimal_limit_boundaries() {
1154        // Exact BIGNUMERIC ceiling is ok.
1155        assert_eq!(
1156            bq(&RivetType::Decimal {
1157                precision: 76,
1158                scale: 38
1159            })
1160            .status,
1161            TargetStatus::Ok
1162        );
1163        // One past precision overflows BIGNUMERIC → Fail, never a silent clamp.
1164        assert_eq!(
1165            bq(&RivetType::Decimal {
1166                precision: 77,
1167                scale: 38
1168            })
1169            .status,
1170            TargetStatus::Fail
1171        );
1172        // One past scale → Fail.
1173        assert_eq!(
1174            bq(&RivetType::Decimal {
1175                precision: 76,
1176                scale: 39
1177            })
1178            .status,
1179            TargetStatus::Fail
1180        );
1181        // Between NUMERIC and BIGNUMERIC escalates rather than overflowing NUMERIC.
1182        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        // DuckDB DECIMAL maxes at precision 38; a wider decimal autoloads as DOUBLE
1195        // (lossy past 2^53) — verified live (pg_edge_decimal_boundaries_round_trip).
1196        // The resolver must tell that truth: autoload_type = DOUBLE, flagged as a
1197        // DIVERGENCE (target != autoload), and NO cast_sql — DOUBLE has already lost
1198        // precision at autoload, so a SELECT-time cast recovers nothing (narrow the
1199        // source precision instead).
1200        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        // Snowflake NUMBER maxes at precision 38 — NUMBER(50,10) is not a valid
1222        // type. The resolver must NOT claim Ok for a type Snowflake would reject;
1223        // past 38 is a Fail (narrow precision at source, or load as FLOAT via a
1224        // declared schema), the same discipline BigQuery applies past BIGNUMERIC.
1225        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        // The boundary is exactly 38: precision 38 is still ok.
1235        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        // ClickHouse Decimal caps at precision 76 (Decimal256) — past it is a Fail,
1248        // not a false Ok, mirroring the Snowflake(>38) / BigQuery(>76,38) guards.
1249        assert_eq!(
1250            ch(&RivetType::Decimal {
1251                precision: 80,
1252                scale: 2
1253            })
1254            .status,
1255            TargetStatus::Fail
1256        );
1257        // 76 (the Decimal256 ceiling) is still ok.
1258        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        // ClickHouse has no TIME type; time-of-day autoloads as Int64 (µs of day).
1271        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        // DateTime64 holds a nanosecond naive timestamp natively at scale 9.
1281        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        // Enum labels ride as text (String), no divergence.
1292        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        // Enum labels ride as text on Snowflake too — pins the SF Enum arm the
1300        // type-matrix live test never asserts.
1301        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        // A nested unsupported element must be a Fail row (not a panic, not a
1316        // silently-dropped column) on every target — the arms exist per target
1317        // but were untested. `List<Unsupported>` is the only way to reach them.
1318        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        // Interval maps per target (BQ STRING / DuckDB INTERVAL / SF TEXT / CH String)
1336        // — pin it so a future arm edit can't silently drop it to Fail.
1337        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        // Negative-scale decimals are rejected by the Parquet writer today, so this
1354        // arm is resolver-only — but it is live code and must not silently vanish.
1355        // BigQuery fails (no negative scale); DuckDB/Snowflake/ClickHouse warn +
1356        // route via a declared schema.
1357        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    // ── L5 recovery SQL (the post-load transform for BigQuery autoload) ───────
1368
1369    #[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        // The post-load casts that actually recover native types (verified live
1399        // against BigQuery — a declared-type load is rejected, a cast is not).
1400        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        // Arrays load natively as ARRAY<STRUCT<item T>> (== autoload), so they are
1404        // NOT recovered — no cast, no flatten (the warn note documents the optional
1405        // UNNEST). tags is still projected (see the projection-count test).
1406        assert!(!sql.contains("AS tags") && !sql.contains("UNNEST(tags)"));
1407        // OK columns pass through unchanged.
1408        assert!(sql.contains("SELECT\n  id"));
1409        // Reads the autoload staging table, writes the recovered table.
1410        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), // ok → passthrough
1436            (
1437                "amount",
1438                RivetType::Decimal {
1439                    precision: 18,
1440                    scale: 2,
1441                },
1442            ), // ok → passthrough
1443            ("attrs", RivetType::Json), // divergent → cast
1444            ("uid", RivetType::Uuid), // divergent → cast
1445            ("created_at", naive),    // divergent → cast
1446            (
1447                "tags",
1448                RivetType::List {
1449                    inner: Box::new(RivetType::String),
1450                },
1451            ), // native ARRAY<STRUCT<item T>> → passthrough (not a divergence)
1452        ];
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        // The SELECT projects exactly one item per input column — nothing dropped,
1462        // nothing duplicated.
1463        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        // OK columns pass through unchanged (bare `  name,`); divergent ones
1477        // carry their cast (`… AS name`).
1478        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        // tags (array) loads natively as ARRAY<STRUCT<item T>> → passthrough, not cast
1484        // (projected once — asserted by the count/contains checks above).
1485        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        // The live clickhouse_load test hand-writes a toUUID expr; this pins the
1492        // resolver's OWN emitted recovery SQL so a regression in the Rust string is
1493        // caught offline. uuid diverges (FixedString(16) -> toUUID); json is a
1494        // load-schema note (cast_sql=None) so it passes through; scalars pass through.
1495        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        // uuid recovers via the resolver's emitted cast, not a hand-written expr.
1512        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        // json has no lossless SELECT-time cast (declared at load) — passes through.
1517        assert!(sql.contains("  attrs") && !sql.contains("AS attrs"));
1518        // scalars pass through unchanged.
1519        assert!(sql.contains("  id") && !sql.contains("AS id"));
1520        // Reads the autoload staging table, writes the recovered MergeTree table.
1521        assert!(sql.contains("CREATE TABLE events ENGINE = MergeTree"));
1522        assert!(sql.contains("FROM events__staging"));
1523        // Exactly one projection per column — nothing dropped or duplicated.
1524        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    // ── Snowflake (verified live 2026-06-01) ─────────────────────────────────
1537
1538    #[test]
1539    fn snowflake_autoload_degradations_and_native_casts() {
1540        // JSON → TEXT autoload / VARIANT native, recover PARSE_JSON.
1541        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        // UUID → BINARY autoload / TEXT native, recover via HEX_ENCODE + REGEXP.
1546        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        // naive timestamp → NUMBER autoload / TIMESTAMP_NTZ native.
1551        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        // TIME → NUMBER autoload, recover TIME_FROM_PARTS.
1560        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        // decimal is native NUMBER(p,s) — no cast.
1566        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        // list autoloads as VARIANT (verified live), recover native ARRAY with ::ARRAY.
1573        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        // Staging columns are lowercase + quoted; passthrough quotes the source.
1603        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        // The load preamble the recovery depends on.
1608        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        // The "unknown target" error message reads from this; it must name
1634        // every target `parse` accepts, or the hint sends operators wrong.
1635        // snowflake regression: the old message hard-coded "bigquery, duckdb".
1636        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}