Skip to main content

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