diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..f315db6 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,103 @@ +--- +id: migrations +title: migrations +slug: /migrations +--- + +Get your desired schema applied to a database by running migrations. Migrations +are split into two parts: a constructive part and a destructive part. The +constructive part is meant to be run automatically, it should no break any +existing code. When this is done and the new code has been deployed and no old +is live anymore, the destructive part can be run manually. + +This makes sure database changes related to each are colocated in the same +migration, but can be run on different times. Replacing one feature for another +would be a great use case. + +## Migration class + +Migrations are created by creating a class that extends +`Access\Migrations\Migration`. This class has two abstract methods that you +need to override to make the migration functional: `constructive` and +`revertConstructive`. + +```php +class SomeMigration extends Migration +{ + public function constructive(SchemaChanges $schemaChanges): void + { + $schemaChanges->createTable('users'); + } + + public function revertConstructive(SchemaChanges $schemaChanges): void + { + $schemaChanges->dropTable('users'); + } +} +``` + +The `SchemaChanges` argument is used to add changes you the migration will +apply to the database. + +## Migrator + +Once the migration is created, it needs to be executed. For this, the +`Migrator` class exists. First the table that keep track of which migrations +are already executed needs to be created. + +```php +$db = ...; +$migrator = new Migrator($db); +$migrator->init(); +``` + +After the migrator has been initialized, the first migration can be run: + +```php +$migration = new SomeMigration(); +$result = $migrator->constructive($migration); +``` + +The `$result` will be a `MigrationResult` that contains a status and the +queries that have been executed. + +## Checkpoints + +When developing migrations it can be a hassle if one of the queries fails for +one reason or another. The database is in an inconsistent state and running a +migration again might no be possible because one of the queries in the +migration _did_ succeed. By using a checkpoint when running a migration it is +possible to skip some steps of a migration. A checkpoint is a simple number +that counts executed steps of a migration, nothing more, nothing less. + +A failed `MigrationResult` will contain a checkpoint that tells which queries +did succeeed, passing that checkpoint to any of the migrator methods will point +to the same step. Without any changes to the migration and/or database, and +passing the checkpoint from the result to the mgirator will fail on exactly the +same query. + +```php +$migration = new SomeMigration(); + +// Skip first step/query of the migration +$checkpoint = new Checkpoint(1); + +$result = $migrator->constructive($migration, $checkpoint); +``` + +## Migrations lifecycle + +Migrations go through a specific lifecycle. + +```mermaid +stateDiagram-v2 +[*] --> NotInitialized +NotInitialized --> Initialized : init() +Initialized --> ConstructiveExecuted : constructive() +ConstructiveExecuted --> ConstructiveReverted : revertConstructive() +ConstructiveExecuted --> DestructiveExecuted : destructive() +ConstructiveReverted --> ConstructiveExecuted : constructive() +DestructiveExecuted --> DestructiveReverted : revertDestructive() +DestructiveReverted --> DestructiveExecuted : destructive() +DestructiveReverted --> ConstructiveReverted : revertConstructive() +``` diff --git a/src/Cascade/CascadeDeleteResolver.php b/src/Cascade/CascadeDeleteResolver.php index 2dc5f2a..e822c2b 100644 --- a/src/Cascade/CascadeDeleteResolver.php +++ b/src/Cascade/CascadeDeleteResolver.php @@ -19,9 +19,9 @@ use Access\Database; use Access\DeleteKind; use Access\Entity; +use Access\Exception\NotSupportedException; use Access\Query; -use Access\Statement; -use Exception; +use Access\Schema\Type; /** * Resolve the order of cascading delete operations @@ -269,30 +269,36 @@ private function dependsOn( private function deleteFields(Entity $entity, DeleteKind $kind): void { - $fields = $entity::fields(); + $fields = $entity::getTableSchema()->getFields(); $values = $entity->getValues(); - foreach ($fields as $fieldName => $relation) { + foreach ($fields as $field) { + $type = $field->getType(); + + // only fields marked as a reference + if (!$type instanceof Type\Reference) { + continue; + } + // skip fields that are not set (or `null`) - if (!isset($values[$fieldName])) { + if (!isset($values[$field->getName()])) { continue; } - /** @var Cascade|null $cascade */ - $cascade = $relation['cascade'] ?? null; + $cascade = $type->getCascade(); if ($cascade === null) { continue; } - if (!isset($relation['target'])) { - continue; - } + $target = $type->getTarget(); - $target = $relation['target']; + if (!is_string($target) || !is_subclass_of($target, Entity::class)) { + throw new NotSupportedException('Cascading delete only works for entity relations'); + } if ($cascade->shouldCascadeDelete($kind, $target)) { - $r = $this->find($target, 'id', $values[$fieldName], $kind); + $r = $this->find($target, 'id', $values[$field->getName()], $kind); $this->resolveRelation($r, $kind, $target, $cascade); } @@ -371,8 +377,7 @@ private function delete(string $klass, array $ids): bool 'id IN (?)' => $ids, ]); - $stmt = new Statement($this->db, $this->db->getProfiler(), $query); - $gen = $stmt->execute(); + $gen = $this->db->executeStatement($query); $updated = $gen->getReturn() > 0; return $updated; @@ -394,8 +399,7 @@ private function softDelete(string $klass, array $ids): bool 'id IN (?)' => $ids, ]); - $stmt = new Statement($this->db, $this->db->getProfiler(), $query); - $gen = $stmt->execute(); + $gen = $this->db->executeStatement($query); $updated = $gen->getReturn() > 0; return $updated; } diff --git a/src/Clause/Condition/Raw.php b/src/Clause/Condition/Raw.php index e5abb48..fd907c9 100644 --- a/src/Clause/Condition/Raw.php +++ b/src/Clause/Condition/Raw.php @@ -34,4 +34,12 @@ public function __construct(string $condition, mixed $value = null) { parent::__construct($condition, self::KIND_RAW, $value); } + + /** + * Return the condition as string + */ + public function getCondition(): string + { + return $this->getField()->getName(); + } } diff --git a/src/Clause/Filter/Filter.php b/src/Clause/Filter/Filter.php index f15aa7f..e763460 100644 --- a/src/Clause/Filter/Filter.php +++ b/src/Clause/Filter/Filter.php @@ -25,8 +25,6 @@ abstract class Filter implements FilterInterface { /** * Filter given collection in place based on this filter clause - * - * @param Collection $collection Collection to filter */ public function filterCollection(Collection $collection): Collection { diff --git a/src/Clause/Filter/FilterItemResult.php b/src/Clause/Filter/FilterItemResult.php new file mode 100644 index 0000000..fe2da7f --- /dev/null +++ b/src/Clause/Filter/FilterItemResult.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Clause\Filter; + +/** + * Controls the flow of filtering a list of items + * + * `FilterItemResult::Done` indicates that no further items should be considered + * + * @author Tim + */ +enum FilterItemResult +{ + /** + * The item should be included + */ + case Include; + + /** + * The item should be excluded + */ + case Exclude; + + /** + * The filtering is done, no further items will be considered + * + * The current item will be excluded + */ + case Done; +} diff --git a/src/Clause/Filter/Unique.php b/src/Clause/Filter/Unique.php index 490320d..78b7814 100644 --- a/src/Clause/Filter/Unique.php +++ b/src/Clause/Filter/Unique.php @@ -34,7 +34,7 @@ public function __construct(string $fieldName) * Create the finder function for this filter clause * * @return callable - * @psalm-return callable(\Access\Entity): scalar + * @psalm-return callable(\Access\Entity): (FilterItemResult|bool) */ public function createFilterFinder(): callable { diff --git a/src/Clause/FilterInterface.php b/src/Clause/FilterInterface.php index e8c3940..4769b85 100644 --- a/src/Clause/FilterInterface.php +++ b/src/Clause/FilterInterface.php @@ -13,6 +13,7 @@ namespace Access\Clause; +use Access\Clause\Filter\FilterItemResult; use Access\Collection; /** @@ -25,7 +26,11 @@ interface FilterInterface extends ClauseInterface /** * Filter given collection in place based on this filter clause * - * @param Collection $collection Collection to filter + * @psalm-template TEntity of \Access\Entity + * @param Collection $collection The collection to filter + * @psalm-param Collection $collection The collection to filter + * @return Collection The filtered collection + * @psalm-return Collection The filtered collection */ public function filterCollection(Collection $collection): Collection; @@ -33,7 +38,7 @@ public function filterCollection(Collection $collection): Collection; * Create the finder function for this filter clause * * @return callable - * @psalm-return callable(\Access\Entity): scalar + * @psalm-return callable(\Access\Entity): (FilterItemResult|bool) */ public function createFilterFinder(): callable; } diff --git a/src/Clause/Limit.php b/src/Clause/Limit.php new file mode 100644 index 0000000..2534a9b --- /dev/null +++ b/src/Clause/Limit.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Clause; + +use Access\Collection; +use Access\Query\QueryGeneratorState; + +/** + * Limit the number of entities + * + * @author Tim + */ +class Limit implements LimitInterface +{ + /** + * The limit of number of entities + */ + private int $limit; + + /** + * Starting offset + */ + private ?int $offset = null; + + public function __construct(int $limit, ?int $offset = null) + { + $this->limit = $limit; + $this->offset = $offset; + } + + /** + * Get the current limit + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * Set a new limit + */ + public function setLimit(int $limit): void + { + $this->limit = $limit; + } + + /** + * Get the current offset + */ + public function getOffset(): ?int + { + return $this->offset; + } + + /** + * Set a new offset + */ + public function setOffset(?int $offset): void + { + $this->offset = $offset; + } + + /** + * {@inheritdoc} + */ + public function limitCollection(?Collection $collection): void + { + if ($collection === null) { + return; + } + + $collection->limit($this); + } + + /** + * {@inheritdoc} + */ + public function getConditionSql(QueryGeneratorState $state): string + { + $limitSql = " LIMIT {$this->limit}"; + + if ($this->offset !== null) { + $limitSql .= " OFFSET {$this->offset}"; + } + + return $limitSql; + } + + /** + * {@inheritdoc} + */ + public function injectConditionValues(QueryGeneratorState $state): void + { + // no values to inject + } +} diff --git a/src/Clause/LimitInterface.php b/src/Clause/LimitInterface.php new file mode 100644 index 0000000..7ececc1 --- /dev/null +++ b/src/Clause/LimitInterface.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Clause; + +use Access\Collection; +use Access\Query\QueryGeneratorState; + +/** + * Clause is limiting + * + * @author Tim + */ +interface LimitInterface extends ClauseInterface +{ + /** + * Get the limit of number of entities + */ + public function getLimit(): int; + + /** + * Get the starting offset + */ + public function getOffset(): ?int; + + /** + * Limit given collection in place based on this limit clause + * + * @param Collection|null $collection Collection to limit + */ + public function limitCollection(?Collection $collection): void; + + /** + * Create the SQL for limit clause + * + * @internal + */ + public function getConditionSql(QueryGeneratorState $state): string; + + /** + * Inject SQL values into indexed values + * + * @param QueryGeneratorState $state A bit of state for query generation + * @internal + */ + public function injectConditionValues(QueryGeneratorState $state): void; +} diff --git a/src/Clause/Multiple.php b/src/Clause/Multiple.php index 7197527..4b2a761 100644 --- a/src/Clause/Multiple.php +++ b/src/Clause/Multiple.php @@ -15,6 +15,7 @@ use Access\Clause\ClauseInterface; use Access\Clause\ConditionInterface; +use Access\Clause\Filter\FilterItemResult; use Access\Collection; use Access\Entity; use Access\Query\QueryGeneratorState; @@ -26,7 +27,12 @@ * * @author Tim */ -class Multiple implements ConditionInterface, OrderByInterface, FilterInterface, \Countable +class Multiple implements + ConditionInterface, + OrderByInterface, + FilterInterface, + LimitInterface, + \Countable { /** * Combinator for AND @@ -148,8 +154,6 @@ public function createSortComparer(): callable /** * Filter given collection in place based on this filter clause - * - * @param Collection $collection Collection to filter */ public function filterCollection(Collection $collection): Collection { @@ -160,28 +164,32 @@ public function filterCollection(Collection $collection): Collection * Create the finder function for this filter clause * * @return callable - * @psalm-return callable(\Access\Entity): scalar + * @psalm-return callable(\Access\Entity): (FilterItemResult|bool) */ public function createFilterFinder(): callable { /** - * @var callable[] $finders - * @psalm-var array $finders + * @var callable[] $filterers + * @psalm-var array $filterers */ - $finders = []; + $filterers = []; foreach ($this->clauses as $clause) { if ($clause instanceof FilterInterface) { - $finders[] = $clause->createFilterFinder(); + $filterers[] = $clause->createFilterFinder(); } } - return function (Entity $entity) use ($finders): bool { - foreach ($finders as $finder) { - $found = $finder($entity); + return function (Entity $entity) use ($filterers): FilterItemResult|bool { + foreach ($filterers as $filterer) { + $itemResult = $filterer($entity); - if ($found === false) { - return false; + if ( + $itemResult === false || + $itemResult === FilterItemResult::Exclude || + $itemResult === FilterItemResult::Done + ) { + return $itemResult; } } @@ -189,6 +197,39 @@ public function createFilterFinder(): callable }; } + /** + * The limit for this clause + * + * NOTE: this always returns 0, because the limit of multiple can vary. Do not use directly. + */ + public function getLimit(): int + { + return 0; + } + + /** + * The offset for this clause + * + * NOTE: this always returns null, because the offset of multiple can vary. Do not use directly. + */ + public function getOffset(): ?int + { + return null; + } + + public function limitCollection(?Collection $collection): void + { + if ($collection === null) { + return; + } + + foreach ($this->clauses as $clause) { + if ($clause instanceof LimitInterface) { + $clause->limitCollection($collection); + } + } + } + /** * {@inheritdoc} */ diff --git a/src/Collection.php b/src/Collection.php index bf7cb55..ef0f412 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -15,6 +15,10 @@ use Access\Clause\ClauseInterface; use Access\Clause\ConditionInterface; +use Access\Clause\Filter\FilterItemResult; +use Access\Clause\FilterInterface; +use Access\Clause\Limit; +use Access\Clause\LimitInterface; use Access\Clause\OrderByInterface; use Access\Collection\GroupedCollection; use Access\Collection\Iterator; @@ -22,6 +26,7 @@ use Access\Entity; use Access\Exception; use Access\Presenter; +use Access\Query\Cursor\Cursor; /** * Collection of entities @@ -166,7 +171,7 @@ public function findInversedRefs(string $klass, string $fieldName): Collection { $this->db->assertValidEntityClass($klass); - $validFieldNames = array_keys($klass::fields()); + $validFieldNames = $klass::getTableSchema()->getFieldNames(); if (!in_array($fieldName, $validFieldNames, true)) { throw new Exception('Unknown field name for inversed refs'); @@ -289,6 +294,35 @@ public function sort(callable|OrderByInterface $comparer): static return $this; } + /** + * Limit collection in place + * + * NOTE: Only the `Limit` class is accepted, not `LimitInterface` + * NOTE: No new collection is created + * + * @param Limit|int $limit Limit of entities + * @param int|null $offset Will override anything set in `$limit` + * @return $this + */ + public function limit(Limit|int $limit, ?int $offset = null): static + { + if (is_int($limit)) { + $limit = new Limit($limit); + } + + if ($offset !== null) { + $limit->setOffset($offset); + } + + $this->entities = array_slice( + $this->entities, + $limit->getOffset() ?? 0, + $limit->getLimit(), + ); + + return $this; + } + /** * Map over collection * @@ -328,15 +362,29 @@ public function reduce(callable $reducer, mixed $initial = null): mixed /** * Create a new filtered collection * - * @psalm-param callable(TEntity): scalar $finder Include entity when $finder returns `true` * @param callable $finder Include entity when $finder returns `true` + * @psalm-param callable(TEntity): (FilterItemResult|bool) $finder Include entity when $finder returns `true` * @return Collection Newly created, and filtered, collection */ public function filter(callable $finder): Collection { /** @var self $result */ $result = new self($this->db); - $result->fromIterable(array_filter($this->entities, $finder)); + + foreach ($this->entities as $entity) { + /** @var FilterItemResult|bool $filterResult */ + $filterResult = $finder($entity); + + if ($filterResult === FilterItemResult::Done) { + break; + } elseif ($filterResult === FilterItemResult::Exclude) { + continue; + } elseif (!$filterResult) { + continue; + } + + $result->addEntity($entity); + } return $result; } @@ -448,6 +496,14 @@ public function applyClause(ClauseInterface $clause): Collection $collection = $collection->filter(\Closure::fromCallable([$clause, 'matchesEntity'])); } + if ($clause instanceof LimitInterface) { + $clause->limitCollection($collection); + } + + if ($clause instanceof FilterInterface) { + $collection = $clause->filterCollection($collection); + } + return $collection; } diff --git a/src/Database.php b/src/Database.php index 868945a..2733fd6 100644 --- a/src/Database.php +++ b/src/Database.php @@ -15,21 +15,15 @@ use Access\Cascade\CascadeDeleteResolver; use Access\Driver\DriverInterface; -use Access\Driver\Mysql; -use Access\Driver\Sqlite; +use Access\Driver\Mysql\Mysql; +use Access\Driver\Sqlite\Sqlite; use Access\Entity; use Access\Exception; use Access\Exception\ClosedConnectionException; use Access\Lock; use Access\Presenter; use Access\Presenter\EntityPresenter; -use Access\Profiler; -use Access\Query; use Access\Query\IncludeSoftDeletedFilter; -use Access\Repository; -use Access\Statement; -use Access\StatementPool; -use Access\Transaction; use DateTimeImmutable; use Psr\Clock\ClockInterface; @@ -366,10 +360,10 @@ public function selectWithEntityProvider( $oldIncludeSoftDeleted = $query->setIncludeSoftDeleted($this->includeSoftDeletedFilter); try { - $stmt = new Statement($this, $this->profiler, $query); + $gen = $this->executeStatement($query); /** @var array $record */ - foreach ($stmt->execute() as $record) { + foreach ($gen as $record) { $model = $entityProvider->create(); $model->hydrate($record); @@ -446,8 +440,7 @@ public function insert(Entity $model): Entity $query = new Query\Insert($model::tableName()); $query->values($values); - $stmt = new Statement($this, $this->profiler, $query); - $gen = $stmt->execute(); + $gen = $this->executeStatement($query); $model->setId(intval($gen->getReturn())); // set default values/timestamps @@ -475,8 +468,7 @@ public function update(Entity $model): bool 'id = ?' => $id, ]); - $stmt = new Statement($this, $this->profiler, $query); - $gen = $stmt->execute(); + $gen = $this->executeStatement($query); // set default values/timestamps $model->markUpdated($values); @@ -597,8 +589,7 @@ public function query(Query $query): void $oldIncludeSoftDeleted = $query->setIncludeSoftDeleted($this->includeSoftDeletedFilter); try { - $stmt = new Statement($this, $this->profiler, $query); - $gen = $stmt->execute(); + $gen = $this->executeStatement($query); // consume generator $gen->getReturn(); @@ -607,6 +598,19 @@ public function query(Query $query): void } } + /** + * Execute a statement and return the generator + * + * @internal + * @param Query $query Query to be executed + * @return \Generator> - yields array + */ + public function executeStatement(Query $query): \Generator + { + $stmt = new Statement($this, $this->profiler, $query); + return $stmt->execute(); + } + /** * Present a single entity as a simple array * @@ -695,6 +699,25 @@ public function withIncludeSoftDeleted(bool $includeSoftDeleted): static return $self; } + /** + * Convert a PDOException to a more specific Exception + * + * Fallback to `Access\Exception` with `$genericErrorMessage` when no specific exception is found + * + * @param \PDOException $e The original PDO exception + * @param string $genericErrorMessage The generic error message to use when no specific exception is found + */ + public function convertPdoException(\PDOException $e, string $genericErrorMessage): Exception + { + $specificException = $this->getDriver()->convertPdoException($e); + + if ($specificException !== null) { + return $specificException; + } + + return new Exception($genericErrorMessage, 0, $e); + } + /** * Check for a valid entity class name * diff --git a/src/DebugQuery.php b/src/DebugQuery.php index 2526686..7dedd74 100644 --- a/src/DebugQuery.php +++ b/src/DebugQuery.php @@ -14,7 +14,6 @@ namespace Access; use Access\Driver\DriverInterface; -use Access\Query; /** * Debug your query @@ -54,7 +53,7 @@ public function toRunnableQuery(?DriverInterface $driver = null): ?string /** @var mixed $value */ foreach ($values as $placeholder => $value) { - $sql = preg_replace("/:$placeholder\b/", $this->toSqlValue($driver, $value), $sql); + $sql = preg_replace("/:$placeholder\b/", $driver->getDebugSqlValue($value), $sql); /** * The placeholders are generated by us, we can use a simple regex to replace them @@ -65,29 +64,4 @@ public function toRunnableQuery(?DriverInterface $driver = null): ?string return $sql; } - - /** - * @param mixed $value - * @return string - */ - private function toSqlValue(DriverInterface $driver, mixed $value): string - { - if ($value === null) { - return 'NULL'; - } - - if (is_int($value)) { - return (string) $value; - } - - // Check if result is non-unicode string using PCRE_UTF8 modifier - // see DoctrineBundle escape function - if (is_string($value) && !preg_match('//u', $value)) { - return '0x' . strtoupper(bin2hex($value)); - } - - // bools and dates are already processed - - return $driver->getDebugStringValue($value); - } } diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php new file mode 100644 index 0000000..e5ae5fc --- /dev/null +++ b/src/Driver/Driver.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver; + +use Access\Schema\Field; + +/** + * Base driver + * + * @author Tim + * @internal + */ +abstract class Driver implements DriverInterface +{ + /** + * Get the SQL definition for a field + */ + public function getSqlFieldDefinition(Field $field): string + { + return $this->getSqlTypeDefinitionBuilder()->fromField($field); + } + + /** + * Get a debug string value for a value + * + * After processing by query + * + * Useful for the debug query, should not be used otherwise, use prepared statements + * + * @return string Save'ish converted SQL value + */ + public function getDebugSqlValue(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + + if (is_int($value)) { + return (string) $value; + } + + // Check if result is non-unicode string using PCRE_UTF8 modifier + // see DoctrineBundle escape function + if (is_string($value) && !preg_match('//u', $value)) { + return '0x' . strtoupper(bin2hex($value)); + } + + // bools and dates are already processed + + return $this->getDebugStringValue($value); + } +} diff --git a/src/Driver/DriverInterface.php b/src/Driver/DriverInterface.php index 42c9d28..6bdb0e6 100644 --- a/src/Driver/DriverInterface.php +++ b/src/Driver/DriverInterface.php @@ -13,7 +13,14 @@ namespace Access\Driver; -use Access\Clause\Field; +use Access\Clause; +use Access\Driver\Query\AlterTableBuilderInterface; +use Access\Driver\Query\CreateDatabaseBuilderInterface; +use Access\Driver\Query\CreateTableBuilderInterface; +use Access\Exception; +use Access\ReadLock as ReadLock; +use Access\Schema; +use Access\Schema\Index; /** * Driver specific interface @@ -26,19 +33,39 @@ interface DriverInterface /** * Escape identifier * - * @param string|Field $identifier Identifier to escape + * @param string|Clause\Field $identifier Identifier to escape * @return string * @internal */ - public function escapeIdentifier(string|Field $identifier): string; + public function escapeIdentifier(string|Clause\Field $identifier): string; /** * Get a debug string value for a value * + * After processing by query + * * Useful for the debug query, should not be used otherwise, use prepared statements + * + * @return string Save'ish converted SQL value + */ + public function getDebugSqlValue(mixed $value): string; + + /** + * Get a debug string value for a value + * + * After processing by query + * + * Useful for the debug query, should not be used otherwise, use prepared statements + * + * @return string Save'ish converted SQL value */ public function getDebugStringValue(mixed $value): string; + /** + * Convert a PDOException to a more specific Exception + */ + public function convertPdoException(\PDOException $e): ?Exception; + /** * Get the function name for random in SQL dialect */ @@ -48,4 +75,39 @@ public function getFunctionNameRandom(): string; * Has the driver support for LOCK/UNLOCK TABLES? */ public function hasLockSupport(): bool; + + /** + * Get the SQL for a read lock + */ + public function getReadLockSql(ReadLock $readLock): string; + + /** + * Get the builder to create SQL type definitions + */ + public function getSqlTypeDefinitionBuilder(): SqlTypeDefinitionBuilderInterface; + + /** + * Get the SQL definition for a field + */ + public function getSqlFieldDefinition(Schema\Field $field): string; + + /** + * Get the SQL definition for an index + */ + public function getSqlIndexDefinition(Index $index): string; + + /** + * Get the builder to create databases + */ + public function getCreateDatabaseBuilder(): CreateDatabaseBuilderInterface; + + /** + * Get the builder to create tables + */ + public function getCreateTableBuilder(): CreateTableBuilderInterface; + + /** + * Get the builder to alter tables + */ + public function getAlterTableBuilder(): AlterTableBuilderInterface; } diff --git a/src/Driver/Mysql.php b/src/Driver/Mysql.php deleted file mode 100644 index 25d0c0a..0000000 --- a/src/Driver/Mysql.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Access\Driver; - -use Access\Clause\Field; - -/** - * MySQL specific driver - * - * @author Tim - * @internal - */ -class Mysql implements DriverInterface -{ - public const NAME = 'mysql'; - - /** - * Escape identifier - * - * @param string|Field $identifier Identifier to escape - * @return string - * @internal - */ - public function escapeIdentifier(string|Field $identifier): string - { - if ($identifier instanceof Field) { - $identifier = $identifier->getName(); - } - - return str_replace('.', '`.`', sprintf('`%s`', str_replace('`', '``', $identifier))); - } - - /** - * Get a debug string value for a value in MySQL dialect - * - * Useful for the debug query, should not be used otherwise, use prepared statements - */ - public function getDebugStringValue(mixed $value): string - { - return sprintf('"%s"', addslashes((string) $value)); - } - - /** - * Get the function name for random in MySQL dialect - */ - public function getFunctionNameRandom(): string - { - return 'RAND()'; - } - - /** - * Has the MySQL driver support for LOCK/UNLOCK TABLES? - */ - public function hasLockSupport(): bool - { - return true; - } -} diff --git a/src/Driver/Mysql/Mysql.php b/src/Driver/Mysql/Mysql.php new file mode 100644 index 0000000..b4ba9ba --- /dev/null +++ b/src/Driver/Mysql/Mysql.php @@ -0,0 +1,214 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Mysql; + +use Access\Clause\Field; +use Access\Driver\Driver; +use Access\Driver\Mysql\MysqlSqlTypeDefinitionBuilder; +use Access\Driver\Mysql\Query\AlterTableBuilder; +use Access\Driver\Mysql\Query\CreateDatabaseBuilder; +use Access\Driver\Mysql\Query\CreateTableBuilder; +use Access\Driver\Query\AlterTableBuilderInterface; +use Access\Driver\Query\CreateDatabaseBuilderInterface; +use Access\Driver\Query\CreateTableBuilderInterface; +use Access\Driver\SqlTypeDefinitionBuilderInterface; +use Access\Exception; +use Access\Exception\ConnectionGoneException; +use Access\Exception\DuplicateEntryException; +use Access\Exception\LockNotAcquiredException; +use Access\Exception\TableDoesNotExistException; +use Access\ReadLock; +use Access\Schema\Index; + +/** + * MySQL specific driver + * + * @author Tim + * @internal + * @psalm-suppress MissingConstructor + * @psalm-suppress PropertyNotSetInConstructor + * @psalm-suppress RedundantPropertyInitializationCheck + */ +class Mysql extends Driver +{ + public const NAME = 'mysql'; + + private const ERROR_CODE_NO_SUCH_TABLE = 1146; + private const ERROR_CODE_BAD_TABLE_ERROR = 1051; + private const ERROR_CODE_SERVER_GONE_ERROR = 2006; + private const ERROR_CODE_CLIENT_INTERACTION_TIMEOUT = 4031; + + private const ERROR_CODE_DUP_ENTRY = 1062; + private const ERROR_CODE_FOREIGN_DUPLICATE_KEY_OLD_UNUSED = 1557; + private const ERROR_CODE_DUP_ENTRY_AUTOINCREMENT_CASE = 1569; + private const ERROR_CODE_DUP_ENTRY_WITH_KEY_NAME = 1586; + + private const ERROR_CODE_LOCK_NOWAIT = 3572; + private const ERROR_CODE_LOCK_WAIT_TIMEOUT = 1205; + + private SqlTypeDefinitionBuilderInterface $sqlTypeDefinition; + private CreateDatabaseBuilder $createDatabaseBuilder; + private CreateTableBuilder $createTableBuilder; + private AlterTableBuilder $alterTableBuilder; + + /** + * Escape identifier + * + * @param string|Field $identifier Identifier to escape + * @return string + * @internal + */ + public function escapeIdentifier(string|Field $identifier): string + { + if ($identifier instanceof Field) { + $identifier = $identifier->getName(); + } + + return str_replace('.', '`.`', sprintf('`%s`', str_replace('`', '``', $identifier))); + } + + /** + * Get a debug string value for a value in MySQL dialect + * + * Useful for the debug query, should not be used otherwise, use prepared statements + */ + public function getDebugStringValue(mixed $value): string + { + return sprintf('"%s"', addslashes((string) $value)); + } + + /** + * Convert a PDOException to a more specific Exception + * + * @see https://dev.mysql.com/doc/mysql-errors/8.0/en/server-error-reference.html + * @see https://dev.mysql.com/doc/mysql-errors/8.0/en/client-error-reference.html + */ + public function convertPdoException(\PDOException $e): ?Exception + { + $message = $e->getMessage(); + + if ($e->errorInfo !== null) { + [, $code] = $e->errorInfo; + } else { + $code = $e->getCode(); + } + + switch ($code) { + case self::ERROR_CODE_NO_SUCH_TABLE: + case self::ERROR_CODE_BAD_TABLE_ERROR: + return new TableDoesNotExistException( + sprintf('Table does not exists: %s', $message), + 0, + $e, + ); + + case self::ERROR_CODE_SERVER_GONE_ERROR: + case self::ERROR_CODE_CLIENT_INTERACTION_TIMEOUT: + return new ConnectionGoneException( + sprintf('Database server has gone away: %s', $message), + 0, + $e, + ); + + case self::ERROR_CODE_DUP_ENTRY: + case self::ERROR_CODE_FOREIGN_DUPLICATE_KEY_OLD_UNUSED: + case self::ERROR_CODE_DUP_ENTRY_AUTOINCREMENT_CASE: + case self::ERROR_CODE_DUP_ENTRY_WITH_KEY_NAME: + return new DuplicateEntryException(sprintf('Duplicate entry: %s', $message), 0, $e); + + case self::ERROR_CODE_LOCK_NOWAIT: + return new LockNotAcquiredException( + sprintf('Unable to acquire lock immediately: %s', $message), + 0, + $e, + ); + + case self::ERROR_CODE_LOCK_WAIT_TIMEOUT: + return new LockNotAcquiredException( + sprintf('Unable to acquire lock in time: %s', $message), + 0, + $e, + ); + + default: + return null; + } + } + + /** + * Get the function name for random in MySQL dialect + */ + public function getFunctionNameRandom(): string + { + return 'RAND()'; + } + + /** + * Has the MySQL driver support for LOCK/UNLOCK TABLES? + */ + public function hasLockSupport(): bool + { + return true; + } + + /** + * Get the SQL for a read lock + */ + public function getReadLockSql(ReadLock $readLock): string + { + return match ($readLock) { + ReadLock::Share => 'FOR SHARE', + ReadLock::ShareNoWait => 'FOR SHARE NOWAIT', + ReadLock::Update => 'FOR UPDATE', + ReadLock::UpdateNoWait => 'FOR UPDATE NOWAIT', + }; + } + + public function getSqlTypeDefinitionBuilder(): SqlTypeDefinitionBuilderInterface + { + return $this->sqlTypeDefinition ??= new MysqlSqlTypeDefinitionBuilder($this); + } + + public function getSqlIndexDefinition(Index $index): string + { + $fields = array_map( + fn(Field|string $field): string => $this->escapeIdentifier( + $field instanceof Field ? $field->getName() : $field, + ), + $index->getFields(), + ); + + return sprintf( + '%sINDEX %s (%s)', + $index->isUnique() ? 'UNIQUE ' : '', + $this->escapeIdentifier($index->getName()), + implode(', ', $fields), + ); + } + + public function getCreateDatabaseBuilder(): CreateDatabaseBuilderInterface + { + return $this->createDatabaseBuilder ??= new CreateDatabaseBuilder($this); + } + + public function getCreateTableBuilder(): CreateTableBuilderInterface + { + return $this->createTableBuilder ??= new CreateTableBuilder($this); + } + + public function getAlterTableBuilder(): AlterTableBuilderInterface + { + return $this->alterTableBuilder ??= new AlterTableBuilder($this); + } +} diff --git a/src/Driver/Mysql/MysqlSqlTypeDefinitionBuilder.php b/src/Driver/Mysql/MysqlSqlTypeDefinitionBuilder.php new file mode 100644 index 0000000..1a2e554 --- /dev/null +++ b/src/Driver/Mysql/MysqlSqlTypeDefinitionBuilder.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Mysql; + +use Access\Driver\DriverInterface; +use Access\Driver\SqlTypeDefinitionBuilder; +use Access\Schema\Field; +use Access\Schema\Type; + +/** + * Base driver + * + * @author Tim + * @internal + */ +class MysqlSqlTypeDefinitionBuilder extends SqlTypeDefinitionBuilder +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function fromField(Field $field): string + { + $innerType = $this->fromType($field->getType()); + + $parts = [$innerType]; + + if ($field->isNullable()) { + $parts[] = 'NULL'; + } else { + $parts[] = 'NOT NULL'; + } + + if ($field->hasStaticDefault()) { + $default = $field->getStaticDefaultValue(); + + if ($default === null) { + $parts[] = 'DEFAULT NULL'; + } else { + $parts[] = + 'DEFAULT ' . + $this->driver->getDebugSqlValue( + $field->getType()->toDatabaseFormatValue($default), + ); + } + } + + if ($field->hasAutoIncrement()) { + $parts[] = 'AUTO_INCREMENT'; + } + + $after = $field->getAfter(); + if ($after !== null) { + $parts[] = 'AFTER ' . $this->driver->escapeIdentifier($after); + } + + return implode(' ', $parts); + } + + public function fromBooleanType(Type\Boolean $type): string + { + return 'INT'; + } + + public function fromDateType(Type\Date $type): string + { + return 'DATE'; + } + + public function fromDateTimeType(Type\DateTime $type): string + { + return 'DATETIME'; + } + + public function fromEnumType(Type\Enum $type): string + { + return 'ENUM(' . + implode( + ', ', + array_map( + fn(string|int $case): string => $this->driver->getDebugSqlValue($case), + $type->getCases(), + ), + ) . + ')'; + } + + public function fromIntegerType(Type\Integer $type): string + { + return 'INT'; + } + + public function fromFloatType(Type\FloatType $type): string + { + return 'REAL'; + } + + public function fromVarCharType(Type\VarChar $type): string + { + return 'VARCHAR(' . $type->getSize() . ')'; + } + + public function fromVarBinaryType(Type\VarBinary $type): string + { + return 'VARBINARY(' . $type->getSize() . ')'; + } + + public function fromTextType(Type\Text $type): string + { + return 'TEXT'; + } + + public function fromJsonType(Type\Json $type): string + { + return 'JSON'; + } + + public function fromReferenceType(Type\Reference $type): string + { + return 'INT'; + } +} diff --git a/src/Driver/Mysql/Query/AlterTableBuilder.php b/src/Driver/Mysql/Query/AlterTableBuilder.php new file mode 100644 index 0000000..4a18821 --- /dev/null +++ b/src/Driver/Mysql/Query/AlterTableBuilder.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Mysql\Query; + +use Access\Clause\Field as ClauseField; +use Access\Driver\DriverInterface; +use Access\Driver\Query\AlterTableBuilderInterface; +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; + +/** + * @see https://dev.mysql.com/doc/refman/8.4/en/alter-table.html + * @author Tim + * @internal + */ +class AlterTableBuilder implements AlterTableBuilderInterface +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function renameTable(Table|string $table): string + { + $table = $table instanceof Table ? $table->getName() : $table; + + return sprintf('RENAME TO %s', $this->driver->escapeIdentifier($table)); + } + + public function addField(Field $field): string + { + return sprintf('ADD COLUMN %s', $field->getSqlDefinition($this->driver)); + } + + public function removeField(ClauseField $field): string + { + return sprintf('DROP COLUMN %s', $this->driver->escapeIdentifier($field)); + } + + public function changeField(ClauseField $from, Field $to): string + { + return sprintf( + 'CHANGE COLUMN %s %s', + $this->driver->escapeIdentifier($from), + $to->getSqlDefinition($this->driver), + ); + } + + public function modifyField(Field $to): string + { + return sprintf('MODIFY COLUMN %s', $to->getSqlDefinition($this->driver)); + } + + public function renameField(ClauseField $from, ClauseField $to): string + { + return sprintf( + 'RENAME COLUMN %s TO %s', + $this->driver->escapeIdentifier($from), + $this->driver->escapeIdentifier($to), + ); + } + + public function addIndex(Index $index): string + { + $definition = $index->getSqlDefinition($this->driver); + return sprintf('ADD %s', $definition); + } + + public function removeIndex(Index|string $index): string + { + $indexName = $index instanceof Index ? $index->getName() : $index; + + return sprintf('DROP INDEX %s', $this->driver->escapeIdentifier($indexName)); + } + + public function renameIndex(Index|string $from, Index|string $to): string + { + $from = $from instanceof Index ? $from->getName() : $from; + $to = $to instanceof Index ? $to->getName() : $to; + + return sprintf( + 'RENAME INDEX %s TO %s', + $this->driver->escapeIdentifier($from), + $this->driver->escapeIdentifier($to), + ); + } +} diff --git a/src/Driver/Mysql/Query/CreateDatabaseBuilder.php b/src/Driver/Mysql/Query/CreateDatabaseBuilder.php new file mode 100644 index 0000000..ccbadc6 --- /dev/null +++ b/src/Driver/Mysql/Query/CreateDatabaseBuilder.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Mysql\Query; + +use Access\Driver\DriverInterface; +use Access\Driver\Query\CreateDatabaseBuilderInterface; +use Access\Schema; +use Access\Schema\Charset; +use Access\Schema\Collate; + +/** + * @see https://dev.mysql.com/doc/refman/8.4/en/create-database.html + * @author Tim + * @internal + */ +class CreateDatabaseBuilder implements CreateDatabaseBuilderInterface +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function createOptions(Schema $schema): string + { + $defaultCharset = match ($schema->getDefaultCharset()) { + Charset::Utf8 => 'utf8mb4', + }; + + $collate = match ($schema->getDefaultCollate()) { + Collate::Default => 'utf8mb4_general_ci', + }; + + return sprintf( + 'DEFAULT CHARACTER SET=%s DEFAULT COLLATE=%s', + $this->driver->escapeIdentifier($defaultCharset), + $this->driver->escapeIdentifier($collate), + ); + } +} diff --git a/src/Driver/Mysql/Query/CreateTableBuilder.php b/src/Driver/Mysql/Query/CreateTableBuilder.php new file mode 100644 index 0000000..383279e --- /dev/null +++ b/src/Driver/Mysql/Query/CreateTableBuilder.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Mysql\Query; + +use Access\Driver\DriverInterface; +use Access\Driver\Query\CreateTableBuilderInterface; +use Access\Schema\Charset; +use Access\Schema\Collate; +use Access\Schema\Engine; +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; +use Access\Schema\Type; + +/** + * @author Tim + * @internal + */ +class CreateTableBuilder implements CreateTableBuilderInterface +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function primaryKey(Field $field): string + { + $type = $field->getType(); + assert($type instanceof Type\Integer); + + return sprintf('PRIMARY KEY (%s)', $this->driver->escapeIdentifier($field->getName())); + } + + public function foreignKey(Field $field): string + { + $type = $field->getType(); + assert($type instanceof Type\Reference); + + return sprintf( + 'FOREIGN KEY (%s) REFERENCES %s (%s)', + $this->driver->escapeIdentifier($field->getName()), + $this->driver->escapeIdentifier($type->getTableName()), + $this->driver->escapeIdentifier('id'), + ); + } + + public function index(Index $index): string + { + return $index->getSqlDefinition($this->driver); + } + + public function tableOptions(Table $table): string + { + $defaultCharset = match ($table->getDefaultCharset()) { + Charset::Utf8 => 'utf8mb4', + }; + + $collate = match ($table->getCollate()) { + Collate::Default => 'utf8mb4_general_ci', + }; + + $engine = match ($table->getEngine()) { + Engine::Default => 'InnoDB', + }; + + return sprintf( + 'DEFAULT CHARSET=%s COLLATE=%s ENGINE=%s', + $defaultCharset, + $collate, + $engine, + ); + } +} diff --git a/src/Driver/Query/AlterTableBuilderInterface.php b/src/Driver/Query/AlterTableBuilderInterface.php new file mode 100644 index 0000000..867ad16 --- /dev/null +++ b/src/Driver/Query/AlterTableBuilderInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Query; + +use Access\Clause\Field as ClauseField; +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; + +/** + * @author Tim + * @internal + */ +interface AlterTableBuilderInterface +{ + public function renameTable(Table|string $table): string; + + public function addField(Field $field): string; + public function removeField(ClauseField $field): string; + public function changeField(ClauseField $from, Field $to): string; + public function modifyField(Field $to): string; + public function renameField(ClauseField $from, ClauseField $to): string; + + public function addIndex(Index $index): string; + public function removeIndex(Index|string $index): string; + public function renameIndex(Index|string $from, Index|string $to): string; +} diff --git a/src/Driver/Query/CreateDatabaseBuilderInterface.php b/src/Driver/Query/CreateDatabaseBuilderInterface.php new file mode 100644 index 0000000..6058a38 --- /dev/null +++ b/src/Driver/Query/CreateDatabaseBuilderInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Query; + +use Access\Schema; + +/** + * @author Tim + * @internal + */ +interface CreateDatabaseBuilderInterface +{ + public function createOptions(Schema $schema): string; +} diff --git a/src/Driver/Query/CreateTableBuilderInterface.php b/src/Driver/Query/CreateTableBuilderInterface.php new file mode 100644 index 0000000..17a803b --- /dev/null +++ b/src/Driver/Query/CreateTableBuilderInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Query; + +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; + +/** + * @author Tim + * @internal + */ +interface CreateTableBuilderInterface +{ + public function primaryKey(Field $field): string; + public function foreignKey(Field $field): string; + public function index(Index $index): string; + public function tableOptions(Table $table): string; +} diff --git a/src/Driver/SqlTypeDefinitionBuilder.php b/src/Driver/SqlTypeDefinitionBuilder.php new file mode 100644 index 0000000..5505c56 --- /dev/null +++ b/src/Driver/SqlTypeDefinitionBuilder.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver; + +use Access\Exception; +use Access\Schema\Type; + +/** + * Base driver + * + * @author Tim + * @internal + */ +abstract class SqlTypeDefinitionBuilder implements SqlTypeDefinitionBuilderInterface +{ + public function fromType(Type $type): string + { + return match ($type::class) { + Type\Boolean::class => $this->fromBooleanType($type), + Type\Date::class => $this->fromDateType($type), + Type\DateTime::class => $this->fromDateTimeType($type), + Type\Enum::class => $this->fromEnumType($type), + Type\Integer::class => $this->fromIntegerType($type), + Type\FloatType::class => $this->fromFloatType($type), + Type\VarChar::class => $this->fromVarCharType($type), + Type\VarBinary::class => $this->fromVarBinaryType($type), + Type\Text::class => $this->fromTextType($type), + Type\Json::class => $this->fromJsonType($type), + Type\Reference::class => $this->fromReferenceType($type), + default => throw new Exception('Unsupported type: ' . $type::class), + }; + } +} diff --git a/src/Driver/SqlTypeDefinitionBuilderInterface.php b/src/Driver/SqlTypeDefinitionBuilderInterface.php new file mode 100644 index 0000000..a0bc509 --- /dev/null +++ b/src/Driver/SqlTypeDefinitionBuilderInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver; + +use Access\Schema\Field; +use Access\Schema\Type; + +/** + * Driver specific sql type definition interface + * + * @author Tim + * @internal + */ +interface SqlTypeDefinitionBuilderInterface +{ + public function fromField(Field $field): string; + + public function fromBooleanType(Type\Boolean $type): string; + public function fromDateType(Type\Date $type): string; + public function fromDateTimeType(Type\DateTime $type): string; + public function fromEnumType(Type\Enum $type): string; + public function fromIntegerType(Type\Integer $type): string; + public function fromFloatType(Type\FloatType $type): string; + public function fromVarCharType(Type\VarChar $type): string; + public function fromVarBinaryType(Type\VarBinary $type): string; + public function fromTextType(Type\Text $type): string; + public function fromJsonType(Type\Json $type): string; + public function fromReferenceType(Type\Reference $type): string; +} diff --git a/src/Driver/Sqlite.php b/src/Driver/Sqlite.php deleted file mode 100644 index 2d644b5..0000000 --- a/src/Driver/Sqlite.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Access\Driver; - -use Access\Clause\Field; - -/** - * SQLite specific driver - * - * @author Tim - * @internal - */ -class Sqlite implements DriverInterface -{ - public const NAME = 'sqlite'; - - /** - * Escape identifier - * - * @param string|Field $identifier Identifier to escape - * @return string - * @internal - */ - public function escapeIdentifier(string|Field $identifier): string - { - if ($identifier instanceof Field) { - $identifier = $identifier->getName(); - } - - return str_replace('.', '"."', sprintf('"%s"', str_replace('"', '""', $identifier))); - } - - /** - * Get a debug string value for a value in SQLite dialect - * - * Useful for the debug query, should not be used otherwise, use prepared statements - */ - public function getDebugStringValue(mixed $value): string - { - return sprintf("'%s'", addslashes((string) $value)); - } - - /** - * Get the function name for random in SQLite dialect - */ - public function getFunctionNameRandom(): string - { - return 'RANDOM()'; - } - - /** - * Has the SQLite driver support for LOCK/UNLOCK TABLES? - */ - public function hasLockSupport(): bool - { - return false; - } -} diff --git a/src/Driver/Sqlite/Query/AlterTableBuilder.php b/src/Driver/Sqlite/Query/AlterTableBuilder.php new file mode 100644 index 0000000..70561c9 --- /dev/null +++ b/src/Driver/Sqlite/Query/AlterTableBuilder.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Sqlite\Query; + +use Access\Clause\Field as ClauseField; +use Access\Driver\DriverInterface; +use Access\Driver\Query\AlterTableBuilderInterface; +use Access\Exception\NotSupportedException; +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; + +/** + * @see https://sqlite.org/lang_altertable.html + * @author Tim + * @internal + */ +class AlterTableBuilder implements AlterTableBuilderInterface +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function renameTable(Table|string $table): string + { + $table = $table instanceof Table ? $table->getName() : $table; + + return sprintf('RENAME TO %s', $this->driver->escapeIdentifier($table)); + } + + public function addField(Field $field): string + { + return sprintf('ADD COLUMN %s', $field->getSqlDefinition($this->driver)); + } + + public function removeField(ClauseField $field): string + { + return sprintf('DROP COLUMN %s', $this->driver->escapeIdentifier($field)); + } + + public function changeField(ClauseField $from, Field $to): string + { + // should this just be a no-op? + throw new NotSupportedException('SQLite does not support changing fields'); + } + + public function modifyField(Field $to): string + { + throw new NotSupportedException('SQLite does not support modifying fields'); + } + + public function renameField(ClauseField $from, ClauseField $to): string + { + return sprintf( + 'RENAME COLUMN %s TO %s', + $this->driver->escapeIdentifier($from), + $this->driver->escapeIdentifier($to), + ); + } + + public function addIndex(Index $index): string + { + throw new NotSupportedException('SQLite does not support adding indexes in alter tables'); + } + + public function removeIndex(Index|string $index): string + { + throw new NotSupportedException('SQLite does not support removing indexes in alter tables'); + } + + public function renameIndex(Index|string $from, Index|string $to): string + { + throw new NotSupportedException('SQLite does not support renaming indexes in alter tables'); + } +} diff --git a/src/Driver/Sqlite/Query/CreateDatabaseBuilder.php b/src/Driver/Sqlite/Query/CreateDatabaseBuilder.php new file mode 100644 index 0000000..4c79ead --- /dev/null +++ b/src/Driver/Sqlite/Query/CreateDatabaseBuilder.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Sqlite\Query; + +use Access\Driver\Query\CreateDatabaseBuilderInterface; +use Access\Exception\NotSupportedException; +use Access\Schema; + +/** + * @author Tim + * @internal + */ +class CreateDatabaseBuilder implements CreateDatabaseBuilderInterface +{ + public function createOptions(Schema $schema): string + { + throw new NotSupportedException('SQLite does not support CREATE DATABASE statement.'); + } +} diff --git a/src/Driver/Sqlite/Query/CreateTableBuilder.php b/src/Driver/Sqlite/Query/CreateTableBuilder.php new file mode 100644 index 0000000..c0d2cf5 --- /dev/null +++ b/src/Driver/Sqlite/Query/CreateTableBuilder.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Sqlite\Query; + +use Access\Clause; +use Access\Driver\DriverInterface; +use Access\Driver\Query\CreateTableBuilderInterface; +use Access\Schema\Field; +use Access\Schema\Index; +use Access\Schema\Table; +use Access\Schema\Type; + +/** + * @see https://sqlite.org/lang_createtable.html + * @author Tim + * @internal + */ +class CreateTableBuilder implements CreateTableBuilderInterface +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function primaryKey(Field $field): string + { + return ''; + } + + public function foreignKey(Field $field): string + { + $type = $field->getType(); + assert($type instanceof Type\Reference); + + return sprintf( + 'FOREIGN KEY (%s) REFERENCES %s (%s)', + $this->driver->escapeIdentifier($field->getName()), + $this->driver->escapeIdentifier($type->getTableName()), + $this->driver->escapeIdentifier('id'), + ); + } + + public function index(Index $index): string + { + if (!$index->isUnique()) { + // should this blow up? + return ''; + } + + $fields = array_map( + fn(Clause\Field|string $field) => $this->driver->escapeIdentifier( + $field instanceof Field ? $field->getName() : $field, + ), + $index->getFields(), + ); + + return sprintf('UNIQUE (%s)', implode(', ', $fields)); + } + + public function tableOptions(Table $table): string + { + return ''; + } +} diff --git a/src/Driver/Sqlite/Sqlite.php b/src/Driver/Sqlite/Sqlite.php new file mode 100644 index 0000000..c9d2d7f --- /dev/null +++ b/src/Driver/Sqlite/Sqlite.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Sqlite; + +use Access\Clause\Field; +use Access\Driver\Driver; +use Access\Driver\Query\AlterTableBuilderInterface; +use Access\Driver\Query\CreateDatabaseBuilderInterface; +use Access\Driver\Query\CreateTableBuilderInterface; +use Access\Driver\SqlTypeDefinitionBuilderInterface; +use Access\Driver\Sqlite\Query\AlterTableBuilder; +use Access\Driver\Sqlite\Query\CreateDatabaseBuilder; +use Access\Driver\Sqlite\Query\CreateTableBuilder; +use Access\Driver\Sqlite\SqliteSqlTypeDefinitionBuilder; +use Access\Exception; +use Access\Exception\DuplicateEntryException; +use Access\Exception\NotSupportedException; +use Access\Exception\TableDoesNotExistException; +use Access\ReadLock; +use Access\Schema\Index; + +/** + * SQLite specific driver + * + * @author Tim + * @internal + * @psalm-suppress MissingConstructor + * @psalm-suppress PropertyNotSetInConstructor + * @psalm-suppress RedundantPropertyInitializationCheck + */ +class Sqlite extends Driver +{ + public const NAME = 'sqlite'; + + private SqlTypeDefinitionBuilderInterface $sqlTypeDefinition; + private CreateDatabaseBuilder $createDatabaseBuilder; + private CreateTableBuilder $createTableBuilder; + private AlterTableBuilder $alterTableBuilder; + + /** + * Escape identifier + * + * @param string|Field $identifier Identifier to escape + * @return string + * @internal + */ + public function escapeIdentifier(string|Field $identifier): string + { + if ($identifier instanceof Field) { + $identifier = $identifier->getName(); + } + + return str_replace('.', '"."', sprintf('"%s"', str_replace('"', '""', $identifier))); + } + + /** + * Get a debug string value for a value in SQLite dialect + * + * Useful for the debug query, should not be used otherwise, use prepared statements + */ + public function getDebugStringValue(mixed $value): string + { + return sprintf("'%s'", addslashes((string) $value)); + } + + /** + * Convert a PDOException to a more specific Exception + */ + public function convertPdoException(\PDOException $e): ?Exception + { + $message = $e->getMessage(); + + if (strpos($message, 'no such table') !== false) { + return new TableDoesNotExistException( + sprintf('Table does not exists: %s', $message), + 0, + $e, + ); + } + + if (strpos($message, 'UNIQUE constraint failed') !== false) { + return new DuplicateEntryException(sprintf('Duplicate entry: %s', $message), 0, $e); + } + + return null; + } + + /** + * Get the function name for random in SQLite dialect + */ + public function getFunctionNameRandom(): string + { + return 'RANDOM()'; + } + + /** + * Has the SQLite driver support for LOCK/UNLOCK TABLES? + */ + public function hasLockSupport(): bool + { + return false; + } + + /** + * Get the SQL for a read lock + */ + public function getReadLockSql(ReadLock $readLock): string + { + return ''; + } + + public function getSqlTypeDefinitionBuilder(): SqlTypeDefinitionBuilderInterface + { + return $this->sqlTypeDefinition ??= new SqliteSqlTypeDefinitionBuilder($this); + } + + public function getSqlIndexDefinition(Index $index): string + { + throw new NotSupportedException('Creating indexes for SQLite not yet possible'); + } + + public function getCreateDatabaseBuilder(): CreateDatabaseBuilderInterface + { + return $this->createDatabaseBuilder ??= new CreateDatabaseBuilder(); + } + + public function getCreateTableBuilder(): CreateTableBuilderInterface + { + return $this->createTableBuilder ??= new CreateTableBuilder($this); + } + + public function getAlterTableBuilder(): AlterTableBuilderInterface + { + return $this->alterTableBuilder ??= new AlterTableBuilder($this); + } +} diff --git a/src/Driver/Sqlite/SqliteSqlTypeDefinitionBuilder.php b/src/Driver/Sqlite/SqliteSqlTypeDefinitionBuilder.php new file mode 100644 index 0000000..3f2c6cc --- /dev/null +++ b/src/Driver/Sqlite/SqliteSqlTypeDefinitionBuilder.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Driver\Sqlite; + +use Access\Driver\DriverInterface; +use Access\Driver\SqlTypeDefinitionBuilder; +use Access\Schema\Field; +use Access\Schema\Type; + +/** + * Base driver + * + * @author Tim + * @internal + */ +class SqliteSqlTypeDefinitionBuilder extends SqlTypeDefinitionBuilder +{ + private DriverInterface $driver; + + public function __construct(DriverInterface $driver) + { + $this->driver = $driver; + } + + public function fromField(Field $field): string + { + $innerType = $this->fromType($field->getType()); + + $parts = [$innerType]; + + if ($field->isNullable()) { + $parts[] = 'NULL'; + } else { + $parts[] = 'NOT NULL'; + } + + if ($field->hasStaticDefault()) { + $default = $field->getStaticDefaultValue(); + + if ($default === null) { + $parts[] = 'DEFAULT NULL'; + } else { + $parts[] = + 'DEFAULT ' . + $this->driver->getDebugSqlValue( + $field->getType()->toDatabaseFormatValue($default), + ); + } + } + + if ($field->isPrimaryKey()) { + $parts[] = 'PRIMARY KEY'; + } + + if ($field->hasAutoIncrement()) { + $parts[] = 'AUTOINCREMENT'; + } + + return implode(' ', $parts); + } + + public function fromBooleanType(Type\Boolean $type): string + { + return 'INTEGER'; + } + + public function fromDateType(Type\Date $type): string + { + return 'DATE'; + } + + public function fromDateTimeType(Type\DateTime $type): string + { + return 'DATETIME'; + } + + public function fromEnumType(Type\Enum $type): string + { + return 'TEXT'; + } + + public function fromIntegerType(Type\Integer $type): string + { + return 'INTEGER'; + } + + public function fromFloatType(Type\FloatType $type): string + { + return 'REAL'; + } + + public function fromVarCharType(Type\VarChar $type): string + { + return 'VARCHAR(' . $type->getSize() . ')'; + } + + public function fromVarBinaryType(Type\VarBinary $type): string + { + return 'BLOB'; + } + + public function fromTextType(Type\Text $type): string + { + return 'TEXT'; + } + + public function fromJsonType(Type\Json $type): string + { + return 'TEXT'; + } + + public function fromReferenceType(Type\Reference $type): string + { + return 'INTEGER'; + } +} diff --git a/src/Entity.php b/src/Entity.php index 94b0a14..b821803 100644 --- a/src/Entity.php +++ b/src/Entity.php @@ -17,7 +17,9 @@ use Access\Repository; use BackedEnum; use Psr\Clock\ClockInterface; -use ValueError; +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; /** * Entity functionality @@ -27,7 +29,7 @@ * @psalm-type FieldOptions = array{ * default?: mixed, * type?: self::FIELD_TYPE_*, - * enumName?: class-string, + * enumName?: class-string, * virtual?: bool, * excludeInCopy?: bool, * target?: class-string, @@ -108,6 +110,73 @@ public static function isSoftDeletable(): bool return false; } + public static function getTableSchema(): Table + { + return static::getGeneratedTableSchema(); + } + + /** + * Generate a table schema from the (legacy) field definitions + * @param array|null $fields + */ + protected static function getGeneratedTableSchema(?array $fields = null): Table + { + // a best effort implementation to create a table schema, + // should probably not be used to actually create a table. + + $table = new Table( + static::tableName(), + hasCreatedAt: static::timestamps() || static::creatable(), + hasUpdatedAt: static::timestamps(), + hasDeletedAt: static::isSoftDeletable(), + ); + + $fields ??= static::fields(); + + foreach ($fields as $name => $field) { + $type = null; + + if (isset($field['cascade']) && isset($field['target'])) { + $type = new Type\Reference($field['target'], $field['cascade']); + } elseif (isset($field['type'])) { + if ($field['type'] === self::FIELD_TYPE_ENUM && isset($field['enumName'])) { + $type = new Type\Enum($field['enumName']); + } else { + $type = match ($field['type']) { + self::FIELD_TYPE_INT => new Type\Integer(), + self::FIELD_TYPE_BOOL => new Type\Boolean(), + self::FIELD_TYPE_DATETIME => new Type\DateTime(), + self::FIELD_TYPE_DATE => new Type\Date(), + self::FIELD_TYPE_JSON => new Type\Json(), + default => null, + }; + } + } + + /** @var array{string, ?Type, mixed} $initArgs */ + $initArgs = [$name, $type]; + + if (array_key_exists('default', $field)) { + /** @psalm-suppress MixedAssignement */ + $initArgs[2] = $field['default']; + } + + $schemaField = new Field(...$initArgs); + + if (isset($field['virtual']) && $field['virtual'] === true) { + $schemaField->markAsVirtual(); + } + + if (isset($field['excludeInCopy']) && $field['excludeInCopy'] === true) { + $schemaField->setIncludeInCopy(false); + } + + $table->addField($schemaField); + } + + return $table; + } + /** * Get the repository class for entity * @@ -122,28 +191,33 @@ public static function getRepository(): string /** * Name of created at field + * @deprecated use Table::CREATED_AT_FIELD */ - public const CREATED_AT_FIELD = 'created_at'; + public const CREATED_AT_FIELD = Table::CREATED_AT_FIELD; /** * Name of updated at field + * @deprecated use Table::UPDATED_AT_FIELD */ - public const UPDATED_AT_FIELD = 'updated_at'; + public const UPDATED_AT_FIELD = Table::UPDATED_AT_FIELD; /** * Name of deleted at field + * @deprecated use Table::DELETED_AT_FIELD */ - public const DELETED_AT_FIELD = 'deleted_at'; + public const DELETED_AT_FIELD = Table::DELETED_AT_FIELD; /** * Date time format + * @deprecated use Type\DateTime::DATABASE_FORMAT */ - public const DATETIME_FORMAT = 'Y-m-d H:i:s'; + public const DATETIME_FORMAT = Type\DateTime::DATABASE_FORMAT; /** * Date format + * @deprecated use Type\Date::DATABASE_FORMAT */ - public const DATE_FORMAT = 'Y-m-d'; + public const DATE_FORMAT = Type\Date::DATABASE_FORMAT; /** * ID of the entity @@ -307,44 +381,37 @@ final public function getInsertValues(?ClockInterface $clock = null): array { $clock ??= new InternalClock(); + $table = $this->getResolvedTableSchema(); + $fields = $table->getFields(); $values = []; - $fields = $this->getResolvedFields(); - foreach ($fields as $field => $options) { - if (isset($options['virtual']) && $options['virtual'] === true) { + foreach ($fields as $field) { + if ($field->getIsVirtual()) { continue; - } elseif (array_key_exists($field, $this->values)) { + } elseif (array_key_exists($field->getName(), $this->values)) { /** @var mixed $value */ - $value = $this->values[$field]; - } elseif (array_key_exists('default', $options)) { - if (is_callable($options['default'])) { - $value = call_user_func($options['default'], $this); - } else { - $value = $options['default']; - } + $value = $this->values[$field->getName()]; + } elseif ($field->hasDefault()) { + $value = $field->getDefaultValue($this); } else { continue; } - $values[$field] = $this->toDatabaseFormat($field, $value); + $values[$field->getName()] = $field->toDatabaseFormatValue($value); } - if (static::timestamps() || static::creatable()) { - $values[self::CREATED_AT_FIELD] = $this->toDatabaseFormatValue( - self::FIELD_TYPE_DATETIME, - $clock->now(), - ); + $dateTimeType = new Type\DateTime(); + + if ($table->hasCreatedAt()) { + $values[Table::CREATED_AT_FIELD] = $dateTimeType->toDatabaseFormatValue($clock->now()); } - if (static::timestamps()) { - $values[self::UPDATED_AT_FIELD] = $this->toDatabaseFormatValue( - self::FIELD_TYPE_DATETIME, - $clock->now(), - ); + if ($table->hasUpdatedAt()) { + $values[Table::UPDATED_AT_FIELD] = $dateTimeType->toDatabaseFormatValue($clock->now()); } - if (static::isSoftDeletable() && !isset($values[self::DELETED_AT_FIELD])) { - $values[self::DELETED_AT_FIELD] = null; + if ($table->hasDeletedAt() && !isset($this->values[Table::DELETED_AT_FIELD])) { + $values[Table::DELETED_AT_FIELD] = null; } return $values; @@ -361,34 +428,35 @@ final public function getUpdateValues(?ClockInterface $clock = null): array /** @var array $values */ $values = []; - $fields = $this->getResolvedFields(); - foreach ($this->updatedFields as $field => $value) { - if ($field === self::DELETED_AT_FIELD) { - $values[self::DELETED_AT_FIELD] = $this->toDatabaseFormatValue( - self::FIELD_TYPE_DATETIME, - $value, - ); + $table = $this->getResolvedTableSchema(); + + $dateTimeType = new Type\DateTime(); + + foreach ($this->updatedFields as $fieldName => $value) { + if ($fieldName === Table::DELETED_AT_FIELD) { + $values[Table::DELETED_AT_FIELD] = $dateTimeType->toDatabaseFormatValue($value); continue; } - if (isset($fields[$field])) { - $options = $fields[$field]; + $field = $table->getField($fieldName); + if ($field !== null && $field->getIsVirtual()) { + continue; + } - if (isset($options['virtual']) && $options['virtual'] === true) { - continue; - } + if ($field === null) { + // field not in schema, just use raw value + $values[$fieldName] = $value; + + continue; } - $values[$field] = $this->toDatabaseFormat($field, $value); + $values[$fieldName] = $field->toDatabaseFormatValue($value); } - if (!empty($values) && static::timestamps()) { - $values[self::UPDATED_AT_FIELD] = $this->toDatabaseFormatValue( - self::FIELD_TYPE_DATETIME, - $clock->now(), - ); + if (!empty($values) && $table->hasUpdatedAt()) { + $values[Table::UPDATED_AT_FIELD] = $dateTimeType->toDatabaseFormatValue($clock->now()); } return $values; @@ -407,20 +475,30 @@ final public function markUpdated(?array $updatedFields = null): void $this->updatedFields = []; if ($updatedFields !== null) { + $table = $this->getResolvedTableSchema(); + $datetimeType = new Type\DateTime(); + /** @var mixed $value */ - foreach ($updatedFields as $field => $value) { - if ($this->isBuiltinDatetimeField($field)) { - $this->values[$field] = $this->fromDatabaseFormatValue( - $field, - self::FIELD_TYPE_DATETIME, - $value, - ); + foreach ($updatedFields as $fieldName => $value) { + if ($table->isBuiltinDatetimeField($fieldName)) { + if ($value === null) { + $this->values[$fieldName] = null; + } else { + $this->values[$fieldName] = $datetimeType->fromDatabaseFormatValue($value); + } continue; } - if (!array_key_exists($field, $this->values)) { - $this->values[$field] = $this->fromDatabaseFormat($field, $value); + if (!array_key_exists($fieldName, $this->values)) { + $field = $table->getField($fieldName); + + if ($field === null) { + // field not in schema, just use raw value + $this->values[$fieldName] = $value; + } else { + $this->values[$fieldName] = $field->fromDatabaseFormatValue($value); + } } } } @@ -433,24 +511,34 @@ final public function markUpdated(?array $updatedFields = null): void */ final public function hydrate(array $record): void { + $table = $this->getResolvedTableSchema(); + $datetimeType = new Type\DateTime(); + /** @var mixed $value */ - foreach ($record as $field => $value) { + foreach ($record as $fieldName => $value) { // the ID has special treatment - if ($field === 'id') { + if ($fieldName === 'id') { continue; } - if ($this->isBuiltinDatetimeField($field)) { - $this->values[$field] = $this->fromDatabaseFormatValue( - $field, - self::FIELD_TYPE_DATETIME, - $value, - ); + if ($table->isBuiltinDatetimeField($fieldName)) { + if ($value === null) { + $this->values[$fieldName] = null; + } else { + $this->values[$fieldName] = $datetimeType->fromDatabaseFormatValue($value); + } continue; } - $this->values[$field] = $this->fromDatabaseFormat($field, $value); + $field = $table->getField($fieldName); + + if ($field === null) { + // field not in schema, just use raw value + $this->values[$fieldName] = $value; + } else { + $this->values[$fieldName] = $field->fromDatabaseFormatValue($value); + } } if (isset($record['id'])) { @@ -461,241 +549,15 @@ final public function hydrate(array $record): void } /** - * Get a value for a field in the database format + * Get the (resolved) table schema * - * @param string $field - * @param mixed $value - * @return mixed - */ - private function toDatabaseFormat(string $field, mixed $value): mixed - { - $fields = $this->getResolvedFields(); - - if (isset($fields[$field])) { - $options = $fields[$field]; - - if (!isset($options['type'])) { - return $value; - } - - $value = $this->toDatabaseFormatValue($options['type'], $value); - } - - return $value; - } - - /** - * Get a value for a type in the database format - * - * @param string $type - * @param mixed $value - * @return mixed - */ - private function toDatabaseFormatValue(string $type, mixed $value): mixed - { - if ($value === null) { - return $value; - } - - switch ($type) { - case self::FIELD_TYPE_BOOL: - return intval($value); - - case self::FIELD_TYPE_DATETIME: - /** @var \DateTimeInterface $value */ - return $this->fromMutable($value) - ->setTimezone(new \DateTimeZone('UTC')) - ->format(self::DATETIME_FORMAT); - - case self::FIELD_TYPE_DATE: - /** @var \DateTimeInterface $value */ - return $this->fromMutable($value) - ->setTimezone(new \DateTimeZone('UTC')) - ->format(self::DATE_FORMAT); - - case self::FIELD_TYPE_JSON: - return json_encode($value); - - case self::FIELD_TYPE_ENUM: - if ($value instanceof BackedEnum) { - return $value->value; - } - - return $value; - - default: - return $value; - } - } - - /** - * Get a value for a field as a PHP value + * Defaults to `self::getTableSchema` * - * @param string $field - * @param mixed $value - * @return mixed - */ - private function fromDatabaseFormat(string $field, mixed $value): mixed - { - $fields = $this->getResolvedFields(); - - if (isset($fields[$field])) { - $options = $fields[$field]; - - if (!isset($options['type'])) { - return $value; - } - - $value = $this->fromDatabaseFormatValue( - $field, - $options['type'], - $value, - $options['enumName'] ?? null, - ); - } - - return $value; - } - - /** - * Get a value for a type as a PHP value - * - * @param string $field - * @param string $type - * @param mixed $value - * @param class-string|null $enumName - * @return mixed - */ - private function fromDatabaseFormatValue( - string $field, - string $type, - mixed $value, - ?string $enumName = null, - ): mixed { - if ($value === null) { - return $value; - } - - switch ($type) { - case self::FIELD_TYPE_INT: - return intval($value); - - case self::FIELD_TYPE_BOOL: - return boolval($value); - - case self::FIELD_TYPE_DATETIME: - if ($value instanceof \DateTimeInterface) { - return $this->fromMutable($value); - } - - if (!is_string($value)) { - throw new Exception('Invalid datetime value'); - } - - return \DateTimeImmutable::createFromFormat( - self::DATETIME_FORMAT, - $value, - new \DateTimeZone('UTC'), - ); - - case self::FIELD_TYPE_DATE: - if (!is_string($value)) { - throw new Exception('Invalid date value'); - } - - return \DateTimeImmutable::createFromFormat( - self::DATE_FORMAT, - $value, - new \DateTimeZone('UTC'), - ); - - case self::FIELD_TYPE_JSON: - if (!is_string($value)) { - throw new Exception('Invalid json value'); - } - - return json_decode($value, true); - - case self::FIELD_TYPE_ENUM: - if (empty($enumName)) { - throw new Exception(sprintf('Missing enum name for field "%s"', $field)); - } - - if (!is_subclass_of($enumName, BackedEnum::class)) { - throw new Exception( - sprintf('Invalid enum name for field "%s": %s', $field, $enumName), - ); - } - - if (!is_int($value) && !is_string($value)) { - throw new Exception('Invalid backing value for enum'); - } - - try { - return $enumName::from($value); - } catch (ValueError $e) { - throw new Exception('Invalid enum value', $e->getCode(), $e); - } - - default: - return $value; - } - } - - /** - * Is the given field a built-in date time field - * - * Checks if the feature for those fields is enabled and the predetermined names - * - * @param string $field The field name - * @return bool Is a built-in date time field - */ - private function isBuiltinDatetimeField(string $field): bool - { - if (static::creatable() && $field === self::CREATED_AT_FIELD) { - return true; - } - - if ( - static::timestamps() && - ($field === self::CREATED_AT_FIELD || $field === self::UPDATED_AT_FIELD) - ) { - return true; - } - - if (static::isSoftDeletable() && $field === self::DELETED_AT_FIELD) { - return true; - } - - return false; - } - - /** - * Make mutable date immutable, if needed - * - * @param \DateTimeInterface $date - * @return \DateTimeImmutable - */ - private function fromMutable(\DateTimeInterface $date): \DateTimeImmutable - { - if ($date instanceof \DateTimeImmutable) { - return $date; - } - - return new \DateTimeImmutable($date->format('Y-m-d H:i:s.u'), $date->getTimezone()); - } - - /** - * Get the (resolved) field definitions - * - * Defaults to `self::fields` - * - * @return array - * @psalm-return array + * @return Table */ - protected function getResolvedFields(): array + protected function getResolvedTableSchema(): Table { - return static::fields(); + return static::getTableSchema(); } /** @@ -713,15 +575,15 @@ public function copy(): static $record = $this->getValues(); - unset($record[self::CREATED_AT_FIELD]); - unset($record[self::UPDATED_AT_FIELD]); - unset($record[self::DELETED_AT_FIELD]); + unset($record[Table::CREATED_AT_FIELD]); + unset($record[Table::UPDATED_AT_FIELD]); + unset($record[Table::DELETED_AT_FIELD]); - $fields = $this->getResolvedFields(); + $fields = $this->getResolvedTableSchema()->getFields(); - foreach ($fields as $field => $options) { - if (isset($options['excludeInCopy']) && $options['excludeInCopy']) { - unset($record[$field]); + foreach ($fields as $field) { + if (!$field->getIncludeInCopy()) { + unset($record[$field->getName()]); } } diff --git a/src/Entity/CreatableTrait.php b/src/Entity/CreatableTrait.php index 1204887..ae23553 100644 --- a/src/Entity/CreatableTrait.php +++ b/src/Entity/CreatableTrait.php @@ -13,7 +13,7 @@ namespace Access\Entity; -use Access\Entity; +use Access\Schema\Table; /** * Helper methods to work with the create_at field @@ -39,6 +39,6 @@ public static function creatable(): bool */ public function getCreatedAt(): \DateTimeImmutable { - return $this->get(Entity::CREATED_AT_FIELD); + return $this->get(Table::CREATED_AT_FIELD); } } diff --git a/src/Entity/SoftDeletableTrait.php b/src/Entity/SoftDeletableTrait.php index 0f616cd..a2342f2 100644 --- a/src/Entity/SoftDeletableTrait.php +++ b/src/Entity/SoftDeletableTrait.php @@ -13,7 +13,7 @@ namespace Access\Entity; -use Access\Entity; +use Access\Schema\Table; /** * Soft deletable functionality for entity @@ -41,7 +41,7 @@ public static function isSoftDeletable(): bool */ public function setDeletedAt(?\DateTimeImmutable $now = null): void { - $this->set(Entity::DELETED_AT_FIELD, $now ?? new \DateTimeImmutable()); + $this->set(Table::DELETED_AT_FIELD, $now ?? new \DateTimeImmutable()); } /** @@ -49,6 +49,6 @@ public function setDeletedAt(?\DateTimeImmutable $now = null): void */ public function getDeletedAt(): ?\DateTimeImmutable { - return $this->get(Entity::DELETED_AT_FIELD); + return $this->get(Table::DELETED_AT_FIELD); } } diff --git a/src/Entity/TimestampableTrait.php b/src/Entity/TimestampableTrait.php index 3ad7158..d45adda 100644 --- a/src/Entity/TimestampableTrait.php +++ b/src/Entity/TimestampableTrait.php @@ -13,7 +13,7 @@ namespace Access\Entity; -use Access\Entity; +use Access\Schema\Table; /** * Helper methods to work with the timestamps @@ -43,6 +43,6 @@ public static function timestamps(): bool */ public function getUpdatedAt(): \DateTimeImmutable { - return $this->get(Entity::UPDATED_AT_FIELD); + return $this->get(Table::UPDATED_AT_FIELD); } } diff --git a/src/EntityProvider/VirtualArrayEntityProvider.php b/src/EntityProvider/VirtualArrayEntityProvider.php index 2cbbe66..07aad2e 100644 --- a/src/EntityProvider/VirtualArrayEntityProvider.php +++ b/src/EntityProvider/VirtualArrayEntityProvider.php @@ -15,6 +15,7 @@ use Access\Entity; use Access\Cascade; +use BackedEnum; /** * Provide empty entity shells for virtual array use @@ -25,7 +26,7 @@ * @psalm-type FieldOptions = array{ * default?: mixed, * type?: Entity::FIELD_TYPE_*, - * enumName?: class-string, + * enumName?: class-string, * virtual?: bool, * excludeInCopy?: bool, * target?: class-string, diff --git a/src/EntityProvider/VirtualEntity.php b/src/EntityProvider/VirtualEntity.php index 6e7541e..4b26b59 100644 --- a/src/EntityProvider/VirtualEntity.php +++ b/src/EntityProvider/VirtualEntity.php @@ -15,6 +15,8 @@ use Access\Cascade; use Access\Entity; +use Access\Schema\Table; +use BackedEnum; /** * Base entity class to fetch a virtual entity @@ -24,7 +26,7 @@ * @psalm-type FieldOptions = array{ * default?: mixed, * type?: Entity::FIELD_TYPE_*, - * enumName?: class-string, + * enumName?: class-string, * virtual?: bool, * excludeInCopy?: bool, * target?: class-string, @@ -67,7 +69,7 @@ public static function tableName(): string /** * Return a empty table definition * - * Method is never executed due to overload of `getResolvedFields` method + * Method is never executed due to overload of `getResolvedTableSchema` method * @codeCoverageIgnore * * @return array @@ -79,13 +81,10 @@ public static function fields(): array } /** - * Resolved table definition with virtual field info - * - * @return array - * @psalm-return array + * Return the resolved table schema based on virtual fields */ - protected function getResolvedFields(): array + protected function getResolvedTableSchema(): Table { - return $this->fields; + return static::getGeneratedTableSchema($this->fields); } } diff --git a/src/Exception/ConnectionGoneException.php b/src/Exception/ConnectionGoneException.php new file mode 100644 index 0000000..39bfd33 --- /dev/null +++ b/src/Exception/ConnectionGoneException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Exception; + +use Access\Exception; + +/** + * Access specific "Connection gone" exception + * + * @author Tim + */ +class ConnectionGoneException extends Exception {} diff --git a/src/Exception/DuplicateEntryException.php b/src/Exception/DuplicateEntryException.php new file mode 100644 index 0000000..5ac0ee6 --- /dev/null +++ b/src/Exception/DuplicateEntryException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Exception; + +use Access\Exception; + +/** + * Access specific "unique constraint" exception + * + * @author Tim + */ +class DuplicateEntryException extends Exception {} diff --git a/src/Exception/LockNotAcquiredException.php b/src/Exception/LockNotAcquiredException.php new file mode 100644 index 0000000..5c240b1 --- /dev/null +++ b/src/Exception/LockNotAcquiredException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Exception; + +use Access\Exception; + +/** + * Access specific "Lock not acquired" exception + * + * @author Tim + */ +class LockNotAcquiredException extends Exception {} diff --git a/src/Exception/TableDoesNotExistException.php b/src/Exception/TableDoesNotExistException.php new file mode 100644 index 0000000..a846440 --- /dev/null +++ b/src/Exception/TableDoesNotExistException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Exception; + +use Access\Exception; + +/** + * Access specific "Table does not exist" exception + * + * @author Tim + */ +class TableDoesNotExistException extends Exception {} diff --git a/src/Migrations/Checkpoint.php b/src/Migrations/Checkpoint.php new file mode 100644 index 0000000..ab14f2a --- /dev/null +++ b/src/Migrations/Checkpoint.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +/** + * Migration checkpoint to track steps + * + * Used to skip migration steps that have already been executed; useful when + * creating/developing migrations and a query fails. With a checkpoint, + * successful steps of a migration can be skipped. + * + * @author Tim + */ +class Checkpoint +{ + public function __construct(private int $step = 0) {} + + public function getStep(): int + { + return $this->step; + } + + /** + * Should the migration step be skipped + */ + public function shouldSkip(int $step): bool + { + return $step < $this->step; + } + + /** + * Advance the checkpoint to the next step + */ + public function advance(): void + { + $this->step += 1; + } +} diff --git a/src/Migrations/Exception/MigrationFailedException.php b/src/Migrations/Exception/MigrationFailedException.php new file mode 100644 index 0000000..55363d6 --- /dev/null +++ b/src/Migrations/Exception/MigrationFailedException.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations\Exception; + +use Access\Exception; +use Access\Migrations\Checkpoint; +use Access\Migrations\MigrationResult; +use Access\Migrations\SchemaChanges; + +class MigrationFailedException extends Exception +{ + private MigrationResult $migrationResult; + + public function __construct(MigrationResult $migrationResult, \Throwable $previous) + { + $this->migrationResult = $migrationResult; + + parent::__construct( + sprintf('%s: %s', $migrationResult->getMessage(), $previous->getMessage()), + 0, + $previous, + ); + } + + public function getChanges(): SchemaChanges + { + $changes = $this->migrationResult->getChanges(); + + // the failure case always has schema changes + assert($changes instanceof SchemaChanges); + + return $changes; + } + + public function getCheckpoint(): Checkpoint + { + $checkpoint = $this->migrationResult->getCheckpoint(); + + // the failure case always has a checkpoint + assert($checkpoint instanceof Checkpoint); + + return $checkpoint; + } +} diff --git a/src/Migrations/Exception/NotInitializedException.php b/src/Migrations/Exception/NotInitializedException.php new file mode 100644 index 0000000..4acfa3c --- /dev/null +++ b/src/Migrations/Exception/NotInitializedException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations\Exception; + +use Access\Exception; + +class NotInitializedException extends Exception {} diff --git a/src/Migrations/Migration.php b/src/Migrations/Migration.php new file mode 100644 index 0000000..00f640a --- /dev/null +++ b/src/Migrations/Migration.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +/** + * Migration + * + * @author Tim + */ +abstract class Migration +{ + public function getDescription(): string + { + return ''; + } + + abstract public function constructive(SchemaChanges $schemaChanges): void; + + public function destructive(SchemaChanges $schemaChanges): void {} + + abstract public function revertConstructive(SchemaChanges $schemaChanges): void; + + public function revertDestructive(SchemaChanges $schemaChanges): void {} +} diff --git a/src/Migrations/MigrationEntity.php b/src/Migrations/MigrationEntity.php new file mode 100644 index 0000000..a7e19ed --- /dev/null +++ b/src/Migrations/MigrationEntity.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +use Access\Entity; +use Access\Schema\Table; +use Access\Schema\Type; + +/** + * @psalm-suppress MixedReturnStatement + */ +class MigrationEntity extends Entity +{ + public static function tableName(): string + { + return 'access_migration_versions'; + } + + public static function fields(): array + { + return []; + } + + public static function getTableSchema(): Table + { + $table = new Table( + static::tableName(), + hasCreatedAt: true, + hasUpdatedAt: true, + hasDeletedAt: true, + ); + + $version = $table->field('version'); + $versionIndex = $table->index('version_index', $version); + $versionIndex->unique(); + + $table->field('constructive_executed_at', new Type\DateTime(), null); + $table->field('destructive_executed_at', new Type\DateTime(), null); + $table->field('constructive_reverted_at', new Type\DateTime(), null); + $table->field('destructive_reverted_at', new Type\DateTime(), null); + + return $table; + } + + public function setVersion(string $version): void + { + $this->set('version', $version); + } + + public function getVersion(): ?string + { + return $this->get('version'); + } + + public function setConstructiveExecutedAt(?\DateTimeImmutable $constructiveExecutedAt): void + { + $this->set('constructive_executed_at', $constructiveExecutedAt); + } + + public function getConstructiveExecutedAt(): ?\DateTimeImmutable + { + return $this->get('constructive_executed_at'); + } + + public function setDestructiveExecutedAt(?\DateTimeImmutable $destructiveExecutedAt): void + { + $this->set('destructive_executed_at', $destructiveExecutedAt); + } + + public function getDestructiveExecutedAt(): ?\DateTimeImmutable + { + return $this->get('destructive_executed_at'); + } + + public function setConstructiveRevertedAt(?\DateTimeImmutable $constructiveRevertedAt): void + { + $this->set('constructive_reverted_at', $constructiveRevertedAt); + } + + public function getConstructiveRevertedAt(): ?\DateTimeImmutable + { + return $this->get('constructive_reverted_at'); + } + + public function setDestructiveRevertedAt(?\DateTimeImmutable $destructiveRevertedAt): void + { + $this->set('destructive_reverted_at', $destructiveRevertedAt); + } + + public function getDestructiveRevertedAt(): ?\DateTimeImmutable + { + return $this->get('destructive_reverted_at'); + } +} diff --git a/src/Migrations/MigrationResult.php b/src/Migrations/MigrationResult.php new file mode 100644 index 0000000..e800095 --- /dev/null +++ b/src/Migrations/MigrationResult.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +enum MigrationResultType +{ + case Success; + case Failure; + case ConstructiveNotExecuted; + case BlockedByDestructiveChange; + case DestructiveNotExecuted; + case AlreadyExecuted; + + public function isSuccess(): bool + { + return $this === self::Success; + } + + public function isFailre(): bool + { + return $this === self::Failure; + } + + public function isWarning(): bool + { + return $this === self::AlreadyExecuted; + } + + public function isError(): bool + { + return $this === self::ConstructiveNotExecuted || + $this === self::BlockedByDestructiveChange || + $this === self::DestructiveNotExecuted; + } + + public function getMessage(): string + { + return match ($this) { + self::Success => 'Migration executed successfully', + self::Failure => 'Migration execution failed', + self::ConstructiveNotExecuted => 'Constructive migration was not executed', + self::BlockedByDestructiveChange => 'Blocked by destructive change', + self::DestructiveNotExecuted => 'Destructive migration was not executed', + self::AlreadyExecuted => 'Migration has already been executed', + }; + } +} + +class MigrationResult +{ + private ?SchemaChanges $changes; + private MigrationResultType $type; + private ?Checkpoint $checkpoint = null; + + private function __construct( + ?SchemaChanges $changes, + MigrationResultType $type, + ?Checkpoint $checkpoint = null, + ) { + $this->changes = $changes; + $this->type = $type; + $this->checkpoint = $checkpoint; + } + + public static function success(SchemaChanges $changes): self + { + return new self($changes, MigrationResultType::Success); + } + + public static function failure(SchemaChanges $changes, Checkpoint $checkpoint): self + { + return new self($changes, MigrationResultType::Failure, $checkpoint); + } + + public static function constructiveNotExecuted(): self + { + return new self(null, MigrationResultType::ConstructiveNotExecuted); + } + + public static function blockedByDestructiveChange(): self + { + return new self(null, MigrationResultType::BlockedByDestructiveChange); + } + + public static function destructiveNotExecuted(): self + { + return new self(null, MigrationResultType::DestructiveNotExecuted); + } + + public static function alreadyExecuted(): self + { + return new self(null, MigrationResultType::AlreadyExecuted); + } + + public function getChanges(): ?SchemaChanges + { + return $this->changes; + } + + public function getCheckpoint(): ?Checkpoint + { + return $this->checkpoint; + } + + public function isSuccess(): bool + { + return $this->type->isSuccess(); + } + + public function isWarning(): bool + { + return $this->type->isWarning(); + } + + public function isError(): bool + { + return $this->type->isError(); + } + + public function getMessage(): string + { + return $this->type->getMessage(); + } +} diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php new file mode 100644 index 0000000..e08d927 --- /dev/null +++ b/src/Migrations/Migrator.php @@ -0,0 +1,270 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +use Access\Database; +use Access\Entity; +use Access\Exception\TableDoesNotExistException; +use Access\Migrations\Exception\MigrationFailedException; +use Access\Migrations\Exception\NotInitializedException; +use Access\Query\CreateTable; + +/** + * Migrator + * + * State machine: + * + * ```mermaid + * stateDiagram-v2 + * [*] --> NotInitialized + * NotInitialized --> Initialized : init() + * Initialized --> ConstructiveExecuted : constructive() + * ConstructiveExecuted --> ConstructiveReverted : revertConstructive() + * ConstructiveExecuted --> DestructiveExecuted : destructive() + * ConstructiveReverted --> ConstructiveExecuted : constructive() + * DestructiveExecuted --> DestructiveReverted : revertDestructive() + * DestructiveReverted --> DestructiveExecuted : destructive() + * DestructiveReverted --> ConstructiveReverted : revertConstructive() + * ``` + * + * @author Tim + */ +class Migrator +{ + private Database $db; + + /** + * @var class-string $migrationsTableEntity + */ + private string $migrationsTableEntity; + + private bool $dryRun = false; + + /** + * @template TEntity of MigrationEntity + * @param class-string $migrationsTableEntity + */ + public function __construct( + Database $db, + string $migrationsTableEntity = MigrationEntity::class, + ) { + $this->db = $db; + $this->migrationsTableEntity = $migrationsTableEntity; + } + + public function init(): void + { + try { + $migrationRecords = $this->db->getRepository($this->migrationsTableEntity)->findAll(1); + iterator_to_array($migrationRecords); + } catch (TableDoesNotExistException) { + try { + $table = $this->migrationsTableEntity::getTableSchema(); + + $query = new CreateTable($table); + + $this->db->query($query); + } catch (\Throwable $e) { + throw new NotInitializedException( + 'Could not create migrations table: ' . $e->getMessage(), + 0, + $e, + ); + } + } + } + + public function setDryRun(bool $dryRun): void + { + $this->dryRun = $dryRun; + } + + public function constructive( + Migration $migration, + Checkpoint $checkpoint = new Checkpoint(), + ): MigrationResult { + $checkpoint = clone $checkpoint; + + $migrationRecord = $this->getMigrationRecord($migration); + + if ($migrationRecord !== null && $migrationRecord->getConstructiveExecutedAt()) { + return MigrationResult::alreadyExecuted(); + } + + $schemaChanges = new SchemaChanges(); + $migration->constructive($schemaChanges); + + if ($this->dryRun) { + return MigrationResult::success($schemaChanges); + } + + try { + $schemaChanges->applyChanges($this->db, $checkpoint); + } catch (\Throwable $e) { + $result = MigrationResult::failure($schemaChanges, $checkpoint); + throw new MigrationFailedException($result, $e); + } + + if ($migrationRecord === null) { + $migrationRecord = $this->createMigrationRecord($migration); + } + + $migrationRecord->setConstructiveExecutedAt($this->db->now()); + $migrationRecord->setConstructiveRevertedAt(null); + $this->db->save($migrationRecord); + + return MigrationResult::success($schemaChanges); + } + + public function destructive( + Migration $migration, + Checkpoint $checkpoint = new Checkpoint(), + ): MigrationResult { + $checkpoint = clone $checkpoint; + + $migrationRecord = $this->getMigrationRecord($migration); + + if ($migrationRecord === null || $migrationRecord->getConstructiveExecutedAt() === null) { + return MigrationResult::constructiveNotExecuted(); + } + + if ($migrationRecord->getDestructiveExecutedAt()) { + return MigrationResult::alreadyExecuted(); + } + + $schemaChanges = new SchemaChanges(); + $migration->destructive($schemaChanges); + + if ($this->dryRun) { + return MigrationResult::success($schemaChanges); + } + + try { + $schemaChanges->applyChanges($this->db, $checkpoint); + } catch (\Throwable $e) { + $result = MigrationResult::failure($schemaChanges, $checkpoint); + throw new MigrationFailedException($result, $e); + } + + $migrationRecord->setDestructiveExecutedAt($this->db->now()); + $migrationRecord->setDestructiveRevertedAt(null); + $this->db->save($migrationRecord); + + return MigrationResult::success($schemaChanges); + } + + public function revertConstructive( + Migration $migration, + Checkpoint $checkpoint = new Checkpoint(), + ): MigrationResult { + $checkpoint = clone $checkpoint; + + $migrationRecord = $this->getMigrationRecord($migration); + + if ($migrationRecord === null || $migrationRecord->getConstructiveExecutedAt() === null) { + return MigrationResult::constructiveNotExecuted(); + } + + if ( + $migrationRecord->getDestructiveExecutedAt() !== null && + $migrationRecord->getDestructiveRevertedAt() === null + ) { + return MigrationResult::blockedByDestructiveChange(); + } + + if ($migrationRecord->getConstructiveRevertedAt() !== null) { + return MigrationResult::alreadyExecuted(); + } + + $schemaChanges = new SchemaChanges(); + $migration->revertConstructive($schemaChanges); + + if ($this->dryRun) { + return MigrationResult::success($schemaChanges); + } + + try { + $schemaChanges->applyChanges($this->db, $checkpoint); + } catch (\Throwable $e) { + $result = MigrationResult::failure($schemaChanges, $checkpoint); + throw new MigrationFailedException($result, $e); + } + + $migrationRecord->setConstructiveExecutedAt(null); + $migrationRecord->setConstructiveRevertedAt($this->db->now()); + $this->db->save($migrationRecord); + + return MigrationResult::success($schemaChanges); + } + + public function revertDestructive( + Migration $migration, + Checkpoint $checkpoint = new Checkpoint(), + ): MigrationResult { + $checkpoint = clone $checkpoint; + + $migrationRecord = $this->getMigrationRecord($migration); + + if ($migrationRecord === null || $migrationRecord->getConstructiveExecutedAt() === null) { + return MigrationResult::constructiveNotExecuted(); + } + + if ($migrationRecord->getDestructiveExecutedAt() === null) { + return MigrationResult::destructiveNotExecuted(); + } + + $schemaChanges = new SchemaChanges(); + $migration->revertDestructive($schemaChanges); + + if ($this->dryRun) { + return MigrationResult::success($schemaChanges); + } + + try { + $schemaChanges->applyChanges($this->db, $checkpoint); + } catch (\Throwable $e) { + $result = MigrationResult::failure($schemaChanges, $checkpoint); + throw new MigrationFailedException($result, $e); + } + + $migrationRecord->setDestructiveExecutedAt(null); + $migrationRecord->setDestructiveRevertedAt($this->db->now()); + $this->db->save($migrationRecord); + + return MigrationResult::success($schemaChanges); + } + + private function getMigrationRecord(Migration $migration): ?MigrationEntity + { + /** @var MigrationEntity|null $migrationRecord */ + $migrationRecord = $this->db->getRepository($this->migrationsTableEntity)->findOneBy([ + 'version' => $migration::class, + ]); + + return $migrationRecord; + } + + private function createMigrationRecord(Migration $migration): MigrationEntity + { + /** + * The user wouldn't do this, right? Right?! + * @psalm-suppress UnsafeInstantiation + * @var MigrationEntity $migrationRecord + */ + $migrationRecord = new $this->migrationsTableEntity(); + $migrationRecord->setVersion($migration::class); + + return $migrationRecord; + } +} diff --git a/src/Migrations/SchemaChanges.php b/src/Migrations/SchemaChanges.php new file mode 100644 index 0000000..4869867 --- /dev/null +++ b/src/Migrations/SchemaChanges.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Migrations; + +use Access\Database; +use Access\Query; +use Access\Query\AlterTable; +use Access\Query\CreateTable; +use Access\Query\DropTable; +use Access\Schema\Table; + +enum ChangeType +{ + case CreateTable; + case AlterTable; + case DropTable; + case Query; +} + +/** + * Schema change + * + * @author Tim + */ +class SchemaChanges +{ + /** + * @var array + */ + private array $changes = []; + + public function createTable( + string $name, + bool $hasCreatedAt = false, + bool $hasUpdatedAt = false, + bool $hasDeletedAt = false, + ): Table { + $table = new Table($name, $hasCreatedAt, $hasUpdatedAt, $hasDeletedAt); + + $this->changes[] = [ + 'type' => ChangeType::CreateTable, + 'createTable' => $table, + ]; + + return $table; + } + + public function alterTable(string $name): AlterTable + { + $alterTable = new AlterTable(new Table($name)); + + $this->changes[] = [ + 'type' => ChangeType::AlterTable, + 'alterTable' => $alterTable, + ]; + + return $alterTable; + } + + public function dropTable(string $name): void + { + $table = new Table($name); + + $this->changes[] = [ + 'type' => ChangeType::DropTable, + 'dropTable' => $table, + ]; + } + + public function query(Query $query): void + { + $this->changes[] = [ + 'type' => ChangeType::Query, + 'query' => $query, + ]; + } + + /** + * @return Query[] + */ + private function generateQueries(Checkpoint $checkpoint): array + { + $queries = []; + $step = 0; + + foreach ($this->changes as $change) { + if ($checkpoint->shouldSkip($step)) { + $step++; + continue; + } + + switch ($change['type']) { + case ChangeType::CreateTable: + assert(isset($change['createTable']), 'Create table must exist'); + + $query = new CreateTable($change['createTable']); + $queries[] = $query; + break; + + case ChangeType::AlterTable: + assert(isset($change['alterTable']), 'Alter table must exist'); + $query = $change['alterTable']; + $queries[] = $query; + break; + + case ChangeType::DropTable: + assert(isset($change['dropTable']), 'Drop table must exist'); + $query = new DropTable($change['dropTable']); + $queries[] = $query; + break; + + case ChangeType::Query: + assert(isset($change['query']), 'Query must exist'); + $queries[] = $change['query']; + break; + } + + $step++; + $checkpoint->advance(); + } + + return $queries; + } + + /** + * Get queries starting from checkpoint + * + * @param Checkpoint $checkpoint Checkpoint to start from, will not be modified + * @return Query[] + */ + public function getQueries(Checkpoint $checkpoint = new Checkpoint()): array + { + $checkpoint = clone $checkpoint; + + return $this->generateQueries($checkpoint); + } + + /** + * Apply changes to the database + * + * @param Database $db Database instance + * @param Checkpoint $checkpoint Checkpoint to start from, will be modified for each query + */ + public function applyChanges(Database $db, Checkpoint $checkpoint = new Checkpoint()): void + { + foreach ($this->getQueries($checkpoint) as $query) { + $db->query($query); + + $checkpoint->advance(); + } + } +} diff --git a/src/Presenter.php b/src/Presenter.php index e156565..0be6e7a 100644 --- a/src/Presenter.php +++ b/src/Presenter.php @@ -16,6 +16,7 @@ use Access\Clause\ClauseInterface; use Access\Clause\ConditionInterface; use Access\Clause\FilterInterface; +use Access\Clause\LimitInterface; use Access\Clause\OrderByInterface; use Access\Presenter\CustomMarkerInterface; use Access\Presenter\EntityPool; @@ -446,7 +447,7 @@ private function resolveMarker( $entities = $clause->filterCollection($entities); } - if ($clause instanceof OrderByInterface) { + if ($clause instanceof OrderByInterface || $clause instanceof LimitInterface) { $entities = $entities->applyClause($clause); } } diff --git a/src/Presenter/EntityMarkerInterface.php b/src/Presenter/EntityMarkerInterface.php index 9693937..f40e92e 100644 --- a/src/Presenter/EntityMarkerInterface.php +++ b/src/Presenter/EntityMarkerInterface.php @@ -42,7 +42,7 @@ public function getFieldName(): string; /** * IDs of the references field of entity * - * @return int[] + * @return int[]|string[] */ public function getRefIds(): array; diff --git a/src/Presenter/EntityPool.php b/src/Presenter/EntityPool.php index 63678c2..61f5e79 100644 --- a/src/Presenter/EntityPool.php +++ b/src/Presenter/EntityPool.php @@ -70,7 +70,7 @@ public function provideCollection( * * @param string $entityKlass Entity class name * @param string $fieldName Name of referenced field - * @param int[] $ids List of IDs + * @param int[]|string[] $ids List of IDs * @return Collection */ public function getCollection(string $entityKlass, string $fieldName, array $ids): Collection @@ -107,9 +107,9 @@ public function getCollection(string $entityKlass, string $fieldName, array $ids * * @param Entity $entity Entity to query * @param string $fieldName Name of referenced field - * @return int|null + * @return int|string|null */ - private function getValue(Entity $entity, string $fieldName): ?int + private function getValue(Entity $entity, string $fieldName): string|int|null { $values = $entity->getValues(); @@ -121,7 +121,7 @@ private function getValue(Entity $entity, string $fieldName): ?int return null; } - if (!is_int($values[$fieldName])) { + if (!is_int($values[$fieldName]) && !is_string($values[$fieldName])) { return null; } diff --git a/src/Presenter/EntityPresenter.php b/src/Presenter/EntityPresenter.php index 699a6af..d036b2c 100644 --- a/src/Presenter/EntityPresenter.php +++ b/src/Presenter/EntityPresenter.php @@ -142,14 +142,14 @@ protected function presentMultiple( * * @param string $presenterKlass Class to present the entity with * @param string $fieldName Name of referenced field - * @param int|Entity|null $id ID of the entity + * @param int|Entity|string|null $id ID of the entity * @param ClauseInterface|null $clause Optional clause to manipulate resulting entities * @return PresentationMarker|null Marker with presenter info */ protected function presentInversedRef( string $presenterKlass, string $fieldName, - int|Entity|null $id, + int|Entity|string|null $id, ?ClauseInterface $clause = null, ): ?PresentationMarker { Database::assertValidPresenterClass($presenterKlass); @@ -191,19 +191,21 @@ protected function presentInversedRef( * * @param string $relationEntityKlass Entity class name * @param string $sourceFieldName Name of referenced field for source - * @param int|Entity|null $id ID of the entity + * @param int|string|Entity|null $id ID of the entity * @param string $targetFieldName Name of referenced field for target * @param string $targetPresenterKlass Class to present the entity with * @param ClauseInterface|null $relationClause Optional clause to manipulate resulting entities (applied to relation entities) + * @param string $targetMatchFieldName Name of the referenced field in target presenter entity * @return FutureMarker|null Marker with presenter info */ protected function presentThroughInversedRef( string $relationEntityKlass, string $sourceFieldName, - int|Entity|null $id, + int|string|Entity|null $id, string $targetFieldName, string $targetPresenterKlass, ?ClauseInterface $relationClause = null, + string $targetMatchFieldName = 'id', ): ?FutureMarker { Database::assertValidEntityClass($relationEntityKlass); Database::assertValidPresenterClass($targetPresenterKlass); @@ -216,8 +218,9 @@ protected function presentThroughInversedRef( $relationEntityKlass, $sourceFieldName, $id, - fn(Entity $entity) => $this->present( + fn(Entity $entity) => $this->presentInversedRef( $targetPresenterKlass, + $targetMatchFieldName, $this->getValidRefId($entity, $targetFieldName), ), $relationClause, @@ -246,14 +249,14 @@ protected function presentThroughInversedRef( * * @param string $presenterKlass Class to present the entity with * @param string $fieldName Name of referenced field - * @param int|int[]|Entity|Collection|null $id ID of the entity + * @param int|int[]|string|string[]|Entity|Collection|null $id ID of the entity * @param ClauseInterface|null $clause Optional clause to manipulate resulting entities * @return PresentationMarker|array Marker with presenter info (or empty array on empty ID) */ protected function presentMultipleInversedRefs( string $presenterKlass, string $fieldName, - int|array|Entity|Collection|null $id, + int|string|array|Entity|Collection|null $id, ?ClauseInterface $clause = null, ): PresentationMarker|array { Database::assertValidPresenterClass($presenterKlass); @@ -296,21 +299,23 @@ protected function presentMultipleInversedRefs( * * @param string $relationEntityKlass Entity class name * @param string $sourceFieldName Name of referenced field for source - * @param int|int[]|Entity|Collection|null $id ID of the entity + * @param int|int[]|string|string[]|Entity|Collection|null $id ID of the entity * @param string $targetFieldName Name of referenced field for target * @param string $targetPresenterKlass Class to present the entity with * @param ClauseInterface|null $relationClause Optional clause to manipulate resulting entities (applied to relation entities) * @param ClauseInterface|null $clause Optional clause to manipulate resulting entities (applied to result entities) + * @param string $targetMatchFieldName Name of the referenced field in target presenter entity * @return FutureMarker|array Marker with presenter info (or empty array on empty ID) */ protected function presentMultipleThroughInversedRefs( string $relationEntityKlass, string $sourceFieldName, - int|array|Entity|Collection|null $id, + int|string|array|Entity|Collection|null $id, string $targetFieldName, string $targetPresenterKlass, ?ClauseInterface $relationClause = null, ?ClauseInterface $clause = null, + string $targetMatchFieldName = 'id', ): FutureMarker|array { Database::assertValidEntityClass($relationEntityKlass); Database::assertValidPresenterClass($targetPresenterKlass); @@ -323,8 +328,9 @@ protected function presentMultipleThroughInversedRefs( $relationEntityKlass, $sourceFieldName, $id, - fn(Collection $collection) => $this->presentMultiple( + fn(Collection $collection) => $this->presentMultipleInversedRefs( $targetPresenterKlass, + $targetMatchFieldName, array_filter( $collection->map( fn(Entity $entity) => $this->getValidRefId($entity, $targetFieldName), @@ -434,7 +440,7 @@ protected function presentFutureMultiple( * * @param string $entityKlass Entity class name * @param string $fieldName Name of referenced field - * @param int|Entity|null $id ID of the entity + * @param int|string|Entity|null $id ID of the entity * @param \Closure $callback On resolved callback * @param ClauseInterface|null $clause Optional clause to manipulate resulting entity * @return FutureMarker|null Marker with future presentation info @@ -442,7 +448,7 @@ protected function presentFutureMultiple( protected function presentFutureInversedRef( string $entityKlass, string $fieldName, - int|Entity|null $id, + int|string|Entity|null $id, \Closure $callback, ?ClauseInterface $clause = null, ): ?FutureMarker { @@ -479,7 +485,7 @@ protected function presentFutureInversedRef( * * @param string $entityKlass Entity class name * @param string $fieldName Name of referenced field - * @param int|int[]|Entity|Collection|null $id ID of the entity + * @param int|int[]|string|string[]|Entity|Collection|null $id ID of the entity * @param \Closure $callback On resolved callback * @param ClauseInterface|null $clause Optional clause to manipulate resulting entities * @return FutureMarker|array Marker with future presentation info (or empty array on empty ID) @@ -487,7 +493,7 @@ protected function presentFutureInversedRef( protected function presentFutureMultipleInversedRefs( string $entityKlass, string $fieldName, - int|array|Entity|Collection|null $id, + int|string|array|Entity|Collection|null $id, \Closure $callback, ?ClauseInterface $clause = null, ): FutureMarker|array { @@ -540,7 +546,7 @@ protected function presentDateTime(?\DateTimeInterface $dateTime): ?string /** * Return a valid ref ID for a target field name of an entity */ - private function getValidRefId(Entity $entity, string $targetFieldName): ?int + private function getValidRefId(Entity $entity, string $targetFieldName): string|int|null { $values = $entity->getValues(); @@ -548,7 +554,7 @@ private function getValidRefId(Entity $entity, string $targetFieldName): ?int return null; } - if (!is_int($values[$targetFieldName])) { + if (!is_int($values[$targetFieldName]) && !is_string($values[$targetFieldName])) { return null; } diff --git a/src/Presenter/FutureMarker.php b/src/Presenter/FutureMarker.php index 06ceb99..d2b6b72 100644 --- a/src/Presenter/FutureMarker.php +++ b/src/Presenter/FutureMarker.php @@ -41,7 +41,7 @@ final class FutureMarker implements EntityMarkerInterface /** * ID of the references field of entity * - * @var int[] + * @var int[]|string[] */ private array $refIds; @@ -65,14 +65,14 @@ final class FutureMarker implements EntityMarkerInterface * * @psalm-param class-string $entityKlass * @param string $entityKlass - * @param int|int[] $refIds ID of the entity + * @param int|int[]|string|string[] $refIds ID of the entity * @param bool $multiple Fill with multiple entities when filled * @param \Closure $callback Function to call when future is resolved */ public function __construct( string $entityKlass, string $fieldName, - int|array $refIds, + int|string|array $refIds, bool $multiple, \Closure $callback, ?ClauseInterface $clause = null, @@ -109,7 +109,7 @@ public function getFieldName(): string /** * ID of the references field of entity * - * @return int[] + * @return int[]|string[] */ public function getRefIds(): array { diff --git a/src/Presenter/PresentationMarker.php b/src/Presenter/PresentationMarker.php index 146cc04..6e0d7c5 100644 --- a/src/Presenter/PresentationMarker.php +++ b/src/Presenter/PresentationMarker.php @@ -42,7 +42,7 @@ final class PresentationMarker implements EntityMarkerInterface /** * ID of the references field of entity * - * @var int[] + * @var int[]|string[] */ private array $refIds; @@ -61,13 +61,13 @@ final class PresentationMarker implements EntityMarkerInterface * * @psalm-param class-string $presenterKlass * @param string $presenterKlass Class to present the entity with - * @param int|int[] $refIds ID of the entity + * @param int|int[]|string|string[] $refIds ID of the entity * @param bool $multiple Fill with multiple entities when filled */ public function __construct( string $presenterKlass, string $fieldName, - int|array $refIds, + int|string|array $refIds, bool $multiple, ?ClauseInterface $clause = null, ) { @@ -116,7 +116,7 @@ public function getFieldName(): string /** * ID of the references field of entity * - * @return int[] + * @return int[]|string[] */ public function getRefIds(): array { diff --git a/src/Query.php b/src/Query.php index 7eedef7..f467e31 100644 --- a/src/Query.php +++ b/src/Query.php @@ -17,6 +17,8 @@ use Access\Clause\Condition\Raw; use Access\Clause\ConditionInterface; use Access\Clause\Field; +use Access\Clause\Limit; +use Access\Clause\LimitInterface; use Access\Clause\Multiple; use Access\Clause\MultipleOr; use Access\Clause\OrderBy\Ascending; @@ -26,11 +28,11 @@ use Access\Clause\OrderBy\Verbatim; use Access\Clause\OrderByInterface; use Access\Driver\DriverInterface; -use Access\Entity; -use Access\IdentifiableInterface; -use Access\Query\QueryGeneratorState; use Access\Query\Cursor\Cursor; use Access\Query\IncludeSoftDeletedFilter; +use Access\Query\QueryGeneratorState; +use Access\Schema\Table; +use Access\Schema\Type; use BackedEnum; /** @@ -61,6 +63,8 @@ abstract class Query protected const PREFIX_SUBQUERY_CONDITION = 'z'; /** @var string */ protected const PREFIX_ORDER = 'o'; + /** @var string */ + protected const PREFIX_LIMIT = 'l'; /** * Unescaped table name @@ -87,14 +91,9 @@ abstract class Query protected array $having = []; /** - * @var int|null - */ - protected ?int $limit = null; - - /** - * @var int|null + * @var Limit|null */ - protected ?int $offset = null; + protected ?Limit $limit = null; /** * @psalm-var arraygetName(); + } + $this->tableName = $tableName; if (is_subclass_of($tableName, Entity::class)) { @@ -159,7 +162,7 @@ protected function __construct(string $tableName, ?string $alias = null) */ $tableIdentifier = $alias ?: $this->tableName; $this->softDeleteCondition = new IsNull( - sprintf('%s.%s', $tableIdentifier, Entity::DELETED_AT_FIELD), + sprintf('%s.%s', $tableIdentifier, Table::DELETED_AT_FIELD), ); } } @@ -244,7 +247,7 @@ private function join( if ($tableName::isSoftDeletable()) { $tableIdentifier = $alias ?: $tableName::tableName(); $softDeleteCondition = new IsNull( - sprintf('%s.%s', $tableIdentifier, Entity::DELETED_AT_FIELD), + sprintf('%s.%s', $tableIdentifier, Table::DELETED_AT_FIELD), ); } @@ -329,6 +332,8 @@ public function having(array|string|ConditionInterface $condition, mixed $value /** * Add a single/multiple ORDER BY part(s) to query * + * Overrides previous set order by + * * @param string|OrderByInterface|string[]|OrderByInterface $orderBy Order by clause * @return $this */ @@ -375,14 +380,21 @@ public function orderBy(string|OrderByInterface|array $orderBy): static /** * Add a LIMIT clause to query * - * @param int $limit - * @param int|null $offset + * @param Limit|int $limit + * @param int|null $offset Will override anything set in `$limit` * @return $this */ - public function limit(int $limit, ?int $offset = null): static + public function limit(Limit|int $limit, ?int $offset = null): static { + if (is_int($limit)) { + $limit = new Limit($limit); + } + + if ($offset !== null) { + $limit->setOffset($offset); + } + $this->limit = $limit; - $this->offset = $offset; return $this; } @@ -430,14 +442,18 @@ public function getValues(?DriverInterface $driver = null): array $i = 0; /** @var mixed $value */ foreach ($this->values as $value) { - // this is not a value, but a direct reference to a field, no need for an indexed value - if (!$value instanceof Field) { - $index = self::PREFIX_PARAM . $i; - - /** @psalm-suppress MixedAssignment */ - $indexedValues[$index] = $this->toDatabaseFormat($value); - $i++; + // this is not a value, no need for an indexed value + // - direct reference to a field + // - raw expression + if ($value instanceof Field || $value instanceof Raw) { + continue; } + + $index = self::PREFIX_PARAM . $i; + + /** @psalm-suppress MixedAssignment */ + $indexedValues[$index] = $this->toDatabaseFormat($value); + $i++; } foreach ($this->joins as $i => $join) { @@ -513,7 +529,7 @@ public static function toDatabaseFormat(mixed $value): mixed } if ($value instanceof \DateTimeInterface) { - return $value->format(Entity::DATETIME_FORMAT); + return $value->format(Type\DateTime::DATABASE_FORMAT); } if (is_bool($value)) { @@ -729,23 +745,20 @@ protected function getOrderBySql(DriverInterface $driver): string * * @return string */ - protected function getLimitSql(): string + protected function getLimitSql(DriverInterface $driver): string { - /** - * All cases for which `empty` returns falsey should default to an empty string - * @psalm-suppress RiskyTruthyFalsyComparison - */ - if (empty($this->limit)) { + $limit = $this->limit; + + if ($limit === null) { return ''; } - $limitSql = " LIMIT {$this->limit}"; + $prefix = self::PREFIX_LIMIT; + $state = new QueryGeneratorState($driver, $prefix, self::PREFIX_SUBQUERY_CONDITION); - if ($this->offset !== null) { - $limitSql .= " OFFSET {$this->offset}"; - } + $sqlLimit = $limit->getConditionSql($state); - return $limitSql; + return $this->replaceQuestionMarks($sqlLimit, $prefix); } /** diff --git a/src/Query/AlterTable.php b/src/Query/AlterTable.php new file mode 100644 index 0000000..6dd4619 --- /dev/null +++ b/src/Query/AlterTable.php @@ -0,0 +1,333 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Clause; +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema; +use Access\Schema\Index; +use Access\Schema\Table; +use Access\Schema\Type; + +enum AlterType +{ + case RenameTable; + case AddField; + case RemoveField; + case ChangeField; + case ModifyField; + case RenameField; + case AddIndex; + case RemoveIndex; + case RenameIndex; +} + +/** + * Create ALTER TABLE query for given table + * + * @author Tim + */ +class AlterTable extends Query +{ + private Table $table; + + /** + * @var array + */ + private array $alters = []; + + /** + * Create a ALTER TABLE query + */ + public function __construct(Table $table) + { + $this->table = $table; + + parent::__construct($table, null); + } + + public function renameTable(Table|string $toTableName): void + { + $this->alters[] = ['type' => AlterType::RenameTable, 'toTableName' => $toTableName]; + } + + public function addField( + Clause\Field|string $field, + ?Type $type = null, + mixed $default = null, + ): Schema\Field { + /** @var array{string, ?Type, mixed} $args */ + $args = func_get_args(); + + $field = $this->maybeCreateField(...$args); + + $this->alters[] = ['type' => AlterType::AddField, 'fieldDefinition' => $field]; + + return $field; + } + + public function removeField(Clause\Field|string $field): void + { + if (is_string($field)) { + $field = new Clause\Field($field); + } + + $this->alters[] = ['type' => AlterType::RemoveField, 'fieldName' => $field]; + } + + public function changeField( + Clause\Field|string $from, + Schema\Field|string $to, + ?Type $type = null, + mixed $default = null, + ): Schema\Field { + if (is_string($from)) { + $from = new Clause\Field($from); + } + + /** @var array{Clause\Field|string, string, ?Type, mixed} $args */ + $args = func_get_args(); + array_shift($args); // remove $from + + $to = $this->maybeCreateField(...$args); + + $this->alters[] = [ + 'type' => AlterType::ChangeField, + 'fromName' => $from, + 'toDefinition' => $to, + ]; + + return $to; + } + + public function modifyField( + Schema\Field|string $to, + ?Type $type = null, + mixed $default = null, + ): Schema\Field { + /** @var array{Schema\Field|string, ?Type, mixed} $args */ + $args = func_get_args(); + + $to = $this->maybeCreateField(...$args); + + $this->alters[] = [ + 'type' => AlterType::ModifyField, + 'toDefinition' => $to, + ]; + + return $to; + } + + public function renameField(Clause\Field|string $from, Clause\Field|string $to): void + { + if (is_string($from)) { + $from = new Clause\Field($from); + } + + if (is_string($to)) { + $to = new Clause\Field($to); + } + + $this->alters[] = ['type' => AlterType::RenameField, 'fromName' => $from, 'toName' => $to]; + } + + /** + * @param array|string|Clause\Field|null $fields + * @return Index The new index definition + */ + public function addIndex( + Index|string $index, + array|string|Clause\Field|null $fields = null, + ): Index { + if (is_string($index)) { + if ($fields === null) { + throw new \InvalidArgumentException( + 'Fields must be provided when index name is given as string', + ); + } + + $index = $this->table->index($index, $fields); + } + + $this->alters[] = ['type' => AlterType::AddIndex, 'indexDefinition' => $index]; + + return $index; + } + + public function removeIndex(Index|string $index): void + { + $this->alters[] = ['type' => AlterType::RemoveIndex, 'indexName' => $index]; + } + + public function renameIndex(Index|string $from, Index|string $to): void + { + $this->alters[] = [ + 'type' => AlterType::RenameIndex, + 'fromIndexName' => $from, + 'toIndexName' => $to, + ]; + } + + /** + * Mayeb create field instance if string is given + * + * @param Type|null $type Defaults to VarChar if null + */ + private function maybeCreateField( + Schema\Field|string $field, + ?Type $type = null, + mixed $default = null, + ): Schema\Field { + if ($field instanceof Schema\Field) { + return $field; + } + + // make sure the dont' trip up our logic with the default value, + // if it's not provided by the user, dont't pass a third argument to the constructor + /** @var array{string, ?Type, mixed} $args */ + $args = func_get_args(); + + $field = $this->table->field(...$args); + + return $field; + } + + public function getSql(?DriverInterface $driver = null): ?string + { + $driver = Database::getDriverOrDefault($driver); + $builder = $driver->getAlterTableBuilder(); + + $columnDefinitions = []; + + foreach ($this->alters as $alter) { + switch ($alter['type']) { + case AlterType::RenameTable: + assert( + isset($alter['toTableName']), + 'To table name must be set for RenameTable alter type', + ); + + $columnDefinitions[] = $builder->renameTable($alter['toTableName']); + break; + + case AlterType::AddField: + assert( + isset($alter['fieldDefinition']), + 'Field must be set for AddField alter type', + ); + + $columnDefinitions[] = $builder->addField($alter['fieldDefinition']); + break; + + case AlterType::RemoveField: + assert( + isset($alter['fieldName']), + 'Field must be set for RemoveField alter type', + ); + + $columnDefinitions[] = $builder->removeField($alter['fieldName']); + break; + + case AlterType::ChangeField: + assert( + isset($alter['fromName']) && isset($alter['toDefinition']), + 'From and To fields must be set for ChangeField alter type', + ); + + $columnDefinitions[] = $builder->changeField( + $alter['fromName'], + $alter['toDefinition'], + ); + break; + + case AlterType::ModifyField: + assert( + isset($alter['toDefinition']), + 'To fields must be set for ModifyField alter type', + ); + + $columnDefinitions[] = $builder->modifyField($alter['toDefinition']); + break; + + case AlterType::RenameField: + assert( + isset($alter['fromName']) && isset($alter['toName']), + 'From and To fields must be set for RenameField alter type', + ); + + $columnDefinitions[] = $builder->renameField( + $alter['fromName'], + $alter['toName'], + ); + break; + + case AlterType::AddIndex: + assert( + isset($alter['indexDefinition']), + 'Index must be set for AddIndex alter type', + ); + + $columnDefinitions[] = $builder->addIndex($alter['indexDefinition']); + break; + + case AlterType::RemoveIndex: + assert( + isset($alter['indexName']), + 'Index must be set for RemoveIndex alter type', + ); + + $columnDefinitions[] = $builder->removeIndex($alter['indexName']); + break; + + case AlterType::RenameIndex: + assert( + isset($alter['fromIndexName']) && isset($alter['toIndexName']), + 'From and To indexes must be set for RenameIndex alter type', + ); + + $columnDefinitions[] = $builder->renameIndex( + $alter['fromIndexName'], + $alter['toIndexName'], + ); + break; + } + } + + if (count($columnDefinitions) === 0) { + return null; + } + + $sql = sprintf( + 'ALTER TABLE %s %s', + $driver->escapeIdentifier($this->tableName), + implode(', ', $columnDefinitions), + ); + + return $sql; + } +} diff --git a/src/Query/CreateDatabase.php b/src/Query/CreateDatabase.php new file mode 100644 index 0000000..b513234 --- /dev/null +++ b/src/Query/CreateDatabase.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema; + +/** + * Create CREATE DATABASE query for given schema + * + * @author Tim + */ +class CreateDatabase extends Query +{ + private Schema $schema; + + private bool $checkExistence = false; + + /** + * Create a CREATE DATABASE query + */ + public function __construct(Schema $schema) + { + parent::__construct('__dummy__'); + + $this->schema = $schema; + } + + public function checkExistence(bool $ifNotExists = true): self + { + $this->checkExistence = $ifNotExists; + + return $this; + } + + public function getSql(?DriverInterface $driver = null): string + { + $driver = Database::getDriverOrDefault($driver); + $builder = $driver->getCreateDatabaseBuilder(); + + $createOptions = $builder->createOptions($this->schema); + + return sprintf( + 'CREATE DATABASE%s %s%s', + $this->checkExistence ? ' IF NOT EXISTS' : '', + $driver->escapeIdentifier($this->schema->getName()), + $createOptions ? ' ' . $createOptions : '', + ); + } +} diff --git a/src/Query/CreateTable.php b/src/Query/CreateTable.php new file mode 100644 index 0000000..ecb632c --- /dev/null +++ b/src/Query/CreateTable.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; + +/** + * Create CREATE TABLE query for given table + * + * @author Tim + */ +class CreateTable extends Query +{ + private Table $table; + + /** + * Create a CREATE TABLE query + */ + public function __construct(Table $table) + { + parent::__construct($table, null); + + $this->table = $table; + } + + public function getSql(?DriverInterface $driver = null): string + { + $driver = Database::getDriverOrDefault($driver); + $builder = $driver->getCreateTableBuilder(); + + $tableParts = []; + $fields = [$this->idField(), ...$this->table->getFields()]; + + if ($this->table->hasCreatedAt()) { + $fields[] = $this->createdAtField(); + } + + if ($this->table->hasUpdatedAt()) { + $fields[] = $this->updatedAtField(); + } + + if ($this->table->hasDeletedAt()) { + $fields[] = $this->deletedAtField(); + } + + foreach ($fields as $field) { + if ($field->getIsVirtual()) { + continue; + } + + $tableParts[] = $field->getSqlDefinition($driver); + } + + foreach ($fields as $field) { + if ($field->getIsVirtual()) { + continue; + } + + if ($field->isPrimaryKey()) { + $tableParts[] = $builder->primaryKey($field); + } + + if ($field->getType() instanceof Type\Reference) { + $tableParts[] = $builder->foreignKey($field); + } + } + + foreach ($this->table->getIndexes() as $index) { + $tableParts[] = $builder->index($index); + } + + $tableParts = array_filter($tableParts); + $tableOptions = $builder->tableOptions($this->table); + + $sql = sprintf( + "CREATE TABLE %s (\n %s\n)%s", + $driver->escapeIdentifier($this->tableName), + implode(",\n ", $tableParts), + $tableOptions ? ' ' . $tableOptions : '', + ); + + return $sql; + } + + private function idField(): Field + { + $idField = new Field('id', new Type\Integer()); + $idField->markAsPrimaryKey(); + $idField->markAsAutoIncrement(); + + return $idField; + } + + private function createdAtField(): Field + { + return new Field(Table::CREATED_AT_FIELD, new Type\DateTime()); + } + + private function updatedAtField(): Field + { + return new Field(Table::UPDATED_AT_FIELD, new Type\DateTime()); + } + + private function deletedAtField(): Field + { + return new Field(Table::DELETED_AT_FIELD, new Type\DateTime(), null); + } +} diff --git a/src/Query/Cursor/CurrentIdsCursor.php b/src/Query/Cursor/CurrentIdsCursor.php index edbdbe4..419ca3c 100644 --- a/src/Query/Cursor/CurrentIdsCursor.php +++ b/src/Query/Cursor/CurrentIdsCursor.php @@ -14,6 +14,7 @@ namespace Access\Query\Cursor; use Access\Clause\Condition\NotIn; +use Access\Entity; use Access\Query; /** @@ -76,4 +77,19 @@ public function apply(Query $query): void $query->where(new NotIn(sprintf('%s.id', $tableName), $this->currentIds)); } } + + /** + * {@inheritdoc} + */ + public function createFilterFinder(): callable + { + // it's a "negative" cursor, no IDs mean that everything is allowed + if (empty($this->currentIds)) { + return fn(Entity $entity): bool => true; + } + + $currentIds = array_flip($this->currentIds); + + return fn(Entity $entity): bool => !isset($currentIds[$entity->getId()]); + } } diff --git a/src/Query/Cursor/Cursor.php b/src/Query/Cursor/Cursor.php index 07c02fb..59a9174 100644 --- a/src/Query/Cursor/Cursor.php +++ b/src/Query/Cursor/Cursor.php @@ -13,6 +13,10 @@ namespace Access\Query\Cursor; +use Access\Clause\Filter\FilterItemResult; +use Access\Clause\FilterInterface; +use Access\Collection; +use Access\Exception\NotSupportedException; use Access\Query; /** @@ -20,7 +24,7 @@ * * @author Tim */ -abstract class Cursor +abstract class Cursor implements FilterInterface { /** * Default page size @@ -72,4 +76,31 @@ public function setPageSize(int $pageSize = self::DEFAULT_PAGE_SIZE): void * @param Query $query The query that needs cursoring */ abstract public function apply(Query $query): void; + + /** + * Filter given collection in place based on this cursor + * + * @psalm-template TEntity of \Access\Entity + * @param Collection $collection The collection that needs cursoring + * @psalm-param Collection $collection The collection that needs cursoring + * @return Collection The filtered collection + * @psalm-return Collection The filtered collection + */ + public function filterCollection(Collection $collection): Collection + { + return $collection->filter($this->createFilterFinder()); + } + + /** + * Create the finder function for this cursor + * + * @return callable + * @psalm-return callable(\Access\Entity): (FilterItemResult|bool) + */ + public function createFilterFinder(): callable + { + throw new NotSupportedException( + sprintf('The "%s" cursor does not support collections', get_class($this)), + ); + } } diff --git a/src/Query/Cursor/PageCursor.php b/src/Query/Cursor/PageCursor.php index d61e8a3..9d6e5c5 100644 --- a/src/Query/Cursor/PageCursor.php +++ b/src/Query/Cursor/PageCursor.php @@ -13,6 +13,7 @@ namespace Access\Query\Cursor; +use Access\Clause\Filter\FilterItemResult; use Access\Query; /** @@ -70,4 +71,28 @@ public function apply(Query $query): void $offset = ($this->page - 1) * $this->pageSize; $query->limit($this->pageSize, $offset); } + + /** + * {@inheritdoc} + */ + public function createFilterFinder(): callable + { + $i = 0; + $pageSize = $this->getPageSize(); + $offset = ($this->getPage() - 1) * $pageSize; + + return function () use (&$i, $offset, $pageSize): FilterItemResult { + if ($i < $offset) { + $i++; + return FilterItemResult::Exclude; + } + + if ($i >= $offset + $pageSize) { + return FilterItemResult::Done; + } + + $i++; + return FilterItemResult::Include; + }; + } } diff --git a/src/Query/Delete.php b/src/Query/Delete.php index 5df36ca..84e22a0 100644 --- a/src/Query/Delete.php +++ b/src/Query/Delete.php @@ -16,6 +16,7 @@ use Access\Database; use Access\Driver\DriverInterface; use Access\Query; +use Access\Schema\Table; /** * Create DELETE query for given table @@ -25,10 +26,10 @@ class Delete extends Query { /** - * @param string $tableName Name of the table (or name of entity class) + * @param Table|string $tableName Name of the table (or name of entity class) * @param string $alias Name of the alias for given table name */ - public function __construct(string $tableName, ?string $alias = null) + public function __construct(Table|string $tableName, ?string $alias = null) { parent::__construct($tableName, $alias); } @@ -51,7 +52,7 @@ public function getSql(?DriverInterface $driver = null): ?string $sqlAlias = $this->getAliasSql($driver); $sqlJoins = $this->getJoinSql($driver); $sqlWhere = $this->getWhereSql($driver); - $sqlLimit = $this->getLimitSql(); + $sqlLimit = $this->getLimitSql($driver); return $sqlDelete . $sqlAlias . $sqlJoins . $sqlWhere . $sqlLimit; } diff --git a/src/Query/DropDatabase.php b/src/Query/DropDatabase.php new file mode 100644 index 0000000..1e7358a --- /dev/null +++ b/src/Query/DropDatabase.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema; + +/** + * Create DROP DATABASE query for given schema + * + * @author Tim + */ +class DropDatabase extends Query +{ + private Schema $schema; + + private bool $checkExistence = false; + + /** + * Create a DROP DATABASE query + */ + public function __construct(Schema $schema) + { + parent::__construct('__dummy__'); + + $this->schema = $schema; + } + + public function checkExistence(bool $ifNotExists = true): self + { + $this->checkExistence = $ifNotExists; + + return $this; + } + + public function getSql(?DriverInterface $driver = null): string + { + $driver = Database::getDriverOrDefault($driver); + + return sprintf( + 'DROP DATABASE%s %s', + $this->checkExistence ? ' IF EXISTS' : '', + $driver->escapeIdentifier($this->schema->getName()), + ); + } +} diff --git a/src/Query/DropTable.php b/src/Query/DropTable.php new file mode 100644 index 0000000..381badc --- /dev/null +++ b/src/Query/DropTable.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema\Table; + +/** + * Create DROP TABLE query for given table + * + * @author Tim + */ +class DropTable extends Query +{ + /** + * Create a DROP TABLE query + */ + public function __construct(Table $table) + { + parent::__construct($table->getName(), null); + } + + public function getSql(?DriverInterface $driver = null): string + { + $driver = Database::getDriverOrDefault($driver); + + $sql = sprintf('DROP TABLE %s', $driver->escapeIdentifier($this->tableName)); + + return $sql; + } +} diff --git a/src/Query/Insert.php b/src/Query/Insert.php index ac26f7d..c19fdec 100644 --- a/src/Query/Insert.php +++ b/src/Query/Insert.php @@ -13,10 +13,12 @@ namespace Access\Query; +use Access\Clause\Condition\Raw; use Access\Clause\Field; use Access\Database; use Access\Driver\DriverInterface; use Access\Query; +use Access\Schema\Table; /** * Create a INSERT query for given table @@ -26,10 +28,10 @@ class Insert extends Query { /** - * @param string $tableName Name of the table (or name of entity class) + * @param Table|string $tableName Name of the table (or name of entity class) * @param string $alias Name of the alias for given table name */ - public function __construct(string $tableName, ?string $alias = null) + public function __construct(Table|string $tableName, ?string $alias = null) { parent::__construct($tableName, $alias); } @@ -52,12 +54,17 @@ public function getSql(?DriverInterface $driver = null): ?string $sqlFields = ' (' . implode(', ', $sqlFields) . ')'; // filter out `Field` instances and use name directly - $sqlValues = array_map( - fn(mixed $value): string => $value instanceof Field - ? $driver->escapeIdentifier($value->getName()) - : '?', - $this->values, - ); + $sqlValues = array_map(function (mixed $value) use ($driver): string { + if ($value instanceof Field) { + return $driver->escapeIdentifier($value->getName()); + } + + if ($value instanceof Raw) { + return $value->getCondition(); + } + + return '?'; + }, $this->values); $sqlValues = ' VALUES (' . implode(', ', $sqlValues) . ')'; diff --git a/src/Query/Select.php b/src/Query/Select.php index e7cd451..7ea88cc 100644 --- a/src/Query/Select.php +++ b/src/Query/Select.php @@ -15,7 +15,9 @@ use Access\Database; use Access\Driver\DriverInterface; +use Access\ReadLock; use Access\Query; +use Access\Schema\Table; /** * Create a SELECT query for given table with optional virtual fields @@ -35,12 +37,20 @@ class Select extends Query private ?string $select = null; /** - * @param string $tableName Name of the table (or name of entity class) + * Read lock for selected rows + */ + private ?ReadLock $readLock = null; + + /** + * @param Table|string $tableName Name of the table (or name of entity class) * @param string $alias Name of the alias for given table name * @param array $virtualFields List of virtual fields, 'name' => 'SQL' */ - public function __construct(string $tableName, ?string $alias = null, array $virtualFields = []) - { + public function __construct( + Table|string $tableName, + ?string $alias = null, + array $virtualFields = [], + ) { parent::__construct($tableName, $alias); $this->virtualFields = $virtualFields; @@ -75,6 +85,50 @@ public function addVirtualField(string $fieldName, string $fieldValue): static return $this; } + /** + * Sets a shared mode lock on any rows that are read + */ + public function readLockForShare(): static + { + $this->readLock = ReadLock::Share; + + return $this; + } + + /** + * Sets a shared mode lock on any rows that are read + * + * Throws [`LockNotAcquiredException`] if the lock could not be acquired immediately + */ + public function readLockForShareNoWait(): static + { + $this->readLock = ReadLock::ShareNoWait; + + return $this; + } + + /** + * Sets an exclusive mode lock on any rows that are read + */ + public function readLockForUpdate(): static + { + $this->readLock = ReadLock::Update; + + return $this; + } + + /** + * Sets an exclusive mode lock on any rows that are read + * + * Throws `LockNotAcquiredException` if the lock could not be acquired immediately + */ + public function readLockForUpdateNoWait(): static + { + $this->readLock = ReadLock::UpdateNoWait; + + return $this; + } + /** * {@inheritdoc} */ @@ -92,7 +146,8 @@ public function getSql(?DriverInterface $driver = null): ?string $sqlGroupBy = $this->getGroupBySql(); $sqlHaving = $this->getHavingSql($driver); $sqlOrderBy = $this->getOrderBySql($driver); - $sqlLimit = $this->getLimitSql(); + $sqlLimit = $this->getLimitSql($driver); + $sqlReadLock = $this->getLockForSql($driver); return $sqlSelect . $sqlFrom . @@ -102,7 +157,8 @@ public function getSql(?DriverInterface $driver = null): ?string $sqlGroupBy . $sqlHaving . $sqlOrderBy . - $sqlLimit; + $sqlLimit . + $sqlReadLock; } /** @@ -150,6 +206,18 @@ private function getSelectSql(DriverInterface $driver): string return $sql; } + /** + * Get the SQL for a read lock + */ + private function getLockForSql(DriverInterface $driver): string + { + if ($this->readLock === null) { + return ''; + } + + return ' ' . $driver->getReadLockSql($this->readLock); + } + /** * Get the values with a prefixed index * diff --git a/src/Query/Union.php b/src/Query/Union.php index fe54b24..f9b6c0d 100644 --- a/src/Query/Union.php +++ b/src/Query/Union.php @@ -16,7 +16,6 @@ use Access\Database; use Access\Driver\DriverInterface; use Access\Exception\NotSupportedException; -use Access\Query\Select; /** * Create a UNION query for given SELECT queries @@ -96,7 +95,7 @@ public function getSql(?DriverInterface $driver = null): string $sqlUnion = implode(' UNION ', $unions); $sqlOrderBy = $this->getOrderBySql($driver); - $sqlLimit = $this->getLimitSql(); + $sqlLimit = $this->getLimitSql($driver); if ($sqlLimit !== '' || $sqlOrderBy !== '') { // only wrap the UNIONs in parentheses if there is an ORDER BY or LIMIT diff --git a/src/Query/Update.php b/src/Query/Update.php index aea7d44..417ded3 100644 --- a/src/Query/Update.php +++ b/src/Query/Update.php @@ -13,10 +13,12 @@ namespace Access\Query; +use Access\Clause\Condition\Raw; use Access\Clause\Field; use Access\Database; use Access\Driver\DriverInterface; use Access\Query; +use Access\Schema\Table; /** * Create a UPDATE query for given table @@ -26,10 +28,10 @@ class Update extends Query { /** - * @param string $tableName Name of the table (or name of entity class) + * @param Table|string $tableName Name of the table (or name of entity class) * @param string $alias Name of the alias for given table name */ - public function __construct(string $tableName, ?string $alias = null) + public function __construct(Table|string $tableName, ?string $alias = null) { parent::__construct($tableName, $alias); } @@ -52,6 +54,9 @@ public function getSql(?DriverInterface $driver = null): ?string $driver->escapeIdentifier($q) . ' = ' . $driver->escapeIdentifier($value->getName()); + } elseif ($value instanceof Raw) { + // use the raw part directly + $parts[] = $driver->escapeIdentifier($q) . ' = ' . $value->getCondition(); } else { $placeholder = self::PREFIX_PARAM . (string) $i; @@ -73,7 +78,7 @@ public function getSql(?DriverInterface $driver = null): ?string $sqlJoins = $this->getJoinSql($driver); $sqlFields = ' SET ' . $fields; $sqlWhere = $this->getWhereSql($driver); - $sqlLimit = $this->getLimitSql(); + $sqlLimit = $this->getLimitSql($driver); return $sqlUpdate . $sqlAlias . $sqlJoins . $sqlFields . $sqlWhere . $sqlLimit; } diff --git a/src/Query/UseDatabase.php b/src/Query/UseDatabase.php new file mode 100644 index 0000000..ec56572 --- /dev/null +++ b/src/Query/UseDatabase.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +use Access\Database; +use Access\Driver\DriverInterface; +use Access\Query; +use Access\Schema; + +/** + * Create USE query for given schema + * + * @author Tim + */ +class UseDatabase extends Query +{ + private Schema $schema; + + /** + * Create a USE query + */ + public function __construct(Schema $schema) + { + parent::__construct('__dummy__'); + + $this->schema = $schema; + } + + public function getSql(?DriverInterface $driver = null): string + { + $driver = Database::getDriverOrDefault($driver); + + return sprintf('USE %s', $driver->escapeIdentifier($this->schema->getName())); + } +} diff --git a/src/ReadLock.php b/src/ReadLock.php new file mode 100644 index 0000000..fd04d88 --- /dev/null +++ b/src/ReadLock.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access; + +/** + * Row level locking types + * + * @internal + * @see https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html + */ +enum ReadLock +{ + /** + * Sets a shared mode lock on any rows that are read. Other sessions can read the rows, + * but cannot modify them until your transaction commits. If any of these rows were + * changed by another transaction that has not yet committed, your query waits until + * that transaction ends and then uses the latest values. + */ + case Share; + + /** + * For index records the search encounters, locks the rows and any associated index + * entries, the same as if you issued an UPDATE statement for those rows. Other + * transactions are blocked from updating those rows, from doing SELECT ... FOR SHARE, + * or from reading the data in certain transaction isolation levels. Consistent reads + * ignore any locks set on the records that exist in the read view. (Old versions of + * a record cannot be locked; they are reconstructed by applying undo logs on an + * in-memory copy of the record.) + */ + case Update; + + /** + * A locking read that uses NOWAIT never waits to acquire a row lock. The query + * executes immediately, failing with an error if a requested row is locked. + * + * @see ReadLock::Share + */ + case ShareNoWait; + + /** + * A locking read that uses NOWAIT never waits to acquire a row lock. The query + * executes immediately, failing with an error if a requested row is locked. + * + * @see ReadLock::Update + */ + case UpdateNoWait; +} diff --git a/src/Schema.php b/src/Schema.php new file mode 100644 index 0000000..53ee1c8 --- /dev/null +++ b/src/Schema.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access; + +use Access\Schema\Charset; +use Access\Schema\Collate; +use Access\Schema\Table; + +/** + * The complete schema with all tables + */ +class Schema +{ + private string $name; + + private Charset $defautCharset = Charset::Utf8; + private Collate $defaultCollate = Collate::Default; + + private array $tables = []; + + public function __construct(string $name) + { + $this->name = $name; + } + + public function getName(): string + { + return $this->name; + } + + public function getDefaultCharset(): Charset + { + return $this->defautCharset; + } + + public function setDefaultCharset(Charset $charset): void + { + $this->defautCharset = $charset; + } + + public function getDefaultCollate(): Collate + { + return $this->defaultCollate; + } + + public function setDefaultCollate(Collate $collate): void + { + $this->defaultCollate = $collate; + } + + /** + * Add a table to the schema + */ + public function addTable(Table $table): void + { + $this->tables[] = $table; + } + + /** + * List all tables in the schema + */ + public function getTables(): array + { + return $this->tables; + } +} diff --git a/src/Schema/Charset.php b/src/Schema/Charset.php new file mode 100644 index 0000000..dace64f --- /dev/null +++ b/src/Schema/Charset.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +enum Charset +{ + case Utf8; +} diff --git a/src/Schema/Collate.php b/src/Schema/Collate.php new file mode 100644 index 0000000..6d34cc2 --- /dev/null +++ b/src/Schema/Collate.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +enum Collate +{ + case Default; +} diff --git a/src/Schema/Engine.php b/src/Schema/Engine.php new file mode 100644 index 0000000..d98b9ec --- /dev/null +++ b/src/Schema/Engine.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +enum Engine +{ + case Default; +} diff --git a/src/Schema/Exception/InvalidDatabaseValueException.php b/src/Schema/Exception/InvalidDatabaseValueException.php new file mode 100644 index 0000000..dde7bc2 --- /dev/null +++ b/src/Schema/Exception/InvalidDatabaseValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Access\Schema\Exception; + +use Access\Exception; + +/** + * Access specific exception + * + * @author Tim + */ +class InvalidDatabaseValueException extends Exception {} diff --git a/src/Schema/Exception/InvalidFieldDefinitionException.php b/src/Schema/Exception/InvalidFieldDefinitionException.php new file mode 100644 index 0000000..778abf8 --- /dev/null +++ b/src/Schema/Exception/InvalidFieldDefinitionException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Access\Schema\Exception; + +use Access\Exception; + +/** + * Access specific exception + * + * @author Tim + */ +class InvalidFieldDefinitionException extends Exception {} diff --git a/src/Schema/Exception/InvalidValueException.php b/src/Schema/Exception/InvalidValueException.php new file mode 100644 index 0000000..aa5fdb1 --- /dev/null +++ b/src/Schema/Exception/InvalidValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Access\Schema\Exception; + +use Access\Exception; + +/** + * Access specific exception + * + * @author Tim + */ +class InvalidValueException extends Exception {} diff --git a/src/Schema/Exception/NoDefaultValueException.php b/src/Schema/Exception/NoDefaultValueException.php new file mode 100644 index 0000000..7e69679 --- /dev/null +++ b/src/Schema/Exception/NoDefaultValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Access\Schema\Exception; + +use Access\Exception; + +/** + * Access specific exception + * + * @author Tim + */ +class NoDefaultValueException extends Exception {} diff --git a/src/Schema/Field.php b/src/Schema/Field.php new file mode 100644 index 0000000..0965b2a --- /dev/null +++ b/src/Schema/Field.php @@ -0,0 +1,296 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +use Access\Clause\Field as ClauseField; +use Access\Driver\DriverInterface; +use Access\Entity; +use Access\Schema\Exception\NoDefaultValueException; +use Access\Schema\Type\VarChar; +use BackedEnum; + +class Field extends ClauseField +{ + /** + * Name of the field + */ + private string $name; + + /** + * Type of the field + * + * Will handle conversion to/from database format + */ + private Type $type; + + /** + * Default value of the field + * + * Can be a static value or a callable that returns the default value + * The callable will receive the entity as first argument + */ + private mixed $default; + + /** + * Does the field have a default value? + */ + // setting the `$default` field to `null` is not the same, because `null` is a valid value + private bool $hasDefault; + + /** + * Is the field nullable? + * + * If a field has a default value of `null`, it is automatically nullable + */ + private bool $nullable = false; + + /** + * Is the field virtual (not stored in the database)? + */ + private bool $isVirtual = false; + + /** + * Should the field be included when copying an entity? + */ + private bool $includeInCopy = true; + + /** + * Is the field a primary key? + */ + private bool $isPrimaryKey = false; + + /** + * Does the field auto-increment? + */ + private bool $hasAutoIncrement = false; + + /** + * After which field should this field appear + * + * Migrations only + */ + private ClauseField|string|null $after = null; + + /** + * @param Type|null $type Defaults to `VarChar` if null + * @param mixed $default Not providing it means no default, providing null means default null + */ + public function __construct(string $name, ?Type $type = null, mixed $default = null) + { + $this->name = $name; + $this->type = $type ?? new VarChar(); + + // has the default value been provided by the user, if yes, then the field has a default value + $hasDefault = func_num_args() > 2; + + if ($hasDefault) { + $this->default = $default; + $this->hasDefault = true; + + if ($default === null) { + $this->nullable = true; + } + } else { + $this->default = null; + $this->hasDefault = false; + } + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): Type + { + return $this->type; + } + + /** + * Override the default value of the field + * + * The field will have a default value + * + * @param mixed $default + */ + public function setDefault(mixed $default): void + { + $this->default = $default; + $this->hasDefault = true; + $this->nullable = $default === null; + } + + public function getDefault(): mixed + { + return $this->default; + } + + public function hasDefault(): bool + { + return $this->hasDefault; + } + + /** + * Get the default value for the field + * + * The entity is passed to the callable default value + * + * @return mixed + */ + public function getDefaultValue(Entity $entity) + { + if (!$this->hasDefault()) { + throw new NoDefaultValueException( + sprintf('No default value for field "%s"', $this->name), + ); + } + + if (is_callable($this->default)) { + return call_user_func($this->default, $entity); + } else { + return $this->default; + } + } + + /** + * @psalm-assert int|float|string|bool|BackedEnum|null $this->default + */ + public function hasStaticDefault(): bool + { + return $this->hasDefault() && + (is_scalar($this->default) || + $this->default instanceof BackedEnum || + $this->default === null); + } + + public function getStaticDefaultValue(): int|float|string|bool|BackedEnum|null + { + if (!$this->hasStaticDefault()) { + throw new NoDefaultValueException( + sprintf('No static default value for field "%s"', $this->name), + ); + } + + return $this->default; + } + + /** + * Override whether the field is nullable + * + * On creation of the field this field is automatically set to nullable if the + * default value is null. Setting a default value will override this again. + */ + public function markAsNullable(bool $nullable = true): void + { + $this->nullable = $nullable; + } + + public function isNullable(): bool + { + return $this->nullable; + } + + public function markAsPrimaryKey(bool $primaryKey = true): void + { + $this->isPrimaryKey = $primaryKey; + } + + public function isPrimaryKey(): bool + { + return $this->isPrimaryKey; + } + + public function markAsAutoIncrement(bool $autoIncrement = true): void + { + $this->hasAutoIncrement = $autoIncrement; + } + + public function hasAutoIncrement(): bool + { + return $this->hasAutoIncrement; + } + + public function setIsVirtual(bool $isVirtual): void + { + $this->isVirtual = $isVirtual; + } + + public function getIsVirtual(): bool + { + return $this->isVirtual; + } + + public function markAsVirtual(): void + { + $this->setIsVirtual(true); + } + + public function setIncludeInCopy(bool $includeInCopy): void + { + $this->includeInCopy = $includeInCopy; + } + + public function getIncludeInCopy(): bool + { + return $this->includeInCopy; + } + + public function after(ClauseField|string|null $field): void + { + $this->after = $field; + } + + public function getAfter(): ClauseField|string|null + { + return $this->after; + } + + /** + * Convert a value from database format to PHP format + */ + public function fromDatabaseFormatValue(mixed $value): mixed + { + if ($value === null) { + return null; + } + + return $this->type->fromDatabaseFormatValue($value); + } + + /** + * Convert a value from PHP format to database format + */ + public function toDatabaseFormatValue(mixed $value): mixed + { + if ($value === null) { + return null; + } + + return $this->type->toDatabaseFormatValue($value); + } + + /** + * Get the SQL definition of the field + * + * Contains the name and the type definition + */ + public function getSqlDefinition(DriverInterface $driver): string + { + return sprintf( + '%s %s', + $driver->escapeIdentifier($this->getName()), + $driver->getSqlFieldDefinition($this), + ); + } +} diff --git a/src/Schema/Index.php b/src/Schema/Index.php new file mode 100644 index 0000000..a5e5101 --- /dev/null +++ b/src/Schema/Index.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +use Access\Clause\Field; +use Access\Driver\DriverInterface; + +class Index +{ + private string $name; + + /** @var array */ + private array $fields; + + private bool $isUnique = false; + + /** + * @param array|string|Field $fields + */ + public function __construct(string $name, array|string|Field $fields) + { + $this->name = $name; + + if (!is_array($fields)) { + $fields = [$fields]; + } + + $this->fields = $fields; + } + + /** + * Set whether the index is unique + */ + public function unique(bool $isUnique = true): self + { + $this->isUnique = $isUnique; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + public function isUnique(): bool + { + return $this->isUnique; + } + + public function getSqlDefinition(DriverInterface $driver): string + { + return $driver->getSqlIndexDefinition($this); + } +} diff --git a/src/Schema/Table.php b/src/Schema/Table.php new file mode 100644 index 0000000..fa266e2 --- /dev/null +++ b/src/Schema/Table.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +use Access\Clause; +use Access\Schema\Type; + +class Table +{ + /** + * Name of created at field + */ + public const CREATED_AT_FIELD = 'created_at'; + + /** + * Name of updated at field + */ + public const UPDATED_AT_FIELD = 'updated_at'; + + /** + * Name of deleted at field + */ + public const DELETED_AT_FIELD = 'deleted_at'; + + private string $name; + + /** + * @var Field[] $fields + */ + private array $fields = []; + + /** + * @var Index[] $indexes + */ + private array $indexes = []; + + private bool $hasCreatedAt = false; + private bool $hasUpdatedAt = false; + private bool $hasDeletedAt = false; + + private Charset $defautCharset = Charset::Utf8; + private Collate $collate = Collate::Default; + private Engine $engine = Engine::Default; + + public function __construct( + string $name, + bool $hasCreatedAt = false, + bool $hasUpdatedAt = false, + bool $hasDeletedAt = false, + ) { + $this->name = $name; + $this->hasCreatedAt = $hasCreatedAt; + $this->hasUpdatedAt = $hasUpdatedAt; + $this->hasDeletedAt = $hasDeletedAt; + } + + /** + * Create and add a field to the table. + * + * @param Type|null $type Defaults to VarChar if null + */ + public function field(string $name, ?Type $type = null, mixed $default = null): Field + { + // make sure the dont' trip up our logic with the default value, + // if it's not provided by the user, dont't pass a third argument to the constructor + /** @var array{string, ?Type, mixed} $args */ + $args = func_get_args(); + + $field = new Field(...$args); + + $this->addField($field); + + return $field; + } + + /** + * @param array|string|Clause\Field $fields + */ + public function index(string $name, array|string|Clause\Field $fields): Index + { + $index = new Index($name, $fields); + + $this->addIndex($index); + + return $index; + } + + public function getName(): string + { + return $this->name; + } + + public function addField(Field $field): void + { + $this->fields[] = $field; + } + + public function addIndex(Index $index): void + { + $this->indexes[] = $index; + } + + /** + * @return Field[] + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * Get the names of all (defined) fields in the table + * + * @return string[] + */ + public function getFieldNames(): array + { + return array_map(fn(Field $field): string => $field->getName(), $this->fields); + } + + /** + * Does the table have a field with the given name? + */ + public function hasField(string $name): bool + { + return $this->getField($name) !== null; + } + + /** + * Get a field by its name + */ + public function getField(string $name): ?Field + { + foreach ($this->fields as $field) { + if ($field->getName() === $name) { + return $field; + } + } + + return null; + } + + /** + * @return Index[] + */ + public function getIndexes(): array + { + return $this->indexes; + } + + public function hasCreatedAt(): bool + { + return $this->hasCreatedAt; + } + + public function hasUpdatedAt(): bool + { + return $this->hasUpdatedAt; + } + + public function hasDeletedAt(): bool + { + return $this->hasDeletedAt; + } + + /** + * Is the given field a built-in date time field + * + * Checks if the feature for those fields is enabled and the predetermined names + * + * @param string $field The field name + * @return bool Is a built-in date time field + */ + public function isBuiltinDatetimeField(string $field): bool + { + if ($this->hasCreatedAt() && $field === self::CREATED_AT_FIELD) { + return true; + } + + if ($this->hasUpdatedAt() && $field === self::UPDATED_AT_FIELD) { + return true; + } + + if ($this->hasDeletedAt() && $field === self::DELETED_AT_FIELD) { + return true; + } + + return false; + } + + public function getDefaultCharset(): Charset + { + return $this->defautCharset; + } + + public function setDefaultCharset(Charset $charset): void + { + $this->defautCharset = $charset; + } + + public function getCollate(): Collate + { + return $this->collate; + } + + public function setCollate(Collate $collate): void + { + $this->collate = $collate; + } + + public function getEngine(): Engine + { + return $this->engine; + } + + public function setEngine(Engine $engine): void + { + $this->engine = $engine; + } +} diff --git a/src/Schema/Type.php b/src/Schema/Type.php new file mode 100644 index 0000000..44b311f --- /dev/null +++ b/src/Schema/Type.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema; + +abstract class Type +{ + /** + * Convert a value from the database format to the PHP format + */ + abstract function fromDatabaseFormatValue(mixed $value): mixed; + + /** + * Convert a value from the PHP format to the database format + */ + abstract function toDatabaseFormatValue(mixed $value): mixed; +} diff --git a/src/Schema/Type/Boolean.php b/src/Schema/Type/Boolean.php new file mode 100644 index 0000000..7996cea --- /dev/null +++ b/src/Schema/Type/Boolean.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Type; + +class Boolean extends Type +{ + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): bool + { + if (!is_int($value)) { + throw new InvalidDatabaseValueException('Invalid boolean type: ' . gettype($value)); + } + + return (bool) $value; + } + + public function toDatabaseFormatValue(mixed $value): int + { + return intval($value); + } +} diff --git a/src/Schema/Type/Date.php b/src/Schema/Type/Date.php new file mode 100644 index 0000000..12010f0 --- /dev/null +++ b/src/Schema/Type/Date.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Type; + +class Date extends Type +{ + public const DATABASE_FORMAT = 'Y-m-d'; + + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): \DateTimeImmutable + { + if (!is_string($value)) { + throw new InvalidDatabaseValueException('Invalid date type: ' . gettype($value)); + } + + $result = \DateTimeImmutable::createFromFormat( + self::DATABASE_FORMAT, + $value, + new \DateTimeZone('UTC'), + ); + + if ($result === false) { + throw new InvalidDatabaseValueException('Invalid date value: ' . $value); + } + + return $result; + } + + public function toDatabaseFormatValue(mixed $value): string + { + /** @var \DateTimeInterface $value */ + return \DateTimeImmutable::createFromInterface($value) + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DATABASE_FORMAT); + } +} diff --git a/src/Schema/Type/DateTime.php b/src/Schema/Type/DateTime.php new file mode 100644 index 0000000..e02f652 --- /dev/null +++ b/src/Schema/Type/DateTime.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Type; + +class DateTime extends Type +{ + public const DATABASE_FORMAT = 'Y-m-d H:i:s'; + + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): \DateTimeImmutable + { + if ($value instanceof \DateTimeInterface) { + return $this->fromMutable($value); + } + + if (!is_string($value)) { + throw new InvalidDatabaseValueException('Invalid date time type: ' . gettype($value)); + } + + $result = \DateTimeImmutable::createFromFormat( + self::DATABASE_FORMAT, + $value, + new \DateTimeZone('UTC'), + ); + + if ($result === false) { + throw new InvalidDatabaseValueException('Invalid date time value: ' . $value); + } + + return $result; + } + + public function toDatabaseFormatValue(mixed $value): string + { + /** @var \DateTimeInterface $value */ + return \DateTimeImmutable::createFromInterface($value) + ->setTimezone(new \DateTimeZone('UTC')) + ->format(self::DATABASE_FORMAT); + } + + /** + * Make mutable date immutable, if needed + * + * @param \DateTimeInterface $date + * @return \DateTimeImmutable + */ + private function fromMutable(\DateTimeInterface $date): \DateTimeImmutable + { + if ($date instanceof \DateTimeImmutable) { + return $date; + } + + return new \DateTimeImmutable($date->format('Y-m-d H:i:s.u'), $date->getTimezone()); + } +} diff --git a/src/Schema/Type/Enum.php b/src/Schema/Type/Enum.php new file mode 100644 index 0000000..40df10f --- /dev/null +++ b/src/Schema/Type/Enum.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Exception\InvalidFieldDefinitionException; +use Access\Schema\Type; +use BackedEnum; +use ValueError; + +/** + * @psalm-template T of BackedEnum + */ +class Enum extends Type +{ + /** + * @var class-string $enumName + * @psalm-var class-string $enumName + */ + private string $enumName; + + /** + * Override the cases available in the enum + * + * Migrations could have different values than the actual enum, most likely in the reverse migration + * + * @var array|null + */ + private ?array $overrideCases = null; + + /** + * @param class-string $enumName + * @psalm-param class-string $enumName + */ + public function __construct(string $enumName) + { + /** + * Users lie + * @psalm-suppress DocblockTypeContradiction + */ + if (empty($enumName)) { + throw new InvalidFieldDefinitionException('Missing enum name'); + } + + if (!is_subclass_of($enumName, BackedEnum::class)) { + throw new InvalidFieldDefinitionException(sprintf('Invalid enum name: %s', $enumName)); + } + + /** + * The `is_subclass_of` check ensures this is correct, but Psalm does not detect it properly + * @psalm-suppress PropertyTypeCoercion + */ + $this->enumName = $enumName; + } + + /** + * @return array + */ + public function getCases(): array + { + if ($this->overrideCases !== null) { + return $this->overrideCases; + } + + return array_map( + fn(BackedEnum $case): string|int => $case->value, + $this->enumName::cases(), + ); + } + + /** + * Set override cases + * + * Migrations could have different values than the actual enum, most likely in the reverse migration + * + * @param array $cases + */ + public function setOverrideCases(array $cases): static + { + $this->overrideCases = $cases; + + return $this; + } + + /** + * @psalm-return T + */ + public function fromDatabaseFormatValue(mixed $value): BackedEnum + { + if (!is_int($value) && !is_string($value)) { + throw new InvalidDatabaseValueException('Invalid backing value for enum'); + } + + try { + return $this->enumName::from($value); + } catch (ValueError $e) { + throw new InvalidDatabaseValueException('Invalid enum value', $e->getCode(), $e); + } + } + + public function toDatabaseFormatValue(mixed $value): string + { + if ($value instanceof BackedEnum) { + return (string) $value->value; + } + + return (string) $value; + } +} diff --git a/src/Schema/Type/Float.php b/src/Schema/Type/Float.php new file mode 100644 index 0000000..b82dcd2 --- /dev/null +++ b/src/Schema/Type/Float.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Type; + +class FloatType extends Type +{ + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): float + { + if (!is_float($value)) { + throw new InvalidDatabaseValueException('Invalid float type: ' . gettype($value)); + } + + return $value; + } + + public function toDatabaseFormatValue(mixed $value): float + { + return (float) $value; + } +} diff --git a/src/Schema/Type/Integer.php b/src/Schema/Type/Integer.php new file mode 100644 index 0000000..85d2e87 --- /dev/null +++ b/src/Schema/Type/Integer.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Type; + +class Integer extends Type +{ + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): int + { + // we are quite lenient here and accept strings and floats as well + // this is to match our previous behavior in the entity class + if (!is_int($value) && !is_string($value) && !is_float($value)) { + throw new InvalidDatabaseValueException('Invalid integer type: ' . gettype($value)); + } + + // best effort :) + return (int) $value; + } + + public function toDatabaseFormatValue(mixed $value): int + { + return (int) $value; + } +} diff --git a/src/Schema/Type/Json.php b/src/Schema/Type/Json.php new file mode 100644 index 0000000..f181b12 --- /dev/null +++ b/src/Schema/Type/Json.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidDatabaseValueException; +use Access\Schema\Exception\InvalidValueException; +use Access\Schema\Type; + +class Json extends Type +{ + public function __construct() {} + + public function fromDatabaseFormatValue(mixed $value): mixed + { + if (!is_string($value)) { + throw new InvalidDatabaseValueException('Invalid JSON type: ' . gettype($value)); + } + + try { + /** @var mixed $result */ + $result = json_decode($value, true, 512, JSON_THROW_ON_ERROR); + + return $result; + } catch (\JsonException $e) { + throw new InvalidDatabaseValueException( + 'Invalid JSON value: ' . $e->getMessage(), + 0, + $e, + ); + } + } + + public function toDatabaseFormatValue(mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidValueException( + 'Failed to encode value as JSON: ' . $e->getMessage(), + 0, + $e, + ); + } + } +} diff --git a/src/Schema/Type/Reference.php b/src/Schema/Type/Reference.php new file mode 100644 index 0000000..01f6189 --- /dev/null +++ b/src/Schema/Type/Reference.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Cascade; +use Access\Database; +use Access\Entity; +use Access\Schema\Table; + +/** + * @psalm-template T of Entity + */ +class Reference extends Integer +{ + /** + * Entity that is referenced + * + * @psalm-var class-string|string|Table + */ + private string|Table $table; + + private ?Cascade $cascade = null; + + /** + * Create a field with a name + * + * @param class-string|string|Table $table + */ + public function __construct(Table|string $table, ?Cascade $cascade = null) + { + $this->table = $table; + $this->cascade = $cascade; + } + + /** + * Get the target entity or table + * + * @psalm-return class-string|string|Table + */ + public function getTarget(): Table|string + { + return $this->table; + } + + /** + * Get the name of the table + */ + public function getTableName(): string + { + if ($this->table instanceof Table) { + return $this->table->getName(); + } + + if (is_subclass_of($this->table, Entity::class)) { + return $this->table::tableName(); + } + + return $this->table; + } + + public function getCascade(): ?Cascade + { + return $this->cascade; + } +} diff --git a/src/Schema/Type/StringType.php b/src/Schema/Type/StringType.php new file mode 100644 index 0000000..82607ef --- /dev/null +++ b/src/Schema/Type/StringType.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +use Access\Schema\Exception\InvalidValueException; +use Access\Schema\Type; + +abstract class StringType extends Type +{ + public function fromDatabaseFormatValue(mixed $value): string + { + if (!is_string($value)) { + throw new InvalidValueException('Invalid string type: ' . gettype($value)); + } + + return $value; + } + + public function toDatabaseFormatValue(mixed $value): string + { + return (string) $value; + } +} diff --git a/src/Schema/Type/Text.php b/src/Schema/Type/Text.php new file mode 100644 index 0000000..6f309cb --- /dev/null +++ b/src/Schema/Type/Text.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +class Text extends StringType {} diff --git a/src/Schema/Type/VarBinary.php b/src/Schema/Type/VarBinary.php new file mode 100644 index 0000000..e8e0526 --- /dev/null +++ b/src/Schema/Type/VarBinary.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +class VarBinary extends StringType +{ + private int $size; + + public function __construct(int $size = 191) + { + $this->size = $size; + } + + public function getSize(): int + { + return $this->size; + } +} diff --git a/src/Schema/Type/VarChar.php b/src/Schema/Type/VarChar.php new file mode 100644 index 0000000..5beb4ac --- /dev/null +++ b/src/Schema/Type/VarChar.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Schema\Type; + +class VarChar extends StringType +{ + private int $size; + + public function __construct(int $size = 191) + { + $this->size = $size; + } + + public function getSize(): int + { + return $this->size; + } +} diff --git a/src/Statement.php b/src/Statement.php index 2360fba..1fec8cd 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -82,7 +82,7 @@ public function __construct(Database $db, Profiler $profiler, Query $query) /** * Execute the query * - * @return \Generator - yields Entity for select queries + * @return \Generator> - yields Entity for select queries */ public function execute(): \Generator { @@ -96,7 +96,10 @@ public function execute(): \Generator $profile->startPrepare(); $statement = $this->statementPool->prepare($this->sql); } catch (\PDOException $e) { - throw new Exception('Unable to prepare query: ' . $e->getMessage(), 0, $e); + throw $this->db->convertPdoException( + $e, + 'Unable to prepare query: ' . $e->getMessage(), + ); } finally { $profile->endPrepare(); } @@ -105,7 +108,10 @@ public function execute(): \Generator $profile->startExecute(); $statement->execute($this->query->getValues($this->db->getDriver())); } catch (\PDOException $e) { - throw new Exception('Unable to execute query: ' . $e->getMessage(), 0, $e); + throw $this->db->convertPdoException( + $e, + 'Unable to execute query: ' . $e->getMessage(), + ); } finally { $profile->endExecute(); } @@ -121,7 +127,7 @@ public function execute(): \Generator yield $row; } } catch (\PDOException $e) { - throw new Exception('Unable to fetch: ' . $e->getMessage(), 0, $e); + throw $this->db->convertPdoException($e, 'Unable to fetch: ' . $e->getMessage()); } finally { $profile->endHydrate(); $profile->setNumberOfResults($numberOfResults); @@ -140,28 +146,35 @@ public function execute(): \Generator */ private function getReturnValue(): ?int { - if ($this->query instanceof Insert) { - if ($this->sql === null) { - // insert queries always return a string, but the type - // of this property is string|null, so we need to check - // for it - // @codeCoverageIgnoreStart - return -1; - // @codeCoverageIgnoreEnd + try { + if ($this->query instanceof Insert) { + if ($this->sql === null) { + // insert queries always return a string, but the type + // of this property is string|null, so we need to check + // for it + // @codeCoverageIgnoreStart + return -1; + // @codeCoverageIgnoreEnd + } + + return (int) $this->db->getConnection()->lastInsertId(); } - return (int) $this->db->getConnection()->lastInsertId(); - } + if (!$this->query instanceof Select) { + if ($this->sql === null) { + return 0; + } - if (!$this->query instanceof Select) { - if ($this->sql === null) { - return 0; + $statement = $this->statementPool->prepare($this->sql); + return $statement->rowCount(); } - $statement = $this->statementPool->prepare($this->sql); - return $statement->rowCount(); + return null; + } catch (\PDOException $e) { + throw $this->db->convertPdoException( + $e, + 'Unable to get return value: ' . $e->getMessage(), + ); } - - return null; } } diff --git a/src/Transaction.php b/src/Transaction.php index ca1a44e..1bf28de 100644 --- a/src/Transaction.php +++ b/src/Transaction.php @@ -74,19 +74,26 @@ public function __destruct() */ public function begin(): void { - $connection = $this->db->getConnection(); - - if ($connection->inTransaction()) { - $this->savepointIdentifier = $this->generateSavepointIdentifier(); - $query = new Savepoint($this->savepointIdentifier); - $this->db->query($query); - } else { - $query = new Begin(); - $this->db->getProfiler()->createForQuery($query); - $connection->beginTransaction(); + try { + $connection = $this->db->getConnection(); + + if ($connection->inTransaction()) { + $this->savepointIdentifier = $this->generateSavepointIdentifier(); + $query = new Savepoint($this->savepointIdentifier); + $this->db->query($query); + } else { + $query = new Begin(); + $this->db->getProfiler()->createForQuery($query); + $connection->beginTransaction(); + } + + $this->inTransaction = true; + } catch (\PDOException $e) { + throw $this->db->convertPdoException( + $e, + 'Unable to begin transaction: ' . $e->getMessage(), + ); } - - $this->inTransaction = true; } /** @@ -94,22 +101,29 @@ public function begin(): void */ public function commit(): void { - if (!$this->inTransaction) { - return; - } + try { + if (!$this->inTransaction) { + return; + } - if ($this->savepointIdentifier !== null) { - // the outer transaction will provide the commit - $this->inTransaction = false; - return; - } + if ($this->savepointIdentifier !== null) { + // the outer transaction will provide the commit + $this->inTransaction = false; + return; + } - $this->db->getConnection()->commit(); + $this->db->getConnection()->commit(); - $query = new Commit(); - $this->db->getProfiler()->createForQuery($query); + $query = new Commit(); + $this->db->getProfiler()->createForQuery($query); - $this->inTransaction = false; + $this->inTransaction = false; + } catch (\PDOException $e) { + throw $this->db->convertPdoException( + $e, + 'Unable to commit transaction: ' . $e->getMessage(), + ); + } } /** @@ -117,24 +131,31 @@ public function commit(): void */ public function rollBack(): void { - if (!$this->inTransaction) { - return; - } + try { + if (!$this->inTransaction) { + return; + } - if ($this->savepointIdentifier !== null) { - $query = new RollbackToSavepoint($this->savepointIdentifier); - $this->db->query($query); + if ($this->savepointIdentifier !== null) { + $query = new RollbackToSavepoint($this->savepointIdentifier); + $this->db->query($query); - $this->inTransaction = false; - return; - } + $this->inTransaction = false; + return; + } - $this->db->getConnection()->rollBack(); + $this->db->getConnection()->rollBack(); - $query = new Rollback(); - $this->db->getProfiler()->createForQuery($query); + $query = new Rollback(); + $this->db->getProfiler()->createForQuery($query); - $this->inTransaction = false; + $this->inTransaction = false; + } catch (\PDOException $e) { + throw $this->db->convertPdoException( + $e, + 'Unable to roll back transaction: ' . $e->getMessage(), + ); + } } private function generateSavepointIdentifier(): string diff --git a/tests/base/BaseCollectionTest.php b/tests/base/BaseCollectionTest.php index 7694c46..3dfaeab 100644 --- a/tests/base/BaseCollectionTest.php +++ b/tests/base/BaseCollectionTest.php @@ -516,4 +516,40 @@ public function testDeduplication(): void $this->assertEquals(0, $deduplicated->count()); } + + public function testSimpleLimit(): void + { + $db = static::createDatabaseWithDummyData(); + + /** @var ProjectRepository $projectRepo */ + $projectRepo = $db->getRepository(Project::class); + $projects = $projectRepo->findAllCollection(); + + $ids = $projects->getIds(); + $this->assertEquals([1, 2], $ids); + + $limitedProjects = $projects->applyClause(new Clause\Limit(1)); + + $limitedIds = $limitedProjects->getIds(); + $this->assertEquals([1], $limitedIds); + } + + public function testMultipleLimit(): void + { + $db = static::createDatabaseWithDummyData(); + + /** @var ProjectRepository $projectRepo */ + $projectRepo = $db->getRepository(Project::class); + $projects = $projectRepo->findAllCollection(); + + $ids = $projects->getIds(); + $this->assertEquals([1, 2], $ids); + + $limitedProjects = $projects->applyClause( + new Clause\Multiple(new Clause\Limit(1, 1), new Clause\Limit(1)), + ); + + $limitedIds = $limitedProjects->getIds(); + $this->assertEquals([2], $limitedIds); + } } diff --git a/tests/base/BaseCursorTest.php b/tests/base/BaseCursorTest.php index 2589552..0aba61e 100644 --- a/tests/base/BaseCursorTest.php +++ b/tests/base/BaseCursorTest.php @@ -15,14 +15,12 @@ use Access\Batch; use Access\Query\Cursor\CurrentIdsCursor; -use Access\Query\Cursor\MaxValueCursor; -use Access\Query\Cursor\MinValueCursor; use Access\Query\Cursor\PageCursor; use Access\Query\Select; use PHPUnit\Framework\TestCase; use Tests\Fixtures\Entity\Project; +use Tests\Fixtures\Entity\User; use Tests\Fixtures\Repository\ProjectRepository; -use Tests\Sqlite\AbstractBaseTestCase; abstract class BaseCursorTest extends TestCase implements DatabaseBuilderInterface { @@ -299,4 +297,63 @@ public function testBatchedMaxValueCursor(): void $this->assertCount(0, $expectedQueries); $this->assertCount(0, $expectedValues); } + + public function testFilterClausePageCursor(): void + { + $db = static::createDatabaseWithDummyData(); + + $third = new User(); + $third->setEmail('third@example.com'); + $third->setName('Third'); + $db->insert($third); + + $all = $db->getRepository(User::class)->findAllCollection(); + + $this->assertCount(3, $all); + + $baseIds = $all->getIds(); + $this->assertEquals([1, 2, 3], $baseIds); + + $cursor = new PageCursor(pageSize: 2, page: 1); + $page = $all->applyClause($cursor); + $this->assertCount(2, $page); + $this->assertEquals([1, 2], $page->getIds()); + + $cursor->setPage(2); + $page = $all->applyClause($cursor); + $this->assertCount(1, $page); + $this->assertEquals([3], $page->getIds()); + + $cursor->setPage(3); + $page = $all->applyClause($cursor); + $this->assertCount(0, $page); + $this->assertEquals([], $page->getIds()); + } + + public function testFilterClauseCurrentIdsCursor(): void + { + $db = static::createDatabaseWithDummyData(); + + $third = new User(); + $third->setEmail('third@example.com'); + $third->setName('Third'); + $db->insert($third); + + $all = $db->getRepository(User::class)->findAllCollection(); + + $this->assertCount(3, $all); + + $baseIds = $all->getIds(); + $this->assertEquals([1, 2, 3], $baseIds); + + $cursor = new CurrentIdsCursor(pageSize: 2); + $page = $all->applyClause($cursor); + $this->assertCount(3, $page); + $this->assertEquals([1, 2, 3], $page->getIds()); + + $cursor->addCurrentIds([1, 2]); + $page = $all->applyClause($cursor); + $this->assertCount(1, $page); + $this->assertEquals([3], $page->getIds()); + } } diff --git a/tests/base/BaseDatabaseTest.php b/tests/base/BaseDatabaseTest.php index 310a97e..78e10bd 100644 --- a/tests/base/BaseDatabaseTest.php +++ b/tests/base/BaseDatabaseTest.php @@ -16,7 +16,11 @@ use Access\Database; use Access\Exception; use Access\Exception\ClosedConnectionException; +use Access\Exception\DuplicateEntryException; use Access\Query; +use Access\Query\CreateTable; +use Access\Query\Insert; +use Access\Schema\Table; use PDO; use PHPUnit\Framework\TestCase; @@ -345,4 +349,25 @@ public function testCloseConnection(): void $this->expectExceptionMessage('Connection is closed'); $db->getConnection(); } + + public function testDuplicateEntryException(): void + { + $db = static::createEmptyDatabase(); + + $posts = new Table('users'); + $slug = $posts->field('slug'); + $posts->index('slug_index', $slug)->unique(); + + $create = new CreateTable($posts); + $db->query($create); + + $insert = new Insert($posts); + $insert->values(['slug' => 'example']); + $db->query($insert); + + $this->expectException(DuplicateEntryException::class); + $this->expectExceptionMessage('Duplicate entry'); + + $db->query($insert); + } } diff --git a/tests/base/BaseEntityTest.php b/tests/base/BaseEntityTest.php index 0b52eaf..8d2210b 100644 --- a/tests/base/BaseEntityTest.php +++ b/tests/base/BaseEntityTest.php @@ -14,6 +14,7 @@ namespace Tests\Base; use Access\Exception; +use Access\Schema\Exception\InvalidFieldDefinitionException; use PHPUnit\Framework\TestCase; use Tests\Fixtures\Entity\InvalidEnumNameEntity; use Tests\Fixtures\Entity\MissingEnumNameEntity; @@ -185,19 +186,21 @@ public function testMissingEnumName(): void $entity = new MissingEnumNameEntity(); $entity->setStatus(UserStatus::ACTIVE); - $db->save($entity); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Missing enum name for field "status"'); + $this->expectException(InvalidFieldDefinitionException::class); + $this->expectExceptionMessage('Missing enum name'); - // hydrating fails - $db->findOne(MissingEnumNameEntity::class, $entity->getId()); + $db->save($entity); } public function testInvalidEnumName(): void { $db = static::createDatabase(); + $this->expectException(InvalidFieldDefinitionException::class); + $this->expectExceptionMessage( + 'Invalid enum name: Tests\Fixtures\Entity\InvalidEnumNameEntity', + ); + $entity = new InvalidEnumNameEntity(); $entity->setStatus(UserStatus::ACTIVE); @@ -205,7 +208,7 @@ public function testInvalidEnumName(): void $this->expectException(Exception::class); $this->expectExceptionMessage( - 'Invalid enum name for field "status": Tests\Fixtures\Entity\InvalidEnumNameEntity', + 'Invalid enum name: Tests\Fixtures\Entity\InvalidEnumNameEntity', ); // hydrating fails diff --git a/tests/base/BaseMigrationTest.php b/tests/base/BaseMigrationTest.php new file mode 100644 index 0000000..2e737f1 --- /dev/null +++ b/tests/base/BaseMigrationTest.php @@ -0,0 +1,176 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Base; + +use Access\Exception; +use Access\Migrations\Checkpoint; +use Access\Migrations\Exception\MigrationFailedException; +use Access\Migrations\MigrationEntity; +use Access\Migrations\Migrator; +use PHPUnit\Framework\TestCase; +use Tests\Fixtures\Entity\User; +use Tests\Fixtures\Migrations\Version2025080412000; +use Tests\Fixtures\Migrations\Version2025080412001; + +abstract class BaseMigrationTest extends TestCase implements DatabaseBuilderInterface +{ + public function testMigration(): void + { + $db = static::createEmptyDatabase(); + + $migrator = new Migrator($db, MigrationEntity::class); + $migrator->init(); + $migrator->init(); // second call is noop + + $migration = new Version2025080412000(); + + $result = $migrator->constructive($migration); + $this->assertTrue($result->isSuccess()); + + $users = $db->findAll(User::class); + + foreach ($users as $user) { + $this->assertInstanceOf(User::class, $user); + } + + // generator is consumed, thus, table is created + $this->assertNull($users->getReturn()); + + $result = $migrator->constructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertFalse($result->isSuccess()); + + // table does not exist anymore + $this->expectException(Exception::class); + + $users = $db->findAll(User::class); + + // consume generator to trigger the query + iterator_to_array($users); + } + + public function testMigrationStateMachine(): void + { + $db = static::createEmptyDatabase(); + + $migrator = new Migrator($db, MigrationEntity::class); + $migrator->init(); + + $migration = new Version2025080412000(); + + $result = $migrator->revertConstructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->destructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertDestructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->constructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->constructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertDestructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->constructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->destructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->destructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertDestructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->revertDestructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->destructive($migration); + $this->assertFalse($result->isSuccess()); + + $result = $migrator->constructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->destructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->revertDestructive($migration); + $this->assertTrue($result->isSuccess()); + + $result = $migrator->revertConstructive($migration); + $this->assertTrue($result->isSuccess()); + + $this->expectException(Exception::class); + $users = $db->findAll(User::class); + + // consume generator to trigger the query + iterator_to_array($users); + } + + public function testMigrationWithInsert(): void + { + $db = static::createEmptyDatabase(); + + $migrator = new Migrator($db); + $migrator->init(); + + $migration = new Version2025080412001(); + + $result = $migrator->constructive($migration); + $this->assertTrue($result->isSuccess()); + + $users = $db->findAll(User::class); + iterator_to_array($users); // consume once + // generator is consumed, thus, table is created + $this->assertNull($users->getReturn()); + } + + public function testMigrationCheckpoint(): void + { + $db = static::createEmptyDatabase(); + + $migrator = new Migrator($db); + $migrator->init(); + + $migration = new Version2025080412001(); + + $checkpoint = new Checkpoint(1); + + // using checkpoint 1 skips past the create table + $this->expectException(MigrationFailedException::class); + $this->expectExceptionMessageMatches('/Table does not exists/'); + + $migrator->constructive($migration, $checkpoint); + } +} diff --git a/tests/base/BasePresenterTest.php b/tests/base/BasePresenterTest.php index c9ae7d2..27e9d32 100644 --- a/tests/base/BasePresenterTest.php +++ b/tests/base/BasePresenterTest.php @@ -38,6 +38,7 @@ use Tests\Fixtures\Presenter\ProjectWithEmptyPresenter; use Tests\Fixtures\Presenter\ProjectWithOwnerPresenter; use Tests\Fixtures\Presenter\ProjectWithReceiveDependenciesPresenter; +use Tests\Fixtures\Presenter\ProjectWithUserNamePresenter; use Tests\Fixtures\Presenter\UserEmptyResultPresenter; use Tests\Fixtures\Presenter\UserOptionalDependencyPresenter; use Tests\Fixtures\Presenter\UserWithClausePresenter; @@ -60,6 +61,7 @@ private function createAndSetupEntities(int $options = 0): array $db = static::createDatabase(); $userOne = new User(); + $userOne->setName('one'); $db->save($userOne); $projectOne = new Project(); @@ -69,6 +71,7 @@ private function createAndSetupEntities(int $options = 0): array if (($options & self::OPTION_EXTRA_USER) !== 0) { $userTwo = new User(); + $userTwo->setName('two'); $db->save($userTwo); } else { $userTwo = null; @@ -1037,6 +1040,126 @@ public function testMultipleFilterUnique(): void $this->assertEquals($expected, $result); } + public function testLimitClause(): void + { + [$db, $userOne, $projectOne] = $this->createAndSetupEntities(); + + $expected = [ + 'id' => $userOne->getId(), + 'projects' => [ + [ + 'id' => $projectOne->getId(), + 'name' => $projectOne->getName(), + ], + ], + ]; + + $presenter = new Presenter($db); + $presenter->addDependency(new Clause\Limit(1)); + $result = $presenter->presentEntity(UserWithClausePresenter::class, $userOne); + + $this->assertEquals($expected, $result); + } + + public function testMultipleLimitClause(): void + { + [$db, $userOne, $projectOne] = $this->createAndSetupEntities(); + + $expected = [ + 'id' => $userOne->getId(), + 'projects' => [ + [ + 'id' => $projectOne->getId(), + 'name' => $projectOne->getName(), + ], + ], + ]; + + $presenter = new Presenter($db); + $presenter->addDependency(new Clause\Multiple(new Clause\Limit(10), new Clause\Limit(1))); + $result = $presenter->presentEntity(UserWithClausePresenter::class, $userOne); + + $this->assertEquals($expected, $result); + } + + public function testMultipleInverseLimitClause(): void + { + [$db, $userOne, $projectOne] = $this->createAndSetupEntities(); + + $expected = [ + 'id' => $userOne->getId(), + 'projects' => [ + [ + 'id' => $projectOne->getId(), + 'name' => $projectOne->getName(), + ], + ], + ]; + + $presenter = new Presenter($db); + $presenter->addDependency(new Clause\Multiple(new Clause\Limit(1), new Clause\Limit(10))); + $result = $presenter->presentEntity(UserWithClausePresenter::class, $userOne); + + $this->assertEquals($expected, $result); + } + + public function testMultipleLimitOffsetClause(): void + { + [$db, $userOne, , $projectTwo] = $this->createAndSetupEntities(); + + $expected = [ + 'id' => $userOne->getId(), + 'projects' => [ + [ + 'id' => $projectTwo->getId(), + 'name' => $projectTwo->getName(), + ], + ], + ]; + + $presenter = new Presenter($db); + $presenter->addDependency( + new Clause\Multiple(new Clause\Limit(1, 1), new Clause\Limit(10)), + ); + $result = $presenter->presentEntity(UserWithClausePresenter::class, $userOne); + + $this->assertEquals($expected, $result); + } + + public function testMixedMultipleLimitClause(): void + { + $db = static::createDatabase(); + + $user = $this->createUser($db, 'Name'); + + $this->createProject($db, $user, 'Same name'); + $this->createProject($db, $user, 'Same name'); + $p3 = $this->createProject($db, $user, 'Other name'); + + $expected = [ + 'id' => $user->getId(), + 'projects' => [ + [ + 'id' => $p3->getId(), + 'name' => $p3->getName(), + ], + ], + ]; + + $presenter = new Presenter($db); + $presenter->addDependency( + new Clause\Multiple( + new Clause\Filter\Unique('id'), + new Clause\Filter\Unique('name'), + new Clause\OrderBy\Descending('id'), + new Clause\Limit(1), + ), + ); + $result = $presenter->presentEntity(UserWithClausePresenter::class, $user); + + $this->assertEquals($expected, $result); + } + public function testInvalidPresenterKlass(): void { [$db, $userOne] = $this->createAndSetupEntities(); @@ -1328,6 +1451,24 @@ public function testCustomMarker(): void $this->assertEquals($expected, $result); } + public function testStringReferenceValue(): void + { + [$db, $userOne, $projectOne] = $this->createAndSetupEntities(); + + $expected = [ + 'id' => $projectOne->getId(), + 'owner' => [ + 'id' => $userOne->getId(), + 'name' => $userOne->getName(), + ], + ]; + + $presenter = $db->createPresenter(); + $result = $presenter->presentEntity(ProjectWithUserNamePresenter::class, $projectOne); + + $this->assertEquals($expected, $result); + } + /** * Helper to create a user in a single line */ diff --git a/tests/base/DatabaseBuilderInterface.php b/tests/base/DatabaseBuilderInterface.php index 3054c61..23f9e08 100644 --- a/tests/base/DatabaseBuilderInterface.php +++ b/tests/base/DatabaseBuilderInterface.php @@ -18,6 +18,8 @@ interface DatabaseBuilderInterface { + public static function createEmptyDatabase(): Database; + public static function createDatabase(): Database; public static function createDatabaseWithDummyData(): Database; diff --git a/tests/fixtures/Entity/InvalidEnumNameEntity.php b/tests/fixtures/Entity/InvalidEnumNameEntity.php index ec96557..fc91029 100644 --- a/tests/fixtures/Entity/InvalidEnumNameEntity.php +++ b/tests/fixtures/Entity/InvalidEnumNameEntity.php @@ -14,7 +14,9 @@ namespace Tests\Fixtures\Entity; use Access\Entity; - +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; use Tests\Fixtures\UserStatus; /** @@ -27,8 +29,13 @@ public static function tableName(): string return 'users'; } + /** + * We testing here, just let us be... + * @psalm-suppress InvalidReturnType + */ public static function fields(): array { + /** @psalm-suppress InvalidReturnStatement */ return [ 'status' => [ 'type' => self::FIELD_TYPE_ENUM, @@ -37,6 +44,17 @@ public static function fields(): array ]; } + public static function getTableSchema(): Table + { + $table = new Table('users'); + + /** @psalm-suppress InvalidArgument */ + $status = new Field('status', new Type\Enum(InvalidEnumNameEntity::class)); + $table->addField($status); + + return $table; + } + public function setStatus(UserStatus $status): void { $this->set('status', $status); diff --git a/tests/fixtures/Entity/LogMessage.php b/tests/fixtures/Entity/LogMessage.php index 88e1d65..f4bc76a 100644 --- a/tests/fixtures/Entity/LogMessage.php +++ b/tests/fixtures/Entity/LogMessage.php @@ -15,6 +15,8 @@ use Access\Entity; use Access\Entity\CreatableTrait; +use Access\Schema\Field; +use Access\Schema\Table; /** * SAFETY Return types are not known, they are stored in an array config @@ -37,6 +39,21 @@ public static function fields(): array ]; } + public static function getParentTableSchema(): Table + { + return parent::getTableSchema(); + } + + public static function getTableSchema(): Table + { + $table = new Table('log_messages', hasCreatedAt: true); + + $message = new Field('message'); + $table->addField($message); + + return $table; + } + public function setMessage(string $message): void { $this->set('message', $message); diff --git a/tests/fixtures/Entity/MissingEnumNameEntity.php b/tests/fixtures/Entity/MissingEnumNameEntity.php index ae2ce3b..c5a5a4e 100644 --- a/tests/fixtures/Entity/MissingEnumNameEntity.php +++ b/tests/fixtures/Entity/MissingEnumNameEntity.php @@ -14,7 +14,9 @@ namespace Tests\Fixtures\Entity; use Access\Entity; - +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; use Tests\Fixtures\UserStatus; /** @@ -36,6 +38,21 @@ public static function fields(): array ]; } + public static function getTableSchema(): Table + { + $table = new Table('users'); + + /** + * We be testin' + * @psalm-suppress ArgumentTypeCoercion + * @psalm-suppress UndefinedClass + */ + $status = new Field('status', new Type\Enum('')); + $table->addField($status); + + return $table; + } + public function setStatus(UserStatus $status): void { $this->set('status', $status); diff --git a/tests/fixtures/Entity/MissingPublicSoftDeleteEntity.php b/tests/fixtures/Entity/MissingPublicSoftDeleteEntity.php index 82af943..770a73e 100644 --- a/tests/fixtures/Entity/MissingPublicSoftDeleteEntity.php +++ b/tests/fixtures/Entity/MissingPublicSoftDeleteEntity.php @@ -14,6 +14,7 @@ namespace Tests\Fixtures\Entity; use Access\Entity; +use Access\Schema\Table; /** * Missing public soft delete @@ -39,4 +40,11 @@ protected function setDeletedAt(\DateTimeInterface $deletedAt): void { $this->set('deleted_at', $deletedAt); } + + public static function getTableSchema(): Table + { + $table = new Table('missing_public_soft_delete'); + + return $table; + } } diff --git a/tests/fixtures/Entity/MissingSetDeletedEntity.php b/tests/fixtures/Entity/MissingSetDeletedEntity.php index b539ff4..419d4d1 100644 --- a/tests/fixtures/Entity/MissingSetDeletedEntity.php +++ b/tests/fixtures/Entity/MissingSetDeletedEntity.php @@ -14,6 +14,7 @@ namespace Tests\Fixtures\Entity; use Access\Entity; +use Access\Schema\Table; /** * Missing public soft delete @@ -34,4 +35,11 @@ public static function fields(): array { return []; } + + public static function getTableSchema(): Table + { + $table = new Table('missing_set_deleted'); + + return $table; + } } diff --git a/tests/fixtures/Entity/MissingTableEntity.php b/tests/fixtures/Entity/MissingTableEntity.php index 42d7ff8..f2f8a61 100644 --- a/tests/fixtures/Entity/MissingTableEntity.php +++ b/tests/fixtures/Entity/MissingTableEntity.php @@ -14,6 +14,7 @@ namespace Tests\Fixtures\Entity; use Access\Entity; +use Access\Schema\Table; /** * Invalid table name @@ -29,4 +30,11 @@ public static function fields(): array { return []; } + + public static function getTableSchema(): Table + { + $table = new Table(''); + + return $table; + } } diff --git a/tests/fixtures/Entity/Photo.php b/tests/fixtures/Entity/Photo.php index b35d84a..d6297db 100644 --- a/tests/fixtures/Entity/Photo.php +++ b/tests/fixtures/Entity/Photo.php @@ -14,6 +14,7 @@ namespace Tests\Fixtures\Entity; use Access\Entity; +use Access\Schema\Table; /** * Invalid repository @@ -44,4 +45,11 @@ public static function fields(): array { return []; } + + public static function getTableSchema(): Table + { + $table = new Table('photos', hasCreatedAt: true, hasUpdatedAt: true); + + return $table; + } } diff --git a/tests/fixtures/Entity/ProfileImage.php b/tests/fixtures/Entity/ProfileImage.php index a618339..de9f342 100644 --- a/tests/fixtures/Entity/ProfileImage.php +++ b/tests/fixtures/Entity/ProfileImage.php @@ -15,6 +15,7 @@ use Access\Entity; use Access\Entity\TimestampableTrait; +use Access\Schema\Table; /** * SAFETY Return types are not known, they are stored in an array config @@ -34,4 +35,16 @@ public static function fields(): array { return []; } + + public static function getParentTableSchema(): Table + { + return parent::getTableSchema(); + } + + public static function getTableSchema(): Table + { + $table = new Table('profile_images', hasCreatedAt: true, hasUpdatedAt: true); + + return $table; + } } diff --git a/tests/fixtures/Entity/Project.php b/tests/fixtures/Entity/Project.php index bc0c349..cae26c2 100644 --- a/tests/fixtures/Entity/Project.php +++ b/tests/fixtures/Entity/Project.php @@ -17,6 +17,9 @@ use Tests\Fixtures\Repository\ProjectRepository; use Access\Entity; +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; /** * SAFETY Return types are not known, they are stored in an array config @@ -58,6 +61,36 @@ public static function fields(): array ]; } + public static function getParentTableSchema(): Table + { + return parent::getTableSchema(); + } + + public static function getTableSchema(): Table + { + $table = new Table('projects', hasCreatedAt: true, hasUpdatedAt: true); + + $status = new Field('status'); + $status->setDefault(fn() => 'IN_PROGRESS'); + $table->addField($status); + + $name = new Field('name'); + $table->addField($name); + + $ownerId = new Field('owner_id', new Type\Reference(User::class, Cascade::deleteSame())); + $table->addField($ownerId); + + $publishedAt = new Field('published_at', new Type\Date(), fn() => null); + $publishedAt->setIncludeInCopy(false); + $table->addField($publishedAt); + + $userName = new Field('user_name'); + $userName->markAsVirtual(); + $table->addField($userName); + + return $table; + } + public static function timestamps(): bool { return true; @@ -96,12 +129,12 @@ public function getUserName(): string public function getCreatedAt(): \DateTimeImmutable { - return $this->get(Entity::CREATED_AT_FIELD); + return $this->get(Table::CREATED_AT_FIELD); } public function getUpdatedAt(): \DateTimeImmutable { - return $this->get(Entity::UPDATED_AT_FIELD); + return $this->get(Table::UPDATED_AT_FIELD); } public function getPublishedAt(): ?\DateTimeImmutable diff --git a/tests/fixtures/Entity/User.php b/tests/fixtures/Entity/User.php index e90716b..90eeb6e 100644 --- a/tests/fixtures/Entity/User.php +++ b/tests/fixtures/Entity/User.php @@ -19,6 +19,9 @@ use Access\Entity; use Access\Entity\SoftDeletableTrait; use Access\Entity\TimestampableTrait; +use Access\Schema\Field; +use Access\Schema\Table; +use Access\Schema\Type; use Tests\Fixtures\UserStatus; /** @@ -60,6 +63,8 @@ public static function fields(): array ], 'profile_image_id' => [ 'type' => self::FIELD_TYPE_INT, + 'target' => ProfileImage::class, + 'cascade' => Cascade::deleteSame(), ], 'status' => [ 'type' => self::FIELD_TYPE_ENUM, @@ -75,6 +80,35 @@ public static function fields(): array ]; } + public static function getParentTableSchema(): Table + { + return parent::getTableSchema(); + } + + public static function getTableSchema(): Table + { + $table = new Table('users', hasCreatedAt: true, hasUpdatedAt: true, hasDeletedAt: true); + + $table->field('role', default: 'USER'); + + $table->field( + 'profile_image_id', + new Type\Reference(ProfileImage::class, Cascade::deleteSame()), + ); + + $table->field('status', new Type\Enum(UserStatus::class), UserStatus::ACTIVE); + + $table->field('email'); + + $table->field('name'); + + $totalProjects = new Field('total_projects', new Type\Integer()); + $totalProjects->markAsVirtual(); + $table->addField($totalProjects); + + return $table; + } + public function setEmail(string $email): void { $this->set('email', $email); diff --git a/tests/fixtures/Migrations/Version2025080412000.php b/tests/fixtures/Migrations/Version2025080412000.php new file mode 100644 index 0000000..3246c7d --- /dev/null +++ b/tests/fixtures/Migrations/Version2025080412000.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Fixtures\Migrations; + +use Access\Migrations\Migration; +use Access\Migrations\SchemaChanges; +use Access\Schema\Type\Reference; +use Access\Schema\Type\Text; + +/** + * Migration + * + * @author Tim + */ +class Version2025080412000 extends Migration +{ + public function constructive(SchemaChanges $schemaChanges): void + { + $users = $schemaChanges->createTable( + 'users', + hasCreatedAt: true, + hasUpdatedAt: true, + hasDeletedAt: true, + ); + + $users->field('role', default: 'USER'); + + $posts = $schemaChanges->createTable('posts'); + $posts->field('title'); + $posts->field('content', new Text()); + $userId = $posts->field('user_id', new Reference($users)); + $posts->index('user_id_index', $userId); + + $posts = $schemaChanges->alterTable('posts'); + $posts->addField('summary', new Text(), null); + } + + public function destructive(SchemaChanges $schemaChanges): void + { + // let's imagine the posts table was created in some previous migration + $posts = $schemaChanges->alterTable('posts'); + $posts->renameField('content', 'body'); + } + + public function revertConstructive(SchemaChanges $schemaChanges): void + { + $schemaChanges->dropTable('posts'); + $schemaChanges->dropTable('users'); + } + + public function revertDestructive(SchemaChanges $schemaChanges): void + { + $posts = $schemaChanges->alterTable('posts'); + $posts->renameField('body', 'content'); + } +} diff --git a/tests/fixtures/Migrations/Version2025080412001.php b/tests/fixtures/Migrations/Version2025080412001.php new file mode 100644 index 0000000..bec436c --- /dev/null +++ b/tests/fixtures/Migrations/Version2025080412001.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Fixtures\Migrations; + +use Access\Migrations\Migration; +use Access\Migrations\SchemaChanges; +use Access\Query\Insert; +use Access\Schema\Type; + +/** + * Migration + * + * @author Tim + */ +class Version2025080412001 extends Migration +{ + public function constructive(SchemaChanges $schemaChanges): void + { + $users = $schemaChanges->createTable( + 'users', + hasCreatedAt: true, + hasUpdatedAt: true, + hasDeletedAt: true, + ); + + $users->field('roles', new Type\Json()); + + $admin = new Insert($users); + $admin->values([ + 'roles' => json_encode(['ROLE_ADMIN']), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $schemaChanges->query($admin); + } + + public function revertConstructive(SchemaChanges $schemaChanges): void + { + $schemaChanges->dropTable('users'); + } +} diff --git a/tests/fixtures/Presenter/ProjectWithUserNamePresenter.php b/tests/fixtures/Presenter/ProjectWithUserNamePresenter.php new file mode 100644 index 0000000..d48aa22 --- /dev/null +++ b/tests/fixtures/Presenter/ProjectWithUserNamePresenter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Fixtures\Presenter; + +use Access\Entity; +use Access\Presenter\EntityPresenter; +use Tests\Fixtures\Entity\Project; + +/** + * Project presenter + * @template-extends EntityPresenter + */ +class ProjectWithUserNamePresenter extends EntityPresenter +{ + public static function getEntityKlass(): string + { + return Project::class; + } + + /** + * @param Project $project + * @return array|null Array representation + */ + public function fromEntity(Entity $project): ?array + { + return [ + 'id' => $project->getId(), + 'owner' => $this->presentInversedRef(SimpleUserPresenter::class, 'name', 'one'), + ]; + } +} diff --git a/tests/fixtures/Presenter/SimpleUserPresenter.php b/tests/fixtures/Presenter/SimpleUserPresenter.php new file mode 100644 index 0000000..7c78652 --- /dev/null +++ b/tests/fixtures/Presenter/SimpleUserPresenter.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Fixtures\Presenter; + +use Access\Entity; +use Access\Presenter\EntityPresenter; +use Tests\Fixtures\Entity\User; + +/** + * User presenter + * @template-extends EntityPresenter + */ +class SimpleUserPresenter extends EntityPresenter +{ + public static function getEntityKlass(): string + { + return User::class; + } + + /** + * @param User $user + * @return array|null Array representation + */ + public function fromEntity(Entity $user): ?array + { + return [ + 'id' => $user->getId(), + 'name' => $user->getName(), + ]; + } +} diff --git a/tests/mysql/DatabaseBuilderTrait.php b/tests/mysql/DatabaseBuilderTrait.php index a3cd9ca..cb8a885 100644 --- a/tests/mysql/DatabaseBuilderTrait.php +++ b/tests/mysql/DatabaseBuilderTrait.php @@ -14,7 +14,11 @@ namespace Tests\Mysql; use Access\Database; +use Access\Query\CreateDatabase; +use Access\Query\DropDatabase; use Access\Query\Raw; +use Access\Query\UseDatabase; +use Access\Schema; use PDO; use Psr\Clock\ClockInterface; @@ -27,16 +31,6 @@ trait DatabaseBuilderTrait { private static function createTables(Database $db): Database { - $name = sprintf('access_test_%s', bin2hex(random_bytes(8))); - - if (!empty($_ENV['MYSQL_DATABASE_NAME'])) { - $name = $_ENV['MYSQL_DATABASE_NAME']; - } - - $db->query(new Raw("DROP DATABASE IF EXISTS `$name`")); - $db->query(new Raw("CREATE DATABASE IF NOT EXISTS `$name`")); - $db->query(new Raw("USE `$name`")); - $createProfileImagesQuery = new Raw('CREATE TABLE `profile_images` ( `id` INT AUTO_INCREMENT, `created_at` DATETIME, @@ -88,6 +82,31 @@ private static function createTables(Database $db): Database return $db; } + private static function initializeDatabase(Database $db): string + { + $name = sprintf('access_test_%s', bin2hex(random_bytes(8))); + + if (!empty($_ENV['MYSQL_DATABASE_NAME'])) { + $name = $_ENV['MYSQL_DATABASE_NAME']; + } + + $schema = new Schema($name); + + $drop = new DropDatabase($schema); + $drop->checkExistence(); + + $create = new CreateDatabase($schema); + $create->checkExistence(); + + $use = new UseDatabase($schema); + + $db->query($drop); + $db->query($create); + $db->query($use); + + return $name; + } + private static function createPdo(): PDO { $host = $_ENV['MYSQL_DATABASE_HOST']; @@ -98,22 +117,44 @@ private static function createPdo(): PDO return new PDO(sprintf('mysql:host=%s;port=%s', $host, $port), $user, $password); } - public static function createDatabase(): Database + public static function createEmptyDatabase(?string &$name = null): Database { $pdo = self::createPdo(); $db = new Database($pdo); - return self::createTables($db); + $name = self::initializeDatabase($db); + + return $db; } - public static function createDatabaseWithMockClock(?ClockInterface $clock = null): Database + /** + * @psalm-param-out string $name + */ + public static function createDatabase(?string &$name = null): Database { $pdo = self::createPdo(); $db = new Database($pdo); + + $name = self::initializeDatabase($db); + + return self::createTables($db); + } + + /** + * @psalm-param-out string $name + */ + public static function createDatabaseWithMockClock( + ?ClockInterface $clock = null, + ?string &$name = null, + ): Database { + $pdo = self::createPdo(); + $db = new Database($pdo); $clock = $clock ?? new MockClock(); $db = new Database($pdo, null, $clock); + $name = self::initializeDatabase($db); + return self::createTables($db); } @@ -123,10 +164,12 @@ public static function createDatabaseWithMockClock(?ClockInterface $clock = null * - 1 profile image * - 2 users * - 2 projects + * + * @psalm-param-out string $name */ - public static function createDatabaseWithDummyData(): Database + public static function createDatabaseWithDummyData(?string &$name = null): Database { - $db = self::createDatabase(); + $db = self::createDatabase($name); $profileImage = new ProfileImage(); $db->save($profileImage); @@ -155,4 +198,16 @@ public static function createDatabaseWithDummyData(): Database return $db; } + + public static function getFreshDatabaseConnection(string $name): Database + { + $pdo = self::createPdo(); + $db = new Database($pdo); + + $schema = new Schema($name); + $use = new UseDatabase($schema); + $db->query($use); + + return $db; + } } diff --git a/tests/mysql/MigrationTest.php b/tests/mysql/MigrationTest.php new file mode 100644 index 0000000..65ab7d8 --- /dev/null +++ b/tests/mysql/MigrationTest.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Mysql; + +use Tests\Base\BaseMigrationTest; + +class MigrationTest extends BaseMigrationTest +{ + use DatabaseBuilderTrait; +} diff --git a/tests/mysql/Query/AlterTableTest.php b/tests/mysql/Query/AlterTableTest.php new file mode 100644 index 0000000..443d392 --- /dev/null +++ b/tests/mysql/Query/AlterTableTest.php @@ -0,0 +1,318 @@ +field('name', new Type\VarChar(50), 'Dave'); + $users->field('status', new Type\Enum(UserStatus::class)); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new Table('users'); + $users->field('name', new Type\VarChar(50), 'Dave'); + + $query = new AlterTable($users); + + $role = $users->field('role', new Type\VarChar(30)); + $role->markAsNullable(); + $query->addField($role); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $query = new AlterTable($users); + $role = $users->field('role'); + $query->removeField($role); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new Table('users'); + $currentName = $users->field('name'); + $newName = $users->field('first_name'); + + $query = new AlterTable($users); + $query->renameField($currentName, $newName); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new Table('users'); + $currentName = $users->field('first_name'); + $newName = $users->field('first_name', new Type\VarChar(50), 'John'); + + $query = new AlterTable($users); + $query->changeField($currentName, $newName); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } + + public function testIndex(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table('projects'); + $projects->field('owner_id', new Type\Reference($users)); + + $query = new CreateTable($projects); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table('projects'); + $ownerId = $projects->field('owner_id', new Type\Reference($users)); + $ownerIndex = $projects->index('owner_id_index', $ownerId); + + $query = new AlterTable($projects); + $query->addIndex($ownerIndex); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $query = new AlterTable($projects); + $query->removeIndex($ownerIndex); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $query = new AlterTable($projects); + $ownerIndex->unique(); + $query->addIndex($ownerIndex); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $newOwnerIndex = $projects->index('new_owner_id_index', $ownerId); + + $query = new AlterTable($projects); + $query->renameIndex($ownerIndex, $newOwnerIndex); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + } + + public function testRenameTable(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new AlterTable($users); + $users->renameTable('members'); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($users); + } + + public function testAfter(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + $users->field('last_name'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new AlterTable($users); + $users->addField('first_name')->after('id'); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($users); + } + + public function testModify(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + $users->field('last_name'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new AlterTable($users); + $users->modifyField('last_name', new Type\VarChar(100), null); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($users); + } +} diff --git a/tests/mysql/Query/CreateTableTest.php b/tests/mysql/Query/CreateTableTest.php new file mode 100644 index 0000000..53010a2 --- /dev/null +++ b/tests/mysql/Query/CreateTableTest.php @@ -0,0 +1,130 @@ +field('name', new Type\VarChar(50), 'Dave'); + + $role = $users->field('role', new Type\VarChar(30)); + $role->markAsNullable(); + + $users->field('status', new Type\Enum(UserStatus::class)); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table( + 'projects', + hasCreatedAt: true, + hasUpdatedAt: true, + hasDeletedAt: true, + ); + + $projects->field('owner_id', new Type\Reference($users)); + + $projects->field('name', new Type\VarChar(50), null); + + $query = new CreateTable($projects); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } + + public function testIndex(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table('projects'); + $ownerId = $projects->field('owner_id', new Type\Reference($users)); + $projects->index('owner_id_index', $ownerId); + + $query = new CreateTable($projects); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } +} diff --git a/tests/mysql/Query/DropTableTest.php b/tests/mysql/Query/DropTableTest.php new file mode 100644 index 0000000..549422f --- /dev/null +++ b/tests/mysql/Query/DropTableTest.php @@ -0,0 +1,40 @@ +query($query); + + $query = new DropTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } +} diff --git a/tests/mysql/StatementTest.php b/tests/mysql/StatementTest.php index e6efc0f..4b59122 100644 --- a/tests/mysql/StatementTest.php +++ b/tests/mysql/StatementTest.php @@ -43,14 +43,11 @@ public function testInvalidExectute(): void $query = new Query\Insert(User::class); $query->values([ - 'id' => 1, + 'id' => 'foo', 'name' => 'Dave', 'email' => 'dave@example.com', ]); $db->query($query); - - // insert with same primary key value - $db->query($query); } } diff --git a/tests/mysql/TransactionTest.php b/tests/mysql/TransactionTest.php index 7ff88ac..76506e5 100644 --- a/tests/mysql/TransactionTest.php +++ b/tests/mysql/TransactionTest.php @@ -13,9 +13,118 @@ namespace Tests\Mysql; +use Access\Exception\LockNotAcquiredException; +use Access\Query\Raw; +use Access\Query\Select; +use Access\Query\Update; +use Access\Transaction; +use ReflectionProperty; use Tests\Base\BaseTransactionTest; +use Tests\Fixtures\Entity\User; class TransactionTest extends BaseTransactionTest { use DatabaseBuilderTrait; + + public function testForShare(): void + { + $db1 = static::createDatabaseWithDummyData($name); + $db2 = static::getFreshDatabaseConnection($name); + + $user1 = null; + $user2 = null; + $users = $db1->findAll(User::class); + + foreach ($users as $user) { + if ($user1 === null) { + $user1 = $user; + } elseif ($user2 === null) { + $user2 = $user; + } + } + + assert($user1 instanceof User); + assert($user2 instanceof User); + + $transaction = $db1->beginTransaction(); + + $select1 = new Select(User::class); + $select1->where('id = ?', $user1->getId()); + $select1->readLockForShareNoWait(); + + $select2 = new Select(User::class); + $select2->where('id = ?', $user2->getId()); + $select2->readLockForShareNoWait(); + + $db1->selectOne(User::class, $select1); + $db2->selectOne(User::class, $select2); + + // record with ID 1 is locked by $db1, but it's only `FOR SHARE`, $db2 still has access + $db2->selectOne(User::class, $select1); + + $update1 = new Update(User::class); + $update1->values(['name' => 'Something different']); + $update1->where('id = ?', $user1->getId()); + + // not ideal to wait for a second, but better than waiting longer :) + $db2->query(new Raw('SET innodb_lock_wait_timeout = 1')); + + try { + $this->expectException(LockNotAcquiredException::class); + $this->expectExceptionMessageMatches('/timeout exceeded/'); + + // record with ID 1 is share locked by $db1, updating is not possible + $db2->query($update1); + + $transaction->commit(); + } finally { + // make sure don't keep a lock around + $transaction->rollBack(); + } + } + + public function testForUpdate(): void + { + $db1 = static::createDatabaseWithDummyData($name); + $db2 = static::getFreshDatabaseConnection($name); + + $user1 = null; + $user2 = null; + $users = $db1->findAll(User::class); + + foreach ($users as $user) { + if ($user1 === null) { + $user1 = $user; + } elseif ($user2 === null) { + $user2 = $user; + } + } + + assert($user1 instanceof User); + assert($user2 instanceof User); + + $transaction = $db1->beginTransaction(); + + $select1 = new Select(User::class); + $select1->where('id = ?', $user1->getId()); + $select1->readLockForUpdateNoWait(); + + $select2 = new Select(User::class); + $select2->where('id = ?', $user2->getId()); + $select2->readLockForUpdateNoWait(); + + $db1->selectOne(User::class, $select1); + $db2->selectOne(User::class, $select2); + + $this->expectException(LockNotAcquiredException::class); + $this->expectExceptionMessageMatches('/NOWAIT/'); + + try { + // record with ID 1 is locked by $db1 + $db2->selectOne(User::class, $select1); + } finally { + // make sure don't keep a lock around + $transaction->rollBack(); + } + } } diff --git a/tests/sqlite/DatabaseBuilderTrait.php b/tests/sqlite/DatabaseBuilderTrait.php index 7a16a3c..ade00b8 100644 --- a/tests/sqlite/DatabaseBuilderTrait.php +++ b/tests/sqlite/DatabaseBuilderTrait.php @@ -75,31 +75,26 @@ private static function createTables(Database $db): Database return $db; } - public static function createDatabase(): Database + public static function createEmptyDatabase(): Database { $db = Database::create('sqlite::memory:'); - return self::createTables($db); + return $db; } - public static function createDatabaseWithMockClock(?ClockInterface $clock = null): Database + public static function createDatabase(): Database { - $clock = $clock ?? new MockClock(); - $db = Database::create('sqlite::memory:', null, $clock); + $db = self::createEmptyDatabase(); return self::createTables($db); } - public static function nukeDatabase(Database $db): void + public static function createDatabaseWithMockClock(?ClockInterface $clock = null): Database { - $dropProjectsQuery = new Raw('DROP TABLE `projects`'); - $db->query($dropProjectsQuery); - - $dropUsersQuery = new Raw('DROP TABLE `users`'); - $db->query($dropUsersQuery); + $clock = $clock ?? new MockClock(); + $db = Database::create('sqlite::memory:', null, $clock); - $dropProfileImagesQuery = new Raw('DROP TABLE `profile_images`'); - $db->query($dropProfileImagesQuery); + return self::createTables($db); } /** diff --git a/tests/sqlite/MigrationTest.php b/tests/sqlite/MigrationTest.php new file mode 100644 index 0000000..bf134f9 --- /dev/null +++ b/tests/sqlite/MigrationTest.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Sqlite; + +use Tests\Base\BaseMigrationTest; + +class MigrationTest extends BaseMigrationTest +{ + use DatabaseBuilderTrait; +} diff --git a/tests/sqlite/Query/AlterTableTest.php b/tests/sqlite/Query/AlterTableTest.php new file mode 100644 index 0000000..b7f47ca --- /dev/null +++ b/tests/sqlite/Query/AlterTableTest.php @@ -0,0 +1,135 @@ +field('name', new Type\VarChar(50), 'Dave'); + + $users->field('status', new Type\Enum(UserStatus::class), UserStatus::ACTIVE); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new Table('users'); + + $users->field('name', new Type\VarChar(50), 'Dave'); + + $query = new AlterTable($users); + + $role = $users->field('role', new Type\VarChar(30)); + $role->markAsNullable(); + $query->addField($role); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $query = new AlterTable($users); + $role = $users->field('role'); + $query->removeField($role); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new Table('users'); + $currentName = $users->field('name'); + $newName = $users->field('first_name'); + + $query = new AlterTable($users); + $query->renameField($currentName, $newName); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } + + public function testRenameTable(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $users = new AlterTable($users); + $users->renameTable('members'); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($users); + } +} diff --git a/tests/sqlite/Query/CreateTableTest.php b/tests/sqlite/Query/CreateTableTest.php new file mode 100644 index 0000000..ea268e5 --- /dev/null +++ b/tests/sqlite/Query/CreateTableTest.php @@ -0,0 +1,125 @@ +field('name', new Type\VarChar(50), 'Dave'); + + $role = $users->field('role', new Type\VarChar(30)); + $role->markAsNullable(); + + $users->field('status', new Type\Enum(UserStatus::class), UserStatus::ACTIVE); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table( + 'projects', + hasCreatedAt: true, + hasUpdatedAt: true, + hasDeletedAt: true, + ); + + $projects->field('owner_id', new Type\Reference($users)); + + $projects->field('name', new Type\VarChar(50), null); + + $query = new CreateTable($projects); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } + + public function testIndex(): void + { + $db = self::createEmptyDatabase(); + + $users = new Table('users'); + + $query = new CreateTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + + $projects = new Table('projects'); + $ownerId = $projects->field('owner_id', new Type\Reference($users)); + $projects->index('owner_id_index', $ownerId)->unique(); + + $query = new CreateTable($projects); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } +} diff --git a/tests/sqlite/Query/DropTableTest.php b/tests/sqlite/Query/DropTableTest.php new file mode 100644 index 0000000..92f38d4 --- /dev/null +++ b/tests/sqlite/Query/DropTableTest.php @@ -0,0 +1,40 @@ +query($query); + + $query = new DropTable($users); + + $this->assertEquals( + <<getSql($db->getDriver()), + ); + + $db->query($query); + } +} diff --git a/tests/sqlite/StatementTest.php b/tests/sqlite/StatementTest.php index 8f6702f..cc8e629 100644 --- a/tests/sqlite/StatementTest.php +++ b/tests/sqlite/StatementTest.php @@ -43,14 +43,11 @@ public function testInvalidExectute(): void $query = new Query\Insert(User::class); $query->values([ - 'id' => 1, + 'id' => 'foo', 'name' => 'Dave', 'email' => 'dave@example.com', ]); $db->query($query); - - // insert with same primary key value - $db->query($query); } } diff --git a/tests/unit/DebugQueryTest.php b/tests/unit/DebugQueryTest.php index e4b28fb..b3965f1 100644 --- a/tests/unit/DebugQueryTest.php +++ b/tests/unit/DebugQueryTest.php @@ -15,7 +15,7 @@ use Access\Clause; use Access\DebugQuery; -use Access\Driver\Sqlite; +use Access\Driver\Sqlite\Sqlite; use Access\Query; use PHPUnit\Framework\TestCase; diff --git a/tests/unit/EntityTest.php b/tests/unit/EntityTest.php new file mode 100644 index 0000000..471ebe8 --- /dev/null +++ b/tests/unit/EntityTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Tests\Unit; + +use PHPUnit\Framework\TestCase; +use Tests\Fixtures\Entity\LogMessage; +use Tests\Fixtures\Entity\ProfileImage; +use Tests\Fixtures\Entity\Project; +use Tests\Fixtures\Entity\User; + +class EntityTest extends TestCase +{ + public function testUserTableSchema(): void + { + // hand made + $a = User::getTableSchema(); + + // based on `fields` + $b = User::getParentTableSchema(); + + $this->assertEquals($a, $b); + } + + public function testProjectTableSchema(): void + { + // hand made + $a = Project::getTableSchema(); + + // based on `fields` + $b = Project::getParentTableSchema(); + + $this->assertEquals($a, $b); + } + + public function testProfileImageTableSchema(): void + { + // hand made + $a = ProfileImage::getTableSchema(); + + // based on `fields` + $b = ProfileImage::getParentTableSchema(); + + $this->assertEquals($a, $b); + } + + public function testLogMessageTableSchema(): void + { + // hand made + $a = LogMessage::getTableSchema(); + + // based on `fields` + $b = LogMessage::getParentTableSchema(); + + $this->assertEquals($a, $b); + } +} diff --git a/website/sidebars.js b/website/sidebars.js index 24fd329..7e4fe33 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -20,6 +20,7 @@ module.exports = { 'presenters', 'transactions', 'locks', + 'migrations', 'profiler', ], },