1use std::fmt::Write;
2use std::path::Path;
3
4use scythe_backend::manifest::{BackendManifest, load_manifest};
5use scythe_backend::naming::{
6 enum_type_name, enum_variant_name, fn_name, row_struct_name, to_pascal_case,
7};
8
9use scythe_core::analyzer::{AnalyzedQuery, CompositeInfo, EnumInfo};
10use scythe_core::errors::{ErrorCode, ScytheError};
11use scythe_core::parser::QueryCommand;
12
13use crate::backend_trait::{CodegenBackend, ResolvedColumn, ResolvedParam};
14
15const DEFAULT_MANIFEST_PG: &str = include_str!("../../manifests/php-pdo.toml");
16const DEFAULT_MANIFEST_MYSQL: &str = include_str!("../../manifests/php-pdo.mysql.toml");
17const DEFAULT_MANIFEST_SQLITE: &str = include_str!("../../manifests/php-pdo.sqlite.toml");
18
19pub struct PhpPdoBackend {
20 manifest: BackendManifest,
21}
22
23impl PhpPdoBackend {
24 pub fn new(engine: &str) -> Result<Self, ScytheError> {
25 let default_toml = match engine {
26 "postgresql" | "postgres" | "pg" => DEFAULT_MANIFEST_PG,
27 "mysql" | "mariadb" => DEFAULT_MANIFEST_MYSQL,
28 "sqlite" | "sqlite3" => DEFAULT_MANIFEST_SQLITE,
29 _ => {
30 return Err(ScytheError::new(
31 ErrorCode::InternalError,
32 format!("unsupported engine '{}' for php-pdo backend", engine),
33 ));
34 }
35 };
36 let manifest_path = Path::new("backends/php-pdo/manifest.toml");
37 let manifest = if manifest_path.exists() {
38 load_manifest(manifest_path)
39 .map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))?
40 } else {
41 toml::from_str(default_toml)
42 .map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))?
43 };
44 Ok(Self { manifest })
45 }
46}
47
48fn rewrite_params(sql: &str) -> String {
50 let mut result = sql.to_string();
51 for i in (1..=99).rev() {
53 let from = format!("${}", i);
54 let to = format!(":p{}", i);
55 result = result.replace(&from, &to);
56 }
57 result
58}
59
60fn php_cast(neutral_type: &str) -> &'static str {
62 match neutral_type {
63 "int16" | "int32" | "int64" => "(int) ",
64 "float32" | "float64" => "(float) ",
65 "bool" => "(bool) ",
66 "string" | "json" | "inet" | "interval" | "uuid" | "decimal" | "bytes" => "(string) ",
67 _ => "",
68 }
69}
70
71impl CodegenBackend for PhpPdoBackend {
72 fn name(&self) -> &str {
73 "php-pdo"
74 }
75
76 fn manifest(&self) -> &scythe_backend::manifest::BackendManifest {
77 &self.manifest
78 }
79
80 fn supported_engines(&self) -> &[&str] {
81 &["postgresql", "mysql", "sqlite"]
82 }
83
84 fn file_header(&self) -> String {
85 "<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Generated;\n\n// Auto-generated by scythe. Do not edit.\n"
86 .to_string()
87 }
88
89 fn query_class_header(&self) -> String {
90 "final class Queries {".to_string()
91 }
92
93 fn file_footer(&self) -> String {
94 "}".to_string()
95 }
96
97 fn generate_row_struct(
98 &self,
99 query_name: &str,
100 columns: &[ResolvedColumn],
101 ) -> Result<String, ScytheError> {
102 let struct_name = row_struct_name(query_name, &self.manifest.naming);
103 let mut out = String::new();
104
105 let _ = writeln!(out, "readonly class {} {{", struct_name);
107 let _ = writeln!(out, " public function __construct(");
108 for c in columns.iter() {
109 let sep = ",";
110 let _ = writeln!(
111 out,
112 " public {} ${}{}",
113 c.full_type, c.field_name, sep
114 );
115 }
116 let _ = writeln!(out, " ) {{}}");
117 let _ = writeln!(out);
118
119 let _ = writeln!(
121 out,
122 " public static function fromRow(array $row): self {{"
123 );
124 let _ = writeln!(out, " return new self(");
125 for c in columns.iter() {
126 let sep = ",";
127 let is_enum = c.neutral_type.starts_with("enum::");
128 let is_datetime = matches!(
129 c.neutral_type.as_str(),
130 "date" | "time" | "time_tz" | "datetime" | "datetime_tz"
131 );
132 if is_enum {
133 let enum_type = &c.lang_type;
135 if c.nullable {
136 let _ = writeln!(
137 out,
138 " {}: $row['{}'] !== null ? {}::from($row['{}']) : null{}",
139 c.field_name, c.name, enum_type, c.name, sep
140 );
141 } else {
142 let _ = writeln!(
143 out,
144 " {}: {}::from($row['{}']){}",
145 c.field_name, enum_type, c.name, sep
146 );
147 }
148 } else if is_datetime {
149 if c.nullable {
151 let _ = writeln!(
152 out,
153 " {}: $row['{}'] !== null ? new \\DateTimeImmutable($row['{}']) : null{}",
154 c.field_name, c.name, c.name, sep
155 );
156 } else {
157 let _ = writeln!(
158 out,
159 " {}: new \\DateTimeImmutable($row['{}']){}",
160 c.field_name, c.name, sep
161 );
162 }
163 } else {
164 let cast = php_cast(&c.neutral_type);
165 if c.nullable {
166 let _ = writeln!(
167 out,
168 " {}: $row['{}'] !== null ? {}{} : null{}",
169 c.field_name,
170 c.name,
171 cast,
172 format_args!("$row['{}']", c.name),
173 sep
174 );
175 } else {
176 let _ = writeln!(
177 out,
178 " {}: {}$row['{}']{}",
179 c.field_name, cast, c.name, sep
180 );
181 }
182 }
183 }
184 let _ = writeln!(out, " );");
185 let _ = writeln!(out, " }}");
186 let _ = write!(out, "}}");
187 Ok(out)
188 }
189
190 fn generate_model_struct(
191 &self,
192 table_name: &str,
193 columns: &[ResolvedColumn],
194 ) -> Result<String, ScytheError> {
195 let name = to_pascal_case(table_name);
196 self.generate_row_struct(&name, columns)
197 }
198
199 fn generate_query_fn(
200 &self,
201 analyzed: &AnalyzedQuery,
202 struct_name: &str,
203 _columns: &[ResolvedColumn],
204 params: &[ResolvedParam],
205 ) -> Result<String, ScytheError> {
206 let func_name = fn_name(&analyzed.name, &self.manifest.naming);
207 let sql = rewrite_params(&super::clean_sql_oneline_with_optional(
208 &analyzed.sql,
209 &analyzed.optional_params,
210 &analyzed.params,
211 ));
212 let mut out = String::new();
213
214 if matches!(analyzed.command, QueryCommand::Batch) {
216 let batch_fn_name = format!("{}Batch", func_name);
217 let _ = writeln!(out, " /**");
219 let _ = writeln!(out, " * @param \\PDO $pdo");
220 let _ = writeln!(out, " * @param array<int, array<int, mixed>> $items");
221 let _ = writeln!(out, " * @return void");
222 let _ = writeln!(out, " */");
223 let _ = writeln!(
224 out,
225 " public static function {}(\\PDO $pdo, array $items): void {{",
226 batch_fn_name
227 );
228 let _ = writeln!(out, " $stmt = $pdo->prepare(\"{}\");", sql);
229 let _ = writeln!(out, " $pdo->beginTransaction();");
230 let _ = writeln!(out, " try {{");
231 let _ = writeln!(out, " foreach ($items as $item) {{");
232 if params.is_empty() {
233 let _ = writeln!(out, " $stmt->execute();");
234 } else {
235 let use_positional = sql.contains('?');
236 if use_positional {
237 let _ = writeln!(out, " $stmt->execute($item);");
238 } else {
239 let bindings = params
241 .iter()
242 .enumerate()
243 .map(|(i, _p)| format!("\"p{}\" => $item[{}]", i + 1, i))
244 .collect::<Vec<_>>()
245 .join(", ");
246 let _ = writeln!(out, " $stmt->execute([{}]);", bindings);
247 }
248 }
249 let _ = writeln!(out, " }}");
250 let _ = writeln!(out, " $pdo->commit();");
251 let _ = writeln!(out, " }} catch (\\Throwable $e) {{");
252 let _ = writeln!(out, " $pdo->rollBack();");
253 let _ = writeln!(out, " throw $e;");
254 let _ = writeln!(out, " }}");
255 let _ = write!(out, " }}");
256 return Ok(out);
257 }
258
259 let param_list = params
261 .iter()
262 .map(|p| format!("{} ${}", p.full_type, p.field_name))
263 .collect::<Vec<_>>()
264 .join(", ");
265 let sep = if param_list.is_empty() { "" } else { ", " };
266
267 let return_type = match &analyzed.command {
269 QueryCommand::One => format!("?{}", struct_name),
270 QueryCommand::Many => "\\Generator".to_string(),
271 QueryCommand::Exec => "void".to_string(),
272 QueryCommand::ExecResult | QueryCommand::ExecRows => "int".to_string(),
273 QueryCommand::Batch | QueryCommand::Grouped => unreachable!(),
274 };
275
276 let _ = writeln!(out, " /**");
278 let _ = writeln!(out, " * @param \\PDO $pdo");
279 for p in params {
280 let _ = writeln!(out, " * @param {} ${}", p.full_type, p.field_name);
281 }
282 match &analyzed.command {
283 QueryCommand::One => {
284 let _ = writeln!(out, " * @return {}|null", struct_name);
285 }
286 QueryCommand::Many => {
287 let _ = writeln!(
288 out,
289 " * @return \\Generator<int, {}, mixed, void>",
290 struct_name
291 );
292 }
293 QueryCommand::Exec => {
294 let _ = writeln!(out, " * @return void");
295 }
296 QueryCommand::ExecResult | QueryCommand::ExecRows => {
297 let _ = writeln!(out, " * @return int");
298 }
299 QueryCommand::Batch | QueryCommand::Grouped => unreachable!(),
300 }
301 let _ = writeln!(out, " */");
302
303 let _ = writeln!(
304 out,
305 " public static function {}(\\PDO $pdo{}{}): {} {{",
306 func_name, sep, param_list, return_type
307 );
308
309 let _ = writeln!(out, " $stmt = $pdo->prepare(\"{}\");", sql);
311
312 if params.is_empty() {
316 let _ = writeln!(out, " $stmt->execute();");
317 } else {
318 let use_positional = sql.contains('?');
319 let bindings = params
320 .iter()
321 .enumerate()
322 .map(|(i, p)| {
323 let value = if p.neutral_type.starts_with("enum::") {
324 format!("${}->value", p.field_name)
325 } else {
326 format!("${}", p.field_name)
327 };
328 if use_positional {
329 value
330 } else {
331 format!("\"p{}\" => {}", i + 1, value)
332 }
333 })
334 .collect::<Vec<_>>()
335 .join(", ");
336 let _ = writeln!(out, " $stmt->execute([{}]);", bindings);
337 }
338
339 match &analyzed.command {
340 QueryCommand::One => {
341 let _ = writeln!(out, " $row = $stmt->fetch(\\PDO::FETCH_ASSOC);");
342 let _ = writeln!(
343 out,
344 " return $row ? {}::fromRow($row) : null;",
345 struct_name
346 );
347 }
348 QueryCommand::Many => {
349 let _ = writeln!(
350 out,
351 " while ($row = $stmt->fetch(\\PDO::FETCH_ASSOC)) {{"
352 );
353 let _ = writeln!(out, " yield {}::fromRow($row);", struct_name);
354 let _ = writeln!(out, " }}");
355 }
356 QueryCommand::Exec => {
357 }
359 QueryCommand::ExecResult | QueryCommand::ExecRows => {
360 let _ = writeln!(out, " return $stmt->rowCount();");
361 }
362 QueryCommand::Batch | QueryCommand::Grouped => unreachable!(),
363 }
364
365 let _ = write!(out, " }}");
366 Ok(out)
367 }
368
369 fn generate_enum_def(&self, enum_info: &EnumInfo) -> Result<String, ScytheError> {
370 let type_name = enum_type_name(&enum_info.sql_name, &self.manifest.naming);
371 let mut out = String::new();
372 let _ = writeln!(out, "enum {}: string {{", type_name);
373 for value in &enum_info.values {
374 let variant = enum_variant_name(value, &self.manifest.naming);
375 let _ = writeln!(out, " case {} = \"{}\";", variant, value);
376 }
377 let _ = write!(out, "}}");
378 Ok(out)
379 }
380
381 fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
382 let name = to_pascal_case(&composite.sql_name);
383 let mut out = String::new();
384 let _ = writeln!(out, "readonly class {} {{", name);
385 let _ = writeln!(out, " public function __construct(");
386 if composite.fields.is_empty() {
387 } else {
389 for field in &composite.fields {
390 let _ = writeln!(out, " public mixed ${},", field.name);
391 }
392 }
393 let _ = writeln!(out, " ) {{}}");
394 let _ = write!(out, "}}");
395 Ok(out)
396 }
397}