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 {
302 name: "GetUser".to_string(),
303 command: QueryCommand::One,
304 sql: "SELECT id, email, name FROM users WHERE id = $1".to_string(),
305 columns: vec![
306 AnalyzedColumn {
307 name: "id".to_string(),
308 neutral_type: "int64".to_string(),
309 nullable: false,
310 },
311 AnalyzedColumn {
312 name: "email".to_string(),
313 neutral_type: "string".to_string(),
314 nullable: false,
315 },
316 AnalyzedColumn {
317 name: "name".to_string(),
318 neutral_type: "string".to_string(),
319 nullable: true,
320 },
321 ],
322 params: vec![AnalyzedParam {
323 name: "id".to_string(),
324 neutral_type: "int64".to_string(),
325 nullable: false,
326 position: 1,
327 }],
328 deprecated: None,
329 source_table: Some("users".to_string()),
330 composites: vec![],
331 enums: vec![],
332 optional_params: vec![],
333 group_by: None,
334 custom: vec![
335 CustomAnnotation {
336 name: "http".into(),
337 value: "GET /users/{id}".into(),
338 line: 3,
339 },
340 CustomAnnotation {
341 name: "http_auth".into(),
342 value: "bearer:jwt".into(),
343 line: 4,
344 },
345 CustomAnnotation {
346 name: "http_status".into(),
347 value: "200,404".into(),
348 line: 5,
349 },
350 ],
351 }
352 }
353
354 fn create_user_query() -> AnalyzedQuery {
355 AnalyzedQuery {
356 name: "CreateUser".to_string(),
357 command: QueryCommand::ExecRows,
358 sql: "INSERT INTO users (email, name) VALUES ($1, $2)".to_string(),
359 columns: vec![],
360 params: vec![
361 AnalyzedParam {
362 name: "email".to_string(),
363 neutral_type: "string".to_string(),
364 nullable: false,
365 position: 1,
366 },
367 AnalyzedParam {
368 name: "name".to_string(),
369 neutral_type: "string".to_string(),
370 nullable: true,
371 position: 2,
372 },
373 ],
374 deprecated: None,
375 source_table: None,
376 composites: vec![],
377 enums: vec![],
378 optional_params: vec![],
379 group_by: None,
380 custom: vec![
381 CustomAnnotation {
382 name: "http".into(),
383 value: "POST /users".into(),
384 line: 1,
385 },
386 CustomAnnotation {
387 name: "http_status".into(),
388 value: "201".into(),
389 line: 2,
390 },
391 ],
392 }
393 }
394
395 fn list_users_query() -> AnalyzedQuery {
396 AnalyzedQuery {
397 name: "ListUsers".to_string(),
398 command: QueryCommand::Many,
399 sql: "SELECT id, email FROM users LIMIT $1 OFFSET $2".to_string(),
400 columns: vec![
401 AnalyzedColumn {
402 name: "id".to_string(),
403 neutral_type: "int64".to_string(),
404 nullable: false,
405 },
406 AnalyzedColumn {
407 name: "email".to_string(),
408 neutral_type: "string".to_string(),
409 nullable: false,
410 },
411 ],
412 params: vec![
413 AnalyzedParam {
414 name: "limit".to_string(),
415 neutral_type: "int32".to_string(),
416 nullable: true,
417 position: 1,
418 },
419 AnalyzedParam {
420 name: "offset".to_string(),
421 neutral_type: "int32".to_string(),
422 nullable: true,
423 position: 2,
424 },
425 ],
426 deprecated: None,
427 source_table: Some("users".to_string()),
428 composites: vec![],
429 enums: vec![],
430 optional_params: vec!["limit".to_string(), "offset".to_string()],
431 group_by: None,
432 custom: vec![CustomAnnotation {
433 name: "http".into(),
434 value: "GET /users".into(),
435 line: 1,
436 }],
437 }
438 }
439
440 #[test]
441 fn route_from_get_query_uses_get_method() {
442 let q = get_user_query();
443 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
444 .unwrap()
445 .unwrap();
446 assert_eq!(route.metadata["method"], "GET");
447 assert_eq!(route.metadata["path"], "/users/{id}");
448 assert_eq!(route.metadata["handler_name"], "handle_get_user");
449 assert_eq!(route.operation_id, "GetUser");
450 }
451
452 #[test]
453 fn handler_name_distinct_from_scythe_fn() {
454 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
455 .unwrap()
456 .unwrap();
457 assert_eq!(route.handler_name, "handle_get_user");
458 assert_ne!(route.handler_name, "get_user");
459 }
460
461 #[test]
462 fn path_param_bound_to_path() {
463 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
464 .unwrap()
465 .unwrap();
466 assert_eq!(route.param_locations.get("id"), Some(&HttpParamBinding::Path));
467 }
468
469 #[test]
470 fn parameter_schema_carries_path_param_as_required() {
471 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
472 .unwrap()
473 .unwrap();
474 let params = &route.metadata["parameter_schema"];
475 assert_eq!(params["type"], "object");
476 assert!(params["properties"]["id"].is_object());
477 assert_eq!(params["required"], json!(["id"]));
478 }
479
480 #[test]
481 fn list_query_params_become_query_and_optional() {
482 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
483 .unwrap()
484 .unwrap();
485 assert_eq!(route.param_locations.get("limit"), Some(&HttpParamBinding::Query));
486 let params = &route.metadata["parameter_schema"];
487 assert!(params["properties"]["limit"].is_object());
488 assert!(params["required"].is_null() || !params["required"].as_array().unwrap().iter().any(|v| v == "limit"));
489 }
490
491 #[test]
492 fn post_query_params_become_body() {
493 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
494 .unwrap()
495 .unwrap();
496 assert_eq!(route.param_locations.get("email"), Some(&HttpParamBinding::Body));
497 assert_eq!(route.metadata["method"], "POST");
498 let req = &route.metadata["request_schema"];
499 assert_eq!(req["type"], "object");
500 assert!(req["properties"]["email"].is_object());
501 assert!(req["properties"]["name"].is_object());
502 assert_eq!(req["required"], json!(["email", "name"]));
503 assert_eq!(route.metadata["expects_json_body"], true);
504 }
505
506 #[test]
507 fn one_query_response_is_object_with_required_columns() {
508 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
509 .unwrap()
510 .unwrap();
511 let resp = &route.metadata["response_schema"];
512 assert_eq!(resp["type"], "object");
513 assert_eq!(resp["required"], json!(["id", "email", "name"]));
514 }
515
516 #[test]
517 fn many_query_response_is_array() {
518 let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
519 .unwrap()
520 .unwrap();
521 let resp = &route.metadata["response_schema"];
522 assert_eq!(resp["type"], "array");
523 assert_eq!(resp["items"]["type"], "object");
524 }
525
526 #[test]
527 fn exec_rows_response_is_rows_object() {
528 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
529 .unwrap()
530 .unwrap();
531 let resp = &route.metadata["response_schema"];
532 assert_eq!(resp["type"], "object");
533 assert_eq!(resp["properties"]["rows"]["type"], "integer");
534 assert_eq!(resp["required"], json!(["rows"]));
535 }
536
537 #[test]
538 fn nullable_column_emits_oneof_null() {
539 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
540 .unwrap()
541 .unwrap();
542 let resp = &route.metadata["response_schema"];
543 let name_schema = &resp["properties"]["name"];
544 assert!(name_schema["oneOf"].is_array());
545 }
546
547 #[test]
548 fn no_http_directive_returns_none() {
549 let mut q = get_user_query();
550 q.custom.clear();
551 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap();
552 assert!(route.is_none());
553 }
554
555 #[test]
556 fn batch_command_with_http_errors() {
557 let mut q = get_user_query();
558 q.command = QueryCommand::Batch;
559 let err = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap_err();
560 assert!(matches!(
561 err,
562 RouteBuildError::Annotation(AnnotationParseError::IncompatibleCommand { .. })
563 ));
564 }
565
566 #[test]
567 fn snake_case_handles_pascal_case() {
568 assert_eq!(to_snake_case("GetUser"), "get_user");
569 assert_eq!(to_snake_case("ListActiveUsers"), "list_active_users");
570 assert_eq!(to_snake_case("CreateUser"), "create_user");
571 }
572
573 #[test]
574 fn default_status_matches_command() {
575 let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
576 .unwrap()
577 .unwrap();
578 assert_eq!(route.default_status, 200);
579 let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
580 .unwrap()
581 .unwrap();
582 assert_eq!(route.default_status, 200);
583 }
584
585 #[test]
586 fn single_body_param_recorded_in_metadata() {
587 let mut q = create_user_query();
588 q.params.truncate(1);
589 let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
590 .unwrap()
591 .unwrap();
592 assert_eq!(route.metadata["body_param_name"], "email");
593 }
594}