Skip to main content

spikard_codegen/sql/
route.rs

1#![allow(
2    clippy::missing_errors_doc,
3    clippy::missing_panics_doc,
4    clippy::must_use_candidate,
5    clippy::doc_markdown,
6    clippy::too_long_first_doc_paragraph,
7    clippy::module_name_repetitions,
8    clippy::too_many_lines
9)]
10//! Build `spikard_core::RouteMetadata` from a scythe `AnalyzedQuery`.
11
12use std::collections::BTreeMap;
13
14use scythe_core::analyzer::AnalyzedQuery;
15use scythe_core::catalog::Catalog;
16use scythe_core::parser::QueryCommand;
17use serde_json::{Map, Value, json};
18use thiserror::Error;
19
20use super::annotations::{
21    AnnotationParseError, HttpAnnotations, HttpMethod, HttpParamBinding, default_status_for, parse_http_annotations,
22};
23use super::neutral_to_json_schema::{BuildOptions, NeutralTypeError, json_schema_for};
24
25#[derive(Debug, Error)]
26pub enum RouteBuildError {
27    #[error("annotation error: {0}")]
28    Annotation(#[from] AnnotationParseError),
29
30    #[error("neutral type error: {0}")]
31    NeutralType(#[from] NeutralTypeError),
32}
33
34/// One spikard route plus the SQL command and HTTP semantics needed to wire it
35/// up. Returned as a single value so callers don't lose the join between the
36/// route's identity (path/method/handler name) and the query metadata that
37/// produced it.
38#[derive(Debug, Clone)]
39pub struct SqlRoute {
40    /// `RouteMetadata` shape spikard-core consumes. Stored as JSON to avoid a
41    /// hard dep on `spikard-core` from `spikard-codegen`; callers (the CLI)
42    /// deserialize into the concrete type at the boundary.
43    pub metadata: Value,
44    /// HTTP semantics that built the route — preserved so the OpenAPI emitter
45    /// and sidecar builder don't have to re-parse `query.custom`.
46    pub http: HttpAnnotations,
47    /// Mapping from SQL param name to its HTTP source. Combines explicit
48    /// `@http_param` overrides with the inference rules in [`bin_param_locations`].
49    pub param_locations: BTreeMap<String, HttpParamBinding>,
50    /// Status code chosen for the default response (from `@http_status` or
51    /// derived from the SQL command).
52    pub default_status: u16,
53    /// Bundle name for the body object when multiple body params exist.
54    pub body_bundle_name: String,
55    /// `operation_id` used in OpenAPI (`PascalCase`, taken from `@name`).
56    pub operation_id: String,
57    /// Handler name in generated code (`snake_case`, `handle_<name>`).
58    pub handler_name: String,
59}
60
61/// Build a `RouteMetadata` (as JSON) from one analyzed query. Returns
62/// `Ok(None)` when the query has no `@http` directive — those are skipped
63/// silently so SQL files can mix HTTP and non-HTTP queries freely.
64pub fn route_from_query(
65    query: &AnalyzedQuery,
66    catalog: &Catalog,
67    opts: &BuildOptions,
68) -> Result<Option<SqlRoute>, RouteBuildError> {
69    let Some(http) = parse_http_annotations(&query.custom)? else {
70        return Ok(None);
71    };
72    let default_status = default_status_for(&query.command, http.method)?;
73
74    let param_locations = bin_param_locations(query, &http);
75    let body_bundle_name = http.request_body_name.clone().unwrap_or_else(|| "payload".to_string());
76
77    let parameter_schema = build_parameter_schema(query, &param_locations, catalog, opts)?;
78    let request_schema = build_request_schema(query, &param_locations, &body_bundle_name, catalog, opts)?;
79    let response_schema = build_response_schema(query, catalog, opts)?;
80
81    let handler_name = format!("handle_{}", to_snake_case(&query.name));
82    let operation_id = query.name.clone();
83
84    let body_param_name = single_body_param(query, &param_locations).map(str::to_string);
85    let expects_json_body = matches!(http.method, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
86        && param_locations.values().any(|v| *v == HttpParamBinding::Body);
87
88    let mut metadata = Map::new();
89    metadata.insert("method".into(), json!(http.method.as_str()));
90    metadata.insert("path".into(), json!(&http.path));
91    metadata.insert("handler_name".into(), json!(&handler_name));
92    metadata.insert("request_schema".into(), request_schema);
93    metadata.insert("response_schema".into(), response_schema);
94    metadata.insert("parameter_schema".into(), parameter_schema);
95    metadata.insert("is_async".into(), json!(true));
96    metadata.insert("expects_json_body".into(), json!(expects_json_body));
97    if let Some(body_name) = body_param_name {
98        metadata.insert("body_param_name".into(), json!(body_name));
99    }
100
101    Ok(Some(SqlRoute {
102        metadata: Value::Object(metadata),
103        http,
104        param_locations,
105        default_status,
106        body_bundle_name,
107        operation_id,
108        handler_name,
109    }))
110}
111
112/// Decide where each `AnalyzedParam` is sourced from, falling back from
113/// explicit `@http_param` overrides to inference rules:
114/// 1. explicit binding wins,
115/// 2. name appears as `{name}` in path → `path`,
116/// 3. GET/DELETE → `query`,
117/// 4. POST/PUT/PATCH → `body`.
118pub fn bin_param_locations(query: &AnalyzedQuery, http: &HttpAnnotations) -> BTreeMap<String, HttpParamBinding> {
119    let path_segments: Vec<&str> = extract_path_params(&http.path);
120    let mut bindings = BTreeMap::new();
121    for p in &query.params {
122        if let Some(explicit) = http.param_bindings.get(&p.name) {
123            bindings.insert(p.name.clone(), *explicit);
124            continue;
125        }
126        if path_segments.iter().any(|s| *s == p.name) {
127            bindings.insert(p.name.clone(), HttpParamBinding::Path);
128            continue;
129        }
130        let inferred = match http.method {
131            HttpMethod::Get | HttpMethod::Delete | HttpMethod::Head | HttpMethod::Options => HttpParamBinding::Query,
132            HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => HttpParamBinding::Body,
133        };
134        bindings.insert(p.name.clone(), inferred);
135    }
136    bindings
137}
138
139fn extract_path_params(path: &str) -> Vec<&str> {
140    let mut out = Vec::new();
141    let bytes = path.as_bytes();
142    let mut i = 0;
143    while i < bytes.len() {
144        if bytes[i] == b'{' {
145            let start = i + 1;
146            while i < bytes.len() && bytes[i] != b'}' {
147                i += 1;
148            }
149            if i < bytes.len() && bytes[i] == b'}' {
150                out.push(&path[start..i]);
151            }
152        }
153        i += 1;
154    }
155    out
156}
157
158fn single_body_param<'a>(query: &'a AnalyzedQuery, locations: &BTreeMap<String, HttpParamBinding>) -> Option<&'a str> {
159    let body_names: Vec<&str> = query
160        .params
161        .iter()
162        .filter(|p| locations.get(&p.name) == Some(&HttpParamBinding::Body))
163        .map(|p| p.name.as_str())
164        .collect();
165    if body_names.len() == 1 {
166        Some(body_names[0])
167    } else {
168        None
169    }
170}
171
172fn build_parameter_schema(
173    query: &AnalyzedQuery,
174    locations: &BTreeMap<String, HttpParamBinding>,
175    catalog: &Catalog,
176    opts: &BuildOptions,
177) -> Result<Value, RouteBuildError> {
178    let mut props = Map::new();
179    let mut required: Vec<String> = Vec::new();
180    let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
181    for p in &query.params {
182        let loc = locations.get(&p.name).copied().unwrap_or(HttpParamBinding::Body);
183        if !matches!(loc, HttpParamBinding::Path | HttpParamBinding::Query) {
184            continue;
185        }
186        let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
187        props.insert(p.name.clone(), schema);
188        let is_required = matches!(loc, HttpParamBinding::Path) || !optional_set.contains(p.name.as_str());
189        if is_required {
190            required.push(p.name.clone());
191        }
192    }
193    if props.is_empty() {
194        return Ok(Value::Null);
195    }
196    let mut obj = Map::new();
197    obj.insert("type".into(), json!("object"));
198    obj.insert("properties".into(), Value::Object(props));
199    if !required.is_empty() {
200        obj.insert("required".into(), json!(required));
201    }
202    Ok(Value::Object(obj))
203}
204
205fn build_request_schema(
206    query: &AnalyzedQuery,
207    locations: &BTreeMap<String, HttpParamBinding>,
208    _bundle_name: &str,
209    catalog: &Catalog,
210    opts: &BuildOptions,
211) -> Result<Value, RouteBuildError> {
212    let optional_set: std::collections::HashSet<&str> = query.optional_params.iter().map(String::as_str).collect();
213    let mut props = Map::new();
214    let mut required: Vec<String> = Vec::new();
215    for p in &query.params {
216        if locations.get(&p.name) != Some(&HttpParamBinding::Body) {
217            continue;
218        }
219        let schema = json_schema_for(&p.neutral_type, p.nullable, &query.enums, catalog, opts)?;
220        props.insert(p.name.clone(), schema);
221        if !optional_set.contains(p.name.as_str()) {
222            required.push(p.name.clone());
223        }
224    }
225    if props.is_empty() {
226        return Ok(Value::Null);
227    }
228    let mut obj = Map::new();
229    obj.insert("type".into(), json!("object"));
230    obj.insert("properties".into(), Value::Object(props));
231    if !required.is_empty() {
232        obj.insert("required".into(), json!(required));
233    }
234    Ok(Value::Object(obj))
235}
236
237fn build_response_schema(
238    query: &AnalyzedQuery,
239    catalog: &Catalog,
240    opts: &BuildOptions,
241) -> Result<Value, RouteBuildError> {
242    match query.command {
243        QueryCommand::Exec | QueryCommand::ExecResult | QueryCommand::Batch => Ok(Value::Null),
244        QueryCommand::ExecRows => Ok(json!({
245            "type": "object",
246            "properties": { "rows": { "type": "integer", "format": "int64" } },
247            "required": ["rows"],
248        })),
249        QueryCommand::One | QueryCommand::Opt => {
250            let row = row_object_schema(query, catalog, opts)?;
251            if matches!(query.command, QueryCommand::Opt) {
252                Ok(json!({ "oneOf": [row, { "type": "null" }] }))
253            } else {
254                Ok(row)
255            }
256        }
257        QueryCommand::Many => {
258            let row = row_object_schema(query, catalog, opts)?;
259            Ok(json!({ "type": "array", "items": row }))
260        }
261        QueryCommand::Grouped => {
262            // For grouped queries we proxy to :many at this layer; richer
263            // shaping will arrive once scythe's grouped codegen lands.
264            let row = row_object_schema(query, catalog, opts)?;
265            Ok(json!({ "type": "array", "items": row }))
266        }
267    }
268}
269
270fn row_object_schema(query: &AnalyzedQuery, catalog: &Catalog, opts: &BuildOptions) -> Result<Value, RouteBuildError> {
271    let mut props = Map::new();
272    let mut required: Vec<String> = Vec::new();
273    for col in &query.columns {
274        let schema = json_schema_for(&col.neutral_type, col.nullable, &query.enums, catalog, opts)?;
275        props.insert(col.name.clone(), schema);
276        required.push(col.name.clone());
277    }
278    let mut obj = Map::new();
279    obj.insert("type".into(), json!("object"));
280    obj.insert("properties".into(), Value::Object(props));
281    if !required.is_empty() {
282        obj.insert("required".into(), json!(required));
283    }
284    Ok(Value::Object(obj))
285}
286
287fn to_snake_case(s: &str) -> String {
288    let mut out = String::with_capacity(s.len() + 4);
289    let mut prev_lower = false;
290    for c in s.chars() {
291        if c.is_ascii_uppercase() {
292            if prev_lower {
293                out.push('_');
294            }
295            out.push(c.to_ascii_lowercase());
296            prev_lower = false;
297        } else {
298            out.push(c);
299            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
300        }
301    }
302    out
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
309    use scythe_core::parser::{CustomAnnotation, QueryCommand};
310
311    fn empty_catalog() -> Catalog {
312        Catalog::from_ddl(&[]).unwrap()
313    }
314
315    fn get_user_query() -> AnalyzedQuery {
316        AnalyzedQuery {
317            name: "GetUser".to_string(),
318            command: QueryCommand::One,
319            sql: "SELECT id, email, name FROM users WHERE id = $1".to_string(),
320            columns: vec![
321                AnalyzedColumn {
322                    name: "id".to_string(),
323                    neutral_type: "int64".to_string(),
324                    nullable: false,
325                },
326                AnalyzedColumn {
327                    name: "email".to_string(),
328                    neutral_type: "string".to_string(),
329                    nullable: false,
330                },
331                AnalyzedColumn {
332                    name: "name".to_string(),
333                    neutral_type: "string".to_string(),
334                    nullable: true,
335                },
336            ],
337            params: vec![AnalyzedParam {
338                name: "id".to_string(),
339                neutral_type: "int64".to_string(),
340                nullable: false,
341                position: 1,
342            }],
343            deprecated: None,
344            source_table: Some("users".to_string()),
345            composites: vec![],
346            enums: vec![],
347            optional_params: vec![],
348            group_by: None,
349            custom: vec![
350                CustomAnnotation {
351                    name: "http".into(),
352                    value: "GET /users/{id}".into(),
353                    line: 3,
354                },
355                CustomAnnotation {
356                    name: "http_auth".into(),
357                    value: "bearer:jwt".into(),
358                    line: 4,
359                },
360                CustomAnnotation {
361                    name: "http_status".into(),
362                    value: "200,404".into(),
363                    line: 5,
364                },
365            ],
366        }
367    }
368
369    fn create_user_query() -> AnalyzedQuery {
370        AnalyzedQuery {
371            name: "CreateUser".to_string(),
372            command: QueryCommand::ExecRows,
373            sql: "INSERT INTO users (email, name) VALUES ($1, $2)".to_string(),
374            columns: vec![],
375            params: vec![
376                AnalyzedParam {
377                    name: "email".to_string(),
378                    neutral_type: "string".to_string(),
379                    nullable: false,
380                    position: 1,
381                },
382                AnalyzedParam {
383                    name: "name".to_string(),
384                    neutral_type: "string".to_string(),
385                    nullable: true,
386                    position: 2,
387                },
388            ],
389            deprecated: None,
390            source_table: None,
391            composites: vec![],
392            enums: vec![],
393            optional_params: vec![],
394            group_by: None,
395            custom: vec![
396                CustomAnnotation {
397                    name: "http".into(),
398                    value: "POST /users".into(),
399                    line: 1,
400                },
401                CustomAnnotation {
402                    name: "http_status".into(),
403                    value: "201".into(),
404                    line: 2,
405                },
406            ],
407        }
408    }
409
410    fn list_users_query() -> AnalyzedQuery {
411        AnalyzedQuery {
412            name: "ListUsers".to_string(),
413            command: QueryCommand::Many,
414            sql: "SELECT id, email FROM users LIMIT $1 OFFSET $2".to_string(),
415            columns: vec![
416                AnalyzedColumn {
417                    name: "id".to_string(),
418                    neutral_type: "int64".to_string(),
419                    nullable: false,
420                },
421                AnalyzedColumn {
422                    name: "email".to_string(),
423                    neutral_type: "string".to_string(),
424                    nullable: false,
425                },
426            ],
427            params: vec![
428                AnalyzedParam {
429                    name: "limit".to_string(),
430                    neutral_type: "int32".to_string(),
431                    nullable: true,
432                    position: 1,
433                },
434                AnalyzedParam {
435                    name: "offset".to_string(),
436                    neutral_type: "int32".to_string(),
437                    nullable: true,
438                    position: 2,
439                },
440            ],
441            deprecated: None,
442            source_table: Some("users".to_string()),
443            composites: vec![],
444            enums: vec![],
445            optional_params: vec!["limit".to_string(), "offset".to_string()],
446            group_by: None,
447            custom: vec![CustomAnnotation {
448                name: "http".into(),
449                value: "GET /users".into(),
450                line: 1,
451            }],
452        }
453    }
454
455    #[test]
456    fn route_from_get_query_uses_get_method() {
457        let q = get_user_query();
458        let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
459            .unwrap()
460            .unwrap();
461        assert_eq!(route.metadata["method"], "GET");
462        assert_eq!(route.metadata["path"], "/users/{id}");
463        assert_eq!(route.metadata["handler_name"], "handle_get_user");
464        assert_eq!(route.operation_id, "GetUser");
465    }
466
467    #[test]
468    fn handler_name_distinct_from_scythe_fn() {
469        // scythe's `fn_name` would emit `get_user` from `@name GetUser`; the
470        // route handler must not collide with that.
471        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
472            .unwrap()
473            .unwrap();
474        assert_eq!(route.handler_name, "handle_get_user");
475        assert_ne!(route.handler_name, "get_user");
476    }
477
478    #[test]
479    fn path_param_bound_to_path() {
480        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
481            .unwrap()
482            .unwrap();
483        assert_eq!(route.param_locations.get("id"), Some(&HttpParamBinding::Path));
484    }
485
486    #[test]
487    fn parameter_schema_carries_path_param_as_required() {
488        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
489            .unwrap()
490            .unwrap();
491        let params = &route.metadata["parameter_schema"];
492        assert_eq!(params["type"], "object");
493        assert!(params["properties"]["id"].is_object());
494        assert_eq!(params["required"], json!(["id"]));
495    }
496
497    #[test]
498    fn list_query_params_become_query_and_optional() {
499        let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
500            .unwrap()
501            .unwrap();
502        assert_eq!(route.param_locations.get("limit"), Some(&HttpParamBinding::Query));
503        let params = &route.metadata["parameter_schema"];
504        // @optional removes them from `required`; they're still in properties.
505        assert!(params["properties"]["limit"].is_object());
506        assert!(params["required"].is_null() || !params["required"].as_array().unwrap().iter().any(|v| v == "limit"));
507    }
508
509    #[test]
510    fn post_query_params_become_body() {
511        let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
512            .unwrap()
513            .unwrap();
514        assert_eq!(route.param_locations.get("email"), Some(&HttpParamBinding::Body));
515        assert_eq!(route.metadata["method"], "POST");
516        let req = &route.metadata["request_schema"];
517        assert_eq!(req["type"], "object");
518        assert!(req["properties"]["email"].is_object());
519        assert!(req["properties"]["name"].is_object());
520        assert_eq!(req["required"], json!(["email", "name"]));
521        assert_eq!(route.metadata["expects_json_body"], true);
522    }
523
524    #[test]
525    fn one_query_response_is_object_with_required_columns() {
526        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
527            .unwrap()
528            .unwrap();
529        let resp = &route.metadata["response_schema"];
530        assert_eq!(resp["type"], "object");
531        assert_eq!(resp["required"], json!(["id", "email", "name"]));
532    }
533
534    #[test]
535    fn many_query_response_is_array() {
536        let route = route_from_query(&list_users_query(), &empty_catalog(), &BuildOptions::default())
537            .unwrap()
538            .unwrap();
539        let resp = &route.metadata["response_schema"];
540        assert_eq!(resp["type"], "array");
541        assert_eq!(resp["items"]["type"], "object");
542    }
543
544    #[test]
545    fn exec_rows_response_is_rows_object() {
546        let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
547            .unwrap()
548            .unwrap();
549        let resp = &route.metadata["response_schema"];
550        assert_eq!(resp["type"], "object");
551        assert_eq!(resp["properties"]["rows"]["type"], "integer");
552        assert_eq!(resp["required"], json!(["rows"]));
553    }
554
555    #[test]
556    fn nullable_column_emits_oneof_null() {
557        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
558            .unwrap()
559            .unwrap();
560        let resp = &route.metadata["response_schema"];
561        let name_schema = &resp["properties"]["name"];
562        assert!(name_schema["oneOf"].is_array());
563    }
564
565    #[test]
566    fn no_http_directive_returns_none() {
567        let mut q = get_user_query();
568        q.custom.clear();
569        let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap();
570        assert!(route.is_none());
571    }
572
573    #[test]
574    fn batch_command_with_http_errors() {
575        let mut q = get_user_query();
576        q.command = QueryCommand::Batch;
577        let err = route_from_query(&q, &empty_catalog(), &BuildOptions::default()).unwrap_err();
578        assert!(matches!(
579            err,
580            RouteBuildError::Annotation(AnnotationParseError::IncompatibleCommand { .. })
581        ));
582    }
583
584    #[test]
585    fn snake_case_handles_pascal_case() {
586        assert_eq!(to_snake_case("GetUser"), "get_user");
587        assert_eq!(to_snake_case("ListActiveUsers"), "list_active_users");
588        assert_eq!(to_snake_case("CreateUser"), "create_user");
589    }
590
591    #[test]
592    fn default_status_matches_command() {
593        let route = route_from_query(&get_user_query(), &empty_catalog(), &BuildOptions::default())
594            .unwrap()
595            .unwrap();
596        assert_eq!(route.default_status, 200);
597        let route = route_from_query(&create_user_query(), &empty_catalog(), &BuildOptions::default())
598            .unwrap()
599            .unwrap();
600        assert_eq!(route.default_status, 200); // exec_rows default
601    }
602
603    #[test]
604    fn single_body_param_recorded_in_metadata() {
605        let mut q = create_user_query();
606        q.params.truncate(1); // only `email`
607        let route = route_from_query(&q, &empty_catalog(), &BuildOptions::default())
608            .unwrap()
609            .unwrap();
610        assert_eq!(route.metadata["body_param_name"], "email");
611    }
612}