Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fec19ca
Add clarify comment about `orderBy` overriding previous value
justim Sep 5, 2025
a5d0b57
Add test helper method to create an empty database
justim Sep 5, 2025
9f8430d
Refactor the debuq query generation
justim Sep 5, 2025
788330f
All things schema
justim Sep 5, 2025
6aae6a0
Add schema queries: CREATE/ALTER/DROP TABLE
justim Sep 5, 2025
4b3f9f1
Use the new schema classes for the entities
justim Sep 5, 2025
5569901
Add migrations
justim Sep 5, 2025
fa891f1
Handle `Raw` conditions special in queries
justim Sep 9, 2025
3abec01
Try and convert `\PDOException`s to more specific exception
justim Sep 10, 2025
dfc53a6
Add basic table options
justim Sep 15, 2025
3f684c5
Add schema queries: CREATE/USE/DROP DATABASE
justim Sep 16, 2025
1243b75
Migration checkpoints
justim Sep 16, 2025
d46fdb9
Add method to enum type to override cases
justim Sep 18, 2025
844706c
Add description method to base migration class
justim Sep 19, 2025
ed6411a
Add support for renaming tables in alter table query
justim Sep 19, 2025
a457c46
Add support for `AFTER` in alter table queries
justim Sep 22, 2025
ba4dca1
Add support for `MODIFY COLUMN` in alter table queries
justim Sep 23, 2025
0c1104f
Move statement execution to single location
justim Oct 6, 2025
470bbcb
Add specific exception for "Connection gone" error
justim Oct 6, 2025
072ae05
Add support for `VarBinary` type
justim Oct 8, 2025
d54870a
Add support for MySQL read locks
justim Oct 8, 2025
5794426
Try and convert all PDO exceptions
justim Oct 17, 2025
1eac62e
Allow strings as inversed ref values in presenters
justim Nov 4, 2025
64c1388
Add `Limit` clause
justim Nov 10, 2025
a50e59b
Add specific "Duplicate entry" exception
justim Dec 4, 2025
df15fa7
Make cursors usable as filter clause for collections
justim Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
@@ -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()
```
36 changes: 20 additions & 16 deletions src/Cascade/CascadeDeleteResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions src/Clause/Condition/Raw.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
2 changes: 0 additions & 2 deletions src/Clause/Filter/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
41 changes: 41 additions & 0 deletions src/Clause/Filter/FilterItemResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/*
* This file is part of the Access package.
*
* (c) Tim <me@justim.net>
*
* 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 <me@justim.net>
*/
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;
}
2 changes: 1 addition & 1 deletion src/Clause/Filter/Unique.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
9 changes: 7 additions & 2 deletions src/Clause/FilterInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Access\Clause;

use Access\Clause\Filter\FilterItemResult;
use Access\Collection;

/**
Expand All @@ -25,15 +26,19 @@ 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<TEntity> $collection The collection to filter
* @return Collection The filtered collection
* @psalm-return Collection<TEntity> The filtered collection
*/
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;
}
107 changes: 107 additions & 0 deletions src/Clause/Limit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

/*
* This file is part of the Access package.
*
* (c) Tim <me@justim.net>
*
* 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 <me@justim.net>
*/
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
}
}
Loading
Loading