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)]
9use scythe_core::analyzer::EnumInfo;
24use scythe_core::catalog::Catalog;
25use serde::{Deserialize, Serialize};
26use serde_json::{Map, Value, json};
27use thiserror::Error;
28
29#[derive(Debug, Clone)]
31pub struct BuildOptions {
32 pub decimal_mode: DecimalMode,
36 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 StringPattern,
55 Number,
57}
58
59#[derive(Debug, Error, PartialEq, Eq)]
60pub enum NeutralTypeError {
61 #[error("unknown neutral type '{0}'")]
62 Unknown(String),
63}
64
65pub 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 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
148pub 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
165fn scythe_core_neutral_for(sql_type: &str, catalog: &Catalog) -> String {
169 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}