Skip to main content

spikard_codegen/sql/
sidecar.rs

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