1use crate::drift::{SqlBaseType, json_schema_base_type};
26use serde_json::Value;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PlannedColumn {
31 pub name: String,
34 pub base_type: SqlBaseType,
36 pub nullable: bool,
41}
42
43pub 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 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 let mut base_type = json_schema_base_type(fragment).unwrap_or(SqlBaseType::Text);
76 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
92fn 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
101pub 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
119pub 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
141pub 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
156pub 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
169pub 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 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 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 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 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 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 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 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}