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 => {
249            let row = row_object_schema(query, catalog, opts)?;
250            Ok(json!({ "type": "array", "items": row }))
251        }
252        QueryCommand::Grouped => {
253            // For grouped queries we proxy to :many at this layer; richer
254            // shaping will arrive once scythe's grouped codegen lands.
255            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        // scythe's `fn_name` would emit `get_user` from `@name GetUser`; the
461        // route handler must not collide with that.
462        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        // @optional removes them from `required`; they're still in properties.
496        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); // exec_rows default
592    }
593
594    #[test]
595    fn single_body_param_recorded_in_metadata() {
596        let mut q = create_user_query();
597        q.params.truncate(1); // only `email`
598        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}