Skip to main content

spikard_codegen/sql/
neutral_to_json_schema.rs

1#![allow(
2    clippy::missing_errors_doc,
3    clippy::missing_panics_doc,
4    clippy::must_use_candidate,
5    clippy::doc_markdown,
6    clippy::too_long_first_doc_paragraph,
7    clippy::module_name_repetitions
8)]
9//! Map scythe's neutral type strings to OpenAPI 3.1 JSON Schema fragments.
10//!
11//! Scythe's type system is documented at
12//! `scythe-core/src/analyzer/type_conversion.rs`. The canonical neutral types
13//! are: `int16`, `int32`, `int64`, `float32`, `float64`, `string`, `bool`,
14//! `bytes`, `uuid`, `date`, `datetime`, `datetime_tz`, `time`, `time_tz`,
15//! `interval`, `json`, `inet`, `decimal`, plus the composite forms
16//! `array<T>`, `range<T>`, `enum::<name>`, `composite::<name>`, and
17//! `json_typed<TypeName>` (produced by scythe's `@json` mapping).
18//!
19//! Nullability is layered by [`json_schema_for`] which wraps the schema with
20//! `{"oneOf": [<schema>, {"type": "null"}]}` when `nullable` is true. This is
21//! the OpenAPI 3.1 idiom (3.0's `"nullable": true` flag is not used).
22
23use scythe_core::analyzer::EnumInfo;
24use scythe_core::catalog::Catalog;
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value, json};
27use thiserror::Error;
28
29/// Build-time knobs for the neutral-type → JSON Schema mapping.
30#[derive(Debug, Clone)]
31pub struct BuildOptions {
32    /// How to render the `decimal` neutral type. JSON Schema has no native
33    /// exact-decimal, so users pick between lossless (`StringPattern`) and
34    /// lossy-but-ergonomic (`Number`).
35    pub decimal_mode: DecimalMode,
36    /// When true, an unrecognised neutral type is an error. When false, it
37    /// falls back to `{}` (any-JSON) so partial schemas still emit.
38    pub strict: bool,
39}
40
41impl Default for BuildOptions {
42    fn default() -> Self {
43        Self {
44            decimal_mode: DecimalMode::StringPattern,
45            strict: false,
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum DecimalMode {
53    /// Render as `{"type": "string", "pattern": "^-?\\d+(\\.\\d+)?$"}`.
54    StringPattern,
55    /// Render as `{"type": "number"}` (lossy — loses precision).
56    Number,
57}
58
59#[derive(Debug, Error, PartialEq, Eq)]
60pub enum NeutralTypeError {
61    #[error("unknown neutral type '{0}'")]
62    Unknown(String),
63}
64
65/// Translate a single neutral type string to a JSON Schema fragment. Does not
66/// apply nullability — see [`json_schema_for`] for the wrapper that does.
67pub fn neutral_to_json_schema(
68    neutral: &str,
69    enums: &[EnumInfo],
70    catalog: &Catalog,
71    opts: &BuildOptions,
72) -> Result<Value, NeutralTypeError> {
73    if let Some(inner) = strip_wrapper(neutral, "array<") {
74        let item = neutral_to_json_schema(inner, enums, catalog, opts)?;
75        return Ok(json!({ "type": "array", "items": item }));
76    }
77    if let Some(inner) = strip_wrapper(neutral, "range<") {
78        let bound = neutral_to_json_schema(inner, enums, catalog, opts)?;
79        let mut props = Map::new();
80        props.insert("lower".to_string(), bound.clone());
81        props.insert("upper".to_string(), bound);
82        props.insert("lower_inclusive".to_string(), json!({ "type": "boolean" }));
83        props.insert("upper_inclusive".to_string(), json!({ "type": "boolean" }));
84        return Ok(json!({ "type": "object", "properties": Value::Object(props) }));
85    }
86    if let Some(enum_name) = neutral.strip_prefix("enum::") {
87        let values: Vec<&str> = enums
88            .iter()
89            .find(|e| e.sql_name.eq_ignore_ascii_case(enum_name))
90            .map(|e| e.values.iter().map(String::as_str).collect())
91            .unwrap_or_default();
92        return Ok(json!({ "type": "string", "enum": values }));
93    }
94    if let Some(composite_name) = neutral.strip_prefix("composite::") {
95        let composite = catalog.get_composite(composite_name);
96        let mut props = Map::new();
97        if let Some(comp) = composite {
98            for field in &comp.fields {
99                let neutral_field = scythe_core_neutral_for(&field.sql_type, catalog);
100                let field_schema = neutral_to_json_schema(&neutral_field, enums, catalog, opts)?;
101                props.insert(field.name.clone(), field_schema);
102            }
103        }
104        return Ok(json!({ "type": "object", "properties": Value::Object(props) }));
105    }
106    if neutral.starts_with("json_typed<") {
107        // `@json col = TypeName` is currently emitted as opaque JSON; future
108        // versions can resolve to `$ref` once a schema registry exists.
109        return Ok(json!({}));
110    }
111
112    let schema = match neutral {
113        "int16" => json!({ "type": "integer", "minimum": -32_768, "maximum": 32_767 }),
114        "int32" => json!({ "type": "integer", "format": "int32" }),
115        "int64" => json!({ "type": "integer", "format": "int64" }),
116        "float32" => json!({ "type": "number", "format": "float" }),
117        "float64" => json!({ "type": "number", "format": "double" }),
118        "string" => json!({ "type": "string" }),
119        "bool" => json!({ "type": "boolean" }),
120        "bytes" => json!({ "type": "string", "format": "byte" }),
121        "uuid" => json!({ "type": "string", "format": "uuid" }),
122        "date" => json!({ "type": "string", "format": "date" }),
123        "datetime" | "datetime_tz" => json!({ "type": "string", "format": "date-time" }),
124        "time" | "time_tz" => json!({ "type": "string", "format": "time" }),
125        "interval" => json!({ "type": "string", "format": "duration" }),
126        "json" => json!({}),
127        "inet" => json!({
128            "type": "string",
129            "oneOf": [{ "format": "ipv4" }, { "format": "ipv6" }]
130        }),
131        "decimal" => match opts.decimal_mode {
132            DecimalMode::StringPattern => json!({
133                "type": "string",
134                "pattern": "^-?\\d+(\\.\\d+)?$"
135            }),
136            DecimalMode::Number => json!({ "type": "number" }),
137        },
138        other => {
139            if opts.strict {
140                return Err(NeutralTypeError::Unknown(other.to_string()));
141            }
142            json!({})
143        }
144    };
145    Ok(schema)
146}
147
148/// Wrap [`neutral_to_json_schema`]'s output for nullability when the column or
149/// parameter is nullable.
150pub fn json_schema_for(
151    neutral: &str,
152    nullable: bool,
153    enums: &[EnumInfo],
154    catalog: &Catalog,
155    opts: &BuildOptions,
156) -> Result<Value, NeutralTypeError> {
157    let base = neutral_to_json_schema(neutral, enums, catalog, opts)?;
158    if nullable {
159        Ok(json!({ "oneOf": [base, { "type": "null" }] }))
160    } else {
161        Ok(base)
162    }
163}
164
165/// Re-derive a neutral type for a SQL type string by consulting the catalog.
166/// Composite field types arrive as raw SQL strings, so we lean on scythe's own
167/// resolver via its public API.
168fn scythe_core_neutral_for(sql_type: &str, catalog: &Catalog) -> String {
169    // The exact mapping function in scythe-core is `pub(super)` and not
170    // exposed. We approximate by deferring to the catalog's enum/composite
171    // lookups plus a small static table that covers the common cases. The
172    // analyzer has already run, so any neutral type we miss here only affects
173    // recursively-defined composite fields.
174    let lower = sql_type.to_lowercase();
175    let stripped = lower.split('(').next().unwrap_or(&lower).trim().to_string();
176    match stripped.as_str() {
177        "integer" | "int" | "int4" | "serial" => "int32".into(),
178        "smallint" | "int2" | "smallserial" => "int16".into(),
179        "bigint" | "int8" | "bigserial" => "int64".into(),
180        "real" | "float4" => "float32".into(),
181        "double precision" | "float8" | "double" | "float" => "float64".into(),
182        "numeric" | "decimal" => "decimal".into(),
183        "text" | "varchar" | "char" | "character" | "character varying" => "string".into(),
184        "boolean" | "bool" => "bool".into(),
185        "bytea" | "blob" | "binary" | "varbinary" => "bytes".into(),
186        "uuid" => "uuid".into(),
187        "date" => "date".into(),
188        "timestamp" | "timestamp without time zone" => "datetime".into(),
189        "timestamp with time zone" | "timestamptz" => "datetime_tz".into(),
190        "time" => "time".into(),
191        "interval" => "interval".into(),
192        "json" | "jsonb" => "json".into(),
193        "inet" | "cidr" => "inet".into(),
194        other => {
195            if catalog.get_enum(other).is_some() {
196                format!("enum::{other}")
197            } else if catalog.get_composite(other).is_some() {
198                format!("composite::{other}")
199            } else {
200                other.to_string()
201            }
202        }
203    }
204}
205
206fn strip_wrapper<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
207    let rest = s.strip_prefix(prefix)?;
208    rest.strip_suffix('>')
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use scythe_core::analyzer::EnumInfo;
215
216    fn opts() -> BuildOptions {
217        BuildOptions::default()
218    }
219
220    fn empty_catalog() -> Catalog {
221        Catalog::from_ddl(&[]).unwrap()
222    }
223
224    fn s(neutral: &str) -> Value {
225        neutral_to_json_schema(neutral, &[], &empty_catalog(), &opts()).unwrap()
226    }
227
228    #[test]
229    fn int16_carries_bounds() {
230        assert_eq!(
231            s("int16"),
232            json!({ "type": "integer", "minimum": -32_768, "maximum": 32_767 })
233        );
234    }
235
236    #[test]
237    fn int32_has_format() {
238        assert_eq!(s("int32"), json!({ "type": "integer", "format": "int32" }));
239    }
240
241    #[test]
242    fn int64_has_format() {
243        assert_eq!(s("int64"), json!({ "type": "integer", "format": "int64" }));
244    }
245
246    #[test]
247    fn float32_and_float64_have_formats() {
248        assert_eq!(s("float32"), json!({ "type": "number", "format": "float" }));
249        assert_eq!(s("float64"), json!({ "type": "number", "format": "double" }));
250    }
251
252    #[test]
253    fn string_and_bool() {
254        assert_eq!(s("string"), json!({ "type": "string" }));
255        assert_eq!(s("bool"), json!({ "type": "boolean" }));
256    }
257
258    #[test]
259    fn bytes_is_byte_format() {
260        assert_eq!(s("bytes"), json!({ "type": "string", "format": "byte" }));
261    }
262
263    #[test]
264    fn uuid_format() {
265        assert_eq!(s("uuid"), json!({ "type": "string", "format": "uuid" }));
266    }
267
268    #[test]
269    fn date_and_datetime_formats() {
270        assert_eq!(s("date"), json!({ "type": "string", "format": "date" }));
271        assert_eq!(s("datetime"), json!({ "type": "string", "format": "date-time" }));
272        assert_eq!(s("datetime_tz"), json!({ "type": "string", "format": "date-time" }));
273    }
274
275    #[test]
276    fn time_and_time_tz_formats() {
277        assert_eq!(s("time"), json!({ "type": "string", "format": "time" }));
278        assert_eq!(s("time_tz"), json!({ "type": "string", "format": "time" }));
279    }
280
281    #[test]
282    fn interval_format() {
283        assert_eq!(s("interval"), json!({ "type": "string", "format": "duration" }));
284    }
285
286    #[test]
287    fn json_is_any() {
288        assert_eq!(s("json"), json!({}));
289    }
290
291    #[test]
292    fn inet_one_of_v4_v6() {
293        assert_eq!(
294            s("inet"),
295            json!({
296                "type": "string",
297                "oneOf": [{ "format": "ipv4" }, { "format": "ipv6" }]
298            })
299        );
300    }
301
302    #[test]
303    fn decimal_string_pattern_by_default() {
304        assert_eq!(
305            s("decimal"),
306            json!({ "type": "string", "pattern": "^-?\\d+(\\.\\d+)?$" })
307        );
308    }
309
310    #[test]
311    fn decimal_number_mode() {
312        let o = BuildOptions {
313            decimal_mode: DecimalMode::Number,
314            ..BuildOptions::default()
315        };
316        assert_eq!(
317            neutral_to_json_schema("decimal", &[], &empty_catalog(), &o).unwrap(),
318            json!({ "type": "number" })
319        );
320    }
321
322    #[test]
323    fn array_of_strings_recurses() {
324        assert_eq!(
325            s("array<string>"),
326            json!({ "type": "array", "items": { "type": "string" } })
327        );
328    }
329
330    #[test]
331    fn array_of_int32_recurses() {
332        assert_eq!(
333            s("array<int32>"),
334            json!({ "type": "array", "items": { "type": "integer", "format": "int32" } })
335        );
336    }
337
338    #[test]
339    fn nested_array_recurses() {
340        assert_eq!(
341            s("array<array<string>>"),
342            json!({
343                "type": "array",
344                "items": { "type": "array", "items": { "type": "string" } }
345            })
346        );
347    }
348
349    #[test]
350    fn range_emits_object_with_bounds() {
351        let v = s("range<int32>");
352        assert_eq!(v["type"], "object");
353        assert!(v["properties"]["lower"].is_object());
354        assert!(v["properties"]["upper"].is_object());
355        assert_eq!(v["properties"]["lower_inclusive"], json!({ "type": "boolean" }));
356    }
357
358    #[test]
359    fn enum_resolves_values_from_enum_info() {
360        let enums = vec![EnumInfo {
361            sql_name: "mood".to_string(),
362            values: vec!["sad".into(), "ok".into(), "happy".into()],
363        }];
364        let v = neutral_to_json_schema("enum::mood", &enums, &empty_catalog(), &opts()).unwrap();
365        assert_eq!(v["type"], "string");
366        assert_eq!(v["enum"], json!(["sad", "ok", "happy"]));
367    }
368
369    #[test]
370    fn unknown_enum_emits_empty_enum_list() {
371        let v = s("enum::missing");
372        assert_eq!(v, json!({ "type": "string", "enum": [] }));
373    }
374
375    #[test]
376    fn composite_emits_object_from_catalog() {
377        let catalog = Catalog::from_ddl(&["CREATE TYPE addr AS (street TEXT, zip INTEGER);"]).unwrap();
378        let v = neutral_to_json_schema("composite::addr", &[], &catalog, &opts()).unwrap();
379        assert_eq!(v["type"], "object");
380        assert_eq!(v["properties"]["street"]["type"], "string");
381        assert_eq!(v["properties"]["zip"]["type"], "integer");
382    }
383
384    #[test]
385    fn json_typed_emits_any() {
386        assert_eq!(s("json_typed<MyType>"), json!({}));
387    }
388
389    #[test]
390    fn unknown_type_falls_back_to_any_in_lenient_mode() {
391        assert_eq!(s("mysterious"), json!({}));
392    }
393
394    #[test]
395    fn unknown_type_errors_in_strict_mode() {
396        let o = BuildOptions {
397            strict: true,
398            ..BuildOptions::default()
399        };
400        let err = neutral_to_json_schema("mysterious", &[], &empty_catalog(), &o).unwrap_err();
401        assert!(matches!(err, NeutralTypeError::Unknown(_)));
402    }
403
404    #[test]
405    fn nullable_wraps_in_oneof_null() {
406        let v = json_schema_for("string", true, &[], &empty_catalog(), &opts()).unwrap();
407        assert_eq!(
408            v,
409            json!({
410                "oneOf": [{ "type": "string" }, { "type": "null" }]
411            })
412        );
413    }
414
415    #[test]
416    fn nonnullable_returns_bare_schema() {
417        let v = json_schema_for("string", false, &[], &empty_catalog(), &opts()).unwrap();
418        assert_eq!(v, json!({ "type": "string" }));
419    }
420}