From 44f735a87d1abd41286e1bc09b6f1e9411ef7d8a Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:07:52 +0100 Subject: [PATCH 01/29] chore(config): Add a base abstract config object Add a BaseConfigObject abstract class with a default implementation for __set_state(), making use of PHPs named parameter and splat operator combo --- src/Config/BaseConfigObject.php | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/Config/BaseConfigObject.php diff --git a/src/Config/BaseConfigObject.php b/src/Config/BaseConfigObject.php new file mode 100644 index 0000000..8625716 --- /dev/null +++ b/src/Config/BaseConfigObject.php @@ -0,0 +1,43 @@ +__set_state magic for + * implementations of {@see ConfigObject}. + * + * @template TState of array + * + * @phpstan-pure + * + * @immutable + */ +abstract readonly class BaseConfigObject implements ConfigObject +{ + /** + * Set the object state. + * + * This method is called when unserializing a persisted object from a + * cached config. + * + * @param TState $data + * + * @return static + * + * @noinspection PhpUnnecessaryLocalVariableInspection + */ + public static function __set_state(array $data): static + { + /** @phpstan-ignore new.static */ + $instance = new static(...$data); + + /** @var static */ + return $instance; + } +} From 7f5fc246b830f6f6cf012cfe2fda594a8ea74a28 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:08:14 +0100 Subject: [PATCH 02/29] chore(database.config): Add database config objects --- src/Database/Config/ConnectionConfig.php | 90 ++++++++++++++++++++++++ src/Database/Config/DatabaseConfig.php | 50 +++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/Database/Config/ConnectionConfig.php create mode 100644 src/Database/Config/DatabaseConfig.php diff --git a/src/Database/Config/ConnectionConfig.php b/src/Database/Config/ConnectionConfig.php new file mode 100644 index 0000000..192cdad --- /dev/null +++ b/src/Database/Config/ConnectionConfig.php @@ -0,0 +1,90 @@ +, + * } + * + * @extends BaseConfigObject + * + * @phpstan-pure + * + * @immutable + */ +final readonly class ConnectionConfig extends BaseConfigObject +{ + /** + * @param string|null $host + * @param int|null $port + * @param string|null $socket + * @param string $database + * @param string $username + * @param string $password + * @param array $options + * + * @return ConnectionConfig + */ + public static function make( + ?string $host, + ?int $port, + ?string $socket, + string $database, + string $username, + string $password, + array $options = [], + ): self { + return new self($host, $port, $socket, $database, $username, $password, $options); + } + + /** + * @param string|null $host + * @param int|null $port + * @param string|null $socket + * @param string $database + * @param string $username + * @param string $password + * @param array $options + */ + private function __construct( + public ?string $host, + public ?int $port, + public ?string $socket, + public string $database, + public string $username, + public string $password, + public array $options = [], + ) { + } + + /** + * Set the object state. + * + * This method is called by PHP when restoring an object exported via + * var_export(), allowing cached config objects to be + * reconstituted from their exported state. + * + * @param array $data + * + * @return static + */ + public static function __set_state(array $data): static + { + // TODO: Implement __set_state() method. + } +} diff --git a/src/Database/Config/DatabaseConfig.php b/src/Database/Config/DatabaseConfig.php new file mode 100644 index 0000000..40fecd8 --- /dev/null +++ b/src/Database/Config/DatabaseConfig.php @@ -0,0 +1,50 @@ +, + * } + * + * @extends BaseConfigObject + * + * @phpstan-pure + * + * @immutable + */ +final readonly class DatabaseConfig extends BaseConfigObject +{ + /** + * @param string $primary + * @param array $connections + * + * @return DatabaseConfig + */ + public static function make(string $primary, array $connections): self + { + return new self($primary, $connections); + } + + /** + * @param string $primary + * @param array $connections + */ + private function __construct( + public string $primary, + public array $connections, + ) { + assert(! empty($this->primary), 'Primary connection is not defined.'); + assert(! empty($this->connections), 'No connections defined.'); + assert(isset($this->connections[$this->primary]), 'Primary connection is not defined.'); + } +} From dfbd86915af7f9be12eeefcbf63d25d267c10445 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:46:38 +0100 Subject: [PATCH 03/29] chore(database): Add database exceptions --- .../Exceptions/ConnectionException.php | 38 +++++++++++++++++++ src/Database/Exceptions/DatabaseException.php | 11 ++++++ 2 files changed, 49 insertions(+) create mode 100644 src/Database/Exceptions/ConnectionException.php create mode 100644 src/Database/Exceptions/DatabaseException.php diff --git a/src/Database/Exceptions/ConnectionException.php b/src/Database/Exceptions/ConnectionException.php new file mode 100644 index 0000000..0b3597f --- /dev/null +++ b/src/Database/Exceptions/ConnectionException.php @@ -0,0 +1,38 @@ +getMessage() ?? 'Unable to connect to the database "' . $this->name . '"', + previous: $previous, + ); + } +} diff --git a/src/Database/Exceptions/DatabaseException.php b/src/Database/Exceptions/DatabaseException.php new file mode 100644 index 0000000..ede2a46 --- /dev/null +++ b/src/Database/Exceptions/DatabaseException.php @@ -0,0 +1,11 @@ + Date: Sun, 29 Mar 2026 20:46:57 +0100 Subject: [PATCH 04/29] chore(database): Hardcode connection driver to MySQL --- src/Database/Config/ConnectionConfig.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Database/Config/ConnectionConfig.php b/src/Database/Config/ConnectionConfig.php index 192cdad..15ece4a 100644 --- a/src/Database/Config/ConnectionConfig.php +++ b/src/Database/Config/ConnectionConfig.php @@ -52,6 +52,8 @@ public static function make( return new self($host, $port, $socket, $database, $username, $password, $options); } + public string $driver; + /** * @param string|null $host * @param int|null $port @@ -70,6 +72,7 @@ private function __construct( public string $password, public array $options = [], ) { + $this->driver = 'mysql'; } /** From c382c9d3a3c3ad5fedb32848927fd1fab60dc9b1 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:47:17 +0100 Subject: [PATCH 05/29] chore(database): Add persistent option to database config --- src/Database/Config/DatabaseConfig.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Database/Config/DatabaseConfig.php b/src/Database/Config/DatabaseConfig.php index 40fecd8..f185c7b 100644 --- a/src/Database/Config/DatabaseConfig.php +++ b/src/Database/Config/DatabaseConfig.php @@ -27,21 +27,24 @@ /** * @param string $primary * @param array $connections + * @param bool $persistent * * @return DatabaseConfig */ - public static function make(string $primary, array $connections): self + public static function make(string $primary, array $connections, bool $persistent = false): self { - return new self($primary, $connections); + return new self($primary, $connections, $persistent); } /** * @param string $primary * @param array $connections + * @param bool $persistent */ private function __construct( public string $primary, public array $connections, + public bool $persistent = false, ) { assert(! empty($this->primary), 'Primary connection is not defined.'); assert(! empty($this->connections), 'No connections defined.'); From 205392ed840e280f91c9d420ab8d02cb1fe6511e Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:47:40 +0100 Subject: [PATCH 06/29] chore(database): Create initial connection DTO --- src/Database/Connection.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Database/Connection.php diff --git a/src/Database/Connection.php b/src/Database/Connection.php new file mode 100644 index 0000000..5b87341 --- /dev/null +++ b/src/Database/Connection.php @@ -0,0 +1,15 @@ + Date: Sun, 29 Mar 2026 20:48:14 +0100 Subject: [PATCH 07/29] chore(database): Create connection factory --- src/Database/ConnectionFactory.php | 149 +++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/Database/ConnectionFactory.php diff --git a/src/Database/ConnectionFactory.php b/src/Database/ConnectionFactory.php new file mode 100644 index 0000000..b20a9c0 --- /dev/null +++ b/src/Database/ConnectionFactory.php @@ -0,0 +1,149 @@ + + */ + private static array $defaultOptions = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_STRINGIFY_FETCHES => false, + ]; + + /** + * Cached database connections. + * + * @var array + */ + private array $connections = []; + + /** + * @param DatabaseConfig $config + */ + public function __construct( + private readonly DatabaseConfig $config, + ) { + } + + /** + * Get a database connection. + * + * Retrieves a cached connection if it exists, otherwise creates a new one. + * If no name is provided, the primary connection is returned. + * + * @param string|null $name + * + * @return Connection + */ + public function make(?string $name = null): Connection + { + $name ??= $this->config->primary; + + return $this->connections[$name] ?? ($this->connections[$name] = $this->createConnection($name)); + } + + /** + * Create a new database connection. + * + * @param string $name + * + * @return Connection + */ + private function createConnection(string $name): Connection + { + // Grab the config. + $config = $this->config->connections[$name] ?? null; + + // In theory, this should never happen, but it's here just in case. + if ($config === null) { + throw ConnectionException::noConfig($name); + } + + try { + // Create the connection. + return new Connection( + $name, + $this->createPdo($config), + ); + } catch (PDOException $e) { + // If there was a PDO problem, throw an exception. + throw ConnectionException::cannotConnect($name, $e); + } + } + + /** + * Create a PDO instance. + * + * @param ConnectionConfig $config + * + * @return PDO + */ + private function createPdo(ConnectionConfig $config): PDO + { + $options = array_merge( + self::$defaultOptions, + $config->options, + ); + + // This should only ever create a MySQL instance because the driver is + // hardcoded, but this is here for the future. + return new PDO(match ($config->driver) { + 'mysql' => $this->createMysqlPdoDsn($config), + default => throw new DatabaseException( + sprintf( + 'Unsupported database driver: %s.', + $config->driver, + ), + ), + }, $config->username, $config->password, $options); + } + + /** + * Create a PDO DSN string for MySQL. + * + * @param ConnectionConfig $config + * + * @return string + */ + private function createMysqlPdoDsn(ConnectionConfig $config): string + { + if ($config->socket) { + return sprintf( + 'mysql:unix_socket=%s;dbname=%s', + $config->socket, + $config->database, + ); + } + + if ($config->host) { + return sprintf( + 'mysql:host=%s;port=%d;dbname=%s', + $config->host, + $config->port ?? 3307, + $config->database, + ); + } + + throw new DatabaseException('No host or socket specified.'); + } +} From 259ccf78f54525e6b456c131e58e5e81abe3e82c Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 20:58:54 +0100 Subject: [PATCH 08/29] chore(database): Change default MySQL port --- src/Database/ConnectionFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/ConnectionFactory.php b/src/Database/ConnectionFactory.php index b20a9c0..4a505e1 100644 --- a/src/Database/ConnectionFactory.php +++ b/src/Database/ConnectionFactory.php @@ -139,7 +139,7 @@ private function createMysqlPdoDsn(ConnectionConfig $config): string return sprintf( 'mysql:host=%s;port=%d;dbname=%s', $config->host, - $config->port ?? 3307, + $config->port ?? 3306, $config->database, ); } From 1bb2115275a8a408083ff28a68ce985567f7ea3e Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Sun, 29 Mar 2026 21:39:24 +0100 Subject: [PATCH 09/29] fix(database): Fix applying of connection options and default options --- src/Database/ConnectionFactory.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Database/ConnectionFactory.php b/src/Database/ConnectionFactory.php index 4a505e1..1006cd8 100644 --- a/src/Database/ConnectionFactory.php +++ b/src/Database/ConnectionFactory.php @@ -100,10 +100,7 @@ private function createConnection(string $name): Connection */ private function createPdo(ConnectionConfig $config): PDO { - $options = array_merge( - self::$defaultOptions, - $config->options, - ); + $options = $config->options + self::$defaultOptions; // This should only ever create a MySQL instance because the driver is // hardcoded, but this is here for the future. From cb28f22b6ec683c973cb6660afd3866cd211925a Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 00:20:57 +0100 Subject: [PATCH 10/29] tests(database): Add connection and connection factory tests --- .github/workflows/codecoverage.yml | 55 ++++ .github/workflows/integration.yml | 76 ++++++ .gitignore | 1 + docker-compose.yml | 45 ++++ infection.json5 | 7 +- src/Database/Config/ConnectionConfig.php | 18 +- src/Database/Config/DatabaseConfig.php | 4 +- src/Database/Exceptions/DatabaseException.php | 1 - .../Database/ConnectionFactoryTest.php | 251 ++++++++++++++++++ .../Database/Config/ConnectionConfigTest.php | 45 ++++ .../Database/Config/DatabaseConfigTest.php | 38 +++ tests/Unit/Database/ConnectionFactoryTest.php | 130 +++++++++ .../Exceptions/ConnectionExceptionTest.php | 63 +++++ 13 files changed, 713 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/integration.yml create mode 100644 docker-compose.yml create mode 100644 tests/Integration/Database/ConnectionFactoryTest.php create mode 100644 tests/Unit/Database/Config/ConnectionConfigTest.php create mode 100644 tests/Unit/Database/Config/DatabaseConfigTest.php create mode 100644 tests/Unit/Database/ConnectionFactoryTest.php create mode 100644 tests/Unit/Database/Exceptions/ConnectionExceptionTest.php diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 64cb71c..2ec8c7f 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -3,6 +3,35 @@ on: [ push, pull_request ] jobs: run: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + mysql_secondary: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test_port + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3307:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 steps: - name: Checkout uses: actions/checkout@v6 @@ -15,8 +44,34 @@ jobs: - name: Install dependencies run: composer self-update && composer install && composer dump-autoload + - name: Start MySQL socket container + run: | + mkdir -p /tmp/mysql_socket + docker run -d \ + --name mysql_socket \ + -e MYSQL_ROOT_PASSWORD=secret \ + -e MYSQL_DATABASE=engine_test_socket \ + -e MYSQL_USER=engine \ + -e MYSQL_PASSWORD=secret \ + -v /tmp/mysql_socket:/var/run/mysqld \ + mysql:8.4 + for i in $(seq 1 30); do + [ -S /tmp/mysql_socket/mysqld.sock ] && break + sleep 2 + done + - name: Run tests and collect coverage run: vendor/bin/phpunit --coverage-clover coverage.xml --log-junit junit.xml + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: engine_test + DB_USERNAME: engine + DB_PASSWORD: secret + DB_PORT_SECONDARY: 3307 + DB_DATABASE_SECONDARY: engine_test_port + DB_SOCKET: /tmp/mysql_socket/mysqld.sock + DB_DATABASE_SOCKET: engine_test_socket - name: Upload coverage to Codecov if: ${{ !cancelled() }} diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..8593001 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,76 @@ +name: Integration Tests + +on: [ pull_request ] + +jobs: + run: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + mysql_secondary: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test_port + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3307:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up php 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + + - name: Install dependencies + run: composer self-update && composer install && composer dump-autoload + + - name: Start MySQL socket container + run: | + mkdir -p /tmp/mysql_socket + docker run -d \ + --name mysql_socket \ + -e MYSQL_ROOT_PASSWORD=secret \ + -e MYSQL_DATABASE=engine_test_socket \ + -e MYSQL_USER=engine \ + -e MYSQL_PASSWORD=secret \ + -v /tmp/mysql_socket:/var/run/mysqld \ + mysql:8.4 + for i in $(seq 1 30); do + [ -S /tmp/mysql_socket/mysqld.sock ] && break + sleep 2 + done + + - name: Run integration tests + run: vendor/bin/phpunit --testsuite Integration + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: engine_test + DB_USERNAME: engine + DB_PASSWORD: secret + DB_PORT_SECONDARY: 3307 + DB_DATABASE_SECONDARY: engine_test_port + DB_SOCKET: /tmp/mysql_socket/mysqld.sock + DB_DATABASE_SOCKET: engine_test_socket diff --git a/.gitignore b/.gitignore index fffe6b7..2a9d7a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /vendor /composer.lock /build +/tmp .phpunit.result.cache .php-cs-fixer.cache diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..92f6b49 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - "3306:3306" + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 5s + timeout: 5s + retries: 10 + + mysql_secondary: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test_port + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - "3307:3306" + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ] + interval: 5s + timeout: 5s + retries: 10 + + mysql_socket: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test_socket + MYSQL_USER: engine + MYSQL_PASSWORD: secret + volumes: + - ./tmp/mysql_socket:/var/run/mysqld + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-S", "/var/run/mysqld/mysqld.sock" ] + interval: 5s + timeout: 5s + retries: 10 diff --git a/infection.json5 b/infection.json5 index 0d12223..af23bae 100644 --- a/infection.json5 +++ b/infection.json5 @@ -26,11 +26,16 @@ "Engine\\Container\\Container::collectDependencies::392" ] }, - "Coalesce" : { + "Coalesce" : { "ignore": [ "Engine\\Container\\Resolvers\\GenericResolver::resolve::56", ] }, + "MatchArmRemoval": { + "ignore": [ + "Engine\\Database\\ConnectionFactory::createPdo::107" + ] + }, "IfNegation" : { "ignore": [ "Engine\\Container\\Container::invokeClassMethod::349" diff --git a/src/Database/Config/ConnectionConfig.php b/src/Database/Config/ConnectionConfig.php index 15ece4a..6bc8018 100644 --- a/src/Database/Config/ConnectionConfig.php +++ b/src/Database/Config/ConnectionConfig.php @@ -63,7 +63,7 @@ public static function make( * @param string $password * @param array $options */ - private function __construct( + protected function __construct( public ?string $host, public ?int $port, public ?string $socket, @@ -74,20 +74,4 @@ private function __construct( ) { $this->driver = 'mysql'; } - - /** - * Set the object state. - * - * This method is called by PHP when restoring an object exported via - * var_export(), allowing cached config objects to be - * reconstituted from their exported state. - * - * @param array $data - * - * @return static - */ - public static function __set_state(array $data): static - { - // TODO: Implement __set_state() method. - } } diff --git a/src/Database/Config/DatabaseConfig.php b/src/Database/Config/DatabaseConfig.php index f185c7b..cfd14b8 100644 --- a/src/Database/Config/DatabaseConfig.php +++ b/src/Database/Config/DatabaseConfig.php @@ -41,10 +41,10 @@ public static function make(string $primary, array $connections, bool $persisten * @param array $connections * @param bool $persistent */ - private function __construct( + protected function __construct( public string $primary, public array $connections, - public bool $persistent = false, + public bool $persistent, ) { assert(! empty($this->primary), 'Primary connection is not defined.'); assert(! empty($this->connections), 'No connections defined.'); diff --git a/src/Database/Exceptions/DatabaseException.php b/src/Database/Exceptions/DatabaseException.php index ede2a46..2487643 100644 --- a/src/Database/Exceptions/DatabaseException.php +++ b/src/Database/Exceptions/DatabaseException.php @@ -7,5 +7,4 @@ class DatabaseException extends RuntimeException { - } diff --git a/tests/Integration/Database/ConnectionFactoryTest.php b/tests/Integration/Database/ConnectionFactoryTest.php new file mode 100644 index 0000000..955da14 --- /dev/null +++ b/tests/Integration/Database/ConnectionFactoryTest.php @@ -0,0 +1,251 @@ +config = DatabaseConfig::make('primary', [ + 'primary' => $connection, + 'secondary' => $connection, + ]); + } + + // ------------------------------------------------------------------------- + // make() - connection creation + // ------------------------------------------------------------------------- + + /** + * - make() returns a Connection instance for a named connection. + */ + #[Test] + public function makeReturnsConnectionForNamedConnection(): void + { + $factory = new ConnectionFactory($this->config); + + $this->assertInstanceOf(Connection::class, $factory->make('primary')); + } + + /** + * - make() with no name falls back to the primary connection. + */ + #[Test] + public function makeWithNoNameReturnsPrimaryConnection(): void + { + $factory = new ConnectionFactory($this->config); + + $connection = $factory->make(); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertSame('primary', $connection->name); + } + + /** + * - make() uses the explicit port from config, not the default fallback. + * + * The secondary container has engine_test_port (not engine_test), so the + * coalesce mutation — which ignores the explicit port and always uses 3306 — + * will fail to find engine_test_port on the primary, killing the mutant. + */ + #[Test] + public function makeUsesExplicitPortOverDefault(): void + { + $config = ConnectionConfig::make( + host: (string) (getenv('DB_HOST') ?: '127.0.0.1'), + port: (int) (getenv('DB_PORT_SECONDARY') ?: 3307), + socket: null, + database: (string) (getenv('DB_DATABASE_SECONDARY') ?: 'engine_test_port'), + username: (string) (getenv('DB_USERNAME') ?: 'engine'), + password: (string) (getenv('DB_PASSWORD') ?: 'secret'), + ); + + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $config]), + ); + + $this->assertInstanceOf(Connection::class, $factory->make('default')); + } + + /** + * - make() connects successfully when port is null, using the default port. + */ + #[Test] + public function makeConnectsWithNullPort(): void + { + $config = ConnectionConfig::make( + host: (string) (getenv('DB_HOST') ?: '127.0.0.1'), + port: null, + socket: null, + database: (string) (getenv('DB_DATABASE') ?: 'engine_test'), + username: (string) (getenv('DB_USERNAME') ?: 'engine'), + password: (string) (getenv('DB_PASSWORD') ?: 'secret'), + ); + + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $config]), + ); + + $this->assertInstanceOf(Connection::class, $factory->make('default')); + } + + // ------------------------------------------------------------------------- + // make() - PDO options + // ------------------------------------------------------------------------- + + /** + * - make() applies options from $config->options on the PDO instance. + * + * Sets FETCH_BOTH via config options, overriding the default FETCH_ASSOC. + * A fetched row will include numeric keys, confirming the override was applied. + */ + #[Test] + public function makeAppliesConnectionConfigOptions(): void + { + $config = ConnectionConfig::make( + host: (string) (getenv('DB_HOST') ?: '127.0.0.1'), + port: (int) (getenv('DB_PORT') ?: 3306), + socket: null, + database: (string) (getenv('DB_DATABASE') ?: 'engine_test'), + username: (string) (getenv('DB_USERNAME') ?: 'engine'), + password: (string) (getenv('DB_PASSWORD') ?: 'secret'), + options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_BOTH], + ); + + $factory = new ConnectionFactory(DatabaseConfig::make('default', ['default' => $config])); + $connection = $factory->make('default'); + + $pdo = new ReflectionProperty(Connection::class, 'pdo')->getValue($connection); + $result = $pdo->query('SELECT 1 AS num')->fetch(); + + $this->assertArrayHasKey(0, $result); + } + + /** + * - make() applies default PDO options even when $config->options is empty. + * + * Default options include FETCH_ASSOC, so a fetched row has only named keys. + */ + #[Test] + public function makeAppliesDefaultPdoOptions(): void + { + $factory = new ConnectionFactory($this->config); + $connection = $factory->make('primary'); + + $pdo = new ReflectionProperty(Connection::class, 'pdo')->getValue($connection); + $result = $pdo->query('SELECT 1 AS num')->fetch(); + + $this->assertArrayHasKey('num', $result); + $this->assertArrayNotHasKey(0, $result); + } + + // ------------------------------------------------------------------------- + // make() - connection caching + // ------------------------------------------------------------------------- + + /** + * - make() called twice with the same name returns the same instance. + */ + #[Test] + public function makeReturnsSameInstanceOnSubsequentCallsForSameName(): void + { + $factory = new ConnectionFactory($this->config); + + $this->assertSame($factory->make('primary'), $factory->make('primary')); + } + + /** + * - make() with different names returns different instances. + */ + #[Test] + public function makeReturnsDifferentInstancesForDifferentNames(): void + { + $factory = new ConnectionFactory($this->config); + + $this->assertNotSame($factory->make('primary'), $factory->make('secondary')); + } + + // ------------------------------------------------------------------------- + // make() - socket connection + // ------------------------------------------------------------------------- + + /** + * - make() connects via a Unix socket when socket is set and host is null. + */ + #[Test] + public function makeConnectsViaSocket(): void + { + if (PHP_OS_FAMILY !== 'Linux') { + $this->markTestSkipped('Unix socket connections are only testable on Linux (Docker Desktop on macOS does not proxy sockets to the host).'); + } + + $socketPath = (string) (getenv('DB_SOCKET') ?: '/tmp/mysql_socket/mysqld.sock'); + + $config = ConnectionConfig::make( + host: null, + port: null, + socket: $socketPath, + database: (string) (getenv('DB_DATABASE_SOCKET') ?: 'engine_test_socket'), + username: (string) (getenv('DB_USERNAME') ?: 'engine'), + password: (string) (getenv('DB_PASSWORD') ?: 'secret'), + ); + + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $config]), + ); + + $this->assertInstanceOf(Connection::class, $factory->make('default')); + } + + // ------------------------------------------------------------------------- + // make() - connection failure + // ------------------------------------------------------------------------- + + /** + * - make() throws ConnectionException when the credentials are invalid. + */ + #[Test] + public function makeThrowsConnectionExceptionOnBadCredentials(): void + { + $badConfig = ConnectionConfig::make( + host: (string) (getenv('DB_HOST') ?: '127.0.0.1'), + port: (int) (getenv('DB_PORT') ?: 3306), + socket: null, + database: 'engine_test', + username: 'invalid_user', + password: 'invalid_password', + ); + + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $badConfig]), + ); + + $this->expectException(ConnectionException::class); + + $factory->make('default'); + } +} diff --git a/tests/Unit/Database/Config/ConnectionConfigTest.php b/tests/Unit/Database/Config/ConnectionConfigTest.php new file mode 100644 index 0000000..3789b0c --- /dev/null +++ b/tests/Unit/Database/Config/ConnectionConfigTest.php @@ -0,0 +1,45 @@ + 5]; + + $config = ConnectionConfig::__set_state([ + 'host' => '127.0.0.1', + 'port' => 3306, + 'socket' => null, + 'database' => 'engine_test', + 'username' => 'engine', + 'password' => 'secret', + 'options' => $options, + ]); + + $this->assertSame('127.0.0.1', $config->host); + $this->assertSame(3306, $config->port); + $this->assertNull($config->socket); + $this->assertSame('engine_test', $config->database); + $this->assertSame('engine', $config->username); + $this->assertSame('secret', $config->password); + $this->assertSame($options, $config->options); + $this->assertSame('mysql', $config->driver); + } +} diff --git a/tests/Unit/Database/Config/DatabaseConfigTest.php b/tests/Unit/Database/Config/DatabaseConfigTest.php new file mode 100644 index 0000000..95a0ee3 --- /dev/null +++ b/tests/Unit/Database/Config/DatabaseConfigTest.php @@ -0,0 +1,38 @@ + ConnectionConfig::make( + host: '127.0.0.1', + port: 3306, + socket: null, + database: 'engine_test', + username: 'engine', + password: 'secret', + ), + ]); + + $this->assertFalse($config->persistent); + } +} diff --git a/tests/Unit/Database/ConnectionFactoryTest.php b/tests/Unit/Database/ConnectionFactoryTest.php new file mode 100644 index 0000000..c3334a5 --- /dev/null +++ b/tests/Unit/Database/ConnectionFactoryTest.php @@ -0,0 +1,130 @@ +validConfig = ConnectionConfig::make( + host: '127.0.0.1', + port: 3307, + socket: null, + database: 'engine_test', + username: 'engine', + password: 'secret', + ); + + $this->noHostConfig = ConnectionConfig::make( + host: null, + port: null, + socket: null, + database: 'engine_test', + username: 'engine', + password: 'secret', + ); + } + + // ------------------------------------------------------------------------- + // make() - missing config + // ------------------------------------------------------------------------- + + /** + * - make() throws ConnectionException when the connection name has no config entry. + */ + #[Test] + public function makeThrowsConnectionExceptionForUnknownConnectionName(): void + { + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $this->validConfig]), + ); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('No configuration found for the database connection "nonexistent"'); + + $factory->make('nonexistent'); + } + + // ------------------------------------------------------------------------- + // make() - primary fallback + // ------------------------------------------------------------------------- + + /** + * - make() with no name falls back to the primary connection and proceeds + * past the config-lookup step (evidenced by DatabaseException rather than + * ConnectionException::noConfig). + */ + #[Test] + public function makeWithNoNameUsesPrimaryConnection(): void + { + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $this->noHostConfig]), + ); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('No host or socket specified.'); + + $factory->make(); + } + + // ------------------------------------------------------------------------- + // make() - DSN errors + // ------------------------------------------------------------------------- + + /** + * - make() throws DatabaseException when the MySQL config has neither a + * host nor a socket. + */ + #[Test] + public function makeThrowsDatabaseExceptionWhenMysqlHasNoHostOrSocket(): void + { + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $this->noHostConfig]), + ); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('No host or socket specified.'); + + $factory->make('default'); + } + + /** + * - make() throws ConnectionException when a socket path is provided but + * unreachable, exercising the socket DSN branch in createMysqlPdoDsn(). + */ + #[Test] + public function makeThrowsConnectionExceptionForUnreachableSocket(): void + { + $config = ConnectionConfig::make( + host: null, + port: null, + socket: '/nonexistent/mysql.sock', + database: 'engine_test', + username: 'engine', + password: 'secret', + ); + + $factory = new ConnectionFactory( + DatabaseConfig::make('default', ['default' => $config]), + ); + + $this->expectException(ConnectionException::class); + + $factory->make('default'); + } +} diff --git a/tests/Unit/Database/Exceptions/ConnectionExceptionTest.php b/tests/Unit/Database/Exceptions/ConnectionExceptionTest.php new file mode 100644 index 0000000..999b996 --- /dev/null +++ b/tests/Unit/Database/Exceptions/ConnectionExceptionTest.php @@ -0,0 +1,63 @@ +assertSame('Unable to connect to the database "myconn"', $exception->getMessage()); + } + + // ------------------------------------------------------------------------- + // cannotConnect() + // ------------------------------------------------------------------------- + + /** + * - cannotConnect() uses the previous exception's message when it has one. + */ + #[Test] + public function cannotConnectUsesPreviousExceptionMessage(): void + { + $previous = new \Exception('connection refused'); + $exception = ConnectionException::cannotConnect('myconn', $previous); + + $this->assertSame('connection refused', $exception->getMessage()); + } + + // ------------------------------------------------------------------------- + // noConfig() + // ------------------------------------------------------------------------- + + /** + * - noConfig() produces a message containing the connection name. + */ + #[Test] + public function noConfigProducesExactMessage(): void + { + $exception = ConnectionException::noConfig('myconn'); + + $this->assertSame( + 'No configuration found for the database connection "myconn"', + $exception->getMessage(), + ); + } +} From fc95ea455086cfe4d2ed4d187a0e254b4e335621 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 00:22:48 +0100 Subject: [PATCH 11/29] fix: Fix phpstan error --- src/Database/Connection.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 5b87341..7bad025 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -9,6 +9,7 @@ { public function __construct( public string $name, + // @phpstan-ignore property.onlyWritten private PDO $pdo, ) { } From 61e8a98f6403efd922d28ad4c65a33b8970d0807 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 00:25:10 +0100 Subject: [PATCH 12/29] fix: Fix socket tests on github acitons fix: Fix github connection socket tests fix: Fix all workflows --- .github/workflows/codecoverage.yml | 13 ++++- .github/workflows/integration.yml | 13 ++++- .github/workflows/mutation-testing.yml | 66 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 2ec8c7f..5add071 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -47,6 +47,7 @@ jobs: - name: Start MySQL socket container run: | mkdir -p /tmp/mysql_socket + chmod 777 /tmp/mysql_socket docker run -d \ --name mysql_socket \ -e MYSQL_ROOT_PASSWORD=secret \ @@ -56,9 +57,19 @@ jobs: -v /tmp/mysql_socket:/var/run/mysqld \ mysql:8.4 for i in $(seq 1 30); do - [ -S /tmp/mysql_socket/mysqld.sock ] && break + docker exec mysql_socket mysqladmin ping -u root -psecret -h 127.0.0.1 --silent 2>/dev/null && break sleep 2 done + for i in $(seq 1 10); do + [ -S /tmp/mysql_socket/mysqld.sock ] && break + sleep 1 + done + [ -S /tmp/mysql_socket/mysqld.sock ] || { echo "ERROR: socket file not found"; docker logs mysql_socket; exit 1; } + docker exec mysql_socket mysql -u root -psecret -h 127.0.0.1 -e " + CREATE USER IF NOT EXISTS 'engine'@'localhost' IDENTIFIED BY 'secret'; + GRANT ALL PRIVILEGES ON \`engine_test_socket\`.* TO 'engine'@'localhost'; + FLUSH PRIVILEGES; + " - name: Run tests and collect coverage run: vendor/bin/phpunit --coverage-clover coverage.xml --log-junit junit.xml diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 8593001..b580659 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -49,6 +49,7 @@ jobs: - name: Start MySQL socket container run: | mkdir -p /tmp/mysql_socket + chmod 777 /tmp/mysql_socket docker run -d \ --name mysql_socket \ -e MYSQL_ROOT_PASSWORD=secret \ @@ -58,9 +59,19 @@ jobs: -v /tmp/mysql_socket:/var/run/mysqld \ mysql:8.4 for i in $(seq 1 30); do - [ -S /tmp/mysql_socket/mysqld.sock ] && break + docker exec mysql_socket mysqladmin ping -u root -psecret -h 127.0.0.1 --silent 2>/dev/null && break sleep 2 done + for i in $(seq 1 10); do + [ -S /tmp/mysql_socket/mysqld.sock ] && break + sleep 1 + done + [ -S /tmp/mysql_socket/mysqld.sock ] || { echo "ERROR: socket file not found"; docker logs mysql_socket; exit 1; } + docker exec mysql_socket mysql -u root -psecret -h 127.0.0.1 -e " + CREATE USER IF NOT EXISTS 'engine'@'localhost' IDENTIFIED BY 'secret'; + GRANT ALL PRIVILEGES ON \`engine_test_socket\`.* TO 'engine'@'localhost'; + FLUSH PRIVILEGES; + " - name: Run integration tests run: vendor/bin/phpunit --testsuite Integration diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml index 0591fe4..5b44f31 100644 --- a/.github/workflows/mutation-testing.yml +++ b/.github/workflows/mutation-testing.yml @@ -5,6 +5,35 @@ on: [ pull_request ] jobs: run: runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + mysql_secondary: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test_port + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3307:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 steps: - name: Checkout uses: actions/checkout@v6 @@ -18,5 +47,42 @@ jobs: - name: Install dependencies run: composer self-update && composer install && composer dump-autoload + - name: Start MySQL socket container + run: | + mkdir -p /tmp/mysql_socket + chmod 777 /tmp/mysql_socket + docker run -d \ + --name mysql_socket \ + -e MYSQL_ROOT_PASSWORD=secret \ + -e MYSQL_DATABASE=engine_test_socket \ + -e MYSQL_USER=engine \ + -e MYSQL_PASSWORD=secret \ + -v /tmp/mysql_socket:/var/run/mysqld \ + mysql:8.4 + for i in $(seq 1 30); do + docker exec mysql_socket mysqladmin ping -u root -psecret -h 127.0.0.1 --silent 2>/dev/null && break + sleep 2 + done + for i in $(seq 1 10); do + [ -S /tmp/mysql_socket/mysqld.sock ] && break + sleep 1 + done + [ -S /tmp/mysql_socket/mysqld.sock ] || { echo "ERROR: socket file not found"; docker logs mysql_socket; exit 1; } + docker exec mysql_socket mysql -u root -psecret -h 127.0.0.1 -e " + CREATE USER IF NOT EXISTS 'engine'@'localhost' IDENTIFIED BY 'secret'; + GRANT ALL PRIVILEGES ON \`engine_test_socket\`.* TO 'engine'@'localhost'; + FLUSH PRIVILEGES; + " + - name: Run mutation testing run: composer mutation + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: engine_test + DB_USERNAME: engine + DB_PASSWORD: secret + DB_PORT_SECONDARY: 3307 + DB_DATABASE_SECONDARY: engine_test_port + DB_SOCKET: /tmp/mysql_socket/mysqld.sock + DB_DATABASE_SOCKET: engine_test_socket From d2e69fe7c7b46b4356834b7a05e10655cc9e899e Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 16:23:10 +0100 Subject: [PATCH 13/29] build(composer): Add a tidy composer command --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2fcddca..0105eb1 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "scripts" : { "test" : "@php vendor/bin/phpunit", "mutation": "@php vendor/bin/infection --threads=max --no-progress", - "analyze" : "@php vendor/bin/phpstan analyse src --memory-limit=2G" + "analyze" : "@php vendor/bin/phpstan analyse src --memory-limit=2G", + "tidy" : "@php vendor/bin/php-cs-fixer fix" } } From 94e430a6e01f9ab053f3073875cd3bd5e4efc00c Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 16:23:51 +0100 Subject: [PATCH 14/29] feat(values): Add a value getter and value type getter contract --- src/Values/Contracts/GetsAsType.php | 63 ++++++++ .../Exceptions/InvalidValueCastException.php | 18 +++ src/Values/ValueGetter.php | 144 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/Values/Contracts/GetsAsType.php create mode 100644 src/Values/Exceptions/InvalidValueCastException.php create mode 100644 src/Values/ValueGetter.php diff --git a/src/Values/Contracts/GetsAsType.php b/src/Values/Contracts/GetsAsType.php new file mode 100644 index 0000000..559c75b --- /dev/null +++ b/src/Values/Contracts/GetsAsType.php @@ -0,0 +1,63 @@ + + * + * @throws InvalidValueCastException + */ + public function array(string $name): array; +} diff --git a/src/Values/Exceptions/InvalidValueCastException.php b/src/Values/Exceptions/InvalidValueCastException.php new file mode 100644 index 0000000..7aaf5ee --- /dev/null +++ b/src/Values/Exceptions/InvalidValueCastException.php @@ -0,0 +1,18 @@ + $values + * + * @return bool + * + * @throws InvalidValueCastException + */ + public static function bool(string $name, array $values): bool + { + $value = $values[$name] ?? null; + + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return (bool) $value; + } + + if (is_string($value)) { + return match ($value) { + 'true', '1', 'yes' => true, + 'false', '0', 'no' => false, + default => throw InvalidValueCastException::cannotCast($name, 'a boolean'), + }; + } + + throw InvalidValueCastException::cannotCast($name, 'a boolean'); + } + + /** + * Get a value as an array. + * + * @param string $name + * @param array $values + * + * @return array + * + * @throws InvalidValueCastException + * @throws \JsonException + */ + public static function array(string $name, array $values): array + { + $value = $values[$name] ?? null; + + if (is_array($value)) { + return $value; + } + + if (is_string($value) && json_validate($value)) { + /** @var array */ + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } + + throw InvalidValueCastException::cannotCast($name, 'an array'); + } + + /** + * Get a value as a string. + * + * @param string $name + * @param array $values + * + * @return string + * + * @throws InvalidValueCastException + */ + public static function string(string $name, array $values): string + { + $value = $values[$name] ?? null; + + if (is_string($value)) { + return $value; + } + + if (is_numeric($value)) { + return (string) $value; + } + + throw InvalidValueCastException::cannotCast($name, 'a string'); + } + + /** + * Get a value as a float. + * + * @param string $name + * @param array $values + * + * @return float + * + * @throws InvalidValueCastException + */ + public static function float(string $name, array $values): float + { + $value = $values[$name] ?? null; + + if (is_float($value)) { + return $value; + } + + if (is_numeric($value)) { + return (float) $value; + } + + throw InvalidValueCastException::cannotCast($name, 'a float'); + } + + /** + * Get a value as an integer. + * + * @param string $name + * @param array $values + * + * @return int + * + * @throws InvalidValueCastException + */ + public static function int(string $name, array $values): int + { + $value = $values[$name] ?? null; + + if (is_int($value)) { + return $value; + } + + if (is_numeric($value)) { + return (int) $value; + } + + throw InvalidValueCastException::cannotCast($name, 'an integer'); + } +} From 6e999eeafa07d9070d267f9547790d18a1dfc592 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 16:24:21 +0100 Subject: [PATCH 15/29] feat(database:query): Add initial query builder functionality --- src/Database/Connection.php | 157 +++++++++++ src/Database/Contracts/Expression.php | 20 ++ src/Database/Contracts/Query.php | 7 + .../Exceptions/InvalidExpressionException.php | 21 ++ src/Database/Exceptions/QueryException.php | 37 +++ src/Database/Query/Clauses/JoinClause.php | 148 ++++++++++ src/Database/Query/Clauses/WhereClause.php | 258 ++++++++++++++++++ src/Database/Query/Concerns/HasJoinClause.php | 125 +++++++++ .../Query/Concerns/HasLimitClause.php | 54 ++++ .../Query/Concerns/HasOrderByClause.php | 65 +++++ .../Query/Concerns/HasWhereClause.php | 139 ++++++++++ src/Database/Query/Cursor.php | 76 ++++++ src/Database/Query/Delete.php | 44 +++ src/Database/Query/Expressions.php | 51 ++++ .../Query/Expressions/ColumnEqualTo.php | 40 +++ .../Query/Expressions/ColumnGreaterThen.php | 40 +++ .../ColumnGreaterThenOrEqualTo.php | 40 +++ src/Database/Query/Expressions/ColumnIn.php | 53 ++++ src/Database/Query/Expressions/ColumnIs.php | 40 +++ .../Query/Expressions/ColumnIsNotNull.php | 39 +++ .../Query/Expressions/ColumnIsNull.php | 39 +++ .../Query/Expressions/ColumnLessThan.php | 40 +++ .../Expressions/ColumnLessThanOrEqualTo.php | 40 +++ .../Query/Expressions/ColumnNotEqualTo.php | 40 +++ .../Query/Expressions/ColumnNotIn.php | 53 ++++ .../Query/Expressions/RawExpression.php | 50 ++++ src/Database/Query/Insert.php | 61 +++++ src/Database/Query/Raw.php | 39 +++ src/Database/Query/Result.php | 107 ++++++++ src/Database/Query/Row.php | 112 ++++++++ src/Database/Query/Select.php | 127 +++++++++ src/Database/Query/Update.php | 72 +++++ src/Database/Query/WriteResult.php | 46 ++++ 33 files changed, 2280 insertions(+) create mode 100644 src/Database/Contracts/Expression.php create mode 100644 src/Database/Contracts/Query.php create mode 100644 src/Database/Exceptions/InvalidExpressionException.php create mode 100644 src/Database/Exceptions/QueryException.php create mode 100644 src/Database/Query/Clauses/JoinClause.php create mode 100644 src/Database/Query/Clauses/WhereClause.php create mode 100644 src/Database/Query/Concerns/HasJoinClause.php create mode 100644 src/Database/Query/Concerns/HasLimitClause.php create mode 100644 src/Database/Query/Concerns/HasOrderByClause.php create mode 100644 src/Database/Query/Concerns/HasWhereClause.php create mode 100644 src/Database/Query/Cursor.php create mode 100644 src/Database/Query/Delete.php create mode 100644 src/Database/Query/Expressions.php create mode 100644 src/Database/Query/Expressions/ColumnEqualTo.php create mode 100644 src/Database/Query/Expressions/ColumnGreaterThen.php create mode 100644 src/Database/Query/Expressions/ColumnGreaterThenOrEqualTo.php create mode 100644 src/Database/Query/Expressions/ColumnIn.php create mode 100644 src/Database/Query/Expressions/ColumnIs.php create mode 100644 src/Database/Query/Expressions/ColumnIsNotNull.php create mode 100644 src/Database/Query/Expressions/ColumnIsNull.php create mode 100644 src/Database/Query/Expressions/ColumnLessThan.php create mode 100644 src/Database/Query/Expressions/ColumnLessThanOrEqualTo.php create mode 100644 src/Database/Query/Expressions/ColumnNotEqualTo.php create mode 100644 src/Database/Query/Expressions/ColumnNotIn.php create mode 100644 src/Database/Query/Expressions/RawExpression.php create mode 100644 src/Database/Query/Insert.php create mode 100644 src/Database/Query/Raw.php create mode 100644 src/Database/Query/Result.php create mode 100644 src/Database/Query/Row.php create mode 100644 src/Database/Query/Select.php create mode 100644 src/Database/Query/Update.php create mode 100644 src/Database/Query/WriteResult.php diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 7bad025..b5cfa25 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -3,7 +3,14 @@ namespace Engine\Database; +use Engine\Database\Contracts\Expression; +use Engine\Database\Exceptions\DatabaseException; +use Engine\Database\Exceptions\QueryException; +use JetBrains\PhpStorm\Language; use PDO; +use PDOException; +use PDOStatement; +use Throwable; final readonly class Connection { @@ -13,4 +20,154 @@ public function __construct( private PDO $pdo, ) { } + + /** + * Execute a query against the database and return the result. + * + * @param string|Expression $query + * @param array $bindings + * + * @return Result + */ + public function query(#[Language('GenericSQL')] Expression|string $query, array $bindings = []): Result + { + if ($query instanceof Expression) { + $bindings = $query->getBindings(); + $query = $query->toSql(); + } + + return new Result($this->statement($query, $bindings), $bindings); + } + + /** + * Execute a query against the database and return the number of affected rows. + * + * @param string|Expression $query + * @param array $bindings + * + * @return WriteResult + */ + public function execute(#[Language('GenericSQL')] Expression|string $query, array $bindings = []): WriteResult + { + if ($query instanceof Expression) { + $bindings = $query->getBindings(); + $query = $query->toSql(); + } + + try { + return new WriteResult( + affectedRows: $this->statement($query, $bindings)->rowCount(), + lastInsertId: $this->pdo->lastInsertId() ?: null, + ); + } catch (PDOException $e) { + throw new QueryException($query, $bindings, previous: $e); + } + } + + /** + * Execute a query against the database and return the result as a cursor. + * + * @param string|Expression $query + * @param array $bindings + * + * @return Cursor + */ + public function stream(#[Language('GenericSQL')] Expression|string $query, array $bindings = []): Cursor + { + if ($query instanceof Expression) { + $bindings = $query->getBindings(); + $query = $query->toSql(); + } + + return new Cursor($this->statement($query, $bindings), $bindings); + } + + /** + * Begin a new database transaction. + * + * @template TReturn of mixed + * + * @param callable(self): TReturn $callback + * + * @return TReturn + * + * @throws Throwable + */ + public function transaction(callable $callback): mixed + { + $this->beginTransaction(); + + try { + $result = $callback($this); + $this->commit(); + return $result; + } catch (Throwable $e) { + $this->rollback(); + throw $e; + } + } + + /** + * Start a new database transaction. + */ + public function beginTransaction(): void + { + try { + $this->pdo->beginTransaction(); + } catch (PDOException $e) { + throw new DatabaseException($e->getMessage(), previous: $e); + } + } + + /** + * Commit the active database transaction. + */ + public function commit(): void + { + try { + $this->pdo->commit(); + } catch (PDOException $e) { + throw new DatabaseException($e->getMessage(), previous: $e); + } + } + + /** + * Rollback the active database transaction. + */ + public function rollback(): void + { + try { + $this->pdo->rollBack(); + } catch (PDOException $e) { + throw new DatabaseException($e->getMessage(), previous: $e); + } + } + + /** + * Determine if the connection is currently in a transaction. + * + * @return bool + */ + public function isInTransaction(): bool + { + return $this->pdo->inTransaction(); + } + + /** + * @param string $query + * @param array $bindings + * + * @return PDOStatement + */ + private function statement(#[Language('GenericSQL')] string $query, array $bindings = []): PDOStatement + { + try { + $statement = $this->pdo->prepare($query); + $statement->execute($bindings); + + return $statement; + } catch (PDOException $e) { + throw new QueryException($query, $bindings, previous: $e); + } + } } diff --git a/src/Database/Contracts/Expression.php b/src/Database/Contracts/Expression.php new file mode 100644 index 0000000..b8b3882 --- /dev/null +++ b/src/Database/Contracts/Expression.php @@ -0,0 +1,20 @@ + + */ + public function getBindings(): array; +} diff --git a/src/Database/Contracts/Query.php b/src/Database/Contracts/Query.php new file mode 100644 index 0000000..0b51a90 --- /dev/null +++ b/src/Database/Contracts/Query.php @@ -0,0 +1,7 @@ + $bindings + */ + public function __construct( + private readonly string $sql, + private readonly array $bindings, + string $message = '', + ?Throwable $previous = null, + ) { + parent::__construct( + $message ?: $previous?->getMessage() ?? 'Unable to execute the query.', + previous: $previous, + ); + } + + public function getSql(): string + { + return $this->sql; + } + + /** + * @return array + */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/Database/Query/Clauses/JoinClause.php b/src/Database/Query/Clauses/JoinClause.php new file mode 100644 index 0000000..ea569bf --- /dev/null +++ b/src/Database/Query/Clauses/JoinClause.php @@ -0,0 +1,148 @@ +}> + */ + private array $conditions = []; + + /** + * Add an ON condition to the join. + * + * @param string $left + * @param string $operator + * @param string $right + * + * @return $this + */ + public function on(string $left, string $operator, string $right): self + { + $this->conditions[] = [ + 'conjunction' => 'AND', + 'sql' => "{$left} {$operator} {$right}", + 'bindings' => [], + ]; + + return $this; + } + + /** + * Add an OR ON condition to the join. + * + * @param string $left + * @param string $operator + * @param string $right + * + * @return $this + */ + public function orOn(string $left, string $operator, string $right): self + { + $this->conditions[] = [ + 'conjunction' => 'OR', + 'sql' => "{$left} {$operator} {$right}", + 'bindings' => [], + ]; + + return $this; + } + + /** + * Add a WHERE condition to the join (bound value, not column reference). + * + * @param string $column + * @param mixed $operatorOrValue + * @param mixed $value + * + * @return $this + */ + public function where(string $column, mixed $operatorOrValue = null, mixed $value = null): self + { + if (func_num_args() === 2) { + $value = $operatorOrValue; + $operator = '='; + } else { + /** @var string $operator */ + $operator = $operatorOrValue; + } + + $this->conditions[] = [ + 'conjunction' => 'AND', + 'sql' => "{$column} {$operator} ?", + 'bindings' => [$value], + ]; + + return $this; + } + + /** + * Add an OR WHERE condition to the join. + * + * @param string $column + * @param mixed $operatorOrValue + * @param mixed $value + * + * @return $this + */ + public function orWhere(string $column, mixed $operatorOrValue = null, mixed $value = null): self + { + if (func_num_args() === 2) { + $value = $operatorOrValue; + $operator = '='; + } else { + /** @var string $operator */ + $operator = $operatorOrValue; + } + + $this->conditions[] = [ + 'conjunction' => 'OR', + 'sql' => "{$column} {$operator} ?", + 'bindings' => [$value], + ]; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $parts = []; + + foreach ($this->conditions as $i => $condition) { + $prefix = $i === 0 ? '' : " {$condition['conjunction']} "; + $parts[] = $prefix . $condition['sql']; + } + + return implode('', $parts); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + $bindings = []; + + foreach ($this->conditions as $condition) { + $bindings[] = $condition['bindings']; + } + + return array_merge(...$bindings ?: [[]]); + } + + public function isEmpty(): bool + { + return empty($this->conditions); + } +} diff --git a/src/Database/Query/Clauses/WhereClause.php b/src/Database/Query/Clauses/WhereClause.php new file mode 100644 index 0000000..83dac2e --- /dev/null +++ b/src/Database/Query/Clauses/WhereClause.php @@ -0,0 +1,258 @@ + + */ + private array $conditions = []; + + /** + * Add a basic where clause to the query. + * + * @param string|Closure $column + * @param mixed|null $operatorOrValue + * @param mixed|null $value + * + * @return $this + */ + public function where(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): self + { + if ($column instanceof Closure) { + $this->condition('AND', $column, null, null); + } else { + if (func_num_args() === 2) { + $value = $operatorOrValue; + $operator = '='; + } else { + /** @var string $operator */ + $operator = $operatorOrValue; + } + + $this->condition('AND', $column, $operator, $value); + } + + return $this; + } + + /** + * Add an "or where" clause to the query. + * + * @param string|Closure $column + * @param mixed|null $operatorOrValue + * @param mixed|null $value + * + * @return $this + */ + public function orWhere(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): self + { + if ($column instanceof Closure) { + $this->condition('OR', $column, null, null); + } else { + if (func_num_args() === 2) { + $value = $operatorOrValue; + $operator = '='; + } else { + /** @var string $operator */ + $operator = $operatorOrValue; + } + + $this->condition('OR', $column, $operator, $value); + } + + return $this; + } + + /** + * Add a "where null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function whereNull(string $column): self + { + $this->condition('AND', $column, 'IS NULL', null); + + return $this; + } + + /** + * Add a "where not null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function orWhereNull(string $column): self + { + $this->condition('OR', $column, 'IS NULL', null); + + return $this; + } + + /** + * Add a "where not null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function whereNotNull(string $column): self + { + $this->condition('AND', $column, 'IS NOT NULL', null); + + return $this; + } + + /** + * Add a "where in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return $this + */ + public function whereIn(string $column, array|Expression $values): self + { + if (is_array($values) && empty($values)) { + throw InvalidExpressionException::emptyInClause($column); + } + + if (is_array($values)) { + $this->condition('AND', $column, 'IN', $values); + } else { + $this->whereRaw("{$column} IN (" . $values->toSql() . ')', $values->getBindings()); + } + + return $this; + } + + /** + * Add a "where not in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return $this + */ + public function whereNotIn(string $column, array|Expression $values): self + { + if (is_array($values) && empty($values)) { + throw InvalidExpressionException::emptyInClause($column); + } + + if (is_array($values)) { + $this->condition('AND', $column, 'NOT IN', $values); + } else { + $this->whereRaw("{$column} NOT IN (" . $values->toSql() . ')', $values->getBindings()); + } + + return $this; + } + + /** + * Add a raw where clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return $this + */ + public function whereRaw(string $sql, array $bindings = []): self + { + $this->conditions[] = [ + 'conjunction' => 'AND', + 'expression' => Expressions::raw($sql, $bindings), + 'grouped' => false, + ]; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $parts = []; + + foreach ($this->conditions as $i => $condition) { + $prefix = $i === 0 ? '' : " {$condition['conjunction']} "; + $sql = $condition['expression']->toSql(); + + $parts[] = $prefix . ($condition['grouped'] ? "({$sql})" : $sql); + } + + return implode('', $parts); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + $bindings = []; + + foreach ($this->conditions as $condition) { + $bindings[] = $condition['expression']->getBindings(); + } + + return array_merge(...$bindings); + } + + public function isEmpty(): bool + { + return empty($this->conditions); + } + + /** + * Add a condition to the query. + * + * @param 'AND'|'OR' $conjunction + * @param string|Closure $column + * @param string|null $operator + * @param mixed $value + */ + private function condition( + string $conjunction, + Closure|string $column, + ?string $operator, + mixed $value, + ): void { + if ($column instanceof Closure) { + $clause = new self(); + + $column($clause); + + if ($clause->isEmpty()) { + throw InvalidExpressionException::emptyGroupedCondition(); + } + + $this->conditions[] = [ + 'conjunction' => $conjunction, + 'expression' => $clause, + 'grouped' => true, + ]; + } else { + /** @var string $operator */ + $this->conditions[] = [ + 'conjunction' => $conjunction, + 'expression' => Expressions::whereColumn($operator, $column, $value), + 'grouped' => false, + ]; + } + } +} diff --git a/src/Database/Query/Concerns/HasJoinClause.php b/src/Database/Query/Concerns/HasJoinClause.php new file mode 100644 index 0000000..dd9112e --- /dev/null +++ b/src/Database/Query/Concerns/HasJoinClause.php @@ -0,0 +1,125 @@ + + */ + private array $joins = []; + + /** + * Add an inner join to the query. + * + * @param string $table + * @param string|Closure $first + * @param string|null $operator + * @param string|null $second + * + * @return $this + */ + public function join(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static + { + return $this->addJoin('INNER', $table, $first, $operator, $second); + } + + /** + * Add a left join to the query. + * + * @param string $table + * @param string|Closure $first + * @param string|null $operator + * @param string|null $second + * + * @return $this + */ + public function leftJoin(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static + { + return $this->addJoin('LEFT', $table, $first, $operator, $second); + } + + /** + * Add a right join to the query. + * + * @param string $table + * @param string|Closure $first + * @param string|null $operator + * @param string|null $second + * + * @return $this + */ + public function rightJoin(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static + { + return $this->addJoin('RIGHT', $table, $first, $operator, $second); + } + + /** + * Add a cross join to the query. + * + * @param string $table + * + * @return $this + */ + public function crossJoin(string $table): static + { + $this->joins[] = ['type' => 'CROSS', 'table' => $table, 'clause' => new JoinClause()]; + + return $this; + } + + private function addJoin(string $type, string $table, Closure|string $first, ?string $operator, ?string $second): static + { + $clause = new JoinClause(); + + if ($first instanceof Closure) { + $first($clause); + } else { + /** @var string $operator */ + $clause->on($first, $operator, $second); + } + + $this->joins[] = ['type' => $type, 'table' => $table, 'clause' => $clause]; + + return $this; + } + + private function buildJoinClause(): string + { + if (empty($this->joins)) { + return ''; + } + + $parts = []; + + foreach ($this->joins as $join) { + $sql = " {$join['type']} JOIN {$join['table']}"; + + if (! $join['clause']->isEmpty()) { + $sql .= ' ON ' . $join['clause']->toSql(); + } + + $parts[] = $sql; + } + + return implode('', $parts); + } + + /** + * @return array + */ + private function getJoinBindings(): array + { + $bindings = []; + + foreach ($this->joins as $join) { + $bindings = array_merge($bindings, $join['clause']->getBindings()); + } + + return $bindings; + } +} diff --git a/src/Database/Query/Concerns/HasLimitClause.php b/src/Database/Query/Concerns/HasLimitClause.php new file mode 100644 index 0000000..7bb0c58 --- /dev/null +++ b/src/Database/Query/Concerns/HasLimitClause.php @@ -0,0 +1,54 @@ +limitValue = $limit; + + return $this; + } + + /** + * Set the offset for the query. + * + * @param int $offset + * + * @return $this + */ + public function offset(int $offset): static + { + $this->offsetValue = $offset; + + return $this; + } + + private function buildLimitClause(): string + { + $sql = ''; + + if ($this->limitValue !== null) { + $sql .= " LIMIT {$this->limitValue}"; + } + + if ($this->offsetValue !== null) { + $sql .= " OFFSET {$this->offsetValue}"; + } + + return $sql; + } +} diff --git a/src/Database/Query/Concerns/HasOrderByClause.php b/src/Database/Query/Concerns/HasOrderByClause.php new file mode 100644 index 0000000..08600b3 --- /dev/null +++ b/src/Database/Query/Concerns/HasOrderByClause.php @@ -0,0 +1,65 @@ + + */ + private array $orders = []; + + /** + * Add an order by clause to the query. + * + * @param string|Expression $column + * @param string $direction + * + * @return $this + */ + public function orderBy(Expression|string $column, string $direction = 'asc'): static + { + $this->orders[] = [ + 'column' => $column, + 'direction' => strtolower($direction) === 'desc' ? 'DESC' : 'ASC', + ]; + + return $this; + } + + private function buildOrderByClause(): string + { + if (empty($this->orders)) { + return ''; + } + + $clauses = array_map(function (array $order): string { + $col = $order['column'] instanceof Expression + ? $order['column']->toSql() + : $order['column']; + + return "{$col} {$order['direction']}"; + }, $this->orders); + + return ' ORDER BY ' . implode(', ', $clauses); + } + + /** + * @return array + */ + private function getOrderByBindings(): array + { + $bindings = []; + + foreach ($this->orders as $order) { + if ($order['column'] instanceof Expression) { + $bindings = array_merge($bindings, $order['column']->getBindings()); + } + } + + return $bindings; + } +} diff --git a/src/Database/Query/Concerns/HasWhereClause.php b/src/Database/Query/Concerns/HasWhereClause.php new file mode 100644 index 0000000..6d80e4c --- /dev/null +++ b/src/Database/Query/Concerns/HasWhereClause.php @@ -0,0 +1,139 @@ + $this->whereClause ?? $this->whereClause = new WhereClause(); + } + + /** + * Add a basic where clause to the query. + * + * @param string|Closure $column + * @param mixed|null $operatorOrValue + * @param mixed|null $value + * + * @return $this + */ + public function where(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): static + { + $this->whereClause->where(...func_get_args()); + + return $this; + } + + /** + * Add an "or where" clause to the query. + * + * @param string|Closure $column + * @param mixed|null $operatorOrValue + * @param mixed|null $value + * + * @return $this + */ + public function orWhere(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): static + { + $this->whereClause->orWhere(...func_get_args()); + + return $this; + } + + /** + * Add a "where null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function whereNull(string $column): static + { + $this->whereClause->whereNull($column); + + return $this; + } + + /** + * Add a "where not null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function orWhereNull(string $column): static + { + $this->whereClause->orWhereNull($column); + + return $this; + } + + /** + * Add a "where not null" clause to the query. + * + * @param string $column + * + * @return $this + */ + public function whereNotNull(string $column): static + { + $this->whereClause->whereNotNull($column); + + return $this; + } + + /** + * Add a "where in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return $this + */ + public function whereIn(string $column, array|Expression $values): static + { + $this->whereClause->whereIn($column, $values); + + return $this; + } + + /** + * Add a "where not in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return $this + */ + public function whereNotIn(string $column, array|Expression $values): static + { + $this->whereClause->whereNotIn($column, $values); + + return $this; + } + + /** + * Add a raw where clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return $this + */ + public function whereRaw(string $sql, array $bindings = []): static + { + $this->whereClause->whereRaw($sql, $bindings); + + return $this; + } + + protected function hasWhereClause(): bool + { + return $this->whereClause->isEmpty() === false; + } +} diff --git a/src/Database/Query/Cursor.php b/src/Database/Query/Cursor.php new file mode 100644 index 0000000..88f6be9 --- /dev/null +++ b/src/Database/Query/Cursor.php @@ -0,0 +1,76 @@ + + * + * @phpstan-ignore property.onlyWritten + */ + private array $bindings; + + /** + * @param PDOStatement $statement + * @param array $bindings + */ + public function __construct(PDOStatement $statement, array $bindings) + { + $this->bindings = $bindings; + $this->statement = $statement; + } + + /** + * Iterate over each row in the result. + * + * @param callable(Row $row):void $callback + */ + public function each(callable $callback): void + { + while ($row = $this->statement->fetch(PDO::FETCH_ASSOC)) { + /** @var array $row */ + $callback(new Row($row)); + } + } + + /** + * Get a generator for each row in the result. + * + * @return Generator + */ + public function rows(): Generator + { + while ($row = $this->statement->fetch(PDO::FETCH_ASSOC)) { + /** @var array $row */ + yield new Row($row); + } + } + + /** + * Get the number of rows in the result. + * + * @return int + */ + public function count(): int + { + return $this->statement->rowCount(); + } + + /** + * Determine if the result is empty. + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->count() === 0; + } +} diff --git a/src/Database/Query/Delete.php b/src/Database/Query/Delete.php new file mode 100644 index 0000000..f0feba5 --- /dev/null +++ b/src/Database/Query/Delete.php @@ -0,0 +1,44 @@ +hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; + + return "DELETE FROM {$this->table}" . $where; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->whereClause->getBindings(); + } +} diff --git a/src/Database/Query/Expressions.php b/src/Database/Query/Expressions.php new file mode 100644 index 0000000..cc1feeb --- /dev/null +++ b/src/Database/Query/Expressions.php @@ -0,0 +1,51 @@ + ColumnEqualTo::make($column, $value), + '<' => ColumnLessThan::make($column, $value), + '>' => ColumnGreaterThen::make($column, $value), + '<=' => ColumnLessThanOrEqualTo::make($column, $value), + '>=' => ColumnGreaterThenOrEqualTo::make($column, $value), + 'is' => ColumnIs::make($column, $value), + 'is null' => ColumnIsNull::make($column), + 'is not null' => ColumnIsNotNull::make($column), + '!=' => ColumnNotEqualTo::make($column, $value), + 'in' => ColumnIn::make($column, $value), // @phpstan-ignore-line + 'not in' => ColumnNotIn::make($column, $value), // @phpstan-ignore-line + default => throw new InvalidArgumentException(sprintf('Invalid operator "%s".', $operator)), + }; + } + + /** + * @param string $sql + * @param array $bindings + * + * @return Expression + */ + public static function raw(string $sql, array $bindings): Expression + { + return RawExpression::make($sql, $bindings); + } +} diff --git a/src/Database/Query/Expressions/ColumnEqualTo.php b/src/Database/Query/Expressions/ColumnEqualTo.php new file mode 100644 index 0000000..af102fb --- /dev/null +++ b/src/Database/Query/Expressions/ColumnEqualTo.php @@ -0,0 +1,40 @@ +column} = ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnGreaterThen.php b/src/Database/Query/Expressions/ColumnGreaterThen.php new file mode 100644 index 0000000..69922d4 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnGreaterThen.php @@ -0,0 +1,40 @@ +column} > ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnGreaterThenOrEqualTo.php b/src/Database/Query/Expressions/ColumnGreaterThenOrEqualTo.php new file mode 100644 index 0000000..b6d376d --- /dev/null +++ b/src/Database/Query/Expressions/ColumnGreaterThenOrEqualTo.php @@ -0,0 +1,40 @@ +column} >= ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnIn.php b/src/Database/Query/Expressions/ColumnIn.php new file mode 100644 index 0000000..cb14a29 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnIn.php @@ -0,0 +1,53 @@ + $values + * + * @return self + */ + public static function make(string $column, array $values): self + { + return new self($column, $values); + } + + /** + * @param string $column + * @param array $values + */ + private function __construct( + private string $column, + private array $values, + ) { + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $params = array_fill(0, count($this->values), '?'); + $params = implode(', ', $params); + + return "{$this->column} IN ({$params})"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->values; + } +} diff --git a/src/Database/Query/Expressions/ColumnIs.php b/src/Database/Query/Expressions/ColumnIs.php new file mode 100644 index 0000000..505217c --- /dev/null +++ b/src/Database/Query/Expressions/ColumnIs.php @@ -0,0 +1,40 @@ +column} IS ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnIsNotNull.php b/src/Database/Query/Expressions/ColumnIsNotNull.php new file mode 100644 index 0000000..c49e949 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnIsNotNull.php @@ -0,0 +1,39 @@ +column} IS NOT NULL"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } +} diff --git a/src/Database/Query/Expressions/ColumnIsNull.php b/src/Database/Query/Expressions/ColumnIsNull.php new file mode 100644 index 0000000..36f2298 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnIsNull.php @@ -0,0 +1,39 @@ +column} IS NULL"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } +} diff --git a/src/Database/Query/Expressions/ColumnLessThan.php b/src/Database/Query/Expressions/ColumnLessThan.php new file mode 100644 index 0000000..447e2b0 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnLessThan.php @@ -0,0 +1,40 @@ +column} < ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnLessThanOrEqualTo.php b/src/Database/Query/Expressions/ColumnLessThanOrEqualTo.php new file mode 100644 index 0000000..8d3e0e1 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnLessThanOrEqualTo.php @@ -0,0 +1,40 @@ +column} <= ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnNotEqualTo.php b/src/Database/Query/Expressions/ColumnNotEqualTo.php new file mode 100644 index 0000000..b1c595c --- /dev/null +++ b/src/Database/Query/Expressions/ColumnNotEqualTo.php @@ -0,0 +1,40 @@ +column} != ?"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Expressions/ColumnNotIn.php b/src/Database/Query/Expressions/ColumnNotIn.php new file mode 100644 index 0000000..8147a96 --- /dev/null +++ b/src/Database/Query/Expressions/ColumnNotIn.php @@ -0,0 +1,53 @@ + $values + * + * @return self + */ + public static function make(string $column, array $values): self + { + return new self($column, $values); + } + + /** + * @param string $column + * @param array $values + */ + private function __construct( + private string $column, + private array $values, + ) { + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $params = array_fill(0, count($this->values), '?'); + $params = implode(', ', $params); + + return "{$this->column} NOT IN ({$params})"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->values; + } +} diff --git a/src/Database/Query/Expressions/RawExpression.php b/src/Database/Query/Expressions/RawExpression.php new file mode 100644 index 0000000..be713ff --- /dev/null +++ b/src/Database/Query/Expressions/RawExpression.php @@ -0,0 +1,50 @@ + $bindings + * + * @return self + */ + public static function make(string $sql, array $bindings): self + { + return new self($sql, $bindings); + } + + /** + * @param string $sql + * @param array $bindings + */ + private function __construct( + private string $sql, + private array $bindings, + ) { + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + return $this->sql; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/Database/Query/Insert.php b/src/Database/Query/Insert.php new file mode 100644 index 0000000..5708a27 --- /dev/null +++ b/src/Database/Query/Insert.php @@ -0,0 +1,61 @@ + + */ + private array $values = []; + + private function __construct( + private string $table, + ) { + } + + /** + * Set the values to insert. + * + * @param array $values + * + * @return $this + */ + public function values(array $values): self + { + $this->values = $values; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $columns = implode(', ', array_keys($this->values)); + $placeholders = implode(', ', array_fill(0, count($this->values), '?')); + + return "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return array_values($this->values); + } +} diff --git a/src/Database/Query/Raw.php b/src/Database/Query/Raw.php new file mode 100644 index 0000000..d2a9c9f --- /dev/null +++ b/src/Database/Query/Raw.php @@ -0,0 +1,39 @@ + $bindings + */ + public function __construct( + private string $sql, + private array $bindings = [], + ) { + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + return $this->sql; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/Database/Query/Result.php b/src/Database/Query/Result.php new file mode 100644 index 0000000..379d396 --- /dev/null +++ b/src/Database/Query/Result.php @@ -0,0 +1,107 @@ + + * + * @phpstan-ignore property.onlyWritten + */ + private array $bindings; + + /** + * @var array|null + */ + private ?array $rows = null; + + /** + * @param PDOStatement $statement + * @param array $bindings + */ + public function __construct(PDOStatement $statement, array $bindings) + { + $this->bindings = $bindings; + $this->statement = $statement; + } + + /** + * Get the first row from the result. + * + * @return Row|null + */ + public function first(): ?Row + { + $this->hydrate(); + + return $this->rows[0] ?? null; + } + + /** + * Get all rows from the result. + * + * @return array + */ + public function all(): array + { + $this->hydrate(); + + return $this->rows; + } + + /** + * Iterate over each row in the result. + * + * @param callable(Row $row):void $callback + */ + public function each(callable $callback): void + { + foreach ($this->all() as $row) { + $callback($row); + } + } + + /** + * Get the number of rows in the result. + * + * @return int + */ + public function count(): int + { + return $this->statement->rowCount(); + } + + /** + * Determine if the result is empty. + * + * @return bool + */ + public function isEmpty(): bool + { + return $this->count() === 0; + } + + /** + * @phpstan-assert array<\Engine\Database\Query\Row> $this->rows + */ + private function hydrate(): void + { + if ($this->rows === null) { + $rows = []; + + /** @var array $row */ + foreach ($this->statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $rows[] = new Row($row); + } + + $this->rows = $rows; + } + } +} diff --git a/src/Database/Query/Row.php b/src/Database/Query/Row.php new file mode 100644 index 0000000..a571ec8 --- /dev/null +++ b/src/Database/Query/Row.php @@ -0,0 +1,112 @@ + + */ + private array $data; + + /** + * @param array $data + */ + public function __construct(array $data) + { + $this->data = $data; + } + + public function get(string $column): mixed + { + return $this->data[$column] ?? null; + } + + public function has(string $column): bool + { + return array_key_exists($column, $this->data); + } + + public function isNull(string $column): bool + { + if ($this->has($column) === false) { + return false; + } + + return $this->get($column) === null; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * Get a value as a string. + * + * @param string $name + * + * @return string + */ + public function string(string $name): string + { + return ValueGetter::string($name, $this->data); + } + + /** + * Get a value as an integer. + * + * @param string $name + * + * @return int + */ + public function int(string $name): int + { + return ValueGetter::int($name, $this->data); + } + + /** + * Get a value as a float. + * + * @param string $name + * + * @return float + */ + public function float(string $name): float + { + return ValueGetter::float($name, $this->data); + } + + /** + * Get a value as a boolean. + * + * @param string $name + * + * @return bool + */ + public function bool(string $name): bool + { + return ValueGetter::bool($name, $this->data); + } + + /** + * Get a value as an array. + * + * @param string $name + * + * @return array + * + * @noinspection PhpDocMissingThrowsInspection + */ + public function array(string $name): array + { + return ValueGetter::array($name, $this->data); + } +} diff --git a/src/Database/Query/Select.php b/src/Database/Query/Select.php new file mode 100644 index 0000000..18839b5 --- /dev/null +++ b/src/Database/Query/Select.php @@ -0,0 +1,127 @@ + + */ + private array $columns = []; + + private function __construct( + private Expression|string $table, + ) { + } + + /** + * Set the columns to select. + * + * @param string|Expression ...$columns + * + * @return $this + */ + public function columns(Expression|string ...$columns): self + { + $this->columns = $columns; + + return $this; + } + + /** + * Add a column to select. + * + * @param string|Expression $column + * + * @return $this + */ + public function addColumn(Expression|string $column): self + { + $this->columns[] = $column; + + return $this; + } + + /** + * Set the query to select distinct rows. + * + * @return $this + */ + public function distinct(): self + { + $this->distinct = true; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $columns = empty($this->columns) ? '*' : implode(', ', array_map( + fn (Expression|string $col) => $col instanceof Expression ? $col->toSql() : $col, + $this->columns, + )); + $distinct = $this->distinct ? 'DISTINCT ' : ''; + $table = $this->table instanceof Expression ? '(' . $this->table->toSql() . ')' : $this->table; + $where = $this->hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; + + return "SELECT {$distinct}{$columns} FROM {$table}" + . $this->buildJoinClause() + . $where + . $this->buildOrderByClause() + . $this->buildLimitClause(); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + $bindings = []; + + // Table subquery bindings + if ($this->table instanceof Expression) { + $bindings = $this->table->getBindings(); + } + + // Column expression bindings + foreach ($this->columns as $column) { + if ($column instanceof Expression) { + $bindings = array_merge($bindings, $column->getBindings()); + } + } + + return array_merge( + $bindings, + $this->getJoinBindings(), + $this->whereClause->getBindings(), + $this->getOrderByBindings(), + ); + } +} diff --git a/src/Database/Query/Update.php b/src/Database/Query/Update.php new file mode 100644 index 0000000..e643708 --- /dev/null +++ b/src/Database/Query/Update.php @@ -0,0 +1,72 @@ + + */ + private array $sets = []; + + private function __construct( + private string $table, + ) { + } + + /** + * Set the column values to update. + * + * @param array $values + * + * @return $this + */ + public function set(array $values): self + { + $this->sets = array_merge($this->sets, $values); + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $setClauses = []; + + foreach (array_keys($this->sets) as $column) { + $setClauses[] = "{$column} = ?"; + } + + $where = $this->hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; + + return "UPDATE {$this->table} SET " . implode(', ', $setClauses) . $where; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return array_merge( + array_values($this->sets), + $this->whereClause->getBindings(), + ); + } +} diff --git a/src/Database/Query/WriteResult.php b/src/Database/Query/WriteResult.php new file mode 100644 index 0000000..c1364d6 --- /dev/null +++ b/src/Database/Query/WriteResult.php @@ -0,0 +1,46 @@ +affectedRows = $affectedRows; + $this->lastInsertId = $lastInsertId; + } + + /** + * Get the number of affected rows. + * + * @return int + */ + public function affectedRows(): int + { + return $this->affectedRows; + } + + /** + * Get the last inserted ID. + * + * @return string|null + */ + public function lastInsertId(): ?string + { + return $this->lastInsertId; + } + + /** + * Check if the write operation was successful. + * + * @return bool + */ + public function wasSuccessful(): bool + { + return $this->affectedRows > 0; + } +} From 745603d554ae02286ea6939d670a918f3b55f4cb Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 16:36:38 +0100 Subject: [PATCH 16/29] feat(engine:database): Require operator in query builder where methods Remove the implicit '=' operator shorthand from where() and orWhere() across JoinClause, WhereClause, and HasWhereClause. The operator parameter is now required when the first argument is not a closure. --- src/Database/Query/Clauses/JoinClause.php | 24 ++++--------------- src/Database/Query/Clauses/WhereClause.php | 24 ++++--------------- .../Query/Concerns/HasWhereClause.php | 12 +++++----- 3 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/Database/Query/Clauses/JoinClause.php b/src/Database/Query/Clauses/JoinClause.php index ea569bf..5251b6e 100644 --- a/src/Database/Query/Clauses/JoinClause.php +++ b/src/Database/Query/Clauses/JoinClause.php @@ -56,21 +56,13 @@ public function orOn(string $left, string $operator, string $right): self * Add a WHERE condition to the join (bound value, not column reference). * * @param string $column - * @param mixed $operatorOrValue + * @param string $operator * @param mixed $value * * @return $this */ - public function where(string $column, mixed $operatorOrValue = null, mixed $value = null): self + public function where(string $column, string $operator, mixed $value): self { - if (func_num_args() === 2) { - $value = $operatorOrValue; - $operator = '='; - } else { - /** @var string $operator */ - $operator = $operatorOrValue; - } - $this->conditions[] = [ 'conjunction' => 'AND', 'sql' => "{$column} {$operator} ?", @@ -84,21 +76,13 @@ public function where(string $column, mixed $operatorOrValue = null, mixed $valu * Add an OR WHERE condition to the join. * * @param string $column - * @param mixed $operatorOrValue + * @param string $operator * @param mixed $value * * @return $this */ - public function orWhere(string $column, mixed $operatorOrValue = null, mixed $value = null): self + public function orWhere(string $column, string $operator, mixed $value): self { - if (func_num_args() === 2) { - $value = $operatorOrValue; - $operator = '='; - } else { - /** @var string $operator */ - $operator = $operatorOrValue; - } - $this->conditions[] = [ 'conjunction' => 'OR', 'sql' => "{$column} {$operator} ?", diff --git a/src/Database/Query/Clauses/WhereClause.php b/src/Database/Query/Clauses/WhereClause.php index 83dac2e..f281026 100644 --- a/src/Database/Query/Clauses/WhereClause.php +++ b/src/Database/Query/Clauses/WhereClause.php @@ -19,24 +19,16 @@ final class WhereClause implements Expression * Add a basic where clause to the query. * * @param string|Closure $column - * @param mixed|null $operatorOrValue + * @param string|null $operator * @param mixed|null $value * * @return $this */ - public function where(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): self + public function where(Closure|string $column, ?string $operator = null, mixed $value = null): self { if ($column instanceof Closure) { $this->condition('AND', $column, null, null); } else { - if (func_num_args() === 2) { - $value = $operatorOrValue; - $operator = '='; - } else { - /** @var string $operator */ - $operator = $operatorOrValue; - } - $this->condition('AND', $column, $operator, $value); } @@ -47,24 +39,16 @@ public function where(Closure|string $column, mixed $operatorOrValue = null, mix * Add an "or where" clause to the query. * * @param string|Closure $column - * @param mixed|null $operatorOrValue + * @param string|null $operator * @param mixed|null $value * * @return $this */ - public function orWhere(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): self + public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): self { if ($column instanceof Closure) { $this->condition('OR', $column, null, null); } else { - if (func_num_args() === 2) { - $value = $operatorOrValue; - $operator = '='; - } else { - /** @var string $operator */ - $operator = $operatorOrValue; - } - $this->condition('OR', $column, $operator, $value); } diff --git a/src/Database/Query/Concerns/HasWhereClause.php b/src/Database/Query/Concerns/HasWhereClause.php index 6d80e4c..6319108 100644 --- a/src/Database/Query/Concerns/HasWhereClause.php +++ b/src/Database/Query/Concerns/HasWhereClause.php @@ -17,14 +17,14 @@ trait HasWhereClause * Add a basic where clause to the query. * * @param string|Closure $column - * @param mixed|null $operatorOrValue + * @param string|null $operator * @param mixed|null $value * * @return $this */ - public function where(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): static + public function where(Closure|string $column, ?string $operator = null, mixed $value = null): static { - $this->whereClause->where(...func_get_args()); + $this->whereClause->where($column, $operator, $value); return $this; } @@ -33,14 +33,14 @@ public function where(Closure|string $column, mixed $operatorOrValue = null, mix * Add an "or where" clause to the query. * * @param string|Closure $column - * @param mixed|null $operatorOrValue + * @param string|null $operator * @param mixed|null $value * * @return $this */ - public function orWhere(Closure|string $column, mixed $operatorOrValue = null, mixed $value = null): static + public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): static { - $this->whereClause->orWhere(...func_get_args()); + $this->whereClause->orWhere($column, $operator, $value); return $this; } From 1cdec96376e7a4d90b0bbc7e0d645d6089af5e49 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 19:13:27 +0100 Subject: [PATCH 17/29] feat(engine:database): Complete query builder with tests and CI matrix Add missing where or-variants (orWhereNotNull, orWhereIn, orWhereNotIn, orWhereRaw, whereFullText, orWhereFullText), group by, having, aggregate expressions (COUNT/SUM/MIN/MAX/AVG), full-text search (MATCH AGAINST), bulk insert, INSERT IGNORE, REPLACE INTO, upsert (ON DUPLICATE KEY UPDATE), and order by/limit on Update and Delete. Expression support added to Update::set() and Insert::upsert(). --- .github/workflows/integration.yml | 53 +- src/Database/Connection.php | 3 + src/Database/Query/Clauses/JoinClause.php | 8 +- src/Database/Query/Clauses/WhereClause.php | 136 ++++- .../Query/Concerns/HasGroupByClause.php | 58 ++ .../Query/Concerns/HasHavingClause.php | 81 +++ .../Query/Concerns/HasWhereClause.php | 107 +++- src/Database/Query/Delete.php | 14 +- src/Database/Query/Expressions.php | 74 +++ src/Database/Query/Expressions/Aggregate.php | 52 ++ .../Query/Expressions/MatchAgainst.php | 59 ++ src/Database/Query/Insert.php | 105 +++- src/Database/Query/Select.php | 9 + src/Database/Query/Update.php | 37 +- .../Integration/Database/QueryBuilderTest.php | 500 ++++++++++++++++ .../Query/Clauses/WhereClauseTest.php | 558 ++++++++++++++++++ .../Query/Concerns/HasGroupByClauseTest.php | 130 ++++ .../Query/Concerns/HasHavingClauseTest.php | 130 ++++ tests/Unit/Database/Query/DeleteTest.php | 122 ++++ .../Query/Expressions/AggregateTest.php | 126 ++++ .../Query/Expressions/MatchAgainstTest.php | 73 +++ tests/Unit/Database/Query/InsertTest.php | 234 ++++++++ tests/Unit/Database/Query/UpdateTest.php | 173 ++++++ 23 files changed, 2801 insertions(+), 41 deletions(-) create mode 100644 src/Database/Query/Concerns/HasGroupByClause.php create mode 100644 src/Database/Query/Concerns/HasHavingClause.php create mode 100644 src/Database/Query/Expressions/Aggregate.php create mode 100644 src/Database/Query/Expressions/MatchAgainst.php create mode 100644 tests/Integration/Database/QueryBuilderTest.php create mode 100644 tests/Unit/Database/Query/Clauses/WhereClauseTest.php create mode 100644 tests/Unit/Database/Query/Concerns/HasGroupByClauseTest.php create mode 100644 tests/Unit/Database/Query/Concerns/HasHavingClauseTest.php create mode 100644 tests/Unit/Database/Query/DeleteTest.php create mode 100644 tests/Unit/Database/Query/Expressions/AggregateTest.php create mode 100644 tests/Unit/Database/Query/Expressions/MatchAgainstTest.php create mode 100644 tests/Unit/Database/Query/InsertTest.php create mode 100644 tests/Unit/Database/Query/UpdateTest.php diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b580659..0239895 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -3,7 +3,8 @@ name: Integration Tests on: [ pull_request ] jobs: - run: + connection: + name: Connection (MySQL 8.4) runs-on: ubuntu-latest services: mysql: @@ -73,8 +74,8 @@ jobs: FLUSH PRIVILEGES; " - - name: Run integration tests - run: vendor/bin/phpunit --testsuite Integration + - name: Run connection integration tests + run: vendor/bin/phpunit --testsuite Integration --group connection-factory env: DB_HOST: 127.0.0.1 DB_PORT: 3306 @@ -85,3 +86,49 @@ jobs: DB_DATABASE_SECONDARY: engine_test_port DB_SOCKET: /tmp/mysql_socket/mysqld.sock DB_DATABASE_SOCKET: engine_test_socket + + query-builder: + name: Query Builder (${{ matrix.image }}) + runs-on: ubuntu-latest + strategy: + matrix: + image: + - mysql:8.0 + - mysql:8.4 + - mariadb:10.11 + - mariadb:11.4 + services: + database: + image: ${{ matrix.image }} + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up php 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + + - name: Install dependencies + run: composer self-update && composer install && composer dump-autoload + + - name: Run query builder integration tests + run: vendor/bin/phpunit --testsuite Integration --group query-builder + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: engine_test + DB_USERNAME: engine + DB_PASSWORD: secret diff --git a/src/Database/Connection.php b/src/Database/Connection.php index b5cfa25..692aa52 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -6,6 +6,9 @@ use Engine\Database\Contracts\Expression; use Engine\Database\Exceptions\DatabaseException; use Engine\Database\Exceptions\QueryException; +use Engine\Database\Query\Cursor; +use Engine\Database\Query\Result; +use Engine\Database\Query\WriteResult; use JetBrains\PhpStorm\Language; use PDO; use PDOException; diff --git a/src/Database/Query/Clauses/JoinClause.php b/src/Database/Query/Clauses/JoinClause.php index 5251b6e..d847cf7 100644 --- a/src/Database/Query/Clauses/JoinClause.php +++ b/src/Database/Query/Clauses/JoinClause.php @@ -19,7 +19,7 @@ final class JoinClause implements Expression * @param string $operator * @param string $right * - * @return $this + * @return static */ public function on(string $left, string $operator, string $right): self { @@ -39,7 +39,7 @@ public function on(string $left, string $operator, string $right): self * @param string $operator * @param string $right * - * @return $this + * @return static */ public function orOn(string $left, string $operator, string $right): self { @@ -59,7 +59,7 @@ public function orOn(string $left, string $operator, string $right): self * @param string $operator * @param mixed $value * - * @return $this + * @return static */ public function where(string $column, string $operator, mixed $value): self { @@ -79,7 +79,7 @@ public function where(string $column, string $operator, mixed $value): self * @param string $operator * @param mixed $value * - * @return $this + * @return static */ public function orWhere(string $column, string $operator, mixed $value): self { diff --git a/src/Database/Query/Clauses/WhereClause.php b/src/Database/Query/Clauses/WhereClause.php index f281026..3c21959 100644 --- a/src/Database/Query/Clauses/WhereClause.php +++ b/src/Database/Query/Clauses/WhereClause.php @@ -7,6 +7,7 @@ use Engine\Database\Contracts\Expression; use Engine\Database\Exceptions\InvalidExpressionException; use Engine\Database\Query\Expressions; +use Engine\Database\Query\Expressions\MatchAgainst; final class WhereClause implements Expression { @@ -22,7 +23,7 @@ final class WhereClause implements Expression * @param string|null $operator * @param mixed|null $value * - * @return $this + * @return static */ public function where(Closure|string $column, ?string $operator = null, mixed $value = null): self { @@ -42,7 +43,7 @@ public function where(Closure|string $column, ?string $operator = null, mixed $v * @param string|null $operator * @param mixed|null $value * - * @return $this + * @return static */ public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): self { @@ -60,7 +61,7 @@ public function orWhere(Closure|string $column, ?string $operator = null, mixed * * @param string $column * - * @return $this + * @return static */ public function whereNull(string $column): self { @@ -74,7 +75,7 @@ public function whereNull(string $column): self * * @param string $column * - * @return $this + * @return static */ public function orWhereNull(string $column): self { @@ -88,7 +89,7 @@ public function orWhereNull(string $column): self * * @param string $column * - * @return $this + * @return static */ public function whereNotNull(string $column): self { @@ -97,13 +98,27 @@ public function whereNotNull(string $column): self return $this; } + /** + * Add an "or where not null" clause to the query. + * + * @param string $column + * + * @return static + */ + public function orWhereNotNull(string $column): self + { + $this->condition('OR', $column, 'IS NOT NULL', null); + + return $this; + } + /** * Add a "where in" clause to the query. * * @param string $column * @param array|Expression $values * - * @return $this + * @return static */ public function whereIn(string $column, array|Expression $values): self { @@ -126,7 +141,7 @@ public function whereIn(string $column, array|Expression $values): self * @param string $column * @param array|Expression $values * - * @return $this + * @return static */ public function whereNotIn(string $column, array|Expression $values): self { @@ -143,13 +158,59 @@ public function whereNotIn(string $column, array|Expression $values): self return $this; } + /** + * Add an "or where in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return static + */ + public function orWhereIn(string $column, array|Expression $values): self + { + if (is_array($values) && empty($values)) { + throw InvalidExpressionException::emptyInClause($column); + } + + if (is_array($values)) { + $this->condition('OR', $column, 'IN', $values); + } else { + $this->orWhereRaw("{$column} IN (" . $values->toSql() . ')', $values->getBindings()); + } + + return $this; + } + + /** + * Add an "or where not in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return static + */ + public function orWhereNotIn(string $column, array|Expression $values): self + { + if (is_array($values) && empty($values)) { + throw InvalidExpressionException::emptyInClause($column); + } + + if (is_array($values)) { + $this->condition('OR', $column, 'NOT IN', $values); + } else { + $this->orWhereRaw("{$column} NOT IN (" . $values->toSql() . ')', $values->getBindings()); + } + + return $this; + } + /** * Add a raw where clause to the query. * * @param string $sql * @param array $bindings * - * @return $this + * @return static */ public function whereRaw(string $sql, array $bindings = []): self { @@ -162,6 +223,65 @@ public function whereRaw(string $sql, array $bindings = []): self return $this; } + /** + * Add a raw "or where" clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return static + */ + public function orWhereRaw(string $sql, array $bindings = []): self + { + $this->conditions[] = [ + 'conjunction' => 'OR', + 'expression' => Expressions::raw($sql, $bindings), + 'grouped' => false, + ]; + + return $this; + } + + /** + * Add a full-text search where clause to the query. + * + * @param array $columns + * @param string $value + * @param string $mode + * + * @return static + */ + public function whereFullText(array $columns, string $value, string $mode = 'natural'): self + { + $this->conditions[] = [ + 'conjunction' => 'AND', + 'expression' => MatchAgainst::make($columns, $value, $mode), + 'grouped' => false, + ]; + + return $this; + } + + /** + * Add an "or" full-text search where clause to the query. + * + * @param array $columns + * @param string $value + * @param string $mode + * + * @return static + */ + public function orWhereFullText(array $columns, string $value, string $mode = 'natural'): self + { + $this->conditions[] = [ + 'conjunction' => 'OR', + 'expression' => MatchAgainst::make($columns, $value, $mode), + 'grouped' => false, + ]; + + return $this; + } + /** * Get the SQL representation of the expression. * diff --git a/src/Database/Query/Concerns/HasGroupByClause.php b/src/Database/Query/Concerns/HasGroupByClause.php new file mode 100644 index 0000000..05a404f --- /dev/null +++ b/src/Database/Query/Concerns/HasGroupByClause.php @@ -0,0 +1,58 @@ + + */ + private array $groups = []; + + /** + * Add columns to the group by clause. + * + * @param string|Expression ...$columns + * + * @return static + */ + public function groupBy(Expression|string ...$columns): static + { + array_push($this->groups, ...$columns); + + return $this; + } + + private function buildGroupByClause(): string + { + if (empty($this->groups)) { + return ''; + } + + $clauses = array_map( + fn (Expression|string $col) => $col instanceof Expression ? $col->toSql() : $col, + $this->groups, + ); + + return ' GROUP BY ' . implode(', ', $clauses); + } + + /** + * @return array + */ + private function getGroupByBindings(): array + { + $bindings = []; + + foreach ($this->groups as $group) { + if ($group instanceof Expression) { + $bindings = array_merge($bindings, $group->getBindings()); + } + } + + return $bindings; + } +} diff --git a/src/Database/Query/Concerns/HasHavingClause.php b/src/Database/Query/Concerns/HasHavingClause.php new file mode 100644 index 0000000..41965f2 --- /dev/null +++ b/src/Database/Query/Concerns/HasHavingClause.php @@ -0,0 +1,81 @@ + $this->havingClause ?? $this->havingClause = new WhereClause(); + } + + /** + * Add a having clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function having(Closure|string $column, ?string $operator = null, mixed $value = null): static + { + $this->havingClause->where($column, $operator, $value); + + return $this; + } + + /** + * Add an "or having" clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function orHaving(Closure|string $column, ?string $operator = null, mixed $value = null): static + { + $this->havingClause->orWhere($column, $operator, $value); + + return $this; + } + + /** + * Add a raw having clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return static + */ + public function havingRaw(string $sql, array $bindings = []): static + { + $this->havingClause->whereRaw($sql, $bindings); + + return $this; + } + + /** + * Add a raw "or having" clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return static + */ + public function orHavingRaw(string $sql, array $bindings = []): static + { + $this->havingClause->orWhereRaw($sql, $bindings); + + return $this; + } + + protected function hasHavingClause(): bool + { + return $this->havingClause->isEmpty() === false; + } +} diff --git a/src/Database/Query/Concerns/HasWhereClause.php b/src/Database/Query/Concerns/HasWhereClause.php index 6319108..5057a04 100644 --- a/src/Database/Query/Concerns/HasWhereClause.php +++ b/src/Database/Query/Concerns/HasWhereClause.php @@ -20,7 +20,7 @@ trait HasWhereClause * @param string|null $operator * @param mixed|null $value * - * @return $this + * @return static */ public function where(Closure|string $column, ?string $operator = null, mixed $value = null): static { @@ -36,7 +36,7 @@ public function where(Closure|string $column, ?string $operator = null, mixed $v * @param string|null $operator * @param mixed|null $value * - * @return $this + * @return static */ public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): static { @@ -50,7 +50,7 @@ public function orWhere(Closure|string $column, ?string $operator = null, mixed * * @param string $column * - * @return $this + * @return static */ public function whereNull(string $column): static { @@ -64,7 +64,7 @@ public function whereNull(string $column): static * * @param string $column * - * @return $this + * @return static */ public function orWhereNull(string $column): static { @@ -78,7 +78,7 @@ public function orWhereNull(string $column): static * * @param string $column * - * @return $this + * @return static */ public function whereNotNull(string $column): static { @@ -87,13 +87,27 @@ public function whereNotNull(string $column): static return $this; } + /** + * Add an "or where not null" clause to the query. + * + * @param string $column + * + * @return static + */ + public function orWhereNotNull(string $column): static + { + $this->whereClause->orWhereNotNull($column); + + return $this; + } + /** * Add a "where in" clause to the query. * * @param string $column * @param array|Expression $values * - * @return $this + * @return static */ public function whereIn(string $column, array|Expression $values): static { @@ -108,7 +122,7 @@ public function whereIn(string $column, array|Expression $values): static * @param string $column * @param array|Expression $values * - * @return $this + * @return static */ public function whereNotIn(string $column, array|Expression $values): static { @@ -117,13 +131,43 @@ public function whereNotIn(string $column, array|Expression $values): static return $this; } + /** + * Add an "or where in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return static + */ + public function orWhereIn(string $column, array|Expression $values): static + { + $this->whereClause->orWhereIn($column, $values); + + return $this; + } + + /** + * Add an "or where not in" clause to the query. + * + * @param string $column + * @param array|Expression $values + * + * @return static + */ + public function orWhereNotIn(string $column, array|Expression $values): static + { + $this->whereClause->orWhereNotIn($column, $values); + + return $this; + } + /** * Add a raw where clause to the query. * * @param string $sql * @param array $bindings * - * @return $this + * @return static */ public function whereRaw(string $sql, array $bindings = []): static { @@ -132,6 +176,53 @@ public function whereRaw(string $sql, array $bindings = []): static return $this; } + /** + * Add a raw "or where" clause to the query. + * + * @param string $sql + * @param array $bindings + * + * @return static + */ + public function orWhereRaw(string $sql, array $bindings = []): static + { + $this->whereClause->orWhereRaw($sql, $bindings); + + return $this; + } + + /** + * Add a full-text search where clause to the query. + * + * @param array $columns + * @param string $value + * @param string $mode + * + * @return static + */ + public function whereFullText(array $columns, string $value, string $mode = 'natural'): static + { + $this->whereClause->whereFullText($columns, $value, $mode); + + return $this; + } + + /** + * Add an "or" full-text search where clause to the query. + * + * @param array $columns + * @param string $value + * @param string $mode + * + * @return static + */ + public function orWhereFullText(array $columns, string $value, string $mode = 'natural'): static + { + $this->whereClause->orWhereFullText($columns, $value, $mode); + + return $this; + } + protected function hasWhereClause(): bool { return $this->whereClause->isEmpty() === false; diff --git a/src/Database/Query/Delete.php b/src/Database/Query/Delete.php index f0feba5..99ab04a 100644 --- a/src/Database/Query/Delete.php +++ b/src/Database/Query/Delete.php @@ -4,11 +4,15 @@ namespace Engine\Database\Query; use Engine\Database\Contracts\Query; +use Engine\Database\Query\Concerns\HasLimitClause; +use Engine\Database\Query\Concerns\HasOrderByClause; use Engine\Database\Query\Concerns\HasWhereClause; final class Delete implements Query { use HasWhereClause; + use HasOrderByClause; + use HasLimitClause; public static function from(string $table): self { @@ -29,7 +33,10 @@ public function toSql(): string { $where = $this->hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; - return "DELETE FROM {$this->table}" . $where; + return "DELETE FROM {$this->table}" + . $where + . $this->buildOrderByClause() + . $this->buildLimitClause(); } /** @@ -39,6 +46,9 @@ public function toSql(): string */ public function getBindings(): array { - return $this->whereClause->getBindings(); + return array_merge( + $this->whereClause->getBindings(), + $this->getOrderByBindings(), + ); } } diff --git a/src/Database/Query/Expressions.php b/src/Database/Query/Expressions.php index cc1feeb..bb70953 100644 --- a/src/Database/Query/Expressions.php +++ b/src/Database/Query/Expressions.php @@ -4,6 +4,7 @@ namespace Engine\Database\Query; use Engine\Database\Contracts\Expression; +use Engine\Database\Query\Expressions\Aggregate; use Engine\Database\Query\Expressions\ColumnEqualTo; use Engine\Database\Query\Expressions\ColumnGreaterThen; use Engine\Database\Query\Expressions\ColumnGreaterThenOrEqualTo; @@ -15,6 +16,7 @@ use Engine\Database\Query\Expressions\ColumnLessThanOrEqualTo; use Engine\Database\Query\Expressions\ColumnNotEqualTo; use Engine\Database\Query\Expressions\ColumnNotIn; +use Engine\Database\Query\Expressions\MatchAgainst; use Engine\Database\Query\Expressions\RawExpression; use InvalidArgumentException; @@ -48,4 +50,76 @@ public static function raw(string $sql, array $bindings): Expression { return RawExpression::make($sql, $bindings); } + + /** + * @param string|Expression $column + * + * @return Expression + */ + public static function count(Expression|string $column = '*'): Expression + { + return Aggregate::make('COUNT', $column); + } + + /** + * @param string|Expression $column + * + * @return Expression + */ + public static function sum(Expression|string $column): Expression + { + return Aggregate::make('SUM', $column); + } + + /** + * @param string|Expression $column + * + * @return Expression + */ + public static function min(Expression|string $column): Expression + { + return Aggregate::make('MIN', $column); + } + + /** + * @param string|Expression $column + * + * @return Expression + */ + public static function max(Expression|string $column): Expression + { + return Aggregate::make('MAX', $column); + } + + /** + * @param string|Expression $column + * + * @return Expression + */ + public static function avg(Expression|string $column): Expression + { + return Aggregate::make('AVG', $column); + } + + /** + * @param array $columns + * @param string $value + * + * @return Expression + */ + public static function match(array $columns, string $value): Expression + { + return MatchAgainst::make($columns, $value); + } + + /** + * @param array $columns + * @param string $value + * + * @return Expression + */ + public static function matchBoolean(array $columns, string $value): Expression + { + return MatchAgainst::make($columns, $value, 'boolean'); + } } diff --git a/src/Database/Query/Expressions/Aggregate.php b/src/Database/Query/Expressions/Aggregate.php new file mode 100644 index 0000000..7137743 --- /dev/null +++ b/src/Database/Query/Expressions/Aggregate.php @@ -0,0 +1,52 @@ +column instanceof Expression ? $this->column->toSql() : $this->column; + + return "{$this->function}({$col})"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return $this->column instanceof Expression ? $this->column->getBindings() : []; + } +} diff --git a/src/Database/Query/Expressions/MatchAgainst.php b/src/Database/Query/Expressions/MatchAgainst.php new file mode 100644 index 0000000..cd9d993 --- /dev/null +++ b/src/Database/Query/Expressions/MatchAgainst.php @@ -0,0 +1,59 @@ + $columns + * @param string $value + * @param string $mode + * + * @return self + */ + public static function make(array $columns, string $value, string $mode = 'natural'): self + { + return new self($columns, $value, $mode); + } + + /** + * @param array $columns + * @param string $value + * @param string $mode + */ + private function __construct( + private array $columns, + private string $value, + private string $mode, + ) { + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $columns = implode(', ', $this->columns); + $mode = match ($this->mode) { + 'boolean' => ' IN BOOLEAN MODE', + default => ' IN NATURAL LANGUAGE MODE', + }; + + return "MATCH({$columns}) AGAINST(?{$mode})"; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return [$this->value]; + } +} diff --git a/src/Database/Query/Insert.php b/src/Database/Query/Insert.php index 5708a27..c600b83 100644 --- a/src/Database/Query/Insert.php +++ b/src/Database/Query/Insert.php @@ -3,6 +3,7 @@ namespace Engine\Database\Query; +use Engine\Database\Contracts\Expression; use Engine\Database\Contracts\Query; final class Insert implements Query @@ -13,9 +14,18 @@ public static function into(string $table): self } /** - * @var array + * @var list> */ - private array $values = []; + private array $rows = []; + + private bool $ignore = false; + + private bool $replace = false; + + /** + * @var array + */ + private array $upsertValues = []; private function __construct( private string $table, @@ -23,15 +33,53 @@ private function __construct( } /** - * Set the values to insert. + * Add a row of values to insert. * * @param array $values * - * @return $this + * @return static */ public function values(array $values): self { - $this->values = $values; + $this->rows[] = $values; + + return $this; + } + + /** + * Set the query to use INSERT IGNORE. + * + * @return static + */ + public function ignore(): self + { + $this->ignore = true; + + return $this; + } + + /** + * Set the query to use REPLACE INTO instead of INSERT INTO. + * + * @return static + */ + public function replace(): self + { + $this->replace = true; + + return $this; + } + + /** + * Set the ON DUPLICATE KEY UPDATE values. + * + * @param array $values + * + * @return static + */ + public function upsert(array $values): self + { + $this->upsertValues = $values; return $this; } @@ -43,10 +91,35 @@ public function values(array $values): self */ public function toSql(): string { - $columns = implode(', ', array_keys($this->values)); - $placeholders = implode(', ', array_fill(0, count($this->values), '?')); + $columns = implode(', ', array_keys($this->rows[0])); + $placeholders = implode(', ', array_fill(0, count($this->rows[0]), '?')); + $tuples = implode(', ', array_fill(0, count($this->rows), "({$placeholders})")); + + if ($this->replace) { + $prefix = 'REPLACE INTO'; + } else if ($this->ignore) { + $prefix = 'INSERT IGNORE INTO'; + } else { + $prefix = 'INSERT INTO'; + } + + $sql = "{$prefix} {$this->table} ({$columns}) VALUES {$tuples}"; + + if (! empty($this->upsertValues)) { + $updates = []; + + foreach ($this->upsertValues as $column => $value) { + if ($value instanceof Expression) { + $updates[] = "{$column} = {$value->toSql()}"; + } else { + $updates[] = "{$column} = ?"; + } + } - return "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; + $sql .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates); + } + + return $sql; } /** @@ -56,6 +129,20 @@ public function toSql(): string */ public function getBindings(): array { - return array_values($this->values); + $bindings = []; + + foreach ($this->rows as $row) { + array_push($bindings, ...array_values($row)); + } + + foreach ($this->upsertValues as $value) { + if ($value instanceof Expression) { + array_push($bindings, ...$value->getBindings()); + } else { + $bindings[] = $value; + } + } + + return $bindings; } } diff --git a/src/Database/Query/Select.php b/src/Database/Query/Select.php index 18839b5..3b1ceab 100644 --- a/src/Database/Query/Select.php +++ b/src/Database/Query/Select.php @@ -5,6 +5,8 @@ use Engine\Database\Contracts\Expression; use Engine\Database\Contracts\Query; +use Engine\Database\Query\Concerns\HasGroupByClause; +use Engine\Database\Query\Concerns\HasHavingClause; use Engine\Database\Query\Concerns\HasJoinClause; use Engine\Database\Query\Concerns\HasLimitClause; use Engine\Database\Query\Concerns\HasOrderByClause; @@ -16,6 +18,8 @@ final class Select implements Query use HasJoinClause; use HasOrderByClause; use HasLimitClause; + use HasGroupByClause; + use HasHavingClause; public static function from(Expression|string $table): self { @@ -88,10 +92,13 @@ public function toSql(): string $distinct = $this->distinct ? 'DISTINCT ' : ''; $table = $this->table instanceof Expression ? '(' . $this->table->toSql() . ')' : $this->table; $where = $this->hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; + $having = $this->hasHavingClause() ? ' HAVING ' . $this->havingClause->toSql() : ''; return "SELECT {$distinct}{$columns} FROM {$table}" . $this->buildJoinClause() . $where + . $this->buildGroupByClause() + . $having . $this->buildOrderByClause() . $this->buildLimitClause(); } @@ -121,6 +128,8 @@ public function getBindings(): array $bindings, $this->getJoinBindings(), $this->whereClause->getBindings(), + $this->getGroupByBindings(), + $this->havingClause->getBindings(), $this->getOrderByBindings(), ); } diff --git a/src/Database/Query/Update.php b/src/Database/Query/Update.php index e643708..52d2ec1 100644 --- a/src/Database/Query/Update.php +++ b/src/Database/Query/Update.php @@ -3,12 +3,17 @@ namespace Engine\Database\Query; +use Engine\Database\Contracts\Expression; use Engine\Database\Contracts\Query; +use Engine\Database\Query\Concerns\HasLimitClause; +use Engine\Database\Query\Concerns\HasOrderByClause; use Engine\Database\Query\Concerns\HasWhereClause; final class Update implements Query { use HasWhereClause; + use HasOrderByClause; + use HasLimitClause; public static function table(string $table): self { @@ -16,7 +21,7 @@ public static function table(string $table): self } /** - * @var array + * @var array */ private array $sets = []; @@ -28,9 +33,9 @@ private function __construct( /** * Set the column values to update. * - * @param array $values + * @param array $values * - * @return $this + * @return static */ public function set(array $values): self { @@ -48,13 +53,20 @@ public function toSql(): string { $setClauses = []; - foreach (array_keys($this->sets) as $column) { - $setClauses[] = "{$column} = ?"; + foreach ($this->sets as $column => $value) { + if ($value instanceof Expression) { + $setClauses[] = "{$column} = {$value->toSql()}"; + } else { + $setClauses[] = "{$column} = ?"; + } } $where = $this->hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; - return "UPDATE {$this->table} SET " . implode(', ', $setClauses) . $where; + return "UPDATE {$this->table} SET " . implode(', ', $setClauses) + . $where + . $this->buildOrderByClause() + . $this->buildLimitClause(); } /** @@ -64,9 +76,20 @@ public function toSql(): string */ public function getBindings(): array { + $setBindings = []; + + foreach ($this->sets as $value) { + if ($value instanceof Expression) { + array_push($setBindings, ...$value->getBindings()); + } else { + $setBindings[] = $value; + } + } + return array_merge( - array_values($this->sets), + $setBindings, $this->whereClause->getBindings(), + $this->getOrderByBindings(), ); } } diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php new file mode 100644 index 0000000..f354594 --- /dev/null +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -0,0 +1,500 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], + ); + + self::$connection = new Connection('test', $pdo); + + self::$connection->execute(' + CREATE TABLE qb_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + status VARCHAR(50) NOT NULL DEFAULT \'active\', + age INT NULL + ) ENGINE=InnoDB + '); + + self::$connection->execute(' + CREATE TABLE qb_posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + FULLTEXT INDEX ft_title_body (title, body) + ) ENGINE=InnoDB + '); + + self::$connection->execute(' + CREATE TABLE qb_counters ( + name VARCHAR(255) NOT NULL PRIMARY KEY, + count INT NOT NULL DEFAULT 0 + ) ENGINE=InnoDB + '); + } + + public static function tearDownAfterClass(): void + { + self::$connection->execute('DROP TABLE IF EXISTS qb_users'); + self::$connection->execute('DROP TABLE IF EXISTS qb_posts'); + self::$connection->execute('DROP TABLE IF EXISTS qb_counters'); + } + + protected function setUp(): void + { + self::$connection->execute('DELETE FROM qb_users'); + self::$connection->execute('DELETE FROM qb_posts'); + self::$connection->execute('DELETE FROM qb_counters'); + self::$connection->execute('ALTER TABLE qb_users AUTO_INCREMENT = 1'); + self::$connection->execute('ALTER TABLE qb_posts AUTO_INCREMENT = 1'); + } + + // ------------------------------------------------------------------------- + // Insert + // ------------------------------------------------------------------------- + + /** + * - Insert a single row and verify it exists with correct data. + */ + #[Test] + public function insertSingleRow(): void + { + $insert = Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 30]) + ; + + $writeResult = self::$connection->execute($insert); + + $this->assertTrue($writeResult->wasSuccessful()); + + $row = self::$connection->query( + Select::from('qb_users')->where('email', '=', 'alice@example.com'), + )->first(); + + $this->assertNotNull($row); + $this->assertSame('Alice', $row->string('name')); + $this->assertSame(30, $row->int('age')); + } + + /** + * - Insert multiple rows via chained values() and verify count is 3. + */ + #[Test] + public function insertBulk(): void + { + $insert = Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30]) + ->values(['name' => 'Carol', 'email' => 'carol@example.com', 'age' => 35]) + ; + + self::$connection->execute($insert); + + $rows = self::$connection->query(Select::from('qb_users'))->all(); + + $this->assertCount(3, $rows); + } + + /** + * - INSERT IGNORE with duplicate email leaves count at 1 and preserves original name. + */ + #[Test] + public function insertIgnore(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]), + ); + + self::$connection->execute( + Insert::into('qb_users') + ->ignore() + ->values(['name' => 'Alice Duplicate', 'email' => 'alice@example.com', 'age' => 26]), + ); + + $rows = self::$connection->query(Select::from('qb_users'))->all(); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]->string('name')); + } + + /** + * - REPLACE INTO with same primary key updates the row with the new count. + */ + #[Test] + public function replaceInto(): void + { + self::$connection->execute( + Insert::into('qb_counters') + ->values(['name' => 'hits', 'count' => 5]), + ); + + self::$connection->execute( + Insert::into('qb_counters') + ->replace() + ->values(['name' => 'hits', 'count' => 99]), + ); + + $row = self::$connection->query( + Select::from('qb_counters')->where('name', '=', 'hits'), + )->first(); + + $this->assertNotNull($row); + $this->assertSame(99, $row->int('count')); + } + + /** + * - Upsert increments an existing counter value via RawExpression. + */ + #[Test] + public function upsert(): void + { + self::$connection->execute( + Insert::into('qb_counters') + ->values(['name' => 'hits', 'count' => 1]), + ); + + self::$connection->execute( + Insert::into('qb_counters') + ->values(['name' => 'hits', 'count' => 1]) + ->upsert(['count' => RawExpression::make('count + 1', [])]), + ); + + $row = self::$connection->query( + Select::from('qb_counters')->where('name', '=', 'hits'), + )->first(); + + $this->assertNotNull($row); + $this->assertSame(2, $row->int('count')); + } + + // ------------------------------------------------------------------------- + // Select + // ------------------------------------------------------------------------- + + /** + * - Select all rows returns the correct number of results. + */ + #[Test] + public function selectAll(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30]), + ); + + $rows = self::$connection->query(Select::from('qb_users'))->all(); + + $this->assertCount(2, $rows); + } + + /** + * - Select with where clause returns only matching rows. + */ + #[Test] + public function selectWithWhere(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'status' => 'active', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'status' => 'inactive', 'age' => 30]), + ); + + $rows = self::$connection->query( + Select::from('qb_users')->where('status', '=', 'active'), + )->all(); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]->string('name')); + } + + /** + * - Group by with having returns only groups matching the condition. + */ + #[Test] + public function selectWithGroupByAndHaving(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'status' => 'active', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'status' => 'active', 'age' => 30]) + ->values(['name' => 'Carol', 'email' => 'carol@example.com', 'status' => 'inactive', 'age' => 35]), + ); + + $rows = self::$connection->query( + Select::from('qb_users') + ->columns('status', Expressions::count('*')) + ->groupBy('status') + ->havingRaw('COUNT(*) > 1'), + )->all(); + + $this->assertCount(1, $rows); + $this->assertSame('active', $rows[0]->string('status')); + } + + /** + * - Order by with limit returns the first N rows in the correct order. + */ + #[Test] + public function selectWithOrderByAndLimit(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Charlie', 'email' => 'charlie@example.com', 'age' => 28]) + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30]), + ); + + $rows = self::$connection->query( + Select::from('qb_users')->orderBy('name', 'asc')->limit(2), + )->all(); + + $this->assertCount(2, $rows); + $this->assertSame('Alice', $rows[0]->string('name')); + $this->assertSame('Bob', $rows[1]->string('name')); + } + + /** + * - Aggregate functions return correct COUNT, MIN, MAX, AVG, SUM values. + */ + #[Test] + public function selectAggregates(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 35]) + ->values(['name' => 'Carol', 'email' => 'carol@example.com', 'age' => 30]), + ); + + $row = self::$connection->query( + Select::from('qb_users')->columns( + Expressions::count('*'), + Expressions::min('age'), + Expressions::max('age'), + Expressions::avg('age'), + Expressions::sum('age'), + ), + )->first(); + + $this->assertNotNull($row); + $this->assertSame(3, $row->int('COUNT(*)')); + $this->assertSame(25, $row->int('MIN(age)')); + $this->assertSame(35, $row->int('MAX(age)')); + $this->assertSame(90, $row->int('SUM(age)')); + + $avg = $row->float('AVG(age)'); + $this->assertGreaterThanOrEqual(30.0, $avg); + } + + /** + * - Full-text search returns rows matching the search term. + */ + #[Test] + public function selectFullTextSearch(): void + { + self::$connection->execute( + Insert::into('qb_posts') + ->values(['user_id' => 1, 'title' => 'Introduction to PHP programming', 'body' => 'PHP is a popular language for web development.']) + ->values(['user_id' => 1, 'title' => 'Advanced PHP techniques', 'body' => 'PHP programming concepts for experienced developers.']) + ->values(['user_id' => 2, 'title' => 'Python basics', 'body' => 'Python is a versatile programming language.']), + ); + + $rows = self::$connection->query( + Select::from('qb_posts')->whereFullText(['title', 'body'], 'PHP programming'), + )->all(); + + $this->assertGreaterThanOrEqual(1, count($rows)); + + $titles = array_map(fn ($row) => $row->string('title'), $rows); + $this->assertContains('Introduction to PHP programming', $titles); + } + + // ------------------------------------------------------------------------- + // Update + // ------------------------------------------------------------------------- + + /** + * - Basic update changes the target row and reports 1 affected row. + */ + #[Test] + public function updateBasic(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'status' => 'active', 'age' => 25]), + ); + + $writeResult = self::$connection->execute( + Update::table('qb_users') + ->set(['status' => 'inactive']) + ->where('email', '=', 'alice@example.com'), + ); + + $this->assertSame(1, $writeResult->affectedRows()); + + $row = self::$connection->query( + Select::from('qb_users')->where('email', '=', 'alice@example.com'), + )->first(); + + $this->assertNotNull($row); + $this->assertSame('inactive', $row->string('status')); + } + + /** + * - Update with a RawExpression increments a value in place. + */ + #[Test] + public function updateWithExpression(): void + { + self::$connection->execute( + Insert::into('qb_counters') + ->values(['name' => 'hits', 'count' => 10]), + ); + + self::$connection->execute( + Update::table('qb_counters') + ->set(['count' => RawExpression::make('count + 5', [])]) + ->where('name', '=', 'hits'), + ); + + $row = self::$connection->query( + Select::from('qb_counters')->where('name', '=', 'hits'), + )->first(); + + $this->assertNotNull($row); + $this->assertSame(15, $row->int('count')); + } + + /** + * - Update with order by and limit affects only the first alphabetical row. + */ + #[Test] + public function updateWithOrderByAndLimit(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Charlie', 'email' => 'charlie@example.com', 'status' => 'active', 'age' => 28]) + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'status' => 'active', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'status' => 'active', 'age' => 30]), + ); + + self::$connection->execute( + Update::table('qb_users') + ->set(['status' => 'inactive']) + ->orderBy('name', 'asc') + ->limit(1), + ); + + $inactive = self::$connection->query( + Select::from('qb_users')->where('status', '=', 'inactive'), + )->all(); + + $this->assertCount(1, $inactive); + $this->assertSame('Alice', $inactive[0]->string('name')); + } + + // ------------------------------------------------------------------------- + // Delete + // ------------------------------------------------------------------------- + + /** + * - Basic delete removes the target row and reports 1 affected row. + */ + #[Test] + public function deleteBasic(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30]), + ); + + $writeResult = self::$connection->execute( + Delete::from('qb_users')->where('email', '=', 'alice@example.com'), + ); + + $this->assertSame(1, $writeResult->affectedRows()); + + $rows = self::$connection->query(Select::from('qb_users'))->all(); + + $this->assertCount(1, $rows); + $this->assertSame('Bob', $rows[0]->string('name')); + } + + /** + * - Delete with order by and limit removes only the first alphabetical row. + */ + #[Test] + public function deleteWithOrderByAndLimit(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Charlie', 'email' => 'charlie@example.com', 'age' => 28]) + ->values(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 25]) + ->values(['name' => 'Bob', 'email' => 'bob@example.com', 'age' => 30]), + ); + + self::$connection->execute( + Delete::from('qb_users')->orderBy('name', 'asc')->limit(1), + ); + + $rows = self::$connection->query(Select::from('qb_users'))->all(); + + $this->assertCount(2, $rows); + + $names = array_map(fn ($row) => $row->string('name'), $rows); + $this->assertFalse(in_array('Alice', $names, true)); + } + + // ------------------------------------------------------------------------- + // Raw + // ------------------------------------------------------------------------- + + /** + * - Raw SQL query executes and returns the expected result. + */ + #[Test] + public function rawQuery(): void + { + $row = self::$connection->query('SELECT 1 + 1 AS result')->first(); + + $this->assertNotNull($row); + $this->assertSame(2, $row->int('result')); + } +} diff --git a/tests/Unit/Database/Query/Clauses/WhereClauseTest.php b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php new file mode 100644 index 0000000..d451726 --- /dev/null +++ b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php @@ -0,0 +1,558 @@ +where('name', '=', 'John'); + + $this->assertSame('name = ?', $clause->toSql()); + $this->assertSame(['John'], $clause->getBindings()); + } + + /** + * - where() with a closure produces grouped SQL wrapped in parentheses. + */ + #[Test] + public function whereWithClosureProducesGroupedSql(): void + { + $clause = new WhereClause(); + $clause->where(function (WhereClause $query) { + $query->where('a', '=', 1); + $query->where('b', '=', 2); + }); + + $this->assertSame('(a = ? AND b = ?)', $clause->toSql()); + $this->assertSame([1, 2], $clause->getBindings()); + } + + /** + * - where() with an empty closure throws InvalidExpressionException. + */ + #[Test] + public function whereWithEmptyClosureThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->where(function (WhereClause $query) { + // intentionally empty + }); + } + + // ------------------------------------------------------------------------- + // orWhere() + // ------------------------------------------------------------------------- + + /** + * - orWhere() uses OR conjunction between conditions. + */ + #[Test] + public function orWhereUsesOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('name', '=', 'John'); + $clause->orWhere('name', '=', 'Jane'); + + $this->assertSame('name = ? OR name = ?', $clause->toSql()); + $this->assertSame(['John', 'Jane'], $clause->getBindings()); + } + + /** + * - orWhere() with a closure produces grouped SQL with OR conjunction. + */ + #[Test] + public function orWhereWithClosureProducesGroupedSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhere(function (WhereClause $query) { + $query->where('role', '=', 'admin'); + $query->where('verified', '=', true); + }); + + $this->assertSame('status = ? OR (role = ? AND verified = ?)', $clause->toSql()); + $this->assertSame(['active', 'admin', true], $clause->getBindings()); + } + + /** + * - orWhere() with an empty closure throws InvalidExpressionException. + */ + #[Test] + public function orWhereWithEmptyClosureThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->orWhere(function (WhereClause $query) { + // intentionally empty + }); + } + + // ------------------------------------------------------------------------- + // whereNull() / orWhereNull() / whereNotNull() + // ------------------------------------------------------------------------- + + /** + * - whereNull() produces IS NULL SQL with no bindings. + */ + #[Test] + public function whereNullProducesIsNullSql(): void + { + $clause = new WhereClause(); + $clause->whereNull('deleted_at'); + + $this->assertSame('deleted_at IS NULL', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + /** + * - orWhereNull() produces IS NULL SQL with OR conjunction. + */ + #[Test] + public function orWhereNullProducesIsNullSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereNull('deleted_at'); + + $this->assertSame('status = ? OR deleted_at IS NULL', $clause->toSql()); + $this->assertSame(['active'], $clause->getBindings()); + } + + /** + * - whereNotNull() produces IS NOT NULL SQL with no bindings. + */ + #[Test] + public function whereNotNullProducesIsNotNullSql(): void + { + $clause = new WhereClause(); + $clause->whereNotNull('email'); + + $this->assertSame('email IS NOT NULL', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + // ------------------------------------------------------------------------- + // orWhereNotNull() + // ------------------------------------------------------------------------- + + /** + * - orWhereNotNull() produces IS NOT NULL SQL with OR conjunction. + */ + #[Test] + public function orWhereNotNullProducesIsNotNullSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereNotNull('email'); + + $this->assertSame('status = ? OR email IS NOT NULL', $clause->toSql()); + $this->assertSame(['active'], $clause->getBindings()); + } + + // ------------------------------------------------------------------------- + // whereIn() / whereNotIn() + // ------------------------------------------------------------------------- + + /** + * - whereIn() with an array produces IN SQL with placeholders. + */ + #[Test] + public function whereInWithArrayProducesInSql(): void + { + $clause = new WhereClause(); + $clause->whereIn('id', [1, 2, 3]); + + $this->assertSame('id IN (?, ?, ?)', $clause->toSql()); + $this->assertSame([1, 2, 3], $clause->getBindings()); + } + + /** + * - whereIn() with an Expression embeds the subquery SQL. + */ + #[Test] + public function whereInWithExpressionEmbedsSubquerySql(): void + { + $subquery = RawExpression::make('SELECT id FROM other WHERE active = ?', [true]); + + $clause = new WhereClause(); + $clause->whereIn('id', $subquery); + + $this->assertSame('id IN (SELECT id FROM other WHERE active = ?)', $clause->toSql()); + $this->assertSame([true], $clause->getBindings()); + } + + /** + * - whereIn() with an empty array throws InvalidExpressionException. + */ + #[Test] + public function whereInWithEmptyArrayThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->whereIn('id', []); + } + + /** + * - whereNotIn() with an array produces NOT IN SQL with placeholders. + */ + #[Test] + public function whereNotInWithArrayProducesNotInSql(): void + { + $clause = new WhereClause(); + $clause->whereNotIn('id', [4, 5]); + + $this->assertSame('id NOT IN (?, ?)', $clause->toSql()); + $this->assertSame([4, 5], $clause->getBindings()); + } + + /** + * - whereNotIn() with an Expression embeds the subquery SQL. + */ + #[Test] + public function whereNotInWithExpressionEmbedsSubquerySql(): void + { + $subquery = RawExpression::make('SELECT id FROM banned', []); + + $clause = new WhereClause(); + $clause->whereNotIn('user_id', $subquery); + + $this->assertSame('user_id NOT IN (SELECT id FROM banned)', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + /** + * - whereNotIn() with an empty array throws InvalidExpressionException. + */ + #[Test] + public function whereNotInWithEmptyArrayThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->whereNotIn('id', []); + } + + // ------------------------------------------------------------------------- + // orWhereIn() + // ------------------------------------------------------------------------- + + /** + * - orWhereIn() with an array produces IN SQL with OR conjunction. + */ + #[Test] + public function orWhereInWithArrayProducesInSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereIn('id', [1, 2, 3]); + + $this->assertSame('status = ? OR id IN (?, ?, ?)', $clause->toSql()); + $this->assertSame(['active', 1, 2, 3], $clause->getBindings()); + } + + /** + * - orWhereIn() with an Expression embeds the subquery SQL with OR conjunction. + */ + #[Test] + public function orWhereInWithExpressionEmbedsSubquerySqlWithOrConjunction(): void + { + $subquery = RawExpression::make('SELECT id FROM other WHERE active = ?', [true]); + + $clause = new WhereClause(); + $clause->where('status', '=', 'banned'); + $clause->orWhereIn('id', $subquery); + + $this->assertSame('status = ? OR id IN (SELECT id FROM other WHERE active = ?)', $clause->toSql()); + $this->assertSame(['banned', true], $clause->getBindings()); + } + + /** + * - orWhereIn() with an empty array throws InvalidExpressionException. + */ + #[Test] + public function orWhereInWithEmptyArrayThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->orWhereIn('id', []); + } + + // ------------------------------------------------------------------------- + // orWhereNotIn() + // ------------------------------------------------------------------------- + + /** + * - orWhereNotIn() with an array produces NOT IN SQL with OR conjunction. + */ + #[Test] + public function orWhereNotInWithArrayProducesNotInSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereNotIn('id', [4, 5]); + + $this->assertSame('status = ? OR id NOT IN (?, ?)', $clause->toSql()); + $this->assertSame(['active', 4, 5], $clause->getBindings()); + } + + /** + * - orWhereNotIn() with an Expression embeds the subquery SQL with OR conjunction. + */ + #[Test] + public function orWhereNotInWithExpressionEmbedsSubquerySqlWithOrConjunction(): void + { + $subquery = RawExpression::make('SELECT id FROM banned', []); + + $clause = new WhereClause(); + $clause->where('active', '=', true); + $clause->orWhereNotIn('user_id', $subquery); + + $this->assertSame('active = ? OR user_id NOT IN (SELECT id FROM banned)', $clause->toSql()); + $this->assertSame([true], $clause->getBindings()); + } + + /** + * - orWhereNotIn() with an empty array throws InvalidExpressionException. + */ + #[Test] + public function orWhereNotInWithEmptyArrayThrowsException(): void + { + $this->expectException(InvalidExpressionException::class); + + $clause = new WhereClause(); + $clause->orWhereNotIn('id', []); + } + + // ------------------------------------------------------------------------- + // whereRaw() + // ------------------------------------------------------------------------- + + /** + * - whereRaw() produces the exact SQL string with the given bindings. + */ + #[Test] + public function whereRawProducesExactSqlWithBindings(): void + { + $clause = new WhereClause(); + $clause->whereRaw('LOWER(name) = ?', ['john']); + + $this->assertSame('LOWER(name) = ?', $clause->toSql()); + $this->assertSame(['john'], $clause->getBindings()); + } + + /** + * - whereRaw() with no bindings produces SQL with an empty bindings array. + */ + #[Test] + public function whereRawWithNoBindingsProducesSqlWithEmptyBindings(): void + { + $clause = new WhereClause(); + $clause->whereRaw('1 = 1'); + + $this->assertSame('1 = 1', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + /** + * - whereRaw() combined with other clauses uses AND conjunction. + */ + #[Test] + public function whereRawCombinedWithOtherClausesUsesAndConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->whereRaw('LOWER(name) = ?', ['john']); + + $this->assertSame('status = ? AND LOWER(name) = ?', $clause->toSql()); + $this->assertSame(['active', 'john'], $clause->getBindings()); + } + + // ------------------------------------------------------------------------- + // orWhereRaw() + // ------------------------------------------------------------------------- + + /** + * - orWhereRaw() produces the exact SQL string with OR conjunction. + */ + #[Test] + public function orWhereRawProducesExactSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereRaw('LOWER(name) = ?', ['john']); + + $this->assertSame('status = ? OR LOWER(name) = ?', $clause->toSql()); + $this->assertSame(['active', 'john'], $clause->getBindings()); + } + + /** + * - orWhereRaw() with no bindings produces SQL with an empty bindings array. + */ + #[Test] + public function orWhereRawWithNoBindingsProducesSqlWithOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('id', '=', 1); + $clause->orWhereRaw('1 = 1'); + + $this->assertSame('id = ? OR 1 = 1', $clause->toSql()); + $this->assertSame([1], $clause->getBindings()); + } + + // ------------------------------------------------------------------------- + // whereFullText() / orWhereFullText() + // ------------------------------------------------------------------------- + + /** + * - whereFullText() in natural language mode produces correct SQL. + */ + #[Test] + public function whereFullTextNaturalModeProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->whereFullText(['title', 'body'], 'search term'); + + $this->assertSame('MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)', $clause->toSql()); + $this->assertSame(['search term'], $clause->getBindings()); + } + + /** + * - whereFullText() in boolean mode produces correct SQL. + */ + #[Test] + public function whereFullTextBooleanModeProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->whereFullText(['title'], '+required -excluded', 'boolean'); + + $this->assertSame('MATCH(title) AGAINST(? IN BOOLEAN MODE)', $clause->toSql()); + $this->assertSame(['+required -excluded'], $clause->getBindings()); + } + + /** + * - whereFullText() uses AND conjunction with other conditions. + */ + #[Test] + public function whereFullTextUsesAndConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->whereFullText(['title'], 'search'); + + $this->assertSame('status = ? AND MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE)', $clause->toSql()); + $this->assertSame(['active', 'search'], $clause->getBindings()); + } + + /** + * - orWhereFullText() uses OR conjunction with other conditions. + */ + #[Test] + public function orWhereFullTextUsesOrConjunction(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->orWhereFullText(['title', 'body'], 'search term'); + + $this->assertSame('status = ? OR MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)', $clause->toSql()); + $this->assertSame(['active', 'search term'], $clause->getBindings()); + } + + /** + * - orWhereFullText() in boolean mode produces correct SQL. + */ + #[Test] + public function orWhereFullTextBooleanModeProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('id', '=', 1); + $clause->orWhereFullText(['title'], '+php', 'boolean'); + + $this->assertSame('id = ? OR MATCH(title) AGAINST(? IN BOOLEAN MODE)', $clause->toSql()); + $this->assertSame([1, '+php'], $clause->getBindings()); + } + + // ------------------------------------------------------------------------- + // isEmpty() + // ------------------------------------------------------------------------- + + /** + * - isEmpty() returns true when no conditions have been added. + */ + #[Test] + public function isEmptyReturnsTrueWhenNoConditions(): void + { + $clause = new WhereClause(); + + $this->assertTrue($clause->isEmpty()); + } + + /** + * - isEmpty() returns false after a condition has been added. + */ + #[Test] + public function isEmptyReturnsFalseAfterConditionAdded(): void + { + $clause = new WhereClause(); + $clause->where('id', '=', 1); + + $this->assertFalse($clause->isEmpty()); + } + + // ------------------------------------------------------------------------- + // Chained combinations + // ------------------------------------------------------------------------- + + /** + * - Multiple chained where clauses produce correct combined SQL with AND conjunctions. + */ + #[Test] + public function chainedWhereClausesProduceCorrectCombinedSql(): void + { + $clause = new WhereClause(); + $clause->where('status', '=', 'active'); + $clause->whereNotNull('email'); + $clause->whereIn('role', ['admin', 'editor']); + + $this->assertSame('status = ? AND email IS NOT NULL AND role IN (?, ?)', $clause->toSql()); + $this->assertSame(['active', 'admin', 'editor'], $clause->getBindings()); + } + + /** + * - Mixed AND and OR conditions produce correct SQL with proper conjunctions. + */ + #[Test] + public function mixedAndOrConditionsProduceCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('active', '=', true); + $clause->orWhere('role', '=', 'admin'); + $clause->where('verified', '=', true); + + $this->assertSame('active = ? OR role = ? AND verified = ?', $clause->toSql()); + $this->assertSame([true, 'admin', true], $clause->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/Concerns/HasGroupByClauseTest.php b/tests/Unit/Database/Query/Concerns/HasGroupByClauseTest.php new file mode 100644 index 0000000..86bbf07 --- /dev/null +++ b/tests/Unit/Database/Query/Concerns/HasGroupByClauseTest.php @@ -0,0 +1,130 @@ +makeQuery(); + $query->groupBy('status'); + + $this->assertSame('GROUP BY status', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + /** + * - groupBy() with multiple columns produces comma-separated SQL. + */ + #[Test] + public function groupByMultipleColumnsProducesCommaSeparatedSql(): void + { + $query = $this->makeQuery(); + $query->groupBy('status', 'role'); + + $this->assertSame('GROUP BY status, role', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + /** + * - groupBy() with an Expression column embeds its SQL and collects bindings. + */ + #[Test] + public function groupByWithExpressionEmbedsSubquerySql(): void + { + $expr = RawExpression::make('YEAR(created_at)', []); + $query = $this->makeQuery(); + $query->groupBy($expr); + + $this->assertSame('GROUP BY YEAR(created_at)', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + /** + * - groupBy() with an Expression collects its bindings. + */ + #[Test] + public function groupByWithExpressionCollectsBindings(): void + { + $expr = RawExpression::make('IF(status = ?, 1, 0)', ['active']); + $query = $this->makeQuery(); + $query->groupBy($expr); + + $this->assertSame('GROUP BY IF(status = ?, 1, 0)', $query->sql()); + $this->assertSame(['active'], $query->bindings()); + } + + /** + * - Chained groupBy() calls accumulate columns. + */ + #[Test] + public function chainedGroupByCallsAccumulateColumns(): void + { + $query = $this->makeQuery(); + $query->groupBy('status'); + $query->groupBy('role'); + + $this->assertSame('GROUP BY status, role', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + /** + * - groupBy() with multiple Expressions accumulates all bindings. + */ + #[Test] + public function groupByWithMultipleExpressionsAccumulatesBindings(): void + { + $expr1 = RawExpression::make('IF(status = ?, 1, 0)', ['active']); + $expr2 = RawExpression::make('IF(role = ?, 1, 0)', ['admin']); + $query = $this->makeQuery(); + $query->groupBy($expr1, $expr2); + + $this->assertSame('GROUP BY IF(status = ?, 1, 0), IF(role = ?, 1, 0)', $query->sql()); + $this->assertSame(['active', 'admin'], $query->bindings()); + } + + /** + * - No groupBy() calls produces an empty string. + */ + #[Test] + public function noGroupByProducesEmptyString(): void + { + $query = $this->makeQuery(); + + $this->assertSame('', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + private function makeQuery(): object + { + return new class { + use HasGroupByClause; + + public function sql(): string + { + return ltrim($this->buildGroupByClause()); + } + + public function bindings(): array + { + return $this->getGroupByBindings(); + } + }; + } +} diff --git a/tests/Unit/Database/Query/Concerns/HasHavingClauseTest.php b/tests/Unit/Database/Query/Concerns/HasHavingClauseTest.php new file mode 100644 index 0000000..ae89120 --- /dev/null +++ b/tests/Unit/Database/Query/Concerns/HasHavingClauseTest.php @@ -0,0 +1,130 @@ +makeQuery(); + $query->having('count', '>', 5); + + $this->assertSame('count > ?', $query->havingClause->toSql()); + $this->assertSame([5], $query->havingClause->getBindings()); + } + + /** + * - having() with a closure produces grouped SQL. + */ + #[Test] + public function havingWithClosureProducesGroupedSql(): void + { + $query = $this->makeQuery(); + $query->having(function (WhereClause $q) { + $q->where('total', '>', 100); + $q->where('count', '>', 1); + }); + + $this->assertSame('(total > ? AND count > ?)', $query->havingClause->toSql()); + $this->assertSame([100, 1], $query->havingClause->getBindings()); + } + + // ------------------------------------------------------------------------- + // orHaving() + // ------------------------------------------------------------------------- + + /** + * - orHaving() uses OR conjunction. + */ + #[Test] + public function orHavingUsesOrConjunction(): void + { + $query = $this->makeQuery(); + $query->having('count', '>', 5); + $query->orHaving('total', '>', 1000); + + $this->assertSame('count > ? OR total > ?', $query->havingClause->toSql()); + $this->assertSame([5, 1000], $query->havingClause->getBindings()); + } + + // ------------------------------------------------------------------------- + // havingRaw() / orHavingRaw() + // ------------------------------------------------------------------------- + + /** + * - havingRaw() produces exact SQL with AND conjunction. + */ + #[Test] + public function havingRawProducesExactSql(): void + { + $query = $this->makeQuery(); + $query->havingRaw('SUM(total) > ?', [500]); + + $this->assertSame('SUM(total) > ?', $query->havingClause->toSql()); + $this->assertSame([500], $query->havingClause->getBindings()); + } + + /** + * - orHavingRaw() produces exact SQL with OR conjunction. + */ + #[Test] + public function orHavingRawUsesOrConjunction(): void + { + $query = $this->makeQuery(); + $query->having('count', '>', 5); + $query->orHavingRaw('AVG(score) > ?', [80]); + + $this->assertSame('count > ? OR AVG(score) > ?', $query->havingClause->toSql()); + $this->assertSame([5, 80], $query->havingClause->getBindings()); + } + + // ------------------------------------------------------------------------- + // isEmpty + // ------------------------------------------------------------------------- + + /** + * - The having clause is initially empty. + */ + #[Test] + public function havingClauseIsInitiallyEmpty(): void + { + $query = $this->makeQuery(); + + $this->assertTrue($query->havingClause->isEmpty()); + } + + /** + * - The having clause is not empty after adding a condition. + */ + #[Test] + public function havingClauseIsNotEmptyAfterCondition(): void + { + $query = $this->makeQuery(); + $query->having('count', '>', 5); + + $this->assertFalse($query->havingClause->isEmpty()); + } + + private function makeQuery(): object + { + return new class { + use HasHavingClause; + }; + } +} diff --git a/tests/Unit/Database/Query/DeleteTest.php b/tests/Unit/Database/Query/DeleteTest.php new file mode 100644 index 0000000..635b86b --- /dev/null +++ b/tests/Unit/Database/Query/DeleteTest.php @@ -0,0 +1,122 @@ +assertSame('DELETE FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // where + // ------------------------------------------------------------------------- + + /** + * - Delete with where clause produces correct SQL and bindings. + */ + #[Test] + public function deleteWithWhereProducesCorrectSql(): void + { + $query = Delete::from('users') + ->where('status', '=', 'inactive') + ; + + $this->assertSame('DELETE FROM users WHERE status = ?', $query->toSql()); + $this->assertSame(['inactive'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // orderBy() + // ------------------------------------------------------------------------- + + /** + * - orderBy() appends ORDER BY clause. + */ + #[Test] + public function orderByAppendsOrderByClause(): void + { + $query = Delete::from('logs') + ->orderBy('created_at', 'asc') + ; + + $this->assertSame('DELETE FROM logs ORDER BY created_at ASC', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - orderBy() with an Expression column collects its bindings. + */ + #[Test] + public function orderByWithExpressionCollectsBindings(): void + { + $expr = RawExpression::make('FIELD(status, ?)', ['active']); + $query = Delete::from('users') + ->orderBy($expr) + ; + + $this->assertSame('DELETE FROM users ORDER BY FIELD(status, ?) ASC', $query->toSql()); + $this->assertSame(['active'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // limit() + // ------------------------------------------------------------------------- + + /** + * - limit() appends LIMIT clause. + */ + #[Test] + public function limitAppendsLimitClause(): void + { + $query = Delete::from('logs') + ->limit(1000) + ; + + $this->assertSame('DELETE FROM logs LIMIT 1000', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Full combination + // ------------------------------------------------------------------------- + + /** + * - Full combination of where, order by, and limit produces correct SQL. + */ + #[Test] + public function fullCombinationProducesCorrectSql(): void + { + $query = Delete::from('logs') + ->where('level', '=', 'debug') + ->orderBy('created_at', 'asc') + ->limit(1000) + ; + + $this->assertSame( + 'DELETE FROM logs WHERE level = ? ORDER BY created_at ASC LIMIT 1000', + $query->toSql(), + ); + $this->assertSame(['debug'], $query->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/Expressions/AggregateTest.php b/tests/Unit/Database/Query/Expressions/AggregateTest.php new file mode 100644 index 0000000..9b82824 --- /dev/null +++ b/tests/Unit/Database/Query/Expressions/AggregateTest.php @@ -0,0 +1,126 @@ +assertSame('COUNT(*)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + /** + * - COUNT(column) produces correct SQL with no bindings. + */ + #[Test] + public function countColumnProducesCorrectSql(): void + { + $expr = Aggregate::make('COUNT', 'id'); + + $this->assertSame('COUNT(id)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + // ------------------------------------------------------------------------- + // SUM / MIN / MAX / AVG + // ------------------------------------------------------------------------- + + /** + * - SUM(column) produces correct SQL. + */ + #[Test] + public function sumProducesCorrectSql(): void + { + $expr = Aggregate::make('SUM', 'total'); + + $this->assertSame('SUM(total)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + /** + * - MIN(column) produces correct SQL. + */ + #[Test] + public function minProducesCorrectSql(): void + { + $expr = Aggregate::make('MIN', 'price'); + + $this->assertSame('MIN(price)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + /** + * - MAX(column) produces correct SQL. + */ + #[Test] + public function maxProducesCorrectSql(): void + { + $expr = Aggregate::make('MAX', 'price'); + + $this->assertSame('MAX(price)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + /** + * - AVG(column) produces correct SQL. + */ + #[Test] + public function avgProducesCorrectSql(): void + { + $expr = Aggregate::make('AVG', 'score'); + + $this->assertSame('AVG(score)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + // ------------------------------------------------------------------------- + // Expression column + // ------------------------------------------------------------------------- + + /** + * - An aggregate with an Expression column embeds its SQL and collects its bindings. + */ + #[Test] + public function aggregateWithExpressionColumnEmbedsSubquerySql(): void + { + $subExpr = RawExpression::make('DISTINCT status', []); + + $expr = Aggregate::make('COUNT', $subExpr); + + $this->assertSame('COUNT(DISTINCT status)', $expr->toSql()); + $this->assertSame([], $expr->getBindings()); + } + + /** + * - An aggregate with an Expression column collects bindings from the expression. + */ + #[Test] + public function aggregateWithExpressionColumnCollectsBindings(): void + { + $subExpr = RawExpression::make('CASE WHEN status = ? THEN 1 END', ['active']); + + $expr = Aggregate::make('SUM', $subExpr); + + $this->assertSame('SUM(CASE WHEN status = ? THEN 1 END)', $expr->toSql()); + $this->assertSame(['active'], $expr->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/Expressions/MatchAgainstTest.php b/tests/Unit/Database/Query/Expressions/MatchAgainstTest.php new file mode 100644 index 0000000..806878e --- /dev/null +++ b/tests/Unit/Database/Query/Expressions/MatchAgainstTest.php @@ -0,0 +1,73 @@ +assertSame('MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)', $expr->toSql()); + $this->assertSame(['search term'], $expr->getBindings()); + } + + /** + * - MATCH AGAINST with a single column produces correct SQL. + */ + #[Test] + public function singleColumnProducesCorrectSql(): void + { + $expr = MatchAgainst::make(['title'], 'search'); + + $this->assertSame('MATCH(title) AGAINST(? IN NATURAL LANGUAGE MODE)', $expr->toSql()); + $this->assertSame(['search'], $expr->getBindings()); + } + + // ------------------------------------------------------------------------- + // Boolean mode + // ------------------------------------------------------------------------- + + /** + * - MATCH AGAINST in boolean mode produces correct SQL. + */ + #[Test] + public function booleanModeProducesCorrectSql(): void + { + $expr = MatchAgainst::make(['title', 'body'], '+required -excluded', 'boolean'); + + $this->assertSame('MATCH(title, body) AGAINST(? IN BOOLEAN MODE)', $expr->toSql()); + $this->assertSame(['+required -excluded'], $expr->getBindings()); + } + + // ------------------------------------------------------------------------- + // Default mode + // ------------------------------------------------------------------------- + + /** + * - MATCH AGAINST defaults to natural language mode when no mode specified. + */ + #[Test] + public function defaultModeIsNaturalLanguage(): void + { + $explicit = MatchAgainst::make(['title'], 'term', 'natural'); + $default = MatchAgainst::make(['title'], 'term'); + + $this->assertSame($explicit->toSql(), $default->toSql()); + } +} diff --git a/tests/Unit/Database/Query/InsertTest.php b/tests/Unit/Database/Query/InsertTest.php new file mode 100644 index 0000000..bee91c7 --- /dev/null +++ b/tests/Unit/Database/Query/InsertTest.php @@ -0,0 +1,234 @@ +values(['name' => 'John', 'age' => 30]); + + $this->assertSame('INSERT INTO users (name, age) VALUES (?, ?)', $query->toSql()); + $this->assertSame(['John', 30], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Bulk insert + // ------------------------------------------------------------------------- + + /** + * - Multiple values() calls produce bulk insert SQL with multiple value tuples. + */ + #[Test] + public function bulkInsertProducesCorrectSql(): void + { + $query = Insert::into('users') + ->values(['name' => 'John', 'age' => 30]) + ->values(['name' => 'Jane', 'age' => 25]) + ; + + $this->assertSame('INSERT INTO users (name, age) VALUES (?, ?), (?, ?)', $query->toSql()); + $this->assertSame(['John', 30, 'Jane', 25], $query->getBindings()); + } + + /** + * - Three rows produce three value tuples with all bindings in order. + */ + #[Test] + public function threeRowsProduceThreeValueTuples(): void + { + $query = Insert::into('logs') + ->values(['level' => 'info', 'message' => 'a']) + ->values(['level' => 'warn', 'message' => 'b']) + ->values(['level' => 'error', 'message' => 'c']) + ; + + $this->assertSame( + 'INSERT INTO logs (level, message) VALUES (?, ?), (?, ?), (?, ?)', + $query->toSql(), + ); + $this->assertSame(['info', 'a', 'warn', 'b', 'error', 'c'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // ignore() + // ------------------------------------------------------------------------- + + /** + * - ignore() produces INSERT IGNORE INTO SQL. + */ + #[Test] + public function ignoreProducesInsertIgnoreSql(): void + { + $query = Insert::into('users') + ->ignore() + ->values(['name' => 'John']) + ; + + $this->assertSame('INSERT IGNORE INTO users (name) VALUES (?)', $query->toSql()); + $this->assertSame(['John'], $query->getBindings()); + } + + /** + * - ignore() can be called after values(). + */ + #[Test] + public function ignoreCanBeCalledAfterValues(): void + { + $query = Insert::into('users') + ->values(['name' => 'John']) + ->ignore() + ; + + $this->assertSame('INSERT IGNORE INTO users (name) VALUES (?)', $query->toSql()); + } + + // ------------------------------------------------------------------------- + // replace() + // ------------------------------------------------------------------------- + + /** + * - replace() produces REPLACE INTO SQL. + */ + #[Test] + public function replaceProducesReplaceIntoSql(): void + { + $query = Insert::into('users') + ->replace() + ->values(['name' => 'John']) + ; + + $this->assertSame('REPLACE INTO users (name) VALUES (?)', $query->toSql()); + $this->assertSame(['John'], $query->getBindings()); + } + + /** + * - replace() can be called after values(). + */ + #[Test] + public function replaceCanBeCalledAfterValues(): void + { + $query = Insert::into('users') + ->values(['name' => 'John']) + ->replace() + ; + + $this->assertSame('REPLACE INTO users (name) VALUES (?)', $query->toSql()); + } + + // ------------------------------------------------------------------------- + // upsert() + // ------------------------------------------------------------------------- + + /** + * - upsert() with plain values appends ON DUPLICATE KEY UPDATE clause. + */ + #[Test] + public function upsertWithPlainValuesProducesCorrectSql(): void + { + $query = Insert::into('users') + ->values(['name' => 'John', 'email' => 'john@example.com']) + ->upsert(['email' => 'john@new.com']) + ; + + $this->assertSame( + 'INSERT INTO users (name, email) VALUES (?, ?) ON DUPLICATE KEY UPDATE email = ?', + $query->toSql(), + ); + $this->assertSame(['John', 'john@example.com', 'john@new.com'], $query->getBindings()); + } + + /** + * - upsert() with Expression values renders SQL inline. + */ + #[Test] + public function upsertWithExpressionRendersInlineSql(): void + { + $query = Insert::into('counters') + ->values(['name' => 'visits', 'count' => 1]) + ->upsert(['count' => RawExpression::make('count + ?', [1])]) + ; + + $this->assertSame( + 'INSERT INTO counters (name, count) VALUES (?, ?) ON DUPLICATE KEY UPDATE count = count + ?', + $query->toSql(), + ); + $this->assertSame(['visits', 1, 1], $query->getBindings()); + } + + /** + * - upsert() with multiple columns produces correct SQL. + */ + #[Test] + public function upsertWithMultipleColumnsProducesCorrectSql(): void + { + $query = Insert::into('users') + ->values(['name' => 'John', 'email' => 'john@example.com', 'age' => 30]) + ->upsert(['email' => 'john@new.com', 'age' => 31]) + ; + + $this->assertSame( + 'INSERT INTO users (name, email, age) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE email = ?, age = ?', + $query->toSql(), + ); + $this->assertSame(['John', 'john@example.com', 30, 'john@new.com', 31], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Combinations + // ------------------------------------------------------------------------- + + /** + * - ignore() combined with upsert() produces correct SQL. + */ + #[Test] + public function ignoreCombinedWithUpsertProducesCorrectSql(): void + { + $query = Insert::into('users') + ->ignore() + ->values(['name' => 'John', 'email' => 'john@example.com']) + ->upsert(['email' => 'john@new.com']) + ; + + $this->assertSame( + 'INSERT IGNORE INTO users (name, email) VALUES (?, ?) ON DUPLICATE KEY UPDATE email = ?', + $query->toSql(), + ); + $this->assertSame(['John', 'john@example.com', 'john@new.com'], $query->getBindings()); + } + + /** + * - Bulk insert combined with upsert() produces correct SQL and bindings. + */ + #[Test] + public function bulkInsertCombinedWithUpsertProducesCorrectSql(): void + { + $query = Insert::into('users') + ->values(['name' => 'John', 'email' => 'john@example.com']) + ->values(['name' => 'Jane', 'email' => 'jane@example.com']) + ->upsert(['email' => RawExpression::make('VALUES(email)', [])]) + ; + + $this->assertSame( + 'INSERT INTO users (name, email) VALUES (?, ?), (?, ?) ON DUPLICATE KEY UPDATE email = VALUES(email)', + $query->toSql(), + ); + $this->assertSame(['John', 'john@example.com', 'Jane', 'jane@example.com'], $query->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/UpdateTest.php b/tests/Unit/Database/Query/UpdateTest.php new file mode 100644 index 0000000..fcd8593 --- /dev/null +++ b/tests/Unit/Database/Query/UpdateTest.php @@ -0,0 +1,173 @@ +set(['name' => 'John', 'age' => 30]); + + $this->assertSame('UPDATE users SET name = ?, age = ?', $query->toSql()); + $this->assertSame(['John', 30], $query->getBindings()); + } + + /** + * - set() with Expression values renders SQL inline. + */ + #[Test] + public function setWithExpressionRendersInlineSql(): void + { + $query = Update::table('counters') + ->set(['hits' => RawExpression::make('hits + ?', [1])]) + ; + + $this->assertSame('UPDATE counters SET hits = hits + ?', $query->toSql()); + $this->assertSame([1], $query->getBindings()); + } + + /** + * - set() with mixed plain and Expression values produces correct SQL and bindings. + */ + #[Test] + public function setWithMixedValuesProducesCorrectSql(): void + { + $query = Update::table('users') + ->set([ + 'name' => 'John', + 'hits' => RawExpression::make('hits + ?', [1]), + 'age' => 30, + ]) + ; + + $this->assertSame('UPDATE users SET name = ?, hits = hits + ?, age = ?', $query->toSql()); + $this->assertSame(['John', 1, 30], $query->getBindings()); + } + + /** + * - Multiple set() calls merge values. + */ + #[Test] + public function multipleSetCallsMergeValues(): void + { + $query = Update::table('users') + ->set(['name' => 'John']) + ->set(['age' => 30]) + ; + + $this->assertSame('UPDATE users SET name = ?, age = ?', $query->toSql()); + $this->assertSame(['John', 30], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // where + // ------------------------------------------------------------------------- + + /** + * - set() with where clause produces correct SQL and bindings in order. + */ + #[Test] + public function setWithWhereProducesCorrectSql(): void + { + $query = Update::table('users') + ->set(['name' => 'John']) + ->where('id', '=', 1) + ; + + $this->assertSame('UPDATE users SET name = ? WHERE id = ?', $query->toSql()); + $this->assertSame(['John', 1], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // orderBy() + // ------------------------------------------------------------------------- + + /** + * - orderBy() appends ORDER BY clause. + */ + #[Test] + public function orderByAppendsOrderByClause(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->orderBy('created_at', 'asc') + ; + + $this->assertSame('UPDATE users SET status = ? ORDER BY created_at ASC', $query->toSql()); + $this->assertSame(['inactive'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // limit() + // ------------------------------------------------------------------------- + + /** + * - limit() appends LIMIT clause. + */ + #[Test] + public function limitAppendsLimitClause(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->limit(10) + ; + + $this->assertSame('UPDATE users SET status = ? LIMIT 10', $query->toSql()); + $this->assertSame(['inactive'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Full combination + // ------------------------------------------------------------------------- + + /** + * - Full combination of set, where, order by, and limit produces correct SQL. + */ + #[Test] + public function fullCombinationProducesCorrectSql(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->where('active', '=', false) + ->orderBy('created_at', 'asc') + ->limit(100) + ; + + $this->assertSame( + 'UPDATE users SET status = ? WHERE active = ? ORDER BY created_at ASC LIMIT 100', + $query->toSql(), + ); + $this->assertSame(['inactive', false], $query->getBindings()); + } + + /** + * - Expression in set() combined with where produces correct binding order. + */ + #[Test] + public function expressionInSetWithWhereProducesCorrectBindingOrder(): void + { + $query = Update::table('counters') + ->set(['hits' => RawExpression::make('hits + ?', [1])]) + ->where('name', '=', 'visits') + ; + + $this->assertSame('UPDATE counters SET hits = hits + ? WHERE name = ?', $query->toSql()); + $this->assertSame([1, 'visits'], $query->getBindings()); + } +} From a341c65a9c9dd7abf3642bdcc7749b5001d1ab42 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 20:47:26 +0100 Subject: [PATCH 18/29] test(engine:database): Achieve 100% code coverage and mutation score Add unit tests for Select, Delete, Update, Insert, Connection, Result, Row, WriteResult, Raw, JoinClause, ValueGetter, and QueryException. Add integration tests for joins, distinct, stream, and transactions. Remove unnecessary empty guard in JoinClause. Add codeCoverageIgnore for untriggerable catch blocks and infection ignores for equivalent mutants. --- infection.json5 | 48 +- src/Database/Connection.php | 6 + src/Database/Query/Clauses/JoinClause.php | 2 +- src/Database/Query/Cursor.php | 4 + .../Integration/Database/QueryBuilderTest.php | 177 ++++++ tests/Unit/Database/ConnectionTest.php | 263 +++++++++ .../Exceptions/QueryExceptionTest.php | 70 +++ .../Database/Query/Clauses/JoinClauseTest.php | 119 ++++ .../Query/Clauses/WhereClauseTest.php | 91 +++ .../Query/Concerns/HasLimitClauseTest.php | 102 ++++ .../Query/Concerns/HasOrderByClauseTest.php | 139 +++++ tests/Unit/Database/Query/DeleteTest.php | 38 ++ tests/Unit/Database/Query/RawTest.php | 37 ++ tests/Unit/Database/Query/ResultTest.php | 117 ++++ tests/Unit/Database/Query/RowTest.php | 162 +++++ tests/Unit/Database/Query/SelectTest.php | 556 ++++++++++++++++++ tests/Unit/Database/Query/UpdateTest.php | 48 ++ tests/Unit/Database/Query/WriteResultTest.php | 70 +++ tests/Unit/Values/ValueGetterTest.php | 200 +++++++ 19 files changed, 2243 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Database/ConnectionTest.php create mode 100644 tests/Unit/Database/Exceptions/QueryExceptionTest.php create mode 100644 tests/Unit/Database/Query/Clauses/JoinClauseTest.php create mode 100644 tests/Unit/Database/Query/Concerns/HasLimitClauseTest.php create mode 100644 tests/Unit/Database/Query/Concerns/HasOrderByClauseTest.php create mode 100644 tests/Unit/Database/Query/RawTest.php create mode 100644 tests/Unit/Database/Query/ResultTest.php create mode 100644 tests/Unit/Database/Query/RowTest.php create mode 100644 tests/Unit/Database/Query/SelectTest.php create mode 100644 tests/Unit/Database/Query/WriteResultTest.php create mode 100644 tests/Unit/Values/ValueGetterTest.php diff --git a/infection.json5 b/infection.json5 index af23bae..64d3dab 100644 --- a/infection.json5 +++ b/infection.json5 @@ -29,6 +29,49 @@ "Coalesce" : { "ignore": [ "Engine\\Container\\Resolvers\\GenericResolver::resolve::56", + "Engine\\Database\\ConnectionFactory::make::62" + ] + }, + "DecrementInteger" : { + "ignore": [ + "Engine\\Database\\Query\\Expressions\\ColumnIn::toSql::38", + "Engine\\Database\\Query\\Expressions\\ColumnNotIn::toSql::38", + "Engine\\Database\\Query\\Insert::toSql::95", + "Engine\\Database\\Query\\Insert::toSql::96", + "Engine\\Values\\ValueGetter::array::64" + ] + }, + "IncrementInteger" : { + "ignore": [ + "Engine\\Database\\Query\\Expressions\\ColumnIn::toSql::38", + "Engine\\Database\\Query\\Expressions\\ColumnNotIn::toSql::38", + "Engine\\Database\\Query\\Insert::toSql::95", + "Engine\\Database\\Query\\Insert::toSql::96", + "Engine\\Values\\ValueGetter::array::64" + ] + }, + "Assignment" : { + "ignore": [ + "Engine\\Database\\Query\\Concerns\\HasLimitClause::buildLimitClause::45" + ] + }, + "ProtectedVisibility": { + "ignore": [ + "Engine\\Database\\Query\\Concerns\\HasWhereClause::hasWhereClause::226", + "Engine\\Database\\Query\\Concerns\\HasHavingClause::hasHavingClause::77" + ] + }, + "MethodCallRemoval": { + "ignore": [ + "Engine\\Database\\Connection::transaction::107" + ] + }, + "ReturnRemoval" : { + "ignore": [ + "Engine\\Container\\Container::invokeClassMethod::350", + "Engine\\Database\\Query\\Concerns\\HasJoinClause::buildJoinClause::94", + "Engine\\Values\\ValueGetter::float::110", + "Engine\\Values\\ValueGetter::int::135" ] }, "MatchArmRemoval": { @@ -40,11 +83,6 @@ "ignore": [ "Engine\\Container\\Container::invokeClassMethod::349" ] - }, - "ReturnRemoval": { - "ignore": [ - "Engine\\Container\\Container::invokeClassMethod::350" - ] } } } diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 692aa52..3bb29ef 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -62,9 +62,11 @@ public function execute(#[Language('GenericSQL')] Expression|string $query, arra affectedRows: $this->statement($query, $bindings)->rowCount(), lastInsertId: $this->pdo->lastInsertId() ?: null, ); + // @codeCoverageIgnoreStart } catch (PDOException $e) { throw new QueryException($query, $bindings, previous: $e); } + // @codeCoverageIgnoreEnd } /** @@ -129,9 +131,11 @@ public function commit(): void { try { $this->pdo->commit(); + // @codeCoverageIgnoreStart } catch (PDOException $e) { throw new DatabaseException($e->getMessage(), previous: $e); } + // @codeCoverageIgnoreEnd } /** @@ -141,9 +145,11 @@ public function rollback(): void { try { $this->pdo->rollBack(); + // @codeCoverageIgnoreStart } catch (PDOException $e) { throw new DatabaseException($e->getMessage(), previous: $e); } + // @codeCoverageIgnoreEnd } /** diff --git a/src/Database/Query/Clauses/JoinClause.php b/src/Database/Query/Clauses/JoinClause.php index d847cf7..c1ef652 100644 --- a/src/Database/Query/Clauses/JoinClause.php +++ b/src/Database/Query/Clauses/JoinClause.php @@ -122,7 +122,7 @@ public function getBindings(): array $bindings[] = $condition['bindings']; } - return array_merge(...$bindings ?: [[]]); + return array_merge(...$bindings); } public function isEmpty(): bool diff --git a/src/Database/Query/Cursor.php b/src/Database/Query/Cursor.php index 88f6be9..201ee83 100644 --- a/src/Database/Query/Cursor.php +++ b/src/Database/Query/Cursor.php @@ -58,6 +58,8 @@ public function rows(): Generator * Get the number of rows in the result. * * @return int + * + * @codeCoverageIgnore Not reliably testable — rowCount() is driver-dependent for SELECT. */ public function count(): int { @@ -68,6 +70,8 @@ public function count(): int * Determine if the result is empty. * * @return bool + * + * @codeCoverageIgnore Not reliably testable — delegates to count() which is driver-dependent. */ public function isEmpty(): bool { diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index f354594..b623da3 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -345,6 +345,86 @@ public function selectFullTextSearch(): void $this->assertContains('Introduction to PHP programming', $titles); } + /** + * - An inner join returns rows matching the join condition. + */ + #[Test] + public function selectWithInnerJoin(): void + { + self::$connection->execute( + Insert::into('qb_users')->values(['name' => 'Alice', 'email' => 'alice@example.com']), + ); + + $userId = self::$connection->query( + Select::from('qb_users')->where('email', '=', 'alice@example.com'), + )->first()->int('id'); + + self::$connection->execute( + Insert::into('qb_posts') + ->values(['user_id' => $userId, 'title' => 'Hello World', 'body' => 'First post content here']), + ); + + $result = self::$connection->query( + Select::from('qb_users') + ->columns('qb_users.name', 'qb_posts.title') + ->join('qb_posts', 'qb_users.id', '=', 'qb_posts.user_id'), + ); + + $this->assertCount(1, $result->all()); + $this->assertSame('Alice', $result->first()->get('name')); + $this->assertSame('Hello World', $result->first()->get('title')); + } + + /** + * - A left join returns all rows from the left table. + */ + #[Test] + public function selectWithLeftJoin(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'alice@example.com']) + ->values(['name' => 'Bob', 'email' => 'bob@example.com']), + ); + + $userId = self::$connection->query( + Select::from('qb_users')->where('email', '=', 'alice@example.com'), + )->first()->int('id'); + + self::$connection->execute( + Insert::into('qb_posts') + ->values(['user_id' => $userId, 'title' => 'Hello', 'body' => 'Post body content']), + ); + + $result = self::$connection->query( + Select::from('qb_users') + ->columns('qb_users.name', 'qb_posts.title') + ->leftJoin('qb_posts', 'qb_users.id', '=', 'qb_posts.user_id'), + ); + + $this->assertCount(2, $result->all()); + } + + /** + * - A distinct select returns unique rows only. + */ + #[Test] + public function selectDistinct(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'a@example.com', 'status' => 'active']) + ->values(['name' => 'Bob', 'email' => 'b@example.com', 'status' => 'active']) + ->values(['name' => 'Charlie', 'email' => 'c@example.com', 'status' => 'inactive']), + ); + + $result = self::$connection->query( + Select::from('qb_users')->distinct()->columns('status'), + ); + + $this->assertCount(2, $result->all()); + } + // ------------------------------------------------------------------------- // Update // ------------------------------------------------------------------------- @@ -497,4 +577,101 @@ public function rawQuery(): void $this->assertNotNull($row); $this->assertSame(2, $row->int('result')); } + + // ------------------------------------------------------------------------- + // Stream + // ------------------------------------------------------------------------- + + /** + * - stream() yields rows one at a time via cursor. + */ + #[Test] + public function streamYieldsRowsViaCursor(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'a@example.com']) + ->values(['name' => 'Bob', 'email' => 'b@example.com']), + ); + + $cursor = self::$connection->stream( + Select::from('qb_users')->orderBy('name', 'asc'), + ); + + $names = []; + + $cursor->each(function ($row) use (&$names) { + $names[] = $row->get('name'); + }); + + $this->assertSame(['Alice', 'Bob'], $names); + } + + /** + * - stream() returns a Cursor whose rows() generator yields Row objects. + */ + #[Test] + public function streamCursorRowsGeneratorYieldsRows(): void + { + self::$connection->execute( + Insert::into('qb_users') + ->values(['name' => 'Alice', 'email' => 'a@example.com']) + ->values(['name' => 'Bob', 'email' => 'b@example.com']), + ); + + $cursor = self::$connection->stream( + Select::from('qb_users')->orderBy('name', 'asc'), + ); + + $names = []; + + foreach ($cursor->rows() as $row) { + $names[] = $row->get('name'); + } + + $this->assertSame(['Alice', 'Bob'], $names); + } + + // ------------------------------------------------------------------------- + // Transactions + // ------------------------------------------------------------------------- + + /** + * - A committed transaction persists data. + */ + #[Test] + public function transactionCommitPersistsData(): void + { + self::$connection->transaction(function (Connection $conn) { + $conn->execute( + Insert::into('qb_users')->values(['name' => 'Alice', 'email' => 'alice@example.com']), + ); + }); + + $result = self::$connection->query(Select::from('qb_users')); + + $this->assertCount(1, $result->all()); + } + + /** + * - A rolled back transaction does not persist data. + */ + #[Test] + public function transactionRollbackDiscardsData(): void + { + try { + self::$connection->transaction(function (Connection $conn) { + $conn->execute( + Insert::into('qb_users')->values(['name' => 'Alice', 'email' => 'alice@example.com']), + ); + throw new \RuntimeException('force rollback'); + }); + } catch (\RuntimeException) { + // expected + } + + $result = self::$connection->query(Select::from('qb_users')); + + $this->assertCount(0, $result->all()); + } } diff --git a/tests/Unit/Database/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php new file mode 100644 index 0000000..f184e50 --- /dev/null +++ b/tests/Unit/Database/ConnectionTest.php @@ -0,0 +1,263 @@ +connection(); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $result = $conn->query('SELECT * FROM test'); + $this->assertSame('Alice', $result->first()->get('name')); + } + + // ------------------------------------------------------------------------- + // execute() + // ------------------------------------------------------------------------- + + /** + * - execute() returns a WriteResult reporting one affected row after an INSERT. + */ + #[Test] + public function executeReturnsWriteResultWithAffectedRows(): void + { + $conn = $this->connection(); + $result = $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $this->assertSame(1, $result->affectedRows()); + } + + /** + * - execute() returns a lastInsertId after an INSERT. + */ + #[Test] + public function executeReturnsLastInsertId(): void + { + $conn = $this->connection(); + $result = $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + + $this->assertNotNull($result->lastInsertId()); + $this->assertSame('1', $result->lastInsertId()); + } + + // ------------------------------------------------------------------------- + // stream() + // ------------------------------------------------------------------------- + + /** + * - stream() returns a Cursor that yields every row via each(). + */ + #[Test] + public function streamReturnsCursorThatYieldsRows(): void + { + $conn = $this->connection(); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Bob']); + $cursor = $conn->stream('SELECT * FROM test ORDER BY name'); + $names = []; + $cursor->each(function (Row $row) use (&$names) { + $names[] = $row->get('name'); + }); + $this->assertSame(['Alice', 'Bob'], $names); + } + + /** + * - stream() returns a Cursor whose rows() generator yields Row objects. + */ + #[Test] + public function streamCursorRowsGeneratorYieldsRows(): void + { + $conn = $this->connection(); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Bob']); + + $cursor = $conn->stream('SELECT * FROM test ORDER BY name'); + $names = []; + + foreach ($cursor->rows() as $row) { + $names[] = $row->get('name'); + } + + $this->assertSame(['Alice', 'Bob'], $names); + } + + // ------------------------------------------------------------------------- + // transaction() - commit + // ------------------------------------------------------------------------- + + /** + * - transaction() commits when the callback completes without throwing. + */ + #[Test] + public function transactionCommitsOnSuccess(): void + { + $conn = $this->connection(); + $conn->transaction(function (Connection $c) { + $c->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + }); + $this->assertSame('Alice', $conn->query('SELECT * FROM test')->first()->get('name')); + } + + // ------------------------------------------------------------------------- + // transaction() - rollback + // ------------------------------------------------------------------------- + + /** + * - transaction() rolls back and re-throws when the callback throws. + */ + #[Test] + public function transactionRollsBackOnException(): void + { + $conn = $this->connection(); + try { + $conn->transaction(function (Connection $c) { + $c->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + throw new \RuntimeException('fail'); + }); + } catch (\RuntimeException) { + } + $this->assertCount(0, $conn->query('SELECT * FROM test')->all()); + } + + // ------------------------------------------------------------------------- + // transaction() - return value + // ------------------------------------------------------------------------- + + /** + * - transaction() passes the callback's return value back to the caller. + */ + #[Test] + public function transactionReturnsCallbackResult(): void + { + $this->assertSame(42, $this->connection()->transaction(fn () => 42)); + } + + // ------------------------------------------------------------------------- + // beginTransaction() / commit() + // ------------------------------------------------------------------------- + + /** + * - beginTransaction() opens a transaction and commit() persists the changes. + */ + #[Test] + public function manualBeginAndCommit(): void + { + $conn = $this->connection(); + $conn->beginTransaction(); + $this->assertTrue($conn->isInTransaction()); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $conn->commit(); + $this->assertFalse($conn->isInTransaction()); + $this->assertSame('Alice', $conn->query('SELECT * FROM test')->first()->get('name')); + } + + // ------------------------------------------------------------------------- + // beginTransaction() / rollback() + // ------------------------------------------------------------------------- + + /** + * - beginTransaction() opens a transaction and rollback() discards the changes. + */ + #[Test] + public function manualBeginAndRollback(): void + { + $conn = $this->connection(); + $conn->beginTransaction(); + $conn->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); + $conn->rollback(); + $this->assertFalse($conn->isInTransaction()); + $this->assertCount(0, $conn->query('SELECT * FROM test')->all()); + } + // ------------------------------------------------------------------------- + // Error paths + // ------------------------------------------------------------------------- + + /** + * - query() throws QueryException for invalid SQL. + */ + #[Test] + public function queryThrowsQueryExceptionForInvalidSql(): void + { + $this->expectException(QueryException::class); + + $this->connection()->query('SELECT * FROM nonexistent_table'); + } + + /** + * - QueryException exposes the SQL and bindings from the failed query. + */ + #[Test] + public function queryExceptionExposesSqlAndBindings(): void + { + try { + $this->connection()->execute('INSERT INTO nonexistent_table (col) VALUES (?)', ['val']); + $this->fail('Expected QueryException'); + } catch (QueryException $e) { + $this->assertSame('INSERT INTO nonexistent_table (col) VALUES (?)', $e->getSql()); + $this->assertSame(['val'], $e->getBindings()); + $this->assertNotEmpty($e->getMessage()); + $this->assertInstanceOf(\PDOException::class, $e->getPrevious()); + $this->assertSame($e->getPrevious()->getMessage(), $e->getMessage()); + } + } + + /** + * - execute() throws QueryException for invalid SQL. + */ + #[Test] + public function executeThrowsQueryExceptionForInvalidSql(): void + { + $this->expectException(QueryException::class); + + $this->connection()->execute('INSERT INTO nonexistent_table (col) VALUES (?)', ['val']); + } + + /** + * - beginTransaction() throws DatabaseException when already in a transaction. + */ + #[Test] + public function beginTransactionThrowsDatabaseExceptionWhenAlreadyInTransaction(): void + { + $this->expectException(DatabaseException::class); + + $conn = $this->connection(); + $conn->beginTransaction(); + $conn->beginTransaction(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * - Build an in-memory SQLite Connection with a pre-created test table. + */ + private function connection(): Connection + { + $pdo = new PDO('sqlite::memory:', null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->exec('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)'); + return new Connection('test', $pdo); + } +} diff --git a/tests/Unit/Database/Exceptions/QueryExceptionTest.php b/tests/Unit/Database/Exceptions/QueryExceptionTest.php new file mode 100644 index 0000000..12640d4 --- /dev/null +++ b/tests/Unit/Database/Exceptions/QueryExceptionTest.php @@ -0,0 +1,70 @@ +assertSame('connection lost', $exception->getMessage()); + } + + /** + * - Constructor uses explicit message over previous exception message. + */ + #[Test] + public function constructorUsesExplicitMessageOverPrevious(): void + { + $previous = new \RuntimeException('connection lost'); + $exception = new QueryException('SELECT 1', [], 'custom message', $previous); + + $this->assertSame('custom message', $exception->getMessage()); + } + + /** + * - Constructor uses fallback message when no message and no previous given. + */ + #[Test] + public function constructorUsesFallbackMessageWhenNoPrevious(): void + { + $exception = new QueryException('SELECT 1', []); + + $this->assertSame('Unable to execute the query.', $exception->getMessage()); + } + + /** + * - getSql() returns the SQL string. + */ + #[Test] + public function getSqlReturnsSqlString(): void + { + $exception = new QueryException('SELECT * FROM users WHERE id = ?', [1]); + + $this->assertSame('SELECT * FROM users WHERE id = ?', $exception->getSql()); + } + + /** + * - getBindings() returns the bindings array. + */ + #[Test] + public function getBindingsReturnsBindingsArray(): void + { + $exception = new QueryException('SELECT * FROM users WHERE id = ?', [1]); + + $this->assertSame([1], $exception->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/Clauses/JoinClauseTest.php b/tests/Unit/Database/Query/Clauses/JoinClauseTest.php new file mode 100644 index 0000000..38f5da2 --- /dev/null +++ b/tests/Unit/Database/Query/Clauses/JoinClauseTest.php @@ -0,0 +1,119 @@ +on('users.id', '=', 'posts.user_id'); + $this->assertSame('users.id = posts.user_id', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + /** + * - orOn() uses OR conjunction between conditions. + */ + #[Test] + public function orOnUsesOrConjunction(): void + { + $clause = new JoinClause(); + $clause->on('users.id', '=', 'posts.user_id'); + $clause->orOn('users.id', '=', 'posts.editor_id'); + $this->assertSame('users.id = posts.user_id OR users.id = posts.editor_id', $clause->toSql()); + $this->assertSame([], $clause->getBindings()); + } + + /** + * - where() produces bound value condition SQL. + */ + #[Test] + public function whereProducesBoundValueSql(): void + { + $clause = new JoinClause(); + $clause->where('posts.status', '=', 'published'); + $this->assertSame('posts.status = ?', $clause->toSql()); + $this->assertSame(['published'], $clause->getBindings()); + } + + /** + * - orWhere() uses OR conjunction with bound value. + */ + #[Test] + public function orWhereUsesOrConjunction(): void + { + $clause = new JoinClause(); + $clause->where('posts.status', '=', 'published'); + $clause->orWhere('posts.status', '=', 'draft'); + $this->assertSame('posts.status = ? OR posts.status = ?', $clause->toSql()); + $this->assertSame(['published', 'draft'], $clause->getBindings()); + } + + /** + * - where() followed by on() uses AND conjunction from the on() call. + */ + #[Test] + public function whereFollowedByOnUsesAndConjunction(): void + { + $clause = new JoinClause(); + $clause->where('posts.status', '=', 'published'); + $clause->on('users.id', '=', 'posts.user_id'); + + $this->assertSame('posts.status = ? AND users.id = posts.user_id', $clause->toSql()); + $this->assertSame(['published'], $clause->getBindings()); + } + + /** + * - on() and where() can be combined in a single join clause. + */ + #[Test] + public function onAndWhereCanBeCombined(): void + { + $clause = new JoinClause(); + $clause->on('users.id', '=', 'posts.user_id'); + $clause->where('posts.status', '=', 'published'); + $this->assertSame('users.id = posts.user_id AND posts.status = ?', $clause->toSql()); + $this->assertSame(['published'], $clause->getBindings()); + } + + /** + * - isEmpty() returns true when no conditions added. + */ + #[Test] + public function isEmptyReturnsTrueWhenNoConditions(): void + { + $this->assertTrue(new JoinClause()->isEmpty()); + } + + /** + * - getBindings() returns an empty array when no conditions are added. + */ + #[Test] + public function getBindingsReturnsEmptyArrayWhenNoConditions(): void + { + $this->assertSame([], new JoinClause()->getBindings()); + } + + /** + * - isEmpty() returns false after a condition is added. + */ + #[Test] + public function isEmptyReturnsFalseAfterCondition(): void + { + $clause = new JoinClause(); + $clause->on('a.id', '=', 'b.a_id'); + $this->assertFalse($clause->isEmpty()); + } +} diff --git a/tests/Unit/Database/Query/Clauses/WhereClauseTest.php b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php index d451726..88731ca 100644 --- a/tests/Unit/Database/Query/Clauses/WhereClauseTest.php +++ b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php @@ -6,6 +6,7 @@ use Engine\Database\Exceptions\InvalidExpressionException; use Engine\Database\Query\Clauses\WhereClause; use Engine\Database\Query\Expressions\RawExpression; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -60,6 +61,96 @@ public function whereWithEmptyClosureThrowsException(): void }); } + /** + * - where() with the < operator produces correct SQL. + */ + #[Test] + public function whereWithLessThanOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('age', '<', 18); + + $this->assertSame('age < ?', $clause->toSql()); + $this->assertSame([18], $clause->getBindings()); + } + + /** + * - where() with the <= operator produces correct SQL. + */ + #[Test] + public function whereWithLessThanOrEqualOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('age', '<=', 18); + + $this->assertSame('age <= ?', $clause->toSql()); + $this->assertSame([18], $clause->getBindings()); + } + + /** + * - where() with the >= operator produces correct SQL. + */ + #[Test] + public function whereWithGreaterThanOrEqualOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('age', '>=', 18); + + $this->assertSame('age >= ?', $clause->toSql()); + $this->assertSame([18], $clause->getBindings()); + } + + /** + * - where() with the > operator produces correct SQL. + */ + #[Test] + public function whereWithGreaterThanOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('age', '>', 18); + + $this->assertSame('age > ?', $clause->toSql()); + $this->assertSame([18], $clause->getBindings()); + } + + /** + * - where() with the IS operator produces correct SQL. + */ + #[Test] + public function whereWithIsOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('active', 'is', true); + + $this->assertSame('active IS ?', $clause->toSql()); + $this->assertSame([true], $clause->getBindings()); + } + + /** + * - where() with the != operator produces correct SQL. + */ + #[Test] + public function whereWithNotEqualOperatorProducesCorrectSql(): void + { + $clause = new WhereClause(); + $clause->where('status', '!=', 'banned'); + + $this->assertSame('status != ?', $clause->toSql()); + $this->assertSame(['banned'], $clause->getBindings()); + } + + /** + * - where() with an invalid operator throws InvalidArgumentException. + */ + #[Test] + public function whereWithInvalidOperatorThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + + $clause = new WhereClause(); + $clause->where('col', 'INVALID', 'value'); + } + // ------------------------------------------------------------------------- // orWhere() // ------------------------------------------------------------------------- diff --git a/tests/Unit/Database/Query/Concerns/HasLimitClauseTest.php b/tests/Unit/Database/Query/Concerns/HasLimitClauseTest.php new file mode 100644 index 0000000..730f99f --- /dev/null +++ b/tests/Unit/Database/Query/Concerns/HasLimitClauseTest.php @@ -0,0 +1,102 @@ +makeQuery(); + $query->limit(10); + + $this->assertSame('LIMIT 10', $query->sql()); + } + + // ------------------------------------------------------------------------- + // offset() + // ------------------------------------------------------------------------- + + /** + * - offset() produces an OFFSET clause. + */ + #[Test] + public function offsetProducesOffsetClause(): void + { + $query = $this->makeQuery(); + $query->offset(5); + + $this->assertSame('OFFSET 5', $query->sql()); + } + + // ------------------------------------------------------------------------- + // limit() + offset() + // ------------------------------------------------------------------------- + + /** + * - limit() and offset() together produce LIMIT ... OFFSET ... clause. + */ + #[Test] + public function limitAndOffsetProduceCombinedClause(): void + { + $query = $this->makeQuery(); + $query->limit(10); + $query->offset(20); + + $this->assertSame('LIMIT 10 OFFSET 20', $query->sql()); + } + + /** + * - offset() called before limit() produces the same result. + */ + #[Test] + public function offsetBeforeLimitProducesSameResult(): void + { + $query = $this->makeQuery(); + $query->offset(20); + $query->limit(10); + + $this->assertSame('LIMIT 10 OFFSET 20', $query->sql()); + } + + // ------------------------------------------------------------------------- + // No clause + // ------------------------------------------------------------------------- + + /** + * - No limit() or offset() calls produces an empty string. + */ + #[Test] + public function noLimitOrOffsetProducesEmptyString(): void + { + $query = $this->makeQuery(); + + $this->assertSame('', $query->sql()); + } + + private function makeQuery(): object + { + return new class { + use HasLimitClause; + + public function sql(): string + { + return ltrim($this->buildLimitClause()); + } + }; + } +} diff --git a/tests/Unit/Database/Query/Concerns/HasOrderByClauseTest.php b/tests/Unit/Database/Query/Concerns/HasOrderByClauseTest.php new file mode 100644 index 0000000..72dfda2 --- /dev/null +++ b/tests/Unit/Database/Query/Concerns/HasOrderByClauseTest.php @@ -0,0 +1,139 @@ +makeQuery(); + $query->orderBy('name'); + + $this->assertSame('ORDER BY name ASC', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + /** + * - orderBy() with lowercase 'desc' produces DESC. + */ + #[Test] + public function orderByWithLowercaseDescProducesDesc(): void + { + $query = $this->makeQuery(); + $query->orderBy('name', 'desc'); + + $this->assertSame('ORDER BY name DESC', $query->sql()); + } + + /** + * - orderBy() with uppercase 'DESC' produces DESC. + */ + #[Test] + public function orderByWithUppercaseDescProducesDesc(): void + { + $query = $this->makeQuery(); + $query->orderBy('name', 'DESC'); + + $this->assertSame('ORDER BY name DESC', $query->sql()); + } + + /** + * - orderBy() with mixed case 'Desc' produces DESC. + */ + #[Test] + public function orderByWithMixedCaseDescProducesDesc(): void + { + $query = $this->makeQuery(); + $query->orderBy('name', 'Desc'); + + $this->assertSame('ORDER BY name DESC', $query->sql()); + } + + /** + * - Multiple orderBy() calls produce comma-separated columns. + */ + #[Test] + public function multipleOrderByCallsProduceCommaSeparatedColumns(): void + { + $query = $this->makeQuery(); + $query->orderBy('name', 'asc'); + $query->orderBy('age', 'desc'); + + $this->assertSame('ORDER BY name ASC, age DESC', $query->sql()); + } + + /** + * - orderBy() with an Expression column embeds its SQL. + */ + #[Test] + public function orderByWithExpressionEmbedsSql(): void + { + $expr = RawExpression::make('FIELD(status, ?)', ['active']); + $query = $this->makeQuery(); + $query->orderBy($expr); + + $this->assertSame('ORDER BY FIELD(status, ?) ASC', $query->sql()); + $this->assertSame(['active'], $query->bindings()); + } + + /** + * - Multiple Expression orderBy calls accumulate all bindings. + */ + #[Test] + public function multipleExpressionOrderByCallsAccumulateBindings(): void + { + $expr1 = RawExpression::make('FIELD(status, ?)', ['active']); + $expr2 = RawExpression::make('FIELD(role, ?)', ['admin']); + $query = $this->makeQuery(); + $query->orderBy($expr1); + $query->orderBy($expr2); + + $this->assertSame('ORDER BY FIELD(status, ?) ASC, FIELD(role, ?) ASC', $query->sql()); + $this->assertSame(['active', 'admin'], $query->bindings()); + } + + /** + * - No orderBy() calls produces an empty string. + */ + #[Test] + public function noOrderByProducesEmptyString(): void + { + $query = $this->makeQuery(); + + $this->assertSame('', $query->sql()); + $this->assertSame([], $query->bindings()); + } + + private function makeQuery(): object + { + return new class { + use HasOrderByClause; + + public function sql(): string + { + return ltrim($this->buildOrderByClause()); + } + + public function bindings(): array + { + return $this->getOrderByBindings(); + } + }; + } +} diff --git a/tests/Unit/Database/Query/DeleteTest.php b/tests/Unit/Database/Query/DeleteTest.php index 635b86b..c7c2eb6 100644 --- a/tests/Unit/Database/Query/DeleteTest.php +++ b/tests/Unit/Database/Query/DeleteTest.php @@ -46,6 +46,44 @@ public function deleteWithWhereProducesCorrectSql(): void $this->assertSame(['inactive'], $query->getBindings()); } + /** + * - orWhere() on Delete produces OR conjunction. + */ + #[Test] + public function orWhereProducesOrConjunction(): void + { + $query = Delete::from('users') + ->where('status', '=', 'inactive') + ->orWhere('age', '<', 13) + ; + + $this->assertSame('DELETE FROM users WHERE status = ? OR age < ?', $query->toSql()); + $this->assertSame(['inactive', 13], $query->getBindings()); + } + + /** + * - whereNull() on Delete produces IS NULL clause. + */ + #[Test] + public function whereNullProducesIsNullClause(): void + { + $query = Delete::from('sessions')->whereNull('expired_at'); + + $this->assertSame('DELETE FROM sessions WHERE expired_at IS NULL', $query->toSql()); + } + + /** + * - whereIn() on Delete produces IN clause. + */ + #[Test] + public function whereInProducesInClause(): void + { + $query = Delete::from('users')->whereIn('id', [1, 2, 3]); + + $this->assertSame('DELETE FROM users WHERE id IN (?, ?, ?)', $query->toSql()); + $this->assertSame([1, 2, 3], $query->getBindings()); + } + // ------------------------------------------------------------------------- // orderBy() // ------------------------------------------------------------------------- diff --git a/tests/Unit/Database/Query/RawTest.php b/tests/Unit/Database/Query/RawTest.php new file mode 100644 index 0000000..c86f45a --- /dev/null +++ b/tests/Unit/Database/Query/RawTest.php @@ -0,0 +1,37 @@ +assertSame('SELECT * FROM users WHERE id = ?', $raw->toSql()); + $this->assertSame([1], $raw->getBindings()); + } + + /** + * - A raw query with no bindings defaults to an empty array. + */ + #[Test] + public function rawQueryDefaultsToEmptyBindings(): void + { + $raw = new Raw('SELECT 1'); + + $this->assertSame('SELECT 1', $raw->toSql()); + $this->assertSame([], $raw->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/ResultTest.php b/tests/Unit/Database/Query/ResultTest.php new file mode 100644 index 0000000..7e52d16 --- /dev/null +++ b/tests/Unit/Database/Query/ResultTest.php @@ -0,0 +1,117 @@ +createResult([['name' => 'Alice'], ['name' => 'Bob']]); + $this->assertInstanceOf(Row::class, $result->first()); + $this->assertSame('Alice', $result->first()->get('name')); + } + + /** + * - first() returns null when the result set is empty. + */ + #[Test] + public function firstReturnsNullWhenEmpty(): void + { + $this->assertNull($this->createResult([])->first()); + } + + // ------------------------------------------------------------------------- + // all() + // ------------------------------------------------------------------------- + + /** + * - all() returns every row as a Row object with correct data. + */ + #[Test] + public function allReturnsAllRowsAsRowObjects(): void + { + $result = $this->createResult([['name' => 'Alice'], ['name' => 'Bob']]); + $rows = $result->all(); + $this->assertCount(2, $rows); + $this->assertInstanceOf(Row::class, $rows[0]); + $this->assertSame('Bob', $rows[1]->get('name')); + } + + // ------------------------------------------------------------------------- + // each() + // ------------------------------------------------------------------------- + + /** + * - each() invokes the callback for every row in order. + */ + #[Test] + public function eachIteratesOverAllRows(): void + { + $result = $this->createResult([['name' => 'Alice'], ['name' => 'Bob']]); + $names = []; + $result->each(function (Row $row) use (&$names) { + $names[] = $row->get('name'); + }); + $this->assertSame(['Alice', 'Bob'], $names); + } + + // ------------------------------------------------------------------------- + // count() + // ------------------------------------------------------------------------- + + /** + * - count() returns the number of rows reported by the statement. + */ + #[Test] + public function countReturnsRowCount(): void + { + $this->assertSame(2, $this->createResult([['a' => 1], ['a' => 2]])->count()); + } + + // ------------------------------------------------------------------------- + // isEmpty() + // ------------------------------------------------------------------------- + + /** + * - isEmpty() returns true when the result set contains no rows. + */ + #[Test] + public function isEmptyReturnsTrueWhenNoRows(): void + { + $this->assertTrue($this->createResult([])->isEmpty()); + } + + /** + * - isEmpty() returns false when the result set contains at least one row. + */ + #[Test] + public function isEmptyReturnsFalseWhenRowsExist(): void + { + $this->assertFalse($this->createResult([['a' => 1]])->isEmpty()); + } + + private function createResult(array $rows): Result + { + $statement = $this->createStub(PDOStatement::class); + $statement->method('fetchAll')->willReturn($rows); + $statement->method('rowCount')->willReturn(count($rows)); + return new Result($statement, []); + } +} diff --git a/tests/Unit/Database/Query/RowTest.php b/tests/Unit/Database/Query/RowTest.php new file mode 100644 index 0000000..0ee6059 --- /dev/null +++ b/tests/Unit/Database/Query/RowTest.php @@ -0,0 +1,162 @@ + 'John', 'age' => 30]); + $this->assertSame('John', $row->get('name')); + $this->assertSame(30, $row->get('age')); + } + + /** + * - get() returns null for a column that does not exist. + */ + #[Test] + public function getReturnsNullForMissingColumn(): void + { + $this->assertNull(new Row(['name' => 'John'])->get('missing')); + } + + // ------------------------------------------------------------------------- + // has() + // ------------------------------------------------------------------------- + + /** + * - has() returns true when the column exists in the data. + */ + #[Test] + public function hasReturnsTrueForExistingColumn(): void + { + $this->assertTrue(new Row(['name' => 'John'])->has('name')); + } + + /** + * - has() returns false when the column is not present in the data. + */ + #[Test] + public function hasReturnsFalseForMissingColumn(): void + { + $this->assertFalse(new Row(['name' => 'John'])->has('missing')); + } + + /** + * - has() returns true even when the column's value is null. + */ + #[Test] + public function hasReturnsTrueForNullValueColumn(): void + { + $this->assertTrue(new Row(['deleted_at' => null])->has('deleted_at')); + } + + // ------------------------------------------------------------------------- + // isNull() + // ------------------------------------------------------------------------- + + /** + * - isNull() returns true when the column exists and its value is null. + */ + #[Test] + public function isNullReturnsTrueForNullValue(): void + { + $this->assertTrue(new Row(['deleted_at' => null])->isNull('deleted_at')); + } + + /** + * - isNull() returns false when the column exists and has a non-null value. + */ + #[Test] + public function isNullReturnsFalseForNonNullValue(): void + { + $this->assertFalse(new Row(['name' => 'John'])->isNull('name')); + } + + /** + * - isNull() returns false when the column does not exist. + */ + #[Test] + public function isNullReturnsFalseForMissingColumn(): void + { + $this->assertFalse(new Row([])->isNull('missing')); + } + + // ------------------------------------------------------------------------- + // toArray() + // ------------------------------------------------------------------------- + + /** + * - toArray() returns the full underlying data array unchanged. + */ + #[Test] + public function toArrayReturnsFullDataArray(): void + { + $data = ['name' => 'John', 'age' => 30]; + $this->assertSame($data, new Row($data)->toArray()); + } + + // ------------------------------------------------------------------------- + // Typed getters + // ------------------------------------------------------------------------- + + /** + * - string() returns the column value cast to a string. + */ + #[Test] + public function stringReturnsValueAsString(): void + { + $this->assertSame('John', new Row(['name' => 'John'])->string('name')); + } + + /** + * - int() returns the column value cast to an integer. + */ + #[Test] + public function intReturnsValueAsInteger(): void + { + $this->assertSame(30, new Row(['age' => 30])->int('age')); + } + + /** + * - float() returns the column value cast to a float. + */ + #[Test] + public function floatReturnsValueAsFloat(): void + { + $this->assertSame(9.99, new Row(['price' => 9.99])->float('price')); + } + + /** + * - bool() returns the column value cast to a boolean. + */ + #[Test] + public function boolReturnsValueAsBoolean(): void + { + $this->assertTrue(new Row(['active' => true])->bool('active')); + } + + /** + * - array() returns the column value cast to an array. + */ + #[Test] + public function arrayReturnsValueAsArray(): void + { + $this->assertSame([1, 2], new Row(['tags' => [1, 2]])->array('tags')); + } +} diff --git a/tests/Unit/Database/Query/SelectTest.php b/tests/Unit/Database/Query/SelectTest.php new file mode 100644 index 0000000..fbc4c5b --- /dev/null +++ b/tests/Unit/Database/Query/SelectTest.php @@ -0,0 +1,556 @@ +assertSame('SELECT * FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // columns() / addColumn() + // ------------------------------------------------------------------------- + + /** + * - columns() produces a SELECT with the named columns. + */ + #[Test] + public function columnsProducesSelectWithSpecificColumns(): void + { + $query = Select::from('users')->columns('name', 'email'); + + $this->assertSame('SELECT name, email FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - An Expression column embeds its SQL inline. + */ + #[Test] + public function columnsWithExpressionEmbedsSql(): void + { + $query = Select::from('users')->columns('name', Expressions::count('*')); + + $this->assertSame('SELECT name, COUNT(*) FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - addColumn() appends a column to an existing columns list. + */ + #[Test] + public function addColumnAppendsColumn(): void + { + $query = Select::from('users')->columns('name')->addColumn('email'); + + $this->assertSame('SELECT name, email FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // distinct() + // ------------------------------------------------------------------------- + + /** + * - distinct() inserts DISTINCT keyword after SELECT. + */ + #[Test] + public function distinctAddsDistinctKeyword(): void + { + $query = Select::from('users')->distinct()->columns('status'); + + $this->assertSame('SELECT DISTINCT status FROM users', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - Expressions::match() factory produces MATCH AGAINST as a column expression. + */ + #[Test] + public function expressionsMatchFactoryProducesMatchAgainstColumn(): void + { + $query = Select::from('posts')->columns(Expressions::match(['title', 'body'], 'search')); + + $this->assertSame( + 'SELECT MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE) FROM posts', + $query->toSql(), + ); + $this->assertSame(['search'], $query->getBindings()); + } + + /** + * - Expressions::matchBoolean() factory produces MATCH AGAINST in boolean mode. + */ + #[Test] + public function expressionsMatchBooleanFactoryProducesBooleanMode(): void + { + $query = Select::from('posts')->columns(Expressions::matchBoolean(['title'], '+php')); + + $this->assertSame( + 'SELECT MATCH(title) AGAINST(? IN BOOLEAN MODE) FROM posts', + $query->toSql(), + ); + $this->assertSame(['+php'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Subquery FROM + // ------------------------------------------------------------------------- + + /** + * - An Expression passed as the table wraps it in a subquery. + */ + #[Test] + public function expressionAsTableProducesSubquery(): void + { + $subquery = RawExpression::make('SELECT id FROM users WHERE active = ?', [true]); + $query = Select::from($subquery)->columns('id'); + + $this->assertSame('SELECT id FROM (SELECT id FROM users WHERE active = ?)', $query->toSql()); + $this->assertSame([true], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // where variants + // ------------------------------------------------------------------------- + + /** + * - where() produces a WHERE clause with a bound placeholder. + */ + #[Test] + public function whereProducesWhereClause(): void + { + $query = Select::from('users')->where('status', '=', 'active'); + + $this->assertSame('SELECT * FROM users WHERE status = ?', $query->toSql()); + $this->assertSame(['active'], $query->getBindings()); + } + + /** + * - orWhere() uses OR conjunction for subsequent conditions. + */ + #[Test] + public function orWhereProducesOrConjunction(): void + { + $query = Select::from('users') + ->where('status', '=', 'active') + ->orWhere('role', '=', 'admin') + ; + + $this->assertSame('SELECT * FROM users WHERE status = ? OR role = ?', $query->toSql()); + $this->assertSame(['active', 'admin'], $query->getBindings()); + } + + /** + * - whereNull() produces an IS NULL clause. + */ + #[Test] + public function whereNullProducesIsNullClause(): void + { + $query = Select::from('users')->whereNull('deleted_at'); + + $this->assertSame('SELECT * FROM users WHERE deleted_at IS NULL', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - orWhereNull() produces an OR IS NULL clause. + */ + #[Test] + public function orWhereNullProducesOrIsNullClause(): void + { + $query = Select::from('users') + ->whereNull('deleted_at') + ->orWhereNull('suspended_at') + ; + + $this->assertSame('SELECT * FROM users WHERE deleted_at IS NULL OR suspended_at IS NULL', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - whereNotNull() produces an IS NOT NULL clause. + */ + #[Test] + public function whereNotNullProducesIsNotNullClause(): void + { + $query = Select::from('users')->whereNotNull('email'); + + $this->assertSame('SELECT * FROM users WHERE email IS NOT NULL', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - orWhereNotNull() produces an OR IS NOT NULL clause. + */ + #[Test] + public function orWhereNotNullProducesOrIsNotNullClause(): void + { + $query = Select::from('users') + ->whereNotNull('email') + ->orWhereNotNull('phone') + ; + + $this->assertSame('SELECT * FROM users WHERE email IS NOT NULL OR phone IS NOT NULL', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - whereIn() produces an IN clause with bound placeholders. + */ + #[Test] + public function whereInProducesInClause(): void + { + $query = Select::from('users')->whereIn('role', ['admin', 'moderator']); + + $this->assertSame('SELECT * FROM users WHERE role IN (?, ?)', $query->toSql()); + $this->assertSame(['admin', 'moderator'], $query->getBindings()); + } + + /** + * - orWhereIn() produces an OR IN clause. + */ + #[Test] + public function orWhereInProducesOrInClause(): void + { + $query = Select::from('users') + ->where('active', '=', true) + ->orWhereIn('role', ['admin', 'moderator']) + ; + + $this->assertSame('SELECT * FROM users WHERE active = ? OR role IN (?, ?)', $query->toSql()); + $this->assertSame([true, 'admin', 'moderator'], $query->getBindings()); + } + + /** + * - whereNotIn() produces a NOT IN clause with bound placeholders. + */ + #[Test] + public function whereNotInProducesNotInClause(): void + { + $query = Select::from('users')->whereNotIn('status', ['banned', 'suspended']); + + $this->assertSame('SELECT * FROM users WHERE status NOT IN (?, ?)', $query->toSql()); + $this->assertSame(['banned', 'suspended'], $query->getBindings()); + } + + /** + * - orWhereNotIn() produces an OR NOT IN clause. + */ + #[Test] + public function orWhereNotInProducesOrNotInClause(): void + { + $query = Select::from('users') + ->where('active', '=', true) + ->orWhereNotIn('status', ['banned', 'suspended']) + ; + + $this->assertSame('SELECT * FROM users WHERE active = ? OR status NOT IN (?, ?)', $query->toSql()); + $this->assertSame([true, 'banned', 'suspended'], $query->getBindings()); + } + + /** + * - whereRaw() embeds raw SQL with its bindings. + */ + #[Test] + public function whereRawProducesRawWhereClause(): void + { + $query = Select::from('users')->whereRaw('YEAR(created_at) = ?', [2024]); + + $this->assertSame('SELECT * FROM users WHERE YEAR(created_at) = ?', $query->toSql()); + $this->assertSame([2024], $query->getBindings()); + } + + /** + * - orWhereRaw() embeds raw SQL with OR conjunction. + */ + #[Test] + public function orWhereRawProducesOrRawWhereClause(): void + { + $query = Select::from('users') + ->where('active', '=', true) + ->orWhereRaw('YEAR(created_at) = ?', [2024]) + ; + + $this->assertSame('SELECT * FROM users WHERE active = ? OR YEAR(created_at) = ?', $query->toSql()); + $this->assertSame([true, 2024], $query->getBindings()); + } + + /** + * - whereFullText() produces a MATCH ... AGAINST clause in natural language mode. + */ + #[Test] + public function whereFullTextProducesMatchAgainstClause(): void + { + $query = Select::from('posts')->whereFullText(['title', 'body'], 'search term'); + + $this->assertSame( + 'SELECT * FROM posts WHERE MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)', + $query->toSql(), + ); + $this->assertSame(['search term'], $query->getBindings()); + } + + /** + * - orWhereFullText() produces an OR MATCH ... AGAINST clause. + */ + #[Test] + public function orWhereFullTextProducesOrMatchAgainstClause(): void + { + $query = Select::from('posts') + ->where('active', '=', true) + ->orWhereFullText(['title', 'body'], 'search term') + ; + + $this->assertSame( + 'SELECT * FROM posts WHERE active = ? OR MATCH(title, body) AGAINST(? IN NATURAL LANGUAGE MODE)', + $query->toSql(), + ); + $this->assertSame([true, 'search term'], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // join variants + // ------------------------------------------------------------------------- + + /** + * - join() produces an INNER JOIN ... ON clause. + */ + #[Test] + public function joinProducesInnerJoinClause(): void + { + $query = Select::from('users')->join('posts', 'users.id', '=', 'posts.user_id'); + + $this->assertSame('SELECT * FROM users INNER JOIN posts ON users.id = posts.user_id', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - leftJoin() produces a LEFT JOIN ... ON clause. + */ + #[Test] + public function leftJoinProducesLeftJoinClause(): void + { + $query = Select::from('users')->leftJoin('profiles', 'users.id', '=', 'profiles.user_id'); + + $this->assertSame('SELECT * FROM users LEFT JOIN profiles ON users.id = profiles.user_id', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - rightJoin() produces a RIGHT JOIN ... ON clause. + */ + #[Test] + public function rightJoinProducesRightJoinClause(): void + { + $query = Select::from('orders')->rightJoin('users', 'orders.user_id', '=', 'users.id'); + + $this->assertSame('SELECT * FROM orders RIGHT JOIN users ON orders.user_id = users.id', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - crossJoin() produces a CROSS JOIN clause with no ON condition. + */ + #[Test] + public function crossJoinProducesCrossJoinClause(): void + { + $query = Select::from('users')->crossJoin('roles'); + + $this->assertSame('SELECT * FROM users CROSS JOIN roles', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - A join closure can combine on() and where() conditions. + */ + #[Test] + public function joinWithClosureProducesComplexConditions(): void + { + $query = Select::from('users')->join('posts', function (JoinClause $join) { + $join->on('users.id', '=', 'posts.user_id'); + $join->where('posts.published', '=', true); + }); + + $this->assertSame( + 'SELECT * FROM users INNER JOIN posts ON users.id = posts.user_id AND posts.published = ?', + $query->toSql(), + ); + $this->assertSame([true], $query->getBindings()); + } + + /** + * - Multiple joins with bound values accumulate all bindings. + */ + #[Test] + public function multipleJoinsWithBoundValuesAccumulateBindings(): void + { + $query = Select::from('users') + ->join('posts', function (JoinClause $join) { + $join->on('users.id', '=', 'posts.user_id'); + $join->where('posts.status', '=', 'published'); + }) + ->join('comments', function (JoinClause $join) { + $join->on('posts.id', '=', 'comments.post_id'); + $join->where('comments.approved', '=', true); + }) + ; + + $this->assertSame( + 'SELECT * FROM users' + . ' INNER JOIN posts ON users.id = posts.user_id AND posts.status = ?' + . ' INNER JOIN comments ON posts.id = comments.post_id AND comments.approved = ?', + $query->toSql(), + ); + $this->assertSame(['published', true], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // groupBy() / having() + // ------------------------------------------------------------------------- + + /** + * - groupBy() appends a GROUP BY clause. + */ + #[Test] + public function groupByProducesGroupByClause(): void + { + $query = Select::from('orders')->columns('status')->groupBy('status'); + + $this->assertSame('SELECT status FROM orders GROUP BY status', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - havingRaw() appends a HAVING clause with a raw expression. + */ + #[Test] + public function havingProducesHavingClause(): void + { + $query = Select::from('orders') + ->columns('status') + ->groupBy('status') + ->havingRaw('COUNT(*) > ?', [5]) + ; + + $this->assertSame('SELECT status FROM orders GROUP BY status HAVING COUNT(*) > ?', $query->toSql()); + $this->assertSame([5], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // orderBy() / limit() / offset() + // ------------------------------------------------------------------------- + + /** + * - orderBy() appends an ORDER BY clause. + */ + #[Test] + public function orderByProducesOrderByClause(): void + { + $query = Select::from('users')->orderBy('name'); + + $this->assertSame('SELECT * FROM users ORDER BY name ASC', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + /** + * - limit() and offset() append LIMIT and OFFSET clauses. + */ + #[Test] + public function limitAndOffsetProduceLimitOffsetClause(): void + { + $query = Select::from('users')->limit(10)->offset(20); + + $this->assertSame('SELECT * FROM users LIMIT 10 OFFSET 20', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + // ------------------------------------------------------------------------- + // Full combination + // ------------------------------------------------------------------------- + + /** + * - All clauses combined produce SQL in the correct clause order. + */ + #[Test] + public function fullCombinationProducesCorrectSqlOrder(): void + { + $query = Select::from('orders') + ->columns('orders.id', 'users.name') + ->join('users', 'orders.user_id', '=', 'users.id') + ->where('orders.status', '=', 'completed') + ->groupBy('orders.id', 'users.name') + ->havingRaw('COUNT(*) > ?', [1]) + ->orderBy('orders.id', 'desc') + ->limit(25) + ->offset(50) + ; + + $this->assertSame( + 'SELECT orders.id, users.name FROM orders' + . ' INNER JOIN users ON orders.user_id = users.id' + . ' WHERE orders.status = ?' + . ' GROUP BY orders.id, users.name' + . ' HAVING COUNT(*) > ?' + . ' ORDER BY orders.id DESC' + . ' LIMIT 25 OFFSET 50', + $query->toSql(), + ); + $this->assertSame(['completed', 1], $query->getBindings()); + } + + /** + * - Bindings are collected in the correct order: table subquery, expression columns, joins, where, group by, having, order by. + */ + #[Test] + public function bindingsAreCollectedInCorrectOrder(): void + { + $tableSubquery = RawExpression::make('SELECT id FROM base WHERE flag = ?', ['table-flag']); + $expressionCol = RawExpression::make('COALESCE(a, ?)', ['col-default']); + $joinWhereValue = 'join-val'; + $whereValue = 'where-val'; + $groupByExpr = RawExpression::make('FIELD(status, ?)', ['group-val']); + $havingValue = 99; + $orderByExpr = RawExpression::make('FIELD(priority, ?)', ['order-val']); + + $query = Select::from($tableSubquery) + ->columns('id', $expressionCol) + ->join('posts', function (JoinClause $join) use ($joinWhereValue) { + $join->on('base.id', '=', 'posts.base_id'); + $join->where('posts.active', '=', $joinWhereValue); + }) + ->where('status', '=', $whereValue) + ->groupBy($groupByExpr) + ->havingRaw('COUNT(*) > ?', [$havingValue]) + ->orderBy($orderByExpr) + ; + + $this->assertSame( + ['table-flag', 'col-default', $joinWhereValue, $whereValue, 'group-val', $havingValue, 'order-val'], + $query->getBindings(), + ); + } +} diff --git a/tests/Unit/Database/Query/UpdateTest.php b/tests/Unit/Database/Query/UpdateTest.php index fcd8593..b7be9aa 100644 --- a/tests/Unit/Database/Query/UpdateTest.php +++ b/tests/Unit/Database/Query/UpdateTest.php @@ -94,6 +94,54 @@ public function setWithWhereProducesCorrectSql(): void $this->assertSame(['John', 1], $query->getBindings()); } + /** + * - orWhere() on Update produces OR conjunction. + */ + #[Test] + public function orWhereProducesOrConjunction(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->where('role', '=', 'guest') + ->orWhere('age', '<', 18) + ; + + $this->assertSame( + 'UPDATE users SET status = ? WHERE role = ? OR age < ?', + $query->toSql(), + ); + $this->assertSame(['inactive', 'guest', 18], $query->getBindings()); + } + + /** + * - whereNull() on Update produces IS NULL clause. + */ + #[Test] + public function whereNullProducesIsNullClause(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->whereNull('deleted_at') + ; + + $this->assertSame('UPDATE users SET status = ? WHERE deleted_at IS NULL', $query->toSql()); + } + + /** + * - whereIn() on Update produces IN clause. + */ + #[Test] + public function whereInProducesInClause(): void + { + $query = Update::table('users') + ->set(['status' => 'inactive']) + ->whereIn('id', [1, 2, 3]) + ; + + $this->assertSame('UPDATE users SET status = ? WHERE id IN (?, ?, ?)', $query->toSql()); + $this->assertSame(['inactive', 1, 2, 3], $query->getBindings()); + } + // ------------------------------------------------------------------------- // orderBy() // ------------------------------------------------------------------------- diff --git a/tests/Unit/Database/Query/WriteResultTest.php b/tests/Unit/Database/Query/WriteResultTest.php new file mode 100644 index 0000000..96ec107 --- /dev/null +++ b/tests/Unit/Database/Query/WriteResultTest.php @@ -0,0 +1,70 @@ +assertSame(3, new WriteResult(3, '1')->affectedRows()); + } + + // ------------------------------------------------------------------------- + // lastInsertId() + // ------------------------------------------------------------------------- + + /** + * - lastInsertId() returns the ID string passed at construction. + */ + #[Test] + public function lastInsertIdReturnsIdString(): void + { + $this->assertSame('42', new WriteResult(1, '42')->lastInsertId()); + } + + /** + * - lastInsertId() returns null when no ID was provided. + */ + #[Test] + public function lastInsertIdReturnsNullWhenNoId(): void + { + $this->assertNull(new WriteResult(1, null)->lastInsertId()); + } + + // ------------------------------------------------------------------------- + // wasSuccessful() + // ------------------------------------------------------------------------- + + /** + * - wasSuccessful() returns true when at least one row was affected. + */ + #[Test] + public function wasSuccessfulReturnsTrueWhenRowsAffected(): void + { + $this->assertTrue(new WriteResult(1, null)->wasSuccessful()); + } + + /** + * - wasSuccessful() returns false when zero rows were affected. + */ + #[Test] + public function wasSuccessfulReturnsFalseWhenNoRowsAffected(): void + { + $this->assertFalse(new WriteResult(0, null)->wasSuccessful()); + } +} diff --git a/tests/Unit/Values/ValueGetterTest.php b/tests/Unit/Values/ValueGetterTest.php new file mode 100644 index 0000000..fc1e729 --- /dev/null +++ b/tests/Unit/Values/ValueGetterTest.php @@ -0,0 +1,200 @@ +assertSame('hello', ValueGetter::string('k', ['k' => 'hello'])); + } + + /** + * - string() casts a numeric int value to a string. + */ + #[Test] + public function stringCastsNumericToString(): void + { + $this->assertSame('42', ValueGetter::string('k', ['k' => 42])); + } + + /** + * - string() throws InvalidValueCastException for a non-castable value (bool). + */ + #[Test] + public function stringThrowsForNonCastableValue(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::string('k', ['k' => true]); + } + + // ------------------------------------------------------------------------- + // float() + // ------------------------------------------------------------------------- + + /** + * - float() returns the value unchanged when it is already a float. + */ + #[Test] + public function floatReturnsFloatValue(): void + { + $this->assertSame(3.14, ValueGetter::float('k', ['k' => 3.14])); + } + + /** + * - float() casts a numeric string to a float. + */ + #[Test] + public function floatCastsNumericStringToFloat(): void + { + $this->assertSame(3.14, ValueGetter::float('k', ['k' => '3.14'])); + } + + /** + * - float() throws InvalidValueCastException for a non-numeric string. + */ + #[Test] + public function floatThrowsForNonCastableValue(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::float('k', ['k' => 'not a number']); + } + + // ------------------------------------------------------------------------- + // bool() + // ------------------------------------------------------------------------- + + /** + * - bool() returns the value unchanged when it is already a boolean. + */ + #[Test] + public function boolReturnsBooleanValue(): void + { + $this->assertTrue(ValueGetter::bool('k', ['k' => true])); + $this->assertFalse(ValueGetter::bool('k', ['k' => false])); + } + + /** + * - bool() casts an int to a boolean (1 => true, 0 => false). + */ + #[Test] + public function boolCastsIntToBoolean(): void + { + $this->assertTrue(ValueGetter::bool('k', ['k' => 1])); + $this->assertFalse(ValueGetter::bool('k', ['k' => 0])); + } + + /** + * - bool() converts recognised truthy/falsy strings to a boolean. + */ + #[Test] + public function boolCastsStringToBoolean(): void + { + $this->assertTrue(ValueGetter::bool('k', ['k' => 'true'])); + $this->assertTrue(ValueGetter::bool('k', ['k' => '1'])); + $this->assertTrue(ValueGetter::bool('k', ['k' => 'yes'])); + $this->assertFalse(ValueGetter::bool('k', ['k' => 'false'])); + $this->assertFalse(ValueGetter::bool('k', ['k' => '0'])); + $this->assertFalse(ValueGetter::bool('k', ['k' => 'no'])); + } + + /** + * - bool() throws InvalidValueCastException for an unrecognised string value. + */ + #[Test] + public function boolThrowsForUnrecognisedString(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::bool('k', ['k' => 'maybe']); + } + + /** + * - bool() throws InvalidValueCastException for a non-castable type (float). + */ + #[Test] + public function boolThrowsForNonCastableValue(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::bool('k', ['k' => 3.14]); + } + + // ------------------------------------------------------------------------- + // array() + // ------------------------------------------------------------------------- + + /** + * - array() returns the value unchanged when it is already an array. + */ + #[Test] + public function arrayReturnsArrayValue(): void + { + $this->assertSame([1, 2], ValueGetter::array('k', ['k' => [1, 2]])); + } + + /** + * - array() decodes a valid JSON string into an array. + */ + #[Test] + public function arrayDecodesJsonString(): void + { + $this->assertSame([1, 2], ValueGetter::array('k', ['k' => '[1,2]'])); + } + + /** + * - array() throws InvalidValueCastException for a non-castable type (int). + */ + #[Test] + public function arrayThrowsForNonCastableValue(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::array('k', ['k' => 42]); + } + + // ------------------------------------------------------------------------- + // int() + // ------------------------------------------------------------------------- + + /** + * - int() returns the value unchanged when it is already an integer. + */ + #[Test] + public function intReturnsIntegerValue(): void + { + $this->assertSame(42, ValueGetter::int('k', ['k' => 42])); + } + + /** + * - int() casts a numeric string to an integer. + */ + #[Test] + public function intCastsNumericStringToInteger(): void + { + $this->assertSame(42, ValueGetter::int('k', ['k' => '42'])); + } + + /** + * - int() throws InvalidValueCastException for a non-numeric string. + */ + #[Test] + public function intThrowsForNonCastableValue(): void + { + $this->expectException(InvalidValueCastException::class); + ValueGetter::int('k', ['k' => 'not a number']); + } +} From 46ce7e54781b87813d8ec63b65dfd4d43753f32e Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 20:54:18 +0100 Subject: [PATCH 19/29] chore(database): Tidy-up of docblocks and fixes Removed unnecessary phpstan ignores, fixed an escaped mutant ignore line number, updated docblocks to use static over $this, and moved array_merges() out of foreach loops --- infection.json5 | 24 +++++++++---------- src/Database/Connection.php | 1 - .../Query/Concerns/HasGroupByClause.php | 5 ++-- src/Database/Query/Concerns/HasJoinClause.php | 18 ++++++++------ .../Query/Concerns/HasLimitClause.php | 4 ++-- .../Query/Concerns/HasOrderByClause.php | 9 +++---- src/Database/Query/Select.php | 6 ++--- 7 files changed, 36 insertions(+), 31 deletions(-) diff --git a/infection.json5 b/infection.json5 index 64d3dab..e15bf26 100644 --- a/infection.json5 +++ b/infection.json5 @@ -10,29 +10,29 @@ "html": "build/infection.html" }, "mutators": { - "@default" : true, - "TrueValue" : { + "@default" : true, + "TrueValue" : { "ignore": [ "Engine\\Container\\Container::lazy" ] }, - "FalseValue" : { + "FalseValue" : { "ignore": [ "Engine\\Container\\Container::resolve" ] }, - "Break_" : { + "Break_" : { "ignore": [ "Engine\\Container\\Container::collectDependencies::392" ] }, - "Coalesce" : { + "Coalesce" : { "ignore": [ "Engine\\Container\\Resolvers\\GenericResolver::resolve::56", "Engine\\Database\\ConnectionFactory::make::62" ] }, - "DecrementInteger" : { + "DecrementInteger" : { "ignore": [ "Engine\\Database\\Query\\Expressions\\ColumnIn::toSql::38", "Engine\\Database\\Query\\Expressions\\ColumnNotIn::toSql::38", @@ -41,7 +41,7 @@ "Engine\\Values\\ValueGetter::array::64" ] }, - "IncrementInteger" : { + "IncrementInteger" : { "ignore": [ "Engine\\Database\\Query\\Expressions\\ColumnIn::toSql::38", "Engine\\Database\\Query\\Expressions\\ColumnNotIn::toSql::38", @@ -61,25 +61,25 @@ "Engine\\Database\\Query\\Concerns\\HasHavingClause::hasHavingClause::77" ] }, - "MethodCallRemoval": { + "MethodCallRemoval" : { "ignore": [ "Engine\\Database\\Connection::transaction::107" ] }, - "ReturnRemoval" : { + "ReturnRemoval" : { "ignore": [ "Engine\\Container\\Container::invokeClassMethod::350", - "Engine\\Database\\Query\\Concerns\\HasJoinClause::buildJoinClause::94", + "Engine\\Database\\Query\\Concerns\\HasJoinClause::buildJoinClause::97", "Engine\\Values\\ValueGetter::float::110", "Engine\\Values\\ValueGetter::int::135" ] }, - "MatchArmRemoval": { + "MatchArmRemoval" : { "ignore": [ "Engine\\Database\\ConnectionFactory::createPdo::107" ] }, - "IfNegation" : { + "IfNegation" : { "ignore": [ "Engine\\Container\\Container::invokeClassMethod::349" ] diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 3bb29ef..4fac21d 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -19,7 +19,6 @@ { public function __construct( public string $name, - // @phpstan-ignore property.onlyWritten private PDO $pdo, ) { } diff --git a/src/Database/Query/Concerns/HasGroupByClause.php b/src/Database/Query/Concerns/HasGroupByClause.php index 05a404f..d177aa1 100644 --- a/src/Database/Query/Concerns/HasGroupByClause.php +++ b/src/Database/Query/Concerns/HasGroupByClause.php @@ -49,10 +49,11 @@ private function getGroupByBindings(): array foreach ($this->groups as $group) { if ($group instanceof Expression) { - $bindings = array_merge($bindings, $group->getBindings()); + $bindings[] = $group->getBindings(); } } - return $bindings; + /** @var array */ + return array_merge(...$bindings); } } diff --git a/src/Database/Query/Concerns/HasJoinClause.php b/src/Database/Query/Concerns/HasJoinClause.php index dd9112e..99bee75 100644 --- a/src/Database/Query/Concerns/HasJoinClause.php +++ b/src/Database/Query/Concerns/HasJoinClause.php @@ -21,7 +21,7 @@ trait HasJoinClause * @param string|null $operator * @param string|null $second * - * @return $this + * @return static */ public function join(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static { @@ -36,7 +36,7 @@ public function join(string $table, Closure|string $first, ?string $operator = n * @param string|null $operator * @param string|null $second * - * @return $this + * @return static */ public function leftJoin(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static { @@ -51,7 +51,7 @@ public function leftJoin(string $table, Closure|string $first, ?string $operator * @param string|null $operator * @param string|null $second * - * @return $this + * @return static */ public function rightJoin(string $table, Closure|string $first, ?string $operator = null, ?string $second = null): static { @@ -63,7 +63,7 @@ public function rightJoin(string $table, Closure|string $first, ?string $operato * * @param string $table * - * @return $this + * @return static */ public function crossJoin(string $table): static { @@ -79,7 +79,10 @@ private function addJoin(string $type, string $table, Closure|string $first, ?st if ($first instanceof Closure) { $first($clause); } else { - /** @var string $operator */ + /** + * @var string $operator + * @var string $second + */ $clause->on($first, $operator, $second); } @@ -117,9 +120,10 @@ private function getJoinBindings(): array $bindings = []; foreach ($this->joins as $join) { - $bindings = array_merge($bindings, $join['clause']->getBindings()); + $bindings[] = $join['clause']->getBindings(); } - return $bindings; + /** @var array */ + return array_merge(...$bindings); } } diff --git a/src/Database/Query/Concerns/HasLimitClause.php b/src/Database/Query/Concerns/HasLimitClause.php index 7bb0c58..e9b5690 100644 --- a/src/Database/Query/Concerns/HasLimitClause.php +++ b/src/Database/Query/Concerns/HasLimitClause.php @@ -14,7 +14,7 @@ trait HasLimitClause * * @param int $limit * - * @return $this + * @return static */ public function limit(int $limit): static { @@ -28,7 +28,7 @@ public function limit(int $limit): static * * @param int $offset * - * @return $this + * @return static */ public function offset(int $offset): static { diff --git a/src/Database/Query/Concerns/HasOrderByClause.php b/src/Database/Query/Concerns/HasOrderByClause.php index 08600b3..a2bce11 100644 --- a/src/Database/Query/Concerns/HasOrderByClause.php +++ b/src/Database/Query/Concerns/HasOrderByClause.php @@ -18,7 +18,7 @@ trait HasOrderByClause * @param string|Expression $column * @param string $direction * - * @return $this + * @return static */ public function orderBy(Expression|string $column, string $direction = 'asc'): static { @@ -36,7 +36,7 @@ private function buildOrderByClause(): string return ''; } - $clauses = array_map(function (array $order): string { + $clauses = array_map(static function (array $order): string { $col = $order['column'] instanceof Expression ? $order['column']->toSql() : $order['column']; @@ -56,10 +56,11 @@ private function getOrderByBindings(): array foreach ($this->orders as $order) { if ($order['column'] instanceof Expression) { - $bindings = array_merge($bindings, $order['column']->getBindings()); + $bindings[] = $order['column']->getBindings(); } } - return $bindings; + /** @var array */ + return array_merge(...$bindings); } } diff --git a/src/Database/Query/Select.php b/src/Database/Query/Select.php index 3b1ceab..5a3f919 100644 --- a/src/Database/Query/Select.php +++ b/src/Database/Query/Select.php @@ -43,7 +43,7 @@ private function __construct( * * @param string|Expression ...$columns * - * @return $this + * @return static */ public function columns(Expression|string ...$columns): self { @@ -57,7 +57,7 @@ public function columns(Expression|string ...$columns): self * * @param string|Expression $column * - * @return $this + * @return static */ public function addColumn(Expression|string $column): self { @@ -69,7 +69,7 @@ public function addColumn(Expression|string $column): self /** * Set the query to select distinct rows. * - * @return $this + * @return static */ public function distinct(): self { From e31b083b4118b15c644250f694cea63e4c6ba876 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Mon, 30 Mar 2026 21:05:58 +0100 Subject: [PATCH 20/29] ci(github): Fix mariadb 11.4 tests on GitHub ci(github): Remove push trigger for code coverage workflow ci(github): Fix mariadb 11.4 healthcheck --- .github/workflows/codecoverage.yml | 4 +++- .github/workflows/integration.yml | 16 ++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 5add071..8b2f0b8 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -1,5 +1,7 @@ name: Code Coverage -on: [ push, pull_request ] + +on: [ pull_request ] + jobs: run: runs-on: ubuntu-latest diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0239895..4f256e8 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -92,11 +92,15 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - image: - - mysql:8.0 - - mysql:8.4 - - mariadb:10.11 - - mariadb:11.4 + include: + - image: mysql:8.0 + health_cmd: mysqladmin ping + - image: mysql:8.4 + health_cmd: mysqladmin ping + - image: mariadb:10.11 + health_cmd: mysqladmin ping + - image: mariadb:11.4 + health_cmd: healthcheck.sh --connect --innodb_initialized services: database: image: ${{ matrix.image }} @@ -108,7 +112,7 @@ jobs: ports: - 3306:3306 options: >- - --health-cmd="mysqladmin ping" + --health-cmd="${{ matrix.health_cmd }}" --health-interval=5s --health-timeout=5s --health-retries=10 From 0b62b774c357992ee13bb40c14559ba4f187f4be Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 31 Mar 2026 15:59:06 +0100 Subject: [PATCH 21/29] chore(database:connections): Add a custom resolver for database connections --- src/Database/Attributes/Database.php | 27 +++++++++++ src/Database/DatabaseResolver.php | 67 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/Database/Attributes/Database.php create mode 100644 src/Database/DatabaseResolver.php diff --git a/src/Database/Attributes/Database.php b/src/Database/Attributes/Database.php new file mode 100644 index 0000000..e11fcd3 --- /dev/null +++ b/src/Database/Attributes/Database.php @@ -0,0 +1,27 @@ + + */ +final readonly class DatabaseResolver implements Resolver +{ + /** + * @var ConnectionFactory + */ + private ConnectionFactory $factory; + + public function __construct(ConnectionFactory $factory) + { + $this->factory = $factory; + } + + /** + * Resolve a dependency. + * + * @template TType of \Engine\Database\Connection + * + * @param \Engine\Container\Dependency $dependency + * @param Container $container + * @param array $arguments + * + * @return Connection + */ + public function resolve(Dependency $dependency, Container $container, array $arguments = []): Connection + { + $database = $dependency->resolvable; + + if (! $database instanceof Database) { + throw new RuntimeException(sprintf( + 'The database connection resolver can only resolve parameters using the "%s" attribute.', + Database::class, + )); + } + + if ( + ! $dependency->type instanceof ReflectionNamedType + || $dependency->type->getName() !== Connection::class + ) { + throw new RuntimeException(sprintf( + 'The database connection resolver can only resolve parameters of the type "%s".', + Connection::class, + )); + } + + return $this->factory->make($database->name); + } +} From 1f8656e4f77b57d2f70a1052d4f068607c14a054 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 31 Mar 2026 17:41:10 +0100 Subject: [PATCH 22/29] feat(engine:database): Add schema abstraction for DDL generation Schema contract, Column (21 type factories, modifiers, named reference), Index (primary, unique, index, fulltext, foreign key), Blueprint (alter operations), and Table (create, alter, drop, shortcuts). --- src/Database/Contracts/Expression.php | 7 + src/Database/Contracts/Query.php | 7 + src/Database/Contracts/Schema.php | 14 + src/Database/Query/Raw.php | 5 + src/Database/Schema/Blueprint.php | 113 ++++ src/Database/Schema/Column.php | 410 ++++++++++++++ src/Database/Schema/Index.php | 199 +++++++ src/Database/Schema/Table.php | 277 ++++++++++ tests/Unit/Database/Schema/BlueprintTest.php | 157 ++++++ tests/Unit/Database/Schema/ColumnTest.php | 528 +++++++++++++++++++ tests/Unit/Database/Schema/IndexTest.php | 266 ++++++++++ tests/Unit/Database/Schema/TableTest.php | 327 ++++++++++++ 12 files changed, 2310 insertions(+) create mode 100644 src/Database/Contracts/Schema.php create mode 100644 src/Database/Schema/Blueprint.php create mode 100644 src/Database/Schema/Column.php create mode 100644 src/Database/Schema/Index.php create mode 100644 src/Database/Schema/Table.php create mode 100644 tests/Unit/Database/Schema/BlueprintTest.php create mode 100644 tests/Unit/Database/Schema/ColumnTest.php create mode 100644 tests/Unit/Database/Schema/IndexTest.php create mode 100644 tests/Unit/Database/Schema/TableTest.php diff --git a/src/Database/Contracts/Expression.php b/src/Database/Contracts/Expression.php index b8b3882..3da3098 100644 --- a/src/Database/Contracts/Expression.php +++ b/src/Database/Contracts/Expression.php @@ -2,6 +2,13 @@ namespace Engine\Database\Contracts; +/** + * Expression Contract + * ------------------- + * + * Represents an SQL expression that can be transformed into a query string + * and supports parameter bindings. + */ interface Expression { /** diff --git a/src/Database/Contracts/Query.php b/src/Database/Contracts/Query.php index 0b51a90..07c837b 100644 --- a/src/Database/Contracts/Query.php +++ b/src/Database/Contracts/Query.php @@ -2,6 +2,13 @@ namespace Engine\Database\Contracts; +/** + * Query Contract + * -------------- + * + * Represents an SQL query, a specific type of {@see Expression}. Used to + * typehint when the expression should be treated as a full query. + */ interface Query extends Expression { } diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php new file mode 100644 index 0000000..04635a9 --- /dev/null +++ b/src/Database/Contracts/Schema.php @@ -0,0 +1,14 @@ + $bindings diff --git a/src/Database/Schema/Blueprint.php b/src/Database/Schema/Blueprint.php new file mode 100644 index 0000000..b4e0d5a --- /dev/null +++ b/src/Database/Schema/Blueprint.php @@ -0,0 +1,113 @@ +}> + */ + private array $operations = []; + + /** + * Add a column or index to the table. + * + * @return static + */ + public function add(Column|Index $expression): self + { + $this->operations[] = [ + 'action' => 'add', + 'expression' => $expression, + 'details' => [], + ]; + + return $this; + } + + /** + * Modify a column in the table. + * + * @return static + */ + public function modify(Column $column): self + { + $this->operations[] = [ + 'action' => 'modify', + 'expression' => $column, + 'details' => [], + ]; + + return $this; + } + + /** + * Drop a column or index from the table. + * + * @return static + */ + public function drop(Column|Index $reference): self + { + $this->operations[] = [ + 'action' => 'drop', + 'expression' => $reference, + 'details' => [], + ]; + + return $this; + } + + /** + * Rename a column in the table. + * + * @return static + */ + public function rename(Column $reference, string $newName): self + { + $this->operations[] = [ + 'action' => 'rename', + 'expression' => $reference, + 'details' => ['to' => $newName], + ]; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + return implode(', ', array_map($this->renderOperation(...), $this->operations)); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } + + /** + * @param array{action: 'add'|'modify'|'drop'|'rename', expression: Column|Index, details: array} $operation + */ + private function renderOperation(array $operation): string + { + $expression = $operation['expression']; + + return match ($operation['action']) { + 'add' => ($expression instanceof Column ? 'ADD COLUMN ' : 'ADD ') . $expression->toSql(), + 'modify' => 'MODIFY COLUMN ' . $expression->toSql(), + 'drop' => ($expression instanceof Column ? 'DROP COLUMN ' : 'DROP INDEX ') . $expression->toSql(), + 'rename' => "RENAME COLUMN {$expression->toSql()} TO `{$operation['details']['to']}`", + }; + } +} diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php new file mode 100644 index 0000000..d8fca06 --- /dev/null +++ b/src/Database/Schema/Column.php @@ -0,0 +1,410 @@ + $values + */ + public static function enum(string $name, array $values): self + { + $quoted = implode(', ', array_map( + fn (string $v) => "'" . str_replace("'", "''", $v) . "'", + $values, + )); + + return new self($name, "ENUM({$quoted})"); + } + + /** + * Create a named reference to an existing column, for use in + * Blueprint drop and rename operations. Produces only the + * backtick-quoted name, with no type or modifiers. + */ + public static function named(string $name): self + { + $column = new self($name, ''); + $column->isNamedRef = true; + + return $column; + } + + private bool $isNullable = false; + + private bool $hasDefault = false; + + private bool|Expression|float|int|string|null $defaultValue = null; + + private bool $isUnsigned = false; + + private bool $isAutoInc = false; + + private ?string $comment = null; + + private ?string $charset = null; + + private ?string $collation = null; + + private ?string $afterColumn = null; + + private bool $isFirst = false; + + private bool $isNamedRef = false; + + private function __construct( + private readonly string $name, + private readonly string $type, + ) { + } + + /** + * Mark the column as nullable. + * + * @return static + */ + public function nullable(): self + { + $this->isNullable = true; + + return $this; + } + + /** + * Set the default value for the column. + * + * Accepts scalar values for literal defaults, or an Expression + * for raw SQL defaults like CURRENT_TIMESTAMP. + * + * @return static + */ + public function default(bool|Expression|float|int|string|null $value): self + { + $this->hasDefault = true; + $this->defaultValue = $value; + + return $this; + } + + /** + * Mark the column as unsigned. + * + * @return static + */ + public function unsigned(): self + { + $this->isUnsigned = true; + + return $this; + } + + /** + * Mark the column as auto-incrementing. + * + * @return static + */ + public function autoIncrement(): self + { + $this->isAutoInc = true; + + return $this; + } + + /** + * Set a comment on the column. + * + * @return static + */ + public function comment(string $comment): self + { + $this->comment = $comment; + + return $this; + } + + /** + * Set the character set for the column. + * + * @return static + */ + public function charset(string $charset): self + { + $this->charset = $charset; + + return $this; + } + + /** + * Set the collation for the column. + * + * @return static + */ + public function collation(string $collation): self + { + $this->collation = $collation; + + return $this; + } + + /** + * Position the column after an existing column. + * + * @return static + */ + public function after(string $column): self + { + $this->afterColumn = $column; + + return $this; + } + + /** + * Position the column first in the table. + * + * @return static + */ + public function first(): self + { + $this->isFirst = true; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + if ($this->isNamedRef) { + return "`{$this->name}`"; + } + + $parts = ["`{$this->name}`", $this->type]; + + if ($this->isUnsigned) { + $parts[] = 'UNSIGNED'; + } + + $parts[] = $this->isNullable ? 'NULL' : 'NOT NULL'; + + if ($this->hasDefault) { + $parts[] = 'DEFAULT ' . $this->renderDefault(); + } + + if ($this->isAutoInc) { + $parts[] = 'AUTO_INCREMENT'; + } + + if ($this->comment !== null) { + $parts[] = "COMMENT '" . str_replace("'", "''", $this->comment) . "'"; + } + + if ($this->charset !== null) { + $parts[] = "CHARACTER SET {$this->charset}"; + } + + if ($this->collation !== null) { + $parts[] = "COLLATE {$this->collation}"; + } + + if ($this->isFirst) { + $parts[] = 'FIRST'; + } + + if ($this->afterColumn !== null) { + $parts[] = "AFTER `{$this->afterColumn}`"; + } + + return implode(' ', $parts); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } + + private function renderDefault(): string + { + if ($this->defaultValue instanceof Expression) { + return $this->defaultValue->toSql(); + } + + if ($this->defaultValue === null) { + return 'NULL'; + } + + if (is_bool($this->defaultValue)) { + return $this->defaultValue ? 'TRUE' : 'FALSE'; + } + + if (is_int($this->defaultValue) || is_float($this->defaultValue)) { + return (string) $this->defaultValue; + } + + return "'" . str_replace("'", "''", $this->defaultValue) . "'"; + } +} diff --git a/src/Database/Schema/Index.php b/src/Database/Schema/Index.php new file mode 100644 index 0000000..42d444f --- /dev/null +++ b/src/Database/Schema/Index.php @@ -0,0 +1,199 @@ + $columns + */ + public static function primary(array|string $columns): self + { + return new self('primary', null, (array) $columns); + } + + /** + * Create a UNIQUE INDEX. + * + * @param list $columns + */ + public static function unique(string $name, array|string $columns): self + { + return new self('unique', $name, (array) $columns); + } + + /** + * Create an INDEX. + * + * @param list $columns + */ + public static function index(string $name, array|string $columns): self + { + return new self('index', $name, (array) $columns); + } + + /** + * Create a FULLTEXT INDEX. + * + * @param list $columns + */ + public static function fulltext(string $name, array|string $columns): self + { + return new self('fulltext', $name, (array) $columns); + } + + /** + * Create a FOREIGN KEY constraint. + * + * @param list $columns + */ + public static function foreign(string $name, array|string $columns): self + { + return new self('foreign', $name, (array) $columns); + } + + /** + * Create a named reference to an existing index, for use in + * Blueprint drop operations. Produces only the backtick-quoted name. + */ + public static function named(string $name): self + { + return new self('named', $name, []); + } + + private ?string $referenceTable = null; + + /** + * @var list + */ + private array $referenceColumns = []; + + private ?string $deleteAction = null; + + private ?string $updateAction = null; + + /** + * @param 'primary'|'unique'|'index'|'fulltext'|'foreign'|'named' $type + * @param list $columns + */ + private function __construct( + private readonly string $type, + private readonly ?string $name, + private readonly array $columns, + ) { + } + + /** + * Set the referenced table and columns for a foreign key. + * + * @param string|list $columns + * + * @return static + */ + public function references(string $table, array|string $columns): self + { + $this->referenceTable = $table; + $this->referenceColumns = (array) $columns; + + return $this; + } + + /** + * Set the ON DELETE action for a foreign key. The action + * string is automatically uppercased. + * + * @return static + */ + public function onDelete(string $action): self + { + $this->deleteAction = strtoupper($action); + + return $this; + } + + /** + * Set the ON UPDATE action for a foreign key. The action + * string is automatically uppercased. + * + * @return static + */ + public function onUpdate(string $action): self + { + $this->updateAction = strtoupper($action); + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + if ($this->type === 'named') { + return "`{$this->name}`"; + } + + if ($this->type === 'foreign') { + return $this->buildForeignKeySql(); + } + + $quotedColumns = $this->quoteColumns($this->columns); + + return match ($this->type) { + 'primary' => "PRIMARY KEY ({$quotedColumns})", + 'unique' => "UNIQUE INDEX `{$this->name}` ({$quotedColumns})", + 'index' => "INDEX `{$this->name}` ({$quotedColumns})", + 'fulltext' => "FULLTEXT INDEX `{$this->name}` ({$quotedColumns})", + }; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } + + private function buildForeignKeySql(): string + { + $quotedColumns = $this->quoteColumns($this->columns); + + $sql = "CONSTRAINT `{$this->name}` FOREIGN KEY ({$quotedColumns})"; + + if ($this->referenceTable !== null) { + $refColumns = $this->quoteColumns($this->referenceColumns); + $sql .= " REFERENCES `{$this->referenceTable}` ({$refColumns})"; + } + + if ($this->deleteAction !== null) { + $sql .= " ON DELETE {$this->deleteAction}"; + } + + if ($this->updateAction !== null) { + $sql .= " ON UPDATE {$this->updateAction}"; + } + + return $sql; + } + + /** + * @param list $columns + */ + private function quoteColumns(array $columns): string + { + return implode(', ', array_map( + fn (string $column) => "`{$column}`", + $columns, + )); + } +} diff --git a/src/Database/Schema/Table.php b/src/Database/Schema/Table.php new file mode 100644 index 0000000..f986027 --- /dev/null +++ b/src/Database/Schema/Table.php @@ -0,0 +1,277 @@ + $definitions + */ + public static function create(string $table, array $definitions): self + { + return new self(self::MODE_CREATE, $table, $definitions); + } + + /** + * Alter an existing table using a blueprint closure. + * + * @param Closure(Blueprint): void $callback + */ + public static function alter(string $table, Closure $callback): self + { + $blueprint = new Blueprint(); + $callback($blueprint); + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + /** + * Drop a table. + */ + public static function drop(string $table): self + { + return new self(self::MODE_DROP, $table); + } + + /** + * Add columns to an existing table. + * + * @param list $columns + */ + public static function addColumns(string $table, array $columns): self + { + $blueprint = new Blueprint(); + + foreach ($columns as $column) { + $blueprint->add($column); + } + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + /** + * Drop columns from an existing table. + * + * @param list $names + */ + public static function dropColumns(string $table, array $names): self + { + $blueprint = new Blueprint(); + + foreach ($names as $name) { + $blueprint->drop(Column::named($name)); + } + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + /** + * Add indexes to an existing table. + * + * @param list $indexes + */ + public static function addIndexes(string $table, array $indexes): self + { + $blueprint = new Blueprint(); + + foreach ($indexes as $index) { + $blueprint->add($index); + } + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + /** + * Drop indexes from an existing table. + * + * @param list $names + */ + public static function dropIndexes(string $table, array $names): self + { + $blueprint = new Blueprint(); + + foreach ($names as $name) { + $blueprint->drop(Index::named($name)); + } + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + /** + * Rename a column in an existing table. + */ + public static function renameColumn(string $table, string $from, string $to): self + { + $blueprint = new Blueprint(); + $blueprint->rename(Column::named($from), $to); + + return new self(self::MODE_ALTER, $table, blueprint: $blueprint); + } + + private bool $ifNotExists = false; + + private bool $ifExists = false; + + private ?string $engine = null; + + private ?string $charset = null; + + private ?string $collation = null; + + /** + * @param self::MODE_* $mode + * @param list $definitions + */ + private function __construct( + private readonly string $mode, + private readonly string $table, + private readonly array $definitions = [], + private readonly ?Blueprint $blueprint = null, + ) { + } + + /** + * Add the IF NOT EXISTS modifier (create mode). + * + * @return static + */ + public function ifNotExists(): self + { + $this->ifNotExists = true; + + return $this; + } + + /** + * Add the IF EXISTS modifier (drop mode). + * + * @return static + */ + public function ifExists(): self + { + $this->ifExists = true; + + return $this; + } + + /** + * Set the table engine. + * + * @return static + */ + public function engine(string $engine): self + { + $this->engine = $engine; + + return $this; + } + + /** + * Set the default character set. + * + * @return static + */ + public function charset(string $charset): self + { + $this->charset = $charset; + + return $this; + } + + /** + * Set the default collation. + * + * @return static + */ + public function collation(string $collation): self + { + $this->collation = $collation; + + return $this; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + return match ($this->mode) { + self::MODE_CREATE => $this->buildCreate(), + self::MODE_ALTER => $this->buildAlter(), + self::MODE_DROP => $this->buildDrop(), + }; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return []; + } + + private function buildCreate(): string + { + $prefix = 'CREATE TABLE'; + + if ($this->ifNotExists) { + $prefix .= ' IF NOT EXISTS'; + } + + $definitionSql = implode(",\n ", array_map( + fn (Expression $def) => $def->toSql(), + $this->definitions, + )); + + $sql = "{$prefix} `{$this->table}` (\n {$definitionSql}\n)"; + + if ($this->engine !== null) { + $sql .= " ENGINE = {$this->engine}"; + } + + if ($this->charset !== null) { + $sql .= " DEFAULT CHARACTER SET {$this->charset}"; + } + + if ($this->collation !== null) { + $sql .= " DEFAULT COLLATE {$this->collation}"; + } + + return $sql; + } + + private function buildAlter(): string + { + assert($this->blueprint !== null); + + return "ALTER TABLE `{$this->table}` {$this->blueprint->toSql()}"; + } + + private function buildDrop(): string + { + $prefix = 'DROP TABLE'; + + if ($this->ifExists) { + $prefix .= ' IF EXISTS'; + } + + return "{$prefix} `{$this->table}`"; + } +} diff --git a/tests/Unit/Database/Schema/BlueprintTest.php b/tests/Unit/Database/Schema/BlueprintTest.php new file mode 100644 index 0000000..0952d3b --- /dev/null +++ b/tests/Unit/Database/Schema/BlueprintTest.php @@ -0,0 +1,157 @@ +add(Column::string('bio', 500)->nullable()); + + $this->assertSame('ADD COLUMN `bio` VARCHAR(500) NULL', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + /** + * - add() with an Index produces ADD clause with index SQL. + */ + #[Test] + public function addIndexProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->add(Index::unique('idx_email', 'email')); + + $this->assertSame('ADD UNIQUE INDEX `idx_email` (`email`)', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + /** + * - add() with a foreign key Index produces ADD CONSTRAINT clause. + */ + #[Test] + public function addForeignKeyProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->add( + Index::foreign('fk_user_id', 'user_id') + ->references('users', 'id') + ->onDelete('CASCADE'), + ); + + $this->assertSame( + 'ADD CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE', + $blueprint->toSql(), + ); + $this->assertSame([], $blueprint->getBindings()); + } + + // ------------------------------------------------------------------------- + // modify() + // ------------------------------------------------------------------------- + + /** + * - modify() produces MODIFY COLUMN clause. + */ + #[Test] + public function modifyProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->modify(Column::string('email', 500)); + + $this->assertSame('MODIFY COLUMN `email` VARCHAR(500) NOT NULL', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + // ------------------------------------------------------------------------- + // drop() + // ------------------------------------------------------------------------- + + /** + * - drop() with a Column produces DROP COLUMN clause. + */ + #[Test] + public function dropColumnProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->drop(Column::named('avatar')); + + $this->assertSame('DROP COLUMN `avatar`', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + /** + * - drop() with an Index produces DROP INDEX clause. + */ + #[Test] + public function dropIndexProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->drop(Index::named('idx_email')); + + $this->assertSame('DROP INDEX `idx_email`', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + // ------------------------------------------------------------------------- + // rename() + // ------------------------------------------------------------------------- + + /** + * - rename() produces RENAME COLUMN clause. + */ + #[Test] + public function renameProducesCorrectSql(): void + { + $blueprint = new Blueprint(); + $blueprint->rename(Column::named('name'), 'display_name'); + + $this->assertSame('RENAME COLUMN `name` TO `display_name`', $blueprint->toSql()); + $this->assertSame([], $blueprint->getBindings()); + } + + // ------------------------------------------------------------------------- + // Multiple operations + // ------------------------------------------------------------------------- + + /** + * - Multiple operations are joined with commas. + */ + #[Test] + public function multipleOperationsJoinedWithCommas(): void + { + $blueprint = new Blueprint(); + $blueprint + ->add(Column::string('bio', 500)->nullable()) + ->modify(Column::string('email', 500)) + ->drop(Column::named('avatar')) + ->rename(Column::named('name'), 'display_name') + ; + + $this->assertSame( + 'ADD COLUMN `bio` VARCHAR(500) NULL' + . ', MODIFY COLUMN `email` VARCHAR(500) NOT NULL' + . ', DROP COLUMN `avatar`' + . ', RENAME COLUMN `name` TO `display_name`', + $blueprint->toSql(), + ); + $this->assertSame([], $blueprint->getBindings()); + } +} diff --git a/tests/Unit/Database/Schema/ColumnTest.php b/tests/Unit/Database/Schema/ColumnTest.php new file mode 100644 index 0000000..b0a1ef9 --- /dev/null +++ b/tests/Unit/Database/Schema/ColumnTest.php @@ -0,0 +1,528 @@ +assertSame('`id` BIGINT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - int produces INT column definition. + */ + #[Test] + public function intProducesCorrectSql(): void + { + $column = Column::int('age'); + + $this->assertSame('`age` INT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - smallInt produces SMALLINT column definition. + */ + #[Test] + public function smallIntProducesCorrectSql(): void + { + $column = Column::smallInt('count'); + + $this->assertSame('`count` SMALLINT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - tinyInt produces TINYINT column definition. + */ + #[Test] + public function tinyIntProducesCorrectSql(): void + { + $column = Column::tinyInt('flag'); + + $this->assertSame('`flag` TINYINT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - decimal produces DECIMAL(precision, scale) column definition. + */ + #[Test] + public function decimalProducesCorrectSql(): void + { + $column = Column::decimal('price', 10, 2); + + $this->assertSame('`price` DECIMAL(10, 2) NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - float produces FLOAT column definition. + */ + #[Test] + public function floatProducesCorrectSql(): void + { + $column = Column::float('rating'); + + $this->assertSame('`rating` FLOAT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - double produces DOUBLE column definition. + */ + #[Test] + public function doubleProducesCorrectSql(): void + { + $column = Column::double('precise'); + + $this->assertSame('`precise` DOUBLE NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + // ------------------------------------------------------------------------- + // String type factories + // ------------------------------------------------------------------------- + + /** + * - string produces VARCHAR(length) column definition. + */ + #[Test] + public function stringProducesCorrectSql(): void + { + $column = Column::string('name', 255); + + $this->assertSame('`name` VARCHAR(255) NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - text produces TEXT column definition. + */ + #[Test] + public function textProducesCorrectSql(): void + { + $column = Column::text('body'); + + $this->assertSame('`body` TEXT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - mediumText produces MEDIUMTEXT column definition. + */ + #[Test] + public function mediumTextProducesCorrectSql(): void + { + $column = Column::mediumText('content'); + + $this->assertSame('`content` MEDIUMTEXT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - longText produces LONGTEXT column definition. + */ + #[Test] + public function longTextProducesCorrectSql(): void + { + $column = Column::longText('data'); + + $this->assertSame('`data` LONGTEXT NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + // ------------------------------------------------------------------------- + // Date/time type factories + // ------------------------------------------------------------------------- + + /** + * - date produces DATE column definition. + */ + #[Test] + public function dateProducesCorrectSql(): void + { + $column = Column::date('birthday'); + + $this->assertSame('`birthday` DATE NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - datetime produces DATETIME column definition. + */ + #[Test] + public function datetimeProducesCorrectSql(): void + { + $column = Column::datetime('published_at'); + + $this->assertSame('`published_at` DATETIME NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - timestamp produces TIMESTAMP column definition. + */ + #[Test] + public function timestampProducesCorrectSql(): void + { + $column = Column::timestamp('created_at'); + + $this->assertSame('`created_at` TIMESTAMP NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - time produces TIME column definition. + */ + #[Test] + public function timeProducesCorrectSql(): void + { + $column = Column::time('duration'); + + $this->assertSame('`duration` TIME NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + // ------------------------------------------------------------------------- + // Binary/blob type factories + // ------------------------------------------------------------------------- + + /** + * - binary produces BINARY column definition. + */ + #[Test] + public function binaryProducesCorrectSql(): void + { + $column = Column::binary('hash'); + + $this->assertSame('`hash` BINARY NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - blob produces BLOB column definition. + */ + #[Test] + public function blobProducesCorrectSql(): void + { + $column = Column::blob('data'); + + $this->assertSame('`data` BLOB NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + // ------------------------------------------------------------------------- + // JSON / boolean / enum type factories + // ------------------------------------------------------------------------- + + /** + * - json produces JSON column definition. + */ + #[Test] + public function jsonProducesCorrectSql(): void + { + $column = Column::json('metadata'); + + $this->assertSame('`metadata` JSON NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - boolean produces BOOLEAN column definition. + */ + #[Test] + public function booleanProducesCorrectSql(): void + { + $column = Column::boolean('active'); + + $this->assertSame('`active` BOOLEAN NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - enum produces ENUM column definition with quoted values. + */ + #[Test] + public function enumProducesCorrectSql(): void + { + $column = Column::enum('status', ['active', 'inactive', 'pending']); + + $this->assertSame("`status` ENUM('active', 'inactive', 'pending') NOT NULL", $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + + /** + * - enum escapes single quotes in values. + */ + #[Test] + public function enumEscapesSingleQuotesInValues(): void + { + $column = Column::enum('label', ["it's", "they're"]); + + $this->assertSame("`label` ENUM('it''s', 'they''re') NOT NULL", $column->toSql()); + } + + // ------------------------------------------------------------------------- + // Modifiers + // ------------------------------------------------------------------------- + + /** + * - nullable() produces NULL instead of NOT NULL. + */ + #[Test] + public function nullableProducesNullSql(): void + { + $column = Column::string('email', 255)->nullable(); + + $this->assertSame('`email` VARCHAR(255) NULL', $column->toSql()); + } + + /** + * - default() with a string value produces DEFAULT 'value'. + */ + #[Test] + public function defaultWithStringProducesCorrectSql(): void + { + $column = Column::string('status', 20)->default('active'); + + $this->assertSame("`status` VARCHAR(20) NOT NULL DEFAULT 'active'", $column->toSql()); + } + + /** + * - default() with null produces DEFAULT NULL. + */ + #[Test] + public function defaultWithNullProducesCorrectSql(): void + { + $column = Column::string('bio', 500)->nullable()->default(null); + + $this->assertSame('`bio` VARCHAR(500) NULL DEFAULT NULL', $column->toSql()); + } + + /** + * - default() with an integer produces DEFAULT N. + */ + #[Test] + public function defaultWithIntProducesCorrectSql(): void + { + $column = Column::int('count')->default(0); + + $this->assertSame('`count` INT NOT NULL DEFAULT 0', $column->toSql()); + } + + /** + * - default() with a boolean produces DEFAULT TRUE/FALSE. + */ + #[Test] + public function defaultWithBoolProducesCorrectSql(): void + { + $column = Column::boolean('active')->default(true); + + $this->assertSame('`active` BOOLEAN NOT NULL DEFAULT TRUE', $column->toSql()); + + $column = Column::boolean('deleted')->default(false); + + $this->assertSame('`deleted` BOOLEAN NOT NULL DEFAULT FALSE', $column->toSql()); + } + + /** + * - default() with a float value produces DEFAULT N.N. + */ + #[Test] + public function defaultWithFloatProducesCorrectSql(): void + { + $column = Column::float('rating')->default(3.14); + + $this->assertSame('`rating` FLOAT NOT NULL DEFAULT 3.14', $column->toSql()); + } + + /** + * - default() with an Expression renders it inline. + */ + #[Test] + public function defaultWithExpressionRendersInline(): void + { + $column = Column::timestamp('created_at')->default( + Raw::from('CURRENT_TIMESTAMP'), + ); + + $this->assertSame('`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', $column->toSql()); + } + + /** + * - default() with a string containing a single quote escapes it. + */ + #[Test] + public function defaultWithSingleQuoteEscapesCorrectly(): void + { + $column = Column::string('greeting', 100)->default("it's"); + + $this->assertSame("`greeting` VARCHAR(100) NOT NULL DEFAULT 'it''s'", $column->toSql()); + } + + /** + * - unsigned() produces UNSIGNED before NULL/NOT NULL. + */ + #[Test] + public function unsignedProducesCorrectSql(): void + { + $column = Column::bigInt('id')->unsigned(); + + $this->assertSame('`id` BIGINT UNSIGNED NOT NULL', $column->toSql()); + } + + /** + * - autoIncrement() produces AUTO_INCREMENT after NULL/NOT NULL. + */ + #[Test] + public function autoIncrementProducesCorrectSql(): void + { + $column = Column::bigInt('id')->unsigned()->autoIncrement(); + + $this->assertSame('`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', $column->toSql()); + } + + /** + * - comment() produces COMMENT with escaped string. + */ + #[Test] + public function commentProducesCorrectSql(): void + { + $column = Column::string('name', 255)->comment('The user name'); + + $this->assertSame("`name` VARCHAR(255) NOT NULL COMMENT 'The user name'", $column->toSql()); + } + + /** + * - comment() with a single quote escapes it. + */ + #[Test] + public function commentWithSingleQuoteEscapesCorrectly(): void + { + $column = Column::string('name', 255)->comment("user's name"); + + $this->assertSame("`name` VARCHAR(255) NOT NULL COMMENT 'user''s name'", $column->toSql()); + } + + /** + * - charset() produces CHARACTER SET clause. + */ + #[Test] + public function charsetProducesCorrectSql(): void + { + $column = Column::string('name', 255)->charset('utf8mb4'); + + $this->assertSame('`name` VARCHAR(255) NOT NULL CHARACTER SET utf8mb4', $column->toSql()); + } + + /** + * - collation() produces COLLATE clause. + */ + #[Test] + public function collationProducesCorrectSql(): void + { + $column = Column::string('name', 255)->collation('utf8mb4_unicode_ci'); + + $this->assertSame('`name` VARCHAR(255) NOT NULL COLLATE utf8mb4_unicode_ci', $column->toSql()); + } + + /** + * - after() produces AFTER clause. + */ + #[Test] + public function afterProducesCorrectSql(): void + { + $column = Column::string('bio', 500)->nullable()->after('email'); + + $this->assertSame('`bio` VARCHAR(500) NULL AFTER `email`', $column->toSql()); + } + + /** + * - first() produces FIRST clause. + */ + #[Test] + public function firstProducesCorrectSql(): void + { + $column = Column::string('id_col', 36)->first(); + + $this->assertSame('`id_col` VARCHAR(36) NOT NULL FIRST', $column->toSql()); + } + + /** + * - Multiple modifiers combine in the correct order. + */ + #[Test] + public function multipleModifiersCombineCorrectly(): void + { + $column = Column::string('email', 255) + ->nullable() + ->default('test@example.com') + ->comment('Primary email') + ->charset('utf8mb4') + ->collation('utf8mb4_unicode_ci') + ->after('name') + ; + + $this->assertSame( + "`email` VARCHAR(255) NULL DEFAULT 'test@example.com' COMMENT 'Primary email'" + . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci AFTER `name`', + $column->toSql(), + ); + } + + /** + * - Unsigned auto-increment column produces correct SQL order. + */ + #[Test] + public function unsignedAutoIncrementProducesCorrectSqlOrder(): void + { + $column = Column::bigInt('id') + ->unsigned() + ->autoIncrement() + ->comment('Primary key') + ->first() + ; + + $this->assertSame( + "`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Primary key' FIRST", + $column->toSql(), + ); + } + + // ------------------------------------------------------------------------- + // Named reference + // ------------------------------------------------------------------------- + + /** + * - named() produces just the backtick-quoted column name. + */ + #[Test] + public function namedProducesQuotedName(): void + { + $column = Column::named('email'); + + $this->assertSame('`email`', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } +} diff --git a/tests/Unit/Database/Schema/IndexTest.php b/tests/Unit/Database/Schema/IndexTest.php new file mode 100644 index 0000000..2e10757 --- /dev/null +++ b/tests/Unit/Database/Schema/IndexTest.php @@ -0,0 +1,266 @@ +assertSame('PRIMARY KEY (`id`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + /** + * - Primary key with composite columns produces correct SQL. + */ + #[Test] + public function primaryKeyCompositeColumnsProducesCorrectSql(): void + { + $index = Index::primary(['tenant_id', 'id']); + + $this->assertSame('PRIMARY KEY (`tenant_id`, `id`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + // ------------------------------------------------------------------------- + // Unique index + // ------------------------------------------------------------------------- + + /** + * - Unique index with a single column produces correct SQL. + */ + #[Test] + public function uniqueIndexSingleColumnProducesCorrectSql(): void + { + $index = Index::unique('users_email_unique', 'email'); + + $this->assertSame('UNIQUE INDEX `users_email_unique` (`email`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + /** + * - Unique index with composite columns produces correct SQL. + */ + #[Test] + public function uniqueIndexCompositeColumnsProducesCorrectSql(): void + { + $index = Index::unique('users_tenant_email_unique', ['tenant_id', 'email']); + + $this->assertSame('UNIQUE INDEX `users_tenant_email_unique` (`tenant_id`, `email`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + // ------------------------------------------------------------------------- + // Regular index + // ------------------------------------------------------------------------- + + /** + * - Regular index with a single column produces correct SQL. + */ + #[Test] + public function indexSingleColumnProducesCorrectSql(): void + { + $index = Index::index('users_name_index', 'name'); + + $this->assertSame('INDEX `users_name_index` (`name`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + /** + * - Regular index with composite columns produces correct SQL. + */ + #[Test] + public function indexCompositeColumnsProducesCorrectSql(): void + { + $index = Index::index('users_name_email_index', ['name', 'email']); + + $this->assertSame('INDEX `users_name_email_index` (`name`, `email`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + // ------------------------------------------------------------------------- + // Fulltext index + // ------------------------------------------------------------------------- + + /** + * - Fulltext index with a single column produces correct SQL. + */ + #[Test] + public function fulltextIndexSingleColumnProducesCorrectSql(): void + { + $index = Index::fulltext('posts_body_fulltext', 'body'); + + $this->assertSame('FULLTEXT INDEX `posts_body_fulltext` (`body`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + /** + * - Fulltext index with composite columns produces correct SQL. + */ + #[Test] + public function fulltextIndexCompositeColumnsProducesCorrectSql(): void + { + $index = Index::fulltext('posts_title_body_fulltext', ['title', 'body']); + + $this->assertSame('FULLTEXT INDEX `posts_title_body_fulltext` (`title`, `body`)', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } + + // ------------------------------------------------------------------------- + // Foreign key + // ------------------------------------------------------------------------- + + /** + * - Foreign key with references produces correct SQL. + */ + #[Test] + public function foreignKeyWithReferencesProducesCorrectSql(): void + { + $index = Index::foreign('posts_user_id_foreign', 'user_id') + ->references('users', 'id') + ; + + $this->assertSame( + 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)', + $index->toSql(), + ); + $this->assertSame([], $index->getBindings()); + } + + /** + * - Foreign key with onDelete produces correct SQL. + */ + #[Test] + public function foreignKeyWithOnDeleteProducesCorrectSql(): void + { + $index = Index::foreign('posts_user_id_foreign', 'user_id') + ->references('users', 'id') + ->onDelete('cascade') + ; + + $this->assertSame( + 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE', + $index->toSql(), + ); + } + + /** + * - Foreign key with onUpdate produces correct SQL. + */ + #[Test] + public function foreignKeyWithOnUpdateProducesCorrectSql(): void + { + $index = Index::foreign('posts_user_id_foreign', 'user_id') + ->references('users', 'id') + ->onUpdate('set null') + ; + + $this->assertSame( + 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE SET NULL', + $index->toSql(), + ); + } + + /** + * - Foreign key with both onDelete and onUpdate produces correct SQL. + */ + #[Test] + public function foreignKeyWithBothActionsProducesCorrectSql(): void + { + $index = Index::foreign('posts_user_id_foreign', 'user_id') + ->references('users', 'id') + ->onDelete('cascade') + ->onUpdate('set null') + ; + + $this->assertSame( + 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)' + . ' ON DELETE CASCADE ON UPDATE SET NULL', + $index->toSql(), + ); + } + + /** + * - Foreign key with composite columns produces correct SQL. + */ + #[Test] + public function foreignKeyCompositeColumnsProducesCorrectSql(): void + { + $index = Index::foreign('orders_tenant_user_foreign', ['tenant_id', 'user_id']) + ->references('users', ['tenant_id', 'id']) + ->onDelete('cascade') + ; + + $this->assertSame( + 'CONSTRAINT `orders_tenant_user_foreign` FOREIGN KEY (`tenant_id`, `user_id`)' + . ' REFERENCES `users` (`tenant_id`, `id`) ON DELETE CASCADE', + $index->toSql(), + ); + } + + /** + * - Foreign key without references produces constraint with just the key columns. + */ + #[Test] + public function foreignKeyWithoutReferencesProducesConstraintOnly(): void + { + $index = Index::foreign('posts_user_id_foreign', 'user_id'); + + $this->assertSame( + 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`)', + $index->toSql(), + ); + } + + /** + * - Foreign key actions are uppercased regardless of input case. + */ + #[Test] + public function foreignKeyActionsAreUppercased(): void + { + $index = Index::foreign('fk_test', 'col') + ->references('other', 'id') + ->onDelete('Cascade') + ->onUpdate('Set Null') + ; + + $this->assertSame( + 'CONSTRAINT `fk_test` FOREIGN KEY (`col`) REFERENCES `other` (`id`)' + . ' ON DELETE CASCADE ON UPDATE SET NULL', + $index->toSql(), + ); + } + + // ------------------------------------------------------------------------- + // Named reference + // ------------------------------------------------------------------------- + + /** + * - named() produces just the backtick-quoted index name. + */ + #[Test] + public function namedProducesQuotedName(): void + { + $index = Index::named('users_email_unique'); + + $this->assertSame('`users_email_unique`', $index->toSql()); + $this->assertSame([], $index->getBindings()); + } +} diff --git a/tests/Unit/Database/Schema/TableTest.php b/tests/Unit/Database/Schema/TableTest.php new file mode 100644 index 0000000..0d243db --- /dev/null +++ b/tests/Unit/Database/Schema/TableTest.php @@ -0,0 +1,327 @@ +assertInstanceOf(Schema::class, $table); + } + + // ------------------------------------------------------------------------- + // Create mode + // ------------------------------------------------------------------------- + + /** + * - create() with columns and indexes produces correct CREATE TABLE SQL. + */ + #[Test] + public function createProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::string('name', 255), + Index::primary('id'), + ]); + + $expected = "CREATE TABLE `users` (\n" + . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n" + . " `name` VARCHAR(255) NOT NULL,\n" + . " PRIMARY KEY (`id`)\n" + . ')'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - create() with ifNotExists() produces IF NOT EXISTS. + */ + #[Test] + public function createWithIfNotExistsProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + ])->ifNotExists(); + + $expected = "CREATE TABLE IF NOT EXISTS `users` (\n" + . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT\n" + . ')'; + + $this->assertSame($expected, $table->toSql()); + } + + /** + * - create() with engine() produces ENGINE clause. + */ + #[Test] + public function createWithEngineProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id'), + ])->engine('InnoDB'); + + $expected = "CREATE TABLE `users` (\n" + . " `id` BIGINT NOT NULL\n" + . ') ENGINE = InnoDB'; + + $this->assertSame($expected, $table->toSql()); + } + + /** + * - create() with charset() produces DEFAULT CHARACTER SET clause. + */ + #[Test] + public function createWithCharsetProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id'), + ])->charset('utf8mb4'); + + $expected = "CREATE TABLE `users` (\n" + . " `id` BIGINT NOT NULL\n" + . ') DEFAULT CHARACTER SET utf8mb4'; + + $this->assertSame($expected, $table->toSql()); + } + + /** + * - create() with collation() produces DEFAULT COLLATE clause. + */ + #[Test] + public function createWithCollationProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id'), + ])->collation('utf8mb4_unicode_ci'); + + $expected = "CREATE TABLE `users` (\n" + . " `id` BIGINT NOT NULL\n" + . ') DEFAULT COLLATE utf8mb4_unicode_ci'; + + $this->assertSame($expected, $table->toSql()); + } + + /** + * - create() with all options combined produces correct SQL. + */ + #[Test] + public function createWithAllOptionsProducesCorrectSql(): void + { + $table = Table::create('users', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::string('name', 255), + Column::string('email', 255), + Index::primary('id'), + Index::unique('users_email_unique', 'email'), + ]) + ->ifNotExists() + ->engine('InnoDB') + ->charset('utf8mb4') + ->collation('utf8mb4_unicode_ci') + ; + + $expected = "CREATE TABLE IF NOT EXISTS `users` (\n" + . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n" + . " `name` VARCHAR(255) NOT NULL,\n" + . " `email` VARCHAR(255) NOT NULL,\n" + . " PRIMARY KEY (`id`),\n" + . " UNIQUE INDEX `users_email_unique` (`email`)\n" + . ')' + . ' ENGINE = InnoDB' + . ' DEFAULT CHARACTER SET utf8mb4' + . ' DEFAULT COLLATE utf8mb4_unicode_ci'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + // ------------------------------------------------------------------------- + // Drop mode + // ------------------------------------------------------------------------- + + /** + * - drop() produces DROP TABLE SQL. + */ + #[Test] + public function dropProducesCorrectSql(): void + { + $table = Table::drop('users'); + + $this->assertSame('DROP TABLE `users`', $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - drop() with ifExists() produces IF EXISTS. + */ + #[Test] + public function dropWithIfExistsProducesCorrectSql(): void + { + $table = Table::drop('users')->ifExists(); + + $this->assertSame('DROP TABLE IF EXISTS `users`', $table->toSql()); + } + + // ------------------------------------------------------------------------- + // Alter closure mode + // ------------------------------------------------------------------------- + + /** + * - alter() with multiple operations produces correct ALTER TABLE SQL. + */ + #[Test] + public function alterWithMultipleOperationsProducesCorrectSql(): void + { + $table = Table::alter('users', function (Blueprint $blueprint) { + $blueprint->add(Column::string('bio', 500)->nullable()); + $blueprint->modify(Column::string('email', 500)); + $blueprint->drop(Column::named('avatar')); + $blueprint->drop(Index::named('users_avatar_index')); + $blueprint->rename(Column::named('name'), 'full_name'); + }); + + $expected = 'ALTER TABLE `users` ' + . 'ADD COLUMN `bio` VARCHAR(500) NULL, ' + . 'MODIFY COLUMN `email` VARCHAR(500) NOT NULL, ' + . 'DROP COLUMN `avatar`, ' + . 'DROP INDEX `users_avatar_index`, ' + . 'RENAME COLUMN `name` TO `full_name`'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - alter() with a single foreign key add operation. + */ + #[Test] + public function alterWithSingleForeignKeyAddProducesCorrectSql(): void + { + $table = Table::alter('posts', function (Blueprint $blueprint) { + $blueprint->add( + Index::foreign('posts_user_id_fk', 'user_id') + ->references('users', 'id') + ->onDelete('cascade'), + ); + }); + + $expected = 'ALTER TABLE `posts` ADD ' + . 'CONSTRAINT `posts_user_id_fk` FOREIGN KEY (`user_id`)' + . ' REFERENCES `users` (`id`)' + . ' ON DELETE CASCADE'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + // ------------------------------------------------------------------------- + // Shortcuts + // ------------------------------------------------------------------------- + + /** + * - addColumns() produces ALTER TABLE with ADD COLUMN clauses. + */ + #[Test] + public function addColumnsProducesCorrectSql(): void + { + $table = Table::addColumns('users', [ + Column::string('bio', 500)->nullable(), + Column::string('avatar', 255)->nullable(), + ]); + + $expected = 'ALTER TABLE `users` ' + . 'ADD COLUMN `bio` VARCHAR(500) NULL, ' + . 'ADD COLUMN `avatar` VARCHAR(255) NULL'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - dropColumns() with string array produces ALTER TABLE with DROP COLUMN clauses. + */ + #[Test] + public function dropColumnsProducesCorrectSql(): void + { + $table = Table::dropColumns('users', ['bio', 'avatar']); + + $expected = 'ALTER TABLE `users` ' + . 'DROP COLUMN `bio`, ' + . 'DROP COLUMN `avatar`'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - addIndexes() produces ALTER TABLE with ADD index clauses. + */ + #[Test] + public function addIndexesProducesCorrectSql(): void + { + $table = Table::addIndexes('users', [ + Index::unique('users_email_unique', 'email'), + Index::index('users_name_index', 'name'), + ]); + + $expected = 'ALTER TABLE `users` ' + . 'ADD UNIQUE INDEX `users_email_unique` (`email`), ' + . 'ADD INDEX `users_name_index` (`name`)'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - dropIndexes() with string array produces ALTER TABLE with DROP INDEX clauses. + */ + #[Test] + public function dropIndexesProducesCorrectSql(): void + { + $table = Table::dropIndexes('users', ['users_email_unique', 'users_name_index']); + + $expected = 'ALTER TABLE `users` ' + . 'DROP INDEX `users_email_unique`, ' + . 'DROP INDEX `users_name_index`'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } + + /** + * - renameColumn() produces ALTER TABLE with RENAME COLUMN clause. + */ + #[Test] + public function renameColumnProducesCorrectSql(): void + { + $table = Table::renameColumn('users', 'name', 'full_name'); + + $expected = 'ALTER TABLE `users` RENAME COLUMN `name` TO `full_name`'; + + $this->assertSame($expected, $table->toSql()); + $this->assertSame([], $table->getBindings()); + } +} From 08ece674a63c8a328c8030bf45fb5ce11b45447a Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 31 Mar 2026 17:41:33 +0100 Subject: [PATCH 23/29] test(engine:database): Fix escaped mutant in Connection::transaction Assert transaction is no longer active after commit, removing the MethodCallRemoval ignore from infection config. --- infection.json5 | 5 ----- tests/Unit/Database/ConnectionTest.php | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/infection.json5 b/infection.json5 index e15bf26..d2d0630 100644 --- a/infection.json5 +++ b/infection.json5 @@ -61,11 +61,6 @@ "Engine\\Database\\Query\\Concerns\\HasHavingClause::hasHavingClause::77" ] }, - "MethodCallRemoval" : { - "ignore": [ - "Engine\\Database\\Connection::transaction::107" - ] - }, "ReturnRemoval" : { "ignore": [ "Engine\\Container\\Container::invokeClassMethod::350", diff --git a/tests/Unit/Database/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php index f184e50..c662da9 100644 --- a/tests/Unit/Database/ConnectionTest.php +++ b/tests/Unit/Database/ConnectionTest.php @@ -114,6 +114,7 @@ public function transactionCommitsOnSuccess(): void $conn->transaction(function (Connection $c) { $c->execute('INSERT INTO test (name) VALUES (?)', ['Alice']); }); + $this->assertFalse($conn->isInTransaction()); $this->assertSame('Alice', $conn->query('SELECT * FROM test')->first()->get('name')); } From d97b24a77477a3a6acb308f7fb0b5d5f35f70fe1 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 31 Mar 2026 17:49:55 +0100 Subject: [PATCH 24/29] test(engine:database): Add tests for DatabaseResolver and Database attribute Covers default and named connection resolution, wrong resolvable type, wrong parameter type, null type, attribute contract and target. --- .../Unit/Database/Attributes/DatabaseTest.php | 70 ++++++ tests/Unit/Database/DatabaseResolverTest.php | 203 ++++++++++++++++++ .../Fixtures/ClassWithDatabaseDependency.php | 16 ++ .../Fixtures/ClassWithInvalidDatabaseType.php | 14 ++ 4 files changed, 303 insertions(+) create mode 100644 tests/Unit/Database/Attributes/DatabaseTest.php create mode 100644 tests/Unit/Database/DatabaseResolverTest.php create mode 100644 tests/Unit/Database/Fixtures/ClassWithDatabaseDependency.php create mode 100644 tests/Unit/Database/Fixtures/ClassWithInvalidDatabaseType.php diff --git a/tests/Unit/Database/Attributes/DatabaseTest.php b/tests/Unit/Database/Attributes/DatabaseTest.php new file mode 100644 index 0000000..51e5909 --- /dev/null +++ b/tests/Unit/Database/Attributes/DatabaseTest.php @@ -0,0 +1,70 @@ +assertInstanceOf(Resolvable::class, new Database()); + } + + // ------------------------------------------------------------------------- + // Name property + // ------------------------------------------------------------------------- + + /** + * - The name defaults to null when not specified. + */ + #[Test] + public function nameDefaultsToNull(): void + { + $database = new Database(); + + $this->assertNull($database->name); + } + + /** + * - The name is set when provided to the constructor. + */ + #[Test] + public function nameIsSetWhenProvided(): void + { + $database = new Database('secondary'); + + $this->assertSame('secondary', $database->name); + } + + // ------------------------------------------------------------------------- + // Attribute target + // ------------------------------------------------------------------------- + + /** + * - The attribute targets parameters only. + */ + #[Test] + public function targetsParametersOnly(): void + { + $attributes = new ReflectionClass(Database::class)->getAttributes(\Attribute::class); + + $this->assertCount(1, $attributes); + $this->assertSame(\Attribute::TARGET_PARAMETER, $attributes[0]->newInstance()->flags); + } +} diff --git a/tests/Unit/Database/DatabaseResolverTest.php b/tests/Unit/Database/DatabaseResolverTest.php new file mode 100644 index 0000000..381553c --- /dev/null +++ b/tests/Unit/Database/DatabaseResolverTest.php @@ -0,0 +1,203 @@ +factoryWithConnections(['primary' => $primary])); + $dependency = $this->dependencyFrom(ClassWithDatabaseDependency::class, 'default'); + + $result = $resolver->resolve($dependency, $this->buildContainer()); + + $this->assertSame($primary, $result); + } + + /** + * - Resolves a Connection for a parameter with #[Database('secondary')], + * returning the named connection. + */ + #[Test] + public function resolvesNamedConnectionWhenNameSpecified(): void + { + $primary = new Connection('primary', new PDO('sqlite::memory:')); + $secondary = new Connection('secondary', new PDO('sqlite::memory:')); + $resolver = new DatabaseResolver($this->factoryWithConnections([ + 'primary' => $primary, + 'secondary' => $secondary, + ])); + $dependency = $this->dependencyFrom(ClassWithDatabaseDependency::class, 'secondary'); + + $result = $resolver->resolve($dependency, $this->buildContainer()); + + $this->assertSame($secondary, $result); + } + + // ------------------------------------------------------------------------- + // Error: wrong resolvable attribute + // ------------------------------------------------------------------------- + + /** + * - Throws RuntimeException when the dependency's resolvable attribute + * is not a Database instance. + */ + #[Test] + public function throwsWhenResolvableIsNotDatabaseAttribute(): void + { + $resolver = new DatabaseResolver($this->factoryWithConnections([])); + $dependency = new Dependency( + 'param', + $this->connectionReflectionType(), + resolvable: new class implements Resolvable {}, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(Database::class); + + $resolver->resolve($dependency, $this->buildContainer()); + } + + // ------------------------------------------------------------------------- + // Error: wrong parameter type + // ------------------------------------------------------------------------- + + /** + * - Throws RuntimeException when the parameter type is not Connection. + */ + #[Test] + public function throwsWhenParameterTypeIsNotConnection(): void + { + $resolver = new DatabaseResolver($this->factoryWithConnections([])); + $dependency = $this->dependencyFrom(ClassWithInvalidDatabaseType::class, 'connection'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(Connection::class); + + $resolver->resolve($dependency, $this->buildContainer()); + } + + /** + * - Throws RuntimeException when the parameter has no type. + */ + #[Test] + public function throwsWhenParameterHasNoType(): void + { + $resolver = new DatabaseResolver($this->factoryWithConnections([])); + $dependency = new Dependency( + 'param', + null, + resolvable: new Database(), + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(Connection::class); + + $resolver->resolve($dependency, $this->buildContainer()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Build a ConnectionFactory pre-loaded with the given connections, + * bypassing the need for a real MySQL connection. + * + * @param array $connections + */ + private function factoryWithConnections(array $connections): ConnectionFactory + { + $primary = array_key_first($connections) ?? 'primary'; + $configs = []; + + foreach (array_keys($connections) as $name) { + $configs[$name] = ConnectionConfig::make('127.0.0.1', 3306, null, 'test', 'test', 'test'); + } + + if (empty($configs)) { + $configs['primary'] = ConnectionConfig::make('127.0.0.1', 3306, null, 'test', 'test', 'test'); + } + + $factory = new ConnectionFactory(DatabaseConfig::make($primary, $configs)); + + // Inject pre-built connections into the factory's private cache. + $ref = new ReflectionClass($factory); + $ref->getProperty('connections')->setValue($factory, $connections); + + return $factory; + } + + private function buildContainer(): Container + { + return new Container( + new ResolverCatalogue([], GenericResolver::class), + new BindingCatalogue([], [], []), + ); + } + + private function connectionReflectionType(): \ReflectionNamedType + { + return new ReflectionClass(ClassWithDatabaseDependency::class) + ->getConstructor() + ->getParameters()[0] + ->getType() + ; + } + + private function dependencyFrom(string $class, string $paramName): Dependency + { + $constructor = new ReflectionClass($class)->getConstructor(); + + foreach ($constructor->getParameters() as $param) { + if ($param->getName() === $paramName) { + $resolvable = null; + + foreach ($param->getAttributes(Resolvable::class, \ReflectionAttribute::IS_INSTANCEOF) as $attr) { + $resolvable = $attr->newInstance(); + } + + return new Dependency( + $param->getName(), + $param->getType(), + resolvable: $resolvable, + ); + } + } + + throw new RuntimeException("Parameter '{$paramName}' not found on {$class}"); + } +} diff --git a/tests/Unit/Database/Fixtures/ClassWithDatabaseDependency.php b/tests/Unit/Database/Fixtures/ClassWithDatabaseDependency.php new file mode 100644 index 0000000..66f265f --- /dev/null +++ b/tests/Unit/Database/Fixtures/ClassWithDatabaseDependency.php @@ -0,0 +1,16 @@ + Date: Tue, 31 Mar 2026 23:56:21 +0100 Subject: [PATCH 25/29] feat(database:migrations): Initial migrations work --- src/Database/Contracts/Migration.php | 21 + .../Contracts/ReversibleMigration.php | 21 + src/Database/Migrations/MigrationLedger.php | 326 ++++++++++++++ src/Database/Migrations/MigrationPhase.php | 13 + src/Database/Migrations/MigrationRunner.php | 423 ++++++++++++++++++ src/Database/Migrations/MigrationStatus.php | 88 ++++ src/Database/Migrations/Migrator.php | 57 +++ src/Database/Query/Update.php | 4 +- .../Database/MigrationLedgerTest.php | 373 +++++++++++++++ .../Database/MigrationRunnerTest.php | 191 ++++++++ .../Migrations/001_create_test_table.php | 23 + .../Migrations/002_seed_test_data.php | 18 + .../Migrations/Fixtures/CreatePostsTable.php | 31 ++ .../Migrations/Fixtures/CreateUsersTable.php | 38 ++ .../Migrations/Fixtures/DataOnlyMigration.php | 19 + .../Migrations/MigrationPhaseTest.php | 74 +++ .../Migrations/MigrationRunnerTest.php | 221 +++++++++ .../Migrations/MigrationStatusTest.php | 239 ++++++++++ .../Unit/Database/Migrations/MigratorTest.php | 155 +++++++ tests/Unit/Database/Query/UpdateTest.php | 24 +- 20 files changed, 2345 insertions(+), 14 deletions(-) create mode 100644 src/Database/Contracts/Migration.php create mode 100644 src/Database/Contracts/ReversibleMigration.php create mode 100644 src/Database/Migrations/MigrationLedger.php create mode 100644 src/Database/Migrations/MigrationPhase.php create mode 100644 src/Database/Migrations/MigrationRunner.php create mode 100644 src/Database/Migrations/MigrationStatus.php create mode 100644 src/Database/Migrations/Migrator.php create mode 100644 tests/Integration/Database/MigrationLedgerTest.php create mode 100644 tests/Integration/Database/MigrationRunnerTest.php create mode 100644 tests/Integration/Database/Migrations/001_create_test_table.php create mode 100644 tests/Integration/Database/Migrations/002_seed_test_data.php create mode 100644 tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php create mode 100644 tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php create mode 100644 tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php create mode 100644 tests/Unit/Database/Migrations/MigrationPhaseTest.php create mode 100644 tests/Unit/Database/Migrations/MigrationRunnerTest.php create mode 100644 tests/Unit/Database/Migrations/MigrationStatusTest.php create mode 100644 tests/Unit/Database/Migrations/MigratorTest.php diff --git a/src/Database/Contracts/Migration.php b/src/Database/Contracts/Migration.php new file mode 100644 index 0000000..73cf410 --- /dev/null +++ b/src/Database/Contracts/Migration.php @@ -0,0 +1,21 @@ +|null */ + private ?array $statuses = null; + + public function __construct(Connection $connection) + { + $this->connection = $connection; + } + + /** + * Create the migrations tracking table if it does not already exist. + * + * @throws QueryException + */ + public function ensureTable(): void + { + $this->connection->execute(Table::create('migrations', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::string('migration', 255), + Column::int('batch')->unsigned(), + Column::string('module', 255), + Column::timestamp('migrated_at')->default(Raw::from('CURRENT_TIMESTAMP')), + Column::boolean('schema')->default(false), + Column::boolean('alter')->default(false), + Column::boolean('data')->default(false), + Column::boolean('errored')->default(false), + Index::primary('id'), + Index::unique('migrations_migration_unique', 'migration'), + ])->ifNotExists()); + + $this->loadStatuses(); + } + + /** + * Get the next batch number. + * + * @return int + * + * @throws QueryException + */ + public function nextBatch(): int + { + $row = $this->connection->query( + Select::from('migrations') + ->columns(Raw::from('COALESCE(MAX(batch), 0) as max_batch')), + )->first(); + + if ($row === null) { + return 1; + } + + return $row->int('max_batch') + 1; + } + + /** + * Record a migration in the ledger. + * + * @param string $migration + * @param string $module + * @param int $batch + * + * @throws RuntimeException if the migration already exists in the ledger + * @throws QueryException + */ + public function record(string $migration, string $module, int $batch): void + { + try { + $this->connection->execute( + Insert::into('migrations')->values([ + 'migration' => $migration, + 'module' => $module, + 'batch' => $batch, + ]), + ); + } catch (QueryException $e) { + throw new RuntimeException(sprintf( + 'Cannot record migration \'%s\': a record already exists in the ledger.', + $migration, + ), 0, $e); + } + + $status = new MigrationStatus($migration, $module, $batch); + + if ($this->statuses !== null) { + $this->statuses[$migration] = $status; + } + } + + /** + * Mark a migration phase as completed. + * + * @param string $migration + * @param MigrationPhase $phase + * + * @throws RuntimeException if the migration does not exist in the ledger + * @throws QueryException + */ + public function markPhase(string $migration, MigrationPhase $phase): void + { + $result = $this->connection->execute( + Update::table('migrations') + ->set([$phase->value => true]) + ->where('migration', '=', $migration), + ); + + if (! $result->wasSuccessful()) { + throw new RuntimeException(sprintf( + 'Cannot mark phase \'%s\' for migration \'%s\': migration not found in ledger.', + $phase->value, + $migration, + )); + } + + if (isset($this->statuses[$migration])) { + $this->statuses[$migration]->markPhase($phase->value); + } + } + + /** + * Mark a migration as errored. + * + * @param string $migration + * + * @throws RuntimeException if the migration does not exist in the ledger + * @throws QueryException + */ + public function markErrored(string $migration): void + { + $result = $this->connection->execute( + Update::table('migrations') + ->set(['errored' => true]) + ->where('migration', '=', $migration), + ); + + if (! $result->wasSuccessful()) { + throw new RuntimeException(sprintf( + 'Cannot mark migration \'%s\' as errored: migration not found in ledger.', + $migration, + )); + } + + if (isset($this->statuses[$migration])) { + $this->statuses[$migration]->markErrored(); + } + } + + /** + * Clear the error flag for a migration. + * + * @param string $migration + * + * @throws RuntimeException if the migration does not exist in the ledger + * @throws QueryException + */ + public function clearError(string $migration): void + { + $result = $this->connection->execute( + Update::table('migrations') + ->set(['errored' => 0]) + ->where('migration', '=', $migration), + ); + + if (! $result->wasSuccessful()) { + throw new RuntimeException(sprintf( + 'Cannot clear error for migration \'%s\': migration not found in ledger.', + $migration, + )); + } + + if (isset($this->statuses[$migration])) { + $this->statuses[$migration]->clearError(); + } + } + + /** + * Get the status for a specific migration. + * + * @param string $migration + * + * @return MigrationStatus|null + * + * @throws QueryException + */ + public function getMigrationStatus(string $migration): ?MigrationStatus + { + if ($this->statuses !== null) { + return $this->statuses[$migration] ?? null; + } + + $row = $this->connection->query( + Select::from('migrations') + ->where('migration', '=', $migration), + )->first(); + + if ($row === null) { + return null; + } + + return MigrationStatus::fromRow($row); + } + + /** + * Get all migrations for a specific batch. + * + * @param int $batch + * + * @return list + * + * @throws QueryException + */ + public function getByBatch(int $batch): array + { + if ($this->statuses !== null) { + return array_values(array_filter( + $this->statuses, + static fn (MigrationStatus $status): bool => $status->batch === $batch, + )); + } + + $statuses = []; + + foreach ($this->connection->query( + Select::from('migrations') + ->where('batch', '=', $batch), + )->all() as $row) { + $statuses[] = MigrationStatus::fromRow($row); + } + + return $statuses; + } + + /** + * Remove a migration record from the ledger. + * + * @param string $migration + * + * @throws RuntimeException if the migration does not exist in the ledger + * @throws QueryException + */ + public function remove(string $migration): void + { + $result = $this->connection->execute( + Delete::from('migrations') + ->where('migration', '=', $migration), + ); + + if (! $result->wasSuccessful()) { + throw new RuntimeException(sprintf( + 'Cannot remove migration \'%s\': migration not found in ledger.', + $migration, + )); + } + + if ($this->statuses !== null) { + unset($this->statuses[$migration]); + } + } + + /** + * Get all recorded migration statuses, keyed by migration name. + * + * @return array + * + * @throws QueryException + */ + public function getAllStatuses(): array + { + if ($this->statuses !== null) { + return $this->statuses; + } + + return $this->loadStatuses(); + } + + /** + * Determine if a migration has been run. + * + * @param string $migration + * + * @return bool + * + * @throws QueryException + */ + public function hasRun(string $migration): bool + { + return $this->getMigrationStatus($migration) !== null; + } + + /** + * Load all migration statuses from the database into the cache. + * + * @return array + * + * @throws QueryException + */ + private function loadStatuses(): array + { + $this->statuses = []; + + foreach ($this->connection->query(Select::from('migrations'))->all() as $row) { + $this->statuses[$row->string('migration')] = MigrationStatus::fromRow($row); + } + + return $this->statuses; + } +} diff --git a/src/Database/Migrations/MigrationPhase.php b/src/Database/Migrations/MigrationPhase.php new file mode 100644 index 0000000..4e0d5e9 --- /dev/null +++ b/src/Database/Migrations/MigrationPhase.php @@ -0,0 +1,13 @@ +>> + */ + private array $schema = []; + + /** + * @var array>> + */ + private array $alter = []; + + /** + * @var array>> + */ + private array $data = []; + + private Connection $connection; + + private Migrator $migrator; + + private MigrationLedger $ledger; + + /** + * @var array> + */ + private array $migrations = []; + + private string $module; + + private string $migration; + + public function __construct(#[Database] Connection $connection) + { + $this->connection = $connection; + $this->migrator = new Migrator($this); + $this->ledger = new MigrationLedger($connection); + } + + /** + * Register a migration with the runner. + * + * @param string $module + * @param string $name + * @param Migration $migration + * + * @return static + */ + public function addMigration(string $module, string $name, Migration $migration): self + { + $this->migrations[$module][$name] = $migration; + + return $this; + } + + /** + * Set the current module and migration context. + * + * @param string $module + * @param string $migration + * + * @return static + */ + public function scope(string $module, string $migration): self + { + $this->module = $module; + $this->migration = $migration; + + return $this; + } + + /** + * Collect migration expressions by calling the migration's up method. + * + * @param Migration $migration + * + * @return static + */ + public function collect(Migration $migration): self + { + $migration->up($this->migrator); + + return $this; + } + + /** + * Append a schema expression for the current scope. + * + * @param Schema $schema + * + * @return static + */ + public function schema(Schema $schema): self + { + $this->schema[$this->module][$this->migration][] = $schema; + + return $this; + } + + /** + * Append an alter expression for the current scope. + * + * @param Schema $schema + * + * @return static + */ + public function alter(Schema $schema): self + { + $this->alter[$this->module][$this->migration][] = $schema; + + return $this; + } + + /** + * Append a data expression for the current scope. + * + * @param Query $query + * + * @return static + */ + public function data(Query $query): self + { + $this->data[$this->module][$this->migration][] = $query; + + return $this; + } + + /** + * Run all pending migrations. + * + * @param MigrationPhase|null $fromPhase + * @param list $onlyPhases + * + * @throws Throwable + */ + public function migrate(?MigrationPhase $fromPhase = null, array $onlyPhases = []): void + { + $this->ledger->ensureTable(); + + $batch = $this->ledger->nextBatch(); + $phases = $this->resolvePhases($fromPhase, $onlyPhases); + + foreach ($this->migrations as $module => $migrations) { + foreach ($migrations as $name => $migration) { + if (! $this->shouldRun($name, $fromPhase)) { + continue; + } + + $this->scope($module, $name); + $migration->up($this->migrator); + + if (! $this->ledger->hasRun($name)) { + $this->ledger->record($name, $module, $batch); + } + } + } + + foreach ($phases as $phase) { + $this->executePhase($phase); + } + } + + /** + * Rollback migrations for a specific batch. + * + * @param int|null $batch + * + * @throws RuntimeException + */ + public function rollbackBatch(?int $batch = null): void + { + $this->ledger->ensureTable(); + + if ($batch === null) { + $batch = $this->ledger->nextBatch() - 1; + } + + if ($batch < 1) { + return; + } + + $statuses = array_reverse($this->ledger->getByBatch($batch)); + + foreach ($statuses as $status) { + $migration = $this->findMigration($status->migration); + + if (! $migration instanceof ReversibleMigration) { + throw new RuntimeException(sprintf( + 'Migration \'%s\' does not implement ReversibleMigration and cannot be rolled back.', + $status->migration, + )); + } + + $this->scope($this->findModule($status->migration), $status->migration); + $migration->down($this->migrator); + } + + $reversePhases = [MigrationPhase::Data, MigrationPhase::Alter, MigrationPhase::Schema]; + + foreach ($reversePhases as $phase) { + $this->executePhase($phase, force: true); + } + + foreach ($statuses as $status) { + $this->ledger->remove($status->migration); + $this->clearCollected($this->findModule($status->migration), $status->migration); + } + } + + /** + * Rollback a single migration by name. + * + * @param string $migrationName + * + * @throws RuntimeException + */ + public function rollbackMigration(string $migrationName): void + { + $migration = $this->findMigration($migrationName); + + if (! $migration instanceof ReversibleMigration) { + throw new RuntimeException( + "Migration '{$migrationName}' does not implement ReversibleMigration and cannot be rolled back.", + ); + } + + $module = $this->findModule($migrationName); + + $this->scope($module, $migrationName); + $migration->down($this->migrator); + + $reversePhases = [MigrationPhase::Data, MigrationPhase::Alter, MigrationPhase::Schema]; + + foreach ($reversePhases as $phase) { + $this->executePhase($phase, force: true); + } + + $this->ledger->remove($migrationName); + $this->clearCollected($module, $migrationName); + } + + /** + * Resolve which phases to execute. + * + * @param MigrationPhase|null $fromPhase + * @param list $onlyPhases + * + * @return list + */ + private function resolvePhases(?MigrationPhase $fromPhase, array $onlyPhases): array + { + $allPhases = [MigrationPhase::Schema, MigrationPhase::Alter, MigrationPhase::Data]; + + if (! empty($onlyPhases)) { + return $onlyPhases; + } + + if ($fromPhase !== null) { + $index = array_search($fromPhase, $allPhases, true); + + if ($index === false) { + return $allPhases; + } + + return array_slice($allPhases, $index); + } + + return $allPhases; + } + + /** + * Determine if a migration should be run. + * + * @param string $migration + * @param MigrationPhase|null $fromPhase + * + * @return bool + */ + private function shouldRun(string $migration, ?MigrationPhase $fromPhase): bool + { + $status = $this->ledger->getMigrationStatus($migration); + + if ($status === null) { + return true; + } + + if ($status->errored) { + return true; + } + + if ($fromPhase !== null) { + return true; + } + + return false; + } + + /** + * Execute all expressions for a given phase. + * + * @param MigrationPhase $phase + * @param bool $force skip phase-completion checks (used for rollback) + * + * @throws Throwable + */ + private function executePhase(MigrationPhase $phase, bool $force = false): void + { + $bucket = match ($phase) { + MigrationPhase::Schema => $this->schema, + MigrationPhase::Alter => $this->alter, + MigrationPhase::Data => $this->data, + }; + + foreach ($bucket as $module => $migrations) { + foreach ($migrations as $migrationName => $expressions) { + $status = $this->ledger->getMigrationStatus($migrationName); + + if (! $force && $status !== null && $status->phaseCompleted($phase->value)) { + continue; + } + + $isDataPhase = $phase === MigrationPhase::Data; + + if ($isDataPhase) { + $this->connection->beginTransaction(); + } + + try { + foreach ($expressions as $expression) { + $this->connection->execute($expression); + } + + if (! $force) { + $this->ledger->markPhase($migrationName, $phase); + } + + if ($isDataPhase) { + $this->connection->commit(); + } + } catch (Throwable $e) { + if ($isDataPhase) { + $this->connection->rollback(); + } + + if (! $force) { + $this->ledger->markErrored($migrationName); + } + + throw $e; + } + } + } + } + + /** + * Find a migration instance by its name. + * + * @param string $name + * + * @return Migration + * + * @throws RuntimeException + */ + private function findMigration(string $name): Migration + { + foreach ($this->migrations as $migrations) { + if (isset($migrations[$name])) { + return $migrations[$name]; + } + } + + throw new RuntimeException("Migration '{$name}' not found."); + } + + /** + * Find the module name for a given migration. + * + * @param string $name + * + * @return string + * + * @throws RuntimeException + */ + private function findModule(string $name): string + { + foreach ($this->migrations as $module => $migrations) { + if (isset($migrations[$name])) { + return $module; + } + } + + throw new RuntimeException("Migration '{$name}' not found."); + } + + /** + * Clear collected expressions for a specific module and migration. + * + * @param string $module + * @param string $migration + */ + private function clearCollected(string $module, string $migration): void + { + unset( + $this->schema[$module][$migration], + $this->alter[$module][$migration], + $this->data[$module][$migration], + ); + } +} diff --git a/src/Database/Migrations/MigrationStatus.php b/src/Database/Migrations/MigrationStatus.php new file mode 100644 index 0000000..aa547c2 --- /dev/null +++ b/src/Database/Migrations/MigrationStatus.php @@ -0,0 +1,88 @@ +string('migration'), + $row->string('module'), + $row->int('batch'), + $row->bool('schema'), + $row->bool('alter'), + $row->bool('data'), + $row->bool('errored'), + ); + } + + public function __construct( + public readonly string $migration, + public readonly string $module, + public readonly int $batch, + public bool $schema = false, + public bool $alter = false, + public bool $data = false, + public bool $errored = false, + ) { + } + + /** + * Check whether a named phase has completed. + * + * @param string $phase + * + * @return bool + */ + public function phaseCompleted(string $phase): bool + { + return match ($phase) { + 'schema' => $this->schema, + 'alter' => $this->alter, + 'data' => $this->data, + default => false, + }; + } + + /** + * Mark a named phase as completed. + * + * @param string $phase + */ + public function markPhase(string $phase): void + { + match ($phase) { + 'schema' => $this->schema = true, + 'alter' => $this->alter = true, + 'data' => $this->data = true, + default => null, + }; + } + + /** + * Mark the migration as errored. + */ + public function markErrored(): void + { + $this->errored = true; + } + + /** + * Clear the errored flag. + */ + public function clearError(): void + { + $this->errored = false; + } +} diff --git a/src/Database/Migrations/Migrator.php b/src/Database/Migrations/Migrator.php new file mode 100644 index 0000000..3cb1d37 --- /dev/null +++ b/src/Database/Migrations/Migrator.php @@ -0,0 +1,57 @@ +runner->schema($schema); + + return $this; + } + + /** + * Register an alter phase expression. + * + * @param Schema $schema + * + * @return static + */ + public function alter(Schema $schema): self + { + $this->runner->alter($schema); + + return $this; + } + + /** + * Register a data phase expression. + * + * @param Query $query + * + * @return static + */ + public function data(Query $query): self + { + $this->runner->data($query); + + return $this; + } +} diff --git a/src/Database/Query/Update.php b/src/Database/Query/Update.php index 52d2ec1..402acf4 100644 --- a/src/Database/Query/Update.php +++ b/src/Database/Query/Update.php @@ -55,9 +55,9 @@ public function toSql(): string foreach ($this->sets as $column => $value) { if ($value instanceof Expression) { - $setClauses[] = "{$column} = {$value->toSql()}"; + $setClauses[] = "`{$column}` = {$value->toSql()}"; } else { - $setClauses[] = "{$column} = ?"; + $setClauses[] = "`{$column}` = ?"; } } diff --git a/tests/Integration/Database/MigrationLedgerTest.php b/tests/Integration/Database/MigrationLedgerTest.php new file mode 100644 index 0000000..8b586e1 --- /dev/null +++ b/tests/Integration/Database/MigrationLedgerTest.php @@ -0,0 +1,373 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], + ); + + self::$connection = new Connection('test', $pdo); + } + + public static function tearDownAfterClass(): void + { + self::$connection->execute('DROP TABLE IF EXISTS migrations'); + } + + protected function setUp(): void + { + self::$connection->execute('DROP TABLE IF EXISTS migrations'); + $this->ledger = new MigrationLedger(self::$connection); + $this->ledger->ensureTable(); + } + + // ------------------------------------------------------------------------- + // ensureTable + // ------------------------------------------------------------------------- + + /** + * - ensureTable creates the migrations table. + */ + #[Test] + public function ensureTableCreatesTheMigrationsTable(): void + { + $result = self::$connection->query('SHOW TABLES LIKE \'migrations\''); + + $this->assertNotNull($result->first()); + } + + /** + * - ensureTable is idempotent and can be called twice without error. + */ + #[Test] + public function ensureTableIsIdempotent(): void + { + $this->ledger->ensureTable(); + + $result = self::$connection->query('SHOW TABLES LIKE \'migrations\''); + + $this->assertNotNull($result->first()); + } + + // ------------------------------------------------------------------------- + // record and getMigrationStatus + // ------------------------------------------------------------------------- + + /** + * - record inserts a row and getMigrationStatus retrieves it with correct values. + */ + #[Test] + public function recordAndGetMigrationStatusRetrievesCorrectValues(): void + { + $this->ledger->record('2024_01_01_000000_create_users_table', 'core', 1); + + $status = $this->ledger->getMigrationStatus('2024_01_01_000000_create_users_table'); + + $this->assertNotNull($status); + $this->assertInstanceOf(MigrationStatus::class, $status); + $this->assertSame('2024_01_01_000000_create_users_table', $status->migration); + $this->assertSame('core', $status->module); + $this->assertSame(1, $status->batch); + $this->assertFalse($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + $this->assertFalse($status->errored); + } + + /** + * - getMigrationStatus returns null for a non-existent migration. + */ + #[Test] + public function getMigrationStatusReturnsNullForNonExistentMigration(): void + { + $row = $this->ledger->getMigrationStatus('non_existent_migration'); + + $this->assertNull($row); + } + + // ------------------------------------------------------------------------- + // nextBatch + // ------------------------------------------------------------------------- + + /** + * - nextBatch returns 1 when no migrations exist. + */ + #[Test] + public function nextBatchReturnsOneWhenNoMigrationsExist(): void + { + $this->assertSame(1, $this->ledger->nextBatch()); + } + + /** + * - nextBatch returns max batch plus one after records exist. + */ + #[Test] + public function nextBatchReturnsMaxBatchPlusOneAfterRecordsExist(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->record('migration_b', 'core', 2); + $this->ledger->record('migration_c', 'core', 3); + + $this->assertSame(4, $this->ledger->nextBatch()); + } + + // ------------------------------------------------------------------------- + // markPhase + // ------------------------------------------------------------------------- + + /** + * - markPhase sets the schema flag to true while other flags remain false. + */ + #[Test] + public function markPhaseSetsSchemaFlagToTrue(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->markPhase('migration_a', MigrationPhase::Schema); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertTrue($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + } + + /** + * - markPhase can mark all three phases independently. + */ + #[Test] + public function markPhaseCanMarkAllThreePhasesIndependently(): void + { + $this->ledger->record('migration_a', 'core', 1); + + $this->ledger->markPhase('migration_a', MigrationPhase::Schema); + $this->ledger->markPhase('migration_a', MigrationPhase::Alter); + $this->ledger->markPhase('migration_a', MigrationPhase::Data); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertTrue($status->schema); + $this->assertTrue($status->alter); + $this->assertTrue($status->data); + } + + // ------------------------------------------------------------------------- + // markErrored and clearError + // ------------------------------------------------------------------------- + + /** + * - markErrored sets the errored flag to true. + */ + #[Test] + public function markErroredSetsErroredFlag(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->markErrored('migration_a'); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertTrue($status->errored); + } + + /** + * - clearError resets the errored flag to false. + */ + #[Test] + public function clearErrorResetsErroredFlag(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->markErrored('migration_a'); + $this->ledger->clearError('migration_a'); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertFalse($status->errored); + } + + // ------------------------------------------------------------------------- + // getByBatch + // ------------------------------------------------------------------------- + + /** + * - getByBatch returns all migrations in the given batch. + */ + #[Test] + public function getByBatchReturnsAllMigrationsInGivenBatch(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->record('migration_b', 'core', 1); + $this->ledger->record('migration_c', 'auth', 2); + + $batch1 = $this->ledger->getByBatch(1); + $batch2 = $this->ledger->getByBatch(2); + + $this->assertCount(2, $batch1); + $this->assertCount(1, $batch2); + + $this->assertContainsOnlyInstancesOf(MigrationStatus::class, $batch1); + $this->assertContainsOnlyInstancesOf(MigrationStatus::class, $batch2); + } + + /** + * - getByBatch returns an empty result for a non-existent batch. + */ + #[Test] + public function getByBatchReturnsEmptyResultForNonExistentBatch(): void + { + $result = $this->ledger->getByBatch(999); + + $this->assertCount(0, $result); + } + + // ------------------------------------------------------------------------- + // remove + // ------------------------------------------------------------------------- + + /** + * - remove deletes the migration record so getMigrationStatus returns null. + */ + #[Test] + public function removeDeletesMigrationRecord(): void + { + $this->ledger->record('migration_a', 'core', 1); + + $this->assertNotNull($this->ledger->getMigrationStatus('migration_a')); + + $this->ledger->remove('migration_a'); + + $this->assertNull($this->ledger->getMigrationStatus('migration_a')); + } + + // ------------------------------------------------------------------------- + // hasRun + // ------------------------------------------------------------------------- + + /** + * - hasRun returns true for a recorded migration. + */ + #[Test] + public function hasRunReturnsTrueForRecordedMigration(): void + { + $this->ledger->record('migration_a', 'core', 1); + + $this->assertTrue($this->ledger->hasRun('migration_a')); + } + + /** + * - hasRun returns false for a non-existent migration. + */ + #[Test] + public function hasRunReturnsFalseForNonExistentMigration(): void + { + $this->assertFalse($this->ledger->hasRun('non_existent_migration')); + } + + // ------------------------------------------------------------------------- + // record() exception paths + // ------------------------------------------------------------------------- + + /** + * - record throws RuntimeException for a duplicate migration name. + */ + #[Test] + public function recordThrowsRuntimeExceptionForDuplicateMigration(): void + { + $this->ledger->record('migration_a', 'core', 1); + + $this->expectException(RuntimeException::class); + + $this->ledger->record('migration_a', 'core', 2); + } + + // ------------------------------------------------------------------------- + // remove() exception paths + // ------------------------------------------------------------------------- + + /** + * - remove throws RuntimeException for a non-existent migration. + */ + #[Test] + public function removeThrowsRuntimeExceptionForNonExistentMigration(): void + { + $this->expectException(RuntimeException::class); + + $this->ledger->remove('non_existent_migration'); + } + + // ------------------------------------------------------------------------- + // Cache consistency + // ------------------------------------------------------------------------- + + /** + * - Cache is consistent after record then getMigrationStatus. + */ + #[Test] + public function cacheIsConsistentAfterRecordThenGet(): void + { + $this->ledger->record('migration_a', 'core', 1); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertInstanceOf(MigrationStatus::class, $status); + $this->assertSame('migration_a', $status->migration); + $this->assertSame('core', $status->module); + $this->assertSame(1, $status->batch); + $this->assertFalse($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + $this->assertFalse($status->errored); + } + + /** + * - Cache is consistent after markPhase then getMigrationStatus. + */ + #[Test] + public function cacheIsConsistentAfterMarkPhaseThenGet(): void + { + $this->ledger->record('migration_a', 'core', 1); + $this->ledger->markPhase('migration_a', MigrationPhase::Schema); + + $status = $this->ledger->getMigrationStatus('migration_a'); + + $this->assertNotNull($status); + $this->assertTrue($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + $this->assertFalse($status->errored); + } +} diff --git a/tests/Integration/Database/MigrationRunnerTest.php b/tests/Integration/Database/MigrationRunnerTest.php new file mode 100644 index 0000000..6b6f546 --- /dev/null +++ b/tests/Integration/Database/MigrationRunnerTest.php @@ -0,0 +1,191 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], + ); + + self::$connection = new Connection('test', $pdo); + } + + public static function tearDownAfterClass(): void + { + self::$connection->execute('DROP TABLE IF EXISTS migration_test'); + self::$connection->execute('DROP TABLE IF EXISTS migrations'); + } + + protected function setUp(): void + { + self::$connection->execute('DROP TABLE IF EXISTS migration_test'); + self::$connection->execute('DROP TABLE IF EXISTS migrations'); + } + + // ------------------------------------------------------------------------- + // migrate + // ------------------------------------------------------------------------- + + /** + * - migrate executes all phases, creating the table and seeding data. + */ + #[Test] + public function migrateExecutesAllPhases(): void + { + $runner = $this->buildRunner(); + $runner->migrate(); + + // Table exists and has a seeded row + $rows = self::$connection->query(Select::from('migration_test'))->all(); + + $this->assertCount(1, $rows); + $this->assertSame('seeded', $rows[0]->string('name')); + + // Ledger has records for both migrations + $ledger = new MigrationLedger(self::$connection); + + $status001 = $ledger->getMigrationStatus('001_create_test_table'); + $this->assertNotNull($status001); + $this->assertTrue($status001->schema); + $this->assertSame('test', $status001->module); + + $status002 = $ledger->getMigrationStatus('002_seed_test_data'); + $this->assertNotNull($status002); + $this->assertTrue($status002->data); + $this->assertSame('test', $status002->module); + } + + /** + * - migrate is idempotent and does not duplicate data when run twice. + */ + #[Test] + public function migrateIsIdempotent(): void + { + $this->buildRunner()->migrate(); + $this->buildRunner()->migrate(); + + $rows = self::$connection->query(Select::from('migration_test'))->all(); + + $this->assertCount(1, $rows); + } + + /** + * - migrate with onlyPhases=[Schema] creates the table but inserts no data. + */ + #[Test] + public function migrateWithOnlySchemaPhaseCreatesTableButNoData(): void + { + $runner = $this->buildRunner(); + $runner->migrate(onlyPhases: [MigrationPhase::Schema]); + + // Table exists + $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); + $this->assertNotNull($tableResult->first()); + + // No data rows + $rows = self::$connection->query(Select::from('migration_test'))->all(); + $this->assertCount(0, $rows); + + // Ledger: 001 has schema=true but data=false + $ledger = new MigrationLedger(self::$connection); + $status001 = $ledger->getMigrationStatus('001_create_test_table'); + + $this->assertNotNull($status001); + $this->assertTrue($status001->schema); + $this->assertFalse($status001->data); + } + + // ------------------------------------------------------------------------- + // rollbackBatch + // ------------------------------------------------------------------------- + + /** + * - rollbackBatch reverses the last batch, dropping the table and clearing the ledger. + */ + #[Test] + public function rollbackBatchReversesLastBatch(): void + { + $this->buildRunner()->migrate(); + $this->buildRunner()->rollbackBatch(); + + // Table no longer exists + $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); + $this->assertNull($tableResult->first()); + + // Ledger has no records for either migration + $ledger = new MigrationLedger(self::$connection); + + $this->assertNull($ledger->getMigrationStatus('001_create_test_table')); + $this->assertNull($ledger->getMigrationStatus('002_seed_test_data')); + } + + // ------------------------------------------------------------------------- + // rollbackMigration + // ------------------------------------------------------------------------- + + /** + * - rollbackMigration reverses a specific migration, leaving others intact. + */ + #[Test] + public function rollbackMigrationReversesSpecificMigration(): void + { + $this->buildRunner()->migrate(); + $this->buildRunner()->rollbackMigration('002_seed_test_data'); + + // Table still exists + $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); + $this->assertNotNull($tableResult->first()); + + // Seeded row is gone + $rows = self::$connection->query(Select::from('migration_test'))->all(); + $this->assertCount(0, $rows); + + // Ledger: 001 still recorded, 002 removed + $ledger = new MigrationLedger(self::$connection); + + $this->assertNotNull($ledger->getMigrationStatus('001_create_test_table')); + $this->assertNull($ledger->getMigrationStatus('002_seed_test_data')); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function buildRunner(): MigrationRunner + { + $runner = new MigrationRunner(self::$connection); + + $runner->addMigration('test', '001_create_test_table', require __DIR__ . '/Migrations/001_create_test_table.php'); + $runner->addMigration('test', '002_seed_test_data', require __DIR__ . '/Migrations/002_seed_test_data.php'); + + return $runner; + } +} diff --git a/tests/Integration/Database/Migrations/001_create_test_table.php b/tests/Integration/Database/Migrations/001_create_test_table.php new file mode 100644 index 0000000..f77382c --- /dev/null +++ b/tests/Integration/Database/Migrations/001_create_test_table.php @@ -0,0 +1,23 @@ +schema(Table::create('migration_test', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::string('name', 255), + Index::primary('id'), + ])); + } + + public function down(Migrator $migrator): void + { + $migrator->schema(Table::drop('migration_test')); + } +}; diff --git a/tests/Integration/Database/Migrations/002_seed_test_data.php b/tests/Integration/Database/Migrations/002_seed_test_data.php new file mode 100644 index 0000000..dd5447b --- /dev/null +++ b/tests/Integration/Database/Migrations/002_seed_test_data.php @@ -0,0 +1,18 @@ +data(Insert::into('migration_test')->values(['name' => 'seeded'])); + } + + public function down(Migrator $migrator): void + { + $migrator->data(Delete::from('migration_test')->where('name', '=', 'seeded')); + } +}; diff --git a/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php b/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php new file mode 100644 index 0000000..ef198b8 --- /dev/null +++ b/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php @@ -0,0 +1,31 @@ +schema(Table::create('posts', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::bigInt('user_id')->unsigned(), + Column::string('title', 255), + Index::primary('id'), + ])); + + $migrator->alter(Table::alter('posts', function ($blueprint) { + $blueprint->add( + Index::foreign('posts_user_id_fk', 'user_id') + ->references('users', 'id') + ->onDelete('cascade'), + ); + })); + } +} diff --git a/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php b/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php new file mode 100644 index 0000000..3b6488f --- /dev/null +++ b/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php @@ -0,0 +1,38 @@ +schema(Table::create('users', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Column::string('name', 255), + Column::string('email', 255), + Index::primary('id'), + Index::unique('users_email_unique', 'email'), + ])); + + $migrator->data(Insert::into('users')->values([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + ])); + } + + public function down(Migrator $migrator): void + { + $migrator->data(Delete::from('users')); + + $migrator->schema(Table::drop('users')); + } +} diff --git a/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php b/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php new file mode 100644 index 0000000..45b853f --- /dev/null +++ b/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php @@ -0,0 +1,19 @@ +data(Insert::into('settings')->values([ + 'key' => 'site_name', + 'value' => 'Test Site', + ])); + } +} diff --git a/tests/Unit/Database/Migrations/MigrationPhaseTest.php b/tests/Unit/Database/Migrations/MigrationPhaseTest.php new file mode 100644 index 0000000..05aabfb --- /dev/null +++ b/tests/Unit/Database/Migrations/MigrationPhaseTest.php @@ -0,0 +1,74 @@ +assertCount(3, $cases); + } + + // ------------------------------------------------------------------------- + // String values + // ------------------------------------------------------------------------- + + /** + * - Schema case has the string value 'schema'. + */ + #[Test] + public function schemaCaseHasCorrectStringValue(): void + { + $this->assertSame('schema', MigrationPhase::Schema->value); + } + + /** + * - Alter case has the string value 'alter'. + */ + #[Test] + public function alterCaseHasCorrectStringValue(): void + { + $this->assertSame('alter', MigrationPhase::Alter->value); + } + + /** + * - Data case has the string value 'data'. + */ + #[Test] + public function dataCaseHasCorrectStringValue(): void + { + $this->assertSame('data', MigrationPhase::Data->value); + } + + // ------------------------------------------------------------------------- + // Construction from string + // ------------------------------------------------------------------------- + + /** + * - Cases can be created from string values via from(). + */ + #[Test] + public function casesCanBeCreatedFromStringValues(): void + { + $this->assertSame(MigrationPhase::Schema, MigrationPhase::from('schema')); + $this->assertSame(MigrationPhase::Alter, MigrationPhase::from('alter')); + $this->assertSame(MigrationPhase::Data, MigrationPhase::from('data')); + } +} diff --git a/tests/Unit/Database/Migrations/MigrationRunnerTest.php b/tests/Unit/Database/Migrations/MigrationRunnerTest.php new file mode 100644 index 0000000..910edcc --- /dev/null +++ b/tests/Unit/Database/Migrations/MigrationRunnerTest.php @@ -0,0 +1,221 @@ +connection = new Connection('test', $pdo); + $this->runner = new MigrationRunner($this->connection); + } + + // ------------------------------------------------------------------------- + // scope() and collect() + // ------------------------------------------------------------------------- + + /** + * - scope() sets the current module and migration name. + */ + #[Test] + public function scopeSetsModuleAndMigrationProperties(): void + { + $this->runner->scope('core', '001_create_users'); + + $reflection = new ReflectionClass($this->runner); + + $module = $reflection->getProperty('module')->getValue($this->runner); + $migration = $reflection->getProperty('migration')->getValue($this->runner); + + $this->assertSame('core', $module); + $this->assertSame('001_create_users', $migration); + } + + /** + * - collect() with CreateUsersTable populates schema and data buckets. + */ + #[Test] + public function collectWithCreateUsersTablePopulatesSchemaAndDataBuckets(): void + { + $this->runner->scope('core', '001_create_users'); + $this->runner->collect(new CreateUsersTable()); + + $reflection = new ReflectionClass($this->runner); + $schemas = $reflection->getProperty('schema')->getValue($this->runner); + $data = $reflection->getProperty('data')->getValue($this->runner); + + $this->assertArrayHasKey('core', $schemas); + $this->assertArrayHasKey('001_create_users', $schemas['core']); + $this->assertCount(1, $schemas['core']['001_create_users']); + + $this->assertArrayHasKey('core', $data); + $this->assertArrayHasKey('001_create_users', $data['core']); + $this->assertCount(1, $data['core']['001_create_users']); + } + + /** + * - collect() with CreatePostsTable populates schema and alter buckets. + */ + #[Test] + public function collectWithCreatePostsTablePopulatesSchemaAndAlterBuckets(): void + { + $this->runner->scope('core', '002_create_posts'); + $this->runner->collect(new CreatePostsTable()); + + $reflection = new ReflectionClass($this->runner); + $schemas = $reflection->getProperty('schema')->getValue($this->runner); + $alters = $reflection->getProperty('alter')->getValue($this->runner); + + $this->assertArrayHasKey('core', $schemas); + $this->assertArrayHasKey('002_create_posts', $schemas['core']); + $this->assertCount(1, $schemas['core']['002_create_posts']); + + $this->assertArrayHasKey('core', $alters); + $this->assertArrayHasKey('002_create_posts', $alters['core']); + $this->assertCount(1, $alters['core']['002_create_posts']); + } + + /** + * - collect() with DataOnlyMigration populates only the data bucket. + */ + #[Test] + public function collectWithDataOnlyMigrationPopulatesOnlyDataBucket(): void + { + $this->runner->scope('core', '003_seed_data'); + $this->runner->collect(new DataOnlyMigration()); + + $reflection = new ReflectionClass($this->runner); + $schemas = $reflection->getProperty('schema')->getValue($this->runner); + $alters = $reflection->getProperty('alter')->getValue($this->runner); + $data = $reflection->getProperty('data')->getValue($this->runner); + + $this->assertEmpty($schemas); + $this->assertEmpty($alters); + + $this->assertArrayHasKey('core', $data); + $this->assertArrayHasKey('003_seed_data', $data['core']); + $this->assertCount(1, $data['core']['003_seed_data']); + } + + /** + * - Expressions from different modules are stored under separate module keys. + */ + #[Test] + public function expressionsFromDifferentModulesAreStoredSeparately(): void + { + $this->runner->scope('core', '001_create_users'); + $this->runner->collect(new CreateUsersTable()); + + $this->runner->scope('blog', '001_create_posts'); + $this->runner->collect(new CreatePostsTable()); + + $reflection = new ReflectionClass($this->runner); + $schemas = $reflection->getProperty('schema')->getValue($this->runner); + + $this->assertArrayHasKey('core', $schemas); + $this->assertArrayHasKey('001_create_users', $schemas['core']); + + $this->assertArrayHasKey('blog', $schemas); + $this->assertArrayHasKey('001_create_posts', $schemas['blog']); + } + + // ------------------------------------------------------------------------- + // addMigration() + // ------------------------------------------------------------------------- + + /** + * - addMigration() stores the migration instance keyed by module and name. + */ + #[Test] + public function addMigrationStoresInstanceByModuleAndName(): void + { + $migration = new CreateUsersTable(); + $this->runner->addMigration('core', '001_create_users', $migration); + + $reflection = new ReflectionClass($this->runner); + $migrations = $reflection->getProperty('migrations')->getValue($this->runner); + + $this->assertArrayHasKey('core', $migrations); + $this->assertArrayHasKey('001_create_users', $migrations['core']); + $this->assertSame($migration, $migrations['core']['001_create_users']); + } + + /** + * - addMigration() can register migrations across multiple modules. + */ + #[Test] + public function addMigrationRegistersAcrossMultipleModules(): void + { + $this->runner->addMigration('core', '001_create_users', new CreateUsersTable()); + $this->runner->addMigration('blog', '001_create_posts', new CreatePostsTable()); + + $reflection = new ReflectionClass($this->runner); + $migrations = $reflection->getProperty('migrations')->getValue($this->runner); + + $this->assertArrayHasKey('core', $migrations); + $this->assertArrayHasKey('blog', $migrations); + $this->assertCount(1, $migrations['core']); + $this->assertCount(1, $migrations['blog']); + } + + /** + * - addMigration() returns self for fluent chaining. + */ + #[Test] + public function addMigrationReturnsSelfForChaining(): void + { + $result = $this->runner->addMigration('core', '001_create_users', new CreateUsersTable()); + + $this->assertSame($this->runner, $result); + } + + // ------------------------------------------------------------------------- + // Rollback error paths + // ------------------------------------------------------------------------- + + /** + * - rollbackMigration() throws RuntimeException for a non-reversible migration. + */ + #[Test] + public function rollbackMigrationThrowsForNonReversibleMigration(): void + { + $this->runner->addMigration('core', '001_create_posts', new CreatePostsTable()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not implement ReversibleMigration'); + + $this->runner->rollbackMigration('001_create_posts'); + } + + /** + * - rollbackMigration() throws RuntimeException for an unknown migration name. + */ + #[Test] + public function rollbackMigrationThrowsForUnknownMigrationName(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Migration 'unknown_migration' not found."); + + $this->runner->rollbackMigration('unknown_migration'); + } +} diff --git a/tests/Unit/Database/Migrations/MigrationStatusTest.php b/tests/Unit/Database/Migrations/MigrationStatusTest.php new file mode 100644 index 0000000..5d73e3e --- /dev/null +++ b/tests/Unit/Database/Migrations/MigrationStatusTest.php @@ -0,0 +1,239 @@ +assertSame('001_create_users', $status->migration); + $this->assertSame('core', $status->module); + $this->assertSame(1, $status->batch); + $this->assertFalse($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + $this->assertFalse($status->errored); + } + + // ------------------------------------------------------------------------- + // Construction with explicit flags + // ------------------------------------------------------------------------- + + /** + * - Construction with explicit flags sets them correctly. + */ + #[Test] + public function constructionWithExplicitFlagsSetsThemCorrectly(): void + { + $status = new MigrationStatus('002_add_roles', 'auth', 3, true, false, true, true); + + $this->assertSame('002_add_roles', $status->migration); + $this->assertSame('auth', $status->module); + $this->assertSame(3, $status->batch); + $this->assertTrue($status->schema); + $this->assertFalse($status->alter); + $this->assertTrue($status->data); + $this->assertTrue($status->errored); + } + + // ------------------------------------------------------------------------- + // phaseCompleted + // ------------------------------------------------------------------------- + + /** + * - phaseCompleted returns the correct flag for the schema phase. + */ + #[Test] + public function phaseCompletedReturnsCorrectValueForSchema(): void + { + $status = new MigrationStatus('001_test', 'core', 1, schema: true); + + $this->assertTrue($status->phaseCompleted('schema')); + $this->assertFalse($status->phaseCompleted('alter')); + $this->assertFalse($status->phaseCompleted('data')); + } + + /** + * - phaseCompleted returns the correct flag for the alter phase. + */ + #[Test] + public function phaseCompletedReturnsCorrectValueForAlter(): void + { + $status = new MigrationStatus('001_test', 'core', 1, alter: true); + + $this->assertFalse($status->phaseCompleted('schema')); + $this->assertTrue($status->phaseCompleted('alter')); + $this->assertFalse($status->phaseCompleted('data')); + } + + /** + * - phaseCompleted returns the correct flag for the data phase. + */ + #[Test] + public function phaseCompletedReturnsCorrectValueForData(): void + { + $status = new MigrationStatus('001_test', 'core', 1, data: true); + + $this->assertFalse($status->phaseCompleted('schema')); + $this->assertFalse($status->phaseCompleted('alter')); + $this->assertTrue($status->phaseCompleted('data')); + } + + /** + * - phaseCompleted returns false for an unknown phase. + */ + #[Test] + public function phaseCompletedReturnsFalseForUnknownPhase(): void + { + $status = new MigrationStatus('001_test', 'core', 1, schema: true, alter: true, data: true); + + $this->assertFalse($status->phaseCompleted('unknown')); + } + + // ------------------------------------------------------------------------- + // markPhase + // ------------------------------------------------------------------------- + + /** + * - markPhase sets the schema flag to true. + */ + #[Test] + public function markPhaseSetsSchemaFlag(): void + { + $status = new MigrationStatus('001_test', 'core', 1); + + $status->markPhase('schema'); + + $this->assertTrue($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + } + + /** + * - markPhase sets the alter flag to true. + */ + #[Test] + public function markPhaseSetsAlterFlag(): void + { + $status = new MigrationStatus('001_test', 'core', 1); + + $status->markPhase('alter'); + + $this->assertFalse($status->schema); + $this->assertTrue($status->alter); + $this->assertFalse($status->data); + } + + /** + * - markPhase sets the data flag to true. + */ + #[Test] + public function markPhaseSetsDataFlag(): void + { + $status = new MigrationStatus('001_test', 'core', 1); + + $status->markPhase('data'); + + $this->assertFalse($status->schema); + $this->assertFalse($status->alter); + $this->assertTrue($status->data); + } + + /** + * - markPhase with an unknown phase does not change any flags. + */ + #[Test] + public function markPhaseWithUnknownPhaseDoesNothing(): void + { + $status = new MigrationStatus('001_test', 'core', 1); + + $status->markPhase('unknown'); + + $this->assertFalse($status->schema); + $this->assertFalse($status->alter); + $this->assertFalse($status->data); + } + + // ------------------------------------------------------------------------- + // markErrored + // ------------------------------------------------------------------------- + + /** + * - markErrored sets the errored flag to true. + */ + #[Test] + public function markErroredSetsErroredFlag(): void + { + $status = new MigrationStatus('001_test', 'core', 1); + + $status->markErrored(); + + $this->assertTrue($status->errored); + } + + // ------------------------------------------------------------------------- + // clearError + // ------------------------------------------------------------------------- + + /** + * - clearError sets the errored flag to false. + */ + #[Test] + public function clearErrorResetsErroredFlag(): void + { + $status = new MigrationStatus('001_test', 'core', 1, errored: true); + + $status->clearError(); + + $this->assertFalse($status->errored); + } + + // ------------------------------------------------------------------------- + // fromRow + // ------------------------------------------------------------------------- + + /** + * - fromRow creates a MigrationStatus from a Row object. + */ + #[Test] + public function fromRowCreatesInstanceFromDatabaseRow(): void + { + $row = new Row([ + 'migration' => '001_create_users', + 'module' => 'core', + 'batch' => 2, + 'schema' => 1, + 'alter' => 0, + 'data' => 1, + 'errored' => 0, + ]); + + $status = MigrationStatus::fromRow($row); + + $this->assertSame('001_create_users', $status->migration); + $this->assertSame('core', $status->module); + $this->assertSame(2, $status->batch); + $this->assertTrue($status->schema); + $this->assertFalse($status->alter); + $this->assertTrue($status->data); + $this->assertFalse($status->errored); + } +} diff --git a/tests/Unit/Database/Migrations/MigratorTest.php b/tests/Unit/Database/Migrations/MigratorTest.php new file mode 100644 index 0000000..0c0edd2 --- /dev/null +++ b/tests/Unit/Database/Migrations/MigratorTest.php @@ -0,0 +1,155 @@ +runner = new MigrationRunner($connection); + + $this->runner->scope('core', 'test_migration'); + + $reflection = new ReflectionClass($this->runner); + $this->migrator = $reflection->getProperty('migrator')->getValue($this->runner); + } + + // ------------------------------------------------------------------------- + // Proxying to runner + // ------------------------------------------------------------------------- + + /** + * - schema() proxies the Schema to the runner's schema bucket. + */ + #[Test] + public function schemaProxiesToRunnerSchemaBucket(): void + { + $table = Table::create('users', [ + Column::bigInt('id')->unsigned()->autoIncrement(), + Index::primary('id'), + ]); + + $this->migrator->schema($table); + + $reflection = new ReflectionClass($this->runner); + $schemas = $reflection->getProperty('schema')->getValue($this->runner); + + $this->assertArrayHasKey('core', $schemas); + $this->assertArrayHasKey('test_migration', $schemas['core']); + $this->assertCount(1, $schemas['core']['test_migration']); + $this->assertSame($table, $schemas['core']['test_migration'][0]); + } + + /** + * - alter() proxies the Schema to the runner's alter bucket. + */ + #[Test] + public function alterProxiesToRunnerAlterBucket(): void + { + $table = Table::create('users', [ + Column::string('email', 255), + ]); + + $this->migrator->alter($table); + + $reflection = new ReflectionClass($this->runner); + $alters = $reflection->getProperty('alter')->getValue($this->runner); + + $this->assertArrayHasKey('core', $alters); + $this->assertArrayHasKey('test_migration', $alters['core']); + $this->assertCount(1, $alters['core']['test_migration']); + $this->assertSame($table, $alters['core']['test_migration'][0]); + } + + /** + * - data() proxies the Query to the runner's data bucket. + */ + #[Test] + public function dataProxiesToRunnerDataBucket(): void + { + $query = Insert::into('users')->values([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $this->migrator->data($query); + + $reflection = new ReflectionClass($this->runner); + $data = $reflection->getProperty('data')->getValue($this->runner); + + $this->assertArrayHasKey('core', $data); + $this->assertArrayHasKey('test_migration', $data['core']); + $this->assertCount(1, $data['core']['test_migration']); + $this->assertSame($query, $data['core']['test_migration'][0]); + } + + // ------------------------------------------------------------------------- + // Fluent chaining + // ------------------------------------------------------------------------- + + /** + * - schema() returns self for fluent chaining. + */ + #[Test] + public function schemaReturnsSelfForFluentChaining(): void + { + $table = Table::create('users', [ + Column::bigInt('id'), + ]); + + $result = $this->migrator->schema($table); + + $this->assertSame($this->migrator, $result); + } + + /** + * - alter() returns self for fluent chaining. + */ + #[Test] + public function alterReturnsSelfForFluentChaining(): void + { + $table = Table::create('users', [ + Column::string('name', 255), + ]); + + $result = $this->migrator->alter($table); + + $this->assertSame($this->migrator, $result); + } + + /** + * - data() returns self for fluent chaining. + */ + #[Test] + public function dataReturnsSelfForFluentChaining(): void + { + $query = Insert::into('users')->values([ + 'name' => 'Test', + ]); + + $result = $this->migrator->data($query); + + $this->assertSame($this->migrator, $result); + } +} diff --git a/tests/Unit/Database/Query/UpdateTest.php b/tests/Unit/Database/Query/UpdateTest.php index b7be9aa..d9ec0fc 100644 --- a/tests/Unit/Database/Query/UpdateTest.php +++ b/tests/Unit/Database/Query/UpdateTest.php @@ -24,7 +24,7 @@ public function setWithPlainValuesProducesCorrectSql(): void { $query = Update::table('users')->set(['name' => 'John', 'age' => 30]); - $this->assertSame('UPDATE users SET name = ?, age = ?', $query->toSql()); + $this->assertSame('UPDATE users SET `name` = ?, `age` = ?', $query->toSql()); $this->assertSame(['John', 30], $query->getBindings()); } @@ -38,7 +38,7 @@ public function setWithExpressionRendersInlineSql(): void ->set(['hits' => RawExpression::make('hits + ?', [1])]) ; - $this->assertSame('UPDATE counters SET hits = hits + ?', $query->toSql()); + $this->assertSame('UPDATE counters SET `hits` = hits + ?', $query->toSql()); $this->assertSame([1], $query->getBindings()); } @@ -56,7 +56,7 @@ public function setWithMixedValuesProducesCorrectSql(): void ]) ; - $this->assertSame('UPDATE users SET name = ?, hits = hits + ?, age = ?', $query->toSql()); + $this->assertSame('UPDATE users SET `name` = ?, `hits` = hits + ?, `age` = ?', $query->toSql()); $this->assertSame(['John', 1, 30], $query->getBindings()); } @@ -71,7 +71,7 @@ public function multipleSetCallsMergeValues(): void ->set(['age' => 30]) ; - $this->assertSame('UPDATE users SET name = ?, age = ?', $query->toSql()); + $this->assertSame('UPDATE users SET `name` = ?, `age` = ?', $query->toSql()); $this->assertSame(['John', 30], $query->getBindings()); } @@ -90,7 +90,7 @@ public function setWithWhereProducesCorrectSql(): void ->where('id', '=', 1) ; - $this->assertSame('UPDATE users SET name = ? WHERE id = ?', $query->toSql()); + $this->assertSame('UPDATE users SET `name` = ? WHERE id = ?', $query->toSql()); $this->assertSame(['John', 1], $query->getBindings()); } @@ -107,7 +107,7 @@ public function orWhereProducesOrConjunction(): void ; $this->assertSame( - 'UPDATE users SET status = ? WHERE role = ? OR age < ?', + 'UPDATE users SET `status` = ? WHERE role = ? OR age < ?', $query->toSql(), ); $this->assertSame(['inactive', 'guest', 18], $query->getBindings()); @@ -124,7 +124,7 @@ public function whereNullProducesIsNullClause(): void ->whereNull('deleted_at') ; - $this->assertSame('UPDATE users SET status = ? WHERE deleted_at IS NULL', $query->toSql()); + $this->assertSame('UPDATE users SET `status` = ? WHERE deleted_at IS NULL', $query->toSql()); } /** @@ -138,7 +138,7 @@ public function whereInProducesInClause(): void ->whereIn('id', [1, 2, 3]) ; - $this->assertSame('UPDATE users SET status = ? WHERE id IN (?, ?, ?)', $query->toSql()); + $this->assertSame('UPDATE users SET `status` = ? WHERE id IN (?, ?, ?)', $query->toSql()); $this->assertSame(['inactive', 1, 2, 3], $query->getBindings()); } @@ -157,7 +157,7 @@ public function orderByAppendsOrderByClause(): void ->orderBy('created_at', 'asc') ; - $this->assertSame('UPDATE users SET status = ? ORDER BY created_at ASC', $query->toSql()); + $this->assertSame('UPDATE users SET `status` = ? ORDER BY created_at ASC', $query->toSql()); $this->assertSame(['inactive'], $query->getBindings()); } @@ -176,7 +176,7 @@ public function limitAppendsLimitClause(): void ->limit(10) ; - $this->assertSame('UPDATE users SET status = ? LIMIT 10', $query->toSql()); + $this->assertSame('UPDATE users SET `status` = ? LIMIT 10', $query->toSql()); $this->assertSame(['inactive'], $query->getBindings()); } @@ -198,7 +198,7 @@ public function fullCombinationProducesCorrectSql(): void ; $this->assertSame( - 'UPDATE users SET status = ? WHERE active = ? ORDER BY created_at ASC LIMIT 100', + 'UPDATE users SET `status` = ? WHERE active = ? ORDER BY created_at ASC LIMIT 100', $query->toSql(), ); $this->assertSame(['inactive', false], $query->getBindings()); @@ -215,7 +215,7 @@ public function expressionInSetWithWhereProducesCorrectBindingOrder(): void ->where('name', '=', 'visits') ; - $this->assertSame('UPDATE counters SET hits = hits + ? WHERE name = ?', $query->toSql()); + $this->assertSame('UPDATE counters SET `hits` = hits + ? WHERE name = ?', $query->toSql()); $this->assertSame([1, 'visits'], $query->getBindings()); } } From 847b9172a64dddbebc19d5cc7cb87f3e4e598611 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Wed, 1 Apr 2026 10:07:30 +0100 Subject: [PATCH 26/29] feat(engine:database): Add Column::char() type Add CHAR(n) factory method to the Column schema builder for fixed-length character columns. --- src/Database/Schema/Column.php | 8 ++++++++ tests/Unit/Database/Schema/ColumnTest.php | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php index d8fca06..2b33ada 100644 --- a/src/Database/Schema/Column.php +++ b/src/Database/Schema/Column.php @@ -71,6 +71,14 @@ public static function string(string $name, int $length): self return new self($name, "VARCHAR({$length})"); } + /** + * Create a CHAR column. + */ + public static function char(string $name, int $length): self + { + return new self($name, "CHAR({$length})"); + } + /** * Create a TEXT column. */ diff --git a/tests/Unit/Database/Schema/ColumnTest.php b/tests/Unit/Database/Schema/ColumnTest.php index b0a1ef9..c9cdc9b 100644 --- a/tests/Unit/Database/Schema/ColumnTest.php +++ b/tests/Unit/Database/Schema/ColumnTest.php @@ -116,6 +116,18 @@ public function stringProducesCorrectSql(): void $this->assertSame([], $column->getBindings()); } + /** + * - char() produces the correct SQL with name and length. + */ + #[Test] + public function charProducesCorrectSql(): void + { + $column = Column::char('code', 2); + + $this->assertSame('`code` CHAR(2) NOT NULL', $column->toSql()); + $this->assertSame([], $column->getBindings()); + } + /** * - text produces TEXT column definition. */ From 7551b24adb64db4d7b798231599d9bda649d742b Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Wed, 1 Apr 2026 12:21:59 +0100 Subject: [PATCH 27/29] refactor(database): Remove all schema and migration functionality --- src/Database/Migrations/MigrationLedger.php | 326 ----------- src/Database/Migrations/MigrationPhase.php | 13 - src/Database/Migrations/MigrationRunner.php | 423 -------------- src/Database/Migrations/MigrationStatus.php | 88 --- src/Database/Migrations/Migrator.php | 57 -- src/Database/Schema/Blueprint.php | 113 ---- src/Database/Schema/Column.php | 418 -------------- src/Database/Schema/Index.php | 199 ------- src/Database/Schema/Table.php | 277 --------- .../Database/MigrationLedgerTest.php | 373 ------------ .../Database/MigrationRunnerTest.php | 191 ------- .../Migrations/001_create_test_table.php | 23 - .../Migrations/002_seed_test_data.php | 18 - .../Migrations/Fixtures/CreatePostsTable.php | 31 - .../Migrations/Fixtures/CreateUsersTable.php | 38 -- .../Migrations/Fixtures/DataOnlyMigration.php | 19 - .../Migrations/MigrationPhaseTest.php | 74 --- .../Migrations/MigrationRunnerTest.php | 221 ------- .../Migrations/MigrationStatusTest.php | 239 -------- .../Unit/Database/Migrations/MigratorTest.php | 155 ----- tests/Unit/Database/Schema/BlueprintTest.php | 157 ----- tests/Unit/Database/Schema/ColumnTest.php | 540 ------------------ tests/Unit/Database/Schema/IndexTest.php | 266 --------- tests/Unit/Database/Schema/TableTest.php | 327 ----------- 24 files changed, 4586 deletions(-) delete mode 100644 src/Database/Migrations/MigrationLedger.php delete mode 100644 src/Database/Migrations/MigrationPhase.php delete mode 100644 src/Database/Migrations/MigrationRunner.php delete mode 100644 src/Database/Migrations/MigrationStatus.php delete mode 100644 src/Database/Migrations/Migrator.php delete mode 100644 src/Database/Schema/Blueprint.php delete mode 100644 src/Database/Schema/Column.php delete mode 100644 src/Database/Schema/Index.php delete mode 100644 src/Database/Schema/Table.php delete mode 100644 tests/Integration/Database/MigrationLedgerTest.php delete mode 100644 tests/Integration/Database/MigrationRunnerTest.php delete mode 100644 tests/Integration/Database/Migrations/001_create_test_table.php delete mode 100644 tests/Integration/Database/Migrations/002_seed_test_data.php delete mode 100644 tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php delete mode 100644 tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php delete mode 100644 tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php delete mode 100644 tests/Unit/Database/Migrations/MigrationPhaseTest.php delete mode 100644 tests/Unit/Database/Migrations/MigrationRunnerTest.php delete mode 100644 tests/Unit/Database/Migrations/MigrationStatusTest.php delete mode 100644 tests/Unit/Database/Migrations/MigratorTest.php delete mode 100644 tests/Unit/Database/Schema/BlueprintTest.php delete mode 100644 tests/Unit/Database/Schema/ColumnTest.php delete mode 100644 tests/Unit/Database/Schema/IndexTest.php delete mode 100644 tests/Unit/Database/Schema/TableTest.php diff --git a/src/Database/Migrations/MigrationLedger.php b/src/Database/Migrations/MigrationLedger.php deleted file mode 100644 index a75fdfa..0000000 --- a/src/Database/Migrations/MigrationLedger.php +++ /dev/null @@ -1,326 +0,0 @@ -|null */ - private ?array $statuses = null; - - public function __construct(Connection $connection) - { - $this->connection = $connection; - } - - /** - * Create the migrations tracking table if it does not already exist. - * - * @throws QueryException - */ - public function ensureTable(): void - { - $this->connection->execute(Table::create('migrations', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::string('migration', 255), - Column::int('batch')->unsigned(), - Column::string('module', 255), - Column::timestamp('migrated_at')->default(Raw::from('CURRENT_TIMESTAMP')), - Column::boolean('schema')->default(false), - Column::boolean('alter')->default(false), - Column::boolean('data')->default(false), - Column::boolean('errored')->default(false), - Index::primary('id'), - Index::unique('migrations_migration_unique', 'migration'), - ])->ifNotExists()); - - $this->loadStatuses(); - } - - /** - * Get the next batch number. - * - * @return int - * - * @throws QueryException - */ - public function nextBatch(): int - { - $row = $this->connection->query( - Select::from('migrations') - ->columns(Raw::from('COALESCE(MAX(batch), 0) as max_batch')), - )->first(); - - if ($row === null) { - return 1; - } - - return $row->int('max_batch') + 1; - } - - /** - * Record a migration in the ledger. - * - * @param string $migration - * @param string $module - * @param int $batch - * - * @throws RuntimeException if the migration already exists in the ledger - * @throws QueryException - */ - public function record(string $migration, string $module, int $batch): void - { - try { - $this->connection->execute( - Insert::into('migrations')->values([ - 'migration' => $migration, - 'module' => $module, - 'batch' => $batch, - ]), - ); - } catch (QueryException $e) { - throw new RuntimeException(sprintf( - 'Cannot record migration \'%s\': a record already exists in the ledger.', - $migration, - ), 0, $e); - } - - $status = new MigrationStatus($migration, $module, $batch); - - if ($this->statuses !== null) { - $this->statuses[$migration] = $status; - } - } - - /** - * Mark a migration phase as completed. - * - * @param string $migration - * @param MigrationPhase $phase - * - * @throws RuntimeException if the migration does not exist in the ledger - * @throws QueryException - */ - public function markPhase(string $migration, MigrationPhase $phase): void - { - $result = $this->connection->execute( - Update::table('migrations') - ->set([$phase->value => true]) - ->where('migration', '=', $migration), - ); - - if (! $result->wasSuccessful()) { - throw new RuntimeException(sprintf( - 'Cannot mark phase \'%s\' for migration \'%s\': migration not found in ledger.', - $phase->value, - $migration, - )); - } - - if (isset($this->statuses[$migration])) { - $this->statuses[$migration]->markPhase($phase->value); - } - } - - /** - * Mark a migration as errored. - * - * @param string $migration - * - * @throws RuntimeException if the migration does not exist in the ledger - * @throws QueryException - */ - public function markErrored(string $migration): void - { - $result = $this->connection->execute( - Update::table('migrations') - ->set(['errored' => true]) - ->where('migration', '=', $migration), - ); - - if (! $result->wasSuccessful()) { - throw new RuntimeException(sprintf( - 'Cannot mark migration \'%s\' as errored: migration not found in ledger.', - $migration, - )); - } - - if (isset($this->statuses[$migration])) { - $this->statuses[$migration]->markErrored(); - } - } - - /** - * Clear the error flag for a migration. - * - * @param string $migration - * - * @throws RuntimeException if the migration does not exist in the ledger - * @throws QueryException - */ - public function clearError(string $migration): void - { - $result = $this->connection->execute( - Update::table('migrations') - ->set(['errored' => 0]) - ->where('migration', '=', $migration), - ); - - if (! $result->wasSuccessful()) { - throw new RuntimeException(sprintf( - 'Cannot clear error for migration \'%s\': migration not found in ledger.', - $migration, - )); - } - - if (isset($this->statuses[$migration])) { - $this->statuses[$migration]->clearError(); - } - } - - /** - * Get the status for a specific migration. - * - * @param string $migration - * - * @return MigrationStatus|null - * - * @throws QueryException - */ - public function getMigrationStatus(string $migration): ?MigrationStatus - { - if ($this->statuses !== null) { - return $this->statuses[$migration] ?? null; - } - - $row = $this->connection->query( - Select::from('migrations') - ->where('migration', '=', $migration), - )->first(); - - if ($row === null) { - return null; - } - - return MigrationStatus::fromRow($row); - } - - /** - * Get all migrations for a specific batch. - * - * @param int $batch - * - * @return list - * - * @throws QueryException - */ - public function getByBatch(int $batch): array - { - if ($this->statuses !== null) { - return array_values(array_filter( - $this->statuses, - static fn (MigrationStatus $status): bool => $status->batch === $batch, - )); - } - - $statuses = []; - - foreach ($this->connection->query( - Select::from('migrations') - ->where('batch', '=', $batch), - )->all() as $row) { - $statuses[] = MigrationStatus::fromRow($row); - } - - return $statuses; - } - - /** - * Remove a migration record from the ledger. - * - * @param string $migration - * - * @throws RuntimeException if the migration does not exist in the ledger - * @throws QueryException - */ - public function remove(string $migration): void - { - $result = $this->connection->execute( - Delete::from('migrations') - ->where('migration', '=', $migration), - ); - - if (! $result->wasSuccessful()) { - throw new RuntimeException(sprintf( - 'Cannot remove migration \'%s\': migration not found in ledger.', - $migration, - )); - } - - if ($this->statuses !== null) { - unset($this->statuses[$migration]); - } - } - - /** - * Get all recorded migration statuses, keyed by migration name. - * - * @return array - * - * @throws QueryException - */ - public function getAllStatuses(): array - { - if ($this->statuses !== null) { - return $this->statuses; - } - - return $this->loadStatuses(); - } - - /** - * Determine if a migration has been run. - * - * @param string $migration - * - * @return bool - * - * @throws QueryException - */ - public function hasRun(string $migration): bool - { - return $this->getMigrationStatus($migration) !== null; - } - - /** - * Load all migration statuses from the database into the cache. - * - * @return array - * - * @throws QueryException - */ - private function loadStatuses(): array - { - $this->statuses = []; - - foreach ($this->connection->query(Select::from('migrations'))->all() as $row) { - $this->statuses[$row->string('migration')] = MigrationStatus::fromRow($row); - } - - return $this->statuses; - } -} diff --git a/src/Database/Migrations/MigrationPhase.php b/src/Database/Migrations/MigrationPhase.php deleted file mode 100644 index 4e0d5e9..0000000 --- a/src/Database/Migrations/MigrationPhase.php +++ /dev/null @@ -1,13 +0,0 @@ ->> - */ - private array $schema = []; - - /** - * @var array>> - */ - private array $alter = []; - - /** - * @var array>> - */ - private array $data = []; - - private Connection $connection; - - private Migrator $migrator; - - private MigrationLedger $ledger; - - /** - * @var array> - */ - private array $migrations = []; - - private string $module; - - private string $migration; - - public function __construct(#[Database] Connection $connection) - { - $this->connection = $connection; - $this->migrator = new Migrator($this); - $this->ledger = new MigrationLedger($connection); - } - - /** - * Register a migration with the runner. - * - * @param string $module - * @param string $name - * @param Migration $migration - * - * @return static - */ - public function addMigration(string $module, string $name, Migration $migration): self - { - $this->migrations[$module][$name] = $migration; - - return $this; - } - - /** - * Set the current module and migration context. - * - * @param string $module - * @param string $migration - * - * @return static - */ - public function scope(string $module, string $migration): self - { - $this->module = $module; - $this->migration = $migration; - - return $this; - } - - /** - * Collect migration expressions by calling the migration's up method. - * - * @param Migration $migration - * - * @return static - */ - public function collect(Migration $migration): self - { - $migration->up($this->migrator); - - return $this; - } - - /** - * Append a schema expression for the current scope. - * - * @param Schema $schema - * - * @return static - */ - public function schema(Schema $schema): self - { - $this->schema[$this->module][$this->migration][] = $schema; - - return $this; - } - - /** - * Append an alter expression for the current scope. - * - * @param Schema $schema - * - * @return static - */ - public function alter(Schema $schema): self - { - $this->alter[$this->module][$this->migration][] = $schema; - - return $this; - } - - /** - * Append a data expression for the current scope. - * - * @param Query $query - * - * @return static - */ - public function data(Query $query): self - { - $this->data[$this->module][$this->migration][] = $query; - - return $this; - } - - /** - * Run all pending migrations. - * - * @param MigrationPhase|null $fromPhase - * @param list $onlyPhases - * - * @throws Throwable - */ - public function migrate(?MigrationPhase $fromPhase = null, array $onlyPhases = []): void - { - $this->ledger->ensureTable(); - - $batch = $this->ledger->nextBatch(); - $phases = $this->resolvePhases($fromPhase, $onlyPhases); - - foreach ($this->migrations as $module => $migrations) { - foreach ($migrations as $name => $migration) { - if (! $this->shouldRun($name, $fromPhase)) { - continue; - } - - $this->scope($module, $name); - $migration->up($this->migrator); - - if (! $this->ledger->hasRun($name)) { - $this->ledger->record($name, $module, $batch); - } - } - } - - foreach ($phases as $phase) { - $this->executePhase($phase); - } - } - - /** - * Rollback migrations for a specific batch. - * - * @param int|null $batch - * - * @throws RuntimeException - */ - public function rollbackBatch(?int $batch = null): void - { - $this->ledger->ensureTable(); - - if ($batch === null) { - $batch = $this->ledger->nextBatch() - 1; - } - - if ($batch < 1) { - return; - } - - $statuses = array_reverse($this->ledger->getByBatch($batch)); - - foreach ($statuses as $status) { - $migration = $this->findMigration($status->migration); - - if (! $migration instanceof ReversibleMigration) { - throw new RuntimeException(sprintf( - 'Migration \'%s\' does not implement ReversibleMigration and cannot be rolled back.', - $status->migration, - )); - } - - $this->scope($this->findModule($status->migration), $status->migration); - $migration->down($this->migrator); - } - - $reversePhases = [MigrationPhase::Data, MigrationPhase::Alter, MigrationPhase::Schema]; - - foreach ($reversePhases as $phase) { - $this->executePhase($phase, force: true); - } - - foreach ($statuses as $status) { - $this->ledger->remove($status->migration); - $this->clearCollected($this->findModule($status->migration), $status->migration); - } - } - - /** - * Rollback a single migration by name. - * - * @param string $migrationName - * - * @throws RuntimeException - */ - public function rollbackMigration(string $migrationName): void - { - $migration = $this->findMigration($migrationName); - - if (! $migration instanceof ReversibleMigration) { - throw new RuntimeException( - "Migration '{$migrationName}' does not implement ReversibleMigration and cannot be rolled back.", - ); - } - - $module = $this->findModule($migrationName); - - $this->scope($module, $migrationName); - $migration->down($this->migrator); - - $reversePhases = [MigrationPhase::Data, MigrationPhase::Alter, MigrationPhase::Schema]; - - foreach ($reversePhases as $phase) { - $this->executePhase($phase, force: true); - } - - $this->ledger->remove($migrationName); - $this->clearCollected($module, $migrationName); - } - - /** - * Resolve which phases to execute. - * - * @param MigrationPhase|null $fromPhase - * @param list $onlyPhases - * - * @return list - */ - private function resolvePhases(?MigrationPhase $fromPhase, array $onlyPhases): array - { - $allPhases = [MigrationPhase::Schema, MigrationPhase::Alter, MigrationPhase::Data]; - - if (! empty($onlyPhases)) { - return $onlyPhases; - } - - if ($fromPhase !== null) { - $index = array_search($fromPhase, $allPhases, true); - - if ($index === false) { - return $allPhases; - } - - return array_slice($allPhases, $index); - } - - return $allPhases; - } - - /** - * Determine if a migration should be run. - * - * @param string $migration - * @param MigrationPhase|null $fromPhase - * - * @return bool - */ - private function shouldRun(string $migration, ?MigrationPhase $fromPhase): bool - { - $status = $this->ledger->getMigrationStatus($migration); - - if ($status === null) { - return true; - } - - if ($status->errored) { - return true; - } - - if ($fromPhase !== null) { - return true; - } - - return false; - } - - /** - * Execute all expressions for a given phase. - * - * @param MigrationPhase $phase - * @param bool $force skip phase-completion checks (used for rollback) - * - * @throws Throwable - */ - private function executePhase(MigrationPhase $phase, bool $force = false): void - { - $bucket = match ($phase) { - MigrationPhase::Schema => $this->schema, - MigrationPhase::Alter => $this->alter, - MigrationPhase::Data => $this->data, - }; - - foreach ($bucket as $module => $migrations) { - foreach ($migrations as $migrationName => $expressions) { - $status = $this->ledger->getMigrationStatus($migrationName); - - if (! $force && $status !== null && $status->phaseCompleted($phase->value)) { - continue; - } - - $isDataPhase = $phase === MigrationPhase::Data; - - if ($isDataPhase) { - $this->connection->beginTransaction(); - } - - try { - foreach ($expressions as $expression) { - $this->connection->execute($expression); - } - - if (! $force) { - $this->ledger->markPhase($migrationName, $phase); - } - - if ($isDataPhase) { - $this->connection->commit(); - } - } catch (Throwable $e) { - if ($isDataPhase) { - $this->connection->rollback(); - } - - if (! $force) { - $this->ledger->markErrored($migrationName); - } - - throw $e; - } - } - } - } - - /** - * Find a migration instance by its name. - * - * @param string $name - * - * @return Migration - * - * @throws RuntimeException - */ - private function findMigration(string $name): Migration - { - foreach ($this->migrations as $migrations) { - if (isset($migrations[$name])) { - return $migrations[$name]; - } - } - - throw new RuntimeException("Migration '{$name}' not found."); - } - - /** - * Find the module name for a given migration. - * - * @param string $name - * - * @return string - * - * @throws RuntimeException - */ - private function findModule(string $name): string - { - foreach ($this->migrations as $module => $migrations) { - if (isset($migrations[$name])) { - return $module; - } - } - - throw new RuntimeException("Migration '{$name}' not found."); - } - - /** - * Clear collected expressions for a specific module and migration. - * - * @param string $module - * @param string $migration - */ - private function clearCollected(string $module, string $migration): void - { - unset( - $this->schema[$module][$migration], - $this->alter[$module][$migration], - $this->data[$module][$migration], - ); - } -} diff --git a/src/Database/Migrations/MigrationStatus.php b/src/Database/Migrations/MigrationStatus.php deleted file mode 100644 index aa547c2..0000000 --- a/src/Database/Migrations/MigrationStatus.php +++ /dev/null @@ -1,88 +0,0 @@ -string('migration'), - $row->string('module'), - $row->int('batch'), - $row->bool('schema'), - $row->bool('alter'), - $row->bool('data'), - $row->bool('errored'), - ); - } - - public function __construct( - public readonly string $migration, - public readonly string $module, - public readonly int $batch, - public bool $schema = false, - public bool $alter = false, - public bool $data = false, - public bool $errored = false, - ) { - } - - /** - * Check whether a named phase has completed. - * - * @param string $phase - * - * @return bool - */ - public function phaseCompleted(string $phase): bool - { - return match ($phase) { - 'schema' => $this->schema, - 'alter' => $this->alter, - 'data' => $this->data, - default => false, - }; - } - - /** - * Mark a named phase as completed. - * - * @param string $phase - */ - public function markPhase(string $phase): void - { - match ($phase) { - 'schema' => $this->schema = true, - 'alter' => $this->alter = true, - 'data' => $this->data = true, - default => null, - }; - } - - /** - * Mark the migration as errored. - */ - public function markErrored(): void - { - $this->errored = true; - } - - /** - * Clear the errored flag. - */ - public function clearError(): void - { - $this->errored = false; - } -} diff --git a/src/Database/Migrations/Migrator.php b/src/Database/Migrations/Migrator.php deleted file mode 100644 index 3cb1d37..0000000 --- a/src/Database/Migrations/Migrator.php +++ /dev/null @@ -1,57 +0,0 @@ -runner->schema($schema); - - return $this; - } - - /** - * Register an alter phase expression. - * - * @param Schema $schema - * - * @return static - */ - public function alter(Schema $schema): self - { - $this->runner->alter($schema); - - return $this; - } - - /** - * Register a data phase expression. - * - * @param Query $query - * - * @return static - */ - public function data(Query $query): self - { - $this->runner->data($query); - - return $this; - } -} diff --git a/src/Database/Schema/Blueprint.php b/src/Database/Schema/Blueprint.php deleted file mode 100644 index b4e0d5a..0000000 --- a/src/Database/Schema/Blueprint.php +++ /dev/null @@ -1,113 +0,0 @@ -}> - */ - private array $operations = []; - - /** - * Add a column or index to the table. - * - * @return static - */ - public function add(Column|Index $expression): self - { - $this->operations[] = [ - 'action' => 'add', - 'expression' => $expression, - 'details' => [], - ]; - - return $this; - } - - /** - * Modify a column in the table. - * - * @return static - */ - public function modify(Column $column): self - { - $this->operations[] = [ - 'action' => 'modify', - 'expression' => $column, - 'details' => [], - ]; - - return $this; - } - - /** - * Drop a column or index from the table. - * - * @return static - */ - public function drop(Column|Index $reference): self - { - $this->operations[] = [ - 'action' => 'drop', - 'expression' => $reference, - 'details' => [], - ]; - - return $this; - } - - /** - * Rename a column in the table. - * - * @return static - */ - public function rename(Column $reference, string $newName): self - { - $this->operations[] = [ - 'action' => 'rename', - 'expression' => $reference, - 'details' => ['to' => $newName], - ]; - - return $this; - } - - /** - * Get the SQL representation of the expression. - * - * @return string - */ - public function toSql(): string - { - return implode(', ', array_map($this->renderOperation(...), $this->operations)); - } - - /** - * Get the bindings for the expression. - * - * @return array - */ - public function getBindings(): array - { - return []; - } - - /** - * @param array{action: 'add'|'modify'|'drop'|'rename', expression: Column|Index, details: array} $operation - */ - private function renderOperation(array $operation): string - { - $expression = $operation['expression']; - - return match ($operation['action']) { - 'add' => ($expression instanceof Column ? 'ADD COLUMN ' : 'ADD ') . $expression->toSql(), - 'modify' => 'MODIFY COLUMN ' . $expression->toSql(), - 'drop' => ($expression instanceof Column ? 'DROP COLUMN ' : 'DROP INDEX ') . $expression->toSql(), - 'rename' => "RENAME COLUMN {$expression->toSql()} TO `{$operation['details']['to']}`", - }; - } -} diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php deleted file mode 100644 index 2b33ada..0000000 --- a/src/Database/Schema/Column.php +++ /dev/null @@ -1,418 +0,0 @@ - $values - */ - public static function enum(string $name, array $values): self - { - $quoted = implode(', ', array_map( - fn (string $v) => "'" . str_replace("'", "''", $v) . "'", - $values, - )); - - return new self($name, "ENUM({$quoted})"); - } - - /** - * Create a named reference to an existing column, for use in - * Blueprint drop and rename operations. Produces only the - * backtick-quoted name, with no type or modifiers. - */ - public static function named(string $name): self - { - $column = new self($name, ''); - $column->isNamedRef = true; - - return $column; - } - - private bool $isNullable = false; - - private bool $hasDefault = false; - - private bool|Expression|float|int|string|null $defaultValue = null; - - private bool $isUnsigned = false; - - private bool $isAutoInc = false; - - private ?string $comment = null; - - private ?string $charset = null; - - private ?string $collation = null; - - private ?string $afterColumn = null; - - private bool $isFirst = false; - - private bool $isNamedRef = false; - - private function __construct( - private readonly string $name, - private readonly string $type, - ) { - } - - /** - * Mark the column as nullable. - * - * @return static - */ - public function nullable(): self - { - $this->isNullable = true; - - return $this; - } - - /** - * Set the default value for the column. - * - * Accepts scalar values for literal defaults, or an Expression - * for raw SQL defaults like CURRENT_TIMESTAMP. - * - * @return static - */ - public function default(bool|Expression|float|int|string|null $value): self - { - $this->hasDefault = true; - $this->defaultValue = $value; - - return $this; - } - - /** - * Mark the column as unsigned. - * - * @return static - */ - public function unsigned(): self - { - $this->isUnsigned = true; - - return $this; - } - - /** - * Mark the column as auto-incrementing. - * - * @return static - */ - public function autoIncrement(): self - { - $this->isAutoInc = true; - - return $this; - } - - /** - * Set a comment on the column. - * - * @return static - */ - public function comment(string $comment): self - { - $this->comment = $comment; - - return $this; - } - - /** - * Set the character set for the column. - * - * @return static - */ - public function charset(string $charset): self - { - $this->charset = $charset; - - return $this; - } - - /** - * Set the collation for the column. - * - * @return static - */ - public function collation(string $collation): self - { - $this->collation = $collation; - - return $this; - } - - /** - * Position the column after an existing column. - * - * @return static - */ - public function after(string $column): self - { - $this->afterColumn = $column; - - return $this; - } - - /** - * Position the column first in the table. - * - * @return static - */ - public function first(): self - { - $this->isFirst = true; - - return $this; - } - - /** - * Get the SQL representation of the expression. - * - * @return string - */ - public function toSql(): string - { - if ($this->isNamedRef) { - return "`{$this->name}`"; - } - - $parts = ["`{$this->name}`", $this->type]; - - if ($this->isUnsigned) { - $parts[] = 'UNSIGNED'; - } - - $parts[] = $this->isNullable ? 'NULL' : 'NOT NULL'; - - if ($this->hasDefault) { - $parts[] = 'DEFAULT ' . $this->renderDefault(); - } - - if ($this->isAutoInc) { - $parts[] = 'AUTO_INCREMENT'; - } - - if ($this->comment !== null) { - $parts[] = "COMMENT '" . str_replace("'", "''", $this->comment) . "'"; - } - - if ($this->charset !== null) { - $parts[] = "CHARACTER SET {$this->charset}"; - } - - if ($this->collation !== null) { - $parts[] = "COLLATE {$this->collation}"; - } - - if ($this->isFirst) { - $parts[] = 'FIRST'; - } - - if ($this->afterColumn !== null) { - $parts[] = "AFTER `{$this->afterColumn}`"; - } - - return implode(' ', $parts); - } - - /** - * Get the bindings for the expression. - * - * @return array - */ - public function getBindings(): array - { - return []; - } - - private function renderDefault(): string - { - if ($this->defaultValue instanceof Expression) { - return $this->defaultValue->toSql(); - } - - if ($this->defaultValue === null) { - return 'NULL'; - } - - if (is_bool($this->defaultValue)) { - return $this->defaultValue ? 'TRUE' : 'FALSE'; - } - - if (is_int($this->defaultValue) || is_float($this->defaultValue)) { - return (string) $this->defaultValue; - } - - return "'" . str_replace("'", "''", $this->defaultValue) . "'"; - } -} diff --git a/src/Database/Schema/Index.php b/src/Database/Schema/Index.php deleted file mode 100644 index 42d444f..0000000 --- a/src/Database/Schema/Index.php +++ /dev/null @@ -1,199 +0,0 @@ - $columns - */ - public static function primary(array|string $columns): self - { - return new self('primary', null, (array) $columns); - } - - /** - * Create a UNIQUE INDEX. - * - * @param list $columns - */ - public static function unique(string $name, array|string $columns): self - { - return new self('unique', $name, (array) $columns); - } - - /** - * Create an INDEX. - * - * @param list $columns - */ - public static function index(string $name, array|string $columns): self - { - return new self('index', $name, (array) $columns); - } - - /** - * Create a FULLTEXT INDEX. - * - * @param list $columns - */ - public static function fulltext(string $name, array|string $columns): self - { - return new self('fulltext', $name, (array) $columns); - } - - /** - * Create a FOREIGN KEY constraint. - * - * @param list $columns - */ - public static function foreign(string $name, array|string $columns): self - { - return new self('foreign', $name, (array) $columns); - } - - /** - * Create a named reference to an existing index, for use in - * Blueprint drop operations. Produces only the backtick-quoted name. - */ - public static function named(string $name): self - { - return new self('named', $name, []); - } - - private ?string $referenceTable = null; - - /** - * @var list - */ - private array $referenceColumns = []; - - private ?string $deleteAction = null; - - private ?string $updateAction = null; - - /** - * @param 'primary'|'unique'|'index'|'fulltext'|'foreign'|'named' $type - * @param list $columns - */ - private function __construct( - private readonly string $type, - private readonly ?string $name, - private readonly array $columns, - ) { - } - - /** - * Set the referenced table and columns for a foreign key. - * - * @param string|list $columns - * - * @return static - */ - public function references(string $table, array|string $columns): self - { - $this->referenceTable = $table; - $this->referenceColumns = (array) $columns; - - return $this; - } - - /** - * Set the ON DELETE action for a foreign key. The action - * string is automatically uppercased. - * - * @return static - */ - public function onDelete(string $action): self - { - $this->deleteAction = strtoupper($action); - - return $this; - } - - /** - * Set the ON UPDATE action for a foreign key. The action - * string is automatically uppercased. - * - * @return static - */ - public function onUpdate(string $action): self - { - $this->updateAction = strtoupper($action); - - return $this; - } - - /** - * Get the SQL representation of the expression. - * - * @return string - */ - public function toSql(): string - { - if ($this->type === 'named') { - return "`{$this->name}`"; - } - - if ($this->type === 'foreign') { - return $this->buildForeignKeySql(); - } - - $quotedColumns = $this->quoteColumns($this->columns); - - return match ($this->type) { - 'primary' => "PRIMARY KEY ({$quotedColumns})", - 'unique' => "UNIQUE INDEX `{$this->name}` ({$quotedColumns})", - 'index' => "INDEX `{$this->name}` ({$quotedColumns})", - 'fulltext' => "FULLTEXT INDEX `{$this->name}` ({$quotedColumns})", - }; - } - - /** - * Get the bindings for the expression. - * - * @return array - */ - public function getBindings(): array - { - return []; - } - - private function buildForeignKeySql(): string - { - $quotedColumns = $this->quoteColumns($this->columns); - - $sql = "CONSTRAINT `{$this->name}` FOREIGN KEY ({$quotedColumns})"; - - if ($this->referenceTable !== null) { - $refColumns = $this->quoteColumns($this->referenceColumns); - $sql .= " REFERENCES `{$this->referenceTable}` ({$refColumns})"; - } - - if ($this->deleteAction !== null) { - $sql .= " ON DELETE {$this->deleteAction}"; - } - - if ($this->updateAction !== null) { - $sql .= " ON UPDATE {$this->updateAction}"; - } - - return $sql; - } - - /** - * @param list $columns - */ - private function quoteColumns(array $columns): string - { - return implode(', ', array_map( - fn (string $column) => "`{$column}`", - $columns, - )); - } -} diff --git a/src/Database/Schema/Table.php b/src/Database/Schema/Table.php deleted file mode 100644 index f986027..0000000 --- a/src/Database/Schema/Table.php +++ /dev/null @@ -1,277 +0,0 @@ - $definitions - */ - public static function create(string $table, array $definitions): self - { - return new self(self::MODE_CREATE, $table, $definitions); - } - - /** - * Alter an existing table using a blueprint closure. - * - * @param Closure(Blueprint): void $callback - */ - public static function alter(string $table, Closure $callback): self - { - $blueprint = new Blueprint(); - $callback($blueprint); - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - /** - * Drop a table. - */ - public static function drop(string $table): self - { - return new self(self::MODE_DROP, $table); - } - - /** - * Add columns to an existing table. - * - * @param list $columns - */ - public static function addColumns(string $table, array $columns): self - { - $blueprint = new Blueprint(); - - foreach ($columns as $column) { - $blueprint->add($column); - } - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - /** - * Drop columns from an existing table. - * - * @param list $names - */ - public static function dropColumns(string $table, array $names): self - { - $blueprint = new Blueprint(); - - foreach ($names as $name) { - $blueprint->drop(Column::named($name)); - } - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - /** - * Add indexes to an existing table. - * - * @param list $indexes - */ - public static function addIndexes(string $table, array $indexes): self - { - $blueprint = new Blueprint(); - - foreach ($indexes as $index) { - $blueprint->add($index); - } - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - /** - * Drop indexes from an existing table. - * - * @param list $names - */ - public static function dropIndexes(string $table, array $names): self - { - $blueprint = new Blueprint(); - - foreach ($names as $name) { - $blueprint->drop(Index::named($name)); - } - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - /** - * Rename a column in an existing table. - */ - public static function renameColumn(string $table, string $from, string $to): self - { - $blueprint = new Blueprint(); - $blueprint->rename(Column::named($from), $to); - - return new self(self::MODE_ALTER, $table, blueprint: $blueprint); - } - - private bool $ifNotExists = false; - - private bool $ifExists = false; - - private ?string $engine = null; - - private ?string $charset = null; - - private ?string $collation = null; - - /** - * @param self::MODE_* $mode - * @param list $definitions - */ - private function __construct( - private readonly string $mode, - private readonly string $table, - private readonly array $definitions = [], - private readonly ?Blueprint $blueprint = null, - ) { - } - - /** - * Add the IF NOT EXISTS modifier (create mode). - * - * @return static - */ - public function ifNotExists(): self - { - $this->ifNotExists = true; - - return $this; - } - - /** - * Add the IF EXISTS modifier (drop mode). - * - * @return static - */ - public function ifExists(): self - { - $this->ifExists = true; - - return $this; - } - - /** - * Set the table engine. - * - * @return static - */ - public function engine(string $engine): self - { - $this->engine = $engine; - - return $this; - } - - /** - * Set the default character set. - * - * @return static - */ - public function charset(string $charset): self - { - $this->charset = $charset; - - return $this; - } - - /** - * Set the default collation. - * - * @return static - */ - public function collation(string $collation): self - { - $this->collation = $collation; - - return $this; - } - - /** - * Get the SQL representation of the expression. - * - * @return string - */ - public function toSql(): string - { - return match ($this->mode) { - self::MODE_CREATE => $this->buildCreate(), - self::MODE_ALTER => $this->buildAlter(), - self::MODE_DROP => $this->buildDrop(), - }; - } - - /** - * Get the bindings for the expression. - * - * @return array - */ - public function getBindings(): array - { - return []; - } - - private function buildCreate(): string - { - $prefix = 'CREATE TABLE'; - - if ($this->ifNotExists) { - $prefix .= ' IF NOT EXISTS'; - } - - $definitionSql = implode(",\n ", array_map( - fn (Expression $def) => $def->toSql(), - $this->definitions, - )); - - $sql = "{$prefix} `{$this->table}` (\n {$definitionSql}\n)"; - - if ($this->engine !== null) { - $sql .= " ENGINE = {$this->engine}"; - } - - if ($this->charset !== null) { - $sql .= " DEFAULT CHARACTER SET {$this->charset}"; - } - - if ($this->collation !== null) { - $sql .= " DEFAULT COLLATE {$this->collation}"; - } - - return $sql; - } - - private function buildAlter(): string - { - assert($this->blueprint !== null); - - return "ALTER TABLE `{$this->table}` {$this->blueprint->toSql()}"; - } - - private function buildDrop(): string - { - $prefix = 'DROP TABLE'; - - if ($this->ifExists) { - $prefix .= ' IF EXISTS'; - } - - return "{$prefix} `{$this->table}`"; - } -} diff --git a/tests/Integration/Database/MigrationLedgerTest.php b/tests/Integration/Database/MigrationLedgerTest.php deleted file mode 100644 index 8b586e1..0000000 --- a/tests/Integration/Database/MigrationLedgerTest.php +++ /dev/null @@ -1,373 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ], - ); - - self::$connection = new Connection('test', $pdo); - } - - public static function tearDownAfterClass(): void - { - self::$connection->execute('DROP TABLE IF EXISTS migrations'); - } - - protected function setUp(): void - { - self::$connection->execute('DROP TABLE IF EXISTS migrations'); - $this->ledger = new MigrationLedger(self::$connection); - $this->ledger->ensureTable(); - } - - // ------------------------------------------------------------------------- - // ensureTable - // ------------------------------------------------------------------------- - - /** - * - ensureTable creates the migrations table. - */ - #[Test] - public function ensureTableCreatesTheMigrationsTable(): void - { - $result = self::$connection->query('SHOW TABLES LIKE \'migrations\''); - - $this->assertNotNull($result->first()); - } - - /** - * - ensureTable is idempotent and can be called twice without error. - */ - #[Test] - public function ensureTableIsIdempotent(): void - { - $this->ledger->ensureTable(); - - $result = self::$connection->query('SHOW TABLES LIKE \'migrations\''); - - $this->assertNotNull($result->first()); - } - - // ------------------------------------------------------------------------- - // record and getMigrationStatus - // ------------------------------------------------------------------------- - - /** - * - record inserts a row and getMigrationStatus retrieves it with correct values. - */ - #[Test] - public function recordAndGetMigrationStatusRetrievesCorrectValues(): void - { - $this->ledger->record('2024_01_01_000000_create_users_table', 'core', 1); - - $status = $this->ledger->getMigrationStatus('2024_01_01_000000_create_users_table'); - - $this->assertNotNull($status); - $this->assertInstanceOf(MigrationStatus::class, $status); - $this->assertSame('2024_01_01_000000_create_users_table', $status->migration); - $this->assertSame('core', $status->module); - $this->assertSame(1, $status->batch); - $this->assertFalse($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - $this->assertFalse($status->errored); - } - - /** - * - getMigrationStatus returns null for a non-existent migration. - */ - #[Test] - public function getMigrationStatusReturnsNullForNonExistentMigration(): void - { - $row = $this->ledger->getMigrationStatus('non_existent_migration'); - - $this->assertNull($row); - } - - // ------------------------------------------------------------------------- - // nextBatch - // ------------------------------------------------------------------------- - - /** - * - nextBatch returns 1 when no migrations exist. - */ - #[Test] - public function nextBatchReturnsOneWhenNoMigrationsExist(): void - { - $this->assertSame(1, $this->ledger->nextBatch()); - } - - /** - * - nextBatch returns max batch plus one after records exist. - */ - #[Test] - public function nextBatchReturnsMaxBatchPlusOneAfterRecordsExist(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->record('migration_b', 'core', 2); - $this->ledger->record('migration_c', 'core', 3); - - $this->assertSame(4, $this->ledger->nextBatch()); - } - - // ------------------------------------------------------------------------- - // markPhase - // ------------------------------------------------------------------------- - - /** - * - markPhase sets the schema flag to true while other flags remain false. - */ - #[Test] - public function markPhaseSetsSchemaFlagToTrue(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->markPhase('migration_a', MigrationPhase::Schema); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertTrue($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - } - - /** - * - markPhase can mark all three phases independently. - */ - #[Test] - public function markPhaseCanMarkAllThreePhasesIndependently(): void - { - $this->ledger->record('migration_a', 'core', 1); - - $this->ledger->markPhase('migration_a', MigrationPhase::Schema); - $this->ledger->markPhase('migration_a', MigrationPhase::Alter); - $this->ledger->markPhase('migration_a', MigrationPhase::Data); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertTrue($status->schema); - $this->assertTrue($status->alter); - $this->assertTrue($status->data); - } - - // ------------------------------------------------------------------------- - // markErrored and clearError - // ------------------------------------------------------------------------- - - /** - * - markErrored sets the errored flag to true. - */ - #[Test] - public function markErroredSetsErroredFlag(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->markErrored('migration_a'); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertTrue($status->errored); - } - - /** - * - clearError resets the errored flag to false. - */ - #[Test] - public function clearErrorResetsErroredFlag(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->markErrored('migration_a'); - $this->ledger->clearError('migration_a'); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertFalse($status->errored); - } - - // ------------------------------------------------------------------------- - // getByBatch - // ------------------------------------------------------------------------- - - /** - * - getByBatch returns all migrations in the given batch. - */ - #[Test] - public function getByBatchReturnsAllMigrationsInGivenBatch(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->record('migration_b', 'core', 1); - $this->ledger->record('migration_c', 'auth', 2); - - $batch1 = $this->ledger->getByBatch(1); - $batch2 = $this->ledger->getByBatch(2); - - $this->assertCount(2, $batch1); - $this->assertCount(1, $batch2); - - $this->assertContainsOnlyInstancesOf(MigrationStatus::class, $batch1); - $this->assertContainsOnlyInstancesOf(MigrationStatus::class, $batch2); - } - - /** - * - getByBatch returns an empty result for a non-existent batch. - */ - #[Test] - public function getByBatchReturnsEmptyResultForNonExistentBatch(): void - { - $result = $this->ledger->getByBatch(999); - - $this->assertCount(0, $result); - } - - // ------------------------------------------------------------------------- - // remove - // ------------------------------------------------------------------------- - - /** - * - remove deletes the migration record so getMigrationStatus returns null. - */ - #[Test] - public function removeDeletesMigrationRecord(): void - { - $this->ledger->record('migration_a', 'core', 1); - - $this->assertNotNull($this->ledger->getMigrationStatus('migration_a')); - - $this->ledger->remove('migration_a'); - - $this->assertNull($this->ledger->getMigrationStatus('migration_a')); - } - - // ------------------------------------------------------------------------- - // hasRun - // ------------------------------------------------------------------------- - - /** - * - hasRun returns true for a recorded migration. - */ - #[Test] - public function hasRunReturnsTrueForRecordedMigration(): void - { - $this->ledger->record('migration_a', 'core', 1); - - $this->assertTrue($this->ledger->hasRun('migration_a')); - } - - /** - * - hasRun returns false for a non-existent migration. - */ - #[Test] - public function hasRunReturnsFalseForNonExistentMigration(): void - { - $this->assertFalse($this->ledger->hasRun('non_existent_migration')); - } - - // ------------------------------------------------------------------------- - // record() exception paths - // ------------------------------------------------------------------------- - - /** - * - record throws RuntimeException for a duplicate migration name. - */ - #[Test] - public function recordThrowsRuntimeExceptionForDuplicateMigration(): void - { - $this->ledger->record('migration_a', 'core', 1); - - $this->expectException(RuntimeException::class); - - $this->ledger->record('migration_a', 'core', 2); - } - - // ------------------------------------------------------------------------- - // remove() exception paths - // ------------------------------------------------------------------------- - - /** - * - remove throws RuntimeException for a non-existent migration. - */ - #[Test] - public function removeThrowsRuntimeExceptionForNonExistentMigration(): void - { - $this->expectException(RuntimeException::class); - - $this->ledger->remove('non_existent_migration'); - } - - // ------------------------------------------------------------------------- - // Cache consistency - // ------------------------------------------------------------------------- - - /** - * - Cache is consistent after record then getMigrationStatus. - */ - #[Test] - public function cacheIsConsistentAfterRecordThenGet(): void - { - $this->ledger->record('migration_a', 'core', 1); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertInstanceOf(MigrationStatus::class, $status); - $this->assertSame('migration_a', $status->migration); - $this->assertSame('core', $status->module); - $this->assertSame(1, $status->batch); - $this->assertFalse($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - $this->assertFalse($status->errored); - } - - /** - * - Cache is consistent after markPhase then getMigrationStatus. - */ - #[Test] - public function cacheIsConsistentAfterMarkPhaseThenGet(): void - { - $this->ledger->record('migration_a', 'core', 1); - $this->ledger->markPhase('migration_a', MigrationPhase::Schema); - - $status = $this->ledger->getMigrationStatus('migration_a'); - - $this->assertNotNull($status); - $this->assertTrue($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - $this->assertFalse($status->errored); - } -} diff --git a/tests/Integration/Database/MigrationRunnerTest.php b/tests/Integration/Database/MigrationRunnerTest.php deleted file mode 100644 index 6b6f546..0000000 --- a/tests/Integration/Database/MigrationRunnerTest.php +++ /dev/null @@ -1,191 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ], - ); - - self::$connection = new Connection('test', $pdo); - } - - public static function tearDownAfterClass(): void - { - self::$connection->execute('DROP TABLE IF EXISTS migration_test'); - self::$connection->execute('DROP TABLE IF EXISTS migrations'); - } - - protected function setUp(): void - { - self::$connection->execute('DROP TABLE IF EXISTS migration_test'); - self::$connection->execute('DROP TABLE IF EXISTS migrations'); - } - - // ------------------------------------------------------------------------- - // migrate - // ------------------------------------------------------------------------- - - /** - * - migrate executes all phases, creating the table and seeding data. - */ - #[Test] - public function migrateExecutesAllPhases(): void - { - $runner = $this->buildRunner(); - $runner->migrate(); - - // Table exists and has a seeded row - $rows = self::$connection->query(Select::from('migration_test'))->all(); - - $this->assertCount(1, $rows); - $this->assertSame('seeded', $rows[0]->string('name')); - - // Ledger has records for both migrations - $ledger = new MigrationLedger(self::$connection); - - $status001 = $ledger->getMigrationStatus('001_create_test_table'); - $this->assertNotNull($status001); - $this->assertTrue($status001->schema); - $this->assertSame('test', $status001->module); - - $status002 = $ledger->getMigrationStatus('002_seed_test_data'); - $this->assertNotNull($status002); - $this->assertTrue($status002->data); - $this->assertSame('test', $status002->module); - } - - /** - * - migrate is idempotent and does not duplicate data when run twice. - */ - #[Test] - public function migrateIsIdempotent(): void - { - $this->buildRunner()->migrate(); - $this->buildRunner()->migrate(); - - $rows = self::$connection->query(Select::from('migration_test'))->all(); - - $this->assertCount(1, $rows); - } - - /** - * - migrate with onlyPhases=[Schema] creates the table but inserts no data. - */ - #[Test] - public function migrateWithOnlySchemaPhaseCreatesTableButNoData(): void - { - $runner = $this->buildRunner(); - $runner->migrate(onlyPhases: [MigrationPhase::Schema]); - - // Table exists - $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); - $this->assertNotNull($tableResult->first()); - - // No data rows - $rows = self::$connection->query(Select::from('migration_test'))->all(); - $this->assertCount(0, $rows); - - // Ledger: 001 has schema=true but data=false - $ledger = new MigrationLedger(self::$connection); - $status001 = $ledger->getMigrationStatus('001_create_test_table'); - - $this->assertNotNull($status001); - $this->assertTrue($status001->schema); - $this->assertFalse($status001->data); - } - - // ------------------------------------------------------------------------- - // rollbackBatch - // ------------------------------------------------------------------------- - - /** - * - rollbackBatch reverses the last batch, dropping the table and clearing the ledger. - */ - #[Test] - public function rollbackBatchReversesLastBatch(): void - { - $this->buildRunner()->migrate(); - $this->buildRunner()->rollbackBatch(); - - // Table no longer exists - $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); - $this->assertNull($tableResult->first()); - - // Ledger has no records for either migration - $ledger = new MigrationLedger(self::$connection); - - $this->assertNull($ledger->getMigrationStatus('001_create_test_table')); - $this->assertNull($ledger->getMigrationStatus('002_seed_test_data')); - } - - // ------------------------------------------------------------------------- - // rollbackMigration - // ------------------------------------------------------------------------- - - /** - * - rollbackMigration reverses a specific migration, leaving others intact. - */ - #[Test] - public function rollbackMigrationReversesSpecificMigration(): void - { - $this->buildRunner()->migrate(); - $this->buildRunner()->rollbackMigration('002_seed_test_data'); - - // Table still exists - $tableResult = self::$connection->query('SHOW TABLES LIKE \'migration_test\''); - $this->assertNotNull($tableResult->first()); - - // Seeded row is gone - $rows = self::$connection->query(Select::from('migration_test'))->all(); - $this->assertCount(0, $rows); - - // Ledger: 001 still recorded, 002 removed - $ledger = new MigrationLedger(self::$connection); - - $this->assertNotNull($ledger->getMigrationStatus('001_create_test_table')); - $this->assertNull($ledger->getMigrationStatus('002_seed_test_data')); - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - private function buildRunner(): MigrationRunner - { - $runner = new MigrationRunner(self::$connection); - - $runner->addMigration('test', '001_create_test_table', require __DIR__ . '/Migrations/001_create_test_table.php'); - $runner->addMigration('test', '002_seed_test_data', require __DIR__ . '/Migrations/002_seed_test_data.php'); - - return $runner; - } -} diff --git a/tests/Integration/Database/Migrations/001_create_test_table.php b/tests/Integration/Database/Migrations/001_create_test_table.php deleted file mode 100644 index f77382c..0000000 --- a/tests/Integration/Database/Migrations/001_create_test_table.php +++ /dev/null @@ -1,23 +0,0 @@ -schema(Table::create('migration_test', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::string('name', 255), - Index::primary('id'), - ])); - } - - public function down(Migrator $migrator): void - { - $migrator->schema(Table::drop('migration_test')); - } -}; diff --git a/tests/Integration/Database/Migrations/002_seed_test_data.php b/tests/Integration/Database/Migrations/002_seed_test_data.php deleted file mode 100644 index dd5447b..0000000 --- a/tests/Integration/Database/Migrations/002_seed_test_data.php +++ /dev/null @@ -1,18 +0,0 @@ -data(Insert::into('migration_test')->values(['name' => 'seeded'])); - } - - public function down(Migrator $migrator): void - { - $migrator->data(Delete::from('migration_test')->where('name', '=', 'seeded')); - } -}; diff --git a/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php b/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php deleted file mode 100644 index ef198b8..0000000 --- a/tests/Unit/Database/Migrations/Fixtures/CreatePostsTable.php +++ /dev/null @@ -1,31 +0,0 @@ -schema(Table::create('posts', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::bigInt('user_id')->unsigned(), - Column::string('title', 255), - Index::primary('id'), - ])); - - $migrator->alter(Table::alter('posts', function ($blueprint) { - $blueprint->add( - Index::foreign('posts_user_id_fk', 'user_id') - ->references('users', 'id') - ->onDelete('cascade'), - ); - })); - } -} diff --git a/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php b/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php deleted file mode 100644 index 3b6488f..0000000 --- a/tests/Unit/Database/Migrations/Fixtures/CreateUsersTable.php +++ /dev/null @@ -1,38 +0,0 @@ -schema(Table::create('users', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::string('name', 255), - Column::string('email', 255), - Index::primary('id'), - Index::unique('users_email_unique', 'email'), - ])); - - $migrator->data(Insert::into('users')->values([ - 'name' => 'Admin', - 'email' => 'admin@example.com', - ])); - } - - public function down(Migrator $migrator): void - { - $migrator->data(Delete::from('users')); - - $migrator->schema(Table::drop('users')); - } -} diff --git a/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php b/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php deleted file mode 100644 index 45b853f..0000000 --- a/tests/Unit/Database/Migrations/Fixtures/DataOnlyMigration.php +++ /dev/null @@ -1,19 +0,0 @@ -data(Insert::into('settings')->values([ - 'key' => 'site_name', - 'value' => 'Test Site', - ])); - } -} diff --git a/tests/Unit/Database/Migrations/MigrationPhaseTest.php b/tests/Unit/Database/Migrations/MigrationPhaseTest.php deleted file mode 100644 index 05aabfb..0000000 --- a/tests/Unit/Database/Migrations/MigrationPhaseTest.php +++ /dev/null @@ -1,74 +0,0 @@ -assertCount(3, $cases); - } - - // ------------------------------------------------------------------------- - // String values - // ------------------------------------------------------------------------- - - /** - * - Schema case has the string value 'schema'. - */ - #[Test] - public function schemaCaseHasCorrectStringValue(): void - { - $this->assertSame('schema', MigrationPhase::Schema->value); - } - - /** - * - Alter case has the string value 'alter'. - */ - #[Test] - public function alterCaseHasCorrectStringValue(): void - { - $this->assertSame('alter', MigrationPhase::Alter->value); - } - - /** - * - Data case has the string value 'data'. - */ - #[Test] - public function dataCaseHasCorrectStringValue(): void - { - $this->assertSame('data', MigrationPhase::Data->value); - } - - // ------------------------------------------------------------------------- - // Construction from string - // ------------------------------------------------------------------------- - - /** - * - Cases can be created from string values via from(). - */ - #[Test] - public function casesCanBeCreatedFromStringValues(): void - { - $this->assertSame(MigrationPhase::Schema, MigrationPhase::from('schema')); - $this->assertSame(MigrationPhase::Alter, MigrationPhase::from('alter')); - $this->assertSame(MigrationPhase::Data, MigrationPhase::from('data')); - } -} diff --git a/tests/Unit/Database/Migrations/MigrationRunnerTest.php b/tests/Unit/Database/Migrations/MigrationRunnerTest.php deleted file mode 100644 index 910edcc..0000000 --- a/tests/Unit/Database/Migrations/MigrationRunnerTest.php +++ /dev/null @@ -1,221 +0,0 @@ -connection = new Connection('test', $pdo); - $this->runner = new MigrationRunner($this->connection); - } - - // ------------------------------------------------------------------------- - // scope() and collect() - // ------------------------------------------------------------------------- - - /** - * - scope() sets the current module and migration name. - */ - #[Test] - public function scopeSetsModuleAndMigrationProperties(): void - { - $this->runner->scope('core', '001_create_users'); - - $reflection = new ReflectionClass($this->runner); - - $module = $reflection->getProperty('module')->getValue($this->runner); - $migration = $reflection->getProperty('migration')->getValue($this->runner); - - $this->assertSame('core', $module); - $this->assertSame('001_create_users', $migration); - } - - /** - * - collect() with CreateUsersTable populates schema and data buckets. - */ - #[Test] - public function collectWithCreateUsersTablePopulatesSchemaAndDataBuckets(): void - { - $this->runner->scope('core', '001_create_users'); - $this->runner->collect(new CreateUsersTable()); - - $reflection = new ReflectionClass($this->runner); - $schemas = $reflection->getProperty('schema')->getValue($this->runner); - $data = $reflection->getProperty('data')->getValue($this->runner); - - $this->assertArrayHasKey('core', $schemas); - $this->assertArrayHasKey('001_create_users', $schemas['core']); - $this->assertCount(1, $schemas['core']['001_create_users']); - - $this->assertArrayHasKey('core', $data); - $this->assertArrayHasKey('001_create_users', $data['core']); - $this->assertCount(1, $data['core']['001_create_users']); - } - - /** - * - collect() with CreatePostsTable populates schema and alter buckets. - */ - #[Test] - public function collectWithCreatePostsTablePopulatesSchemaAndAlterBuckets(): void - { - $this->runner->scope('core', '002_create_posts'); - $this->runner->collect(new CreatePostsTable()); - - $reflection = new ReflectionClass($this->runner); - $schemas = $reflection->getProperty('schema')->getValue($this->runner); - $alters = $reflection->getProperty('alter')->getValue($this->runner); - - $this->assertArrayHasKey('core', $schemas); - $this->assertArrayHasKey('002_create_posts', $schemas['core']); - $this->assertCount(1, $schemas['core']['002_create_posts']); - - $this->assertArrayHasKey('core', $alters); - $this->assertArrayHasKey('002_create_posts', $alters['core']); - $this->assertCount(1, $alters['core']['002_create_posts']); - } - - /** - * - collect() with DataOnlyMigration populates only the data bucket. - */ - #[Test] - public function collectWithDataOnlyMigrationPopulatesOnlyDataBucket(): void - { - $this->runner->scope('core', '003_seed_data'); - $this->runner->collect(new DataOnlyMigration()); - - $reflection = new ReflectionClass($this->runner); - $schemas = $reflection->getProperty('schema')->getValue($this->runner); - $alters = $reflection->getProperty('alter')->getValue($this->runner); - $data = $reflection->getProperty('data')->getValue($this->runner); - - $this->assertEmpty($schemas); - $this->assertEmpty($alters); - - $this->assertArrayHasKey('core', $data); - $this->assertArrayHasKey('003_seed_data', $data['core']); - $this->assertCount(1, $data['core']['003_seed_data']); - } - - /** - * - Expressions from different modules are stored under separate module keys. - */ - #[Test] - public function expressionsFromDifferentModulesAreStoredSeparately(): void - { - $this->runner->scope('core', '001_create_users'); - $this->runner->collect(new CreateUsersTable()); - - $this->runner->scope('blog', '001_create_posts'); - $this->runner->collect(new CreatePostsTable()); - - $reflection = new ReflectionClass($this->runner); - $schemas = $reflection->getProperty('schema')->getValue($this->runner); - - $this->assertArrayHasKey('core', $schemas); - $this->assertArrayHasKey('001_create_users', $schemas['core']); - - $this->assertArrayHasKey('blog', $schemas); - $this->assertArrayHasKey('001_create_posts', $schemas['blog']); - } - - // ------------------------------------------------------------------------- - // addMigration() - // ------------------------------------------------------------------------- - - /** - * - addMigration() stores the migration instance keyed by module and name. - */ - #[Test] - public function addMigrationStoresInstanceByModuleAndName(): void - { - $migration = new CreateUsersTable(); - $this->runner->addMigration('core', '001_create_users', $migration); - - $reflection = new ReflectionClass($this->runner); - $migrations = $reflection->getProperty('migrations')->getValue($this->runner); - - $this->assertArrayHasKey('core', $migrations); - $this->assertArrayHasKey('001_create_users', $migrations['core']); - $this->assertSame($migration, $migrations['core']['001_create_users']); - } - - /** - * - addMigration() can register migrations across multiple modules. - */ - #[Test] - public function addMigrationRegistersAcrossMultipleModules(): void - { - $this->runner->addMigration('core', '001_create_users', new CreateUsersTable()); - $this->runner->addMigration('blog', '001_create_posts', new CreatePostsTable()); - - $reflection = new ReflectionClass($this->runner); - $migrations = $reflection->getProperty('migrations')->getValue($this->runner); - - $this->assertArrayHasKey('core', $migrations); - $this->assertArrayHasKey('blog', $migrations); - $this->assertCount(1, $migrations['core']); - $this->assertCount(1, $migrations['blog']); - } - - /** - * - addMigration() returns self for fluent chaining. - */ - #[Test] - public function addMigrationReturnsSelfForChaining(): void - { - $result = $this->runner->addMigration('core', '001_create_users', new CreateUsersTable()); - - $this->assertSame($this->runner, $result); - } - - // ------------------------------------------------------------------------- - // Rollback error paths - // ------------------------------------------------------------------------- - - /** - * - rollbackMigration() throws RuntimeException for a non-reversible migration. - */ - #[Test] - public function rollbackMigrationThrowsForNonReversibleMigration(): void - { - $this->runner->addMigration('core', '001_create_posts', new CreatePostsTable()); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('does not implement ReversibleMigration'); - - $this->runner->rollbackMigration('001_create_posts'); - } - - /** - * - rollbackMigration() throws RuntimeException for an unknown migration name. - */ - #[Test] - public function rollbackMigrationThrowsForUnknownMigrationName(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Migration 'unknown_migration' not found."); - - $this->runner->rollbackMigration('unknown_migration'); - } -} diff --git a/tests/Unit/Database/Migrations/MigrationStatusTest.php b/tests/Unit/Database/Migrations/MigrationStatusTest.php deleted file mode 100644 index 5d73e3e..0000000 --- a/tests/Unit/Database/Migrations/MigrationStatusTest.php +++ /dev/null @@ -1,239 +0,0 @@ -assertSame('001_create_users', $status->migration); - $this->assertSame('core', $status->module); - $this->assertSame(1, $status->batch); - $this->assertFalse($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - $this->assertFalse($status->errored); - } - - // ------------------------------------------------------------------------- - // Construction with explicit flags - // ------------------------------------------------------------------------- - - /** - * - Construction with explicit flags sets them correctly. - */ - #[Test] - public function constructionWithExplicitFlagsSetsThemCorrectly(): void - { - $status = new MigrationStatus('002_add_roles', 'auth', 3, true, false, true, true); - - $this->assertSame('002_add_roles', $status->migration); - $this->assertSame('auth', $status->module); - $this->assertSame(3, $status->batch); - $this->assertTrue($status->schema); - $this->assertFalse($status->alter); - $this->assertTrue($status->data); - $this->assertTrue($status->errored); - } - - // ------------------------------------------------------------------------- - // phaseCompleted - // ------------------------------------------------------------------------- - - /** - * - phaseCompleted returns the correct flag for the schema phase. - */ - #[Test] - public function phaseCompletedReturnsCorrectValueForSchema(): void - { - $status = new MigrationStatus('001_test', 'core', 1, schema: true); - - $this->assertTrue($status->phaseCompleted('schema')); - $this->assertFalse($status->phaseCompleted('alter')); - $this->assertFalse($status->phaseCompleted('data')); - } - - /** - * - phaseCompleted returns the correct flag for the alter phase. - */ - #[Test] - public function phaseCompletedReturnsCorrectValueForAlter(): void - { - $status = new MigrationStatus('001_test', 'core', 1, alter: true); - - $this->assertFalse($status->phaseCompleted('schema')); - $this->assertTrue($status->phaseCompleted('alter')); - $this->assertFalse($status->phaseCompleted('data')); - } - - /** - * - phaseCompleted returns the correct flag for the data phase. - */ - #[Test] - public function phaseCompletedReturnsCorrectValueForData(): void - { - $status = new MigrationStatus('001_test', 'core', 1, data: true); - - $this->assertFalse($status->phaseCompleted('schema')); - $this->assertFalse($status->phaseCompleted('alter')); - $this->assertTrue($status->phaseCompleted('data')); - } - - /** - * - phaseCompleted returns false for an unknown phase. - */ - #[Test] - public function phaseCompletedReturnsFalseForUnknownPhase(): void - { - $status = new MigrationStatus('001_test', 'core', 1, schema: true, alter: true, data: true); - - $this->assertFalse($status->phaseCompleted('unknown')); - } - - // ------------------------------------------------------------------------- - // markPhase - // ------------------------------------------------------------------------- - - /** - * - markPhase sets the schema flag to true. - */ - #[Test] - public function markPhaseSetsSchemaFlag(): void - { - $status = new MigrationStatus('001_test', 'core', 1); - - $status->markPhase('schema'); - - $this->assertTrue($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - } - - /** - * - markPhase sets the alter flag to true. - */ - #[Test] - public function markPhaseSetsAlterFlag(): void - { - $status = new MigrationStatus('001_test', 'core', 1); - - $status->markPhase('alter'); - - $this->assertFalse($status->schema); - $this->assertTrue($status->alter); - $this->assertFalse($status->data); - } - - /** - * - markPhase sets the data flag to true. - */ - #[Test] - public function markPhaseSetsDataFlag(): void - { - $status = new MigrationStatus('001_test', 'core', 1); - - $status->markPhase('data'); - - $this->assertFalse($status->schema); - $this->assertFalse($status->alter); - $this->assertTrue($status->data); - } - - /** - * - markPhase with an unknown phase does not change any flags. - */ - #[Test] - public function markPhaseWithUnknownPhaseDoesNothing(): void - { - $status = new MigrationStatus('001_test', 'core', 1); - - $status->markPhase('unknown'); - - $this->assertFalse($status->schema); - $this->assertFalse($status->alter); - $this->assertFalse($status->data); - } - - // ------------------------------------------------------------------------- - // markErrored - // ------------------------------------------------------------------------- - - /** - * - markErrored sets the errored flag to true. - */ - #[Test] - public function markErroredSetsErroredFlag(): void - { - $status = new MigrationStatus('001_test', 'core', 1); - - $status->markErrored(); - - $this->assertTrue($status->errored); - } - - // ------------------------------------------------------------------------- - // clearError - // ------------------------------------------------------------------------- - - /** - * - clearError sets the errored flag to false. - */ - #[Test] - public function clearErrorResetsErroredFlag(): void - { - $status = new MigrationStatus('001_test', 'core', 1, errored: true); - - $status->clearError(); - - $this->assertFalse($status->errored); - } - - // ------------------------------------------------------------------------- - // fromRow - // ------------------------------------------------------------------------- - - /** - * - fromRow creates a MigrationStatus from a Row object. - */ - #[Test] - public function fromRowCreatesInstanceFromDatabaseRow(): void - { - $row = new Row([ - 'migration' => '001_create_users', - 'module' => 'core', - 'batch' => 2, - 'schema' => 1, - 'alter' => 0, - 'data' => 1, - 'errored' => 0, - ]); - - $status = MigrationStatus::fromRow($row); - - $this->assertSame('001_create_users', $status->migration); - $this->assertSame('core', $status->module); - $this->assertSame(2, $status->batch); - $this->assertTrue($status->schema); - $this->assertFalse($status->alter); - $this->assertTrue($status->data); - $this->assertFalse($status->errored); - } -} diff --git a/tests/Unit/Database/Migrations/MigratorTest.php b/tests/Unit/Database/Migrations/MigratorTest.php deleted file mode 100644 index 0c0edd2..0000000 --- a/tests/Unit/Database/Migrations/MigratorTest.php +++ /dev/null @@ -1,155 +0,0 @@ -runner = new MigrationRunner($connection); - - $this->runner->scope('core', 'test_migration'); - - $reflection = new ReflectionClass($this->runner); - $this->migrator = $reflection->getProperty('migrator')->getValue($this->runner); - } - - // ------------------------------------------------------------------------- - // Proxying to runner - // ------------------------------------------------------------------------- - - /** - * - schema() proxies the Schema to the runner's schema bucket. - */ - #[Test] - public function schemaProxiesToRunnerSchemaBucket(): void - { - $table = Table::create('users', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Index::primary('id'), - ]); - - $this->migrator->schema($table); - - $reflection = new ReflectionClass($this->runner); - $schemas = $reflection->getProperty('schema')->getValue($this->runner); - - $this->assertArrayHasKey('core', $schemas); - $this->assertArrayHasKey('test_migration', $schemas['core']); - $this->assertCount(1, $schemas['core']['test_migration']); - $this->assertSame($table, $schemas['core']['test_migration'][0]); - } - - /** - * - alter() proxies the Schema to the runner's alter bucket. - */ - #[Test] - public function alterProxiesToRunnerAlterBucket(): void - { - $table = Table::create('users', [ - Column::string('email', 255), - ]); - - $this->migrator->alter($table); - - $reflection = new ReflectionClass($this->runner); - $alters = $reflection->getProperty('alter')->getValue($this->runner); - - $this->assertArrayHasKey('core', $alters); - $this->assertArrayHasKey('test_migration', $alters['core']); - $this->assertCount(1, $alters['core']['test_migration']); - $this->assertSame($table, $alters['core']['test_migration'][0]); - } - - /** - * - data() proxies the Query to the runner's data bucket. - */ - #[Test] - public function dataProxiesToRunnerDataBucket(): void - { - $query = Insert::into('users')->values([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); - - $this->migrator->data($query); - - $reflection = new ReflectionClass($this->runner); - $data = $reflection->getProperty('data')->getValue($this->runner); - - $this->assertArrayHasKey('core', $data); - $this->assertArrayHasKey('test_migration', $data['core']); - $this->assertCount(1, $data['core']['test_migration']); - $this->assertSame($query, $data['core']['test_migration'][0]); - } - - // ------------------------------------------------------------------------- - // Fluent chaining - // ------------------------------------------------------------------------- - - /** - * - schema() returns self for fluent chaining. - */ - #[Test] - public function schemaReturnsSelfForFluentChaining(): void - { - $table = Table::create('users', [ - Column::bigInt('id'), - ]); - - $result = $this->migrator->schema($table); - - $this->assertSame($this->migrator, $result); - } - - /** - * - alter() returns self for fluent chaining. - */ - #[Test] - public function alterReturnsSelfForFluentChaining(): void - { - $table = Table::create('users', [ - Column::string('name', 255), - ]); - - $result = $this->migrator->alter($table); - - $this->assertSame($this->migrator, $result); - } - - /** - * - data() returns self for fluent chaining. - */ - #[Test] - public function dataReturnsSelfForFluentChaining(): void - { - $query = Insert::into('users')->values([ - 'name' => 'Test', - ]); - - $result = $this->migrator->data($query); - - $this->assertSame($this->migrator, $result); - } -} diff --git a/tests/Unit/Database/Schema/BlueprintTest.php b/tests/Unit/Database/Schema/BlueprintTest.php deleted file mode 100644 index 0952d3b..0000000 --- a/tests/Unit/Database/Schema/BlueprintTest.php +++ /dev/null @@ -1,157 +0,0 @@ -add(Column::string('bio', 500)->nullable()); - - $this->assertSame('ADD COLUMN `bio` VARCHAR(500) NULL', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - /** - * - add() with an Index produces ADD clause with index SQL. - */ - #[Test] - public function addIndexProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->add(Index::unique('idx_email', 'email')); - - $this->assertSame('ADD UNIQUE INDEX `idx_email` (`email`)', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - /** - * - add() with a foreign key Index produces ADD CONSTRAINT clause. - */ - #[Test] - public function addForeignKeyProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->add( - Index::foreign('fk_user_id', 'user_id') - ->references('users', 'id') - ->onDelete('CASCADE'), - ); - - $this->assertSame( - 'ADD CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE', - $blueprint->toSql(), - ); - $this->assertSame([], $blueprint->getBindings()); - } - - // ------------------------------------------------------------------------- - // modify() - // ------------------------------------------------------------------------- - - /** - * - modify() produces MODIFY COLUMN clause. - */ - #[Test] - public function modifyProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->modify(Column::string('email', 500)); - - $this->assertSame('MODIFY COLUMN `email` VARCHAR(500) NOT NULL', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - // ------------------------------------------------------------------------- - // drop() - // ------------------------------------------------------------------------- - - /** - * - drop() with a Column produces DROP COLUMN clause. - */ - #[Test] - public function dropColumnProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->drop(Column::named('avatar')); - - $this->assertSame('DROP COLUMN `avatar`', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - /** - * - drop() with an Index produces DROP INDEX clause. - */ - #[Test] - public function dropIndexProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->drop(Index::named('idx_email')); - - $this->assertSame('DROP INDEX `idx_email`', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - // ------------------------------------------------------------------------- - // rename() - // ------------------------------------------------------------------------- - - /** - * - rename() produces RENAME COLUMN clause. - */ - #[Test] - public function renameProducesCorrectSql(): void - { - $blueprint = new Blueprint(); - $blueprint->rename(Column::named('name'), 'display_name'); - - $this->assertSame('RENAME COLUMN `name` TO `display_name`', $blueprint->toSql()); - $this->assertSame([], $blueprint->getBindings()); - } - - // ------------------------------------------------------------------------- - // Multiple operations - // ------------------------------------------------------------------------- - - /** - * - Multiple operations are joined with commas. - */ - #[Test] - public function multipleOperationsJoinedWithCommas(): void - { - $blueprint = new Blueprint(); - $blueprint - ->add(Column::string('bio', 500)->nullable()) - ->modify(Column::string('email', 500)) - ->drop(Column::named('avatar')) - ->rename(Column::named('name'), 'display_name') - ; - - $this->assertSame( - 'ADD COLUMN `bio` VARCHAR(500) NULL' - . ', MODIFY COLUMN `email` VARCHAR(500) NOT NULL' - . ', DROP COLUMN `avatar`' - . ', RENAME COLUMN `name` TO `display_name`', - $blueprint->toSql(), - ); - $this->assertSame([], $blueprint->getBindings()); - } -} diff --git a/tests/Unit/Database/Schema/ColumnTest.php b/tests/Unit/Database/Schema/ColumnTest.php deleted file mode 100644 index c9cdc9b..0000000 --- a/tests/Unit/Database/Schema/ColumnTest.php +++ /dev/null @@ -1,540 +0,0 @@ -assertSame('`id` BIGINT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - int produces INT column definition. - */ - #[Test] - public function intProducesCorrectSql(): void - { - $column = Column::int('age'); - - $this->assertSame('`age` INT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - smallInt produces SMALLINT column definition. - */ - #[Test] - public function smallIntProducesCorrectSql(): void - { - $column = Column::smallInt('count'); - - $this->assertSame('`count` SMALLINT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - tinyInt produces TINYINT column definition. - */ - #[Test] - public function tinyIntProducesCorrectSql(): void - { - $column = Column::tinyInt('flag'); - - $this->assertSame('`flag` TINYINT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - decimal produces DECIMAL(precision, scale) column definition. - */ - #[Test] - public function decimalProducesCorrectSql(): void - { - $column = Column::decimal('price', 10, 2); - - $this->assertSame('`price` DECIMAL(10, 2) NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - float produces FLOAT column definition. - */ - #[Test] - public function floatProducesCorrectSql(): void - { - $column = Column::float('rating'); - - $this->assertSame('`rating` FLOAT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - double produces DOUBLE column definition. - */ - #[Test] - public function doubleProducesCorrectSql(): void - { - $column = Column::double('precise'); - - $this->assertSame('`precise` DOUBLE NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - // ------------------------------------------------------------------------- - // String type factories - // ------------------------------------------------------------------------- - - /** - * - string produces VARCHAR(length) column definition. - */ - #[Test] - public function stringProducesCorrectSql(): void - { - $column = Column::string('name', 255); - - $this->assertSame('`name` VARCHAR(255) NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - char() produces the correct SQL with name and length. - */ - #[Test] - public function charProducesCorrectSql(): void - { - $column = Column::char('code', 2); - - $this->assertSame('`code` CHAR(2) NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - text produces TEXT column definition. - */ - #[Test] - public function textProducesCorrectSql(): void - { - $column = Column::text('body'); - - $this->assertSame('`body` TEXT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - mediumText produces MEDIUMTEXT column definition. - */ - #[Test] - public function mediumTextProducesCorrectSql(): void - { - $column = Column::mediumText('content'); - - $this->assertSame('`content` MEDIUMTEXT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - longText produces LONGTEXT column definition. - */ - #[Test] - public function longTextProducesCorrectSql(): void - { - $column = Column::longText('data'); - - $this->assertSame('`data` LONGTEXT NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - // ------------------------------------------------------------------------- - // Date/time type factories - // ------------------------------------------------------------------------- - - /** - * - date produces DATE column definition. - */ - #[Test] - public function dateProducesCorrectSql(): void - { - $column = Column::date('birthday'); - - $this->assertSame('`birthday` DATE NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - datetime produces DATETIME column definition. - */ - #[Test] - public function datetimeProducesCorrectSql(): void - { - $column = Column::datetime('published_at'); - - $this->assertSame('`published_at` DATETIME NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - timestamp produces TIMESTAMP column definition. - */ - #[Test] - public function timestampProducesCorrectSql(): void - { - $column = Column::timestamp('created_at'); - - $this->assertSame('`created_at` TIMESTAMP NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - time produces TIME column definition. - */ - #[Test] - public function timeProducesCorrectSql(): void - { - $column = Column::time('duration'); - - $this->assertSame('`duration` TIME NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - // ------------------------------------------------------------------------- - // Binary/blob type factories - // ------------------------------------------------------------------------- - - /** - * - binary produces BINARY column definition. - */ - #[Test] - public function binaryProducesCorrectSql(): void - { - $column = Column::binary('hash'); - - $this->assertSame('`hash` BINARY NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - blob produces BLOB column definition. - */ - #[Test] - public function blobProducesCorrectSql(): void - { - $column = Column::blob('data'); - - $this->assertSame('`data` BLOB NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - // ------------------------------------------------------------------------- - // JSON / boolean / enum type factories - // ------------------------------------------------------------------------- - - /** - * - json produces JSON column definition. - */ - #[Test] - public function jsonProducesCorrectSql(): void - { - $column = Column::json('metadata'); - - $this->assertSame('`metadata` JSON NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - boolean produces BOOLEAN column definition. - */ - #[Test] - public function booleanProducesCorrectSql(): void - { - $column = Column::boolean('active'); - - $this->assertSame('`active` BOOLEAN NOT NULL', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - enum produces ENUM column definition with quoted values. - */ - #[Test] - public function enumProducesCorrectSql(): void - { - $column = Column::enum('status', ['active', 'inactive', 'pending']); - - $this->assertSame("`status` ENUM('active', 'inactive', 'pending') NOT NULL", $column->toSql()); - $this->assertSame([], $column->getBindings()); - } - - /** - * - enum escapes single quotes in values. - */ - #[Test] - public function enumEscapesSingleQuotesInValues(): void - { - $column = Column::enum('label', ["it's", "they're"]); - - $this->assertSame("`label` ENUM('it''s', 'they''re') NOT NULL", $column->toSql()); - } - - // ------------------------------------------------------------------------- - // Modifiers - // ------------------------------------------------------------------------- - - /** - * - nullable() produces NULL instead of NOT NULL. - */ - #[Test] - public function nullableProducesNullSql(): void - { - $column = Column::string('email', 255)->nullable(); - - $this->assertSame('`email` VARCHAR(255) NULL', $column->toSql()); - } - - /** - * - default() with a string value produces DEFAULT 'value'. - */ - #[Test] - public function defaultWithStringProducesCorrectSql(): void - { - $column = Column::string('status', 20)->default('active'); - - $this->assertSame("`status` VARCHAR(20) NOT NULL DEFAULT 'active'", $column->toSql()); - } - - /** - * - default() with null produces DEFAULT NULL. - */ - #[Test] - public function defaultWithNullProducesCorrectSql(): void - { - $column = Column::string('bio', 500)->nullable()->default(null); - - $this->assertSame('`bio` VARCHAR(500) NULL DEFAULT NULL', $column->toSql()); - } - - /** - * - default() with an integer produces DEFAULT N. - */ - #[Test] - public function defaultWithIntProducesCorrectSql(): void - { - $column = Column::int('count')->default(0); - - $this->assertSame('`count` INT NOT NULL DEFAULT 0', $column->toSql()); - } - - /** - * - default() with a boolean produces DEFAULT TRUE/FALSE. - */ - #[Test] - public function defaultWithBoolProducesCorrectSql(): void - { - $column = Column::boolean('active')->default(true); - - $this->assertSame('`active` BOOLEAN NOT NULL DEFAULT TRUE', $column->toSql()); - - $column = Column::boolean('deleted')->default(false); - - $this->assertSame('`deleted` BOOLEAN NOT NULL DEFAULT FALSE', $column->toSql()); - } - - /** - * - default() with a float value produces DEFAULT N.N. - */ - #[Test] - public function defaultWithFloatProducesCorrectSql(): void - { - $column = Column::float('rating')->default(3.14); - - $this->assertSame('`rating` FLOAT NOT NULL DEFAULT 3.14', $column->toSql()); - } - - /** - * - default() with an Expression renders it inline. - */ - #[Test] - public function defaultWithExpressionRendersInline(): void - { - $column = Column::timestamp('created_at')->default( - Raw::from('CURRENT_TIMESTAMP'), - ); - - $this->assertSame('`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', $column->toSql()); - } - - /** - * - default() with a string containing a single quote escapes it. - */ - #[Test] - public function defaultWithSingleQuoteEscapesCorrectly(): void - { - $column = Column::string('greeting', 100)->default("it's"); - - $this->assertSame("`greeting` VARCHAR(100) NOT NULL DEFAULT 'it''s'", $column->toSql()); - } - - /** - * - unsigned() produces UNSIGNED before NULL/NOT NULL. - */ - #[Test] - public function unsignedProducesCorrectSql(): void - { - $column = Column::bigInt('id')->unsigned(); - - $this->assertSame('`id` BIGINT UNSIGNED NOT NULL', $column->toSql()); - } - - /** - * - autoIncrement() produces AUTO_INCREMENT after NULL/NOT NULL. - */ - #[Test] - public function autoIncrementProducesCorrectSql(): void - { - $column = Column::bigInt('id')->unsigned()->autoIncrement(); - - $this->assertSame('`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT', $column->toSql()); - } - - /** - * - comment() produces COMMENT with escaped string. - */ - #[Test] - public function commentProducesCorrectSql(): void - { - $column = Column::string('name', 255)->comment('The user name'); - - $this->assertSame("`name` VARCHAR(255) NOT NULL COMMENT 'The user name'", $column->toSql()); - } - - /** - * - comment() with a single quote escapes it. - */ - #[Test] - public function commentWithSingleQuoteEscapesCorrectly(): void - { - $column = Column::string('name', 255)->comment("user's name"); - - $this->assertSame("`name` VARCHAR(255) NOT NULL COMMENT 'user''s name'", $column->toSql()); - } - - /** - * - charset() produces CHARACTER SET clause. - */ - #[Test] - public function charsetProducesCorrectSql(): void - { - $column = Column::string('name', 255)->charset('utf8mb4'); - - $this->assertSame('`name` VARCHAR(255) NOT NULL CHARACTER SET utf8mb4', $column->toSql()); - } - - /** - * - collation() produces COLLATE clause. - */ - #[Test] - public function collationProducesCorrectSql(): void - { - $column = Column::string('name', 255)->collation('utf8mb4_unicode_ci'); - - $this->assertSame('`name` VARCHAR(255) NOT NULL COLLATE utf8mb4_unicode_ci', $column->toSql()); - } - - /** - * - after() produces AFTER clause. - */ - #[Test] - public function afterProducesCorrectSql(): void - { - $column = Column::string('bio', 500)->nullable()->after('email'); - - $this->assertSame('`bio` VARCHAR(500) NULL AFTER `email`', $column->toSql()); - } - - /** - * - first() produces FIRST clause. - */ - #[Test] - public function firstProducesCorrectSql(): void - { - $column = Column::string('id_col', 36)->first(); - - $this->assertSame('`id_col` VARCHAR(36) NOT NULL FIRST', $column->toSql()); - } - - /** - * - Multiple modifiers combine in the correct order. - */ - #[Test] - public function multipleModifiersCombineCorrectly(): void - { - $column = Column::string('email', 255) - ->nullable() - ->default('test@example.com') - ->comment('Primary email') - ->charset('utf8mb4') - ->collation('utf8mb4_unicode_ci') - ->after('name') - ; - - $this->assertSame( - "`email` VARCHAR(255) NULL DEFAULT 'test@example.com' COMMENT 'Primary email'" - . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci AFTER `name`', - $column->toSql(), - ); - } - - /** - * - Unsigned auto-increment column produces correct SQL order. - */ - #[Test] - public function unsignedAutoIncrementProducesCorrectSqlOrder(): void - { - $column = Column::bigInt('id') - ->unsigned() - ->autoIncrement() - ->comment('Primary key') - ->first() - ; - - $this->assertSame( - "`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Primary key' FIRST", - $column->toSql(), - ); - } - - // ------------------------------------------------------------------------- - // Named reference - // ------------------------------------------------------------------------- - - /** - * - named() produces just the backtick-quoted column name. - */ - #[Test] - public function namedProducesQuotedName(): void - { - $column = Column::named('email'); - - $this->assertSame('`email`', $column->toSql()); - $this->assertSame([], $column->getBindings()); - } -} diff --git a/tests/Unit/Database/Schema/IndexTest.php b/tests/Unit/Database/Schema/IndexTest.php deleted file mode 100644 index 2e10757..0000000 --- a/tests/Unit/Database/Schema/IndexTest.php +++ /dev/null @@ -1,266 +0,0 @@ -assertSame('PRIMARY KEY (`id`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - /** - * - Primary key with composite columns produces correct SQL. - */ - #[Test] - public function primaryKeyCompositeColumnsProducesCorrectSql(): void - { - $index = Index::primary(['tenant_id', 'id']); - - $this->assertSame('PRIMARY KEY (`tenant_id`, `id`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - // ------------------------------------------------------------------------- - // Unique index - // ------------------------------------------------------------------------- - - /** - * - Unique index with a single column produces correct SQL. - */ - #[Test] - public function uniqueIndexSingleColumnProducesCorrectSql(): void - { - $index = Index::unique('users_email_unique', 'email'); - - $this->assertSame('UNIQUE INDEX `users_email_unique` (`email`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - /** - * - Unique index with composite columns produces correct SQL. - */ - #[Test] - public function uniqueIndexCompositeColumnsProducesCorrectSql(): void - { - $index = Index::unique('users_tenant_email_unique', ['tenant_id', 'email']); - - $this->assertSame('UNIQUE INDEX `users_tenant_email_unique` (`tenant_id`, `email`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - // ------------------------------------------------------------------------- - // Regular index - // ------------------------------------------------------------------------- - - /** - * - Regular index with a single column produces correct SQL. - */ - #[Test] - public function indexSingleColumnProducesCorrectSql(): void - { - $index = Index::index('users_name_index', 'name'); - - $this->assertSame('INDEX `users_name_index` (`name`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - /** - * - Regular index with composite columns produces correct SQL. - */ - #[Test] - public function indexCompositeColumnsProducesCorrectSql(): void - { - $index = Index::index('users_name_email_index', ['name', 'email']); - - $this->assertSame('INDEX `users_name_email_index` (`name`, `email`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - // ------------------------------------------------------------------------- - // Fulltext index - // ------------------------------------------------------------------------- - - /** - * - Fulltext index with a single column produces correct SQL. - */ - #[Test] - public function fulltextIndexSingleColumnProducesCorrectSql(): void - { - $index = Index::fulltext('posts_body_fulltext', 'body'); - - $this->assertSame('FULLTEXT INDEX `posts_body_fulltext` (`body`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - /** - * - Fulltext index with composite columns produces correct SQL. - */ - #[Test] - public function fulltextIndexCompositeColumnsProducesCorrectSql(): void - { - $index = Index::fulltext('posts_title_body_fulltext', ['title', 'body']); - - $this->assertSame('FULLTEXT INDEX `posts_title_body_fulltext` (`title`, `body`)', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } - - // ------------------------------------------------------------------------- - // Foreign key - // ------------------------------------------------------------------------- - - /** - * - Foreign key with references produces correct SQL. - */ - #[Test] - public function foreignKeyWithReferencesProducesCorrectSql(): void - { - $index = Index::foreign('posts_user_id_foreign', 'user_id') - ->references('users', 'id') - ; - - $this->assertSame( - 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)', - $index->toSql(), - ); - $this->assertSame([], $index->getBindings()); - } - - /** - * - Foreign key with onDelete produces correct SQL. - */ - #[Test] - public function foreignKeyWithOnDeleteProducesCorrectSql(): void - { - $index = Index::foreign('posts_user_id_foreign', 'user_id') - ->references('users', 'id') - ->onDelete('cascade') - ; - - $this->assertSame( - 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE', - $index->toSql(), - ); - } - - /** - * - Foreign key with onUpdate produces correct SQL. - */ - #[Test] - public function foreignKeyWithOnUpdateProducesCorrectSql(): void - { - $index = Index::foreign('posts_user_id_foreign', 'user_id') - ->references('users', 'id') - ->onUpdate('set null') - ; - - $this->assertSame( - 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON UPDATE SET NULL', - $index->toSql(), - ); - } - - /** - * - Foreign key with both onDelete and onUpdate produces correct SQL. - */ - #[Test] - public function foreignKeyWithBothActionsProducesCorrectSql(): void - { - $index = Index::foreign('posts_user_id_foreign', 'user_id') - ->references('users', 'id') - ->onDelete('cascade') - ->onUpdate('set null') - ; - - $this->assertSame( - 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)' - . ' ON DELETE CASCADE ON UPDATE SET NULL', - $index->toSql(), - ); - } - - /** - * - Foreign key with composite columns produces correct SQL. - */ - #[Test] - public function foreignKeyCompositeColumnsProducesCorrectSql(): void - { - $index = Index::foreign('orders_tenant_user_foreign', ['tenant_id', 'user_id']) - ->references('users', ['tenant_id', 'id']) - ->onDelete('cascade') - ; - - $this->assertSame( - 'CONSTRAINT `orders_tenant_user_foreign` FOREIGN KEY (`tenant_id`, `user_id`)' - . ' REFERENCES `users` (`tenant_id`, `id`) ON DELETE CASCADE', - $index->toSql(), - ); - } - - /** - * - Foreign key without references produces constraint with just the key columns. - */ - #[Test] - public function foreignKeyWithoutReferencesProducesConstraintOnly(): void - { - $index = Index::foreign('posts_user_id_foreign', 'user_id'); - - $this->assertSame( - 'CONSTRAINT `posts_user_id_foreign` FOREIGN KEY (`user_id`)', - $index->toSql(), - ); - } - - /** - * - Foreign key actions are uppercased regardless of input case. - */ - #[Test] - public function foreignKeyActionsAreUppercased(): void - { - $index = Index::foreign('fk_test', 'col') - ->references('other', 'id') - ->onDelete('Cascade') - ->onUpdate('Set Null') - ; - - $this->assertSame( - 'CONSTRAINT `fk_test` FOREIGN KEY (`col`) REFERENCES `other` (`id`)' - . ' ON DELETE CASCADE ON UPDATE SET NULL', - $index->toSql(), - ); - } - - // ------------------------------------------------------------------------- - // Named reference - // ------------------------------------------------------------------------- - - /** - * - named() produces just the backtick-quoted index name. - */ - #[Test] - public function namedProducesQuotedName(): void - { - $index = Index::named('users_email_unique'); - - $this->assertSame('`users_email_unique`', $index->toSql()); - $this->assertSame([], $index->getBindings()); - } -} diff --git a/tests/Unit/Database/Schema/TableTest.php b/tests/Unit/Database/Schema/TableTest.php deleted file mode 100644 index 0d243db..0000000 --- a/tests/Unit/Database/Schema/TableTest.php +++ /dev/null @@ -1,327 +0,0 @@ -assertInstanceOf(Schema::class, $table); - } - - // ------------------------------------------------------------------------- - // Create mode - // ------------------------------------------------------------------------- - - /** - * - create() with columns and indexes produces correct CREATE TABLE SQL. - */ - #[Test] - public function createProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::string('name', 255), - Index::primary('id'), - ]); - - $expected = "CREATE TABLE `users` (\n" - . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n" - . " `name` VARCHAR(255) NOT NULL,\n" - . " PRIMARY KEY (`id`)\n" - . ')'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - create() with ifNotExists() produces IF NOT EXISTS. - */ - #[Test] - public function createWithIfNotExistsProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - ])->ifNotExists(); - - $expected = "CREATE TABLE IF NOT EXISTS `users` (\n" - . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT\n" - . ')'; - - $this->assertSame($expected, $table->toSql()); - } - - /** - * - create() with engine() produces ENGINE clause. - */ - #[Test] - public function createWithEngineProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id'), - ])->engine('InnoDB'); - - $expected = "CREATE TABLE `users` (\n" - . " `id` BIGINT NOT NULL\n" - . ') ENGINE = InnoDB'; - - $this->assertSame($expected, $table->toSql()); - } - - /** - * - create() with charset() produces DEFAULT CHARACTER SET clause. - */ - #[Test] - public function createWithCharsetProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id'), - ])->charset('utf8mb4'); - - $expected = "CREATE TABLE `users` (\n" - . " `id` BIGINT NOT NULL\n" - . ') DEFAULT CHARACTER SET utf8mb4'; - - $this->assertSame($expected, $table->toSql()); - } - - /** - * - create() with collation() produces DEFAULT COLLATE clause. - */ - #[Test] - public function createWithCollationProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id'), - ])->collation('utf8mb4_unicode_ci'); - - $expected = "CREATE TABLE `users` (\n" - . " `id` BIGINT NOT NULL\n" - . ') DEFAULT COLLATE utf8mb4_unicode_ci'; - - $this->assertSame($expected, $table->toSql()); - } - - /** - * - create() with all options combined produces correct SQL. - */ - #[Test] - public function createWithAllOptionsProducesCorrectSql(): void - { - $table = Table::create('users', [ - Column::bigInt('id')->unsigned()->autoIncrement(), - Column::string('name', 255), - Column::string('email', 255), - Index::primary('id'), - Index::unique('users_email_unique', 'email'), - ]) - ->ifNotExists() - ->engine('InnoDB') - ->charset('utf8mb4') - ->collation('utf8mb4_unicode_ci') - ; - - $expected = "CREATE TABLE IF NOT EXISTS `users` (\n" - . " `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n" - . " `name` VARCHAR(255) NOT NULL,\n" - . " `email` VARCHAR(255) NOT NULL,\n" - . " PRIMARY KEY (`id`),\n" - . " UNIQUE INDEX `users_email_unique` (`email`)\n" - . ')' - . ' ENGINE = InnoDB' - . ' DEFAULT CHARACTER SET utf8mb4' - . ' DEFAULT COLLATE utf8mb4_unicode_ci'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - // ------------------------------------------------------------------------- - // Drop mode - // ------------------------------------------------------------------------- - - /** - * - drop() produces DROP TABLE SQL. - */ - #[Test] - public function dropProducesCorrectSql(): void - { - $table = Table::drop('users'); - - $this->assertSame('DROP TABLE `users`', $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - drop() with ifExists() produces IF EXISTS. - */ - #[Test] - public function dropWithIfExistsProducesCorrectSql(): void - { - $table = Table::drop('users')->ifExists(); - - $this->assertSame('DROP TABLE IF EXISTS `users`', $table->toSql()); - } - - // ------------------------------------------------------------------------- - // Alter closure mode - // ------------------------------------------------------------------------- - - /** - * - alter() with multiple operations produces correct ALTER TABLE SQL. - */ - #[Test] - public function alterWithMultipleOperationsProducesCorrectSql(): void - { - $table = Table::alter('users', function (Blueprint $blueprint) { - $blueprint->add(Column::string('bio', 500)->nullable()); - $blueprint->modify(Column::string('email', 500)); - $blueprint->drop(Column::named('avatar')); - $blueprint->drop(Index::named('users_avatar_index')); - $blueprint->rename(Column::named('name'), 'full_name'); - }); - - $expected = 'ALTER TABLE `users` ' - . 'ADD COLUMN `bio` VARCHAR(500) NULL, ' - . 'MODIFY COLUMN `email` VARCHAR(500) NOT NULL, ' - . 'DROP COLUMN `avatar`, ' - . 'DROP INDEX `users_avatar_index`, ' - . 'RENAME COLUMN `name` TO `full_name`'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - alter() with a single foreign key add operation. - */ - #[Test] - public function alterWithSingleForeignKeyAddProducesCorrectSql(): void - { - $table = Table::alter('posts', function (Blueprint $blueprint) { - $blueprint->add( - Index::foreign('posts_user_id_fk', 'user_id') - ->references('users', 'id') - ->onDelete('cascade'), - ); - }); - - $expected = 'ALTER TABLE `posts` ADD ' - . 'CONSTRAINT `posts_user_id_fk` FOREIGN KEY (`user_id`)' - . ' REFERENCES `users` (`id`)' - . ' ON DELETE CASCADE'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - // ------------------------------------------------------------------------- - // Shortcuts - // ------------------------------------------------------------------------- - - /** - * - addColumns() produces ALTER TABLE with ADD COLUMN clauses. - */ - #[Test] - public function addColumnsProducesCorrectSql(): void - { - $table = Table::addColumns('users', [ - Column::string('bio', 500)->nullable(), - Column::string('avatar', 255)->nullable(), - ]); - - $expected = 'ALTER TABLE `users` ' - . 'ADD COLUMN `bio` VARCHAR(500) NULL, ' - . 'ADD COLUMN `avatar` VARCHAR(255) NULL'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - dropColumns() with string array produces ALTER TABLE with DROP COLUMN clauses. - */ - #[Test] - public function dropColumnsProducesCorrectSql(): void - { - $table = Table::dropColumns('users', ['bio', 'avatar']); - - $expected = 'ALTER TABLE `users` ' - . 'DROP COLUMN `bio`, ' - . 'DROP COLUMN `avatar`'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - addIndexes() produces ALTER TABLE with ADD index clauses. - */ - #[Test] - public function addIndexesProducesCorrectSql(): void - { - $table = Table::addIndexes('users', [ - Index::unique('users_email_unique', 'email'), - Index::index('users_name_index', 'name'), - ]); - - $expected = 'ALTER TABLE `users` ' - . 'ADD UNIQUE INDEX `users_email_unique` (`email`), ' - . 'ADD INDEX `users_name_index` (`name`)'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - dropIndexes() with string array produces ALTER TABLE with DROP INDEX clauses. - */ - #[Test] - public function dropIndexesProducesCorrectSql(): void - { - $table = Table::dropIndexes('users', ['users_email_unique', 'users_name_index']); - - $expected = 'ALTER TABLE `users` ' - . 'DROP INDEX `users_email_unique`, ' - . 'DROP INDEX `users_name_index`'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } - - /** - * - renameColumn() produces ALTER TABLE with RENAME COLUMN clause. - */ - #[Test] - public function renameColumnProducesCorrectSql(): void - { - $table = Table::renameColumn('users', 'name', 'full_name'); - - $expected = 'ALTER TABLE `users` RENAME COLUMN `name` TO `full_name`'; - - $this->assertSame($expected, $table->toSql()); - $this->assertSame([], $table->getBindings()); - } -} From 82c9dac3b12d1fdf58f8293121ba707891830440 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Wed, 1 Apr 2026 19:37:44 +0100 Subject: [PATCH 28/29] chore(database): Small fixes and improvements --- src/Database/Connection.php | 20 ++++++++++++++++++- src/Database/DatabaseResolver.php | 6 +++--- .../Exceptions/InvalidExpressionException.php | 5 +++++ src/Database/Query/Cursor.php | 8 +++----- src/Database/Query/Expressions.php | 4 ++-- src/Database/Query/Raw.php | 10 ++++++++-- src/Database/Query/Result.php | 8 +++----- tests/Unit/Database/DatabaseResolverTest.php | 8 ++++---- .../Query/Clauses/WhereClauseTest.php | 4 ++-- tests/Unit/Database/Query/RawTest.php | 4 ++-- 10 files changed, 51 insertions(+), 26 deletions(-) diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 4fac21d..c561b3f 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -171,11 +171,29 @@ private function statement(#[Language('GenericSQL')] string $query, array $bindi { try { $statement = $this->pdo->prepare($query); - $statement->execute($bindings); + $statement->execute($this->processBindings($bindings)); return $statement; } catch (PDOException $e) { throw new QueryException($query, $bindings, previous: $e); } } + + /** + * Process the given bindings. + * + * Processes bindings to better prepare them for execution. Currently, + * converts bool to int. + * + * @param array $bindings + * + * @return array + */ + private function processBindings(array $bindings): array + { + return array_map( + static fn (mixed $value): mixed => is_bool($value) ? (int) $value : $value, + $bindings, + ); + } } diff --git a/src/Database/DatabaseResolver.php b/src/Database/DatabaseResolver.php index 54c2b8b..3c944ed 100644 --- a/src/Database/DatabaseResolver.php +++ b/src/Database/DatabaseResolver.php @@ -8,7 +8,7 @@ use Engine\Container\Dependency; use Engine\Database\Attributes\Database; use ReflectionNamedType; -use RuntimeException; +use Engine\Database\Exceptions\DatabaseException; /** * Database Resolver @@ -46,7 +46,7 @@ public function resolve(Dependency $dependency, Container $container, array $arg $database = $dependency->resolvable; if (! $database instanceof Database) { - throw new RuntimeException(sprintf( + throw new DatabaseException(sprintf( 'The database connection resolver can only resolve parameters using the "%s" attribute.', Database::class, )); @@ -56,7 +56,7 @@ public function resolve(Dependency $dependency, Container $container, array $arg ! $dependency->type instanceof ReflectionNamedType || $dependency->type->getName() !== Connection::class ) { - throw new RuntimeException(sprintf( + throw new DatabaseException(sprintf( 'The database connection resolver can only resolve parameters of the type "%s".', Connection::class, )); diff --git a/src/Database/Exceptions/InvalidExpressionException.php b/src/Database/Exceptions/InvalidExpressionException.php index fa57157..2cc2181 100644 --- a/src/Database/Exceptions/InvalidExpressionException.php +++ b/src/Database/Exceptions/InvalidExpressionException.php @@ -18,4 +18,9 @@ public static function emptyInClause(string $column): self sprintf('Cannot use an empty array for an IN clause on column "%s".', $column), ); } + + public static function invalidOperator(string $operator): self + { + return new self(sprintf('Invalid operator "%s".', $operator)); + } } diff --git a/src/Database/Query/Cursor.php b/src/Database/Query/Cursor.php index 201ee83..2a99de0 100644 --- a/src/Database/Query/Cursor.php +++ b/src/Database/Query/Cursor.php @@ -9,14 +9,12 @@ final class Cursor { - private PDOStatement $statement; - /** * @var array - * - * @phpstan-ignore property.onlyWritten */ - private array $bindings; + public readonly array $bindings; + + private PDOStatement $statement; /** * @param PDOStatement $statement diff --git a/src/Database/Query/Expressions.php b/src/Database/Query/Expressions.php index bb70953..5149b37 100644 --- a/src/Database/Query/Expressions.php +++ b/src/Database/Query/Expressions.php @@ -4,6 +4,7 @@ namespace Engine\Database\Query; use Engine\Database\Contracts\Expression; +use Engine\Database\Exceptions\InvalidExpressionException; use Engine\Database\Query\Expressions\Aggregate; use Engine\Database\Query\Expressions\ColumnEqualTo; use Engine\Database\Query\Expressions\ColumnGreaterThen; @@ -18,7 +19,6 @@ use Engine\Database\Query\Expressions\ColumnNotIn; use Engine\Database\Query\Expressions\MatchAgainst; use Engine\Database\Query\Expressions\RawExpression; -use InvalidArgumentException; final class Expressions { @@ -36,7 +36,7 @@ public static function whereColumn(string $operator, string $column, mixed $valu '!=' => ColumnNotEqualTo::make($column, $value), 'in' => ColumnIn::make($column, $value), // @phpstan-ignore-line 'not in' => ColumnNotIn::make($column, $value), // @phpstan-ignore-line - default => throw new InvalidArgumentException(sprintf('Invalid operator "%s".', $operator)), + default => throw InvalidExpressionException::invalidOperator($operator), }; } diff --git a/src/Database/Query/Raw.php b/src/Database/Query/Raw.php index a276cf9..e86c256 100644 --- a/src/Database/Query/Raw.php +++ b/src/Database/Query/Raw.php @@ -7,9 +7,15 @@ final readonly class Raw implements Expression { - public static function from(string $sql): self + /** + * @param string $sql + * @param array $bindings + * + * @return static + */ + public static function from(string $sql, array $bindings = []): self { - return new self($sql); + return new self($sql, $bindings); } /** diff --git a/src/Database/Query/Result.php b/src/Database/Query/Result.php index 379d396..f4f9fd2 100644 --- a/src/Database/Query/Result.php +++ b/src/Database/Query/Result.php @@ -8,14 +8,12 @@ final class Result { - private PDOStatement $statement; - /** * @var array - * - * @phpstan-ignore property.onlyWritten */ - private array $bindings; + public readonly array $bindings; + + private PDOStatement $statement; /** * @var array|null diff --git a/tests/Unit/Database/DatabaseResolverTest.php b/tests/Unit/Database/DatabaseResolverTest.php index 381553c..1cd964e 100644 --- a/tests/Unit/Database/DatabaseResolverTest.php +++ b/tests/Unit/Database/DatabaseResolverTest.php @@ -20,7 +20,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use ReflectionClass; -use RuntimeException; +use Engine\Database\Exceptions\DatabaseException; use Tests\Unit\Database\Fixtures\ClassWithDatabaseDependency; use Tests\Unit\Database\Fixtures\ClassWithInvalidDatabaseType; @@ -85,7 +85,7 @@ public function throwsWhenResolvableIsNotDatabaseAttribute(): void resolvable: new class implements Resolvable {}, ); - $this->expectException(RuntimeException::class); + $this->expectException(DatabaseException::class); $this->expectExceptionMessage(Database::class); $resolver->resolve($dependency, $this->buildContainer()); @@ -104,7 +104,7 @@ public function throwsWhenParameterTypeIsNotConnection(): void $resolver = new DatabaseResolver($this->factoryWithConnections([])); $dependency = $this->dependencyFrom(ClassWithInvalidDatabaseType::class, 'connection'); - $this->expectException(RuntimeException::class); + $this->expectException(DatabaseException::class); $this->expectExceptionMessage(Connection::class); $resolver->resolve($dependency, $this->buildContainer()); @@ -123,7 +123,7 @@ public function throwsWhenParameterHasNoType(): void resolvable: new Database(), ); - $this->expectException(RuntimeException::class); + $this->expectException(DatabaseException::class); $this->expectExceptionMessage(Connection::class); $resolver->resolve($dependency, $this->buildContainer()); diff --git a/tests/Unit/Database/Query/Clauses/WhereClauseTest.php b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php index 88731ca..4596db6 100644 --- a/tests/Unit/Database/Query/Clauses/WhereClauseTest.php +++ b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php @@ -6,7 +6,6 @@ use Engine\Database\Exceptions\InvalidExpressionException; use Engine\Database\Query\Clauses\WhereClause; use Engine\Database\Query\Expressions\RawExpression; -use InvalidArgumentException; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -145,7 +144,8 @@ public function whereWithNotEqualOperatorProducesCorrectSql(): void #[Test] public function whereWithInvalidOperatorThrowsException(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidExpressionException::class); + $this->expectExceptionMessage('Invalid operator "INVALID".'); $clause = new WhereClause(); $clause->where('col', 'INVALID', 'value'); diff --git a/tests/Unit/Database/Query/RawTest.php b/tests/Unit/Database/Query/RawTest.php index c86f45a..5d69353 100644 --- a/tests/Unit/Database/Query/RawTest.php +++ b/tests/Unit/Database/Query/RawTest.php @@ -17,7 +17,7 @@ class RawTest extends TestCase #[Test] public function rawQueryReturnsSqlAndBindings(): void { - $raw = new Raw('SELECT * FROM users WHERE id = ?', [1]); + $raw = Raw::from('SELECT * FROM users WHERE id = ?', [1]); $this->assertSame('SELECT * FROM users WHERE id = ?', $raw->toSql()); $this->assertSame([1], $raw->getBindings()); @@ -29,7 +29,7 @@ public function rawQueryReturnsSqlAndBindings(): void #[Test] public function rawQueryDefaultsToEmptyBindings(): void { - $raw = new Raw('SELECT 1'); + $raw = Raw::from('SELECT 1'); $this->assertSame('SELECT 1', $raw->toSql()); $this->assertSame([], $raw->getBindings()); From 83ffba3aed4b46dcfff1052991a03a56d0f25dec Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Wed, 1 Apr 2026 19:39:17 +0100 Subject: [PATCH 29/29] style: Code tidy --- src/Database/DatabaseResolver.php | 2 +- tests/Unit/Database/DatabaseResolverTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/DatabaseResolver.php b/src/Database/DatabaseResolver.php index 3c944ed..3353c42 100644 --- a/src/Database/DatabaseResolver.php +++ b/src/Database/DatabaseResolver.php @@ -7,8 +7,8 @@ use Engine\Container\Contracts\Resolver; use Engine\Container\Dependency; use Engine\Database\Attributes\Database; -use ReflectionNamedType; use Engine\Database\Exceptions\DatabaseException; +use ReflectionNamedType; /** * Database Resolver diff --git a/tests/Unit/Database/DatabaseResolverTest.php b/tests/Unit/Database/DatabaseResolverTest.php index 1cd964e..01e24a1 100644 --- a/tests/Unit/Database/DatabaseResolverTest.php +++ b/tests/Unit/Database/DatabaseResolverTest.php @@ -15,12 +15,12 @@ use Engine\Database\Connection; use Engine\Database\ConnectionFactory; use Engine\Database\DatabaseResolver; +use Engine\Database\Exceptions\DatabaseException; use PDO; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use ReflectionClass; -use Engine\Database\Exceptions\DatabaseException; use Tests\Unit\Database\Fixtures\ClassWithDatabaseDependency; use Tests\Unit\Database\Fixtures\ClassWithInvalidDatabaseType;