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