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 => {
249 let row = row_object_schema(query, catalog, opts)?;
250 Ok(json!({ "type": "array", "items": row }))
251 }
252 QueryCommand::Grouped => {
253 let row = row_object_schema(query, catalog, opts)?;
256 Ok(json!({ "type": "array", "items": row }))
257 }
258 }
259}
260
261fn row_object_schema(query: &AnalyzedQuery, catalog: &Catalog, opts: &BuildOptions) -> Result<Value, RouteBuildError> {
262 let mut props = Map::new();
263 let mut required: Vec<String> = Vec::new();
264 for col in &query.columns {
265 let schema = json_schema_for(&col.neutral_type, col.nullable, &query.enums, catalog, opts)?;
266 props.insert(col.name.clone(), schema);
267 required.push(col.name.clone());
268 }
269 let mut obj = Map::new();
270 obj.insert("type".into(), json!("object"));
271 obj.insert("properties".into(), Value::Object(props));
272 if !required.is_empty() {
273 obj.insert("required".into(), json!(required));
274 }
275 Ok(Value::Object(obj))
276}
277
278fn to_snake_case(s: &str) -> String {
279 let mut out = String::with_capacity(s.len() + 4);
280 let mut prev_lower = false;
281 for c in s.chars() {
282 if c.is_ascii_uppercase() {
283 if prev_lower {
284 out.push('_');
285 }
286 out.push(c.to_ascii_lowercase());
287 prev_lower = false;
288 } else {
289 out.push(c);
290 prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
291 }
292 }
293 out
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
300 use scythe_core::parser::{CustomAnnotation, QueryCommand};
301
302 fn empty_catalog() -> Catalog {
303 Catalog::from_ddl(&[]).unwrap()
304 }
305
306 fn get_user_query() -> AnalyzedQuery {
307 AnalyzedQuery {
308 name: "GetUser".to_string(),
309 command: QueryCommand::One,
310 sql: "SELECT id, email, name FROM users WHERE id = $1".to_string(),
311 columns: vec![
312 AnalyzedColumn {
313 name: "id".to_string(),
314 neutral_type: "int64".to_string(),
315 nullable: false,
316 },
317 AnalyzedColumn {
318 name: "email".to_string(),
319 neutral_type: "string".to_string(),
320 nullable: false,
321 },
322 AnalyzedColumn {
323 name: "name".to_string(),
324 neutral_type: "string".to_string(),
325 nullable: true,
326 },
327 ],
328 params: vec![AnalyzedParam {
329 name: "id".to_string(),
330 neutral_type: "int64".to_string(),
331 nullable: false,
332 position: 1,
333 }],
334 deprecated: None,
335 source_table: Some("users".to_string()),
336 composites: vec![],
337 enums: vec![],
338 optional_params: vec![],
339 group_by: None,
340 custom: vec![
341 CustomAnnotation {
342 name: "http".into(),
343 value: "GET /users/{id}".into(),
344 line: 3,
345 },
346 CustomAnnotation {
347 name: "http_auth".into(),
348 value: "bearer:jwt".into(),
349 line: 4,
350 },
351 CustomAnnotation {
352 name: "http_status".into(),
353 value: "200,404".into(),
354 line: 5,
355 },
356 ],
357 }
358 }
359
360 fn create_user_query() -> AnalyzedQuery {
361 AnalyzedQuery {
362 name: "CreateUser".to_string(),
363 command: QueryCommand::ExecRows,
364 sql: "INSERT INTO users (email, name) VALUES ($1, $2)".to_string(),
365 columns: vec![],
366 params: vec![
367 AnalyzedParam {
368 name: "email".to_string(),
369 neutral_type: "string".to_string(),
370 nullable: false,
371 position: 1,
372 },
373 AnalyzedParam {
374 name: "name".to_string(),
375 neutral_type: "string".to_string(),
376 nullable: true,
377 position: 2,
378 },
379 ],
380 deprecated: None,
381 source_table: None,
382 composites: vec![],
383 enums: vec![],
384 optional_params: vec![],
385 group_by: None,
386 custom: vec![
387 CustomAnnotation {
388 name: "http".into(),
389 value: "POST /users".into(),
390 line: 1,
391 },
392 CustomAnnotation {
393 name: "http_status".into(),
394 value: "201".into(),
395 line: 2,
396 },
397 ],
398 }
399 }
400
401 fn list_users_query() -> AnalyzedQuery {
402 AnalyzedQuery {
403 name: "ListUsers".to_string(),
404 command: QueryCommand::Many,
405 sql: "SELECT id, email FROM users LIMIT $1 OFFSET $2".to_string(),
406 columns: vec![
407 AnalyzedColumn {
408 name: "id".to_string(),
409 neutral_type: "int64".to_string(),
410 nullable: false,
411 },
412 AnalyzedColumn {
413 name: "email".to_string(),
414 neutral_type: "string".to_string(),
415 nullable: false,
416 },
417 ],
418 params: vec![
419 AnalyzedParam {
420 name: "limit".to_string(),
421 neutral_type: "int32".to_string(),
422 nullable: true,
423 position: 1,
424 },
425 AnalyzedParam {
426 name: "offset".to_string(),
427 neutral_type: "int32".to_string(),
428 nullable: true,
429 position: 2,
430 },
431 ],
432 deprecated: None,
433 source_table: Some("users".to_string()),
434 composites: vec![],
435 enums: vec![],
436 optional_params: vec!["limit".to_string(), "offset".to_string()],
437 group_by: None,
438 custom: vec![CustomAnnotation {
439 name: "http".into(),
440 value: "GET /users".into(),
441 line: 1,
442 }],
443 }
444 }
445
446 #[test]
447 fn route_from_get_query_uses_get_method() {
448 let q = get_user_query();
449 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
450 .unwrap()
451 .unwrap();
452 assert_eq!(route.metadata["method"], "GET");
453 assert_eq!(route.metadata["path"], "/users/{id}");
454 assert_eq!(route.metadata["handler_name"], "handle_get_user");
455 assert_eq!(route.operation_id, "GetUser");
456 }
457
458 #[test]
459 fn handler_name_distinct_from_scythe_fn() {
460 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
463 .unwrap()
464 .unwrap();
465 assert_eq!(route.handler_name, "handle_get_user");
466 assert_ne!(route.handler_name, "get_user");
467 }
468
469 #[test]
470 fn path_param_bound_to_path() {
471 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
472 .unwrap()
473 .unwrap();
474 assert_eq!(route.param_locations.get("id"), Some(&HttpParamBinding::Path));
475 }
476
477 #[test]
478 fn parameter_schema_carries_path_param_as_required() {
479 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
480 .unwrap()
481 .unwrap();
482 let params = &route.metadata["parameter_schema"];
483 assert_eq!(params["type"], "object");
484 assert!(params["properties"]["id"].is_object());
485 assert_eq!(params["required"], json!(["id"]));
486 }
487
488 #[test]
489 fn list_query_params_become_query_and_optional() {
490 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
491 .unwrap()
492 .unwrap();
493 assert_eq!(route.param_locations.get("limit"), Some(&HttpParamBinding::Query));
494 let params = &route.metadata["parameter_schema"];
495 assert!(params["properties"]["limit"].is_object());
497 assert!(params["required"].is_null() || !params["required"].as_array().unwrap().iter().any(|v| v == "limit"));
498 }
499
500 #[test]
501 fn post_query_params_become_body() {
502 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
503 .unwrap()
504 .unwrap();
505 assert_eq!(route.param_locations.get("email"), Some(&HttpParamBinding::Body));
506 assert_eq!(route.metadata["method"], "POST");
507 let req = &route.metadata["request_schema"];
508 assert_eq!(req["type"], "object");
509 assert!(req["properties"]["email"].is_object());
510 assert!(req["properties"]["name"].is_object());
511 assert_eq!(req["required"], json!(["email", "name"]));
512 assert_eq!(route.metadata["expects_json_body"], true);
513 }
514
515 #[test]
516 fn one_query_response_is_object_with_required_columns() {
517 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
518 .unwrap()
519 .unwrap();
520 let resp = &route.metadata["response_schema"];
521 assert_eq!(resp["type"], "object");
522 assert_eq!(resp["required"], json!(["id", "email", "name"]));
523 }
524
525 #[test]
526 fn many_query_response_is_array() {
527 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
528 .unwrap()
529 .unwrap();
530 let resp = &route.metadata["response_schema"];
531 assert_eq!(resp["type"], "array");
532 assert_eq!(resp["items"]["type"], "object");
533 }
534
535 #[test]
536 fn exec_rows_response_is_rows_object() {
537 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
538 .unwrap()
539 .unwrap();
540 let resp = &route.metadata["response_schema"];
541 assert_eq!(resp["type"], "object");
542 assert_eq!(resp["properties"]["rows"]["type"], "integer");
543 assert_eq!(resp["required"], json!(["rows"]));
544 }
545
546 #[test]
547 fn nullable_column_emits_oneof_null() {
548 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
549 .unwrap()
550 .unwrap();
551 let resp = &route.metadata["response_schema"];
552 let name_schema = &resp["properties"]["name"];
553 assert!(name_schema["oneOf"].is_array());
554 }
555
556 #[test]
557 fn no_http_directive_returns_none() {
558 let mut q = get_user_query();
559 q.custom.clear();
560 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap();
561 assert!(route.is_none());
562 }
563
564 #[test]
565 fn batch_command_with_http_errors() {
566 let mut q = get_user_query();
567 q.command = QueryCommand::Batch;
568 let err = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap_err();
569 assert!(matches!(
570 err,
571 RouteBuildError::Annotation(AnnotationParseError::IncompatibleCommand { .. })
572 ));
573 }
574
575 #[test]
576 fn snake_case_handles_pascal_case() {
577 assert_eq!(to_snake_case("GetUser"), "get_user");
578 assert_eq!(to_snake_case("ListActiveUsers"), "list_active_users");
579 assert_eq!(to_snake_case("CreateUser"), "create_user");
580 }
581
582 #[test]
583 fn default_status_matches_command() {
584 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
585 .unwrap()
586 .unwrap();
587 assert_eq!(route.default_status, 200);
588 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
589 .unwrap()
590 .unwrap();
591 assert_eq!(route.default_status, 200); }
593
594 #[test]
595 fn single_body_param_recorded_in_metadata() {
596 let mut q = create_user_query();
597 q.params.truncate(1); let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
599 .unwrap()
600 .unwrap();
601 assert_eq!(route.metadata["body_param_name"], "email");
602 }
603}