1use std::collections::BTreeMap;
4
5use scythe_core::analyzer::AnalyzedQuery;
6use scythe_core::catalog::Catalog;
7use scythe_core::parser::QueryCommand;
8use serde_json::{Map, Value, json};
9use thiserror::Error;
10
11use super::annotations::{
12 AnnotationParseError, HttpAnnotations, HttpMethod, HttpParamBinding, default_status_for, parse_http_annotations,
13};
14use super::neutral_to_json_schema::{BuildOptions, NeutralTypeError, json_schema_for};
15
16#[derive(Debug, Error)]
17pub enum RouteBuildError {
18 #[error("annotation error: {0}")]
19 Annotation(#[from] AnnotationParseError),
20
21 #[error("neutral type error: {0}")]
22 NeutralType(#[from] NeutralTypeError),
23}
24
25#[derive(Debug, Clone)]
30pub struct SqlRoute {
31 pub metadata: Value,
35 pub http: HttpAnnotations,
38 pub param_locations: BTreeMap<String, HttpParamBinding>,
41 pub default_status: u16,
44 pub body_bundle_name: String,
46 pub operation_id: String,
48 pub handler_name: String,
50}
51
52pub fn route_from_query(
56 query: &AnalyzedQuery,
57 catalog: &Catalog,
58 opts: &BuildOptions,
59) -> Result<Option<SqlRoute>, RouteBuildError> {
60 let Some(http) = parse_http_annotations(&query.custom)? else {
61 return Ok(None);
62 };
63 let default_status = default_status_for(&query.command, http.method)?;
64
65 let param_locations = bin_param_locations(query, &http);
66 let body_bundle_name = http.request_body_name.clone().unwrap_or_else(|| "payload".to_string());
67
68 let parameter_schema = build_parameter_schema(query, ¶m_locations, catalog, opts)?;
69 let request_schema = build_request_schema(query, ¶m_locations, &body_bundle_name, catalog, opts)?;
70 let response_schema = build_response_schema(query, catalog, opts)?;
71
72 let handler_name = format!("handle_{}", to_snake_case(&query.name));
73 let operation_id = query.name.clone();
74
75 let body_param_name = single_body_param(query, ¶m_locations).map(str::to_string);
76 let expects_json_body = matches!(http.method, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
77 && param_locations.values().any(|v| *v == HttpParamBinding::Body);
78
79 let mut metadata = Map::new();
80 metadata.insert("method".into(), json!(http.method.as_str()));
81 metadata.insert("path".into(), json!(&http.path));
82 metadata.insert("handler_name".into(), json!(&handler_name));
83 metadata.insert("request_schema".into(), request_schema);
84 metadata.insert("response_schema".into(), response_schema);
85 metadata.insert("parameter_schema".into(), parameter_schema);
86 metadata.insert("is_async".into(), json!(true));
87 metadata.insert("expects_json_body".into(), json!(expects_json_body));
88 if let Some(body_name) = body_param_name {
89 metadata.insert("body_param_name".into(), json!(body_name));
90 }
91
92 Ok(Some(SqlRoute {
93 metadata: Value::Object(metadata),
94 http,
95 param_locations,
96 default_status,
97 body_bundle_name,
98 operation_id,
99 handler_name,
100 }))
101}
102
103pub fn bin_param_locations(query: &AnalyzedQuery, http: &HttpAnnotations) -> BTreeMap<String, HttpParamBinding> {
110 let path_segments: Vec<&str> = extract_path_params(&http.path);
111 let mut bindings = BTreeMap::new();
112 for p in &query.params {
113 if let Some(explicit) = http.param_bindings.get(&p.name) {
114 bindings.insert(p.name.clone(), *explicit);
115 continue;
116 }
117 if path_segments.iter().any(|s| *s == p.name) {
118 bindings.insert(p.name.clone(), HttpParamBinding::Path);
119 continue;
120 }
121 let inferred = match http.method {
122 HttpMethod::Get | HttpMethod::Delete | HttpMethod::Head | HttpMethod::Options => HttpParamBinding::Query,
123 HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => HttpParamBinding::Body,
124 };
125 bindings.insert(p.name.clone(), inferred);
126 }
127 bindings
128}
129
130fn extract_path_params(path: &str) -> Vec<&str> {
131 let mut out = Vec::new();
132 let bytes = path.as_bytes();
133 let mut i = 0;
134 while i < bytes.len() {
135 if bytes[i] == b'{' {
136 let start = i + 1;
137 while i < bytes.len() && bytes[i] != b'}' {
138 i += 1;
139 }
140 if i < bytes.len() && bytes[i] == b'}' {
141 out.push(&path[start..i]);
142 }
143 }
144 i += 1;
145 }
146 out
147}
148
149fn single_body_param<'a>(query: &'a AnalyzedQuery, locations: &BTreeMap<String, HttpParamBinding>) -> Option<&'a str> {
150 let body_names: Vec<&str> = query
151 .params
152 .iter()
153 .filter(|p| locations.get(&p.name) == Some(&HttpParamBinding::Body))
154 .map(|p| p.name.as_str())
155 .collect();
156 if body_names.len() == 1 {
157 Some(body_names[0])
158 } else {
159 None
160 }
161}
162
163fn build_parameter_schema(
164 query: &AnalyzedQuery,
165 locations: &BTreeMap<String, HttpParamBinding>,
166 catalog: &Catalog,
167 opts: &BuildOptions,
168) -> Result<Value, RouteBuildError> {
169 let mut props = Map::new();
170 let mut required: Vec<String> = Vec::new();
171 let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
172 for p in &query.params {
173 let loc = locations.get(&p.name).copied().unwrap_or(HttpParamBinding::Body);
174 if !matches!(loc, HttpParamBinding::Path | HttpParamBinding::Query) {
175 continue;
176 }
177 let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
178 props.insert(p.name.clone(), schema);
179 let is_required = matches!(loc, HttpParamBinding::Path) || !optional_set.contains(p.name.as_str());
180 if is_required {
181 required.push(p.name.clone());
182 }
183 }
184 if props.is_empty() {
185 return Ok(Value::Null);
186 }
187 let mut obj = Map::new();
188 obj.insert("type".into(), json!("object"));
189 obj.insert("properties".into(), Value::Object(props));
190 if !required.is_empty() {
191 obj.insert("required".into(), json!(required));
192 }
193 Ok(Value::Object(obj))
194}
195
196fn build_request_schema(
197 query: &AnalyzedQuery,
198 locations: &BTreeMap<String, HttpParamBinding>,
199 _bundle_name: &str,
200 catalog: &Catalog,
201 opts: &BuildOptions,
202) -> Result<Value, RouteBuildError> {
203 let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
204 let mut props = Map::new();
205 let mut required: Vec<String> = Vec::new();
206 for p in &query.params {
207 if locations.get(&p.name) != Some(&HttpParamBinding::Body) {
208 continue;
209 }
210 let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
211 props.insert(p.name.clone(), schema);
212 if !optional_set.contains(p.name.as_str()) {
213 required.push(p.name.clone());
214 }
215 }
216 if props.is_empty() {
217 return Ok(Value::Null);
218 }
219 let mut obj = Map::new();
220 obj.insert("type".into(), json!("object"));
221 obj.insert("properties".into(), Value::Object(props));
222 if !required.is_empty() {
223 obj.insert("required".into(), json!(required));
224 }
225 Ok(Value::Object(obj))
226}
227
228fn build_response_schema(
229 query: &AnalyzedQuery,
230 catalog: &Catalog,
231 opts: &BuildOptions,
232) -> Result<Value, RouteBuildError> {
233 match query.command {
234 QueryCommand::Exec | QueryCommand::ExecResult | QueryCommand::Batch => Ok(Value::Null),
235 QueryCommand::ExecRows => Ok(json!({
236 "type": "object",
237 "properties": { "rows": { "type": "integer", "format": "int64" } },
238 "required": ["rows"],
239 })),
240 QueryCommand::One | QueryCommand::Opt => {
241 let row = row_object_schema(query, catalog, opts)?;
242 if matches!(query.command, QueryCommand::Opt) {
243 Ok(json!({ "oneOf": [row, { "type": "null" }] }))
244 } else {
245 Ok(row)
246 }
247 }
248 QueryCommand::Many | QueryCommand::Grouped => {
249 let row = row_object_schema(query, catalog, opts)?;
250 Ok(json!({ "type": "array", "items": row }))
251 }
252 }
253}
254
255fn row_object_schema(query: &AnalyzedQuery, catalog: &Catalog, opts: &BuildOptions) -> Result<Value, RouteBuildError> {
256 let mut props = Map::new();
257 let mut required: Vec<String> = Vec::new();
258 for col in &query.columns {
259 let schema = json_schema_for(&col.neutral_type, col.nullable, &query.enums, catalog, opts)?;
260 props.insert(col.name.clone(), schema);
261 required.push(col.name.clone());
262 }
263 let mut obj = Map::new();
264 obj.insert("type".into(), json!("object"));
265 obj.insert("properties".into(), Value::Object(props));
266 if !required.is_empty() {
267 obj.insert("required".into(), json!(required));
268 }
269 Ok(Value::Object(obj))
270}
271
272fn to_snake_case(s: &str) -> String {
273 let mut out = String::with_capacity(s.len() + 4);
274 let mut prev_lower = false;
275 for c in s.chars() {
276 if c.is_ascii_uppercase() {
277 if prev_lower {
278 out.push('_');
279 }
280 out.push(c.to_ascii_lowercase());
281 prev_lower = false;
282 } else {
283 out.push(c);
284 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
285 }
286 }
287 out
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
294 use scythe_core::parser::{CustomAnnotation, QueryCommand};
295
296 fn empty_catalog() -> Catalog {
297 Catalog::from_ddl(&[]).unwrap()
298 }
299
300 fn get_user_query() -> AnalyzedQuery {
301 AnalyzedQuery::build(|q| {
302 q.name = "GetUser".to_string();
303 q.command = QueryCommand::One;
304 q.sql = "SELECT id, email, name FROM users WHERE id = $1".to_string();
305 q.columns = vec![
306 AnalyzedColumn {
307 name: "id".to_string(),
308 neutral_type: "int64".to_string(),
309 nullable: false,
310 ..Default::default()
311 },
312 AnalyzedColumn {
313 name: "email".to_string(),
314 neutral_type: "string".to_string(),
315 nullable: false,
316 ..Default::default()
317 },
318 AnalyzedColumn {
319 name: "name".to_string(),
320 neutral_type: "string".to_string(),
321 nullable: true,
322 ..Default::default()
323 },
324 ];
325 q.params = vec![AnalyzedParam {
326 name: "id".to_string(),
327 neutral_type: "int64".to_string(),
328 nullable: false,
329 position: 1,
330 ..Default::default()
331 }];
332 q.deprecated = None;
333 q.source_table = Some("users".to_string());
334 q.composites = vec![];
335 q.enums = vec![];
336 q.optional_params = vec![];
337 q.group_by = None;
338 q.custom = vec![
339 CustomAnnotation {
340 name: "http".into(),
341 value: "GET /users/{id}".into(),
342 line: 3,
343 suggested_keyword: None,
344 },
345 CustomAnnotation {
346 name: "http_auth".into(),
347 value: "bearer:jwt".into(),
348 line: 4,
349 suggested_keyword: None,
350 },
351 CustomAnnotation {
352 name: "http_status".into(),
353 value: "200,404".into(),
354 line: 5,
355 suggested_keyword: None,
356 },
357 ];
358 })
359 }
360
361 fn create_user_query() -> AnalyzedQuery {
362 AnalyzedQuery::build(|q| {
363 q.name = "CreateUser".to_string();
364 q.command = QueryCommand::ExecRows;
365 q.sql = "INSERT INTO users (email, name) VALUES ($1, $2)".to_string();
366 q.columns = vec![];
367 q.params = vec![
368 AnalyzedParam {
369 name: "email".to_string(),
370 neutral_type: "string".to_string(),
371 nullable: false,
372 position: 1,
373 ..Default::default()
374 },
375 AnalyzedParam {
376 name: "name".to_string(),
377 neutral_type: "string".to_string(),
378 nullable: true,
379 position: 2,
380 ..Default::default()
381 },
382 ];
383 q.deprecated = None;
384 q.source_table = None;
385 q.composites = vec![];
386 q.enums = vec![];
387 q.optional_params = vec![];
388 q.group_by = None;
389 q.custom = vec![
390 CustomAnnotation {
391 name: "http".into(),
392 value: "POST /users".into(),
393 line: 1,
394 suggested_keyword: None,
395 },
396 CustomAnnotation {
397 name: "http_status".into(),
398 value: "201".into(),
399 line: 2,
400 suggested_keyword: None,
401 },
402 ];
403 })
404 }
405
406 fn list_users_query() -> AnalyzedQuery {
407 AnalyzedQuery::build(|q| {
408 q.name = "ListUsers".to_string();
409 q.command = QueryCommand::Many;
410 q.sql = "SELECT id, email FROM users LIMIT $1 OFFSET $2".to_string();
411 q.columns = vec![
412 AnalyzedColumn {
413 name: "id".to_string(),
414 neutral_type: "int64".to_string(),
415 nullable: false,
416 ..Default::default()
417 },
418 AnalyzedColumn {
419 name: "email".to_string(),
420 neutral_type: "string".to_string(),
421 nullable: false,
422 ..Default::default()
423 },
424 ];
425 q.params = vec![
426 AnalyzedParam {
427 name: "limit".to_string(),
428 neutral_type: "int32".to_string(),
429 nullable: true,
430 position: 1,
431 ..Default::default()
432 },
433 AnalyzedParam {
434 name: "offset".to_string(),
435 neutral_type: "int32".to_string(),
436 nullable: true,
437 position: 2,
438 ..Default::default()
439 },
440 ];
441 q.deprecated = None;
442 q.source_table = Some("users".to_string());
443 q.composites = vec![];
444 q.enums = vec![];
445 q.optional_params = vec!["limit".to_string(), "offset".to_string()];
446 q.group_by = None;
447 q.custom = vec![CustomAnnotation {
448 name: "http".into(),
449 value: "GET /users".into(),
450 line: 1,
451 suggested_keyword: None,
452 }];
453 })
454 }
455
456 #[test]
457 fn route_from_get_query_uses_get_method() {
458 let q = get_user_query();
459 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
460 .unwrap()
461 .unwrap();
462 assert_eq!(route.metadata["method"], "GET");
463 assert_eq!(route.metadata["path"], "/users/{id}");
464 assert_eq!(route.metadata["handler_name"], "handle_get_user");
465 assert_eq!(route.operation_id, "GetUser");
466 }
467
468 #[test]
469 fn handler_name_distinct_from_scythe_fn() {
470 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
471 .unwrap()
472 .unwrap();
473 assert_eq!(route.handler_name, "handle_get_user");
474 assert_ne!(route.handler_name, "get_user");
475 }
476
477 #[test]
478 fn path_param_bound_to_path() {
479 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
480 .unwrap()
481 .unwrap();
482 assert_eq!(route.param_locations.get("id"), Some(&HttpParamBinding::Path));
483 }
484
485 #[test]
486 fn parameter_schema_carries_path_param_as_required() {
487 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
488 .unwrap()
489 .unwrap();
490 let params = &route.metadata["parameter_schema"];
491 assert_eq!(params["type"], "object");
492 assert!(params["properties"]["id"].is_object());
493 assert_eq!(params["required"], json!(["id"]));
494 }
495
496 #[test]
497 fn list_query_params_become_query_and_optional() {
498 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
499 .unwrap()
500 .unwrap();
501 assert_eq!(route.param_locations.get("limit"), Some(&HttpParamBinding::Query));
502 let params = &route.metadata["parameter_schema"];
503 assert!(params["properties"]["limit"].is_object());
504 assert!(params["required"].is_null() || !params["required"].as_array().unwrap().iter().any(|v| v == "limit"));
505 }
506
507 #[test]
508 fn post_query_params_become_body() {
509 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
510 .unwrap()
511 .unwrap();
512 assert_eq!(route.param_locations.get("email"), Some(&HttpParamBinding::Body));
513 assert_eq!(route.metadata["method"], "POST");
514 let req = &route.metadata["request_schema"];
515 assert_eq!(req["type"], "object");
516 assert!(req["properties"]["email"].is_object());
517 assert!(req["properties"]["name"].is_object());
518 assert_eq!(req["required"], json!(["email", "name"]));
519 assert_eq!(route.metadata["expects_json_body"], true);
520 }
521
522 #[test]
523 fn one_query_response_is_object_with_required_columns() {
524 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
525 .unwrap()
526 .unwrap();
527 let resp = &route.metadata["response_schema"];
528 assert_eq!(resp["type"], "object");
529 assert_eq!(resp["required"], json!(["id", "email", "name"]));
530 }
531
532 #[test]
533 fn many_query_response_is_array() {
534 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
535 .unwrap()
536 .unwrap();
537 let resp = &route.metadata["response_schema"];
538 assert_eq!(resp["type"], "array");
539 assert_eq!(resp["items"]["type"], "object");
540 }
541
542 #[test]
543 fn exec_rows_response_is_rows_object() {
544 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
545 .unwrap()
546 .unwrap();
547 let resp = &route.metadata["response_schema"];
548 assert_eq!(resp["type"], "object");
549 assert_eq!(resp["properties"]["rows"]["type"], "integer");
550 assert_eq!(resp["required"], json!(["rows"]));
551 }
552
553 #[test]
554 fn nullable_column_emits_oneof_null() {
555 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
556 .unwrap()
557 .unwrap();
558 let resp = &route.metadata["response_schema"];
559 let name_schema = &resp["properties"]["name"];
560 assert!(name_schema["oneOf"].is_array());
561 }
562
563 #[test]
564 fn no_http_directive_returns_none() {
565 let mut q = get_user_query();
566 q.custom.clear();
567 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap();
568 assert!(route.is_none());
569 }
570
571 #[test]
572 fn batch_command_with_http_errors() {
573 let mut q = get_user_query();
574 q.command = QueryCommand::Batch;
575 let err = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap_err();
576 assert!(matches!(
577 err,
578 RouteBuildError::Annotation(AnnotationParseError::IncompatibleCommand { .. })
579 ));
580 }
581
582 #[test]
583 fn snake_case_handles_pascal_case() {
584 assert_eq!(to_snake_case("GetUser"), "get_user");
585 assert_eq!(to_snake_case("ListActiveUsers"), "list_active_users");
586 assert_eq!(to_snake_case("CreateUser"), "create_user");
587 }
588
589 #[test]
590 fn default_status_matches_command() {
591 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
592 .unwrap()
593 .unwrap();
594 assert_eq!(route.default_status, 200);
595 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
596 .unwrap()
597 .unwrap();
598 assert_eq!(route.default_status, 200);
599 }
600
601 #[test]
602 fn single_body_param_recorded_in_metadata() {
603 let mut q = create_user_query();
604 q.params.truncate(1);
605 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
606 .unwrap()
607 .unwrap();
608 assert_eq!(route.metadata["body_param_name"], "email");
609 }
610}