Skip to main content

spikard_codegen/sql/
neutral_to_json_schema.rs

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