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::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}