Skip to main content

scythe_codegen/backends/
php_pdo.rs

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
48/// Rewrite $1, $2, ... to :p1, :p2, ...
49fn rewrite_params(sql: &str) -> String {
50    let mut result = sql.to_string();
51    // Replace from highest number down to avoid $1 matching inside $10
52    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
60/// Map a neutral type to a PHP cast expression.
61fn 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\n// Auto-generated by scythe. Do not edit.\n"
86            .to_string()
87    }
88
89    fn generate_row_struct(
90        &self,
91        query_name: &str,
92        columns: &[ResolvedColumn],
93    ) -> Result<String, ScytheError> {
94        let struct_name = row_struct_name(query_name, &self.manifest.naming);
95        let mut out = String::new();
96
97        // Readonly class with constructor
98        let _ = writeln!(out, "readonly class {} {{", struct_name);
99        let _ = writeln!(out, "    public function __construct(");
100        for c in columns.iter() {
101            let sep = ",";
102            let _ = writeln!(
103                out,
104                "        public {} ${}{}",
105                c.full_type, c.field_name, sep
106            );
107        }
108        let _ = writeln!(out, "    ) {{}}");
109        let _ = writeln!(out);
110
111        // fromRow factory method
112        let _ = writeln!(
113            out,
114            "    public static function fromRow(array $row): self {{"
115        );
116        let _ = writeln!(out, "        return new self(");
117        for c in columns.iter() {
118            let sep = ",";
119            let is_enum = c.neutral_type.starts_with("enum::");
120            let is_datetime = matches!(
121                c.neutral_type.as_str(),
122                "date" | "time" | "time_tz" | "datetime" | "datetime_tz"
123            );
124            if is_enum {
125                // Enum columns: convert DB string to PHP backed enum via ::from()
126                let enum_type = &c.lang_type;
127                if c.nullable {
128                    let _ = writeln!(
129                        out,
130                        "            {}: $row['{}'] !== null ? {}::from($row['{}']) : null{}",
131                        c.field_name, c.name, enum_type, c.name, sep
132                    );
133                } else {
134                    let _ = writeln!(
135                        out,
136                        "            {}: {}::from($row['{}']){}",
137                        c.field_name, enum_type, c.name, sep
138                    );
139                }
140            } else if is_datetime {
141                // DateTime columns: PDO returns strings, wrap in DateTimeImmutable
142                if c.nullable {
143                    let _ = writeln!(
144                        out,
145                        "            {}: $row['{}'] !== null ? new \\DateTimeImmutable($row['{}']) : null{}",
146                        c.field_name, c.name, c.name, sep
147                    );
148                } else {
149                    let _ = writeln!(
150                        out,
151                        "            {}: new \\DateTimeImmutable($row['{}']){}",
152                        c.field_name, c.name, sep
153                    );
154                }
155            } else {
156                let cast = php_cast(&c.neutral_type);
157                if c.nullable {
158                    let _ = writeln!(
159                        out,
160                        "            {}: $row['{}'] !== null ? {}{} : null{}",
161                        c.field_name,
162                        c.name,
163                        cast,
164                        format_args!("$row['{}']", c.name),
165                        sep
166                    );
167                } else {
168                    let _ = writeln!(
169                        out,
170                        "            {}: {}$row['{}']{}",
171                        c.field_name, cast, c.name, sep
172                    );
173                }
174            }
175        }
176        let _ = writeln!(out, "        );");
177        let _ = writeln!(out, "    }}");
178        let _ = write!(out, "}}");
179        Ok(out)
180    }
181
182    fn generate_model_struct(
183        &self,
184        table_name: &str,
185        columns: &[ResolvedColumn],
186    ) -> Result<String, ScytheError> {
187        let name = to_pascal_case(table_name);
188        self.generate_row_struct(&name, columns)
189    }
190
191    fn generate_query_fn(
192        &self,
193        analyzed: &AnalyzedQuery,
194        struct_name: &str,
195        _columns: &[ResolvedColumn],
196        params: &[ResolvedParam],
197    ) -> Result<String, ScytheError> {
198        let func_name = fn_name(&analyzed.name, &self.manifest.naming);
199        let sql = rewrite_params(&super::clean_sql_oneline(&analyzed.sql));
200        let mut out = String::new();
201
202        // Build PHP parameter list
203        let param_list = params
204            .iter()
205            .map(|p| format!("{} ${}", p.full_type, p.field_name))
206            .collect::<Vec<_>>()
207            .join(", ");
208        let sep = if param_list.is_empty() { "" } else { ", " };
209
210        // Return type depends on command
211        let return_type = match &analyzed.command {
212            QueryCommand::One => format!("?{}", struct_name),
213            QueryCommand::Many | QueryCommand::Batch => "array".to_string(),
214            QueryCommand::Exec => "void".to_string(),
215            QueryCommand::ExecResult | QueryCommand::ExecRows => "int".to_string(),
216        };
217
218        let _ = writeln!(
219            out,
220            "function {}(PDO $pdo{}{}): {} {{",
221            func_name, sep, param_list, return_type
222        );
223
224        // Prepare statement
225        let _ = writeln!(out, "    $stmt = $pdo->prepare(\"{}\");", sql);
226
227        // Build execute params
228        // If the SQL contains `?` placeholders (MySQL/SQLite), use positional array.
229        // If it contains `:pN` placeholders (PostgreSQL), use named array.
230        if params.is_empty() {
231            let _ = writeln!(out, "    $stmt->execute();");
232        } else {
233            let use_positional = sql.contains('?');
234            let bindings = params
235                .iter()
236                .enumerate()
237                .map(|(i, p)| {
238                    let value = if p.neutral_type.starts_with("enum::") {
239                        format!("${}->value", p.field_name)
240                    } else {
241                        format!("${}", p.field_name)
242                    };
243                    if use_positional {
244                        value
245                    } else {
246                        format!("\"p{}\" => {}", i + 1, value)
247                    }
248                })
249                .collect::<Vec<_>>()
250                .join(", ");
251            let _ = writeln!(out, "    $stmt->execute([{}]);", bindings);
252        }
253
254        match &analyzed.command {
255            QueryCommand::One => {
256                let _ = writeln!(out, "    $row = $stmt->fetch(PDO::FETCH_ASSOC);");
257                let _ = writeln!(
258                    out,
259                    "    return $row ? {}::fromRow($row) : null;",
260                    struct_name
261                );
262            }
263            QueryCommand::Many | QueryCommand::Batch => {
264                let _ = writeln!(out, "    $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);");
265                let _ = writeln!(
266                    out,
267                    "    return array_map([{}::class, 'fromRow'], $rows);",
268                    struct_name
269                );
270            }
271            QueryCommand::Exec => {
272                // nothing else needed
273            }
274            QueryCommand::ExecResult | QueryCommand::ExecRows => {
275                let _ = writeln!(out, "    return $stmt->rowCount();");
276            }
277        }
278
279        let _ = write!(out, "}}");
280        Ok(out)
281    }
282
283    fn generate_enum_def(&self, enum_info: &EnumInfo) -> Result<String, ScytheError> {
284        let type_name = enum_type_name(&enum_info.sql_name, &self.manifest.naming);
285        let mut out = String::new();
286        let _ = writeln!(out, "enum {}: string {{", type_name);
287        for value in &enum_info.values {
288            let variant = enum_variant_name(value, &self.manifest.naming);
289            let _ = writeln!(out, "    case {} = \"{}\";", variant, value);
290        }
291        let _ = write!(out, "}}");
292        Ok(out)
293    }
294
295    fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
296        let name = to_pascal_case(&composite.sql_name);
297        let mut out = String::new();
298        let _ = writeln!(out, "readonly class {} {{", name);
299        let _ = writeln!(out, "    public function __construct(");
300        if composite.fields.is_empty() {
301            // empty constructor
302        } else {
303            for field in &composite.fields {
304                let _ = writeln!(out, "        public mixed ${},", field.name);
305            }
306        }
307        let _ = writeln!(out, "    ) {{}}");
308        let _ = write!(out, "}}");
309        Ok(out)
310    }
311}