1#![allow(
2 clippy::missing_errors_doc,
3 clippy::missing_panics_doc,
4 clippy::must_use_candidate,
5 clippy::doc_markdown,
6 clippy::too_long_first_doc_paragraph,
7 clippy::module_name_repetitions
8)]
9use std::collections::BTreeMap;
19
20use scythe_core::analyzer::AnalyzedQuery;
21use scythe_core::parser::QueryCommand;
22use serde::{Deserialize, Serialize};
23
24use super::annotations::HttpParamBinding;
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct Sidecar {
29 pub by_language: BTreeMap<String, BTreeMap<String, SidecarEntry>>,
30}
31
32impl Sidecar {
33 pub fn new() -> Self {
34 Self::default()
35 }
36
37 pub fn insert(&mut self, language: &str, operation_id: &str, entry: SidecarEntry) {
39 self.by_language
40 .entry(language.to_string())
41 .or_default()
42 .insert(operation_id.to_string(), entry);
43 }
44
45 pub fn entry_for<'a>(&'a self, language: &str, operation_id: &str) -> Option<&'a SidecarEntry> {
46 self.by_language.get(language).and_then(|m| m.get(operation_id))
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SidecarEntry {
53 pub scythe_fn: String,
56 pub scythe_module: String,
59 pub params: Vec<SidecarParam>,
63 pub return_lang_type: String,
66 pub is_async: bool,
69 pub command: QueryCommand,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct SidecarParam {
77 pub name: String,
80 pub lang_type: String,
83 pub source: HttpParamBinding,
85}
86
87pub fn build_sidecar_entry<F>(
94 query: &AnalyzedQuery,
95 bindings: &BTreeMap<String, HttpParamBinding>,
96 scythe_module: &str,
97 scythe_fn: &str,
98 is_async: bool,
99 lang_type_for: F,
100) -> SidecarEntry
101where
102 F: Fn(&str, bool) -> String,
103{
104 let params = query
105 .params
106 .iter()
107 .map(|p| {
108 let source = bindings.get(&p.name).copied().unwrap_or(HttpParamBinding::Body);
109 SidecarParam {
110 name: p.name.clone(),
111 lang_type: lang_type_for(&p.neutral_type, p.nullable),
112 source,
113 }
114 })
115 .collect();
116
117 let return_lang_type = compose_return_type(query, &lang_type_for);
118
119 SidecarEntry {
120 scythe_fn: scythe_fn.to_string(),
121 scythe_module: scythe_module.to_string(),
122 params,
123 return_lang_type,
124 is_async,
125 command: query.command.clone(),
126 }
127}
128
129fn compose_return_type<F>(query: &AnalyzedQuery, lang_type_for: &F) -> String
130where
131 F: Fn(&str, bool) -> String,
132{
133 match query.command {
134 QueryCommand::Exec => "void".to_string(),
135 QueryCommand::ExecRows => "rows".to_string(),
136 _ => {
137 let cols: Vec<String> = query
140 .columns
141 .iter()
142 .map(|c| lang_type_for(&c.neutral_type, c.nullable))
143 .collect();
144 cols.join(", ")
145 }
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery};
153 use scythe_core::parser::QueryCommand;
154
155 fn fake_query() -> AnalyzedQuery {
156 AnalyzedQuery {
157 name: "GetUser".to_string(),
158 command: QueryCommand::One,
159 sql: "SELECT id, name FROM users WHERE id = $1".to_string(),
160 columns: vec![
161 AnalyzedColumn {
162 name: "id".to_string(),
163 neutral_type: "int32".to_string(),
164 nullable: false,
165 },
166 AnalyzedColumn {
167 name: "name".to_string(),
168 neutral_type: "string".to_string(),
169 nullable: true,
170 },
171 ],
172 params: vec![AnalyzedParam {
173 name: "id".to_string(),
174 neutral_type: "int32".to_string(),
175 nullable: false,
176 position: 1,
177 }],
178 deprecated: None,
179 source_table: Some("users".to_string()),
180 composites: vec![],
181 enums: vec![],
182 optional_params: vec![],
183 group_by: None,
184 custom: vec![],
185 }
186 }
187
188 fn py_lang_type(neutral: &str, nullable: bool) -> String {
189 let base = match neutral {
190 "int32" | "int64" | "int16" => "int",
191 "string" => "str",
192 "bool" => "bool",
193 _ => "Any",
194 };
195 if nullable {
196 format!("{base} | None")
197 } else {
198 base.to_string()
199 }
200 }
201
202 #[test]
203 fn carries_scythe_module_and_fn() {
204 let entry = build_sidecar_entry(
205 &fake_query(),
206 &BTreeMap::new(),
207 "queries",
208 "get_user",
209 true,
210 py_lang_type,
211 );
212 assert_eq!(entry.scythe_module, "queries");
213 assert_eq!(entry.scythe_fn, "get_user");
214 assert!(entry.is_async);
215 }
216
217 #[test]
218 fn binds_params_from_map() {
219 let mut bindings = BTreeMap::new();
220 bindings.insert("id".to_string(), HttpParamBinding::Path);
221 let entry = build_sidecar_entry(&fake_query(), &bindings, "queries", "get_user", true, py_lang_type);
222 assert_eq!(entry.params.len(), 1);
223 assert_eq!(entry.params[0].name, "id");
224 assert_eq!(entry.params[0].source, HttpParamBinding::Path);
225 assert_eq!(entry.params[0].lang_type, "int");
226 }
227
228 #[test]
229 fn unbound_params_default_to_body() {
230 let entry = build_sidecar_entry(
231 &fake_query(),
232 &BTreeMap::new(),
233 "queries",
234 "get_user",
235 true,
236 py_lang_type,
237 );
238 assert_eq!(entry.params[0].source, HttpParamBinding::Body);
239 }
240
241 #[test]
242 fn return_type_lists_columns_for_one_command() {
243 let entry = build_sidecar_entry(
244 &fake_query(),
245 &BTreeMap::new(),
246 "queries",
247 "get_user",
248 true,
249 py_lang_type,
250 );
251 assert_eq!(entry.return_lang_type, "int, str | None");
252 }
253
254 #[test]
255 fn return_type_is_void_for_exec() {
256 let mut q = fake_query();
257 q.command = QueryCommand::Exec;
258 let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
259 assert_eq!(entry.return_lang_type, "void");
260 }
261
262 #[test]
263 fn return_type_is_rows_for_exec_rows() {
264 let mut q = fake_query();
265 q.command = QueryCommand::ExecRows;
266 let entry = build_sidecar_entry(&q, &BTreeMap::new(), "queries", "f", true, py_lang_type);
267 assert_eq!(entry.return_lang_type, "rows");
268 }
269
270 #[test]
271 fn sidecar_insert_and_lookup() {
272 let mut sidecar = Sidecar::new();
273 let entry = build_sidecar_entry(
274 &fake_query(),
275 &BTreeMap::new(),
276 "queries",
277 "get_user",
278 true,
279 py_lang_type,
280 );
281 sidecar.insert("python", "GetUser", entry);
282 assert!(sidecar.entry_for("python", "GetUser").is_some());
283 assert!(sidecar.entry_for("typescript", "GetUser").is_none());
284 }
285
286 #[test]
287 fn sidecar_serializes_to_json() {
288 let mut sidecar = Sidecar::new();
289 let entry = build_sidecar_entry(
290 &fake_query(),
291 &BTreeMap::new(),
292 "queries",
293 "get_user",
294 true,
295 py_lang_type,
296 );
297 sidecar.insert("python", "GetUser", entry);
298 let json = serde_json::to_string(&sidecar).unwrap();
299 assert!(json.contains("\"by_language\""));
300 assert!(json.contains("\"python\""));
301 assert!(json.contains("\"GetUser\""));
302 }
303}