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