1use scythe_core::analyzer::EnumInfo;
16use scythe_core::catalog::Catalog;
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19use thiserror::Error;
20
21#[derive(Debug, Clone)]
23pub struct BuildOptions {
24 pub decimal_mode: DecimalMode,
28 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 StringPattern,
47 Number,
49}
50
51#[derive(Debug, Error, PartialEq, Eq)]
52pub enum NeutralTypeError {
53 #[error("unknown neutral type '{0}'")]
54 Unknown(String),
55}
56
57pub 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!({}));
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
140pub 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
157fn scythe_core_neutral_for(sql_type: &str, catalog: &Catalog) -> String {
161 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}