Skip to main content

spikard_codegen/sql/
sidecar.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)]
9//! Per-language call metadata that crosses the boundary from spikard's SQL
10//! module to the per-language handler-stub generators.
11//!
12//! The OpenAPI spec emitted by [`crate::sql::openapi_from_routes`] stays vanilla
13//! — no `x-*` extensions — so any generic OpenAPI consumer sees a normal
14//! document. The sidecar JSON carries everything spikard's per-language
15//! generators need to replace `raise NotImplementedError("TODO")` stubs with
16//! real bodies that call into scythe-generated query functions.
17
18use std::collections::BTreeMap;
19
20use scythe_core::analyzer::AnalyzedQuery;
21use scythe_core::parser::QueryCommand;
22use serde::{Deserialize, Serialize};
23
24use super::annotations::HttpParamBinding;
25
26/// Top-level sidecar: language → operation_id → entry.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct Sidecar {
29    pub by_language: BTreeMap<String, BTreeMap<String, SidecarEntry>>,
30}
31
32impl Sidecar {
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Insert an entry for `(language, operation_id)`.
38    pub fn insert(&mut self, language: &str, operation_id: &str, entry: SidecarEntry) {
39        self.by_language
40            .entry(language.to_string())
41            .or_default()
42            .insert(operation_id.to_string(), entry);
43    }
44
45    pub fn entry_for<'a>(&'a self, language: &str, operation_id: &str) -> Option<&'a SidecarEntry> {
46        self.by_language.get(language).and_then(|m| m.get(operation_id))
47    }
48}
49
50/// One handler's call info in one target language.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SidecarEntry {
53    /// Function name emitted by scythe's codegen backend in this language
54    /// (already canonicalised: e.g. `get_user` for Python from `@name GetUser`).
55    pub scythe_fn: String,
56    /// Module/package path the function lives in (e.g. `queries`,
57    /// `queries.users`). Per-language generators turn this into an import.
58    pub scythe_module: String,
59    /// Call arguments in the order scythe expects them, with sources tagged so
60    /// the generator knows whether to pull from `request.path`,
61    /// `request.query`, the body, or a header.
62    pub params: Vec<SidecarParam>,
63    /// Resolved return type in this language (e.g. `User` in Python with a
64    /// dataclass, `Promise<User | null>` in TS).
65    pub return_lang_type: String,
66    /// Whether the scythe-generated function is `async fn` (Rust),
67    /// `async def` (Python), `async`/`Promise` (TS), etc.
68    pub is_async: bool,
69    /// Drives how the generator wraps the call result (single row, array, exec,
70    /// affected-rows count, etc.).
71    pub command: QueryCommand,
72}
73
74/// One argument of a sidecar call.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SidecarParam {
77    /// SQL parameter name as it appears in scythe's `AnalyzedParam` and in the
78    /// scythe-generated function signature.
79    pub name: String,
80    /// Resolved language type (e.g. `int` in Python, `number` in TS,
81    /// `Option<i32>` in Rust).
82    pub lang_type: String,
83    /// Where to pull the value from in the HTTP request.
84    pub source: HttpParamBinding,
85}
86
87/// Build a sidecar entry from an `AnalyzedQuery` and a per-param binding map.
88///
89/// `lang_type_for` resolves a `(neutral_type, nullable)` pair to the target
90/// language's type string. We deliberately take it as a closure so the SQL
91/// module stays language-agnostic — callers supply scythe's own backend-aware
92/// resolver per language.
93pub fn build_sidecar_entry<F>(
94    query: &AnalyzedQuery,
95    bindings: &BTreeMap<String, HttpParamBinding>,
96    scythe_module: &str,
97    scythe_fn: &str,
98    is_async: bool,
99    lang_type_for: F,
100) -> SidecarEntry
101where
102    F: Fn(&str, bool) -> String,
103{
104    let params = query
105        .params
106        .iter()
107        .map(|p| {
108            let source = bindings.get(&p.name).copied().unwrap_or(HttpParamBinding::Body);
109            SidecarParam {
110                name: p.name.clone(),
111                lang_type: lang_type_for(&p.neutral_type, p.nullable),
112                source,
113            }
114        })
115        .collect();
116
117    let return_lang_type = compose_return_type(query, &lang_type_for);
118
119    SidecarEntry {
120        scythe_fn: scythe_fn.to_string(),
121        scythe_module: scythe_module.to_string(),
122        params,
123        return_lang_type,
124        is_async,
125        command: query.command.clone(),
126    }
127}
128
129fn compose_return_type<F>(query: &AnalyzedQuery, lang_type_for: &F) -> String
130where
131    F: Fn(&str, bool) -> String,
132{
133    match query.command {
134        QueryCommand::Exec => "void".to_string(),
135        QueryCommand::ExecRows => "rows".to_string(),
136        _ => {
137            // Compose a tuple-style string of the row's column types; the
138            // generator translates this into the language's row struct.
139            let cols: Vec<String> = query
140                .columns
141                .iter()
142                .map(|c| lang_type_for(&c.neutral_type, c.nullable))
143                .collect();
144            cols.join(", ")
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
153    use scythe_core::parser::QueryCommand;
154
155    fn fake_query() -> AnalyzedQuery {
156        AnalyzedQuery {
157            name: "GetUser".to_string(),
158            command: QueryCommand::One,
159            sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
160            columns: vec![
161                AnalyzedColumn {
162                    name: "id".to_string(),
163                    neutral_type: "int32".to_string(),
164                    nullable: false,
165                },
166                AnalyzedColumn {
167                    name: "name".to_string(),
168                    neutral_type: "string".to_string(),
169                    nullable: true,
170                },
171            ],
172            params: vec![AnalyzedParam {
173                name: "id".to_string(),
174                neutral_type: "int32".to_string(),
175                nullable: false,
176                position: 1,
177            }],
178            deprecated: None,
179            source_table: Some("users".to_string()),
180            composites: vec![],
181            enums: vec![],
182            optional_params: vec![],
183            group_by: None,
184            custom: vec![],
185        }
186    }
187
188    fn py_lang_type(neutral: &str, nullable: bool) -> String {
189        let base = match neutral {
190            "int32" | "int64" | "int16" => "int",
191            "string" => "str",
192            "bool" => "bool",
193            _ => "Any",
194        };
195        if nullable {
196            format!("{base} | None")
197        } else {
198            base.to_string()
199        }
200    }
201
202    #[test]
203    fn carries_scythe_module_and_fn() {
204        let entry = build_sidecar_entry(
205            &fake_query(),
206            &BTreeMap::new(),
207            "queries",
208            "get_user",
209            true,
210            py_lang_type,
211        );
212        assert_eq!(entry.scythe_module, "queries");
213        assert_eq!(entry.scythe_fn, "get_user");
214        assert!(entry.is_async);
215    }
216
217    #[test]
218    fn binds_params_from_map() {
219        let mut bindings = BTreeMap::new();
220        bindings.insert("id".to_string(), HttpParamBinding::Path);
221        let entry = build_sidecar_entry(&fake_query(), &bindings, "queries", "get_user", true, py_lang_type);
222        assert_eq!(entry.params.len(), 1);
223        assert_eq!(entry.params[0].name, "id");
224        assert_eq!(entry.params[0].source, HttpParamBinding::Path);
225        assert_eq!(entry.params[0].lang_type, "int");
226    }
227
228    #[test]
229    fn unbound_params_default_to_body() {
230        let entry = build_sidecar_entry(
231            &fake_query(),
232            &BTreeMap::new(),
233            "queries",
234            "get_user",
235            true,
236            py_lang_type,
237        );
238        assert_eq!(entry.params[0].source, HttpParamBinding::Body);
239    }
240
241    #[test]
242    fn return_type_lists_columns_for_one_command() {
243        let entry = build_sidecar_entry(
244            &fake_query(),
245            &BTreeMap::new(),
246            "queries",
247            "get_user",
248            true,
249            py_lang_type,
250        );
251        assert_eq!(entry.return_lang_type, "int, str | None");
252    }
253
254    #[test]
255    fn return_type_is_void_for_exec() {
256        let mut q = fake_query();
257        q.command = QueryCommand::Exec;
258        let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
259        assert_eq!(entry.return_lang_type, "void");
260    }
261
262    #[test]
263    fn return_type_is_rows_for_exec_rows() {
264        let mut q = fake_query();
265        q.command = QueryCommand::ExecRows;
266        let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
267        assert_eq!(entry.return_lang_type, "rows");
268    }
269
270    #[test]
271    fn sidecar_insert_and_lookup() {
272        let mut sidecar = Sidecar::new();
273        let entry = build_sidecar_entry(
274            &fake_query(),
275            &BTreeMap::new(),
276            "queries",
277            "get_user",
278            true,
279            py_lang_type,
280        );
281        sidecar.insert("python", "GetUser", entry);
282        assert!(sidecar.entry_for("python", "GetUser").is_some());
283        assert!(sidecar.entry_for("typescript", "GetUser").is_none());
284    }
285
286    #[test]
287    fn sidecar_serializes_to_json() {
288        let mut sidecar = Sidecar::new();
289        let entry = build_sidecar_entry(
290            &fake_query(),
291            &BTreeMap::new(),
292            "queries",
293            "get_user",
294            true,
295            py_lang_type,
296        );
297        sidecar.insert("python", "GetUser", entry);
298        let json = serde_json::to_string(&sidecar).unwrap();
299        assert!(json.contains("\"by_language\""));
300        assert!(json.contains("\"python\""));
301        assert!(json.contains("\"GetUser\""));
302    }
303}