Skip to main content

spikard_codegen/sql/
mod.rs

1// reason: SQL module is internal codegen infrastructure; pedantic doc lints
2// (missing_errors_doc, must_use_candidate, doc_markdown, module_name_repetitions, …)
3// are silenced at the module level to keep per-function annotations noise-free.
4// too_many_lines applies to route_from_query which is deliberately a single pipeline function.
5// similar_names applies to test locals (spec/sec) where renaming would reduce clarity.
6#![allow(
7    clippy::missing_errors_doc,
8    clippy::missing_panics_doc,
9    clippy::must_use_candidate,
10    clippy::doc_markdown,
11    clippy::too_long_first_doc_paragraph,
12    clippy::module_name_repetitions,
13    clippy::too_many_lines,
14    clippy::similar_names
15)]
16//! SQL-driven HTTP handler generation.
17//!
18//! Consumes scythe's `AnalyzedQuery` IR (plus the `Catalog` it was analyzed
19//! against) and emits routes, JSON Schema validators, and an OpenAPI 3.1 spec.
20//! The HTTP vocabulary (`@http`, `@http_param`, `@http_auth`, …) lives
21//! entirely in this module — scythe knows nothing about HTTP. Spikard parses
22//! them out of `AnalyzedQuery.custom`.
23//!
24//! Pipeline:
25//!
26//! ```text
27//!   AnalyzedQuery.custom  ─►  parse_http_annotations  ─►  HttpAnnotations
28//!   AnalyzedQuery (+ Catalog) ─►  route_from_query   ─►  RouteMetadata
29//!   [RouteMetadata + AnalyzedQuery]  ─►  openapi_from_routes  ─►  Value (OpenAPI 3.1)
30//!   [AnalyzedQuery]  ─►  build_sidecar  ─►  Sidecar  (per-language call info)
31//! ```
32
33pub mod annotations;
34pub mod neutral_to_json_schema;
35pub mod openapi;
36pub mod route;
37pub mod sidecar;
38
39pub use annotations::{
40    AnnotationParseError, ApiKeyLocation, AuthRequirement, HttpAnnotations, HttpMethod, HttpParamBinding,
41    parse_http_annotations,
42};
43pub use neutral_to_json_schema::{BuildOptions, DecimalMode, neutral_to_json_schema};
44pub use openapi::{OpenApiInfo, openapi_from_routes};
45pub use route::{RouteBuildError, SqlRoute, route_from_query};
46pub use sidecar::{Sidecar, SidecarEntry, SidecarParam};
47
48use scythe_core::analyzer::AnalyzedQuery;
49use scythe_core::catalog::Catalog;
50use serde_json::Value;
51
52/// Aggregate output of [`build_handler_set`]: routes + OpenAPI spec + sidecar.
53#[derive(Debug, Clone)]
54pub struct HandlerSet {
55    /// One JSON `RouteMetadata` value per HTTP-annotated query.
56    pub routes: Vec<Value>,
57    /// The same routes, paired with their HTTP annotations and command —
58    /// useful for callers that need to wire OpenAPI emission or sidecar entries
59    /// without re-parsing.
60    pub sql_routes: Vec<SqlRoute>,
61    /// OpenAPI 3.1 spec built from `sql_routes`.
62    pub openapi: Value,
63    /// Per-language call info for handler-stub generators.
64    pub sidecar: Sidecar,
65}
66
67/// Walk a slice of analyzed queries and build the full handler set: routes,
68/// OpenAPI spec, and a per-language sidecar. Queries without an `@http`
69/// directive are skipped silently. The sidecar is populated by passing per-
70/// language `lang_type_for` resolvers in `languages`.
71///
72/// `languages` maps language name → `(scythe_module, scythe_fn_for, is_async,
73/// lang_type_for)` tuples. The closures are evaluated once per query, giving
74/// callers full control over per-language naming and type resolution without
75/// pulling scythe's backend trait into this crate's surface.
76pub fn build_handler_set(
77    catalog: &Catalog,
78    queries: &[AnalyzedQuery],
79    info: &OpenApiInfo,
80    opts: &BuildOptions,
81    languages: &[LanguageBackend<'_>],
82) -> Result<HandlerSet, RouteBuildError> {
83    let mut sql_routes = Vec::new();
84    let mut routes = Vec::new();
85
86    for query in queries {
87        let Some(route) = route_from_query(query, catalog, opts)? else {
88            continue;
89        };
90        routes.push(route.metadata.clone());
91        sql_routes.push(route);
92    }
93
94    let openapi = openapi_from_routes(&sql_routes, info);
95
96    let mut sidecar = Sidecar::new();
97    for backend in languages {
98        for (route, query) in sql_routes.iter().zip(matching_queries(queries, &sql_routes)) {
99            let scythe_fn = (backend.scythe_fn_for)(&query.name);
100            let entry = sidecar::build_sidecar_entry(
101                query,
102                &route.param_locations,
103                backend.scythe_module,
104                &scythe_fn,
105                backend.is_async,
106                |neutral, nullable| (backend.lang_type_for)(neutral, nullable),
107            );
108            sidecar.insert(backend.name, &route.operation_id, entry);
109        }
110    }
111
112    Ok(HandlerSet {
113        routes,
114        sql_routes,
115        openapi,
116        sidecar,
117    })
118}
119
120/// Per-language inputs to [`build_handler_set`]. Each backend names itself
121/// (used as the sidecar key) and supplies callbacks that translate scythe
122/// metadata into language-native names and types.
123pub struct LanguageBackend<'a> {
124    pub name: &'a str,
125    pub scythe_module: &'a str,
126    pub is_async: bool,
127    pub scythe_fn_for: &'a dyn Fn(&str) -> String,
128    pub lang_type_for: &'a dyn Fn(&str, bool) -> String,
129}
130
131fn matching_queries<'a>(queries: &'a [AnalyzedQuery], routes: &[SqlRoute]) -> Vec<&'a AnalyzedQuery> {
132    routes
133        .iter()
134        .filter_map(|r| queries.iter().find(|q| q.name == r.operation_id))
135        .collect()
136}
137
138#[cfg(test)]
139mod orchestrator_tests {
140    use super::*;
141    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
142    use scythe_core::parser::{CustomAnnotation, QueryCommand};
143
144    fn empty_catalog() -> Catalog {
145        Catalog::from_ddl(&[]).unwrap()
146    }
147
148    fn snake_for(name: &str) -> String {
149        let mut out = String::new();
150        let mut prev_lower = false;
151        for c in name.chars() {
152            if c.is_ascii_uppercase() {
153                if prev_lower {
154                    out.push('_');
155                }
156                out.push(c.to_ascii_lowercase());
157                prev_lower = false;
158            } else {
159                out.push(c);
160                prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
161            }
162        }
163        out
164    }
165
166    fn py_type(neutral: &str, nullable: bool) -> String {
167        let base = match neutral {
168            "int32" | "int64" | "int16" => "int",
169            "string" => "str",
170            "bool" => "bool",
171            _ => "Any",
172        };
173        if nullable {
174            format!("{base} | None")
175        } else {
176            base.to_string()
177        }
178    }
179
180    fn get_user() -> AnalyzedQuery {
181        AnalyzedQuery {
182            name: "GetUser".to_string(),
183            command: QueryCommand::One,
184            sql: "SELECT id FROM users WHERE id = $1".into(),
185            columns: vec![AnalyzedColumn {
186                name: "id".into(),
187                neutral_type: "int64".into(),
188                nullable: false,
189            }],
190            params: vec![AnalyzedParam {
191                name: "id".into(),
192                neutral_type: "int64".into(),
193                nullable: false,
194                position: 1,
195            }],
196            deprecated: None,
197            source_table: Some("users".into()),
198            composites: vec![],
199            enums: vec![],
200            optional_params: vec![],
201            group_by: None,
202            custom: vec![CustomAnnotation {
203                name: "http".into(),
204                value: "GET /users/{id}".into(),
205                line: 1,
206            }],
207        }
208    }
209
210    fn no_http() -> AnalyzedQuery {
211        AnalyzedQuery {
212            name: "InternalQuery".to_string(),
213            command: QueryCommand::One,
214            sql: "SELECT 1".into(),
215            columns: vec![],
216            params: vec![],
217            deprecated: None,
218            source_table: None,
219            composites: vec![],
220            enums: vec![],
221            optional_params: vec![],
222            group_by: None,
223            custom: vec![],
224        }
225    }
226
227    #[test]
228    fn skips_queries_without_http_directive() {
229        let queries = vec![get_user(), no_http()];
230        let set = build_handler_set(
231            &empty_catalog(),
232            &queries,
233            &OpenApiInfo::new("t", "0.1"),
234            &BuildOptions::default(),
235            &[],
236        )
237        .unwrap();
238        assert_eq!(set.routes.len(), 1);
239        assert_eq!(set.sql_routes.len(), 1);
240        assert_eq!(set.sql_routes[0].operation_id, "GetUser");
241    }
242
243    #[test]
244    fn populates_sidecar_per_language() {
245        let queries = vec![get_user()];
246        let snake = |s: &str| snake_for(s);
247        let set = build_handler_set(
248            &empty_catalog(),
249            &queries,
250            &OpenApiInfo::new("t", "0.1"),
251            &BuildOptions::default(),
252            &[LanguageBackend {
253                name: "python",
254                scythe_module: "queries",
255                is_async: true,
256                scythe_fn_for: &snake,
257                lang_type_for: &py_type,
258            }],
259        )
260        .unwrap();
261        let entry = set.sidecar.entry_for("python", "GetUser").unwrap();
262        assert_eq!(entry.scythe_module, "queries");
263        assert_eq!(entry.scythe_fn, "get_user");
264        assert!(entry.is_async);
265    }
266
267    #[test]
268    fn openapi_emitted_in_set() {
269        let queries = vec![get_user()];
270        let set = build_handler_set(
271            &empty_catalog(),
272            &queries,
273            &OpenApiInfo::new("t", "0.1"),
274            &BuildOptions::default(),
275            &[],
276        )
277        .unwrap();
278        assert_eq!(set.openapi["openapi"], "3.1.0");
279        assert!(set.openapi["paths"]["/users/{id}"]["get"].is_object());
280    }
281}