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