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 `raise NotImplementedError("TODO")` 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            // Compose a tuple-style string of the row's column types; the
130            // generator translates this into the language's row struct.
131            let cols: Vec<String> = query
132                .columns
133                .iter()
134                .map(|c| lang_type_for(&c.neutral_type, c.nullable))
135                .collect();
136            cols.join(", ")
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
145    use scythe_core::parser::QueryCommand;
146
147    fn fake_query() -> AnalyzedQuery {
148        AnalyzedQuery {
149            name: "GetUser".to_string(),
150            command: QueryCommand::One,
151            sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
152            columns: vec![
153                AnalyzedColumn {
154                    name: "id".to_string(),
155                    neutral_type: "int32".to_string(),
156                    nullable: false,
157                },
158                AnalyzedColumn {
159                    name: "name".to_string(),
160                    neutral_type: "string".to_string(),
161                    nullable: true,
162                },
163            ],
164            params: vec![AnalyzedParam {
165                name: "id".to_string(),
166                neutral_type: "int32".to_string(),
167                nullable: false,
168                position: 1,
169            }],
170            deprecated: None,
171            source_table: Some("users".to_string()),
172            composites: vec![],
173            enums: vec![],
174            optional_params: vec![],
175            group_by: None,
176            custom: vec![],
177        }
178    }
179
180    fn py_lang_type(neutral: &str, nullable: bool) -> String {
181        let base = match neutral {
182            "int32" | "int64" | "int16" => "int",
183            "string" => "str",
184            "bool" => "bool",
185            _ => "Any",
186        };
187        if nullable {
188            format!("{base} | None")
189        } else {
190            base.to_string()
191        }
192    }
193
194    #[test]
195    fn carries_scythe_module_and_fn() {
196        let entry = build_sidecar_entry(
197            &fake_query(),
198            &BTreeMap::new(),
199            "queries",
200            "get_user",
201            true,
202            py_lang_type,
203        );
204        assert_eq!(entry.scythe_module, "queries");
205        assert_eq!(entry.scythe_fn, "get_user");
206        assert!(entry.is_async);
207    }
208
209    #[test]
210    fn binds_params_from_map() {
211        let mut bindings = BTreeMap::new();
212        bindings.insert("id".to_string(), HttpParamBinding::Path);
213        let entry = build_sidecar_entry(&fake_query(), &bindings, "queries", "get_user", true, py_lang_type);
214        assert_eq!(entry.params.len(), 1);
215        assert_eq!(entry.params[0].name, "id");
216        assert_eq!(entry.params[0].source, HttpParamBinding::Path);
217        assert_eq!(entry.params[0].lang_type, "int");
218    }
219
220    #[test]
221    fn unbound_params_default_to_body() {
222        let entry = build_sidecar_entry(
223            &fake_query(),
224            &BTreeMap::new(),
225            "queries",
226            "get_user",
227            true,
228            py_lang_type,
229        );
230        assert_eq!(entry.params[0].source, HttpParamBinding::Body);
231    }
232
233    #[test]
234    fn return_type_lists_columns_for_one_command() {
235        let entry = build_sidecar_entry(
236            &fake_query(),
237            &BTreeMap::new(),
238            "queries",
239            "get_user",
240            true,
241            py_lang_type,
242        );
243        assert_eq!(entry.return_lang_type, "int, str | None");
244    }
245
246    #[test]
247    fn return_type_is_void_for_exec() {
248        let mut q = fake_query();
249        q.command = QueryCommand::Exec;
250        let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
251        assert_eq!(entry.return_lang_type, "void");
252    }
253
254    #[test]
255    fn return_type_is_rows_for_exec_rows() {
256        let mut q = fake_query();
257        q.command = QueryCommand::ExecRows;
258        let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
259        assert_eq!(entry.return_lang_type, "rows");
260    }
261
262    #[test]
263    fn sidecar_insert_and_lookup() {
264        let mut sidecar = Sidecar::new();
265        let entry = build_sidecar_entry(
266            &fake_query(),
267            &BTreeMap::new(),
268            "queries",
269            "get_user",
270            true,
271            py_lang_type,
272        );
273        sidecar.insert("python", "GetUser", entry);
274        assert!(sidecar.entry_for("python", "GetUser").is_some());
275        assert!(sidecar.entry_for("typescript", "GetUser").is_none());
276    }
277
278    #[test]
279    fn sidecar_serializes_to_json() {
280        let mut sidecar = Sidecar::new();
281        let entry = build_sidecar_entry(
282            &fake_query(),
283            &BTreeMap::new(),
284            "queries",
285            "get_user",
286            true,
287            py_lang_type,
288        );
289        sidecar.insert("python", "GetUser", entry);
290        let json = serde_json::to_string(&sidecar).unwrap();
291        assert!(json.contains("\"by_language\""));
292        assert!(json.contains("\"python\""));
293        assert!(json.contains("\"GetUser\""));
294    }
295}