diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 64cb71c..8b2f0b8 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -1,8 +1,39 @@ name: Code Coverage -on: [ push, pull_request ] + +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 @@ -15,8 +46,45 @@ 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 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..4f256e8 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,138 @@ +name: Integration Tests + +on: [ pull_request ] + +jobs: + connection: + name: Connection (MySQL 8.4) + 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 + 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 connection integration tests + run: vendor/bin/phpunit --testsuite Integration --group connection-factory + 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 + + query-builder: + name: Query Builder (${{ matrix.image }}) + runs-on: ubuntu-latest + strategy: + matrix: + 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 }} + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: engine_test + MYSQL_USER: engine + MYSQL_PASSWORD: secret + ports: + - 3306:3306 + options: >- + --health-cmd="${{ matrix.health_cmd }}" + --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/.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 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/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" } } 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..d2d0630 100644 --- a/infection.json5 +++ b/infection.json5 @@ -10,35 +10,73 @@ "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" ] }, - "IfNegation" : { + "DecrementInteger" : { "ignore": [ - "Engine\\Container\\Container::invokeClassMethod::349" + "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" ] }, - "ReturnRemoval": { + "IncrementInteger" : { "ignore": [ - "Engine\\Container\\Container::invokeClassMethod::350" + "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" + ] + }, + "ReturnRemoval" : { + "ignore": [ + "Engine\\Container\\Container::invokeClassMethod::350", + "Engine\\Database\\Query\\Concerns\\HasJoinClause::buildJoinClause::97", + "Engine\\Values\\ValueGetter::float::110", + "Engine\\Values\\ValueGetter::int::135" + ] + }, + "MatchArmRemoval" : { + "ignore": [ + "Engine\\Database\\ConnectionFactory::createPdo::107" + ] + }, + "IfNegation" : { + "ignore": [ + "Engine\\Container\\Container::invokeClassMethod::349" ] } } 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; + } +} 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 @@ +, + * } + * + * @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); + } + + public string $driver; + + /** + * @param string|null $host + * @param int|null $port + * @param string|null $socket + * @param string $database + * @param string $username + * @param string $password + * @param array $options + */ + protected function __construct( + public ?string $host, + public ?int $port, + public ?string $socket, + public string $database, + public string $username, + public string $password, + public array $options = [], + ) { + $this->driver = 'mysql'; + } +} diff --git a/src/Database/Config/DatabaseConfig.php b/src/Database/Config/DatabaseConfig.php new file mode 100644 index 0000000..cfd14b8 --- /dev/null +++ b/src/Database/Config/DatabaseConfig.php @@ -0,0 +1,53 @@ +, + * } + * + * @extends BaseConfigObject + * + * @phpstan-pure + * + * @immutable + */ +final readonly class DatabaseConfig extends BaseConfigObject +{ + /** + * @param string $primary + * @param array $connections + * @param bool $persistent + * + * @return DatabaseConfig + */ + public static function make(string $primary, array $connections, bool $persistent = false): self + { + return new self($primary, $connections, $persistent); + } + + /** + * @param string $primary + * @param array $connections + * @param bool $persistent + */ + protected function __construct( + public string $primary, + public array $connections, + public bool $persistent, + ) { + 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.'); + } +} diff --git a/src/Database/Connection.php b/src/Database/Connection.php new file mode 100644 index 0000000..c561b3f --- /dev/null +++ b/src/Database/Connection.php @@ -0,0 +1,199 @@ + $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, + ); + // @codeCoverageIgnoreStart + } catch (PDOException $e) { + throw new QueryException($query, $bindings, previous: $e); + } + // @codeCoverageIgnoreEnd + } + + /** + * 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(); + // @codeCoverageIgnoreStart + } catch (PDOException $e) { + throw new DatabaseException($e->getMessage(), previous: $e); + } + // @codeCoverageIgnoreEnd + } + + /** + * Rollback the active database transaction. + */ + public function rollback(): void + { + try { + $this->pdo->rollBack(); + // @codeCoverageIgnoreStart + } catch (PDOException $e) { + throw new DatabaseException($e->getMessage(), previous: $e); + } + // @codeCoverageIgnoreEnd + } + + /** + * 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($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/ConnectionFactory.php b/src/Database/ConnectionFactory.php new file mode 100644 index 0000000..1006cd8 --- /dev/null +++ b/src/Database/ConnectionFactory.php @@ -0,0 +1,146 @@ + + */ + 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 = $config->options + self::$defaultOptions; + + // 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 ?? 3306, + $config->database, + ); + } + + throw new DatabaseException('No host or socket specified.'); + } +} diff --git a/src/Database/Contracts/Expression.php b/src/Database/Contracts/Expression.php new file mode 100644 index 0000000..3da3098 --- /dev/null +++ b/src/Database/Contracts/Expression.php @@ -0,0 +1,27 @@ + + */ + public function getBindings(): array; +} 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 @@ + + */ +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 DatabaseException(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 DatabaseException(sprintf( + 'The database connection resolver can only resolve parameters of the type "%s".', + Connection::class, + )); + } + + return $this->factory->make($database->name); + } +} 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..2487643 --- /dev/null +++ b/src/Database/Exceptions/DatabaseException.php @@ -0,0 +1,10 @@ + $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..c1ef652 --- /dev/null +++ b/src/Database/Query/Clauses/JoinClause.php @@ -0,0 +1,132 @@ +}> + */ + private array $conditions = []; + + /** + * Add an ON condition to the join. + * + * @param string $left + * @param string $operator + * @param string $right + * + * @return static + */ + 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 static + */ + 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 string $operator + * @param mixed $value + * + * @return static + */ + public function where(string $column, string $operator, mixed $value): self + { + $this->conditions[] = [ + 'conjunction' => 'AND', + 'sql' => "{$column} {$operator} ?", + 'bindings' => [$value], + ]; + + return $this; + } + + /** + * Add an OR WHERE condition to the join. + * + * @param string $column + * @param string $operator + * @param mixed $value + * + * @return static + */ + public function orWhere(string $column, string $operator, mixed $value): self + { + $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..3c21959 --- /dev/null +++ b/src/Database/Query/Clauses/WhereClause.php @@ -0,0 +1,362 @@ + + */ + private array $conditions = []; + + /** + * Add a basic where clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function where(Closure|string $column, ?string $operator = null, mixed $value = null): self + { + if ($column instanceof Closure) { + $this->condition('AND', $column, null, null); + } else { + $this->condition('AND', $column, $operator, $value); + } + + return $this; + } + + /** + * Add an "or where" clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): self + { + if ($column instanceof Closure) { + $this->condition('OR', $column, null, null); + } else { + $this->condition('OR', $column, $operator, $value); + } + + return $this; + } + + /** + * Add a "where null" clause to the query. + * + * @param string $column + * + * @return static + */ + 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 static + */ + 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 static + */ + public function whereNotNull(string $column): self + { + $this->condition('AND', $column, 'IS NOT NULL', null); + + 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 static + */ + 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 static + */ + 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 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 static + */ + public function whereRaw(string $sql, array $bindings = []): self + { + $this->conditions[] = [ + 'conjunction' => 'AND', + 'expression' => Expressions::raw($sql, $bindings), + 'grouped' => false, + ]; + + 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. + * + * @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/HasGroupByClause.php b/src/Database/Query/Concerns/HasGroupByClause.php new file mode 100644 index 0000000..d177aa1 --- /dev/null +++ b/src/Database/Query/Concerns/HasGroupByClause.php @@ -0,0 +1,59 @@ + + */ + 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[] = $group->getBindings(); + } + } + + /** @var array */ + return array_merge(...$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/HasJoinClause.php b/src/Database/Query/Concerns/HasJoinClause.php new file mode 100644 index 0000000..99bee75 --- /dev/null +++ b/src/Database/Query/Concerns/HasJoinClause.php @@ -0,0 +1,129 @@ + + */ + 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 static + */ + 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 static + */ + 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 static + */ + 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 static + */ + 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 + * @var string $second + */ + $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[] = $join['clause']->getBindings(); + } + + /** @var array */ + return array_merge(...$bindings); + } +} diff --git a/src/Database/Query/Concerns/HasLimitClause.php b/src/Database/Query/Concerns/HasLimitClause.php new file mode 100644 index 0000000..e9b5690 --- /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 static + */ + 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..a2bce11 --- /dev/null +++ b/src/Database/Query/Concerns/HasOrderByClause.php @@ -0,0 +1,66 @@ + + */ + private array $orders = []; + + /** + * Add an order by clause to the query. + * + * @param string|Expression $column + * @param string $direction + * + * @return static + */ + 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(static 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[] = $order['column']->getBindings(); + } + } + + /** @var array */ + return array_merge(...$bindings); + } +} diff --git a/src/Database/Query/Concerns/HasWhereClause.php b/src/Database/Query/Concerns/HasWhereClause.php new file mode 100644 index 0000000..5057a04 --- /dev/null +++ b/src/Database/Query/Concerns/HasWhereClause.php @@ -0,0 +1,230 @@ + $this->whereClause ?? $this->whereClause = new WhereClause(); + } + + /** + * Add a basic where clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function where(Closure|string $column, ?string $operator = null, mixed $value = null): static + { + $this->whereClause->where($column, $operator, $value); + + return $this; + } + + /** + * Add an "or where" clause to the query. + * + * @param string|Closure $column + * @param string|null $operator + * @param mixed|null $value + * + * @return static + */ + public function orWhere(Closure|string $column, ?string $operator = null, mixed $value = null): static + { + $this->whereClause->orWhere($column, $operator, $value); + + return $this; + } + + /** + * Add a "where null" clause to the query. + * + * @param string $column + * + * @return static + */ + 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 static + */ + 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 static + */ + public function whereNotNull(string $column): static + { + $this->whereClause->whereNotNull($column); + + 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 static + */ + 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 static + */ + public function whereNotIn(string $column, array|Expression $values): static + { + $this->whereClause->whereNotIn($column, $values); + + 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 static + */ + public function whereRaw(string $sql, array $bindings = []): static + { + $this->whereClause->whereRaw($sql, $bindings); + + 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/Cursor.php b/src/Database/Query/Cursor.php new file mode 100644 index 0000000..2a99de0 --- /dev/null +++ b/src/Database/Query/Cursor.php @@ -0,0 +1,78 @@ + + */ + public readonly array $bindings; + + private PDOStatement $statement; + + /** + * @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 + * + * @codeCoverageIgnore Not reliably testable — rowCount() is driver-dependent for SELECT. + */ + public function count(): int + { + return $this->statement->rowCount(); + } + + /** + * Determine if the result is empty. + * + * @return bool + * + * @codeCoverageIgnore Not reliably testable — delegates to count() which is driver-dependent. + */ + 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..99ab04a --- /dev/null +++ b/src/Database/Query/Delete.php @@ -0,0 +1,54 @@ +hasWhereClause() ? ' WHERE ' . $this->whereClause->toSql() : ''; + + return "DELETE FROM {$this->table}" + . $where + . $this->buildOrderByClause() + . $this->buildLimitClause(); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + return array_merge( + $this->whereClause->getBindings(), + $this->getOrderByBindings(), + ); + } +} diff --git a/src/Database/Query/Expressions.php b/src/Database/Query/Expressions.php new file mode 100644 index 0000000..5149b37 --- /dev/null +++ b/src/Database/Query/Expressions.php @@ -0,0 +1,125 @@ + 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 InvalidExpressionException::invalidOperator($operator), + }; + } + + /** + * @param string $sql + * @param array $bindings + * + * @return Expression + */ + 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/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/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/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..c600b83 --- /dev/null +++ b/src/Database/Query/Insert.php @@ -0,0 +1,148 @@ +> + */ + private array $rows = []; + + private bool $ignore = false; + + private bool $replace = false; + + /** + * @var array + */ + private array $upsertValues = []; + + private function __construct( + private string $table, + ) { + } + + /** + * Add a row of values to insert. + * + * @param array $values + * + * @return static + */ + public function values(array $values): self + { + $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; + } + + /** + * Get the SQL representation of the expression. + * + * @return string + */ + public function toSql(): string + { + $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} = ?"; + } + } + + $sql .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $updates); + } + + return $sql; + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + public function getBindings(): array + { + $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/Raw.php b/src/Database/Query/Raw.php new file mode 100644 index 0000000..e86c256 --- /dev/null +++ b/src/Database/Query/Raw.php @@ -0,0 +1,50 @@ + $bindings + * + * @return static + */ + public static function from(string $sql, array $bindings = []): self + { + return new self($sql, $bindings); + } + + /** + * @param string $sql + * @param array $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..f4f9fd2 --- /dev/null +++ b/src/Database/Query/Result.php @@ -0,0 +1,105 @@ + + */ + public readonly array $bindings; + + private PDOStatement $statement; + + /** + * @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..5a3f919 --- /dev/null +++ b/src/Database/Query/Select.php @@ -0,0 +1,136 @@ + + */ + private array $columns = []; + + private function __construct( + private Expression|string $table, + ) { + } + + /** + * Set the columns to select. + * + * @param string|Expression ...$columns + * + * @return static + */ + public function columns(Expression|string ...$columns): self + { + $this->columns = $columns; + + return $this; + } + + /** + * Add a column to select. + * + * @param string|Expression $column + * + * @return static + */ + public function addColumn(Expression|string $column): self + { + $this->columns[] = $column; + + return $this; + } + + /** + * Set the query to select distinct rows. + * + * @return static + */ + 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() : ''; + $having = $this->hasHavingClause() ? ' HAVING ' . $this->havingClause->toSql() : ''; + + return "SELECT {$distinct}{$columns} FROM {$table}" + . $this->buildJoinClause() + . $where + . $this->buildGroupByClause() + . $having + . $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->getGroupByBindings(), + $this->havingClause->getBindings(), + $this->getOrderByBindings(), + ); + } +} diff --git a/src/Database/Query/Update.php b/src/Database/Query/Update.php new file mode 100644 index 0000000..402acf4 --- /dev/null +++ b/src/Database/Query/Update.php @@ -0,0 +1,95 @@ + + */ + private array $sets = []; + + private function __construct( + private string $table, + ) { + } + + /** + * Set the column values to update. + * + * @param array $values + * + * @return static + */ + 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 ($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 + . $this->buildOrderByClause() + . $this->buildLimitClause(); + } + + /** + * Get the bindings for the expression. + * + * @return array + */ + 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( + $setBindings, + $this->whereClause->getBindings(), + $this->getOrderByBindings(), + ); + } +} 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; + } +} 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'); + } +} 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/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php new file mode 100644 index 0000000..b623da3 --- /dev/null +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -0,0 +1,677 @@ + 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); + } + + /** + * - 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 + // ------------------------------------------------------------------------- + + /** + * - 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')); + } + + // ------------------------------------------------------------------------- + // 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/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/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/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php new file mode 100644 index 0000000..c662da9 --- /dev/null +++ b/tests/Unit/Database/ConnectionTest.php @@ -0,0 +1,264 @@ +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->assertFalse($conn->isInTransaction()); + $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/DatabaseResolverTest.php b/tests/Unit/Database/DatabaseResolverTest.php new file mode 100644 index 0000000..01e24a1 --- /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(DatabaseException::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(DatabaseException::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(DatabaseException::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/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(), + ); + } +} 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/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 @@ +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 new file mode 100644 index 0000000..4596db6 --- /dev/null +++ b/tests/Unit/Database/Query/Clauses/WhereClauseTest.php @@ -0,0 +1,649 @@ +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 + }); + } + + /** + * - 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(InvalidExpressionException::class); + $this->expectExceptionMessage('Invalid operator "INVALID".'); + + $clause = new WhereClause(); + $clause->where('col', 'INVALID', 'value'); + } + + // ------------------------------------------------------------------------- + // 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/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 new file mode 100644 index 0000000..c7c2eb6 --- /dev/null +++ b/tests/Unit/Database/Query/DeleteTest.php @@ -0,0 +1,160 @@ +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()); + } + + /** + * - 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() + // ------------------------------------------------------------------------- + + /** + * - 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/RawTest.php b/tests/Unit/Database/Query/RawTest.php new file mode 100644 index 0000000..5d69353 --- /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 = Raw::from('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 new file mode 100644 index 0000000..d9ec0fc --- /dev/null +++ b/tests/Unit/Database/Query/UpdateTest.php @@ -0,0 +1,221 @@ +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()); + } + + /** + * - 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() + // ------------------------------------------------------------------------- + + /** + * - 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()); + } +} 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']); + } +}