packages/ztd-query-core/tests/Fake/FakeSchemaParser.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Fake;
6
7use ZtdQuery\Platform\SchemaParser;
8use ZtdQuery\Schema\ColumnDeclaration;
9use ZtdQuery\Schema\ColumnTypeFamily;
10use ZtdQuery\Schema\TableDefinition;
11
12/**
13 * Fake SchemaParser that parses simplified CREATE TABLE statements.
14 *
15 * Supports a subset of SQL DDL via regex parsing:
16 * CREATE TABLE table_name (col1 TYPE [NOT NULL] [PRIMARY KEY], ..., PRIMARY KEY(col1, ...))
17 */
18final class FakeSchemaParser implements SchemaParser
19{
20 /**
21 * Reads.
22 *
23 * @param string $createTableSql
24 * @return ?TableDefinition
25 */
26 public function parse(string $createTableSql): ?TableDefinition
27 {
28 $sql = trim($createTableSql);
29
30 if (preg_match('/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?\s*\((.+)\)\s*;?\s*$/is', $sql, $matches) !== 1) {
31 return null;
32 }
33
34 $body = $matches[2];
35
36 $columns = [];
37 $columnTypes = [];
38 $typedColumns = [];
39 $primaryKeys = [];
40 $notNullColumns = [];
41 $uniqueConstraints = [];
42
43 $parts = $this->splitColumns($body);
44
45 foreach ($parts as $part) {
46 $part = trim($part);
47
48 if (preg_match('/^\s*PRIMARY\s+KEY\s*\(([^)]+)\)/i', $part, $pkMatch) === 1) {
49 $pkCols = array_map(
50 static fn (string $c): string => trim($c, " \t\n\r\0\x0B`\"'"),
51 explode(',', $pkMatch[1])
52 );
53 $primaryKeys = array_merge($primaryKeys, $pkCols);
54 continue;
55 }
56
57 if (preg_match('/^\s*(?:CONSTRAINT\s+[`"\']?(\w+)[`"\']?\s+)?UNIQUE\s*(?:KEY\s*)?(?:[`"\']?\w+[`"\']?\s*)?\(([^)]+)\)/i', $part, $uqMatch) === 1) {
58 $constraintName = $uqMatch[1] !== '' ? $uqMatch[1] : 'unique_' . count($uniqueConstraints);
59 $uqCols = array_map(
60 static fn (string $c): string => trim($c, " \t\n\r\0\x0B`\"'"),
61 explode(',', $uqMatch[2])
62 );
63 $uniqueConstraints[$constraintName] = $uqCols;
64 continue;
65 }
66
67 if (preg_match('/^\s*(?:KEY|INDEX)\s/i', $part) === 1) {
68 continue;
69 }
70
71 if (preg_match('/^\s*[`"\']?(\w+)[`"\']?\s+(\w+(?:\([^)]*\))?)/i', $part, $colMatch) === 1) {
72 $colName = $colMatch[1];
73 $colType = strtoupper($colMatch[2]);
74
75 $columns[] = $colName;
76 $columnTypes[$colName] = $colType;
77 $typedColumns[$colName] = new ColumnDeclaration(
78 $this->mapTypeFamily($colType),
79 $colType
80 );
81
82 if (preg_match('/\bNOT\s+NULL\b/i', $part) === 1) {
83 $notNullColumns[] = $colName;
84 }
85
86 if (preg_match('/\bPRIMARY\s+KEY\b/i', $part) === 1) {
87 $primaryKeys[] = $colName;
88 if (!in_array($colName, $notNullColumns, true)) {
89 $notNullColumns[] = $colName;
90 }
91 }
92
93 if (preg_match('/\bUNIQUE\b/i', $part) === 1 && preg_match('/\bUNIQUE\s+KEY\b/i', $part) !== 1) {
94 $uniqueConstraints['unique_' . $colName] = [$colName];
95 }
96 }
97 }
98
99 if ($columns === []) {
100 return null;
101 }
102
103 return new TableDefinition(
104 $columns,
105 $columnTypes,
106 $primaryKeys,
107 $notNullColumns,
108 $uniqueConstraints,
109 $typedColumns,
110 );
111 }
112
113 /**
114 * Map type family.
115 *
116 * @param string $type
117 * @return ColumnTypeFamily
118 */
119 public function mapTypeFamily(string $type): ColumnTypeFamily
120 {
121 $base = preg_replace('/\(.*\)/', '', $type) ?? $type;
122 $base = strtoupper(trim($base));
123
124 return match (true) {
125 in_array($base, ['INT', 'INTEGER', 'BIGINT', 'SMALLINT', 'TINYINT', 'MEDIUMINT', 'SERIAL'], true) => ColumnTypeFamily::INTEGER,
126 in_array($base, ['FLOAT', 'REAL'], true) => ColumnTypeFamily::FLOAT,
127 in_array($base, ['DOUBLE'], true) => ColumnTypeFamily::DOUBLE,
128 in_array($base, ['DECIMAL', 'NUMERIC', 'DEC'], true) => ColumnTypeFamily::DECIMAL,
129 in_array($base, ['VARCHAR', 'CHAR', 'NVARCHAR', 'NCHAR'], true) => ColumnTypeFamily::STRING,
130 in_array($base, ['TEXT', 'LONGTEXT', 'MEDIUMTEXT', 'TINYTEXT', 'CLOB'], true) => ColumnTypeFamily::TEXT,
131 in_array($base, ['BOOLEAN', 'BOOL'], true) => ColumnTypeFamily::BOOLEAN,
132 $base === 'DATE' => ColumnTypeFamily::DATE,
133 $base === 'TIME' => ColumnTypeFamily::TIME,
134 $base === 'DATETIME' => ColumnTypeFamily::DATETIME,
135 $base === 'TIMESTAMP' => ColumnTypeFamily::TIMESTAMP,
136 in_array($base, ['BLOB', 'BINARY', 'VARBINARY', 'LONGBLOB', 'MEDIUMBLOB', 'TINYBLOB'], true) => ColumnTypeFamily::BINARY,
137 $base === 'JSON' => ColumnTypeFamily::JSON,
138 default => ColumnTypeFamily::UNKNOWN,
139 };
140 }
141
142 /**
143 * Split column definitions by commas, respecting parentheses nesting.
144 *
145 * @return array<int, string>
146 */
147 public function splitColumns(string $body): array
148 {
149 $parts = [];
150 $depth = 0;
151 $current = '';
152
153 for ($i = 0; $i < strlen($body); $i++) {
154 $ch = $body[$i];
155 if ($ch === '(') {
156 $depth++;
157 } elseif ($ch === ')') {
158 $depth--;
159 } elseif ($ch === ',' && $depth === 0) {
160 $parts[] = $current;
161 $current = '';
162 continue;
163 }
164 $current .= $ch;
165 }
166
167 if (trim($current) !== '') {
168 $parts[] = $current;
169 }
170
171 return $parts;
172 }
173}
174