Skip to main content

spikard_codegen/sql/
route.rs

1//! Build `spikard_core::RouteMetadata` from a scythe `AnalyzedQuery`.
2
3use 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/// One spikard route plus the SQL command and HTTP semantics needed to wire it
26/// up. Returned as a single value so callers don't lose the join between the
27/// route's identity (path/method/handler name) and the query metadata that
28/// produced it.
29#[derive(Debug, Clone)]
30pub struct SqlRoute {
31    /// `RouteMetadata` shape spikard-core consumes. Stored as JSON to avoid a
32    /// hard dep on `spikard-core` from `spikard-codegen`; callers (the CLI)
33    /// deserialize into the concrete type at the boundary.
34    pub metadata: Value,
35    /// HTTP semantics that built the route — preserved so the OpenAPI emitter
36    /// and sidecar builder don't have to re-parse `query.custom`.
37    pub http: HttpAnnotations,
38    /// Mapping from SQL param name to its HTTP source. Combines explicit
39    /// `@http_param` overrides with the inference rules in [`bin_param_locations`].
40    pub param_locations: BTreeMap<String, HttpParamBinding>,
41    /// Status code chosen for the default response (from `@http_status` or
42    /// derived from the SQL command).
43    pub default_status: u16,
44    /// Bundle name for the body object when multiple body params exist.
45    pub body_bundle_name: String,
46    /// `operation_id` used in OpenAPI (`PascalCase`, taken from `@name`).
47    pub operation_id: String,
48    /// Handler name in generated code (`snake_case`, `handle_<name>`).
49    pub handler_name: String,
50}
51
52/// Build a `RouteMetadata` (as JSON) from one analyzed query. Returns
53/// `Ok(None)` when the query has no `@http` directive — those are skipped
54/// silently so SQL files can mix HTTP and non-HTTP queries freely.
55pub 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, &param_locations, catalog, opts)?;
69    let request_schema = build_request_schema(query, &param_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, &param_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
103/// Decide where each `AnalyzedParam` is sourced from, falling back from
104/// explicit `@http_param` overrides to inference rules:
105/// 1. explicit binding wins,
106/// 2. name appears as `{name}` in path → `path`,
107/// 3. GET/DELETE → `query`,
108/// 4. POST/PUT/PATCH → `body`.
109pub 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}