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        return Ok(json!({}));
100    }
101
102    let schema = match neutral {
103        "int16" => json!({ "type": "integer", "minimum": -32_768, "maximum": 32_767 }),
104        "int32" => json!({ "type": "integer", "format": "int32" }),
105        "int64" => json!({ "type": "integer", "format": "int64" }),
106        "float32" => json!({ "type": "number", "format": "float" }),
107        "float64" => json!({ "type": "number", "format": "double" }),
108        "string" => json!({ "type": "string" }),
109        "bool" => json!({ "type": "boolean" }),
110        "bytes" => json!({ "type": "string", "format": "byte" }),
111        "uuid" => json!({ "type": "string", "format": "uuid" }),
112        "date" => json!({ "type": "string", "format": "date" }),
113        "datetime" | "datetime_tz" => json!({ "type": "string", "format": "date-time" }),
114        "time" | "time_tz" => json!({ "type": "string", "format": "time" }),
115        "interval" => json!({ "type": "string", "format": "duration" }),
116        "json" => json!({}),
117        "inet" => json!({
118            "type": "string",
119            "oneOf": [{ "format": "ipv4" }, { "format": "ipv6" }]
120        }),
121        "decimal" => match opts.decimal_mode {
122            DecimalMode::StringPattern => json!({
123                "type": "string",
124                "pattern": "^-?\\d+(\\.\\d+)?$"
125            }),
126            DecimalMode::Number => json!({ "type": "number" }),
127        },
128        other => {
129            if opts.strict {
130                return Err(NeutralTypeError::Unknown(other.to_string()));
131            }
132            json!({})
133        }
134    };
135    Ok(schema)
136}
137
138/// Wrap [`neutral_to_json_schema`]'s output for nullability when the column or
139/// parameter is nullable.
140pub fn json_schema_for(
141    neutral: &str,
142    nullable: bool,
143    enums: &[EnumInfo],
144    catalog: &Catalog,
145    opts: &BuildOptions,
146) -> Result<Value, NeutralTypeError> {
147    let base = neutral_to_json_schema(neutral, enums, catalog, opts)?;
148    if nullable {
149        Ok(json!({ "oneOf": [base, { "type": "null" }] }))
150    } else {
151        Ok(base)
152    }
153}
154
155/// Re-derive a neutral type for a SQL type string by consulting the catalog.
156/// Composite field types arrive as raw SQL strings, so we lean on scythe's own
157/// resolver via its public API.
158fn scythe_core_neutral_for(sql_type: &str, catalog: &Catalog) -> String {
159    let lower = sql_type.to_lowercase();
160    let stripped = lower.split('(').next().unwrap_or(&lower).trim().to_string();
161    match stripped.as_str() {
162        "integer" | "int" | "int4" | "serial" => "int32".into(),
163        "smallint" | "int2" | "smallserial" => "int16".into(),
164        "bigint" | "int8" | "bigserial" => "int64".into(),
165        "real" | "float4" => "float32".into(),
166        "double precision" | "float8" | "double" | "float" => "float64".into(),
167        "numeric" | "decimal" => "decimal".into(),
168        "text" | "varchar" | "char" | "character" | "character varying" => "string".into(),
169        "boolean" | "bool" => "bool".into(),
170        "bytea" | "blob" | "binary" | "varbinary" => "bytes".into(),
171        "uuid" => "uuid".into(),
172        "date" => "date".into(),
173        "timestamp" | "timestamp without time zone" => "datetime".into(),
174        "timestamp with time zone" | "timestamptz" => "datetime_tz".into(),
175        "time" => "time".into(),
176        "interval" => "interval".into(),
177        "json" | "jsonb" => "json".into(),
178        "inet" | "cidr" => "inet".into(),
179        other => {
180            if catalog.get_enum(other).is_some() {
181                format!("enum::{other}")
182            } else if catalog.get_composite(other).is_some() {
183                format!("composite::{other}")
184            } else {
185                other.to_string()
186            }
187        }
188    }
189}
190
191fn strip_wrapper<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
192    let rest = s.strip_prefix(prefix)?;
193    rest.strip_suffix('>')
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use scythe_core::analyzer::EnumInfo;
200
201    fn opts() -> BuildOptions {
202        BuildOptions::default()
203    }
204
205    fn empty_catalog() -> Catalog {
206        Catalog::from_ddl(&[]).unwrap()
207    }
208
209    fn s(neutral: &str) -> Value {
210        neutral_to_json_schema(neutral, &[], &empty_catalog(), &opts()).unwrap()
211    }
212
213    #[test]
214    fn int16_carries_bounds() {
215        assert_eq!(
216            s("int16"),
217            json!({ "type": "integer", "minimum": -32_768, "maximum": 32_767 })
218        );
219    }
220
221    #[test]
222    fn int32_has_format() {
223        assert_eq!(s("int32"), json!({ "type": "integer", "format": "int32" }));
224    }
225
226    #[test]
227    fn int64_has_format() {
228        assert_eq!(s("int64"), json!({ "type": "integer", "format": "int64" }));
229    }
230
231    #[test]
232    fn float32_and_float64_have_formats() {
233        assert_eq!(s("float32"), json!({ "type": "number", "format": "float" }));
234        assert_eq!(s("float64"), json!({ "type": "number", "format": "double" }));
235    }
236
237    #[test]
238    fn string_and_bool() {
239        assert_eq!(s("string"), json!({ "type": "string" }));
240        assert_eq!(s("bool"), json!({ "type": "boolean" }));
241    }
242
243    #[test]
244    fn bytes_is_byte_format() {
245        assert_eq!(s("bytes"), json!({ "type": "string", "format": "byte" }));
246    }
247
248    #[test]
249    fn uuid_format() {
250        assert_eq!(s("uuid"), json!({ "type": "string", "format": "uuid" }));
251    }
252
253    #[test]
254    fn date_and_datetime_formats() {
255        assert_eq!(s("date"), json!({ "type": "string", "format": "date" }));
256        assert_eq!(s("datetime"), json!({ "type": "string", "format": "date-time" }));
257        assert_eq!(s("datetime_tz"), json!({ "type": "string", "format": "date-time" }));
258    }
259
260    #[test]
261    fn time_and_time_tz_formats() {
262        assert_eq!(s("time"), json!({ "type": "string", "format": "time" }));
263        assert_eq!(s("time_tz"), json!({ "type": "string", "format": "time" }));
264    }
265
266    #[test]
267    fn interval_format() {
268        assert_eq!(s("interval"), json!({ "type": "string", "format": "duration" }));
269    }
270
271    #[test]
272    fn json_is_any() {
273        assert_eq!(s("json"), json!({}));
274    }
275
276    #[test]
277    fn inet_one_of_v4_v6() {
278        assert_eq!(
279            s("inet"),
280            json!({
281                "type": "string",
282                "oneOf": [{ "format": "ipv4" }, { "format": "ipv6" }]
283            })
284        );
285    }
286
287    #[test]
288    fn decimal_string_pattern_by_default() {
289        assert_eq!(
290            s("decimal"),
291            json!({ "type": "string", "pattern": "^-?\\d+(\\.\\d+)?$" })
292        );
293    }
294
295    #[test]
296    fn decimal_number_mode() {
297        let o = BuildOptions {
298            decimal_mode: DecimalMode::Number,
299            ..BuildOptions::default()
300        };
301        assert_eq!(
302            neutral_to_json_schema("decimal", &[], &empty_catalog(), &o).unwrap(),
303            json!({ "type": "number" })
304        );
305    }
306
307    #[test]
308    fn array_of_strings_recurses() {
309        assert_eq!(
310            s("array<string>"),
311            json!({ "type": "array", "items": { "type": "string" } })
312        );
313    }
314
315    #[test]
316    fn array_of_int32_recurses() {
317        assert_eq!(
318            s("array<int32>"),
319            json!({ "type": "array", "items": { "type": "integer", "format": "int32" } })
320        );
321    }
322
323    #[test]
324    fn nested_array_recurses() {
325        assert_eq!(
326            s("array<array<string>>"),
327            json!({
328                "type": "array",
329                "items": { "type": "array", "items": { "type": "string" } }
330            })
331        );
332    }
333
334    #[test]
335    fn range_emits_object_with_bounds() {
336        let v = s("range<int32>");
337        assert_eq!(v["type"], "object");
338        assert!(v["properties"]["lower"].is_object());
339        assert!(v["properties"]["upper"].is_object());
340        assert_eq!(v["properties"]["lower_inclusive"], json!({ "type": "boolean" }));
341    }
342
343    #[test]
344    fn enum_resolves_values_from_enum_info() {
345        let enums = vec![EnumInfo {
346            sql_name: "mood".to_string(),
347            values: vec!["sad".into(), "ok".into(), "happy".into()],
348        }];
349        let v = neutral_to_json_schema("enum::mood", &enums, &empty_catalog(), &opts()).unwrap();
350        assert_eq!(v["type"], "string");
351        assert_eq!(v["enum"], json!(["sad", "ok", "happy"]));
352    }
353
354    #[test]
355    fn unknown_enum_emits_empty_enum_list() {
356        let v = s("enum::missing");
357        assert_eq!(v, json!({ "type": "string", "enum": [] }));
358    }
359
360    #[test]
361    fn composite_emits_object_from_catalog() {
362        let catalog = Catalog::from_ddl(&["CREATE TYPE addr AS (street TEXT, zip INTEGER);"]).unwrap();
363        let v = neutral_to_json_schema("composite::addr", &[], &catalog, &opts()).unwrap();
364        assert_eq!(v["type"], "object");
365        assert_eq!(v["properties"]["street"]["type"], "string");
366        assert_eq!(v["properties"]["zip"]["type"], "integer");
367    }
368
369    #[test]
370    fn json_typed_emits_any() {
371        assert_eq!(s("json_typed<MyType>"), json!({}));
372    }
373
374    #[test]
375    fn unknown_type_falls_back_to_any_in_lenient_mode() {
376        assert_eq!(s("mysterious"), json!({}));
377    }
378
379    #[test]
380    fn unknown_type_errors_in_strict_mode() {
381        let o = BuildOptions {
382            strict: true,
383            ..BuildOptions::default()
384        };
385        let err = neutral_to_json_schema("mysterious", &[], &empty_catalog(), &o).unwrap_err();
386        assert!(matches!(err, NeutralTypeError::Unknown(_)));
387    }
388
389    #[test]
390    fn nullable_wraps_in_oneof_null() {
391        let v = json_schema_for("string", true, &[], &empty_catalog(), &opts()).unwrap();
392        assert_eq!(
393            v,
394            json!({
395                "oneOf": [{ "type": "string" }, { "type": "null" }]
396            })
397        );
398    }
399
400    #[test]
401    fn nonnullable_returns_bare_schema() {
402        let v = json_schema_for("string", false, &[], &empty_catalog(), &opts()).unwrap();
403        assert_eq!(v, json!({ "type": "string" }));
404    }
405}