Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.34% covered (success)
94.34%
50 / 53
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlTableSchemaHelper
94.23% covered (success)
94.23%
49 / 52
66.67% covered (warning)
66.67%
4 / 6
17.06
0.00% covered (danger)
0.00%
0 / 1
 getTableColumns
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 hasColumn
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resetCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchSqliteColumns
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 fetchMysqlColumns
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 hasColumnFallback
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Infrastructure\Schema;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12
13/**
14 * SqlTableSchemaHelper.
15 *
16 * Provides high-performance cached schema introspection for physical database tables.
17 *
18 * @package App\Core\Engine\Infrastructure\Schema
19 */
20final class SqlTableSchemaHelper
21{
22    /** @var array<string, array<string, array{name: string, type: string, is_nullable: bool, default: mixed}>> */
23    private static array $schemaCache = [];
24
25    /**
26     * Inspects and caches columns metadata for a physical database table.
27     *
28     * @param PDO $pdo Database connection.
29     * @param string $tableName Table name.
30     * @return array<string, array{name: string, type: string, is_nullable: bool, default: mixed}> Columns metadata.
31     */
32    public static function getTableColumns(PDO $pdo, string $tableName): array
33    {
34        $cacheKey = spl_object_id($pdo) . ':' . $tableName;
35        if (isset(self::$schemaCache[$cacheKey])) {
36            return self::$schemaCache[$cacheKey];
37        }
38
39        $columns = [];
40        try {
41            $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
42            if ($driver === 'sqlite') {
43                $columns = self::fetchSqliteColumns($pdo, $tableName);
44            } else {
45                $columns = self::fetchMysqlColumns($pdo, $tableName);
46            }
47        } catch (\Throwable) {
48            // Return empty array on missing table or unexpected error
49        }
50
51        self::$schemaCache[$cacheKey] = $columns;
52        return $columns;
53    }
54
55    /**
56     * Checks if a column exists in a given table.
57     *
58     * @param PDO $pdo Database connection.
59     * @param string $tableName Table name.
60     * @param string $columnName Column name to verify.
61     * @return bool True if column exists.
62     */
63    public static function hasColumn(PDO $pdo, string $tableName, string $columnName): bool
64    {
65        $cols = self::getTableColumns($pdo, $tableName);
66        if (isset($cols[$columnName])) {
67            return true;
68        }
69
70        if (empty($cols)) {
71            return self::hasColumnFallback($pdo, $tableName, $columnName);
72        }
73
74        return false;
75    }
76
77    /**
78     * Resets schema cache (for unit tests).
79     */
80    public static function resetCache(): void
81    {
82        self::$schemaCache = [];
83    }
84
85    /**
86     * Introspects columns for SQLite tables using PRAGMA table_info.
87     *
88     * @return array<string, array{name: string, type: string, is_nullable: bool, default: mixed}>
89     */
90    private static function fetchSqliteColumns(PDO $pdo, string $tableName): array
91    {
92        $columns = [];
93        $escaped = str_replace('`', '``', $tableName);
94        $stmt = $pdo->query("PRAGMA table_info(`{$escaped}`)");
95        if ($stmt !== false) {
96            /** @var array<int, array<string, mixed>> $rows */
97            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
98            foreach ($rows as $row) {
99                $fieldName = (string) ($row['name'] ?? '');
100                if ($fieldName === '') {
101                    continue;
102                }
103                $columns[$fieldName] = [
104                    'name'        => $fieldName,
105                    'type'        => (string) ($row['type'] ?? ''),
106                    'is_nullable' => ((int) ($row['notnull'] ?? 0)) === 0,
107                    'default'     => $row['dflt_value'] ?? null,
108                ];
109            }
110        }
111
112        return $columns;
113    }
114
115    /**
116     * Introspects columns for MySQL tables using SHOW COLUMNS.
117     *
118     * @return array<string, array{name: string, type: string, is_nullable: bool, default: mixed}>
119     */
120    private static function fetchMysqlColumns(PDO $pdo, string $tableName): array
121    {
122        $columns = [];
123        $escaped = str_replace('`', '``', $tableName);
124        $stmt = $pdo->query("SHOW COLUMNS FROM `{$escaped}`");
125        if ($stmt !== false) {
126            /** @var array<int, array{Field: string, Type: string, Null: string, Default: mixed}> $rows */
127            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
128            foreach ($rows as $row) {
129                $fieldName = (string) $row['Field'];
130                $columns[$fieldName] = [
131                    'name'        => $fieldName,
132                    'type'        => (string) $row['Type'],
133                    'is_nullable' => (string) $row['Null'] === 'YES',
134                    'default'     => $row['Default'],
135                ];
136            }
137        }
138
139        return $columns;
140    }
141
142    /**
143     * Fallback verification when table metadata cannot be introspected.
144     */
145    private static function hasColumnFallback(PDO $pdo, string $tableName, string $columnName): bool
146    {
147        try {
148            $stmt = $pdo->prepare("SELECT `{$columnName}` FROM `{$tableName}` LIMIT 0");
149            $stmt->execute();
150
151            return true;
152        } catch (\Throwable) {
153            return false;
154        }
155    }
156}