packages/ztd-query-sqlite/src/Schema/Create/TableDefinitionBuilder.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Platform\Sqlite\Schema\Create;
6
7use ZtdQuery\Platform\Sqlite\Schema\SqliteColumnTypeMapper;
8use ZtdQuery\Schema\ColumnDeclaration;
9use ZtdQuery\Schema\Key\IdentityGenerationStrategy;
10use ZtdQuery\Schema\TableDefinition;
11
12/**
13 * Accumulates column and key declarations in their original CREATE TABLE order.
14 *
15 * @visibility ZtdQuery\Platform\Sqlite
16 */
17final class TableDefinitionBuilder
18{
19    /**
20     * @var list<string>
21     */
22    private array $columns = [];
23
24    /**
25     * @var array<string, string>
26     */
27    private array $columnTypes = [];
28
29    /**
30     * @var array<string, string>
31     */
32    private array $primaryKeyMap = [];
33
34    /**
35     * @var list<string>
36     */
37    private array $notNullColumns = [];
38
39    /**
40     * @var array<string, list<string>>
41     */
42    private array $uniqueConstraints = [];
43
44    /**
45     * @var array<string, string>
46     */
47    private array $columnDefaults = [];
48
49    /**
50     * @var array<string, string>
51     */
52    private array $generatedExpressions = [];
53
54    private int $uniqueIndex = 0;
55
56    /**
57     * Adds one column or table constraint, ignoring unsupported table constraints.
58     */
59    public function addDefinition(string $definition): void
60    {
61        $definition = trim($definition);
62        if ($definition === '') {
63            return;
64        }
65        $parser = new ColumnDefinitionParser();
66        $keyword = $parser->leadingKeyword($definition);
67        if (in_array($keyword, ['PRIMARY', 'UNIQUE', 'CONSTRAINT', 'FOREIGN', 'CHECK'], true)) {
68            $this->addConstraint($definition);
69            return;
70        }
71        $column = $parser->parseColumnDefinition($definition);
72        if ($column !== null) {
73            $this->addColumn($column);
74        }
75    }
76
77    /**
78     * Records table-level PRIMARY KEY and UNIQUE declarations.
79     */
80    public function addConstraint(string $definition): void
81    {
82        $prefix = '(?:CONSTRAINT\s+(?:"(?:[^"]|"")*"|`(?:[^`]|``)*`|[^\s]+)\s+)?';
83        if (preg_match('/^' . $prefix . 'PRIMARY\s+KEY\s*\(([^)]+)\)/i', $definition, $matches) === 1) {
84            foreach ((new ColumnDefinitionParser())->parseColumnNameList($matches[1]) as $column) {
85                $this->primaryKeyMap[$column] = $column;
86            }
87        }
88        if (preg_match('/^' . $prefix . 'UNIQUE\s*\(([^)]+)\)/i', $definition, $matches) === 1) {
89            $columns = (new ColumnDefinitionParser())->parseColumnNameList($matches[1]);
90            if ($columns !== []) {
91                $this->uniqueConstraints['unique_' . $this->uniqueIndex++] = $columns;
92            }
93        }
94    }
95
96    /**
97     * @param array{name: string, type: string|null, notNull: bool, primaryKey: bool, unique: bool, default: string|null, generatedExpression: string|null} $colInfo
98     */
99    public function addColumn(array $colInfo): void
100    {
101        $this->columns[] = $colInfo['name'];
102
103        if ($colInfo['type'] !== null) {
104            $this->columnTypes[$colInfo['name']] = $colInfo['type'];
105        }
106
107        if ($colInfo['notNull']) {
108            $this->notNullColumns[] = $colInfo['name'];
109        }
110
111        if ($colInfo['primaryKey']) {
112            $this->primaryKeyMap[$colInfo['name']] = $colInfo['name'];
113            if (!in_array($colInfo['name'], $this->notNullColumns, true)) {
114                $this->notNullColumns[] = $colInfo['name'];
115            }
116        }
117
118        if ($colInfo['unique']) {
119            $keyName = $colInfo['name'] . '_UNIQUE';
120            $this->uniqueConstraints[$keyName] = [$colInfo['name']];
121        }
122
123        if ($colInfo['default'] !== null) {
124            $this->columnDefaults[$colInfo['name']] = $colInfo['default'];
125        }
126        if ($colInfo['generatedExpression'] !== null) {
127            $this->generatedExpressions[$colInfo['name']] = $colInfo['generatedExpression'];
128        }
129    }
130
131    /**
132     * Validates referenced columns and produces a complete portable table definition.
133     */
134    public function build(string $sql): ?TableDefinition
135    {
136        if ($this->columns === []) {
137            return null;
138        }
139
140        foreach ($this->uniqueConstraints as $constraintColumns) {
141            foreach ($constraintColumns as $col) {
142                if (!in_array($col, $this->columns, true)) {
143                    return null;
144                }
145            }
146        }
147
148        /**
149         * @var array<string, ColumnDeclaration> $typedColumns
150         */
151        $typedColumns = [];
152        foreach ($this->columnTypes as $colName => $nativeType) {
153            $typedColumns[$colName] = (new SqliteColumnTypeMapper())->map($nativeType);
154        }
155
156        $primaryKeys = array_values($this->primaryKeyMap);
157        $identityStrategies = [];
158        if (!TableBodyParser::hasWithoutRowid($sql) && count($primaryKeys) === 1) {
159            $identityColumn = $primaryKeys[0];
160            if (($this->columnTypes[$identityColumn] ?? null) === 'INTEGER') {
161                $identityStrategies[$identityColumn] = IdentityGenerationStrategy::MaxValue;
162            }
163        }
164
165        return new TableDefinition(
166            $this->columns,
167            $this->columnTypes,
168            $primaryKeys,
169            array_values(array_unique($this->notNullColumns)),
170            $this->uniqueConstraints,
171            $typedColumns,
172            $this->columnDefaults,
173            $identityStrategies,
174            $this->generatedExpressions,
175            (new \ZtdQuery\Platform\Sqlite\Schema\Key\SqliteForeignKeyDefinitionParser())->parseCreateTable($sql),
176        );
177    }
178}
179