Skip to main content

faucet_core/
create_table.rs

1//! Shared auto-create-table planning for table-based sinks (#580).
2//!
3//! A first-ever sync cannot assume the destination table exists. The BigQuery
4//! sink solved this in #578 by inferring a schema from the first written page
5//! and creating the table; every other table destination has the same problem,
6//! and before this module each one answered it differently (or not at all) —
7//! different field names, different defaults, some with no create path at all.
8//!
9//! This module holds the **dialect-neutral half**: turn a page of records into
10//! an ordered, deduplicated column plan. Each sink supplies only the mapping
11//! from [`SqlBaseType`] to its own type keyword — the same mapping its
12//! `evolve_schema` already needs, so the two agree by construction rather than
13//! by inspection.
14//!
15//! ## Why column order is sorted, not source order
16//!
17//! Source order would read better in a `SELECT *`, but it is not recoverable:
18//! `serde_json::Map` is a `BTreeMap` or an `IndexMap` depending on whether the
19//! `preserve_order` feature is unified into the build, so "the order the
20//! record listed its keys" is a *build-time* property. A created table's
21//! column order would then differ between a `-p crate` build and an
22//! `--all-features` one — the kind of difference that only shows up as a
23//! diffed schema in production. Sorted is worse to read and always the same.
24
25use crate::drift::{SqlBaseType, json_schema_base_type};
26use serde_json::Value;
27
28/// One column of a planned `CREATE TABLE`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PlannedColumn {
31    /// Column name, exactly as it appeared in the record. Quoting is the
32    /// sink's job (each dialect quotes differently).
33    pub name: String,
34    /// Dialect-neutral type, to be mapped by the sink.
35    pub base_type: SqlBaseType,
36    /// Whether any observed value for this column was null, or the column was
37    /// absent from at least one record. Planned columns are always created
38    /// nullable in practice — see [`plan_columns`] — but the flag is kept so a
39    /// sink that wants to emit `NOT NULL` for a provably-present column can.
40    pub nullable: bool,
41}
42
43/// Plan the columns for a table created from `page`.
44///
45/// Returns `None` when the page carries nothing to infer from (empty, or no
46/// record is a JSON object) — the caller must then leave the table
47/// uncreated rather than emit a zero-column `CREATE TABLE`, and try again on
48/// the next page.
49///
50/// **Every column is planned nullable.** A column that happened to be present
51/// and non-null in the first page is not thereby required forever, and a
52/// `NOT NULL` inferred from one page turns the *second* page into a hard write
53/// failure the moment a real-world record omits the field. Narrowing later is
54/// the schema-drift policy's job (#194), which can see more than one page.
55pub fn plan_columns(page: &[Value]) -> Option<Vec<PlannedColumn>> {
56    let schema = crate::schema::infer_schema(page);
57    let props = schema.get("properties")?.as_object()?;
58    if props.is_empty() {
59        return None;
60    }
61
62    // Sorted, for the determinism reason in the module docs. `infer_schema`
63    // already unions every record's keys, so a column that appears only in a
64    // later record is still planned.
65    let mut ordered: Vec<String> = props.keys().cloned().collect();
66    ordered.sort_unstable();
67
68    let columns: Vec<PlannedColumn> = ordered
69        .into_iter()
70        .map(|name| {
71            let fragment = &props[&name];
72            // A column whose every observed value was null carries no type
73            // information; TEXT is the one choice that can hold whatever
74            // shows up next without a lossy cast.
75            let mut base_type = json_schema_base_type(fragment).unwrap_or(SqlBaseType::Text);
76            // An integer above i64::MAX fits no BIGINT/INTEGER column (SQLite
77            // would silently store it as a rounded REAL); TEXT keeps its digits.
78            if base_type == SqlBaseType::Integer && holds_wide_integer(page, &name) {
79                base_type = SqlBaseType::Text;
80            }
81            PlannedColumn {
82                base_type,
83                nullable: true,
84                name,
85            }
86        })
87        .collect();
88
89    (!columns.is_empty()).then_some(columns)
90}
91
92/// Whether any record holds an integer above `i64::MAX` under `column`.
93fn holds_wide_integer(page: &[Value], column: &str) -> bool {
94    page.iter().any(|r| {
95        r.get(column)
96            .and_then(Value::as_number)
97            .is_some_and(|n| n.is_u64() && n.as_i64().is_none())
98    })
99}
100
101/// Render a `CREATE TABLE` column list, given a dialect's type mapper and
102/// identifier quoter.
103///
104/// Kept separate from [`plan_columns`] so a sink whose `CREATE TABLE` needs
105/// extra clauses (a primary key, a partition spec, an engine) composes this
106/// into its own statement rather than fighting a one-size template.
107pub fn render_columns<Q, T>(columns: &[PlannedColumn], quote: Q, ty: T) -> String
108where
109    Q: Fn(&str) -> String,
110    T: Fn(SqlBaseType) -> &'static str,
111{
112    columns
113        .iter()
114        .map(|c| format!("{} {}", quote(&c.name), ty(c.base_type)))
115        .collect::<Vec<_>>()
116        .join(", ")
117}
118
119/// Plan the columns for a table whose writes dedup on `key` (#676).
120///
121/// Like [`plan_columns`], plus: every key column is planned even when the
122/// first page never carries it (typed TEXT, the type that holds anything), and
123/// key columns are marked non-nullable. A created table can then carry a
124/// primary key on `key` — which is what gives an upsert's `ON CONFLICT` /
125/// `MERGE` a target on the very first run.
126pub fn plan_keyed_columns(page: &[Value], key: &[String]) -> Option<Vec<PlannedColumn>> {
127    let mut columns = plan_columns(page)?;
128    for k in key {
129        match columns.iter_mut().find(|c| &c.name == k) {
130            Some(c) => c.nullable = false,
131            None => columns.push(PlannedColumn {
132                name: k.clone(),
133                base_type: SqlBaseType::Text,
134                nullable: false,
135            }),
136        }
137    }
138    Some(columns)
139}
140
141/// Render a column list where the type keyword may depend on the column, not
142/// just its base type — for dialects that cannot index an unbounded type and so
143/// need a bounded one for key columns (MySQL `TEXT`, SQL Server `NVARCHAR(MAX)`).
144pub fn render_column_defs<Q, T>(columns: &[PlannedColumn], quote: Q, ty: T) -> String
145where
146    Q: Fn(&str) -> String,
147    T: Fn(&PlannedColumn) -> &'static str,
148{
149    columns
150        .iter()
151        .map(|c| format!("{} {}", quote(&c.name), ty(c)))
152        .collect::<Vec<_>>()
153        .join(", ")
154}
155
156/// `PRIMARY KEY (<key…>)` for a created table, or `None` for an empty key.
157pub fn render_primary_key<Q>(key: &[String], quote: Q) -> Option<String>
158where
159    Q: Fn(&str) -> String,
160{
161    (!key.is_empty()).then(|| {
162        format!(
163            "PRIMARY KEY ({})",
164            key.iter().map(|k| quote(k)).collect::<Vec<_>>().join(", ")
165        )
166    })
167}
168
169/// The uniform error a sink raises when its target is missing and
170/// `create_table: false` (#580).
171///
172/// One wording across every sink, because the fix is always the same two
173/// choices and an operator hitting it on connector number three should not
174/// have to re-read a different sentence.
175pub fn missing_target_error(connector: &str, target: &str) -> crate::error::FaucetError {
176    crate::error::FaucetError::Sink(format!(
177        "{connector}: target `{target}` does not exist and `create_table: false`. \
178         Create it first, or set `create_table: true` to have faucet create it from \
179         the first page's inferred schema."
180    ))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use serde_json::json;
187
188    #[test]
189    fn plans_every_column_with_its_inferred_type() {
190        let page = vec![
191            json!({ "id": 1, "name": "a", "amount": 1.5, "ok": true }),
192            json!({ "id": 2, "name": "b", "amount": 2.5, "ok": false }),
193        ];
194        let cols = plan_columns(&page).expect("a plan");
195        let by_name: std::collections::HashMap<&str, SqlBaseType> = cols
196            .iter()
197            .map(|c| (c.name.as_str(), c.base_type))
198            .collect();
199        assert_eq!(by_name["id"], SqlBaseType::Integer);
200        assert_eq!(by_name["name"], SqlBaseType::Text);
201        assert_eq!(by_name["amount"], SqlBaseType::Double);
202        assert_eq!(by_name["ok"], SqlBaseType::Boolean);
203    }
204
205    #[test]
206    fn column_order_is_deterministic_regardless_of_record_key_order() {
207        // `serde_json::Map` is a BTreeMap or an IndexMap depending on feature
208        // unification, so a plan that read order off the record would produce
209        // a different table under `-p crate` than under `--all-features`.
210        let a = plan_columns(&[json!({ "z": 1, "a": 2, "m": 3 })]).expect("a");
211        let b = plan_columns(&[json!({ "a": 2, "m": 3, "z": 1 })]).expect("b");
212        assert_eq!(a, b, "the same record set must plan the same table");
213        assert_eq!(
214            a.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
215            vec!["a", "m", "z"]
216        );
217    }
218
219    #[test]
220    fn a_column_appearing_only_in_a_later_record_is_still_planned() {
221        // A sparse first record must not silently drop a column — the next
222        // page would then fail to write it, on a table faucet itself created.
223        let page = vec![json!({ "id": 1 }), json!({ "id": 2, "note": "hi" })];
224        let cols = plan_columns(&page).expect("a plan");
225        assert_eq!(
226            cols.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
227            vec!["id", "note"]
228        );
229    }
230
231    #[test]
232    fn an_integer_column_above_i64_plans_as_text() {
233        let cols = plan_columns(&[
234            json!({"big": 1, "small": 1}),
235            json!({"big": 18446744073709551615u64, "small": 2}),
236        ])
237        .unwrap();
238        let ty = |n: &str| cols.iter().find(|c| c.name == n).unwrap().base_type;
239        assert_eq!(ty("big"), SqlBaseType::Text);
240        assert_eq!(ty("small"), SqlBaseType::Integer);
241    }
242
243    #[test]
244    fn every_planned_column_is_nullable() {
245        // Inferring NOT NULL from one page turns the second page into a hard
246        // write failure the first time a record omits the field.
247        let page = vec![json!({ "id": 1, "name": "a" })];
248        let cols = plan_columns(&page).expect("a plan");
249        assert!(cols.iter().all(|c| c.nullable), "{cols:?}");
250    }
251
252    #[test]
253    fn nested_values_plan_as_json() {
254        let page = vec![json!({ "obj": {"a": 1}, "arr": [1, 2] })];
255        let cols = plan_columns(&page).expect("a plan");
256        assert!(
257            cols.iter().all(|c| c.base_type == SqlBaseType::Json),
258            "{cols:?}"
259        );
260    }
261
262    #[test]
263    fn an_all_null_column_falls_back_to_text() {
264        // No type information at all; TEXT is the only choice that can hold
265        // whatever turns up next without a lossy cast.
266        let page = vec![json!({ "id": 1, "maybe": Value::Null })];
267        let cols = plan_columns(&page).expect("a plan");
268        let maybe = cols.iter().find(|c| c.name == "maybe").expect("column");
269        assert_eq!(maybe.base_type, SqlBaseType::Text);
270    }
271
272    #[test]
273    fn an_empty_or_non_object_page_plans_nothing() {
274        // A zero-column CREATE TABLE is invalid in every dialect, so the sink
275        // must wait for a page it can actually learn from.
276        assert!(plan_columns(&[]).is_none());
277        assert!(plan_columns(&[json!(1), json!("x")]).is_none());
278        assert!(plan_columns(&[json!({})]).is_none());
279    }
280
281    #[test]
282    fn mixed_int_and_float_widens_to_double() {
283        // Creating an INTEGER column and then writing 1.5 into it is exactly
284        // the silent-truncation class this inference exists to avoid.
285        let page = vec![json!({ "n": 1 }), json!({ "n": 1.5 })];
286        let cols = plan_columns(&page).expect("a plan");
287        assert_eq!(cols[0].base_type, SqlBaseType::Double);
288    }
289
290    #[test]
291    fn render_columns_uses_the_dialects_quoting_and_types() {
292        let cols = plan_columns(&[json!({ "id": 1, "name": "a" })]).expect("a plan");
293        let sql = render_columns(
294            &cols,
295            |n| format!("\"{}\"", n.replace('"', "\"\"")),
296            |t| match t {
297                SqlBaseType::Integer => "BIGINT",
298                SqlBaseType::Double => "DOUBLE PRECISION",
299                SqlBaseType::Boolean => "BOOLEAN",
300                SqlBaseType::Text => "TEXT",
301                SqlBaseType::Json => "JSONB",
302            },
303        );
304        assert_eq!(sql, "\"id\" BIGINT, \"name\" TEXT");
305    }
306
307    #[test]
308    fn the_missing_target_error_names_both_ways_out() {
309        let e = missing_target_error("postgres sink", "public.orders");
310        let msg = e.to_string();
311        assert!(msg.contains("public.orders"), "{msg}");
312        assert!(
313            msg.contains("create_table: true"),
314            "must name the fix: {msg}"
315        );
316        assert!(
317            matches!(e, crate::error::FaucetError::Sink(_)),
318            "a missing destination is a sink failure, not a config one — the config \
319             was legal, the destination was not there"
320        );
321    }
322
323    #[test]
324    fn render_columns_quotes_a_hostile_identifier() {
325        // The quoter is the sink's, but the renderer must actually route every
326        // name through it — a name reaching the SQL unquoted is an injection.
327        let cols = plan_columns(&[json!({ "we\"ird": 1 })]).expect("a plan");
328        let sql = render_columns(
329            &cols,
330            |n| format!("\"{}\"", n.replace('"', "\"\"")),
331            |_| "TEXT",
332        );
333        assert_eq!(sql, "\"we\"\"ird\" TEXT");
334    }
335
336    #[test]
337    fn keyed_plan_marks_key_columns_required_and_adds_missing_ones() {
338        let page = vec![json!({ "id": 1, "name": "a" })];
339        let cols = plan_keyed_columns(&page, &["id".into(), "tenant".into()]).expect("a plan");
340        let id = cols.iter().find(|c| c.name == "id").unwrap();
341        assert!(!id.nullable);
342        assert_eq!(id.base_type, SqlBaseType::Integer);
343        let tenant = cols.iter().find(|c| c.name == "tenant").unwrap();
344        assert!(!tenant.nullable);
345        assert_eq!(tenant.base_type, SqlBaseType::Text);
346        assert!(cols.iter().find(|c| c.name == "name").unwrap().nullable);
347    }
348
349    #[test]
350    fn keyed_plan_is_none_for_an_empty_page() {
351        assert!(plan_keyed_columns(&[], &["id".into()]).is_none());
352    }
353
354    #[test]
355    fn column_defs_can_type_by_column() {
356        let cols = vec![
357            PlannedColumn {
358                name: "id".into(),
359                base_type: SqlBaseType::Text,
360                nullable: false,
361            },
362            PlannedColumn {
363                name: "note".into(),
364                base_type: SqlBaseType::Text,
365                nullable: true,
366            },
367        ];
368        let sql = render_column_defs(
369            &cols,
370            |n| format!("`{n}`"),
371            |c| {
372                if c.nullable { "TEXT" } else { "VARCHAR(255)" }
373            },
374        );
375        assert_eq!(sql, "`id` VARCHAR(255), `note` TEXT");
376    }
377
378    #[test]
379    fn primary_key_renders_every_key_column_in_order() {
380        let q = |n: &str| format!("\"{n}\"");
381        assert_eq!(
382            render_primary_key(&["a".into(), "b".into()], q).as_deref(),
383            Some("PRIMARY KEY (\"a\", \"b\")")
384        );
385        assert!(render_primary_key(&[], q).is_none());
386    }
387}