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
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}