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