| 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: | |
| 39: | private $_connection; |
| 40: | |
| 41: | |
| 42: | public $table; |
| 43: | |
| 44: | |
| 45: | private $createdTableNames = []; |
| 46: | |
| 47: | |
| 48: | |
| 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) { |
| 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: | |
| 80: | |
| 81: | protected function createSchemaManager(): AbstractSchemaManager |
| 82: | { |
| 83: | return $this->getConnection()->createSchemaManager(); |
| 84: | } |
| 85: | |
| 86: | |
| 87: | |
| 88: | |
| 89: | |
| 90: | |
| 91: | |
| 92: | |
| 93: | |
| 94: | |
| 95: | |
| 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: | |
| 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: | |
| 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: | |
| 181: | |
| 182: | |
| 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)])) |
| 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: | |
| 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: | |
| 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: | |
| 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: | |
| 356: | |
| 357: | |
| 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: | |
| 399: | |
| 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: | |
| 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: | |
| 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: | |
| 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: | |
| 504: | |
| 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: | |