1use 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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct SidecarEntry {
45 pub scythe_fn: String,
48 pub scythe_module: String,
51 pub params: Vec<SidecarParam>,
55 pub return_lang_type: String,
58 pub is_async: bool,
61 pub command: QueryCommand,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SidecarParam {
69 pub name: String,
72 pub lang_type: String,
75 pub source: HttpParamBinding,
77}
78
79pub 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
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}