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!({}));
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
138pub 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
155fn 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}