1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Schema;
6:
7: use Atk4\Data\Exception;
8: use Atk4\Data\Field;
9: use Atk4\Data\Field\SqlExpressionField;
10: use Atk4\Data\Model;
11: use Atk4\Data\Model\Join;
12: use Atk4\Data\Persistence;
13: use Atk4\Data\Persistence\Sql\Connection;
14: use Atk4\Data\Reference;
15: use Atk4\Data\Reference\HasMany;
16: use Atk4\Data\Reference\HasOne;
17: use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException;
18: use Doctrine\DBAL\Exception\TableNotFoundException;
19: use Doctrine\DBAL\Platforms\AbstractPlatform;
20: use Doctrine\DBAL\Platforms\MySQLPlatform;
21: use Doctrine\DBAL\Platforms\OraclePlatform;
22: use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
23: use Doctrine\DBAL\Platforms\SQLitePlatform;
24: use Doctrine\DBAL\Platforms\SQLServerPlatform;
25: use Doctrine\DBAL\Schema\AbstractAsset;
26: use Doctrine\DBAL\Schema\AbstractSchemaManager;
27: use Doctrine\DBAL\Schema\ForeignKeyConstraint;
28: use Doctrine\DBAL\Schema\Identifier;
29: use Doctrine\DBAL\Schema\Index;
30: use Doctrine\DBAL\Schema\Table;
31:
32: class Migrator
33: {
34: public const REF_TYPE_NONE = 0;
35: public const REF_TYPE_LINK = 1;
36: public const REF_TYPE_PRIMARY = 2;
37:
38: /** @var Connection */
39: private $_connection;
40:
41: /** @var Table */
42: public $table;
43:
44: /** @var array<int, string> */
45: private $createdTableNames = [];
46:
47: /**
48: * @param Connection|Persistence\Sql|Model $source
49: */
50: public function __construct(object $source)
51: {
52: if ($source instanceof Connection) {
53: $this->_connection = $source;
54: } elseif ($source instanceof Persistence\Sql) {
55: $this->_connection = $source->getConnection();
56: } elseif ($source instanceof Model && $source->getPersistence() instanceof Persistence\Sql) { // @phpstan-ignore-line
57: $this->_connection = $source->getPersistence()->getConnection();
58: } else {
59: throw (new Exception('Source must be SQL connection, persistence or initialized model'))
60: ->addMoreInfo('source', $source);
61: }
62:
63: if ($source instanceof Model) {
64: $this->setModel($source);
65: }
66: }
67:
68: public function getConnection(): Connection
69: {
70: return $this->_connection;
71: }
72:
73: protected function getDatabasePlatform(): AbstractPlatform
74: {
75: return $this->getConnection()->getDatabasePlatform();
76: }
77:
78: /**
79: * @phpstan-return AbstractSchemaManager<AbstractPlatform>
80: */
81: protected function createSchemaManager(): AbstractSchemaManager
82: {
83: return $this->getConnection()->createSchemaManager();
84: }
85:
86: /**
87: * Fix namespaced table name split for MSSQL/PostgreSQL.
88: *
89: * DBAL PR rejected: https://github.com/doctrine/dbal/pull/5494
90: *
91: * @template T of AbstractAsset
92: *
93: * @param T $abstractAsset
94: *
95: * @return T
96: */
97: protected function fixAbstractAssetName(AbstractAsset $abstractAsset, string $name): AbstractAsset
98: {
99: \Closure::bind(static function () use ($abstractAsset, $name) {
100: $abstractAsset->_quoted = true;
101: $lastDotPos = strrpos($name, '.');
102: if ($lastDotPos !== false) {
103: $abstractAsset->_namespace = substr($name, 0, $lastDotPos);
104: $abstractAsset->_name = substr($name, $lastDotPos + 1);
105: } else {
106: $abstractAsset->_namespace = null;
107: $abstractAsset->_name = $name;
108: }
109: }, null, AbstractAsset::class)();
110:
111: return $abstractAsset;
112: }
113:
114: public function table(string $tableName): self
115: {
116: $table = $this->fixAbstractAssetName(new Table('0.0'), $tableName);
117: if ($this->getDatabasePlatform() instanceof MySQLPlatform) {
118: $table->addOption('charset', 'utf8mb4');
119: }
120:
121: $this->table = $table;
122:
123: return $this;
124: }
125:
126: /**
127: * @return array<int, string>
128: */
129: public function getCreatedTableNames(): array
130: {
131: return $this->createdTableNames;
132: }
133:
134: public function create(): self
135: {
136: $this->createSchemaManager()->createTable($this->table);
137: $this->createdTableNames[] = $this->table->getName();
138:
139: return $this;
140: }
141:
142: public function drop(bool $dropForeignKeysFirst = false): self
143: {
144: $schemaManager = $this->createSchemaManager();
145:
146: if ($dropForeignKeysFirst) {
147: // TODO https://github.com/doctrine/dbal/issues/5488 implement all foreign keys fetch in one query
148: $foreignKeysByTableToDrop = [];
149: foreach ($schemaManager->listTableNames() as $tableName) {
150: $foreignKeys = $schemaManager->listTableForeignKeys($tableName);
151: foreach ($foreignKeys as $foreignKey) {
152: if ($foreignKey->getForeignTableName() === $this->stripDatabaseFromTableName($this->table->getName())) {
153: $foreignKeysByTableToDrop[$tableName][] = $foreignKey;
154: }
155: }
156: }
157: foreach ($foreignKeysByTableToDrop as $tableName => $foreignKeys) {
158: foreach ($foreignKeys as $foreignKey) {
159: $schemaManager->dropForeignKey($foreignKey, $this->getDatabasePlatform()->quoteIdentifier($tableName));
160: }
161: }
162: }
163:
164: $schemaManager->dropTable($this->table->getQuotedName($this->getDatabasePlatform()));
165:
166: $this->createdTableNames = array_diff($this->createdTableNames, [$this->table->getName()]);
167:
168: return $this;
169: }
170:
171: public function dropIfExists(bool $dropForeignKeysFirst = false): self
172: {
173: try {
174: $this->drop($dropForeignKeysFirst);
175: } catch (TableNotFoundException $e) {
176: }
177:
178: $this->createdTableNames = array_diff($this->createdTableNames, [$this->table->getName()]);
179:
180: // OracleSchemaManager::dropTable() called in self::drop() above tries to drop AI,
181: // but if AI trigger is not present, AI sequence is not dropped
182: // https://github.com/doctrine/dbal/issues/4997
183: if ($this->getDatabasePlatform() instanceof OraclePlatform) {
184: $schemaManager = $this->createSchemaManager();
185: $dropTriggerSql = $this->getDatabasePlatform()
186: ->getDropAutoincrementSql($this->table->getQuotedName($this->getDatabasePlatform()))[1];
187: try {
188: \Closure::bind(static function () use ($schemaManager, $dropTriggerSql) {
189: $schemaManager->_execSql($dropTriggerSql);
190: }, null, AbstractSchemaManager::class)();
191: } catch (DatabaseObjectNotFoundException $e) {
192: }
193: }
194:
195: return $this;
196: }
197:
198: protected function stripDatabaseFromTableName(string $tableName): string
199: {
200: $platform = $this->getDatabasePlatform();
201: $lastDotPos = strrpos($tableName, '.');
202: if ($lastDotPos !== false) {
203: $database = substr($tableName, 0, $lastDotPos);
204: if ($platform instanceof PostgreSQLPlatform || $platform instanceof SQLServerPlatform) {
205: $currentDatabase = $this->getConnection()->dsql()
206: ->field($this->getConnection()->expr('{{}}', [$this->getDatabasePlatform()->getCurrentDatabaseExpression(true)])) // @phpstan-ignore-line
207: ->getOne();
208: } else {
209: $currentDatabase = $this->getConnection()->getConnection()->getDatabase();
210: }
211: if ($database !== $currentDatabase) {
212: throw (new Exception('Table name has database specified, but it does not match the current database'))
213: ->addMoreInfo('table', $tableName)
214: ->addMoreInfo('currentDatabase', $currentDatabase);
215: }
216: $tableName = substr($tableName, $lastDotPos + 1);
217: }
218:
219: return $tableName;
220: }
221:
222: /**
223: * @param array<string, mixed> $options
224: */
225: public function field(string $fieldName, array $options = []): self
226: {
227: $type = $options['type'] ?? 'string';
228: unset($options['type']);
229: if ($type === 'time' && $this->getDatabasePlatform() instanceof OraclePlatform) {
230: $type = 'string';
231: }
232:
233: $refType = $options['ref_type'] ?? self::REF_TYPE_NONE;
234: unset($options['ref_type']);
235:
236: $column = $this->table->addColumn($this->getDatabasePlatform()->quoteSingleIdentifier($fieldName), $type);
237:
238: if (($options['nullable'] ?? true) && $refType !== self::REF_TYPE_PRIMARY) {
239: $column->setNotnull(false);
240: }
241:
242: if ($type === 'integer' && $refType !== self::REF_TYPE_NONE) {
243: $column->setUnsigned(true);
244: }
245:
246: // TODO remove, hack for createForeignKey so ID columns are unsigned
247: if ($type === 'integer' && str_ends_with($fieldName, '_id')) {
248: $column->setUnsigned(true);
249: }
250:
251: if (in_array($type, ['string', 'text'], true)) {
252: if ($this->getDatabasePlatform() instanceof SQLitePlatform) {
253: $column->setPlatformOption('collation', 'NOCASE');
254: }
255: }
256:
257: if ($refType === self::REF_TYPE_PRIMARY) {
258: $this->table->setPrimaryKey([$this->getDatabasePlatform()->quoteSingleIdentifier($fieldName)]);
259: $column->setAutoincrement(true);
260: }
261:
262: return $this;
263: }
264:
265: /**
266: * @param array<string, mixed> $options
267: */
268: public function id(string $name = 'id', array $options = []): self
269: {
270: $options = array_merge([
271: 'type' => 'integer',
272: 'ref_type' => self::REF_TYPE_PRIMARY,
273: 'nullable' => false,
274: ], $options);
275:
276: $this->field($name, $options);
277:
278: return $this;
279: }
280:
281: public function setModel(Model $model): Model
282: {
283: $this->table($model->table);
284:
285: foreach ($model->getFields() as $field) {
286: if ($field->neverPersist || $field instanceof SqlExpressionField) {
287: continue;
288: }
289:
290: if ($field->shortName === $model->idField) {
291: $refype = self::REF_TYPE_PRIMARY;
292: $persistField = $field;
293: } else {
294: $refField = $field->hasReference() ? $this->getReferenceField($field) : null;
295: if ($refField !== null) {
296: $refype = self::REF_TYPE_LINK;
297: $persistField = $refField;
298: } else {
299: $refype = self::REF_TYPE_NONE;
300: $persistField = $field;
301: }
302: }
303:
304: $options = [
305: 'type' => $persistField->type,
306: 'ref_type' => $refype,
307: 'nullable' => ($field->nullable && !$field->required) || ($persistField->nullable && !$persistField->required),
308: ];
309:
310: $this->field($field->getPersistenceName(), $options);
311: }
312:
313: return $model;
314: }
315:
316: protected function getReferenceField(Field $field): ?Field
317: {
318: $reference = $field->getReference();
319: if ($reference instanceof HasOne) {
320: $referenceField = $reference->getTheirFieldName($reference->createTheirModel());
321:
322: $modelSeed = is_array($reference->model)
323: ? $reference->model
324: : clone $reference->model;
325: $referenceModel = Model::fromSeed($modelSeed, [new Persistence\Sql($this->getConnection())]);
326:
327: return $referenceModel->getField($referenceField);
328: }
329:
330: return null;
331: }
332:
333: protected function resolvePersistenceField(Field $field): ?Field
334: {
335: if ($field->neverPersist || $field instanceof SqlExpressionField) {
336: return null;
337: }
338:
339: if ($field->hasJoin()) {
340: return $this->resolvePersistenceField(
341: $field->getJoin()->getForeignModel()->getField($field->getPersistenceName())
342: );
343: }
344:
345: if (is_object($field->getOwner()->table)) {
346: return $this->resolvePersistenceField(
347: $field->getOwner()->table->getField($field->getPersistenceName())
348: );
349: }
350:
351: return $field;
352: }
353:
354: /**
355: * @param Reference|Join $relation
356: *
357: * @return array{Field, Field}
358: */
359: protected function resolveRelationDirection(object $relation): array
360: {
361: if ($relation instanceof HasOne) {
362: $localField = $relation->getOwner()->getField($relation->getOurFieldName());
363: $theirModel = $relation->createTheirModel();
364: $foreignField = $theirModel->getField($relation->getTheirFieldName($theirModel));
365: } elseif ($relation instanceof HasMany) {
366: $localField = $relation->createTheirModel()->getField($relation->getTheirFieldName());
367: $foreignField = $relation->getOwner()->getField($relation->getOurFieldName());
368: } elseif ($relation instanceof Join) {
369: $localField = $relation->getMasterField();
370: $foreignField = $relation->getForeignModel()->getField($relation->foreignField);
371: if ($relation->reverse) {
372: [$localField, $foreignField] = [$foreignField, $localField];
373: }
374: } else {
375: throw (new Exception('Relation must be HasOne, HasMany or Join'))
376: ->addMoreInfo('relation', $relation);
377: }
378:
379: return [$localField, $foreignField];
380: }
381:
382: public function isTableExists(string $tableName): bool
383: {
384: try {
385: [$sql] = $this->getConnection()->dsql()
386: ->field($this->getConnection()->expr('1'))
387: ->table($tableName)
388: ->render();
389: $this->getConnection()->getConnection()->executeQuery($sql);
390:
391: return true;
392: } catch (TableNotFoundException $e) {
393: return false;
394: }
395: }
396:
397: /**
398: * DBAL list methods have very broken support for quoted table name
399: * and almost no support for table name with database name.
400: */
401: protected function fixTableNameForListMethod(string $tableName): string
402: {
403: $tableName = $this->stripDatabaseFromTableName($tableName);
404:
405: $platform = $this->getDatabasePlatform();
406: if ($platform instanceof MySQLPlatform || $platform instanceof SQLServerPlatform) {
407: return $tableName;
408: }
409:
410: return $platform->quoteSingleIdentifier($tableName);
411: }
412:
413: /**
414: * @param list<Field> $fields
415: */
416: public function isIndexExists(array $fields, bool $requireUnique): bool
417: {
418: $fields = array_map(fn ($field) => $this->resolvePersistenceField($field), $fields);
419: $table = reset($fields)->getOwner()->table;
420:
421: $indexes = $this->createSchemaManager()->listTableIndexes($this->fixTableNameForListMethod($table));
422: $fieldPersistenceNames = array_map(static fn ($field) => $field->getPersistenceName(), $fields);
423: foreach ($indexes as $index) {
424: $indexPersistenceNames = $index->getUnquotedColumns();
425: if ($requireUnique) {
426: if ($indexPersistenceNames === $fieldPersistenceNames && $index->isUnique()) {
427: return true;
428: }
429: } else {
430: if (array_slice($indexPersistenceNames, 0, count($fieldPersistenceNames)) === $fieldPersistenceNames) {
431: return true;
432: }
433: }
434: }
435:
436: return false;
437: }
438:
439: /**
440: * @param list<Field> $fields
441: */
442: public function createIndex(array $fields, bool $isUnique): void
443: {
444: $fields = array_map(fn ($field) => $this->resolvePersistenceField($field), $fields);
445: $table = reset($fields)->getOwner()->table;
446:
447: $platform = $this->getDatabasePlatform();
448:
449: $mssqlNullable = null;
450: if ($platform instanceof SQLServerPlatform) {
451: $mssqlNullable = false;
452: foreach ($fields as $field) {
453: if ($field->nullable && !$field->required) {
454: $mssqlNullable = true;
455: }
456: }
457: }
458:
459: $index = new Index(
460: \Closure::bind(static function () use ($table, $fields) {
461: return (new Identifier(''))->_generateIdentifierName([
462: $table,
463: ...array_map(static fn ($field) => $field->getPersistenceName(), $fields),
464: ], 'uniq');
465: }, null, Identifier::class)(),
466: array_map(static fn ($field) => $platform->quoteSingleIdentifier($field->getPersistenceName()), $fields),
467: $isUnique,
468: false,
469: $mssqlNullable === false ? ['atk4-not-null'] : []
470: );
471:
472: $this->createSchemaManager()->createIndex($index, $platform->quoteIdentifier($table));
473: }
474:
475: /**
476: * @param Reference|Join|array{Field, Field} $relation
477: */
478: public function createForeignKey($relation): void
479: {
480: [$localField, $foreignField] = is_array($relation)
481: ? $relation
482: : $this->resolveRelationDirection($relation);
483: $localField = $this->resolvePersistenceField($localField);
484: $foreignField = $this->resolvePersistenceField($foreignField);
485:
486: $platform = $this->getDatabasePlatform();
487:
488: if (!$this->isIndexExists([$foreignField], true)) {
489: if ($foreignField->nullable && !$foreignField->required && $platform instanceof SQLServerPlatform) {
490: $foreignFieldForIndex = clone $foreignField;
491: $foreignFieldForIndex->nullable = false;
492: } else {
493: $foreignFieldForIndex = $foreignField;
494: }
495:
496: $this->createIndex([$foreignFieldForIndex], true);
497: }
498:
499: $foreignKey = new ForeignKeyConstraint(
500: [$platform->quoteSingleIdentifier($localField->getPersistenceName())],
501: '0.0',
502: [$platform->quoteSingleIdentifier($foreignField->getPersistenceName())],
503: // DBAL auto FK generator does not honor foreign table/columns
504: // https://github.com/doctrine/dbal/pull/5490
505: \Closure::bind(static function () use ($localField, $foreignField) {
506: return (new Identifier(''))->_generateIdentifierName([
507: $localField->getOwner()->table,
508: $localField->getPersistenceName(),
509: $foreignField->getOwner()->table,
510: $foreignField->getPersistenceName(),
511: ], 'fk');
512: }, null, Identifier::class)()
513: );
514: $foreignTableIdentifier = $this->fixAbstractAssetName(new Identifier('0.0'), $foreignField->getOwner()->table);
515: \Closure::bind(static fn () => $foreignKey->_foreignTableName = $foreignTableIdentifier, null, ForeignKeyConstraint::class)();
516:
517: $this->createSchemaManager()->createForeignKey($foreignKey, $platform->quoteIdentifier($localField->getOwner()->table));
518: }
519: }
520: