diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..9846749 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,64 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.4 + ports: + - 13306:3306 + env: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: query_test + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: postgres:16 + ports: + - 15432:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: query_test + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + clickhouse: + image: clickhouse/clickhouse-server:24 + ports: + - 18123:8123 + - 19000:9000 + options: >- + --health-cmd="wget --spider -q http://localhost:8123/ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: pdo, pdo_mysql, pdo_pgsql + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run integration tests + run: composer test:integration diff --git a/.gitignore b/.gitignore index 5e20fe1..30a6d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .phpunit.result.cache composer.phar /vendor/ +.idea +coverage diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c183c8d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# Project Rules + +- Never add decorative section-style comment headers (e.g. `// ==================`, `// ----------`, `// ~~~~` or similar). Use plain single-line comments only when necessary. +- Always use imports (`use` statements) instead of fully qualified class names in test files and source code. diff --git a/README.md b/README.md index b0452ca..2b4b7d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Linter](https://github.com/utopia-php/query/actions/workflows/linter.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/linter.yml) [![Static Analysis](https://github.com/utopia-php/query/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/static-analysis.yml) -A simple PHP library providing a query abstraction for filtering, ordering, and pagination. It offers a fluent, type-safe API for building queries that can be serialized to JSON and parsed back, making it easy to pass query definitions between client and server or between services. +A PHP library for building type-safe, dialect-aware SQL queries and DDL statements. Provides a fluent builder API with parameterized output for MySQL, PostgreSQL, and ClickHouse, plus a serializable `Query` value object for passing query definitions between services. ## Installation @@ -12,81 +12,129 @@ A simple PHP library providing a query abstraction for filtering, ordering, and composer require utopia-php/query ``` -## System Requirements - -- PHP 8.4+ - -## Usage +**Requires PHP 8.4+** + +## Table of Contents + +- [Query Object](#query-object) + - [Filters](#filters) + - [Ordering and Pagination](#ordering-and-pagination) + - [Logical Combinations](#logical-combinations) + - [Spatial Queries](#spatial-queries) + - [Vector Similarity](#vector-similarity) + - [JSON Queries](#json-queries) + - [Selection](#selection) + - [Raw Expressions](#raw-expressions) + - [Serialization](#serialization) + - [Helpers](#helpers) +- [Query Builder](#query-builder) + - [Basic Usage](#basic-usage) + - [Aggregations](#aggregations) + - [Joins](#joins) + - [Unions and Set Operations](#unions-and-set-operations) + - [CTEs (Common Table Expressions)](#ctes-common-table-expressions) + - [Window Functions](#window-functions) + - [CASE Expressions](#case-expressions) + - [Inserts](#inserts) + - [Updates](#updates) + - [Deletes](#deletes) + - [Upsert](#upsert) + - [Locking](#locking) + - [Transactions](#transactions) + - [Conditional Building](#conditional-building) + - [Debugging](#debugging) + - [Hooks](#hooks) +- [Dialect-Specific Features](#dialect-specific-features) + - [MySQL](#mysql) + - [PostgreSQL](#postgresql) + - [ClickHouse](#clickhouse) + - [Feature Matrix](#feature-matrix) +- [Schema Builder](#schema-builder) + - [Creating Tables](#creating-tables) + - [Altering Tables](#altering-tables) + - [Indexes](#indexes) + - [Foreign Keys](#foreign-keys) + - [Views](#views) + - [Procedures and Triggers](#procedures-and-triggers) + - [PostgreSQL Schema Extensions](#postgresql-schema-extensions) + - [ClickHouse Schema](#clickhouse-schema) +- [Compiler Interface](#compiler-interface) +- [Contributing](#contributing) +- [License](#license) + +## Query Object + +The `Query` class is a serializable value object representing a single query predicate. It serves as the input to the builder's `filter()`, `having()`, and other methods. ```php use Utopia\Query\Query; ``` -### Filter Queries +### Filters ```php // Equality -$query = Query::equal('status', ['active', 'pending']); -$query = Query::notEqual('role', 'guest'); +Query::equal('status', ['active', 'pending']); +Query::notEqual('role', 'guest'); // Comparison -$query = Query::greaterThan('age', 18); -$query = Query::greaterThanEqual('score', 90); -$query = Query::lessThan('price', 100); -$query = Query::lessThanEqual('quantity', 0); +Query::greaterThan('age', 18); +Query::greaterThanEqual('score', 90); +Query::lessThan('price', 100); +Query::lessThanEqual('quantity', 0); // Range -$query = Query::between('createdAt', '2024-01-01', '2024-12-31'); -$query = Query::notBetween('priority', 1, 3); +Query::between('createdAt', '2024-01-01', '2024-12-31'); +Query::notBetween('priority', 1, 3); // String matching -$query = Query::startsWith('email', 'admin'); -$query = Query::endsWith('filename', '.pdf'); -$query = Query::search('content', 'hello world'); -$query = Query::regex('slug', '^[a-z0-9-]+$'); +Query::startsWith('email', 'admin'); +Query::endsWith('filename', '.pdf'); +Query::search('content', 'hello world'); +Query::regex('slug', '^[a-z0-9-]+$'); // Array / contains -$query = Query::contains('tags', ['php', 'utopia']); -$query = Query::containsAny('categories', ['news', 'blog']); -$query = Query::containsAll('permissions', ['read', 'write']); -$query = Query::notContains('labels', ['deprecated']); +Query::contains('tags', ['php', 'utopia']); +Query::containsAny('categories', ['news', 'blog']); +Query::containsAll('permissions', ['read', 'write']); +Query::notContains('labels', ['deprecated']); // Null checks -$query = Query::isNull('deletedAt'); -$query = Query::isNotNull('verifiedAt'); +Query::isNull('deletedAt'); +Query::isNotNull('verifiedAt'); -// Existence -$query = Query::exists(['name', 'email']); -$query = Query::notExists('legacyField'); +// Existence (compiles to IS NOT NULL / IS NULL) +Query::exists(['name', 'email']); +Query::notExists('legacyField'); // Date helpers -$query = Query::createdAfter('2024-01-01'); -$query = Query::updatedBetween('2024-01-01', '2024-06-30'); +Query::createdAfter('2024-01-01'); +Query::updatedBetween('2024-01-01', '2024-06-30'); ``` ### Ordering and Pagination ```php -$query = Query::orderAsc('createdAt'); -$query = Query::orderDesc('score'); -$query = Query::orderRandom(); +Query::orderAsc('createdAt'); +Query::orderDesc('score'); +Query::orderRandom(); -$query = Query::limit(25); -$query = Query::offset(50); +Query::limit(25); +Query::offset(50); -$query = Query::cursorAfter('doc_abc123'); -$query = Query::cursorBefore('doc_xyz789'); +Query::cursorAfter('doc_abc123'); +Query::cursorBefore('doc_xyz789'); ``` ### Logical Combinations ```php -$query = Query::and([ +Query::and([ Query::greaterThan('age', 18), Query::equal('status', ['active']), ]); -$query = Query::or([ +Query::or([ Query::equal('role', ['admin']), Query::equal('role', ['moderator']), ]); @@ -95,27 +143,44 @@ $query = Query::or([ ### Spatial Queries ```php -$query = Query::distanceLessThan('location', [40.7128, -74.0060], 5000, meters: true); -$query = Query::distanceGreaterThan('location', [51.5074, -0.1278], 100); - -$query = Query::intersects('area', [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]); -$query = Query::overlaps('region', [[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]); -$query = Query::touches('boundary', [[0, 0], [1, 1]]); -$query = Query::crosses('path', [[0, 0], [5, 5]]); +Query::distanceLessThan('location', [40.7128, -74.0060], 5000, meters: true); +Query::distanceGreaterThan('location', [51.5074, -0.1278], 100); + +Query::intersects('area', [[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]); +Query::overlaps('region', [[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]); +Query::touches('boundary', [[0, 0], [1, 1]]); +Query::crosses('path', [[0, 0], [5, 5]]); +Query::covers('zone', [1.0, 2.0]); +Query::spatialEquals('geom', [3.0, 4.0]); ``` ### Vector Similarity ```php -$query = Query::vectorDot('embedding', [0.1, 0.2, 0.3, 0.4]); -$query = Query::vectorCosine('embedding', [0.1, 0.2, 0.3, 0.4]); -$query = Query::vectorEuclidean('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorDot('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorCosine('embedding', [0.1, 0.2, 0.3, 0.4]); +Query::vectorEuclidean('embedding', [0.1, 0.2, 0.3, 0.4]); +``` + +### JSON Queries + +```php +Query::jsonContains('tags', 'php'); +Query::jsonNotContains('tags', 'legacy'); +Query::jsonOverlaps('categories', ['news', 'blog']); +Query::jsonPath('metadata', 'address.city', '=', 'London'); ``` ### Selection ```php -$query = Query::select(['name', 'email', 'createdAt']); +Query::select(['name', 'email', 'createdAt']); +``` + +### Raw Expressions + +```php +Query::raw('score > ? AND score < ?', [10, 100]); ``` ### Serialization @@ -125,166 +190,811 @@ Queries serialize to JSON and can be parsed back: ```php $query = Query::equal('status', ['active']); -// Serialize to JSON string +// Serialize $json = $query->toString(); // '{"method":"equal","attribute":"status","values":["active"]}' -// Parse back from JSON string +// Parse back $parsed = Query::parse($json); -// Parse multiple queries -$queries = Query::parseQueries([$json1, $json2, $json3]); +// Parse multiple +$queries = Query::parseQueries([$json1, $json2]); ``` -### Grouping Helpers +### Helpers + +```php +// Group queries by type +$grouped = Query::groupByType($queries); +// $grouped->filters, $grouped->limit, $grouped->orderAttributes, etc. -`groupByType` splits an array of queries into categorized buckets: +// Filter by method type +$cursors = Query::getByType($queries, [Method::CursorAfter, Method::CursorBefore]); + +// Merge (later limit/offset/cursor overrides earlier) +$merged = Query::merge($defaultQueries, $userQueries); + +// Diff — queries in A not in B +$unique = Query::diff($queriesA, $queriesB); + +// Validate attributes against an allow-list +$errors = Query::validate($queries, ['name', 'age', 'status']); + +// Page helper — returns [limit, offset] queries +[$limit, $offset] = Query::page(3, 10); +``` + +## Query Builder + +The builder generates parameterized SQL from the fluent API. Every `build()`, `insert()`, `update()`, and `delete()` call returns a `BuildResult` with `->query` (the SQL string) and `->bindings` (the parameter array). + +Three dialect implementations are provided: + +- `Utopia\Query\Builder\MySQL` — MySQL/MariaDB +- `Utopia\Query\Builder\PostgreSQL` — PostgreSQL +- `Utopia\Query\Builder\ClickHouse` — ClickHouse + +MySQL and PostgreSQL extend `Builder\SQL` which adds locking, transactions, and upsert. ClickHouse extends `Builder` directly with its own `ALTER TABLE` mutation syntax. + +### Basic Usage ```php -$queries = [ - Query::equal('status', ['active']), - Query::greaterThan('age', 18), - Query::orderAsc('name'), - Query::limit(25), - Query::offset(10), - Query::select(['name', 'email']), - Query::cursorAfter('abc123'), -]; +use Utopia\Query\Builder\MySQL as Builder; +use Utopia\Query\Query; -$grouped = Query::groupByType($queries); +$result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(0) + ->build(); + +$result->query; // SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ? +$result->bindings; // ['active', 18, 25, 0] +``` + +**Batch mode** — pass all queries at once: -// $grouped['filters'] — filter Query objects -// $grouped['selections'] — select Query objects -// $grouped['limit'] — int|null -// $grouped['offset'] — int|null -// $grouped['orderAttributes'] — ['name'] -// $grouped['orderTypes'] — ['ASC'] -// $grouped['cursor'] — 'abc123' -// $grouped['cursorDirection'] — 'after' +```php +$result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::orderAsc('name'), + Query::limit(25), + ]) + ->build(); ``` -`getByType` filters queries by one or more method types: +**Using with PDO:** ```php -$cursors = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->build(); + +$stmt = $pdo->prepare($result->query); +$stmt->execute($result->bindings); +$rows = $stmt->fetchAll(); ``` -### Building an Adapter +### Aggregations + +```php +$result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->sum('price', 'total_price') + ->select(['status']) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->build(); + +// SELECT COUNT(*) AS `total`, SUM(`price`) AS `total_price`, `status` +// FROM `orders` GROUP BY `status` HAVING `total` > ? +``` -The `Query` object is backend-agnostic — your library decides how to translate it. Use `groupByType` to break queries apart, then map each piece to your target syntax: +**Distinct:** ```php -use Utopia\Query\Query; +$result = (new Builder()) + ->from('users') + ->distinct() + ->select(['country']) + ->build(); + +// SELECT DISTINCT `country` FROM `users` +``` + +### Joins + +```php +$result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->crossJoin('colors') + ->build(); + +// SELECT * FROM `users` +// JOIN `orders` ON `users`.`id` = `orders`.`user_id` +// LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` +// CROSS JOIN `colors` +``` + +### Unions and Set Operations + +```php +$admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + +// SELECT * FROM `users` WHERE `status` IN (?) +// UNION SELECT * FROM `admins` WHERE `role` IN (?) +``` + +Also available: `unionAll()`, `intersect()`, `intersectAll()`, `except()`, `exceptAll()`. + +### CTEs (Common Table Expressions) + +```php +$activeUsers = (new Builder())->from('users')->filter([Query::equal('status', ['active'])]); + +$result = (new Builder()) + ->with('active_users', $activeUsers) + ->from('active_users') + ->select(['name']) + ->build(); + +// WITH `active_users` AS (SELECT * FROM `users` WHERE `status` IN (?)) +// SELECT `name` FROM `active_users` +``` + +Use `withRecursive()` for recursive CTEs. + +### Window Functions + +```php +$result = (new Builder()) + ->from('sales') + ->select(['employee', 'amount']) + ->selectWindow('ROW_NUMBER()', 'row_num', partitionBy: ['department'], orderBy: ['amount']) + ->selectWindow('SUM(amount)', 'running_total', partitionBy: ['department'], orderBy: ['date']) + ->build(); + +// SELECT `employee`, `amount`, +// ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `amount` ASC) AS `row_num`, +// SUM(amount) OVER (PARTITION BY `department` ORDER BY `date` ASC) AS `running_total` +// FROM `sales` +``` + +Prefix an `orderBy` column with `-` for descending order (e.g., `['-amount']`). + +### CASE Expressions + +```php +$result = (new Builder()) + ->from('orders') + ->select(['id']) + ->selectCase( + (new Builder())->case() + ->when('amount > ?', 'high', conditionBindings: [1000]) + ->when('amount > ?', 'medium', conditionBindings: [100]) + ->elseResult('low') + ->alias('priority') + ->build() + ) + ->build(); + +// SELECT `id`, CASE WHEN amount > ? THEN ? WHEN amount > ? THEN ? ELSE ? END AS `priority` +// FROM `orders` +``` + +### Inserts + +```php +// Single row +$result = (new Builder()) + ->into('users') + ->set('name', 'Alice') + ->set('email', 'alice@example.com') + ->insert(); + +// Batch insert +$result = (new Builder()) + ->into('users') + ->set('name', 'Alice')->set('email', 'alice@example.com') + ->addRow() + ->set('name', 'Bob')->set('email', 'bob@example.com') + ->insert(); + +// INSERT ... SELECT +$source = (new Builder())->from('archived_users')->filter([Query::equal('status', ['active'])]); + +$result = (new Builder()) + ->into('users') + ->fromSelect($source, ['name', 'email']) + ->insertSelect(); +``` -class SQLAdapter +### Updates + +```php +$result = (new Builder()) + ->from('users') + ->set('status', 'inactive') + ->setRaw('updated_at', 'NOW()') + ->filter([Query::equal('id', [42])]) + ->update(); + +// UPDATE `users` SET `status` = ?, `updated_at` = NOW() WHERE `id` IN (?) +``` + +### Deletes + +```php +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['deleted'])]) + ->delete(); + +// DELETE FROM `users` WHERE `status` IN (?) +``` + +### Upsert + +Available on MySQL and PostgreSQL builders (`Builder\SQL` subclasses): + +```php +// MySQL — ON DUPLICATE KEY UPDATE +$result = (new Builder()) + ->into('counters') + ->set('key', 'visits') + ->set('value', 1) + ->onConflict(['key']) + ->upsert(); + +// PostgreSQL — ON CONFLICT (...) DO UPDATE SET +$result = (new \Utopia\Query\Builder\PostgreSQL()) + ->into('counters') + ->set('key', 'visits') + ->set('value', 1) + ->onConflict(['key']) + ->upsert(); +``` + +### Locking + +Available on MySQL and PostgreSQL builders: + +```php +$result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() + ->build(); + +// SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE +``` + +Also available: `forShare()`. + +### Transactions + +Available on MySQL and PostgreSQL builders: + +```php +$builder = new Builder(); + +$builder->begin(); // BEGIN +$builder->savepoint('sp1'); // SAVEPOINT `sp1` +$builder->rollbackToSavepoint('sp1'); +$builder->commit(); // COMMIT +$builder->rollback(); // ROLLBACK +``` + +### Conditional Building + +`when()` applies a callback only when the condition is true: + +```php +$result = (new Builder()) + ->from('users') + ->when($filterActive, fn(Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); +``` + +### Debugging + +`toRawSql()` inlines bindings for inspection (not for execution): + +```php +$sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + +// SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10 +``` + +### Hooks + +Hooks extend the builder with reusable, testable classes for attribute resolution and condition injection. + +**Attribute hooks** map virtual field names to real column names: + +```php +use Utopia\Query\Hook\Attribute\Map; + +$result = (new Builder()) + ->from('users') + ->addHook(new Map([ + '$id' => '_uid', + '$createdAt' => '_createdAt', + ])) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + +// SELECT * FROM `users` WHERE `_uid` IN (?) +``` + +**Filter hooks** inject conditions into every query: + +```php +use Utopia\Query\Hook\Filter\Tenant; +use Utopia\Query\Hook\Filter\Permission; + +$result = (new Builder()) + ->from('users') + ->addHook(new Tenant(['tenant_abc'])) + ->addHook(new Permission( + roles: ['role:member'], + permissionsTable: fn(string $table) => "mydb_{$table}_perms", + )) + ->filter([Query::equal('status', ['active'])]) + ->build(); + +// SELECT * FROM `users` +// WHERE `status` IN (?) AND `tenant_id` IN (?) +// AND `id` IN (SELECT DISTINCT `document_id` FROM `mydb_users_perms` WHERE `role` IN (?) AND `type` = ?) +``` + +**Custom filter hooks** implement `Hook\Filter`: + +```php +use Utopia\Query\Builder\Condition; +use Utopia\Query\Hook\Filter; + +class SoftDeleteHook implements Filter { - /** - * @param array $queries - */ - public function find(string $table, array $queries): array + public function filter(string $table): Condition { - $grouped = Query::groupByType($queries); - - // SELECT - $columns = '*'; - if (!empty($grouped['selections'])) { - $columns = implode(', ', $grouped['selections'][0]->getValues()); - } - - $sql = "SELECT {$columns} FROM {$table}"; - - // WHERE - $conditions = []; - foreach ($grouped['filters'] as $filter) { - $conditions[] = match ($filter->getMethod()) { - Query::TYPE_EQUAL => $filter->getAttribute() . ' IN (' . $this->placeholders($filter->getValues()) . ')', - Query::TYPE_NOT_EQUAL => $filter->getAttribute() . ' != ?', - Query::TYPE_GREATER => $filter->getAttribute() . ' > ?', - Query::TYPE_LESSER => $filter->getAttribute() . ' < ?', - Query::TYPE_BETWEEN => $filter->getAttribute() . ' BETWEEN ? AND ?', - Query::TYPE_IS_NULL => $filter->getAttribute() . ' IS NULL', - Query::TYPE_IS_NOT_NULL => $filter->getAttribute() . ' IS NOT NULL', - Query::TYPE_STARTS_WITH => $filter->getAttribute() . " LIKE CONCAT(?, '%')", - // ... handle other types - }; - } - - if (!empty($conditions)) { - $sql .= ' WHERE ' . implode(' AND ', $conditions); - } - - // ORDER BY - foreach ($grouped['orderAttributes'] as $i => $attr) { - $sql .= ($i === 0 ? ' ORDER BY ' : ', ') . $attr . ' ' . $grouped['orderTypes'][$i]; - } - - // LIMIT / OFFSET - if ($grouped['limit'] !== null) { - $sql .= ' LIMIT ' . $grouped['limit']; - } - if ($grouped['offset'] !== null) { - $sql .= ' OFFSET ' . $grouped['offset']; - } - - // Execute $sql with bound parameters ... + return new Condition('deleted_at IS NULL'); } } ``` -The same pattern works for any backend. A Redis adapter might map filters to sorted-set range commands, an Elasticsearch adapter might build a `bool` query, or a MongoDB adapter might produce a `find()` filter document — the Query objects stay the same regardless: +**Join filter hooks** inject per-join conditions with placement control (ON vs WHERE): ```php -class RedisAdapter +use Utopia\Query\Hook\Join\Filter as JoinFilter; +use Utopia\Query\Hook\Join\Condition as JoinCondition; +use Utopia\Query\Hook\Join\Placement; + +class ActiveJoinFilter implements JoinFilter { - /** - * @param array $queries - */ - public function find(string $key, array $queries): array + public function filterJoin(string $table, string $joinType): ?JoinCondition { - $grouped = Query::groupByType($queries); - - foreach ($grouped['filters'] as $filter) { - match ($filter->getMethod()) { - Query::TYPE_BETWEEN => $this->redis->zRangeByScore( - $key, - $filter->getValues()[0], - $filter->getValues()[1], - ), - Query::TYPE_GREATER => $this->redis->zRangeByScore( - $key, - '(' . $filter->getValue(), - '+inf', - ), - // ... handle other types - }; - } - - // ... + return new JoinCondition( + new Condition('active = ?', [1]), + $joinType === 'LEFT JOIN' ? Placement::On : Placement::Where, + ); } } ``` -This keeps your application code decoupled from any particular storage engine — swap adapters without changing a single query. +Built-in `Tenant` and `Permission` hooks implement both `Filter` and `JoinFilter` — they automatically apply ON placement for LEFT/RIGHT joins and WHERE placement for INNER/CROSS joins. -## Contributing +## Dialect-Specific Features -All code contributions should go through a pull request and be approved by a core developer before being merged. This is to ensure a proper review of all the code. +### MySQL -```bash -# Install dependencies -composer install +```php +use Utopia\Query\Builder\MySQL as Builder; +``` -# Run tests -composer test +**Spatial queries** — uses `ST_Distance()`, `ST_Intersects()`, `ST_Contains()`, etc.: + +```php +$result = (new Builder()) + ->from('stores') + ->filterDistance('location', [40.7128, -74.0060], '<', 5000, meters: true) + ->build(); + +// WHERE ST_Distance(ST_SRID(`location`, 4326), ST_GeomFromText(?, 4326), 'metre') < ? +``` -# Run linter -composer lint +All spatial predicates: `filterDistance`, `filterIntersects`, `filterNotIntersects`, `filterCrosses`, `filterNotCrosses`, `filterOverlaps`, `filterNotOverlaps`, `filterTouches`, `filterNotTouches`, `filterCovers`, `filterNotCovers`, `filterSpatialEquals`, `filterNotSpatialEquals`. -# Auto-format code -composer format +**JSON operations:** -# Run static analysis -composer check +```php +// Filtering +$result = (new Builder()) + ->from('products') + ->filterJsonContains('tags', 'sale') + ->filterJsonPath('metadata', 'color', '=', 'red') + ->build(); + +// WHERE JSON_CONTAINS(`tags`, ?) AND JSON_EXTRACT(`metadata`, '$.color') = ? + +// Mutations (in UPDATE) +$result = (new Builder()) + ->from('products') + ->filter([Query::equal('id', [1])]) + ->setJsonAppend('tags', ['new-tag']) + ->update(); +``` + +JSON mutation methods: `setJsonAppend`, `setJsonPrepend`, `setJsonInsert`, `setJsonRemove`, `setJsonIntersect`, `setJsonDiff`, `setJsonUnique`. + +**Query hints:** + +```php +$result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->maxExecutionTime(5000) + ->build(); + +// SELECT /*+ NO_INDEX_MERGE(users) max_execution_time(5000) */ * FROM `users` +``` + +**Full-text search** — `MATCH() AGAINST()`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE MATCH(`content`) AGAINST (?) +``` + +### PostgreSQL + +```php +use Utopia\Query\Builder\PostgreSQL as Builder; +``` + +**Spatial queries** — uses PostGIS functions with geography casting for meter-based distance: + +```php +$result = (new Builder()) + ->from('stores') + ->filterDistance('location', [40.7128, -74.0060], '<', 5000, meters: true) + ->build(); + +// WHERE ST_Distance(("location"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ? +``` + +**Vector search** — uses pgvector operators (`<=>`, `<->`, `<#>`): + +```php +$result = (new Builder()) + ->from('documents') + ->select(['title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], 'cosine') + ->limit(10) + ->build(); + +// SELECT "title" FROM "documents" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ? +``` + +Metrics: `cosine` (`<=>`), `euclidean` (`<->`), `dot` (`<#>`). + +**JSON operations** — uses native JSONB operators: + +```php +$result = (new Builder()) + ->from('products') + ->filterJsonContains('tags', 'sale') + ->build(); + +// WHERE "tags" @> ?::jsonb +``` + +**Full-text search** — `to_tsvector() @@ plainto_tsquery()`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE to_tsvector("content") @@ plainto_tsquery(?) +``` + +**Regex** — uses PostgreSQL `~` operator instead of `REGEXP`. + +### ClickHouse + +```php +use Utopia\Query\Builder\ClickHouse as Builder; +``` + +**FINAL** — force merging of data parts: + +```php +$result = (new Builder()) + ->from('events') + ->final() + ->build(); + +// SELECT * FROM `events` FINAL +``` + +**SAMPLE** — approximate query processing: + +```php +$result = (new Builder()) + ->from('events') + ->sample(0.1) + ->count('*', 'approx_total') + ->build(); + +// SELECT COUNT(*) AS `approx_total` FROM `events` SAMPLE 0.1 +``` + +**PREWHERE** — filter before reading columns (optimization for wide tables): + +```php +$result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + +// SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ? +``` + +**SETTINGS:** + +```php +$result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4', 'optimize_read_in_order' => '1']) + ->build(); + +// SELECT * FROM `events` SETTINGS max_threads=4, optimize_read_in_order=1 +``` + +**String matching** — uses native ClickHouse functions instead of LIKE: + +```php +// startsWith/endsWith → native functions +Query::startsWith('name', 'Al'); // startsWith(`name`, ?) +Query::endsWith('file', '.pdf'); // endsWith(`file`, ?) + +// contains/notContains → position() +Query::contains('tags', ['php']); // position(`tags`, ?) > 0 +``` + +**Regex** — uses `match()` function instead of `REGEXP`. + +**UPDATE/DELETE** — compiles to `ALTER TABLE ... UPDATE/DELETE` with mandatory WHERE: + +```php +$result = (new Builder()) + ->from('events') + ->set('status', 'archived') + ->filter([Query::lessThan('created_at', '2024-01-01')]) + ->update(); + +// ALTER TABLE `events` UPDATE `status` = ? WHERE `created_at` < ? +``` + +> **Note:** Full-text search (`Query::search()`) is not supported in ClickHouse and throws `UnsupportedException`. The ClickHouse builder also forces all join filter hook conditions to WHERE placement, since ClickHouse does not support subqueries in JOIN ON. + +### Feature Matrix + +Unsupported features are not on the class — consumers type-hint the interface to check capability (e.g., `if ($builder instanceof Spatial)`). + +| Feature | Builder | SQL | MySQL | PostgreSQL | ClickHouse | +|---------|:-------:|:---:|:-----:|:----------:|:----------:| +| Selects, Filters, Aggregates, Joins, Unions, CTEs, Inserts, Updates, Deletes, Hooks | x | | | | | +| Windows | x | | | | | +| Locking, Transactions, Upsert | | x | | | | +| Spatial | | | x | x | | +| Vector Search | | | | x | | +| JSON | | | x | x | | +| Hints | | | x | | x | +| PREWHERE, FINAL, SAMPLE | | | | | x | + +## Schema Builder + +The schema builder generates DDL statements for table creation, alteration, indexes, views, and more. + +```php +use Utopia\Query\Schema\MySQL as Schema; +// or: PostgreSQL, ClickHouse +``` + +### Creating Tables + +```php +$schema = new Schema(); + +$result = $schema->create('users', function ($table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + $table->integer('age')->nullable(); + $table->boolean('active')->default(true); + $table->json('metadata'); + $table->timestamps(); +}); + +$result->query; // CREATE TABLE `users` (...) +``` + +Available column types: `id`, `string`, `text`, `integer`, `bigInteger`, `float`, `boolean`, `datetime`, `timestamp`, `json`, `binary`, `enum`, `point`, `linestring`, `polygon`, `vector` (PostgreSQL only), `timestamps`. + +Column modifiers: `nullable()`, `default($value)`, `unsigned()`, `unique()`, `primary()`, `autoIncrement()`, `after($column)`, `comment($text)`. + +### Altering Tables + +```php +$result = $schema->alter('users', function ($table) { + $table->string('phone', 20)->nullable(); + $table->modifyColumn('name', 'string', 500); + $table->renameColumn('email', 'email_address'); + $table->dropColumn('legacy_field'); +}); +``` + +### Indexes + +```php +$result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); +$result = $schema->dropIndex('users', 'idx_email'); +``` + +PostgreSQL supports index methods and operator classes: + +```php +$schema = new \Utopia\Query\Schema\PostgreSQL(); + +// GIN trigram index +$result = $schema->createIndex('users', 'idx_name_trgm', ['name'], + method: 'gin', operatorClass: 'gin_trgm_ops'); + +// HNSW vector index +$result = $schema->createIndex('documents', 'idx_embedding', ['embedding'], + method: 'hnsw', operatorClass: 'vector_cosine_ops'); +``` + +### Foreign Keys + +```php +$result = $schema->addForeignKey('orders', 'fk_user', 'user_id', + 'users', 'id', onDelete: 'CASCADE'); + +$result = $schema->dropForeignKey('orders', 'fk_user'); +``` + +### Views + +```php +$query = (new Builder())->from('users')->filter([Query::equal('active', [true])]); + +$result = $schema->createView('active_users', $query); +$result = $schema->createOrReplaceView('active_users', $query); +$result = $schema->dropView('active_users'); +``` + +### Procedures and Triggers + +```php +// MySQL +$result = $schema->createProcedure('update_stats', ['IN user_id INT'], 'UPDATE stats SET count = count + 1 WHERE id = user_id;'); + +// Trigger +$result = $schema->createTrigger('before_insert_users', 'users', 'BEFORE', 'INSERT', 'SET NEW.created_at = NOW();'); +``` + +### PostgreSQL Schema Extensions + +```php +$schema = new \Utopia\Query\Schema\PostgreSQL(); + +// Extensions (e.g., pgvector, pg_trgm) +$result = $schema->createExtension('vector'); +// CREATE EXTENSION IF NOT EXISTS "vector" + +// Procedures → CREATE FUNCTION ... LANGUAGE plpgsql +$result = $schema->createProcedure('increment', ['p_id INTEGER'], ' +BEGIN + UPDATE counters SET value = value + 1 WHERE id = p_id; +END; +'); + +// DROP CONSTRAINT instead of DROP FOREIGN KEY +$result = $schema->dropForeignKey('orders', 'fk_user'); +// ALTER TABLE "orders" DROP CONSTRAINT "fk_user" + +// DROP INDEX without table name +$result = $schema->dropIndex('orders', 'idx_status'); +// DROP INDEX "idx_status" +``` + +Type differences from MySQL: `INTEGER` (not `INT`), `DOUBLE PRECISION` (not `DOUBLE`), `BOOLEAN` (not `TINYINT(1)`), `JSONB` (not `JSON`), `BYTEA` (not `BLOB`), `VECTOR(n)` for pgvector, `GEOMETRY(type, srid)` for PostGIS. Enums use `TEXT CHECK (col IN (...))`. Auto-increment uses `GENERATED BY DEFAULT AS IDENTITY`. + +### ClickHouse Schema + +```php +$schema = new \Utopia\Query\Schema\ClickHouse(); + +$result = $schema->create('events', function ($table) { + $table->string('event_id', 36)->primary(); + $table->string('event_type', 50); + $table->integer('count'); + $table->datetime('created_at'); +}); + +// CREATE TABLE `events` (...) ENGINE = MergeTree() ORDER BY (...) +``` + +ClickHouse uses `Nullable(type)` wrapping for nullable columns, `Enum8(...)` for enums, `Tuple(Float64, Float64)` for points, and `TYPE minmax GRANULARITY 3` for indexes. Foreign keys, stored procedures, and triggers throw `UnsupportedException`. + +## Compiler Interface + +The `Compiler` interface lets you build custom backends. Each `Query` dispatches to the correct compiler method via `$query->compile($compiler)`: + +```php +use Utopia\Query\Compiler; +use Utopia\Query\Query; +use Utopia\Query\Method; + +class MyCompiler implements Compiler +{ + public function compileFilter(Query $query): string { /* ... */ } + public function compileOrder(Query $query): string { /* ... */ } + public function compileLimit(Query $query): string { /* ... */ } + public function compileOffset(Query $query): string { /* ... */ } + public function compileSelect(Query $query): string { /* ... */ } + public function compileCursor(Query $query): string { /* ... */ } + public function compileAggregate(Query $query): string { /* ... */ } + public function compileGroupBy(Query $query): string { /* ... */ } + public function compileJoin(Query $query): string { /* ... */ } +} +``` + +This is the pattern used by [utopia-php/database](https://github.com/utopia-php/database) — it implements `Compiler` for each supported database engine, keeping application code decoupled from storage backends. + +## Contributing + +All code contributions should go through a pull request and be approved by a core developer before being merged. + +```bash +composer install # Install dependencies +composer test # Run tests +composer lint # Check formatting +composer format # Auto-format code +composer check # Run static analysis (PHPStan level max) ``` ## License diff --git a/composer.json b/composer.json index e645108..9886c69 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,13 @@ }, "autoload-dev": { "psr-4": { - "Tests\\Query\\": "tests/Query" + "Tests\\Query\\": "tests/Query", + "Tests\\Integration\\": "tests/Integration" } }, "scripts": { - "test": "vendor/bin/phpunit --configuration phpunit.xml", + "test": "vendor/bin/phpunit --testsuite Query", + "test:integration": "vendor/bin/phpunit --testsuite Integration", "lint": "php -d memory_limit=2G ./vendor/bin/pint --test", "format": "php -d memory_limit=2G ./vendor/bin/pint", "check": "./vendor/bin/phpstan analyse --level max src tests --memory-limit 2G" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..344101b --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,28 @@ +services: + mysql: + image: mysql:8.4 + ports: + - "13306:3306" + environment: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: query_test + tmpfs: + - /var/lib/mysql + + postgres: + image: postgres:16 + ports: + - "15432:5432" + environment: + POSTGRES_PASSWORD: test + POSTGRES_DB: query_test + tmpfs: + - /var/lib/postgresql/data + + clickhouse: + image: clickhouse/clickhouse-server:24 + ports: + - "18123:8123" + - "19000:9000" + tmpfs: + - /var/lib/clickhouse diff --git a/phpunit.xml b/phpunit.xml index 2ac99d0..2536aa0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,7 +6,10 @@ bootstrap="vendor/autoload.php"> - tests + tests/Query + + + tests/Integration diff --git a/src/Query/Builder.php b/src/Query/Builder.php new file mode 100644 index 0000000..d303000 --- /dev/null +++ b/src/Query/Builder.php @@ -0,0 +1,1935 @@ + + */ + protected array $pendingQueries = []; + + /** + * @var list + */ + protected array $bindings = []; + + /** + * @var list + */ + protected array $unions = []; + + /** @var list */ + protected array $filterHooks = []; + + /** @var list */ + protected array $attributeHooks = []; + + /** @var list */ + protected array $joinFilterHooks = []; + + /** @var list> */ + protected array $pendingRows = []; + + /** @var array */ + protected array $rawSets = []; + + /** @var array> */ + protected array $rawSetBindings = []; + + protected ?LockMode $lockMode = null; + + protected ?string $lockOfTable = null; + + protected ?Builder $insertSelectSource = null; + + /** @var list */ + protected array $insertSelectColumns = []; + + /** @var list */ + protected array $ctes = []; + + /** @var list */ + protected array $rawSelects = []; + + /** @var list */ + protected array $windowSelects = []; + + /** @var list */ + protected array $caseSelects = []; + + /** @var array */ + protected array $caseSets = []; + + /** @var string[] */ + protected array $conflictKeys = []; + + /** @var string[] */ + protected array $conflictUpdateColumns = []; + + /** @var array */ + protected array $conflictRawSets = []; + + /** @var array> */ + protected array $conflictRawSetBindings = []; + + /** @var array Column-specific expressions for INSERT (e.g. 'location' => 'ST_GeomFromText(?)') */ + protected array $insertColumnExpressions = []; + + /** @var array> Extra bindings for insert column expressions */ + protected array $insertColumnExpressionBindings = []; + + protected string $insertAlias = ''; + + /** @var list */ + protected array $whereInSubqueries = []; + + /** @var list */ + protected array $subSelects = []; + + protected ?SubSelect $fromSubquery = null; + + protected bool $noTable = false; + + /** @var list */ + protected array $rawOrders = []; + + /** @var list */ + protected array $rawGroups = []; + + /** @var list */ + protected array $rawHavings = []; + + /** @var array */ + protected array $joinBuilders = []; + + /** @var list */ + protected array $existsSubqueries = []; + + abstract protected function quote(string $identifier): string; + + /** + * Compile a random ordering expression (e.g. RAND() or rand()) + */ + abstract protected function compileRandom(): string; + + /** + * Compile a regex filter + * + * @param array $values + */ + abstract protected function compileRegex(string $attribute, array $values): string; + + /** + * Compile a full-text search filter + * + * @param array $values + */ + abstract protected function compileSearch(string $attribute, array $values, bool $not): string; + + protected function buildTableClause(): string + { + if ($this->noTable) { + return ''; + } + + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub->subquery->build(); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub->alias); + } + + $sql = 'FROM ' . $this->quote($this->table); + + if ($this->tableAlias !== '') { + $sql .= ' AS ' . $this->quote($this->tableAlias); + } + + return $sql; + } + + /** + * Hook called after JOIN clauses, before WHERE. Override to inject e.g. PREWHERE. + * + * @param array $parts + */ + protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void + { + // no-op by default + } + + public function from(string $table, string $alias = ''): static + { + $this->table = $table; + $this->tableAlias = $alias; + $this->fromSubquery = null; + $this->noTable = false; + + return $this; + } + + /** + * Build a query without a FROM clause (e.g. SELECT 1, SELECT CONNECTION_ID()). + */ + public function fromNone(): static + { + $this->noTable = true; + $this->table = ''; + $this->tableAlias = ''; + $this->fromSubquery = null; + + return $this; + } + + public function into(string $table): static + { + $this->table = $table; + + return $this; + } + + /** + * Set an alias for the INSERT target table (e.g. INSERT INTO table AS alias). + * Used by PostgreSQL ON CONFLICT to reference the existing row. + */ + public function insertAs(string $alias): static + { + $this->insertAlias = $alias; + + return $this; + } + + /** + * @param array $row + */ + public function set(array $row): static + { + $this->pendingRows[] = $row; + + return $this; + } + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static + { + $this->rawSets[$column] = $expression; + $this->rawSetBindings[$column] = $bindings; + + return $this; + } + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + public function onConflict(array $keys, array $updateColumns): static + { + $this->conflictKeys = $keys; + $this->conflictUpdateColumns = $updateColumns; + + return $this; + } + + /** + * @param list $bindings + */ + public function conflictSetRaw(string $column, string $expression, array $bindings = []): static + { + $this->conflictRawSets[$column] = $expression; + $this->conflictRawSetBindings[$column] = $bindings; + + return $this; + } + + /** + * Register a raw expression wrapper for a column in INSERT statements. + * + * The expression must contain exactly one `?` placeholder which will receive + * the column's value from each row. E.g. `ST_GeomFromText(?, 4326)`. + * + * @param list $extraBindings Additional bindings beyond the column value (e.g. SRID) + */ + public function insertColumnExpression(string $column, string $expression, array $extraBindings = []): static + { + $this->insertColumnExpressions[$column] = $expression; + if (! empty($extraBindings)) { + $this->insertColumnExpressionBindings[$column] = $extraBindings; + } + + return $this; + } + + public function filterWhereIn(string $column, Builder $subquery): static + { + $this->whereInSubqueries[] = new WhereInSubquery($column, $subquery, false); + + return $this; + } + + public function filterWhereNotIn(string $column, Builder $subquery): static + { + $this->whereInSubqueries[] = new WhereInSubquery($column, $subquery, true); + + return $this; + } + + public function selectSub(Builder $subquery, string $alias): static + { + $this->subSelects[] = new SubSelect($subquery, $alias); + + return $this; + } + + public function fromSub(Builder $subquery, string $alias): static + { + $this->fromSubquery = new SubSelect($subquery, $alias); + $this->table = ''; + + return $this; + } + + /** + * @param list $bindings + */ + public function orderByRaw(string $expression, array $bindings = []): static + { + $this->rawOrders[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * @param list $bindings + */ + public function groupByRaw(string $expression, array $bindings = []): static + { + $this->rawGroups[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * @param list $bindings + */ + public function havingRaw(string $expression, array $bindings = []): static + { + $this->rawHavings[] = new Condition($expression, $bindings); + + return $this; + } + + public function countDistinct(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::countDistinct($attribute, $alias); + + return $this; + } + + /** + * @param \Closure(JoinBuilder): void $callback + */ + public function joinWhere(string $table, Closure $callback, JoinType $type = JoinType::Inner, string $alias = ''): static + { + $joinBuilder = new JoinBuilder(); + $callback($joinBuilder); + + $method = match ($type) { + JoinType::Left => Method::LeftJoin, + JoinType::Right => Method::RightJoin, + JoinType::Cross => Method::CrossJoin, + default => Method::Join, + }; + + if ($method === Method::CrossJoin) { + $this->pendingQueries[] = new Query($method, $table, $alias !== '' ? [$alias] : []); + } else { + // Use placeholder values; the JoinBuilder will handle the ON clause + $values = ['', '=', '']; + if ($alias !== '') { + $values[] = $alias; + } + $this->pendingQueries[] = new Query($method, $table, $values); + } + + $index = \count($this->pendingQueries) - 1; + $this->joinBuilders[$index] = $joinBuilder; + + return $this; + } + + public function filterExists(Builder $subquery): static + { + $this->existsSubqueries[] = new ExistsSubquery($subquery, false); + + return $this; + } + + public function filterNotExists(Builder $subquery): static + { + $this->existsSubqueries[] = new ExistsSubquery($subquery, true); + + return $this; + } + + public function explain(bool $analyze = false): BuildResult + { + $result = $this->build(); + $prefix = $analyze ? 'EXPLAIN ANALYZE ' : 'EXPLAIN '; + + return new BuildResult($prefix . $result->query, $result->bindings); + } + + /** + * @param array $columns + */ + public function select(array $columns): static + { + $this->pendingQueries[] = Query::select($columns); + + return $this; + } + + /** + * @param array $queries + */ + public function filter(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + public function sortAsc(string $attribute): static + { + $this->pendingQueries[] = Query::orderAsc($attribute); + + return $this; + } + + public function sortDesc(string $attribute): static + { + $this->pendingQueries[] = Query::orderDesc($attribute); + + return $this; + } + + public function sortRandom(): static + { + $this->pendingQueries[] = Query::orderRandom(); + + return $this; + } + + public function limit(int $value): static + { + $this->pendingQueries[] = Query::limit($value); + + return $this; + } + + public function offset(int $value): static + { + $this->pendingQueries[] = Query::offset($value); + + return $this; + } + + public function cursorAfter(mixed $value): static + { + $this->pendingQueries[] = Query::cursorAfter($value); + + return $this; + } + + public function cursorBefore(mixed $value): static + { + $this->pendingQueries[] = Query::cursorBefore($value); + + return $this; + } + + /** + * @param array $queries + */ + public function queries(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + public function addHook(Hook $hook): static + { + if ($hook instanceof Filter) { + $this->filterHooks[] = $hook; + } + if ($hook instanceof Attribute) { + $this->attributeHooks[] = $hook; + } + if ($hook instanceof JoinFilter) { + $this->joinFilterHooks[] = $hook; + } + + return $this; + } + + public function count(string $attribute = '*', string $alias = ''): static + { + $this->pendingQueries[] = Query::count($attribute, $alias); + + return $this; + } + + public function sum(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::sum($attribute, $alias); + + return $this; + } + + public function avg(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::avg($attribute, $alias); + + return $this; + } + + public function min(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::min($attribute, $alias); + + return $this; + } + + public function max(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::max($attribute, $alias); + + return $this; + } + + /** + * @param array $columns + */ + public function groupBy(array $columns): static + { + $this->pendingQueries[] = Query::groupBy($columns); + + return $this; + } + + /** + * @param array $queries + */ + public function having(array $queries): static + { + $this->pendingQueries[] = Query::having($queries); + + return $this; + } + + public function distinct(): static + { + $this->pendingQueries[] = Query::distinct(); + + return $this; + } + + public function join(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $this->pendingQueries[] = Query::join($table, $left, $right, $operator, $alias); + + return $this; + } + + public function leftJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $this->pendingQueries[] = Query::leftJoin($table, $left, $right, $operator, $alias); + + return $this; + } + + public function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $this->pendingQueries[] = Query::rightJoin($table, $left, $right, $operator, $alias); + + return $this; + } + + public function crossJoin(string $table, string $alias = ''): static + { + $this->pendingQueries[] = Query::crossJoin($table, $alias); + + return $this; + } + + public function union(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::Union, $result->query, $result->bindings); + + return $this; + } + + public function unionAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::UnionAll, $result->query, $result->bindings); + + return $this; + } + + public function intersect(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::Intersect, $result->query, $result->bindings); + + return $this; + } + + public function intersectAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::IntersectAll, $result->query, $result->bindings); + + return $this; + } + + public function except(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::Except, $result->query, $result->bindings); + + return $this; + } + + public function exceptAll(self $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::ExceptAll, $result->query, $result->bindings); + + return $this; + } + + /** + * @param list $columns + */ + public function fromSelect(array $columns, self $source): static + { + $this->insertSelectColumns = $columns; + $this->insertSelectSource = $source; + + return $this; + } + + public function insertSelect(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + if ($this->insertSelectSource === null) { + throw new ValidationException('No SELECT source specified. Call fromSelect() before insertSelect().'); + } + + if (empty($this->insertSelectColumns)) { + throw new ValidationException('No columns specified. Call fromSelect() with columns before insertSelect().'); + } + + $wrappedColumns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $this->insertSelectColumns + ); + + $sourceResult = $this->insertSelectSource->build(); + + $sql = 'INSERT INTO ' . $this->quote($this->table) + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' ' . $sourceResult->query; + + foreach ($sourceResult->bindings as $binding) { + $this->addBinding($binding); + } + + return new BuildResult($sql, $this->bindings); + } + + public function with(string $name, self $query): static + { + $result = $query->build(); + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, false); + + return $this; + } + + public function withRecursive(string $name, self $query): static + { + $result = $query->build(); + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, true); + + return $this; + } + + /** + * @param list $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static + { + $this->rawSelects[] = new Condition($expression, $bindings); + + return $this; + } + + public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null): static + { + $this->windowSelects[] = new WindowSelect($function, $alias, $partitionBy, $orderBy); + + return $this; + } + + public function selectCase(CaseExpression $case): static + { + $this->caseSelects[] = $case; + + return $this; + } + + public function setCase(string $column, CaseExpression $case): static + { + $this->caseSets[$column] = $case; + + return $this; + } + + public function when(bool $condition, Closure $callback): static + { + if ($condition) { + $callback($this); + } + + return $this; + } + + public function page(int $page, int $perPage = 25): static + { + if ($page < 1) { + throw new ValidationException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new ValidationException('Per page must be >= 1, got ' . $perPage); + } + + $this->pendingQueries[] = Query::limit($perPage); + $this->pendingQueries[] = Query::offset(($page - 1) * $perPage); + + return $this; + } + + public function toRawSql(): string + { + $result = $this->build(); + $sql = $result->query; + $offset = 0; + + foreach ($result->bindings as $binding) { + if (\is_string($binding)) { + $value = "'" . str_replace("'", "''", $binding) . "'"; + } elseif (\is_int($binding) || \is_float($binding)) { + $value = (string) $binding; + } elseif (\is_bool($binding)) { + $value = $binding ? '1' : '0'; + } else { + $value = 'NULL'; + } + + $pos = \strpos($sql, '?', $offset); + if ($pos !== false) { + $sql = \substr_replace($sql, $value, $pos, 1); + $offset = $pos + \strlen($value); + } + } + + return $sql; + } + + public function build(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + // CTE prefix + $ctePrefix = ''; + if (! empty($this->ctes)) { + $hasRecursive = false; + $cteParts = []; + foreach ($this->ctes as $cte) { + if ($cte->recursive) { + $hasRecursive = true; + } + foreach ($cte->bindings as $binding) { + $this->addBinding($binding); + } + $cteParts[] = $this->quote($cte->name) . ' AS (' . $cte->query . ')'; + } + $keyword = $hasRecursive ? 'WITH RECURSIVE' : 'WITH'; + $ctePrefix = $keyword . ' ' . \implode(', ', $cteParts) . ' '; + } + + $grouped = Query::groupByType($this->pendingQueries); + + $parts = []; + + // SELECT + $selectParts = []; + + if (! empty($grouped->aggregations)) { + foreach ($grouped->aggregations as $agg) { + $selectParts[] = $this->compileAggregate($agg); + } + } + + if (! empty($grouped->selections)) { + $selectParts[] = $this->compileSelect($grouped->selections[0]); + } + + // Sub-selects + foreach ($this->subSelects as $subSelect) { + $subResult = $subSelect->subquery->build(); + $selectParts[] = '(' . $subResult->query . ') AS ' . $this->quote($subSelect->alias); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // Raw selects + foreach ($this->rawSelects as $rawSelect) { + $selectParts[] = $rawSelect->expression; + foreach ($rawSelect->bindings as $binding) { + $this->addBinding($binding); + } + } + + // Window function selects + foreach ($this->windowSelects as $win) { + $overParts = []; + + if ($win->partitionBy !== null && $win->partitionBy !== []) { + $partCols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $win->partitionBy + ); + $overParts[] = 'PARTITION BY ' . \implode(', ', $partCols); + } + + if ($win->orderBy !== null && $win->orderBy !== []) { + $orderCols = []; + foreach ($win->orderBy as $col) { + if (\str_starts_with($col, '-')) { + $orderCols[] = $this->resolveAndWrap(\substr($col, 1)) . ' DESC'; + } else { + $orderCols[] = $this->resolveAndWrap($col) . ' ASC'; + } + } + $overParts[] = 'ORDER BY ' . \implode(', ', $orderCols); + } + + $overClause = \implode(' ', $overParts); + $selectParts[] = $win->function . ' OVER (' . $overClause . ') AS ' . $this->quote($win->alias); + } + + // CASE selects + foreach ($this->caseSelects as $caseSelect) { + $selectParts[] = $caseSelect->sql; + foreach ($caseSelect->bindings as $binding) { + $this->addBinding($binding); + } + } + + $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; + + $selectKeyword = $grouped->distinct ? 'SELECT DISTINCT' : 'SELECT'; + $parts[] = $selectKeyword . ' ' . $selectSQL; + + // FROM + $tableClause = $this->buildTableClause(); + if ($tableClause !== '') { + $parts[] = $tableClause; + } + + // JOINS + $joinFilterWhereClauses = []; + if (! empty($grouped->joins)) { + // Build a map from pending query index to join index for JoinBuilder lookup + $joinQueryIndices = []; + foreach ($this->pendingQueries as $idx => $pq) { + if ($pq->getMethod()->isJoin()) { + $joinQueryIndices[] = $idx; + } + } + + foreach ($grouped->joins as $joinIdx => $joinQuery) { + $pendingIdx = $joinQueryIndices[$joinIdx] ?? -1; + $joinBuilder = $this->joinBuilders[$pendingIdx] ?? null; + + if ($joinBuilder !== null) { + $joinSQL = $this->compileJoinWithBuilder($joinQuery, $joinBuilder); + } else { + $joinSQL = $this->compileJoin($joinQuery); + } + + $joinTable = $joinQuery->getAttribute(); + $joinType = match ($joinQuery->getMethod()) { + Method::Join => JoinType::Inner, + Method::LeftJoin => JoinType::Left, + Method::RightJoin => JoinType::Right, + Method::CrossJoin => JoinType::Cross, + default => JoinType::Inner, + }; + $isCrossJoin = $joinType === JoinType::Cross; + + foreach ($this->joinFilterHooks as $hook) { + $result = $hook->filterJoin($joinTable, $joinType); + if ($result === null) { + continue; + } + + $placement = $this->resolveJoinFilterPlacement($result->placement, $isCrossJoin); + + if ($placement === Placement::On) { + $joinSQL .= ' AND ' . $result->condition->expression; + foreach ($result->condition->bindings as $binding) { + $this->addBinding($binding); + } + } else { + $joinFilterWhereClauses[] = $result->condition; + } + } + + $parts[] = $joinSQL; + } + } + + // Hook: after joins (e.g. ClickHouse PREWHERE) + $this->buildAfterJoins($parts, $grouped); + + // WHERE + $whereClauses = []; + + foreach ($grouped->filters as $filter) { + $whereClauses[] = $this->compileFilter($filter); + } + + foreach ($this->filterHooks as $hook) { + $condition = $hook->filter($this->table); + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { + $this->addBinding($binding); + } + } + + foreach ($joinFilterWhereClauses as $condition) { + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { + $this->addBinding($binding); + } + } + + // WHERE IN subqueries + foreach ($this->whereInSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub->column) . ' ' . $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // EXISTS subqueries + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + $cursorSQL = ''; + if ($grouped->cursor !== null && $grouped->cursorDirection !== null) { + $cursorQueries = Query::getCursorQueries($this->pendingQueries, false); + if (! empty($cursorQueries)) { + $cursorSQL = $this->compileCursor($cursorQueries[0]); + } + } + if ($cursorSQL !== '') { + $whereClauses[] = $cursorSQL; + } + + if (! empty($whereClauses)) { + $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); + } + + // GROUP BY + $groupByParts = []; + if (! empty($grouped->groupBy)) { + $groupByCols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $grouped->groupBy + ); + $groupByParts = $groupByCols; + } + foreach ($this->rawGroups as $rawGroup) { + $groupByParts[] = $rawGroup->expression; + foreach ($rawGroup->bindings as $binding) { + $this->addBinding($binding); + } + } + if (! empty($groupByParts)) { + $parts[] = 'GROUP BY ' . \implode(', ', $groupByParts); + } + + // HAVING + $havingClauses = []; + if (! empty($grouped->having)) { + foreach ($grouped->having as $havingQuery) { + foreach ($havingQuery->getValues() as $subQuery) { + /** @var Query $subQuery */ + $havingClauses[] = $this->compileFilter($subQuery); + } + } + } + foreach ($this->rawHavings as $rawHaving) { + $havingClauses[] = $rawHaving->expression; + foreach ($rawHaving->bindings as $binding) { + $this->addBinding($binding); + } + } + if (! empty($havingClauses)) { + $parts[] = 'HAVING ' . \implode(' AND ', $havingClauses); + } + + // ORDER BY + $orderClauses = []; + + $vectorOrderExpr = $this->compileVectorOrderExpr(); + if ($vectorOrderExpr !== null) { + $orderClauses[] = $vectorOrderExpr->expression; + foreach ($vectorOrderExpr->bindings as $binding) { + $this->addBinding($binding); + } + } + + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], false); + foreach ($orderQueries as $orderQuery) { + $orderClauses[] = $this->compileOrder($orderQuery); + } + foreach ($this->rawOrders as $rawOrder) { + $orderClauses[] = $rawOrder->expression; + foreach ($rawOrder->bindings as $binding) { + $this->addBinding($binding); + } + } + if (! empty($orderClauses)) { + $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); + } + + // LIMIT + if ($grouped->limit !== null) { + $parts[] = 'LIMIT ?'; + $this->addBinding($grouped->limit); + } + + // OFFSET + if ($this->shouldEmitOffset($grouped->offset, $grouped->limit)) { + $parts[] = 'OFFSET ?'; + $this->addBinding($grouped->offset); + } + + // LOCKING + if ($this->lockMode !== null) { + $lockSql = $this->lockMode->toSql(); + if ($this->lockOfTable !== null) { + $lockSql .= ' OF ' . $this->quote($this->lockOfTable); + } + $parts[] = $lockSql; + } + + $sql = \implode(' ', $parts); + + // UNION + if (!empty($this->unions)) { + $sql = '(' . $sql . ')'; + } + foreach ($this->unions as $union) { + $sql .= ' ' . $union->type->value . ' (' . $union->query . ')'; + foreach ($union->bindings as $binding) { + $this->addBinding($binding); + } + } + + $sql = $ctePrefix . $sql; + + return new BuildResult($sql, $this->bindings); + } + + /** + * Compile the INSERT INTO ... VALUES portion. + * + * @return array{0: string, 1: list} + */ + protected function compileInsertBody(): array + { + $this->validateTable(); + $this->validateRows('insert'); + $columns = $this->validateAndGetColumns(); + + $wrappedColumns = \array_map(fn (string $col): string => $this->resolveAndWrap($col), $columns); + + $bindings = []; + $rowPlaceholders = []; + foreach ($this->pendingRows as $row) { + $placeholders = []; + foreach ($columns as $col) { + $bindings[] = $row[$col] ?? null; + if (isset($this->insertColumnExpressions[$col])) { + $placeholders[] = $this->insertColumnExpressions[$col]; + foreach ($this->insertColumnExpressionBindings[$col] ?? [] as $extra) { + $bindings[] = $extra; + } + } else { + $placeholders[] = '?'; + } + } + $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; + } + + $tablePart = $this->quote($this->table); + if ($this->insertAlias !== '') { + $tablePart .= ' AS ' . $this->insertAlias; + } + + $sql = 'INSERT INTO ' . $tablePart + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); + + return [$sql, $bindings]; + } + + public function insert(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + return new BuildResult($sql, $this->bindings); + } + + public function update(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $assignments = []; + + if (! empty($this->pendingRows)) { + foreach ($this->pendingRows[0] as $col => $value) { + $assignments[] = $this->resolveAndWrap($col) . ' = ?'; + $this->addBinding($value); + } + } + + foreach ($this->rawSets as $col => $expression) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $expression; + if (isset($this->rawSetBindings[$col])) { + foreach ($this->rawSetBindings[$col] as $binding) { + $this->addBinding($binding); + } + } + } + + foreach ($this->caseSets as $col => $caseData) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData->sql; + foreach ($caseData->bindings as $binding) { + $this->addBinding($binding); + } + } + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $parts = ['UPDATE ' . $this->quote($this->table) . ' SET ' . \implode(', ', $assignments)]; + + $this->compileWhereClauses($parts); + + $this->compileOrderAndLimit($parts); + + return new BuildResult(\implode(' ', $parts), $this->bindings); + } + + public function delete(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $parts = ['DELETE FROM ' . $this->quote($this->table)]; + + $this->compileWhereClauses($parts); + + $this->compileOrderAndLimit($parts); + + return new BuildResult(\implode(' ', $parts), $this->bindings); + } + + /** + * @param array $parts + */ + protected function compileWhereClauses(array &$parts): void + { + $grouped = Query::groupByType($this->pendingQueries); + $whereClauses = []; + + foreach ($grouped->filters as $filter) { + $whereClauses[] = $this->compileFilter($filter); + } + + foreach ($this->filterHooks as $hook) { + $condition = $hook->filter($this->table); + $whereClauses[] = $condition->expression; + foreach ($condition->bindings as $binding) { + $this->addBinding($binding); + } + } + + // WHERE IN subqueries + foreach ($this->whereInSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub->column) . ' ' . $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + // EXISTS subqueries + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + } + + if (! empty($whereClauses)) { + $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); + } + } + + /** + * @param array $parts + */ + protected function compileOrderAndLimit(array &$parts): void + { + $orderClauses = []; + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], false); + foreach ($orderQueries as $orderQuery) { + $orderClauses[] = $this->compileOrder($orderQuery); + } + foreach ($this->rawOrders as $rawOrder) { + $orderClauses[] = $rawOrder->expression; + foreach ($rawOrder->bindings as $binding) { + $this->addBinding($binding); + } + } + if (! empty($orderClauses)) { + $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); + } + + $grouped = Query::groupByType($this->pendingQueries); + if ($grouped->limit !== null) { + $parts[] = 'LIMIT ?'; + $this->addBinding($grouped->limit); + } + } + + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + return $offset !== null && $limit !== null; + } + + /** + * Hook for subclasses to inject a vector distance ORDER BY expression. + */ + protected function compileVectorOrderExpr(): ?Condition + { + return null; + } + + protected function validateTable(): void + { + if ($this->noTable) { + return; + } + if ($this->table === '' && $this->fromSubquery === null) { + throw new ValidationException('No table specified. Call from() or into() before building a query.'); + } + } + + protected function validateRows(string $operation): void + { + if (empty($this->pendingRows)) { + throw new ValidationException("No rows to {$operation}. Call set() before {$operation}()."); + } + + foreach ($this->pendingRows as $row) { + if (empty($row)) { + throw new ValidationException('Cannot ' . $operation . ' an empty row. Each set() call must include at least one column.'); + } + } + } + + /** + * Validates that all rows have the same columns and returns the column list. + * + * @return list + */ + protected function validateAndGetColumns(): array + { + $columns = \array_keys($this->pendingRows[0]); + + foreach ($columns as $col) { + if ($col === '') { + throw new ValidationException('Column names must be non-empty strings.'); + } + } + + if (\count($this->pendingRows) > 1) { + $expectedKeys = $columns; + \sort($expectedKeys); + + foreach ($this->pendingRows as $i => $row) { + $rowKeys = \array_keys($row); + \sort($rowKeys); + + if ($rowKeys !== $expectedKeys) { + throw new ValidationException("Row {$i} has different columns than row 0. All rows in a batch must have the same columns."); + } + } + } + + return $columns; + } + + /** + * @return list + */ + public function getBindings(): array + { + return $this->bindings; + } + + public function reset(): static + { + $this->pendingQueries = []; + $this->bindings = []; + $this->table = ''; + $this->tableAlias = ''; + $this->unions = []; + $this->pendingRows = []; + $this->rawSets = []; + $this->rawSetBindings = []; + $this->conflictKeys = []; + $this->conflictUpdateColumns = []; + $this->conflictRawSets = []; + $this->conflictRawSetBindings = []; + $this->insertColumnExpressions = []; + $this->insertColumnExpressionBindings = []; + $this->insertAlias = ''; + $this->lockMode = null; + $this->lockOfTable = null; + $this->insertSelectSource = null; + $this->insertSelectColumns = []; + $this->ctes = []; + $this->rawSelects = []; + $this->windowSelects = []; + $this->caseSelects = []; + $this->caseSets = []; + $this->whereInSubqueries = []; + $this->subSelects = []; + $this->fromSubquery = null; + $this->noTable = false; + $this->rawOrders = []; + $this->rawGroups = []; + $this->rawHavings = []; + $this->joinBuilders = []; + $this->existsSubqueries = []; + + return $this; + } + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + $values = $query->getValues(); + + return match ($method) { + Method::Equal => $this->compileIn($attribute, $values), + Method::NotEqual => $this->compileNotIn($attribute, $values), + Method::LessThan => $this->compileComparison($attribute, '<', $values), + Method::LessThanEqual => $this->compileComparison($attribute, '<=', $values), + Method::GreaterThan => $this->compileComparison($attribute, '>', $values), + Method::GreaterThanEqual => $this->compileComparison($attribute, '>=', $values), + Method::Between => $this->compileBetween($attribute, $values, false), + Method::NotBetween => $this->compileBetween($attribute, $values, true), + Method::StartsWith => $this->compileLike($attribute, $values, '', '%', false), + Method::NotStartsWith => $this->compileLike($attribute, $values, '', '%', true), + Method::EndsWith => $this->compileLike($attribute, $values, '%', '', false), + Method::NotEndsWith => $this->compileLike($attribute, $values, '%', '', true), + Method::Contains => $this->compileContains($attribute, $values), + Method::ContainsAny => $this->compileIn($attribute, $values), + Method::ContainsAll => $this->compileContainsAll($attribute, $values), + Method::NotContains => $this->compileNotContains($attribute, $values), + Method::Search => $this->compileSearch($attribute, $values, false), + Method::NotSearch => $this->compileSearch($attribute, $values, true), + Method::Regex => $this->compileRegex($attribute, $values), + Method::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + Method::And => $this->compileLogical($query, 'AND'), + Method::Or => $this->compileLogical($query, 'OR'), + Method::Having => $this->compileLogical($query, 'AND'), + Method::Exists => $this->compileExists($query), + Method::NotExists => $this->compileNotExists($query), + Method::Raw => $this->compileRaw($query), + default => throw new UnsupportedException('Unsupported filter type: ' . $method->value), + }; + } + + public function compileOrder(Query $query): string + { + return match ($query->getMethod()) { + Method::OrderAsc => $this->resolveAndWrap($query->getAttribute()) . ' ASC', + Method::OrderDesc => $this->resolveAndWrap($query->getAttribute()) . ' DESC', + Method::OrderRandom => $this->compileRandom(), + default => throw new UnsupportedException('Unsupported order type: ' . $query->getMethod()->value), + }; + } + + public function compileLimit(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'LIMIT ?'; + } + + public function compileOffset(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'OFFSET ?'; + } + + public function compileSelect(Query $query): string + { + /** @var array $values */ + $values = $query->getValues(); + $columns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $values + ); + + return \implode(', ', $columns); + } + + public function compileCursor(Query $query): string + { + $value = $query->getValue(); + $this->addBinding($value); + + $operator = $query->getMethod() === Method::CursorAfter ? '>' : '<'; + + return $this->quote('_cursor') . ' ' . $operator . ' ?'; + } + + public function compileAggregate(Query $query): string + { + $method = $query->getMethod(); + + if ($method === Method::CountDistinct) { + $attr = $query->getAttribute(); + $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); + /** @var string $alias */ + $alias = $query->getValue(''); + $sql = 'COUNT(DISTINCT ' . $col . ')'; + + if ($alias !== '') { + $sql .= ' AS ' . $this->quote($alias); + } + + return $sql; + } + + $func = match ($method) { + Method::Count => 'COUNT', + Method::Sum => 'SUM', + Method::Avg => 'AVG', + Method::Min => 'MIN', + Method::Max => 'MAX', + default => throw new ValidationException("Unknown aggregate: {$method->value}"), + }; + $attr = $query->getAttribute(); + $col = ($attr === '*' || $attr === '') ? '*' : $this->resolveAndWrap($attr); + /** @var string $alias */ + $alias = $query->getValue(''); + $sql = $func . '(' . $col . ')'; + + if ($alias !== '') { + $sql .= ' AS ' . $this->quote($alias); + } + + return $sql; + } + + public function compileGroupBy(Query $query): string + { + /** @var array $values */ + $values = $query->getValues(); + $columns = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $values + ); + + return \implode(', ', $columns); + } + + public function compileJoin(Query $query): string + { + $type = match ($query->getMethod()) { + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + default => throw new UnsupportedException('Unsupported join type: ' . $query->getMethod()->value), + }; + + $table = $this->quote($query->getAttribute()); + $values = $query->getValues(); + + // Handle alias for cross join (alias is values[0]) + if ($query->getMethod() === Method::CrossJoin) { + /** @var string $alias */ + $alias = $values[0] ?? ''; + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + return $type . ' ' . $table; + } + + if (empty($values)) { + return $type . ' ' . $table; + } + + /** @var string $leftCol */ + $leftCol = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + /** @var string $rightCol */ + $rightCol = $values[2]; + /** @var string $alias */ + $alias = $values[3] ?? ''; + + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (! \in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + $left = $this->resolveAndWrap($leftCol); + $right = $this->resolveAndWrap($rightCol); + + return $type . ' ' . $table . ' ON ' . $left . ' ' . $operator . ' ' . $right; + } + + protected function compileJoinWithBuilder(Query $query, JoinBuilder $joinBuilder): string + { + $type = match ($query->getMethod()) { + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + default => throw new UnsupportedException('Unsupported join type: ' . $query->getMethod()->value), + }; + + $table = $this->quote($query->getAttribute()); + $values = $query->getValues(); + + // Handle alias + if ($query->getMethod() === Method::CrossJoin) { + /** @var string $alias */ + $alias = $values[0] ?? ''; + } else { + /** @var string $alias */ + $alias = $values[3] ?? ''; + } + + if ($alias !== '') { + $table .= ' AS ' . $this->quote($alias); + } + + $onParts = []; + + foreach ($joinBuilder->ons as $on) { + $left = $this->resolveAndWrap($on->left); + $right = $this->resolveAndWrap($on->right); + $onParts[] = $left . ' ' . $on->operator . ' ' . $right; + } + + foreach ($joinBuilder->wheres as $where) { + $onParts[] = $where->expression; + foreach ($where->bindings as $binding) { + $this->addBinding($binding); + } + } + + if (empty($onParts)) { + return $type . ' ' . $table; + } + + return $type . ' ' . $table . ' ON ' . \implode(' AND ', $onParts); + } + + protected function resolveAttribute(string $attribute): string + { + foreach ($this->attributeHooks as $hook) { + $attribute = $hook->resolve($attribute); + } + + return $attribute; + } + + protected function resolveAndWrap(string $attribute): string + { + return $this->quote($this->resolveAttribute($attribute)); + } + + protected function addBinding(mixed $value): void + { + $this->bindings[] = $value; + } + + /** + * @param array $values + */ + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + { + /** @var string $rawVal */ + $rawVal = $values[0]; + $val = $this->escapeLikeValue($rawVal); + $this->addBinding($prefix . $val . $suffix); + $keyword = $not ? 'NOT LIKE' : 'LIKE'; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * @param array $values + */ + protected function compileContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + + return $attribute . ' LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' OR ', $parts) . ')'; + } + + /** + * @param array $values + */ + protected function compileContainsAll(string $attribute, array $values): string + { + /** @var array $values */ + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * @param array $values + */ + protected function compileNotContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding('%' . $this->escapeLikeValue($values[0]) . '%'); + + return $attribute . ' NOT LIKE ?'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding('%' . $this->escapeLikeValue($value) . '%'); + $parts[] = $attribute . ' NOT LIKE ?'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * Escape LIKE metacharacters in user input before wrapping with wildcards. + */ + protected function escapeLikeValue(string $value): string + { + return \str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } + + /** + * Resolve the placement for a join filter condition. + * ClickHouse overrides this to always return Placement::Where since it + * does not support subqueries in JOIN ON conditions. + */ + protected function resolveJoinFilterPlacement(Placement $requested, bool $isCrossJoin): Placement + { + return $isCrossJoin ? Placement::Where : $requested; + } + + /** + * @param array $values + */ + private function compileIn(string $attribute, array $values): string + { + if ($values === []) { + return '1 = 0'; + } + + $hasNulls = false; + $nonNulls = []; + + foreach ($values as $value) { + if ($value === null) { + $hasNulls = true; + } else { + $nonNulls[] = $value; + } + } + + $hasNonNulls = $nonNulls !== []; + + if ($hasNulls && ! $hasNonNulls) { + return $attribute . ' IS NULL'; + } + + $placeholders = \array_fill(0, \count($nonNulls), '?'); + foreach ($nonNulls as $value) { + $this->addBinding($value); + } + $inClause = $attribute . ' IN (' . \implode(', ', $placeholders) . ')'; + + if ($hasNulls) { + return '(' . $inClause . ' OR ' . $attribute . ' IS NULL)'; + } + + return $inClause; + } + + /** + * @param array $values + */ + private function compileNotIn(string $attribute, array $values): string + { + if ($values === []) { + return '1 = 1'; + } + + $hasNulls = false; + $nonNulls = []; + + foreach ($values as $value) { + if ($value === null) { + $hasNulls = true; + } else { + $nonNulls[] = $value; + } + } + + $hasNonNulls = $nonNulls !== []; + + if ($hasNulls && ! $hasNonNulls) { + return $attribute . ' IS NOT NULL'; + } + + if (\count($nonNulls) === 1) { + $this->addBinding($nonNulls[0]); + $notClause = $attribute . ' != ?'; + } else { + $placeholders = \array_fill(0, \count($nonNulls), '?'); + foreach ($nonNulls as $value) { + $this->addBinding($value); + } + $notClause = $attribute . ' NOT IN (' . \implode(', ', $placeholders) . ')'; + } + + if ($hasNulls) { + return '(' . $notClause . ' AND ' . $attribute . ' IS NOT NULL)'; + } + + return $notClause; + } + + /** + * @param array $values + */ + private function compileComparison(string $attribute, string $operator, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileBetween(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + $this->addBinding($values[1]); + $keyword = $not ? 'NOT BETWEEN' : 'BETWEEN'; + + return $attribute . ' ' . $keyword . ' ? AND ?'; + } + + private function compileLogical(Query $query, string $operator): string + { + $parts = []; + foreach ($query->getValues() as $subQuery) { + /** @var Query $subQuery */ + $parts[] = $this->compileFilter($subQuery); + } + + if ($parts === []) { + return $operator === 'OR' ? '1 = 0' : '1 = 1'; + } + + return '(' . \implode(' ' . $operator . ' ', $parts) . ')'; + } + + private function compileExists(Query $query): string + { + $parts = []; + foreach ($query->getValues() as $attr) { + /** @var string $attr */ + $parts[] = $this->resolveAndWrap($attr) . ' IS NOT NULL'; + } + + if ($parts === []) { + return '1 = 1'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + private function compileNotExists(Query $query): string + { + $parts = []; + foreach ($query->getValues() as $attr) { + /** @var string $attr */ + $parts[] = $this->resolveAndWrap($attr) . ' IS NULL'; + } + + if ($parts === []) { + return '1 = 1'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + private function compileRaw(Query $query): string + { + $attribute = $query->getAttribute(); + + if ($attribute === '') { + return '1 = 1'; + } + + foreach ($query->getValues() as $binding) { + $this->addBinding($binding); + } + + return $attribute; + } +} diff --git a/src/Query/Builder/BuildResult.php b/src/Query/Builder/BuildResult.php new file mode 100644 index 0000000..c0d6318 --- /dev/null +++ b/src/Query/Builder/BuildResult.php @@ -0,0 +1,15 @@ + $bindings + */ + public function __construct( + public string $query, + public array $bindings, + ) { + } +} diff --git a/src/Query/Builder/Case/Builder.php b/src/Query/Builder/Case/Builder.php new file mode 100644 index 0000000..9accf2a --- /dev/null +++ b/src/Query/Builder/Case/Builder.php @@ -0,0 +1,89 @@ + */ + private array $whens = []; + + private ?string $elseResult = null; + + /** @var list */ + private array $elseBindings = []; + + private string $alias = ''; + + /** + * @param list $conditionBindings + * @param list $resultBindings + */ + public function when(string $condition, string $result, array $conditionBindings = [], array $resultBindings = []): static + { + $this->whens[] = new WhenClause($condition, $result, $conditionBindings, $resultBindings); + + return $this; + } + + /** + * @param list $bindings + */ + public function elseResult(string $result, array $bindings = []): static + { + $this->elseResult = $result; + $this->elseBindings = $bindings; + + return $this; + } + + /** + * Set the alias for this CASE expression. + * + * The alias is used as-is in the generated SQL (e.g. `CASE ... END AS alias`). + * The caller must pass a pre-quoted identifier if quoting is required, since + * Case\Builder does not have access to the builder's quote() method. + */ + public function alias(string $alias): static + { + $this->alias = $alias; + + return $this; + } + + public function build(): Expression + { + if (empty($this->whens)) { + throw new ValidationException('CASE expression requires at least one WHEN clause.'); + } + + $sql = 'CASE'; + $bindings = []; + + foreach ($this->whens as $when) { + $sql .= ' WHEN ' . $when->condition . ' THEN ' . $when->result; + foreach ($when->conditionBindings as $binding) { + $bindings[] = $binding; + } + foreach ($when->resultBindings as $binding) { + $bindings[] = $binding; + } + } + + if ($this->elseResult !== null) { + $sql .= ' ELSE ' . $this->elseResult; + foreach ($this->elseBindings as $binding) { + $bindings[] = $binding; + } + } + + $sql .= ' END'; + + if ($this->alias !== '') { + $sql .= ' AS ' . $this->alias; + } + + return new Expression($sql, $bindings); + } +} diff --git a/src/Query/Builder/Case/Expression.php b/src/Query/Builder/Case/Expression.php new file mode 100644 index 0000000..ecd8b51 --- /dev/null +++ b/src/Query/Builder/Case/Expression.php @@ -0,0 +1,16 @@ + $bindings + */ + public function __construct( + public string $sql, + public array $bindings, + ) { + } + +} diff --git a/src/Query/Builder/Case/WhenClause.php b/src/Query/Builder/Case/WhenClause.php new file mode 100644 index 0000000..1de49cf --- /dev/null +++ b/src/Query/Builder/Case/WhenClause.php @@ -0,0 +1,18 @@ + $conditionBindings + * @param list $resultBindings + */ + public function __construct( + public string $condition, + public string $result, + public array $conditionBindings, + public array $resultBindings, + ) { + } +} diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php new file mode 100644 index 0000000..a5b0c0c --- /dev/null +++ b/src/Query/Builder/ClickHouse.php @@ -0,0 +1,368 @@ + + */ + protected array $prewhereQueries = []; + + protected bool $useFinal = false; + + protected ?float $sampleFraction = null; + + /** @var list */ + protected array $hints = []; + + /** + * Add PREWHERE filters (evaluated before reading all columns — major ClickHouse optimization) + * + * @param array $queries + */ + public function prewhere(array $queries): static + { + foreach ($queries as $query) { + $this->prewhereQueries[] = $query; + } + + return $this; + } + + /** + * Add FINAL keyword after table name (forces merging of data parts) + */ + public function final(): static + { + $this->useFinal = true; + + return $this; + } + + /** + * Add SAMPLE clause after table name (approximate query processing) + */ + public function sample(float $fraction): static + { + if ($fraction <= 0.0 || $fraction >= 1.0) { + throw new ValidationException('Sample fraction must be between 0 and 1 exclusive'); + } + + $this->sampleFraction = $fraction; + + return $this; + } + + public function hint(string $hint): static + { + if (!\preg_match('/^[A-Za-z0-9_=., ]+$/', $hint)) { + throw new ValidationException('Invalid hint: ' . $hint); + } + + $this->hints[] = $hint; + + return $this; + } + + /** + * @param array $settings + */ + public function settings(array $settings): static + { + foreach ($settings as $key => $value) { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) { + throw new ValidationException('Invalid ClickHouse setting key: ' . $key); + } + + $value = (string) $value; + + if (!\preg_match('/^[a-zA-Z0-9_.]+$/', $value)) { + throw new ValidationException('Invalid ClickHouse setting value: ' . $value); + } + + $this->hints[] = $key . '=' . $value; + } + + return $this; + } + + public function reset(): static + { + parent::reset(); + $this->prewhereQueries = []; + $this->useFinal = false; + $this->sampleFraction = null; + $this->hints = []; + + return $this; + } + + protected function compileRandom(): string + { + return 'rand()'; + } + + /** + * ClickHouse uses the match(column, pattern) function instead of REGEXP + * + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return 'match(' . $attribute . ', ?)'; + } + + /** + * ClickHouse does not support MATCH() AGAINST() full-text search + * + * @param array $values + * + * @throws UnsupportedException + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + throw new UnsupportedException('Full-text search (MATCH AGAINST) is not supported in ClickHouse. Use contains() or a custom full-text index instead.'); + } + + /** + * ClickHouse uses startsWith()/endsWith() functions instead of LIKE with wildcards. + * + * @param array $values + */ + protected function compileLike(string $attribute, array $values, string $prefix, string $suffix, bool $not): string + { + /** @var string $rawVal */ + $rawVal = $values[0]; + + // startsWith: prefix='', suffix='%' + if ($prefix === '' && $suffix === '%') { + $func = $not ? 'NOT startsWith' : 'startsWith'; + $this->addBinding($rawVal); + + return $func . '(' . $attribute . ', ?)'; + } + + // endsWith: prefix='%', suffix='' + if ($prefix === '%' && $suffix === '') { + $func = $not ? 'NOT endsWith' : 'endsWith'; + $this->addBinding($rawVal); + + return $func . '(' . $attribute . ', ?)'; + } + + // Fallback for any other LIKE pattern (should not occur in practice) + $val = $this->escapeLikeValue($rawVal); + $this->addBinding($prefix . $val . $suffix); + $keyword = $not ? 'NOT LIKE' : 'LIKE'; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * ClickHouse uses position() instead of LIKE '%val%' for substring matching. + * + * @param array $values + */ + protected function compileContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding($values[0]); + + return 'position(' . $attribute . ', ?) > 0'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) > 0'; + } + + return '(' . \implode(' OR ', $parts) . ')'; + } + + /** + * ClickHouse uses position() instead of LIKE '%val%' for substring matching (all values). + * + * @param array $values + */ + protected function compileContainsAll(string $attribute, array $values): string + { + /** @var array $values */ + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) > 0'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + /** + * ClickHouse uses position() = 0 instead of NOT LIKE '%val%'. + * + * @param array $values + */ + protected function compileNotContains(string $attribute, array $values): string + { + /** @var array $values */ + if (\count($values) === 1) { + $this->addBinding($values[0]); + + return 'position(' . $attribute . ', ?) = 0'; + } + + $parts = []; + foreach ($values as $value) { + $this->addBinding($value); + $parts[] = 'position(' . $attribute . ', ?) = 0'; + } + + return '(' . \implode(' AND ', $parts) . ')'; + } + + public function update(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $assignments = []; + + if (! empty($this->pendingRows)) { + foreach ($this->pendingRows[0] as $col => $value) { + $assignments[] = $this->resolveAndWrap($col) . ' = ?'; + $this->addBinding($value); + } + } + + foreach ($this->rawSets as $col => $expression) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $expression; + if (isset($this->rawSetBindings[$col])) { + foreach ($this->rawSetBindings[$col] as $binding) { + $this->addBinding($binding); + } + } + } + + foreach ($this->caseSets as $col => $caseData) { + $assignments[] = $this->resolveAndWrap($col) . ' = ' . $caseData->sql; + foreach ($caseData->bindings as $binding) { + $this->addBinding($binding); + } + } + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $parts = []; + + $this->compileWhereClauses($parts); + + if (empty($parts)) { + throw new ValidationException('ClickHouse UPDATE requires a WHERE clause.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($this->table) + . ' UPDATE ' . \implode(', ', $assignments) + . ' ' . \implode(' ', $parts); + + return new BuildResult($sql, $this->bindings); + } + + public function delete(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + + $parts = []; + + $this->compileWhereClauses($parts); + + if (empty($parts)) { + throw new ValidationException('ClickHouse DELETE requires a WHERE clause.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($this->table) + . ' DELETE ' . \implode(' ', $parts); + + return new BuildResult($sql, $this->bindings); + } + + /** + * ClickHouse does not support subqueries in JOIN ON conditions. + * Force all join filter conditions to WHERE placement. + */ + protected function resolveJoinFilterPlacement(Placement $requested, bool $isCrossJoin): Placement + { + return Placement::Where; + } + + public function build(): BuildResult + { + $result = parent::build(); + + if (! empty($this->hints)) { + $settingsStr = \implode(', ', $this->hints); + + return new BuildResult($result->query . ' SETTINGS ' . $settingsStr, $result->bindings); + } + + return $result; + } + + protected function buildTableClause(): string + { + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub->subquery->build(); + foreach ($subResult->bindings as $binding) { + $this->addBinding($binding); + } + + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub->alias); + } + + $sql = 'FROM ' . $this->quote($this->table); + + if ($this->useFinal) { + $sql .= ' FINAL'; + } + + if ($this->sampleFraction !== null) { + $sql .= ' SAMPLE ' . \sprintf('%.10g', $this->sampleFraction); + } + + if ($this->tableAlias !== '') { + $sql .= ' AS ' . $this->quote($this->tableAlias); + } + + return $sql; + } + + /** + * @param array $parts + */ + protected function buildAfterJoins(array &$parts, GroupedQueries $grouped): void + { + if (! empty($this->prewhereQueries)) { + $clauses = []; + foreach ($this->prewhereQueries as $query) { + $clauses[] = $this->compileFilter($query); + } + $parts[] = 'PREWHERE ' . \implode(' AND ', $clauses); + } + } +} diff --git a/src/Query/Builder/Condition.php b/src/Query/Builder/Condition.php new file mode 100644 index 0000000..ec95211 --- /dev/null +++ b/src/Query/Builder/Condition.php @@ -0,0 +1,16 @@ + $bindings + */ + public function __construct( + public string $expression, + public array $bindings = [], + ) { + } + +} diff --git a/src/Query/Builder/CteClause.php b/src/Query/Builder/CteClause.php new file mode 100644 index 0000000..43265fa --- /dev/null +++ b/src/Query/Builder/CteClause.php @@ -0,0 +1,17 @@ + $bindings + */ + public function __construct( + public string $name, + public string $query, + public array $bindings, + public bool $recursive, + ) { + } +} diff --git a/src/Query/Builder/ExistsSubquery.php b/src/Query/Builder/ExistsSubquery.php new file mode 100644 index 0000000..ffd040d --- /dev/null +++ b/src/Query/Builder/ExistsSubquery.php @@ -0,0 +1,14 @@ + $columns + */ + public function groupBy(array $columns): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function having(array $queries): static; +} diff --git a/src/Query/Builder/Feature/CTEs.php b/src/Query/Builder/Feature/CTEs.php new file mode 100644 index 0000000..129a514 --- /dev/null +++ b/src/Query/Builder/Feature/CTEs.php @@ -0,0 +1,12 @@ + $row + */ + public function set(array $row): static; + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + public function onConflict(array $keys, array $updateColumns): static; + + public function insert(): BuildResult; + + /** + * @param list $columns + */ + public function fromSelect(array $columns, Builder $source): static; + + public function insertSelect(): BuildResult; +} diff --git a/src/Query/Builder/Feature/Joins.php b/src/Query/Builder/Feature/Joins.php new file mode 100644 index 0000000..a39c76c --- /dev/null +++ b/src/Query/Builder/Feature/Joins.php @@ -0,0 +1,21 @@ + $values + */ + public function filterJsonOverlaps(string $attribute, array $values): static; + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static; + + // Mutation operations (for UPDATE SET) + + /** + * @param array $values + */ + public function setJsonAppend(string $column, array $values): static; + + /** + * @param array $values + */ + public function setJsonPrepend(string $column, array $values): static; + + public function setJsonInsert(string $column, int $index, mixed $value): static; + + public function setJsonRemove(string $column, mixed $value): static; + + /** + * @param array $values + */ + public function setJsonIntersect(string $column, array $values): static; + + /** + * @param array $values + */ + public function setJsonDiff(string $column, array $values): static; + + public function setJsonUnique(string $column): static; +} diff --git a/src/Query/Builder/Feature/Locking.php b/src/Query/Builder/Feature/Locking.php new file mode 100644 index 0000000..eb70a8b --- /dev/null +++ b/src/Query/Builder/Feature/Locking.php @@ -0,0 +1,18 @@ + $columns + */ + public function returning(array $columns = ['*']): static; +} diff --git a/src/Query/Builder/Feature/Selects.php b/src/Query/Builder/Feature/Selects.php new file mode 100644 index 0000000..f83959e --- /dev/null +++ b/src/Query/Builder/Feature/Selects.php @@ -0,0 +1,62 @@ + $columns + */ + public function select(array $columns): static; + + /** + * @param list $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static; + + public function distinct(): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function filter(array $queries): static; + + /** + * @param array<\Utopia\Query\Query> $queries + */ + public function queries(array $queries): static; + + public function sortAsc(string $attribute): static; + + public function sortDesc(string $attribute): static; + + public function sortRandom(): static; + + public function limit(int $value): static; + + public function offset(int $value): static; + + public function page(int $page, int $perPage = 25): static; + + public function cursorAfter(mixed $value): static; + + public function cursorBefore(mixed $value): static; + + public function when(bool $condition, Closure $callback): static; + + public function build(): BuildResult; + + public function toRawSql(): string; + + /** + * @return list + */ + public function getBindings(): array; + + public function reset(): static; +} diff --git a/src/Query/Builder/Feature/Spatial.php b/src/Query/Builder/Feature/Spatial.php new file mode 100644 index 0000000..a276dc2 --- /dev/null +++ b/src/Query/Builder/Feature/Spatial.php @@ -0,0 +1,71 @@ + $point [longitude, latitude] + */ + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static; + + /** + * @param array $geometry WKT-compatible geometry coordinates + */ + public function filterIntersects(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotIntersects(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterCrosses(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotCrosses(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterOverlaps(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotOverlaps(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterTouches(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotTouches(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterCovers(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotCovers(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterSpatialEquals(string $attribute, array $geometry): static; + + /** + * @param array $geometry + */ + public function filterNotSpatialEquals(string $attribute, array $geometry): static; +} diff --git a/src/Query/Builder/Feature/Transactions.php b/src/Query/Builder/Feature/Transactions.php new file mode 100644 index 0000000..a8dd5e4 --- /dev/null +++ b/src/Query/Builder/Feature/Transactions.php @@ -0,0 +1,20 @@ + $row + */ + public function set(array $row): static; + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static; + + public function update(): BuildResult; +} diff --git a/src/Query/Builder/Feature/Upsert.php b/src/Query/Builder/Feature/Upsert.php new file mode 100644 index 0000000..4646cfc --- /dev/null +++ b/src/Query/Builder/Feature/Upsert.php @@ -0,0 +1,12 @@ + $vector The query vector + */ + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static; +} diff --git a/src/Query/Builder/Feature/Windows.php b/src/Query/Builder/Feature/Windows.php new file mode 100644 index 0000000..31843b5 --- /dev/null +++ b/src/Query/Builder/Feature/Windows.php @@ -0,0 +1,16 @@ +|null $partitionBy Columns for PARTITION BY + * @param list|null $orderBy Columns for ORDER BY (prefix with - for DESC) + */ + public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null): static; +} diff --git a/src/Query/Builder/GroupedQueries.php b/src/Query/Builder/GroupedQueries.php new file mode 100644 index 0000000..5d3fc3f --- /dev/null +++ b/src/Query/Builder/GroupedQueries.php @@ -0,0 +1,39 @@ + $filters + * @param list $selections + * @param list $aggregations + * @param list $groupBy + * @param list $having + * @param list $joins + * @param list $unions + * @param array $orderAttributes + * @param array $orderTypes + */ + public function __construct( + public array $filters = [], + public array $selections = [], + public array $aggregations = [], + public array $groupBy = [], + public array $having = [], + public bool $distinct = false, + public array $joins = [], + public array $unions = [], + public ?int $limit = null, + public ?int $offset = null, + public array $orderAttributes = [], + public array $orderTypes = [], + public mixed $cursor = null, + public ?CursorDirection $cursorDirection = null, + ) { + } +} diff --git a/src/Query/Builder/JoinBuilder.php b/src/Query/Builder/JoinBuilder.php new file mode 100644 index 0000000..7f15e68 --- /dev/null +++ b/src/Query/Builder/JoinBuilder.php @@ -0,0 +1,75 @@ +', '<=', '>=', '<>']; + + /** @var list */ + public private(set) array $ons = []; + + /** @var list */ + public private(set) array $wheres = []; + + /** + * Add an ON condition to the join. + * + * Note: $left and $right should be raw column identifiers (e.g. "users.id"). + * The parent builder's compileJoinWithBuilder already calls resolveAndWrap on these values. + */ + public function on(string $left, string $right, string $operator = '='): static + { + if (!\in_array($operator, self::ALLOWED_OPERATORS, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + $this->ons[] = new JoinOn($left, $operator, $right); + + return $this; + } + + /** + * @param list $bindings + */ + public function onRaw(string $expression, array $bindings = []): static + { + $this->wheres[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * Add a WHERE condition to the join. + * + * Note: $column is used as-is in the SQL expression. The caller is responsible + * for ensuring it is a safe, pre-validated column identifier. + */ + public function where(string $column, string $operator, mixed $value): static + { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $column)) { + throw new ValidationException('Invalid column name: ' . $column); + } + + if (!\in_array($operator, self::ALLOWED_OPERATORS, true)) { + throw new ValidationException('Invalid join operator: ' . $operator); + } + + $this->wheres[] = new Condition($column . ' ' . $operator . ' ?', [$value]); + + return $this; + } + + /** + * @param list $bindings + */ + public function whereRaw(string $expression, array $bindings = []): static + { + $this->wheres[] = new Condition($expression, $bindings); + + return $this; + } + +} diff --git a/src/Query/Builder/JoinOn.php b/src/Query/Builder/JoinOn.php new file mode 100644 index 0000000..ca4c14d --- /dev/null +++ b/src/Query/Builder/JoinOn.php @@ -0,0 +1,13 @@ +value; + } +} diff --git a/src/Query/Builder/MySQL.php b/src/Query/Builder/MySQL.php new file mode 100644 index 0000000..11ceb04 --- /dev/null +++ b/src/Query/Builder/MySQL.php @@ -0,0 +1,471 @@ + */ + protected array $hints = []; + + /** @var array */ + protected array $jsonSets = []; + + protected function compileRandom(): string + { + return 'RAND()'; + } + + /** + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEXP ?'; + } + + /** + * @param array $values + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT (MATCH(' . $attribute . ') AGAINST(?))'; + } + + return 'MATCH(' . $attribute . ') AGAINST(?)'; + } + + protected function compileConflictClause(): string + { + $updates = []; + foreach ($this->conflictUpdateColumns as $col) { + $wrapped = $this->resolveAndWrap($col); + if (isset($this->conflictRawSets[$col])) { + $updates[] = $wrapped . ' = ' . $this->conflictRawSets[$col]; + foreach ($this->conflictRawSetBindings[$col] ?? [] as $binding) { + $this->addBinding($binding); + } + } else { + $updates[] = $wrapped . ' = VALUES(' . $wrapped . ')'; + } + } + + return 'ON DUPLICATE KEY UPDATE ' . \implode(', ', $updates); + } + + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static + { + $wkt = 'POINT(' . (float) $point[0] . ' ' . (float) $point[1] . ')'; + $method = match ($operator) { + '<' => Method::DistanceLessThan, + '>' => Method::DistanceGreaterThan, + '=' => Method::DistanceEqual, + '!=' => Method::DistanceNotEqual, + default => Method::DistanceLessThan, + }; + + $this->pendingQueries[] = new Query($method, $attribute, [[$wkt, $distance, $meters]]); + + return $this; + } + + public function filterIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::intersects($attribute, $geometry); + + return $this; + } + + public function filterNotIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notIntersects($attribute, $geometry); + + return $this; + } + + public function filterCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::crosses($attribute, $geometry); + + return $this; + } + + public function filterNotCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCrosses($attribute, $geometry); + + return $this; + } + + public function filterOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::overlaps($attribute, $geometry); + + return $this; + } + + public function filterNotOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notOverlaps($attribute, $geometry); + + return $this; + } + + public function filterTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::touches($attribute, $geometry); + + return $this; + } + + public function filterNotTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notTouches($attribute, $geometry); + + return $this; + } + + public function filterCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::covers($attribute, $geometry); + + return $this; + } + + public function filterNotCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCovers($attribute, $geometry); + + return $this; + } + + public function filterSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::spatialEquals($attribute, $geometry); + + return $this; + } + + public function filterNotSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notSpatialEquals($attribute, $geometry); + + return $this; + } + + public function filterJsonContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonContains($attribute, $value); + + return $this; + } + + public function filterJsonNotContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonNotContains($attribute, $value); + + return $this; + } + + public function filterJsonOverlaps(string $attribute, array $values): static + { + $this->pendingQueries[] = Query::jsonOverlaps($attribute, $values); + + return $this; + } + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + $this->pendingQueries[] = Query::jsonPath($attribute, $path, $operator, $value); + + return $this; + } + + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'JSON_MERGE_PRESERVE(IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()), ?)', + [\json_encode($values)], + ); + + return $this; + } + + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'JSON_MERGE_PRESERVE(?, IFNULL(' . $this->resolveAndWrap($column) . ', JSON_ARRAY()))', + [\json_encode($values)], + ); + + return $this; + } + + public function setJsonInsert(string $column, int $index, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + 'JSON_ARRAY_INSERT(' . $this->resolveAndWrap($column) . ', ?, ?)', + ['$[' . $index . ']', $value], + ); + + return $this; + } + + public function setJsonRemove(string $column, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + 'JSON_REMOVE(' . $this->resolveAndWrap($column) . ', JSON_UNQUOTE(JSON_SEARCH(' . $this->resolveAndWrap($column) . ', \'one\', ?)))', + [$value], + ); + + return $this; + } + + public function setJsonIntersect(string $column, array $values): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE JSON_CONTAINS(?, val))', [\json_encode($values)]); + + return $this; + } + + public function setJsonDiff(string $column, array $values): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE NOT JSON_CONTAINS(?, val))', [\json_encode($values)]); + + return $this; + } + + public function setJsonUnique(string $column): static + { + $this->setRaw($column, '(SELECT JSON_ARRAYAGG(val) FROM (SELECT DISTINCT val FROM JSON_TABLE(' . $this->resolveAndWrap($column) . ', \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt) AS dt)'); + + return $this; + } + + public function hint(string $hint): static + { + if (!\preg_match('/^[A-Za-z0-9_()= ,]+$/', $hint)) { + throw new ValidationException('Invalid hint: ' . $hint); + } + + $this->hints[] = $hint; + + return $this; + } + + public function maxExecutionTime(int $ms): static + { + return $this->hint("MAX_EXECUTION_TIME({$ms})"); + } + + public function insertOrIgnore(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + // Replace "INSERT INTO" with "INSERT IGNORE INTO" + $sql = \preg_replace('/^INSERT INTO/', 'INSERT IGNORE INTO', $sql, 1) ?? $sql; + + return new BuildResult($sql, $this->bindings); + } + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + + if ($method->isSpatial()) { + return $this->compileSpatialFilter($method, $attribute, $query); + } + + if ($method->isJson()) { + return $this->compileJsonFilter($method, $attribute, $query); + } + + return parent::compileFilter($query); + } + + public function build(): BuildResult + { + $result = parent::build(); + + if (! empty($this->hints)) { + $hintStr = '/*+ ' . \implode(' ', $this->hints) . ' */'; + $query = \preg_replace('/^SELECT(\s+DISTINCT)?/', 'SELECT$1 ' . $hintStr, $result->query, 1); + + return new BuildResult($query ?? $result->query, $result->bindings); + } + + return $result; + } + + public function update(): BuildResult + { + // Apply JSON sets as rawSets before calling parent + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $result; + } + + public function reset(): static + { + parent::reset(); + $this->hints = []; + $this->jsonSets = []; + + return $this; + } + + private function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::DistanceLessThan, + Method::DistanceGreaterThan, + Method::DistanceEqual, + Method::DistanceNotEqual => $this->compileSpatialDistance($method, $attribute, $values), + Method::Intersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, false), + Method::NotIntersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, true), + Method::Crosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, false), + Method::NotCrosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, true), + Method::Overlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, false), + Method::NotOverlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, true), + Method::Touches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, false), + Method::NotTouches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, true), + Method::Covers => $this->compileSpatialPredicate('ST_Contains', $attribute, $values, false), + Method::NotCovers => $this->compileSpatialPredicate('ST_Contains', $attribute, $values, true), + Method::SpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, false), + Method::NotSpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, true), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string, 1: float, 2: bool} $data */ + $data = $values[0]; + $wkt = $data[0]; + $distance = $data[1]; + $meters = $data[2]; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + if ($meters) { + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(ST_SRID(' . $attribute . ', 4326), ST_GeomFromText(?, 4326), \'metre\') ' . $operator . ' ?'; + } + + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?)) ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string + { + /** @var array $geometry */ + $geometry = $values[0]; + $wkt = $this->geometryToWkt($geometry); + $this->addBinding($wkt); + + $expr = $function . '(' . $attribute . ', ST_GeomFromText(?, 4326))'; + + return $not ? 'NOT ' . $expr : $expr; + } + + private function compileJsonFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::JsonContains => $this->compileJsonContains($attribute, $values, false), + Method::JsonNotContains => $this->compileJsonContains($attribute, $values, true), + Method::JsonOverlaps => $this->compileJsonOverlapsFilter($attribute, $values), + Method::JsonPath => $this->compileJsonPathFilter($attribute, $values), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileJsonContains(string $attribute, array $values, bool $not): string + { + $this->addBinding(\json_encode($values[0])); + $expr = 'JSON_CONTAINS(' . $attribute . ', ?)'; + + return $not ? 'NOT ' . $expr : $expr; + } + + /** + * @param array $values + */ + private function compileJsonOverlapsFilter(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $this->addBinding(\json_encode($arr)); + + return 'JSON_OVERLAPS(' . $attribute . ', ?)'; + } + + /** + * @param array $values + */ + private function compileJsonPathFilter(string $attribute, array $values): string + { + /** @var string $path */ + $path = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + $value = $values[2]; + + if (!\preg_match('/^[a-zA-Z0-9_.\[\]]+$/', $path)) { + throw new ValidationException('Invalid JSON path: ' . $path); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid JSON path operator: ' . $operator); + } + + $this->addBinding($value); + + return 'JSON_EXTRACT(' . $attribute . ', \'$.' . $path . '\') ' . $operator . ' ?'; + } + +} diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php new file mode 100644 index 0000000..0213ed5 --- /dev/null +++ b/src/Query/Builder/PostgreSQL.php @@ -0,0 +1,570 @@ + */ + protected array $returningColumns = []; + + /** @var array */ + protected array $jsonSets = []; + + /** @var ?array{attribute: string, vector: array, metric: VectorMetric} */ + protected ?array $vectorOrder = null; + + protected function compileRandom(): string + { + return 'RANDOM()'; + } + + /** + * @param array $values + */ + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' ~ ?'; + } + + /** + * @param array $values + */ + protected function compileSearch(string $attribute, array $values, bool $not): string + { + $this->addBinding($values[0]); + + if ($not) { + return 'NOT (to_tsvector(' . $attribute . ') @@ plainto_tsquery(?))'; + } + + return 'to_tsvector(' . $attribute . ') @@ plainto_tsquery(?)'; + } + + protected function compileConflictClause(): string + { + $wrappedKeys = \array_map( + fn (string $key): string => $this->resolveAndWrap($key), + $this->conflictKeys + ); + + $updates = []; + foreach ($this->conflictUpdateColumns as $col) { + $wrapped = $this->resolveAndWrap($col); + if (isset($this->conflictRawSets[$col])) { + $updates[] = $wrapped . ' = ' . $this->conflictRawSets[$col]; + foreach ($this->conflictRawSetBindings[$col] ?? [] as $binding) { + $this->addBinding($binding); + } + } else { + $updates[] = $wrapped . ' = EXCLUDED.' . $wrapped; + } + } + + return 'ON CONFLICT (' . \implode(', ', $wrappedKeys) . ') DO UPDATE SET ' . \implode(', ', $updates); + } + + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + return $offset !== null; + } + + /** + * @param list $columns + */ + public function returning(array $columns = ['*']): static + { + $this->returningColumns = $columns; + + return $this; + } + + public function forUpdateOf(string $table): static + { + $this->lockMode = LockMode::ForUpdate; + $this->lockOfTable = $table; + + return $this; + } + + public function forShareOf(string $table): static + { + $this->lockMode = LockMode::ForShare; + $this->lockOfTable = $table; + + return $this; + } + + public function insertOrIgnore(): BuildResult + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + $sql .= ' ON CONFLICT DO NOTHING'; + + return $this->appendReturning(new BuildResult($sql, $this->bindings)); + } + + public function insert(): BuildResult + { + $result = parent::insert(); + + return $this->appendReturning($result); + } + + public function update(): BuildResult + { + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $this->appendReturning($result); + } + + public function delete(): BuildResult + { + $result = parent::delete(); + + return $this->appendReturning($result); + } + + public function upsert(): BuildResult + { + $result = parent::upsert(); + + return $this->appendReturning($result); + } + + private function appendReturning(BuildResult $result): BuildResult + { + if (empty($this->returningColumns)) { + return $result; + } + + $columns = \array_map( + fn (string $col): string => $col === '*' ? '*' : $this->resolveAndWrap($col), + $this->returningColumns + ); + + return new BuildResult( + $result->query . ' RETURNING ' . \implode(', ', $columns), + $result->bindings + ); + } + + public function filterDistance(string $attribute, array $point, string $operator, float $distance, bool $meters = false): static + { + $wkt = 'POINT(' . (float) $point[0] . ' ' . (float) $point[1] . ')'; + $method = match ($operator) { + '<' => Method::DistanceLessThan, + '>' => Method::DistanceGreaterThan, + '=' => Method::DistanceEqual, + '!=' => Method::DistanceNotEqual, + default => Method::DistanceLessThan, + }; + + $this->pendingQueries[] = new Query($method, $attribute, [[$wkt, $distance, $meters]]); + + return $this; + } + + public function filterIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::intersects($attribute, $geometry); + + return $this; + } + + public function filterNotIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notIntersects($attribute, $geometry); + + return $this; + } + + public function filterCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::crosses($attribute, $geometry); + + return $this; + } + + public function filterNotCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCrosses($attribute, $geometry); + + return $this; + } + + public function filterOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::overlaps($attribute, $geometry); + + return $this; + } + + public function filterNotOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notOverlaps($attribute, $geometry); + + return $this; + } + + public function filterTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::touches($attribute, $geometry); + + return $this; + } + + public function filterNotTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notTouches($attribute, $geometry); + + return $this; + } + + public function filterCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::covers($attribute, $geometry); + + return $this; + } + + public function filterNotCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCovers($attribute, $geometry); + + return $this; + } + + public function filterSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::spatialEquals($attribute, $geometry); + + return $this; + } + + public function filterNotSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notSpatialEquals($attribute, $geometry); + + return $this; + } + + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static + { + $this->vectorOrder = [ + 'attribute' => $attribute, + 'vector' => $vector, + 'metric' => $metric, + ]; + + return $this; + } + + public function filterJsonContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonContains($attribute, $value); + + return $this; + } + + public function filterJsonNotContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonNotContains($attribute, $value); + + return $this; + } + + public function filterJsonOverlaps(string $attribute, array $values): static + { + $this->pendingQueries[] = Query::jsonOverlaps($attribute, $values); + + return $this; + } + + public function filterJsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + $this->pendingQueries[] = Query::jsonPath($attribute, $path, $operator, $value); + + return $this; + } + + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb) || ?::jsonb', + [\json_encode($values)], + ); + + return $this; + } + + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + '?::jsonb || COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb)', + [\json_encode($values)], + ); + + return $this; + } + + public function setJsonInsert(string $column, int $index, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + 'jsonb_insert(' . $this->resolveAndWrap($column) . ', \'{' . $index . '}\', ?::jsonb)', + [\json_encode($value)], + ); + + return $this; + } + + public function setJsonRemove(string $column, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + $this->resolveAndWrap($column) . ' - ?', + [\json_encode($value)], + ); + + return $this; + } + + public function setJsonIntersect(string $column, array $values): static + { + $this->setRaw($column, '(SELECT jsonb_agg(elem) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem WHERE elem <@ ?::jsonb)', [\json_encode($values)]); + + return $this; + } + + public function setJsonDiff(string $column, array $values): static + { + $this->setRaw($column, '(SELECT COALESCE(jsonb_agg(elem), \'[]\'::jsonb) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem WHERE NOT elem <@ ?::jsonb)', [\json_encode($values)]); + + return $this; + } + + public function setJsonUnique(string $column): static + { + $this->setRaw($column, '(SELECT jsonb_agg(DISTINCT elem) FROM jsonb_array_elements(' . $this->resolveAndWrap($column) . ') AS elem)'); + + return $this; + } + + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + + if ($method->isSpatial()) { + return $this->compileSpatialFilter($method, $attribute, $query); + } + + if ($method->isJson()) { + return $this->compileJsonFilter($method, $attribute, $query); + } + + if ($method->isVector()) { + return $this->compileVectorFilter($method, $attribute, $query); + } + + return parent::compileFilter($query); + } + + protected function compileVectorOrderExpr(): ?Condition + { + if ($this->vectorOrder === null) { + return null; + } + + $attr = $this->resolveAndWrap($this->vectorOrder['attribute']); + $operator = $this->vectorOrder['metric']->toOperator(); + $vectorJson = \json_encode($this->vectorOrder['vector']); + + return new Condition( + '(' . $attr . ' ' . $operator . ' ?::vector) ASC', + [$vectorJson], + ); + } + + public function reset(): static + { + parent::reset(); + $this->jsonSets = []; + $this->vectorOrder = null; + $this->returningColumns = []; + + return $this; + } + + private function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::DistanceLessThan, + Method::DistanceGreaterThan, + Method::DistanceEqual, + Method::DistanceNotEqual => $this->compileSpatialDistance($method, $attribute, $values), + Method::Intersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, false), + Method::NotIntersects => $this->compileSpatialPredicate('ST_Intersects', $attribute, $values, true), + Method::Crosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, false), + Method::NotCrosses => $this->compileSpatialPredicate('ST_Crosses', $attribute, $values, true), + Method::Overlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, false), + Method::NotOverlaps => $this->compileSpatialPredicate('ST_Overlaps', $attribute, $values, true), + Method::Touches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, false), + Method::NotTouches => $this->compileSpatialPredicate('ST_Touches', $attribute, $values, true), + Method::Covers => $this->compileSpatialPredicate('ST_Covers', $attribute, $values, false), + Method::NotCovers => $this->compileSpatialPredicate('ST_Covers', $attribute, $values, true), + Method::SpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, false), + Method::NotSpatialEquals => $this->compileSpatialPredicate('ST_Equals', $attribute, $values, true), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string, 1: float, 2: bool} $data */ + $data = $values[0]; + $wkt = $data[0]; + $distance = $data[1]; + $meters = $data[2]; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + if ($meters) { + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance((' . $attribute . '::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) ' . $operator . ' ?'; + } + + $this->addBinding($wkt); + $this->addBinding($distance); + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?)) ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + private function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string + { + /** @var array $geometry */ + $geometry = $values[0]; + $wkt = $this->geometryToWkt($geometry); + $this->addBinding($wkt); + + $expr = $function . '(' . $attribute . ', ST_GeomFromText(?, 4326))'; + + return $not ? 'NOT ' . $expr : $expr; + } + + private function compileJsonFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::JsonContains => $this->compileJsonContainsExpr($attribute, $values, false), + Method::JsonNotContains => $this->compileJsonContainsExpr($attribute, $values, true), + Method::JsonOverlaps => $this->compileJsonOverlapsExpr($attribute, $values), + Method::JsonPath => $this->compileJsonPathExpr($attribute, $values), + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + private function compileJsonContainsExpr(string $attribute, array $values, bool $not): string + { + $this->addBinding(\json_encode($values[0])); + $expr = $attribute . ' @> ?::jsonb'; + + return $not ? 'NOT (' . $expr . ')' : $expr; + } + + /** + * @param array $values + */ + private function compileJsonOverlapsExpr(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $this->addBinding(\json_encode($arr)); + + return $attribute . ' ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))'; + } + + /** + * @param array $values + */ + private function compileJsonPathExpr(string $attribute, array $values): string + { + /** @var string $path */ + $path = $values[0]; + /** @var string $operator */ + $operator = $values[1]; + $value = $values[2]; + + if (!\preg_match('/^[a-zA-Z0-9_.\[\]]+$/', $path)) { + throw new ValidationException('Invalid JSON path: ' . $path); + } + + $allowedOperators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + if (!\in_array($operator, $allowedOperators, true)) { + throw new ValidationException('Invalid JSON path operator: ' . $operator); + } + + $this->addBinding($value); + + return $attribute . '->>\''. $path . '\' ' . $operator . ' ?'; + } + + private function compileVectorFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + /** @var array $vector */ + $vector = $values[0]; + + $operator = match ($method) { + Method::VectorCosine => '<=>', + Method::VectorEuclidean => '<->', + Method::VectorDot => '<#>', + default => '<=>', + }; + + $this->addBinding(\json_encode($vector)); + + return '(' . $attribute . ' ' . $operator . ' ?::vector)'; + } + +} diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php new file mode 100644 index 0000000..5786f4d --- /dev/null +++ b/src/Query/Builder/SQL.php @@ -0,0 +1,187 @@ +lockMode = LockMode::ForUpdate; + + return $this; + } + + public function forShare(): static + { + $this->lockMode = LockMode::ForShare; + + return $this; + } + + public function forUpdateSkipLocked(): static + { + $this->lockMode = LockMode::ForUpdateSkipLocked; + + return $this; + } + + public function forUpdateNoWait(): static + { + $this->lockMode = LockMode::ForUpdateNoWait; + + return $this; + } + + public function forShareSkipLocked(): static + { + $this->lockMode = LockMode::ForShareSkipLocked; + + return $this; + } + + public function forShareNoWait(): static + { + $this->lockMode = LockMode::ForShareNoWait; + + return $this; + } + + public function begin(): BuildResult + { + return new BuildResult('BEGIN', []); + } + + public function commit(): BuildResult + { + return new BuildResult('COMMIT', []); + } + + public function rollback(): BuildResult + { + return new BuildResult('ROLLBACK', []); + } + + public function savepoint(string $name): BuildResult + { + return new BuildResult('SAVEPOINT ' . $this->quote($name), []); + } + + public function releaseSavepoint(string $name): BuildResult + { + return new BuildResult('RELEASE SAVEPOINT ' . $this->quote($name), []); + } + + public function rollbackToSavepoint(string $name): BuildResult + { + return new BuildResult('ROLLBACK TO SAVEPOINT ' . $this->quote($name), []); + } + + abstract protected function compileConflictClause(): string; + + public function upsert(): BuildResult + { + $this->bindings = []; + $this->validateTable(); + $this->validateRows('upsert'); + $columns = $this->validateAndGetColumns(); + + if (empty($this->conflictKeys)) { + throw new ValidationException('No conflict keys specified. Call onConflict() before upsert().'); + } + + if (empty($this->conflictUpdateColumns)) { + throw new ValidationException('No conflict update columns specified. Call onConflict() with update columns before upsert().'); + } + + $rowColumns = $columns; + foreach ($this->conflictUpdateColumns as $col) { + if (! \in_array($col, $rowColumns, true)) { + throw new ValidationException("Conflict update column '{$col}' is not present in the row data."); + } + } + + $wrappedColumns = \array_map(fn (string $col): string => $this->resolveAndWrap($col), $columns); + + $rowPlaceholders = []; + foreach ($this->pendingRows as $row) { + $placeholders = []; + foreach ($columns as $col) { + $this->addBinding($row[$col] ?? null); + if (isset($this->insertColumnExpressions[$col])) { + $placeholders[] = $this->insertColumnExpressions[$col]; + foreach ($this->insertColumnExpressionBindings[$col] ?? [] as $extra) { + $this->addBinding($extra); + } + } else { + $placeholders[] = '?'; + } + } + $rowPlaceholders[] = '(' . \implode(', ', $placeholders) . ')'; + } + + $tablePart = $this->quote($this->table); + if ($this->insertAlias !== '') { + $tablePart .= ' AS ' . $this->insertAlias; + } + + $sql = 'INSERT INTO ' . $tablePart + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); + + $sql .= ' ' . $this->compileConflictClause(); + + return new BuildResult($sql, $this->bindings); + } + + abstract public function insertOrIgnore(): BuildResult; + + /** + * Convert a geometry array to WKT string. + * + * @param array $geometry + */ + protected function geometryToWkt(array $geometry): string + { + // Simple array of [lon, lat] -> POINT + if (\count($geometry) === 2 && \is_numeric($geometry[0]) && \is_numeric($geometry[1])) { + return 'POINT(' . (float) $geometry[0] . ' ' . (float) $geometry[1] . ')'; + } + + // Array of points -> check depth + if (isset($geometry[0]) && \is_array($geometry[0])) { + // Array of arrays of arrays -> POLYGON + if (isset($geometry[0][0]) && \is_array($geometry[0][0])) { + $rings = []; + foreach ($geometry as $ring) { + /** @var array> $ring */ + $points = \array_map(fn (array $p): string => (float) $p[0] . ' ' . (float) $p[1], $ring); + $rings[] = '(' . \implode(', ', $points) . ')'; + } + + return 'POLYGON(' . \implode(', ', $rings) . ')'; + } + + // Array of [lon, lat] pairs -> LINESTRING + /** @var array> $geometry */ + $points = \array_map(fn (array $p): string => (float) $p[0] . ' ' . (float) $p[1], $geometry); + + return 'LINESTRING(' . \implode(', ', $points) . ')'; + } + + /** @var int|float|string $rawX */ + $rawX = $geometry[0] ?? 0; + /** @var int|float|string $rawY */ + $rawY = $geometry[1] ?? 0; + + return 'POINT(' . (float) $rawX . ' ' . (float) $rawY . ')'; + } +} diff --git a/src/Query/Builder/SubSelect.php b/src/Query/Builder/SubSelect.php new file mode 100644 index 0000000..3a01b80 --- /dev/null +++ b/src/Query/Builder/SubSelect.php @@ -0,0 +1,14 @@ + $bindings + */ + public function __construct( + public UnionType $type, + public string $query, + public array $bindings, + ) { + } +} diff --git a/src/Query/Builder/UnionType.php b/src/Query/Builder/UnionType.php new file mode 100644 index 0000000..3172d37 --- /dev/null +++ b/src/Query/Builder/UnionType.php @@ -0,0 +1,13 @@ + '<=>', + self::Euclidean => '<->', + self::Dot => '<#>', + }; + } +} diff --git a/src/Query/Builder/WhereInSubquery.php b/src/Query/Builder/WhereInSubquery.php new file mode 100644 index 0000000..9ba63a6 --- /dev/null +++ b/src/Query/Builder/WhereInSubquery.php @@ -0,0 +1,15 @@ + $partitionBy + * @param ?list $orderBy + */ + public function __construct( + public string $function, + public string $alias, + public ?array $partitionBy, + public ?array $orderBy, + ) { + } +} diff --git a/src/Query/Compiler.php b/src/Query/Compiler.php new file mode 100644 index 0000000..f7c0a24 --- /dev/null +++ b/src/Query/Compiler.php @@ -0,0 +1,51 @@ + $map */ + public function __construct(private array $map) + { + } + + public function resolve(string $attribute): string + { + return $this->map[$attribute] ?? $attribute; + } +} diff --git a/src/Query/Hook/Filter.php b/src/Query/Hook/Filter.php new file mode 100644 index 0000000..a6726de --- /dev/null +++ b/src/Query/Hook/Filter.php @@ -0,0 +1,11 @@ + $roles + * @param \Closure(string): string $permissionsTable Receives the base table name, returns the permissions table name + * @param list|null $columns Column names to check permissions for. NULL rows (wildcard) are always included. + * @param Filter|null $subqueryFilter Optional filter applied inside the permissions subquery (e.g. tenant filtering) + */ + public function __construct( + protected array $roles, + protected \Closure $permissionsTable, + protected string $type = 'read', + protected ?array $columns = null, + protected string $documentColumn = 'id', + protected string $permDocumentColumn = 'document_id', + protected string $permRoleColumn = 'role', + protected string $permTypeColumn = 'type', + protected string $permColumnColumn = 'column', + protected ?Filter $subqueryFilter = null, + ) { + foreach ([$documentColumn, $permDocumentColumn, $permRoleColumn, $permTypeColumn, $permColumnColumn] as $col) { + if (!\preg_match(self::IDENTIFIER_PATTERN, $col)) { + throw new \InvalidArgumentException('Invalid column name: ' . $col); + } + } + } + + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } + + /** @var string $permTable */ + $permTable = ($this->permissionsTable)($table); + + if (!\preg_match(self::IDENTIFIER_PATTERN, $permTable)) { + throw new \InvalidArgumentException('Invalid permissions table name: ' . $permTable); + } + + $rolePlaceholders = \implode(', ', \array_fill(0, \count($this->roles), '?')); + + $columnClause = ''; + $columnBindings = []; + + if ($this->columns !== null) { + if (empty($this->columns)) { + $columnClause = " AND {$this->permColumnColumn} IS NULL"; + } else { + $colPlaceholders = \implode(', ', \array_fill(0, \count($this->columns), '?')); + $columnClause = " AND ({$this->permColumnColumn} IS NULL OR {$this->permColumnColumn} IN ({$colPlaceholders}))"; + $columnBindings = $this->columns; + } + } + + $subFilterClause = ''; + $subFilterBindings = []; + if ($this->subqueryFilter !== null) { + $subCondition = $this->subqueryFilter->filter($permTable); + $subFilterClause = ' AND ' . $subCondition->expression; + $subFilterBindings = $subCondition->bindings; + } + + return new Condition( + "{$this->documentColumn} IN (SELECT DISTINCT {$this->permDocumentColumn} FROM {$permTable} WHERE {$this->permRoleColumn} IN ({$rolePlaceholders}) AND {$this->permTypeColumn} = ?{$columnClause}{$subFilterClause})", + [...$this->roles, $this->type, ...$columnBindings, ...$subFilterBindings], + ); + } + + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + $condition = $this->filter($table); + + $placement = match ($joinType) { + JoinType::Left, JoinType::Right => Placement::On, + default => Placement::Where, + }; + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Query/Hook/Filter/Tenant.php b/src/Query/Hook/Filter/Tenant.php new file mode 100644 index 0000000..d806b78 --- /dev/null +++ b/src/Query/Hook/Filter/Tenant.php @@ -0,0 +1,51 @@ + $tenantIds + */ + public function __construct( + protected array $tenantIds, + protected string $column = 'tenant_id', + ) { + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $column)) { + throw new \InvalidArgumentException('Invalid column name: ' . $column); + } + } + + public function filter(string $table): Condition + { + if (empty($this->tenantIds)) { + return new Condition('1 = 0'); + } + + $placeholders = implode(', ', array_fill(0, count($this->tenantIds), '?')); + + return new Condition( + "{$this->column} IN ({$placeholders})", + $this->tenantIds, + ); + } + + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + $condition = $this->filter($table); + + $placement = match ($joinType) { + JoinType::Left, JoinType::Right => Placement::On, + default => Placement::Where, + }; + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Query/Hook/Join/Condition.php b/src/Query/Hook/Join/Condition.php new file mode 100644 index 0000000..517feb8 --- /dev/null +++ b/src/Query/Hook/Join/Condition.php @@ -0,0 +1,14 @@ + true, + default => false, + }; + } + + public function isSpatial(): bool + { + return match ($this) { + self::Crosses, + self::NotCrosses, + self::DistanceEqual, + self::DistanceNotEqual, + self::DistanceGreaterThan, + self::DistanceLessThan, + self::Intersects, + self::NotIntersects, + self::Overlaps, + self::NotOverlaps, + self::Touches, + self::NotTouches, + self::Covers, + self::NotCovers, + self::SpatialEquals, + self::NotSpatialEquals => true, + default => false, + }; + } + + public function isVector(): bool + { + return match ($this) { + self::VectorDot, + self::VectorCosine, + self::VectorEuclidean => true, + default => false, + }; + } + + public function isJson(): bool + { + return match ($this) { + self::JsonContains, + self::JsonNotContains, + self::JsonOverlaps, + self::JsonPath => true, + default => false, + }; + } + + public function isNested(): bool + { + return match ($this) { + self::And, + self::Or, + self::ElemMatch, + self::Having, + self::Union, + self::UnionAll => true, + default => false, + }; + } + + public function isAggregate(): bool + { + return match ($this) { + self::Count, + self::CountDistinct, + self::Sum, + self::Avg, + self::Min, + self::Max => true, + default => false, + }; + } + + public function isJoin(): bool + { + return match ($this) { + self::Join, + self::LeftJoin, + self::RightJoin, + self::CrossJoin => true, + default => false, + }; + } +} diff --git a/src/Query/OrderDirection.php b/src/Query/OrderDirection.php new file mode 100644 index 0000000..f6e212f --- /dev/null +++ b/src/Query/OrderDirection.php @@ -0,0 +1,10 @@ + $values */ - public function __construct(string $method, string $attribute = '', array $values = []) + public function __construct(Method|string $method, string $attribute = '', array $values = []) { - $this->method = $method; + $this->method = $method instanceof Method ? $method : Method::from($method); $this->attribute = $attribute; $this->values = $values; } @@ -224,7 +45,7 @@ public function __clone(): void } } - public function getMethod(): string + public function getMethod(): Method { return $this->method; } @@ -250,9 +71,9 @@ public function getValue(mixed $default = null): mixed /** * Sets method */ - public function setMethod(string $method): static + public function setMethod(Method|string $method): static { - $this->method = $method; + $this->method = $method instanceof Method ? $method : Method::from($method); return $this; } @@ -294,58 +115,7 @@ public function setValue(mixed $value): static */ public static function isMethod(string $value): bool { - return match ($value) { - self::TYPE_EQUAL, - self::TYPE_NOT_EQUAL, - self::TYPE_LESSER, - self::TYPE_LESSER_EQUAL, - self::TYPE_GREATER, - self::TYPE_GREATER_EQUAL, - self::TYPE_CONTAINS, - self::TYPE_CONTAINS_ANY, - self::TYPE_NOT_CONTAINS, - self::TYPE_SEARCH, - self::TYPE_NOT_SEARCH, - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_RANDOM, - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_IS_NULL, - self::TYPE_IS_NOT_NULL, - self::TYPE_BETWEEN, - self::TYPE_NOT_BETWEEN, - self::TYPE_STARTS_WITH, - self::TYPE_NOT_STARTS_WITH, - self::TYPE_ENDS_WITH, - self::TYPE_NOT_ENDS_WITH, - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES, - self::TYPE_OR, - self::TYPE_AND, - self::TYPE_CONTAINS_ALL, - self::TYPE_ELEM_MATCH, - self::TYPE_SELECT, - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS, - self::TYPE_REGEX => true, - default => false, - }; + return Method::tryFrom($value) !== null; } /** @@ -353,21 +123,7 @@ public static function isMethod(string $value): bool */ public function isSpatialQuery(): bool { - return match ($this->method) { - self::TYPE_CROSSES, - self::TYPE_NOT_CROSSES, - self::TYPE_DISTANCE_EQUAL, - self::TYPE_DISTANCE_NOT_EQUAL, - self::TYPE_DISTANCE_GREATER_THAN, - self::TYPE_DISTANCE_LESS_THAN, - self::TYPE_INTERSECTS, - self::TYPE_NOT_INTERSECTS, - self::TYPE_OVERLAPS, - self::TYPE_NOT_OVERLAPS, - self::TYPE_TOUCHES, - self::TYPE_NOT_TOUCHES => true, - default => false, - }; + return $this->method->isSpatial(); } /** @@ -420,14 +176,16 @@ public static function parseQuery(array $query): static throw new QueryException('Invalid query values. Must be an array, got '.\gettype($values)); } - if (\in_array($method, self::LOGICAL_TYPES, true)) { + $methodEnum = Method::from($method); + + if ($methodEnum->isNested()) { foreach ($values as $index => $value) { /** @var array $value */ $values[$index] = static::parseQuery($value); } } - return new static($method, $attribute, $values); + return new static($methodEnum, $attribute, $values); } /** @@ -454,13 +212,13 @@ public static function parseQueries(array $queries): array */ public function toArray(): array { - $array = ['method' => $this->method]; + $array = ['method' => $this->method->value]; if (! empty($this->attribute)) { $array['attribute'] = $this->attribute; } - if (\in_array($this->method, self::LOGICAL_TYPES, true)) { + if ($this->method->isNested()) { foreach ($this->values as $index => $value) { /** @var Query $value */ $array['values'][$index] = $value->toArray(); @@ -475,6 +233,36 @@ public function toArray(): array return $array; } + /** + * Compile this query using the given compiler + */ + public function compile(Compiler $compiler): string + { + return match ($this->method) { + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom => $compiler->compileOrder($this), + Method::Limit => $compiler->compileLimit($this), + Method::Offset => $compiler->compileOffset($this), + Method::CursorAfter, + Method::CursorBefore => $compiler->compileCursor($this), + Method::Select => $compiler->compileSelect($this), + Method::Count, + Method::CountDistinct, + Method::Sum, + Method::Avg, + Method::Min, + Method::Max => $compiler->compileAggregate($this), + Method::GroupBy => $compiler->compileGroupBy($this), + Method::Join, + Method::LeftJoin, + Method::RightJoin, + Method::CrossJoin => $compiler->compileJoin($this), + Method::Having => $compiler->compileFilter($this), + default => $compiler->compileFilter($this), + }; + } + /** * @throws QueryException */ @@ -490,26 +278,26 @@ public function toString(): string /** * Helper method to create Query with equal method * - * @param array> $values + * @param array> $values */ public static function equal(string $attribute, array $values): static { - return new static(self::TYPE_EQUAL, $attribute, $values); + return new static(Method::Equal, $attribute, $values); } /** * Helper method to create Query with notEqual method * - * @param string|int|float|bool|array $value + * @param string|int|float|bool|null|array $value */ - public static function notEqual(string $attribute, string|int|float|bool|array $value): static + public static function notEqual(string $attribute, string|int|float|bool|array|null $value): static { // maps or not an array if ((is_array($value) && ! array_is_list($value)) || ! is_array($value)) { $value = [$value]; } - return new static(self::TYPE_NOT_EQUAL, $attribute, $value); + return new static(Method::NotEqual, $attribute, $value); } /** @@ -517,7 +305,7 @@ public static function notEqual(string $attribute, string|int|float|bool|array $ */ public static function lessThan(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_LESSER, $attribute, [$value]); + return new static(Method::LessThan, $attribute, [$value]); } /** @@ -525,7 +313,7 @@ public static function lessThan(string $attribute, string|int|float|bool $value) */ public static function lessThanEqual(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_LESSER_EQUAL, $attribute, [$value]); + return new static(Method::LessThanEqual, $attribute, [$value]); } /** @@ -533,7 +321,7 @@ public static function lessThanEqual(string $attribute, string|int|float|bool $v */ public static function greaterThan(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_GREATER, $attribute, [$value]); + return new static(Method::GreaterThan, $attribute, [$value]); } /** @@ -541,19 +329,18 @@ public static function greaterThan(string $attribute, string|int|float|bool $val */ public static function greaterThanEqual(string $attribute, string|int|float|bool $value): static { - return new static(self::TYPE_GREATER_EQUAL, $attribute, [$value]); + return new static(Method::GreaterThanEqual, $attribute, [$value]); } /** * Helper method to create Query with contains method * - * @deprecated Use containsAny() for array attributes, or keep using contains() for string substring matching. - * * @param array $values */ + #[\Deprecated('Use containsAny() for array attributes, or keep using contains() for string substring matching.')] public static function contains(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS, $attribute, $values); + return new static(Method::Contains, $attribute, $values); } /** @@ -564,7 +351,7 @@ public static function contains(string $attribute, array $values): static */ public static function containsAny(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS_ANY, $attribute, $values); + return new static(Method::ContainsAny, $attribute, $values); } /** @@ -574,7 +361,7 @@ public static function containsAny(string $attribute, array $values): static */ public static function notContains(string $attribute, array $values): static { - return new static(self::TYPE_NOT_CONTAINS, $attribute, $values); + return new static(Method::NotContains, $attribute, $values); } /** @@ -582,7 +369,7 @@ public static function notContains(string $attribute, array $values): static */ public static function between(string $attribute, string|int|float|bool $start, string|int|float|bool $end): static { - return new static(self::TYPE_BETWEEN, $attribute, [$start, $end]); + return new static(Method::Between, $attribute, [$start, $end]); } /** @@ -590,7 +377,7 @@ public static function between(string $attribute, string|int|float|bool $start, */ public static function notBetween(string $attribute, string|int|float|bool $start, string|int|float|bool $end): static { - return new static(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); + return new static(Method::NotBetween, $attribute, [$start, $end]); } /** @@ -598,7 +385,7 @@ public static function notBetween(string $attribute, string|int|float|bool $star */ public static function search(string $attribute, string $value): static { - return new static(self::TYPE_SEARCH, $attribute, [$value]); + return new static(Method::Search, $attribute, [$value]); } /** @@ -606,7 +393,7 @@ public static function search(string $attribute, string $value): static */ public static function notSearch(string $attribute, string $value): static { - return new static(self::TYPE_NOT_SEARCH, $attribute, [$value]); + return new static(Method::NotSearch, $attribute, [$value]); } /** @@ -616,7 +403,7 @@ public static function notSearch(string $attribute, string $value): static */ public static function select(array $attributes): static { - return new static(self::TYPE_SELECT, values: $attributes); + return new static(Method::Select, values: $attributes); } /** @@ -624,7 +411,7 @@ public static function select(array $attributes): static */ public static function orderDesc(string $attribute = ''): static { - return new static(self::TYPE_ORDER_DESC, $attribute); + return new static(Method::OrderDesc, $attribute); } /** @@ -632,7 +419,7 @@ public static function orderDesc(string $attribute = ''): static */ public static function orderAsc(string $attribute = ''): static { - return new static(self::TYPE_ORDER_ASC, $attribute); + return new static(Method::OrderAsc, $attribute); } /** @@ -640,7 +427,7 @@ public static function orderAsc(string $attribute = ''): static */ public static function orderRandom(): static { - return new static(self::TYPE_ORDER_RANDOM); + return new static(Method::OrderRandom); } /** @@ -648,7 +435,7 @@ public static function orderRandom(): static */ public static function limit(int $value): static { - return new static(self::TYPE_LIMIT, values: [$value]); + return new static(Method::Limit, values: [$value]); } /** @@ -656,7 +443,7 @@ public static function limit(int $value): static */ public static function offset(int $value): static { - return new static(self::TYPE_OFFSET, values: [$value]); + return new static(Method::Offset, values: [$value]); } /** @@ -664,7 +451,7 @@ public static function offset(int $value): static */ public static function cursorAfter(mixed $value): static { - return new static(self::TYPE_CURSOR_AFTER, values: [$value]); + return new static(Method::CursorAfter, values: [$value]); } /** @@ -672,7 +459,7 @@ public static function cursorAfter(mixed $value): static */ public static function cursorBefore(mixed $value): static { - return new static(self::TYPE_CURSOR_BEFORE, values: [$value]); + return new static(Method::CursorBefore, values: [$value]); } /** @@ -680,7 +467,7 @@ public static function cursorBefore(mixed $value): static */ public static function isNull(string $attribute): static { - return new static(self::TYPE_IS_NULL, $attribute); + return new static(Method::IsNull, $attribute); } /** @@ -688,27 +475,27 @@ public static function isNull(string $attribute): static */ public static function isNotNull(string $attribute): static { - return new static(self::TYPE_IS_NOT_NULL, $attribute); + return new static(Method::IsNotNull, $attribute); } public static function startsWith(string $attribute, string $value): static { - return new static(self::TYPE_STARTS_WITH, $attribute, [$value]); + return new static(Method::StartsWith, $attribute, [$value]); } public static function notStartsWith(string $attribute, string $value): static { - return new static(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + return new static(Method::NotStartsWith, $attribute, [$value]); } public static function endsWith(string $attribute, string $value): static { - return new static(self::TYPE_ENDS_WITH, $attribute, [$value]); + return new static(Method::EndsWith, $attribute, [$value]); } public static function notEndsWith(string $attribute, string $value): static { - return new static(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + return new static(Method::NotEndsWith, $attribute, [$value]); } /** @@ -764,7 +551,7 @@ public static function updatedBetween(string $start, string $end): static */ public static function or(array $queries): static { - return new static(self::TYPE_OR, '', $queries); + return new static(Method::Or, '', $queries); } /** @@ -772,7 +559,7 @@ public static function or(array $queries): static */ public static function and(array $queries): static { - return new static(self::TYPE_AND, '', $queries); + return new static(Method::And, '', $queries); } /** @@ -780,14 +567,14 @@ public static function and(array $queries): static */ public static function containsAll(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS_ALL, $attribute, $values); + return new static(Method::ContainsAll, $attribute, $values); } /** * Filters $queries for $types * * @param array $queries - * @param array $types + * @param array $types * @return array */ public static function getByType(array $queries, array $types, bool $clone = true): array @@ -812,8 +599,8 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr return self::getByType( $queries, [ - Query::TYPE_CURSOR_AFTER, - Query::TYPE_CURSOR_BEFORE, + Method::CursorAfter, + Method::CursorBefore, ], $clone ); @@ -823,21 +610,17 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * Iterates through queries and groups them by type * * @param array $queries - * @return array{ - * filters: array, - * selections: array, - * limit: int|null, - * offset: int|null, - * orderAttributes: array, - * orderTypes: array, - * cursor: mixed, - * cursorDirection: string|null - * } - */ - public static function groupByType(array $queries): array + */ + public static function groupByType(array $queries): GroupedQueries { $filters = []; $selections = []; + $aggregations = []; + $groupBy = []; + $having = []; + $distinct = false; + $joins = []; + $unions = []; $limit = null; $offset = null; $orderAttributes = []; @@ -855,21 +638,21 @@ public static function groupByType(array $queries): array $values = $query->getValues(); switch ($method) { - case Query::TYPE_ORDER_ASC: - case Query::TYPE_ORDER_DESC: - case Query::TYPE_ORDER_RANDOM: + case Method::OrderAsc: + case Method::OrderDesc: + case Method::OrderRandom: if (! empty($attribute)) { $orderAttributes[] = $attribute; } $orderTypes[] = match ($method) { - Query::TYPE_ORDER_ASC => self::ORDER_ASC, - Query::TYPE_ORDER_DESC => self::ORDER_DESC, - Query::TYPE_ORDER_RANDOM => self::ORDER_RANDOM, + Method::OrderAsc => OrderDirection::Asc, + Method::OrderDesc => OrderDirection::Desc, + Method::OrderRandom => OrderDirection::Random, }; break; - case Query::TYPE_LIMIT: + case Method::Limit: // Keep the 1st limit encountered and ignore the rest if ($limit !== null) { break; @@ -877,7 +660,7 @@ public static function groupByType(array $queries): array $limit = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $limit; break; - case Query::TYPE_OFFSET: + case Method::Offset: // Keep the 1st offset encountered and ignore the rest if ($offset !== null) { break; @@ -885,37 +668,79 @@ public static function groupByType(array $queries): array $offset = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $offset; break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: + case Method::CursorAfter: + case Method::CursorBefore: // Keep the 1st cursor encountered and ignore the rest if ($cursor !== null) { break; } $cursor = $values[0] ?? $limit; - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? self::CURSOR_AFTER : self::CURSOR_BEFORE; + $cursorDirection = $method === Method::CursorAfter ? CursorDirection::After : CursorDirection::Before; break; - case Query::TYPE_SELECT: + case Method::Select: $selections[] = clone $query; break; + case Method::Count: + case Method::CountDistinct: + case Method::Sum: + case Method::Avg: + case Method::Min: + case Method::Max: + $aggregations[] = clone $query; + break; + + case Method::GroupBy: + /** @var array $values */ + foreach ($values as $col) { + $groupBy[] = $col; + } + break; + + case Method::Having: + $having[] = clone $query; + break; + + case Method::Distinct: + $distinct = true; + break; + + case Method::Join: + case Method::LeftJoin: + case Method::RightJoin: + case Method::CrossJoin: + $joins[] = clone $query; + break; + + case Method::Union: + case Method::UnionAll: + $unions[] = clone $query; + break; + default: $filters[] = clone $query; break; } } - return [ - 'filters' => $filters, - 'selections' => $selections, - 'limit' => $limit, - 'offset' => $offset, - 'orderAttributes' => $orderAttributes, - 'orderTypes' => $orderTypes, - 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, - ]; + return new GroupedQueries( + filters: $filters, + selections: $selections, + aggregations: $aggregations, + groupBy: $groupBy, + having: $having, + distinct: $distinct, + joins: $joins, + unions: $unions, + limit: $limit, + offset: $offset, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + cursor: $cursor, + cursorDirection: $cursorDirection, + ); } /** @@ -923,11 +748,7 @@ public static function groupByType(array $queries): array */ public function isNested(): bool { - if (\in_array($this->getMethod(), self::LOGICAL_TYPES, true)) { - return true; - } - - return false; + return $this->method->isNested(); } public function onArray(): bool @@ -959,7 +780,7 @@ public function getAttributeType(): string */ public static function distanceEqual(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_EQUAL, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceEqual, $attribute, [[$values, $distance, $meters]]); } /** @@ -969,7 +790,7 @@ public static function distanceEqual(string $attribute, array $values, int|float */ public static function distanceNotEqual(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_NOT_EQUAL, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceNotEqual, $attribute, [[$values, $distance, $meters]]); } /** @@ -979,7 +800,7 @@ public static function distanceNotEqual(string $attribute, array $values, int|fl */ public static function distanceGreaterThan(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_GREATER_THAN, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceGreaterThan, $attribute, [[$values, $distance, $meters]]); } /** @@ -989,7 +810,7 @@ public static function distanceGreaterThan(string $attribute, array $values, int */ public static function distanceLessThan(string $attribute, array $values, int|float $distance, bool $meters = false): static { - return new static(self::TYPE_DISTANCE_LESS_THAN, $attribute, [[$values, $distance, $meters]]); + return new static(Method::DistanceLessThan, $attribute, [[$values, $distance, $meters]]); } /** @@ -999,7 +820,7 @@ public static function distanceLessThan(string $attribute, array $values, int|fl */ public static function intersects(string $attribute, array $values): static { - return new static(self::TYPE_INTERSECTS, $attribute, [$values]); + return new static(Method::Intersects, $attribute, [$values]); } /** @@ -1009,7 +830,7 @@ public static function intersects(string $attribute, array $values): static */ public static function notIntersects(string $attribute, array $values): static { - return new static(self::TYPE_NOT_INTERSECTS, $attribute, [$values]); + return new static(Method::NotIntersects, $attribute, [$values]); } /** @@ -1019,7 +840,7 @@ public static function notIntersects(string $attribute, array $values): static */ public static function crosses(string $attribute, array $values): static { - return new static(self::TYPE_CROSSES, $attribute, [$values]); + return new static(Method::Crosses, $attribute, [$values]); } /** @@ -1029,7 +850,7 @@ public static function crosses(string $attribute, array $values): static */ public static function notCrosses(string $attribute, array $values): static { - return new static(self::TYPE_NOT_CROSSES, $attribute, [$values]); + return new static(Method::NotCrosses, $attribute, [$values]); } /** @@ -1039,7 +860,7 @@ public static function notCrosses(string $attribute, array $values): static */ public static function overlaps(string $attribute, array $values): static { - return new static(self::TYPE_OVERLAPS, $attribute, [$values]); + return new static(Method::Overlaps, $attribute, [$values]); } /** @@ -1049,7 +870,7 @@ public static function overlaps(string $attribute, array $values): static */ public static function notOverlaps(string $attribute, array $values): static { - return new static(self::TYPE_NOT_OVERLAPS, $attribute, [$values]); + return new static(Method::NotOverlaps, $attribute, [$values]); } /** @@ -1059,7 +880,7 @@ public static function notOverlaps(string $attribute, array $values): static */ public static function touches(string $attribute, array $values): static { - return new static(self::TYPE_TOUCHES, $attribute, [$values]); + return new static(Method::Touches, $attribute, [$values]); } /** @@ -1069,7 +890,7 @@ public static function touches(string $attribute, array $values): static */ public static function notTouches(string $attribute, array $values): static { - return new static(self::TYPE_NOT_TOUCHES, $attribute, [$values]); + return new static(Method::NotTouches, $attribute, [$values]); } /** @@ -1079,7 +900,7 @@ public static function notTouches(string $attribute, array $values): static */ public static function vectorDot(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_DOT, $attribute, [$vector]); + return new static(Method::VectorDot, $attribute, [$vector]); } /** @@ -1089,7 +910,7 @@ public static function vectorDot(string $attribute, array $vector): static */ public static function vectorCosine(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_COSINE, $attribute, [$vector]); + return new static(Method::VectorCosine, $attribute, [$vector]); } /** @@ -1099,7 +920,7 @@ public static function vectorCosine(string $attribute, array $vector): static */ public static function vectorEuclidean(string $attribute, array $vector): static { - return new static(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); + return new static(Method::VectorEuclidean, $attribute, [$vector]); } /** @@ -1107,7 +928,7 @@ public static function vectorEuclidean(string $attribute, array $vector): static */ public static function regex(string $attribute, string $pattern): static { - return new static(self::TYPE_REGEX, $attribute, [$pattern]); + return new static(Method::Regex, $attribute, [$pattern]); } /** @@ -1117,7 +938,7 @@ public static function regex(string $attribute, string $pattern): static */ public static function exists(array $attributes): static { - return new static(self::TYPE_EXISTS, '', $attributes); + return new static(Method::Exists, '', $attributes); } /** @@ -1127,7 +948,7 @@ public static function exists(array $attributes): static */ public static function notExists(string|int|float|bool|array $attribute): static { - return new static(self::TYPE_NOT_EXISTS, '', is_array($attribute) ? $attribute : [$attribute]); + return new static(Method::NotExists, '', is_array($attribute) ? $attribute : [$attribute]); } /** @@ -1135,6 +956,329 @@ public static function notExists(string|int|float|bool|array $attribute): static */ public static function elemMatch(string $attribute, array $queries): static { - return new static(self::TYPE_ELEM_MATCH, $attribute, $queries); + return new static(Method::ElemMatch, $attribute, $queries); + } + + // Aggregation factory methods + + public static function count(string $attribute = '*', string $alias = ''): static + { + return new static(Method::Count, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function countDistinct(string $attribute, string $alias = ''): static + { + return new static(Method::CountDistinct, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function sum(string $attribute, string $alias = ''): static + { + return new static(Method::Sum, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function avg(string $attribute, string $alias = ''): static + { + return new static(Method::Avg, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function min(string $attribute, string $alias = ''): static + { + return new static(Method::Min, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function max(string $attribute, string $alias = ''): static + { + return new static(Method::Max, $attribute, $alias !== '' ? [$alias] : []); + } + + /** + * @param array $attributes + */ + public static function groupBy(array $attributes): static + { + return new static(Method::GroupBy, '', $attributes); + } + + /** + * @param array $queries + */ + public static function having(array $queries): static + { + return new static(Method::Having, '', $queries); + } + + public static function distinct(): static + { + return new static(Method::Distinct); + } + + // Join factory methods + + public static function join(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::Join, $table, $values); + } + + public static function leftJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::LeftJoin, $table, $values); + } + + public static function rightJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::RightJoin, $table, $values); + } + + public static function crossJoin(string $table, string $alias = ''): static + { + return new static(Method::CrossJoin, $table, $alias !== '' ? [$alias] : []); + } + + // Union factory methods + + /** + * @param array $queries + */ + public static function union(array $queries): static + { + return new static(Method::Union, '', $queries); + } + + /** + * @param array $queries + */ + public static function unionAll(array $queries): static + { + return new static(Method::UnionAll, '', $queries); + } + + // JSON factory methods + + public static function jsonContains(string $attribute, mixed $value): static + { + return new static(Method::JsonContains, $attribute, [$value]); + } + + public static function jsonNotContains(string $attribute, mixed $value): static + { + return new static(Method::JsonNotContains, $attribute, [$value]); + } + + /** + * @param array $values + */ + public static function jsonOverlaps(string $attribute, array $values): static + { + return new static(Method::JsonOverlaps, $attribute, [$values]); + } + + public static function jsonPath(string $attribute, string $path, string $operator, mixed $value): static + { + return new static(Method::JsonPath, $attribute, [$path, $operator, $value]); + } + + // Spatial predicate extras + + /** + * @param array $values + */ + public static function covers(string $attribute, array $values): static + { + return new static(Method::Covers, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function notCovers(string $attribute, array $values): static + { + return new static(Method::NotCovers, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function spatialEquals(string $attribute, array $values): static + { + return new static(Method::SpatialEquals, $attribute, [$values]); + } + + /** + * @param array $values + */ + public static function notSpatialEquals(string $attribute, array $values): static + { + return new static(Method::NotSpatialEquals, $attribute, [$values]); + } + + // Raw factory method + + /** + * @param array $bindings + */ + public static function raw(string $sql, array $bindings = []): static + { + return new static(Method::Raw, $sql, $bindings); + } + + // Convenience: page + + /** + * Returns an array of limit and offset queries for page-based pagination + * + * @return array{0: static, 1: static} + */ + public static function page(int $page, int $perPage = 25): array + { + if ($page < 1) { + throw new \Utopia\Query\Exception\ValidationException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new \Utopia\Query\Exception\ValidationException('Per page must be >= 1, got ' . $perPage); + } + + return [ + static::limit($perPage), + static::offset(($page - 1) * $perPage), + ]; + } + + // Static helpers + + /** + * Merge two query arrays. For limit/offset/cursor, values from $queriesB override $queriesA. + * + * @param array $queriesA + * @param array $queriesB + * @return array + */ + public static function merge(array $queriesA, array $queriesB): array + { + $singularTypes = [ + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, + ]; + + $result = $queriesA; + + foreach ($queriesB as $queryB) { + $method = $queryB->getMethod(); + + if (\in_array($method, $singularTypes, true)) { + // Remove existing queries of the same type from result + $result = \array_values(\array_filter( + $result, + fn (Query $q): bool => $q->getMethod() !== $method + )); + } + + $result[] = $queryB; + } + + return $result; + } + + /** + * Returns queries in A that are not in B (compared by toArray()) + * + * @param array $queriesA + * @param array $queriesB + * @return array + */ + public static function diff(array $queriesA, array $queriesB): array + { + $bArrays = \array_map(fn (Query $q): array => $q->toArray(), $queriesB); + + $result = []; + foreach ($queriesA as $queryA) { + $aArray = $queryA->toArray(); + if (! array_any($bArrays, fn (array $b): bool => $aArray === $b)) { + $result[] = $queryA; + } + } + + return $result; + } + + /** + * Validate queries against allowed attributes + * + * @param array $queries + * @param array $allowedAttributes + * @return array Error messages + */ + public static function validate(array $queries, array $allowedAttributes): array + { + $errors = []; + $skipTypes = [ + Method::Limit, + Method::Offset, + Method::CursorAfter, + Method::CursorBefore, + Method::OrderRandom, + Method::Distinct, + Method::Select, + Method::Exists, + Method::NotExists, + ]; + + foreach ($queries as $query) { + $method = $query->getMethod(); + + // Recursively validate nested queries + if ($method->isNested()) { + /** @var array $nested */ + $nested = $query->getValues(); + $errors = \array_merge($errors, static::validate($nested, $allowedAttributes)); + + continue; + } + + if (\in_array($method, $skipTypes, true)) { + continue; + } + + // GROUP_BY stores attributes in values + if ($method === Method::GroupBy) { + /** @var array $columns */ + $columns = $query->getValues(); + foreach ($columns as $col) { + if (! \in_array($col, $allowedAttributes, true)) { + $errors[] = "Invalid attribute \"{$col}\" used in {$method->value}"; + } + } + + continue; + } + + $attribute = $query->getAttribute(); + + if ($attribute === '' || $attribute === '*') { + continue; + } + + if (! \in_array($attribute, $allowedAttributes, true)) { + $errors[] = "Invalid attribute \"{$attribute}\" used in {$method->value}"; + } + } + + return $errors; } } diff --git a/src/Query/QuotesIdentifiers.php b/src/Query/QuotesIdentifiers.php new file mode 100644 index 0000000..2f30151 --- /dev/null +++ b/src/Query/QuotesIdentifiers.php @@ -0,0 +1,18 @@ + $segment === '*' + ? '*' + : $this->wrapChar . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) . $this->wrapChar, $segments); + + return \implode('.', $wrapped); + } +} diff --git a/src/Query/Schema.php b/src/Query/Schema.php new file mode 100644 index 0000000..d60892f --- /dev/null +++ b/src/Query/Schema.php @@ -0,0 +1,369 @@ +columns as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + if ($column->isUnique) { + $uniqueColumns[] = $column->name; + } + } + + // Raw column definitions (bypass typed Column objects) + foreach ($blueprint->rawColumnDefs as $rawDef) { + $columnDefs[] = $rawDef; + } + + // Inline PRIMARY KEY constraint + if (! empty($primaryKeys)) { + $columnDefs[] = 'PRIMARY KEY (' . \implode(', ', $primaryKeys) . ')'; + } + + // Inline UNIQUE constraints for columns marked unique + foreach ($uniqueColumns as $col) { + $columnDefs[] = 'UNIQUE (' . $this->quote($col) . ')'; + } + + // Indexes + foreach ($blueprint->indexes as $index) { + $keyword = match ($index->type) { + IndexType::Unique => 'UNIQUE INDEX', + IndexType::Fulltext => 'FULLTEXT INDEX', + IndexType::Spatial => 'SPATIAL INDEX', + default => 'INDEX', + }; + $columnDefs[] = $keyword . ' ' . $this->quote($index->name) + . ' (' . $this->compileIndexColumns($index) . ')'; + } + + // Raw index definitions (bypass typed Index objects) + foreach ($blueprint->rawIndexDefs as $rawIdx) { + $columnDefs[] = $rawIdx; + } + + // Foreign keys + foreach ($blueprint->foreignKeys as $fk) { + $def = 'FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; + } + $columnDefs[] = $def; + } + + $sql = 'CREATE TABLE ' . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')'; + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->columns as $column) { + $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; + $def = $keyword . ' ' . $this->compileColumnDefinition($column); + if ($column->after !== null) { + $def .= ' AFTER ' . $this->quote($column->after); + } + $alterations[] = $def; + } + + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); + } + + foreach ($blueprint->dropColumns as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->indexes as $index) { + $keyword = match ($index->type) { + IndexType::Unique => 'ADD UNIQUE INDEX', + IndexType::Fulltext => 'ADD FULLTEXT INDEX', + IndexType::Spatial => 'ADD SPATIAL INDEX', + default => 'ADD INDEX', + }; + $alterations[] = $keyword . ' ' . $this->quote($index->name) + . ' (' . $this->compileIndexColumns($index) . ')'; + } + + foreach ($blueprint->dropIndexes as $name) { + $alterations[] = 'DROP INDEX ' . $this->quote($name); + } + + foreach ($blueprint->foreignKeys as $fk) { + $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; + } + $alterations[] = $def; + } + + foreach ($blueprint->dropForeignKeys as $name) { + $alterations[] = 'DROP FOREIGN KEY ' . $this->quote($name); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new BuildResult($sql, []); + } + + public function drop(string $table): BuildResult + { + return new BuildResult('DROP TABLE ' . $this->quote($table), []); + } + + public function dropIfExists(string $table): BuildResult + { + return new BuildResult('DROP TABLE IF EXISTS ' . $this->quote($table), []); + } + + public function rename(string $from, string $to): BuildResult + { + return new BuildResult( + 'RENAME TABLE ' . $this->quote($from) . ' TO ' . $this->quote($to), + [] + ); + } + + public function truncate(string $table): BuildResult + { + return new BuildResult('TRUNCATE TABLE ' . $this->quote($table), []); + } + + /** + * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns Raw SQL expressions appended to column list (bypass quoting) + */ + public function createIndex( + string $table, + string $name, + array $columns, + bool $unique = false, + string $type = '', + string $method = '', + string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + array $rawColumns = [], + ): BuildResult { + $keyword = match (true) { + $unique => 'CREATE UNIQUE INDEX', + $type === 'fulltext' => 'CREATE FULLTEXT INDEX', + $type === 'spatial' => 'CREATE SPATIAL INDEX', + default => 'CREATE INDEX', + }; + + $indexType = $unique ? IndexType::Unique : ($type !== '' ? IndexType::from($type) : IndexType::Index); + $index = new Schema\Index($name, $columns, $indexType, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); + + $sql = $keyword . ' ' . $this->quote($name) + . ' ON ' . $this->quote($table); + + if ($method !== '') { + $sql .= ' USING ' . \strtoupper($method); + } + + $sql .= ' (' . $this->compileIndexColumns($index) . ')'; + + return new BuildResult($sql, []); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'DROP INDEX ' . $this->quote($name) . ' ON ' . $this->quote($table), + [] + ); + } + + public function createView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + public function createOrReplaceView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE OR REPLACE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + public function dropView(string $name): BuildResult + { + return new BuildResult('DROP VIEW ' . $this->quote($name), []); + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->isUnsigned) { + $unsigned = $this->compileUnsigned(); + if ($unsigned !== '') { + $parts[] = $unsigned; + } + } + + if ($column->isAutoIncrement) { + $parts[] = $this->compileAutoIncrement(); + } + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace("'", "''", $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + protected function compileDefaultValue(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if (\is_bool($value)) { + return $value ? '1' : '0'; + } + if (\is_int($value) || \is_float($value)) { + return (string) $value; + } + + /** @var string|int|float $value */ + return "'" . \str_replace("'", "''", (string) $value) . "'"; + } + + protected function compileUnsigned(): string + { + return 'UNSIGNED'; + } + + /** + * Compile index column list with lengths, orders, collations, and operator classes. + */ + protected function compileIndexColumns(Schema\Index $index): string + { + $parts = []; + + foreach ($index->columns as $col) { + $part = $this->quote($col); + + if (isset($index->collations[$col])) { + $part .= ' COLLATE ' . $index->collations[$col]; + } + + if (isset($index->lengths[$col])) { + $part .= '(' . $index->lengths[$col] . ')'; + } + + if ($index->operatorClass !== '') { + $part .= ' ' . $index->operatorClass; + } + + if (isset($index->orders[$col])) { + $part .= ' ' . \strtoupper($index->orders[$col]); + } + + $parts[] = $part; + } + + // Append raw expressions (bypass quoting) — for CAST ARRAY, JSONB paths, etc. + foreach ($index->rawColumns as $raw) { + $parts[] = $raw; + } + + return \implode(', ', $parts); + } + + public function renameIndex(string $table, string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) . ' RENAME INDEX ' . $this->quote($from) . ' TO ' . $this->quote($to), + [] + ); + } + + public function createDatabase(string $name): BuildResult + { + return new BuildResult('CREATE DATABASE ' . $this->quote($name), []); + } + + public function dropDatabase(string $name): BuildResult + { + return new BuildResult('DROP DATABASE ' . $this->quote($name), []); + } + + public function analyzeTable(string $table): BuildResult + { + return new BuildResult('ANALYZE TABLE ' . $this->quote($table), []); + } +} diff --git a/src/Query/Schema/Blueprint.php b/src/Query/Schema/Blueprint.php new file mode 100644 index 0000000..c15d2f6 --- /dev/null +++ b/src/Query/Schema/Blueprint.php @@ -0,0 +1,360 @@ + */ + public private(set) array $columns = []; + + /** @var list */ + public private(set) array $indexes = []; + + /** @var list */ + public private(set) array $foreignKeys = []; + + /** @var list */ + public private(set) array $dropColumns = []; + + /** @var list */ + public private(set) array $renameColumns = []; + + /** @var list */ + public private(set) array $dropIndexes = []; + + /** @var list */ + public private(set) array $dropForeignKeys = []; + + /** @var list Raw SQL column definitions (bypass typed Column objects) */ + public private(set) array $rawColumnDefs = []; + + /** @var list Raw SQL index definitions (bypass typed Index objects) */ + public private(set) array $rawIndexDefs = []; + + public function id(string $name = 'id'): Column + { + $col = new Column($name, ColumnType::BigInteger); + $col->isUnsigned = true; + $col->isAutoIncrement = true; + $col->isPrimary = true; + $this->columns[] = $col; + + return $col; + } + + public function string(string $name, int $length = 255): Column + { + $col = new Column($name, ColumnType::String, $length); + $this->columns[] = $col; + + return $col; + } + + public function text(string $name): Column + { + $col = new Column($name, ColumnType::Text); + $this->columns[] = $col; + + return $col; + } + + public function mediumText(string $name): Column + { + $col = new Column($name, ColumnType::MediumText); + $this->columns[] = $col; + + return $col; + } + + public function longText(string $name): Column + { + $col = new Column($name, ColumnType::LongText); + $this->columns[] = $col; + + return $col; + } + + public function integer(string $name): Column + { + $col = new Column($name, ColumnType::Integer); + $this->columns[] = $col; + + return $col; + } + + public function bigInteger(string $name): Column + { + $col = new Column($name, ColumnType::BigInteger); + $this->columns[] = $col; + + return $col; + } + + public function float(string $name): Column + { + $col = new Column($name, ColumnType::Float); + $this->columns[] = $col; + + return $col; + } + + public function boolean(string $name): Column + { + $col = new Column($name, ColumnType::Boolean); + $this->columns[] = $col; + + return $col; + } + + public function datetime(string $name, int $precision = 0): Column + { + $col = new Column($name, ColumnType::Datetime, precision: $precision); + $this->columns[] = $col; + + return $col; + } + + public function timestamp(string $name, int $precision = 0): Column + { + $col = new Column($name, ColumnType::Timestamp, precision: $precision); + $this->columns[] = $col; + + return $col; + } + + public function json(string $name): Column + { + $col = new Column($name, ColumnType::Json); + $this->columns[] = $col; + + return $col; + } + + public function binary(string $name): Column + { + $col = new Column($name, ColumnType::Binary); + $this->columns[] = $col; + + return $col; + } + + /** + * @param string[] $values + */ + public function enum(string $name, array $values): Column + { + $col = new Column($name, ColumnType::Enum); + $col->enumValues = $values; + $this->columns[] = $col; + + return $col; + } + + public function point(string $name, int $srid = 4326): Column + { + $col = new Column($name, ColumnType::Point); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function linestring(string $name, int $srid = 4326): Column + { + $col = new Column($name, ColumnType::Linestring); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function polygon(string $name, int $srid = 4326): Column + { + $col = new Column($name, ColumnType::Polygon); + $col->srid = $srid; + $this->columns[] = $col; + + return $col; + } + + public function vector(string $name, int $dimensions): Column + { + $col = new Column($name, ColumnType::Vector); + $col->dimensions = $dimensions; + $this->columns[] = $col; + + return $col; + } + + public function timestamps(int $precision = 3): void + { + $this->datetime('created_at', $precision); + $this->datetime('updated_at', $precision); + } + + /** + * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + */ + public function index( + array $columns, + string $name = '', + string $method = '', + string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + ): void { + if ($name === '') { + $name = 'idx_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Index, $lengths, $orders, $method, $operatorClass, $collations); + } + + /** + * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + */ + public function uniqueIndex( + array $columns, + string $name = '', + array $lengths = [], + array $orders = [], + array $collations = [], + ): void { + if ($name === '') { + $name = 'uniq_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Unique, $lengths, $orders, collations: $collations); + } + + /** + * @param string[] $columns + */ + public function fulltextIndex(array $columns, string $name = ''): void + { + if ($name === '') { + $name = 'ft_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Fulltext); + } + + /** + * @param string[] $columns + */ + public function spatialIndex(array $columns, string $name = ''): void + { + if ($name === '') { + $name = 'sp_' . \implode('_', $columns); + } + $this->indexes[] = new Index($name, $columns, IndexType::Spatial); + } + + public function foreignKey(string $column): ForeignKey + { + $fk = new ForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function addColumn(string $name, ColumnType|string $type, int|null $lengthOrPrecision = null): Column + { + if (\is_string($type)) { + $type = ColumnType::from($type); + } + $col = new Column($name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null); + $this->columns[] = $col; + + return $col; + } + + public function modifyColumn(string $name, ColumnType|string $type, int|null $lengthOrPrecision = null): Column + { + if (\is_string($type)) { + $type = ColumnType::from($type); + } + $col = new Column($name, $type, $type === ColumnType::String ? $lengthOrPrecision : null, $type !== ColumnType::String ? $lengthOrPrecision : null); + $col->isModify = true; + $this->columns[] = $col; + + return $col; + } + + public function renameColumn(string $from, string $to): void + { + $this->renameColumns[] = new RenameColumn($from, $to); + } + + public function dropColumn(string $name): void + { + $this->dropColumns[] = $name; + } + + /** + * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns Raw SQL expressions appended to column list (bypass quoting) + */ + public function addIndex( + string $name, + array $columns, + IndexType|string $type = IndexType::Index, + array $lengths = [], + array $orders = [], + string $method = '', + string $operatorClass = '', + array $collations = [], + array $rawColumns = [], + ): void { + if (\is_string($type)) { + $type = IndexType::from($type); + } + $this->indexes[] = new Index($name, $columns, $type, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); + } + + public function dropIndex(string $name): void + { + $this->dropIndexes[] = $name; + } + + public function addForeignKey(string $column): ForeignKey + { + $fk = new ForeignKey($column); + $this->foreignKeys[] = $fk; + + return $fk; + } + + public function dropForeignKey(string $name): void + { + $this->dropForeignKeys[] = $name; + } + + /** + * Add a raw SQL column definition (bypass typed Column objects). + * + * Example: $table->rawColumn('`my_col` VARCHAR(255) NOT NULL DEFAULT ""') + */ + public function rawColumn(string $definition): void + { + $this->rawColumnDefs[] = $definition; + } + + /** + * Add a raw SQL index definition (bypass typed Index objects). + * + * Example: $table->rawIndex('INDEX `idx_name` (`col1`, `col2`)') + */ + public function rawIndex(string $definition): void + { + $this->rawIndexDefs[] = $definition; + } + +} diff --git a/src/Query/Schema/ClickHouse.php b/src/Query/Schema/ClickHouse.php new file mode 100644 index 0000000..c592132 --- /dev/null +++ b/src/Query/Schema/ClickHouse.php @@ -0,0 +1,185 @@ +type) { + ColumnType::String => 'String', + ColumnType::Text => 'String', + ColumnType::MediumText, ColumnType::LongText => 'String', + ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32', + ColumnType::BigInteger => $column->isUnsigned ? 'UInt64' : 'Int64', + ColumnType::Float => 'Float64', + ColumnType::Boolean => 'UInt8', + ColumnType::Datetime => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Timestamp => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Json => 'String', + ColumnType::Binary => 'String', + ColumnType::Enum => $this->compileClickHouseEnum($column->enumValues), + ColumnType::Point => 'Tuple(Float64, Float64)', + ColumnType::Linestring => 'Array(Tuple(Float64, Float64))', + ColumnType::Polygon => 'Array(Array(Tuple(Float64, Float64)))', + ColumnType::Vector => 'Array(Float64)', + }; + + if ($column->isNullable) { + $type = 'Nullable(' . $type . ')'; + } + + return $type; + } + + protected function compileAutoIncrement(): string + { + return ''; + } + + protected function compileUnsigned(): string + { + return ''; + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace("'", "''", $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP INDEX ' . $this->quote($name), + [] + ); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->columns as $column) { + $keyword = $column->isModify ? 'MODIFY COLUMN' : 'ADD COLUMN'; + $alterations[] = $keyword . ' ' . $this->compileColumnDefinition($column); + } + + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); + } + + foreach ($blueprint->dropColumns as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->dropIndexes as $name) { + $alterations[] = 'DROP INDEX ' . $this->quote($name); + } + + if (! empty($blueprint->foreignKeys)) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + if (! empty($blueprint->dropForeignKeys)) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function create(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $columnDefs = []; + $primaryKeys = []; + + foreach ($blueprint->columns as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + } + + // Indexes (ClickHouse uses INDEX ... TYPE ... GRANULARITY ...) + foreach ($blueprint->indexes as $index) { + $cols = \array_map(fn (string $c): string => $this->quote($c), $index->columns); + $expr = \count($cols) === 1 ? $cols[0] : '(' . \implode(', ', $cols) . ')'; + $columnDefs[] = 'INDEX ' . $this->quote($index->name) + . ' ' . $expr . ' TYPE minmax GRANULARITY 3'; + } + + if (! empty($blueprint->foreignKeys)) { + throw new UnsupportedException('Foreign keys are not supported in ClickHouse.'); + } + + $sql = 'CREATE TABLE ' . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')' + . ' ENGINE = MergeTree()'; + + if (! empty($primaryKeys)) { + $sql .= ' ORDER BY (' . \implode(', ', $primaryKeys) . ')'; + } + + return new BuildResult($sql, []); + } + + public function createView(string $name, Builder $query): BuildResult + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new BuildResult($sql, $result->bindings); + } + + /** + * @param string[] $values + */ + private function compileClickHouseEnum(array $values): string + { + $parts = []; + foreach (\array_values($values) as $i => $value) { + $parts[] = "'" . \str_replace("'", "\\'", $value) . "' = " . ($i + 1); + } + + return 'Enum8(' . \implode(', ', $parts) . ')'; + } +} diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php new file mode 100644 index 0000000..f4702db --- /dev/null +++ b/src/Query/Schema/Column.php @@ -0,0 +1,107 @@ +isNullable = true; + + return $this; + } + + public function default(mixed $value): static + { + $this->default = $value; + $this->hasDefault = true; + + return $this; + } + + public function unsigned(): static + { + $this->isUnsigned = true; + + return $this; + } + + public function unique(): static + { + $this->isUnique = true; + + return $this; + } + + public function primary(): static + { + $this->isPrimary = true; + + return $this; + } + + public function after(string $column): static + { + $this->after = $column; + + return $this; + } + + public function autoIncrement(): static + { + $this->isAutoIncrement = true; + + return $this; + } + + public function comment(string $comment): static + { + $this->comment = $comment; + + return $this; + } + + public function collation(string $collation): static + { + $this->collation = $collation; + + return $this; + } +} diff --git a/src/Query/Schema/ColumnType.php b/src/Query/Schema/ColumnType.php new file mode 100644 index 0000000..854c7c0 --- /dev/null +++ b/src/Query/Schema/ColumnType.php @@ -0,0 +1,24 @@ + $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult; + + public function dropProcedure(string $name): BuildResult; +} diff --git a/src/Query/Schema/Feature/Triggers.php b/src/Query/Schema/Feature/Triggers.php new file mode 100644 index 0000000..62ad02d --- /dev/null +++ b/src/Query/Schema/Feature/Triggers.php @@ -0,0 +1,18 @@ +column = $column; + } + + public function references(string $column): static + { + $this->refColumn = $column; + + return $this; + } + + public function on(string $table): static + { + $this->refTable = $table; + + return $this; + } + + public function onDelete(ForeignKeyAction|string $action): static + { + if (\is_string($action)) { + $action = ForeignKeyAction::from(\strtoupper($action)); + } + + $this->onDelete = $action; + + return $this; + } + + public function onUpdate(ForeignKeyAction|string $action): static + { + if (\is_string($action)) { + $action = ForeignKeyAction::from(\strtoupper($action)); + } + + $this->onUpdate = $action; + + return $this; + } +} diff --git a/src/Query/Schema/ForeignKeyAction.php b/src/Query/Schema/ForeignKeyAction.php new file mode 100644 index 0000000..959a8a2 --- /dev/null +++ b/src/Query/Schema/ForeignKeyAction.php @@ -0,0 +1,12 @@ + $lengths + * @param array $orders + * @param array $collations Column-specific collations (column name => collation) + * @param list $rawColumns Raw SQL expressions appended to the column list (bypass quoting) + */ + public function __construct( + public string $name, + public array $columns, + public IndexType $type = IndexType::Index, + public array $lengths = [], + public array $orders = [], + public string $method = '', + public string $operatorClass = '', + public array $collations = [], + public array $rawColumns = [], + ) { + if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { + throw new ValidationException('Invalid index method: ' . $method); + } + if ($operatorClass !== '' && ! \preg_match('/^[A-Za-z0-9_.]+$/', $operatorClass)) { + throw new ValidationException('Invalid operator class: ' . $operatorClass); + } + foreach ($collations as $collation) { + if (! \preg_match('/^[A-Za-z0-9_]+$/', $collation)) { + throw new ValidationException('Invalid collation: ' . $collation); + } + } + } +} diff --git a/src/Query/Schema/IndexType.php b/src/Query/Schema/IndexType.php new file mode 100644 index 0000000..237f6a8 --- /dev/null +++ b/src/Query/Schema/IndexType.php @@ -0,0 +1,11 @@ +type) { + ColumnType::String => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text => 'TEXT', + ColumnType::MediumText => 'MEDIUMTEXT', + ColumnType::LongText => 'LONGTEXT', + ColumnType::Integer => 'INT', + ColumnType::BigInteger => 'BIGINT', + ColumnType::Float => 'DOUBLE', + ColumnType::Boolean => 'TINYINT(1)', + ColumnType::Datetime => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', + ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + ColumnType::Json => 'JSON', + ColumnType::Binary => 'BLOB', + ColumnType::Enum => "ENUM('" . \implode("','", \array_map(fn ($v) => \str_replace("'", "''", $v), $column->enumValues)) . "')", + ColumnType::Point => 'POINT' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Linestring => 'LINESTRING' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Polygon => 'POLYGON' . ($column->srid !== null ? ' SRID ' . $column->srid : ''), + ColumnType::Vector => throw new \Utopia\Query\Exception\UnsupportedException('Vector type is not supported in MySQL.'), + }; + } + + protected function compileAutoIncrement(): string + { + return 'AUTO_INCREMENT'; + } + + public function createDatabase(string $name): BuildResult + { + return new BuildResult( + 'CREATE DATABASE ' . $this->quote($name) . ' /*!40100 DEFAULT CHARACTER SET utf8mb4 */', + [] + ); + } + + /** + * MySQL CHANGE COLUMN: rename and/or retype a column in one statement. + */ + public function changeColumn(string $table, string $oldName, string $newName, string $type): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' CHANGE COLUMN ' . $this->quote($oldName) . ' ' . $this->quote($newName) . ' ' . $type, + [] + ); + } + + /** + * MySQL MODIFY COLUMN: retype a column without renaming. + */ + public function modifyColumn(string $table, string $name, string $type): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' MODIFY ' . $this->quote($name) . ' ' . $type, + [] + ); + } +} diff --git a/src/Query/Schema/ParameterDirection.php b/src/Query/Schema/ParameterDirection.php new file mode 100644 index 0000000..25ab6d6 --- /dev/null +++ b/src/Query/Schema/ParameterDirection.php @@ -0,0 +1,10 @@ +type) { + ColumnType::String => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::Integer => 'INTEGER', + ColumnType::BigInteger => 'BIGINT', + ColumnType::Float => 'DOUBLE PRECISION', + ColumnType::Boolean => 'BOOLEAN', + ColumnType::Datetime => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ') WITHOUT TIME ZONE' : 'TIMESTAMP WITHOUT TIME ZONE', + ColumnType::Json => 'JSONB', + ColumnType::Binary => 'BYTEA', + ColumnType::Enum => 'TEXT', + ColumnType::Point => 'GEOMETRY(POINT' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Linestring => 'GEOMETRY(LINESTRING' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Polygon => 'GEOMETRY(POLYGON' . ($column->srid !== null ? ', ' . $column->srid : '') . ')', + ColumnType::Vector => 'VECTOR(' . ($column->dimensions ?? 0) . ')', + }; + } + + protected function compileAutoIncrement(): string + { + return 'GENERATED BY DEFAULT AS IDENTITY'; + } + + protected function compileUnsigned(): string + { + return ''; + } + + protected function compileColumnDefinition(Column $column): string + { + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->isUnsigned) { + $unsigned = $this->compileUnsigned(); + if ($unsigned !== '') { + $parts[] = $unsigned; + } + } + + if ($column->isAutoIncrement) { + $parts[] = $this->compileAutoIncrement(); + } + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + // PostgreSQL enum emulation via CHECK constraint + if ($column->type === ColumnType::Enum && ! empty($column->enumValues)) { + $values = \array_map(fn (string $v): string => "'" . \str_replace("'", "''", $v) . "'", $column->enumValues); + $parts[] = 'CHECK (' . $this->quote($column->name) . ' IN (' . \implode(', ', $values) . '))'; + } + + // No inline COMMENT in PostgreSQL (use COMMENT ON COLUMN separately) + + return \implode(' ', $parts); + } + + /** + * @param string[] $columns + * @param array $lengths + * @param array $orders + * @param array $collations + * @param list $rawColumns + */ + public function createIndex( + string $table, + string $name, + array $columns, + bool $unique = false, + string $type = '', + string $method = '', + string $operatorClass = '', + array $lengths = [], + array $orders = [], + array $collations = [], + array $rawColumns = [], + ): BuildResult { + if ($method !== '' && ! \preg_match('/^[A-Za-z0-9_]+$/', $method)) { + throw new ValidationException('Invalid index method: ' . $method); + } + if ($operatorClass !== '' && ! \preg_match('/^[A-Za-z0-9_.]+$/', $operatorClass)) { + throw new ValidationException('Invalid operator class: ' . $operatorClass); + } + + $keyword = $unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; + + $sql = $keyword . ' ' . $this->quote($name) + . ' ON ' . $this->quote($table); + + if ($method !== '') { + $sql .= ' USING ' . \strtoupper($method); + } + + $indexType = $unique ? IndexType::Unique : ($type !== '' ? IndexType::from($type) : IndexType::Index); + $index = new Index($name, $columns, $indexType, $lengths, $orders, $method, $operatorClass, $collations, $rawColumns); + + $sql .= ' (' . $this->compileIndexColumns($index) . ')'; + + return new BuildResult($sql, []); + } + + public function dropIndex(string $table, string $name): BuildResult + { + return new BuildResult( + 'DROP INDEX ' . $this->quote($name), + [] + ); + } + + public function dropForeignKey(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP CONSTRAINT ' . $this->quote($name), + [] + ); + } + + /** + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE FUNCTION ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' RETURNS VOID LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' END; $$'; + + return new BuildResult($sql, []); + } + + public function dropProcedure(string $name): BuildResult + { + return new BuildResult('DROP FUNCTION ' . $this->quote($name), []); + } + + public function createTrigger( + string $name, + string $table, + TriggerTiming|string $timing, + TriggerEvent|string $event, + string $body, + ): BuildResult { + if ($timing instanceof TriggerTiming) { + $timingValue = $timing->value; + } else { + $timingValue = \strtoupper($timing); + TriggerTiming::from($timingValue); + } + + if ($event instanceof TriggerEvent) { + $eventValue = $event->value; + } else { + $eventValue = \strtoupper($event); + TriggerEvent::from($eventValue); + } + + $funcName = $name . '_func'; + + $sql = 'CREATE FUNCTION ' . $this->quote($funcName) + . '() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' RETURN NEW; END; $$; ' + . 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timingValue . ' ' . $eventValue + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW EXECUTE FUNCTION ' . $this->quote($funcName) . '()'; + + return new BuildResult($sql, []); + } + + /** + * @param callable(Blueprint): void $definition + */ + public function alter(string $table, callable $definition): BuildResult + { + $blueprint = new Blueprint(); + $definition($blueprint); + + $alterations = []; + + foreach ($blueprint->columns as $column) { + $keyword = $column->isModify ? 'ALTER COLUMN' : 'ADD COLUMN'; + if ($column->isModify) { + $def = $keyword . ' ' . $this->quote($column->name) + . ' TYPE ' . $this->compileColumnType($column); + } else { + $def = $keyword . ' ' . $this->compileColumnDefinition($column); + } + $alterations[] = $def; + } + + foreach ($blueprint->renameColumns as $rename) { + $alterations[] = 'RENAME COLUMN ' . $this->quote($rename->from) + . ' TO ' . $this->quote($rename->to); + } + + foreach ($blueprint->dropColumns as $col) { + $alterations[] = 'DROP COLUMN ' . $this->quote($col); + } + + foreach ($blueprint->foreignKeys as $fk) { + $def = 'ADD FOREIGN KEY (' . $this->quote($fk->column) . ')' + . ' REFERENCES ' . $this->quote($fk->refTable) + . ' (' . $this->quote($fk->refColumn) . ')'; + if ($fk->onDelete !== null) { + $def .= ' ON DELETE ' . $fk->onDelete->value; + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->value; + } + $alterations[] = $def; + } + + foreach ($blueprint->dropForeignKeys as $name) { + $alterations[] = 'DROP CONSTRAINT ' . $this->quote($name); + } + + $statements = []; + + if (! empty($alterations)) { + $statements[] = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + } + + // PostgreSQL indexes are standalone statements, not ALTER TABLE clauses + foreach ($blueprint->indexes as $index) { + $keyword = $index->type === IndexType::Unique ? 'CREATE UNIQUE INDEX' : 'CREATE INDEX'; + + $indexSql = $keyword . ' ' . $this->quote($index->name) + . ' ON ' . $this->quote($table); + + if ($index->method !== '') { + $indexSql .= ' USING ' . \strtoupper($index->method); + } + + $indexSql .= ' (' . $this->compileIndexColumns($index) . ')'; + $statements[] = $indexSql; + } + + foreach ($blueprint->dropIndexes as $name) { + $statements[] = 'DROP INDEX ' . $this->quote($name); + } + + return new BuildResult(\implode('; ', $statements), []); + } + + public function rename(string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [] + ); + } + + public function createExtension(string $name): BuildResult + { + return new BuildResult('CREATE EXTENSION IF NOT EXISTS ' . $this->quote($name), []); + } + + public function dropExtension(string $name): BuildResult + { + return new BuildResult('DROP EXTENSION IF EXISTS ' . $this->quote($name), []); + } + + /** + * Create a collation. + * + * @param array $options Key-value pairs (e.g. ['provider' => 'icu', 'locale' => 'und-u-ks-level1']) + */ + public function createCollation(string $name, array $options, bool $deterministic = true): BuildResult + { + $optParts = []; + foreach ($options as $key => $value) { + $optParts[] = $key . " = '" . \str_replace("'", "''", $value) . "'"; + } + $optParts[] = 'deterministic = ' . ($deterministic ? 'true' : 'false'); + + $sql = 'CREATE COLLATION IF NOT EXISTS ' . $this->quote($name) + . ' (' . \implode(', ', $optParts) . ')'; + + return new BuildResult($sql, []); + } + + public function renameIndex(string $table, string $from, string $to): BuildResult + { + return new BuildResult( + 'ALTER INDEX ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [] + ); + } + + /** + * PostgreSQL uses schemas instead of databases for namespace isolation. + */ + public function createDatabase(string $name): BuildResult + { + return new BuildResult('CREATE SCHEMA ' . $this->quote($name), []); + } + + public function dropDatabase(string $name): BuildResult + { + return new BuildResult('DROP SCHEMA IF EXISTS ' . $this->quote($name) . ' CASCADE', []); + } + + public function analyzeTable(string $table): BuildResult + { + return new BuildResult('ANALYZE ' . $this->quote($table), []); + } + + /** + * Alter a column's type with an optional USING expression for type casting. + */ + public function alterColumnType(string $table, string $column, string $type, string $using = ''): BuildResult + { + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ALTER COLUMN ' . $this->quote($column) + . ' TYPE ' . $type; + + if ($using !== '') { + $sql .= ' USING ' . $using; + } + + return new BuildResult($sql, []); + } +} diff --git a/src/Query/Schema/RenameColumn.php b/src/Query/Schema/RenameColumn.php new file mode 100644 index 0000000..c72dfff --- /dev/null +++ b/src/Query/Schema/RenameColumn.php @@ -0,0 +1,12 @@ +resolveForeignKeyAction($onDelete); + $onUpdateAction = $this->resolveForeignKeyAction($onUpdate); + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ADD CONSTRAINT ' . $this->quote($name) + . ' FOREIGN KEY (' . $this->quote($column) . ')' + . ' REFERENCES ' . $this->quote($refTable) + . ' (' . $this->quote($refColumn) . ')'; + + if ($onDeleteAction !== null) { + $sql .= ' ON DELETE ' . $onDeleteAction->value; + } + if ($onUpdateAction !== null) { + $sql .= ' ON UPDATE ' . $onUpdateAction->value; + } + + return new BuildResult($sql, []); + } + + public function dropForeignKey(string $table, string $name): BuildResult + { + return new BuildResult( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP FOREIGN KEY ' . $this->quote($name), + [] + ); + } + + /** + * Validate and compile a procedure parameter list. + * + * @param list $params + * @return list + */ + protected function compileProcedureParams(array $params): array + { + $paramList = []; + foreach ($params as $param) { + if ($param[0] instanceof ParameterDirection) { + $direction = $param[0]->value; + } else { + $direction = \strtoupper($param[0]); + ParameterDirection::from($direction); + } + + $name = $this->quote($param[1]); + + if (! \preg_match('/^[A-Za-z0-9_() ,]+$/', $param[2])) { + throw new ValidationException('Invalid procedure parameter type: ' . $param[2]); + } + + $paramList[] = $direction . ' ' . $name . ' ' . $param[2]; + } + + return $paramList; + } + + /** + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): BuildResult + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE PROCEDURE ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' BEGIN ' . $body . ' END'; + + return new BuildResult($sql, []); + } + + public function dropProcedure(string $name): BuildResult + { + return new BuildResult('DROP PROCEDURE ' . $this->quote($name), []); + } + + public function createTrigger( + string $name, + string $table, + TriggerTiming|string $timing, + TriggerEvent|string $event, + string $body, + ): BuildResult { + if ($timing instanceof TriggerTiming) { + $timingValue = $timing->value; + } else { + $timingValue = \strtoupper($timing); + TriggerTiming::from($timingValue); + } + + if ($event instanceof TriggerEvent) { + $eventValue = $event->value; + } else { + $eventValue = \strtoupper($event); + TriggerEvent::from($eventValue); + } + + $sql = 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timingValue . ' ' . $eventValue + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW BEGIN ' . $body . ' END'; + + return new BuildResult($sql, []); + } + + public function dropTrigger(string $name): BuildResult + { + return new BuildResult('DROP TRIGGER ' . $this->quote($name), []); + } + + private function resolveForeignKeyAction(ForeignKeyAction|string $action): ?ForeignKeyAction + { + if ($action instanceof ForeignKeyAction) { + return $action; + } + + if ($action === '') { + return null; + } + + return ForeignKeyAction::from(\strtoupper($action)); + } +} diff --git a/src/Query/Schema/TriggerEvent.php b/src/Query/Schema/TriggerEvent.php new file mode 100644 index 0000000..573e241 --- /dev/null +++ b/src/Query/Schema/TriggerEvent.php @@ -0,0 +1,10 @@ +connectClickhouse(); + + $this->trackClickhouseTable('ch_events'); + $this->trackClickhouseTable('ch_users'); + + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_events`'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_users`'); + + $this->clickhouseStatement(' + CREATE TABLE `ch_users` ( + `id` UInt32, + `name` String, + `email` String, + `age` UInt32, + `country` String + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + $this->clickhouseStatement(' + CREATE TABLE `ch_events` ( + `id` UInt32, + `user_id` UInt32, + `action` String, + `value` Float64, + `created_at` DateTime + ) ENGINE = MergeTree() + ORDER BY (`id`, `created_at`) + '); + + $this->clickhouseStatement(" + INSERT INTO `ch_users` (`id`, `name`, `email`, `age`, `country`) VALUES + (1, 'Alice', 'alice@test.com', 30, 'US'), + (2, 'Bob', 'bob@test.com', 25, 'UK'), + (3, 'Charlie', 'charlie@test.com', 35, 'US'), + (4, 'Diana', 'diana@test.com', 28, 'DE'), + (5, 'Eve', 'eve@test.com', 22, 'UK') + "); + + $this->clickhouseStatement(" + INSERT INTO `ch_events` (`id`, `user_id`, `action`, `value`, `created_at`) VALUES + (1, 1, 'click', 1.5, '2024-01-01 10:00:00'), + (2, 1, 'purchase', 99.99, '2024-01-02 11:00:00'), + (3, 2, 'click', 2.0, '2024-01-01 12:00:00'), + (4, 2, 'click', 3.5, '2024-01-03 09:00:00'), + (5, 3, 'purchase', 49.99, '2024-01-02 14:00:00'), + (6, 3, 'view', 0.0, '2024-01-04 08:00:00'), + (7, 4, 'click', 1.0, '2024-01-05 10:00:00'), + (8, 5, 'purchase', 199.99, '2024-01-06 16:00:00') + "); + } + + public function testSelectWithWhere(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'country']) + ->filter([Query::equal('country', ['US'])]) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithOrderByAndLimit(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'age']) + ->sortDesc('age') + ->limit(3) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + $this->assertEquals('Alice', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + } + + public function testSelectWithJoin(): void + { + $result = (new Builder()) + ->from('ch_events', 'e') + ->select(['e.id', 'e.action', 'u.name']) + ->join('ch_users', 'e.user_id', 'u.id', '=', 'u') + ->filter([Query::equal('e.action', ['purchase'])]) + ->sortAsc('e.id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + $this->assertEquals('Eve', $rows[2]['name']); + } + + public function testSelectWithPrewhere(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->prewhere([Query::equal('action', ['click'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertEquals('click', $row['action']); + } + } + + public function testSelectWithFinal(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->final() + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(5, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } + + public function testInsertSingleRow(): void + { + $insert = (new Builder()) + ->into('ch_events') + ->set([ + 'id' => 100, + 'user_id' => 1, + 'action' => 'signup', + 'value' => 0.0, + 'created_at' => '2024-02-01 00:00:00', + ]) + ->insert(); + + $this->executeOnClickhouse($insert); + + $select = (new Builder()) + ->from('ch_events') + ->select(['id', 'action']) + ->filter([Query::equal('id', [100])]) + ->build(); + + $rows = $this->executeOnClickhouse($select); + + $this->assertCount(1, $rows); + $this->assertEquals('signup', $rows[0]['action']); + } + + public function testInsertMultipleRows(): void + { + $insert = (new Builder()) + ->into('ch_users') + ->set(['id' => 10, 'name' => 'Frank', 'email' => 'frank@test.com', 'age' => 40, 'country' => 'FR']) + ->set(['id' => 11, 'name' => 'Grace', 'email' => 'grace@test.com', 'age' => 33, 'country' => 'FR']) + ->insert(); + + $this->executeOnClickhouse($insert); + + $select = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->filter([Query::equal('country', ['FR'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($select); + + $this->assertCount(2, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + $this->assertEquals('Grace', $rows[1]['name']); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['action']) + ->count('*', 'cnt') + ->groupBy(['action']) + ->having([Query::greaterThan('cnt', 1)]) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $actions = array_column($rows, 'action'); + $this->assertContains('click', $actions); + $this->assertContains('purchase', $actions); + foreach ($rows as $row) { + $this->assertGreaterThan(1, (int) $row['cnt']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithUnionAll(): void + { + $first = (new Builder()) + ->from('ch_users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]); + + $second = (new Builder()) + ->from('ch_users') + ->select(['name']) + ->filter([Query::equal('country', ['UK'])]); + + $result = $first->unionAll($second)->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(4, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'country']) + ->filter([Query::equal('country', ['US'])]); + + $result = (new Builder()) + ->with('us_users', $cteQuery) + ->from('us_users') + ->select(['id', 'name']) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithWindowFunction(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->selectWindow('row_number()', 'rn', ['action'], ['id']) + ->filter([Query::equal('action', ['click'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $this->assertEquals(1, (int) $rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[1]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(3, (int) $rows[2]['rn']); // @phpstan-ignore cast.int + } + + public function testSelectWithDistinct(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['country']) + ->distinct() + ->sortAsc('country') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $countries = array_column($rows, 'country'); + $this->assertEquals(['DE', 'UK', 'US'], $countries); + } + + public function testSelectWithSubqueryInWhere(): void + { + $subquery = (new Builder()) + ->from('ch_events') + ->select(['user_id']) + ->filter([Query::equal('action', ['purchase'])]); + + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithSample(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->sample(0.5) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertLessThanOrEqual(5, count($rows)); + foreach ($rows as $row) { + $this->assertArrayHasKey('id', $row); + $this->assertArrayHasKey('name', $row); + } + } + + public function testSelectWithSettings(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action', 'value']) + ->sortAsc('id') + ->settings(['max_threads' => '2']) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(8, $rows); + $this->assertEquals('click', $rows[0]['action']); + } +} diff --git a/tests/Integration/Builder/MySQLIntegrationTest.php b/tests/Integration/Builder/MySQLIntegrationTest.php new file mode 100644 index 0000000..c141ee3 --- /dev/null +++ b/tests/Integration/Builder/MySQLIntegrationTest.php @@ -0,0 +1,501 @@ +builder = new Builder(); + $pdo = $this->connectMysql(); + + $this->trackMysqlTable('users'); + $this->trackMysqlTable('orders'); + + $this->mysqlStatement('DROP TABLE IF EXISTS `orders`'); + $this->mysqlStatement('DROP TABLE IF EXISTS `users`'); + + $this->mysqlStatement(' + CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL, + `email` VARCHAR(150) NOT NULL UNIQUE, + `age` INT NOT NULL DEFAULT 0, + `city` VARCHAR(100) NOT NULL DEFAULT \'\', + `active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB + '); + + $this->mysqlStatement(' + CREATE TABLE `orders` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `product` VARCHAR(100) NOT NULL, + `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00, + `status` VARCHAR(20) NOT NULL DEFAULT \'pending\', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) + ) ENGINE=InnoDB + '); + + $stmt = $pdo->prepare(' + INSERT INTO `users` (`name`, `email`, `age`, `city`, `active`) VALUES + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?) + '); + $stmt->execute([ + 'Alice', 'alice@example.com', 30, 'New York', 1, + 'Bob', 'bob@example.com', 25, 'London', 1, + 'Charlie', 'charlie@example.com', 35, 'New York', 0, + 'Diana', 'diana@example.com', 28, 'Paris', 1, + 'Eve', 'eve@example.com', 22, 'London', 1, + ]); + + $stmt = $pdo->prepare(' + INSERT INTO `orders` (`user_id`, `product`, `amount`, `status`) VALUES + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?), + (?, ?, ?, ?) + '); + $stmt->execute([ + 1, 'Widget', 29.99, 'completed', + 1, 'Gadget', 49.99, 'completed', + 2, 'Widget', 29.99, 'pending', + 3, 'Gizmo', 19.99, 'completed', + 4, 'Widget', 29.99, 'cancelled', + 4, 'Gadget', 49.99, 'pending', + ]); + } + + private function fresh(): Builder + { + return $this->builder->reset(); + } + + public function testSelectWithWhere(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('city', ['New York'])]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithOrderByAndLimit(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->sortDesc('age') + ->limit(3) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + $this->assertEquals('Alice', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + } + + public function testSelectWithJoin(): void + { + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'o.product', 'o.amount']) + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['completed'])]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertContains('Widget', $products); + $this->assertContains('Gadget', $products); + $this->assertContains('Gizmo', $products); + } + + public function testSelectWithLeftJoin(): void + { + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'o.product']) + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + $names = array_column($rows, 'name'); + $this->assertContains('Eve', $names); + } + + public function testInsertSingleRow(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Frank', 'email' => 'frank@example.com', 'age' => 40, 'city' => 'Berlin', 'active' => 1]) + ->insert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + } + + public function testInsertMultipleRows(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Grace', 'email' => 'grace@example.com', 'age' => 33, 'city' => 'Tokyo', 'active' => 1]) + ->set(['name' => 'Hank', 'email' => 'hank@example.com', 'age' => 45, 'city' => 'Tokyo', 'active' => 0]) + ->insert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['Tokyo'])]) + ->build() + ); + + $this->assertCount(2, $rows); + } + + public function testUpdateWithWhere(): void + { + $result = $this->fresh() + ->from('users') + ->set(['active' => 0]) + ->filter([Query::equal('name', ['Bob'])]) + ->update(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['active']) + ->filter([Query::equal('name', ['Bob'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals(0, $rows[0]['active']); + } + + public function testDeleteWithWhere(): void + { + $this->mysqlStatement('DELETE FROM `orders` WHERE `user_id` = 3'); + + $result = $this->fresh() + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->delete(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('name', ['Charlie'])]) + ->build() + ); + + $this->assertCount(0, $rows); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 1)]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + foreach ($rows as $row) { + $this->assertGreaterThan(1, (int) $row['order_count']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithUnion(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]) + ->union( + (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['London'])]) + ) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(4, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Bob', $names); + $this->assertContains('Eve', $names); + } + + public function testSelectWithCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('`age` < 25', "'young'") + ->when('`age` BETWEEN 25 AND 30', "'mid'") + ->elseResult("'senior'") + ->alias('`age_group`') + ->build(); + + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->selectCase($case) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + $map = array_column($rows, 'age_group', 'name'); + $this->assertEquals('mid', $map['Alice']); + $this->assertEquals('mid', $map['Bob']); + $this->assertEquals('senior', $map['Charlie']); + $this->assertEquals('mid', $map['Diana']); + $this->assertEquals('young', $map['Eve']); + } + + public function testSelectWithWhereInSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + } + + public function testSelectWithExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders', 'o') + ->selectRaw('1') + ->filter([Query::equal('o.status', ['completed'])]); + + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($subquery) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + + $noMatchSubquery = (new Builder()) + ->from('orders', 'o') + ->selectRaw('1') + ->filter([Query::equal('o.status', ['refunded'])]); + + $emptyResult = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($noMatchSubquery) + ->build(); + + $emptyRows = $this->executeOnMysql($emptyResult); + + $this->assertCount(0, $emptyRows); + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('amount', 'total') + ->groupBy(['user_id']); + + $result = $this->fresh() + ->with('user_totals', $cteQuery) + ->from('user_totals') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 30)]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertGreaterThan(30, (float) $row['total']); // @phpstan-ignore cast.double + } + } + + public function testUpsertOnDuplicateKeyUpdate(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 31, 'city' => 'New York', 'active' => 1]) + ->onConflict(['email'], ['age']) + ->upsert(); + + $this->executeOnMysql($result); + + $rows = $this->executeOnMysql( + $this->fresh() + ->from('users') + ->select(['age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testSelectWithWindowFunction(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id', 'product', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertArrayHasKey('rn', $row); + $this->assertGreaterThanOrEqual(1, (int) $row['rn']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithDistinct(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['product']) + ->distinct() + ->sortAsc('product') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertEquals(['Gadget', 'Gizmo', 'Widget'], $products); + } + + public function testSelectWithBetween(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->filter([Query::between('age', 25, 30)]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertGreaterThanOrEqual(25, (int) $row['age']); // @phpstan-ignore cast.int + $this->assertLessThanOrEqual(30, (int) $row['age']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithStartsWith(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name', 'email']) + ->filter([Query::startsWith('name', 'Al')]) + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } + + public function testSelectForUpdate(): void + { + $pdo = $this->connectMysql(); + $pdo->beginTransaction(); + + try { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('name', ['Alice'])]) + ->forUpdate() + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } +} diff --git a/tests/Integration/Builder/PostgreSQLIntegrationTest.php b/tests/Integration/Builder/PostgreSQLIntegrationTest.php new file mode 100644 index 0000000..0cf2ad5 --- /dev/null +++ b/tests/Integration/Builder/PostgreSQLIntegrationTest.php @@ -0,0 +1,456 @@ +trackPostgresTable('users'); + $this->trackPostgresTable('orders'); + + $this->postgresStatement('DROP TABLE IF EXISTS "orders" CASCADE'); + $this->postgresStatement('DROP TABLE IF EXISTS "users" CASCADE'); + + $this->postgresStatement(' + CREATE TABLE "users" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) NOT NULL UNIQUE, + "age" INT NOT NULL DEFAULT 0, + "city" VARCHAR(100) DEFAULT NULL, + "active" BOOLEAN NOT NULL DEFAULT TRUE, + "created_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + '); + + $this->postgresStatement(' + CREATE TABLE "orders" ( + "id" SERIAL PRIMARY KEY, + "user_id" INT NOT NULL REFERENCES "users"("id"), + "product" VARCHAR(255) NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "status" VARCHAR(50) NOT NULL DEFAULT \'pending\', + "created_at" TIMESTAMP NOT NULL DEFAULT NOW() + ) + '); + + $this->postgresStatement(" + INSERT INTO \"users\" (\"name\", \"email\", \"age\", \"city\", \"active\") VALUES + ('Alice', 'alice@example.com', 30, 'New York', TRUE), + ('Bob', 'bob@example.com', 25, 'London', TRUE), + ('Charlie', 'charlie@example.com', 35, 'New York', FALSE), + ('Diana', 'diana@example.com', 28, 'Paris', TRUE), + ('Eve', 'eve@example.com', 22, 'London', TRUE) + "); + + $this->postgresStatement(" + INSERT INTO \"orders\" (\"user_id\", \"product\", \"amount\", \"status\") VALUES + (1, 'Widget', 29.99, 'completed'), + (1, 'Gadget', 49.99, 'completed'), + (2, 'Widget', 29.99, 'pending'), + (3, 'Gizmo', 99.99, 'completed'), + (4, 'Widget', 29.99, 'cancelled'), + (4, 'Gadget', 49.99, 'pending'), + (5, 'Gizmo', 99.99, 'completed') + "); + } + + public function testSelectWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('city', ['New York'])]) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithOrderByLimitOffset(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->sortAsc('name') + ->limit(2) + ->offset(1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals('Bob', $rows[0]['name']); + $this->assertEquals('Charlie', $rows[1]['name']); + } + + public function testSelectWithJoin(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'o.product', 'o.amount']) + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['completed'])]) + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(4, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Widget', $rows[0]['product']); + } + + public function testSelectWithLeftJoin(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'o.product']) + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['cancelled'])]) + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Diana', $rows[0]['name']); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Frank', 'email' => 'frank@example.com', 'age' => 40, 'city' => 'Berlin', 'active' => true]) + ->insert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'city']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Frank', $rows[0]['name']); + $this->assertEquals('Berlin', $rows[0]['city']); + } + + public function testInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Grace', 'email' => 'grace@example.com', 'age' => 33, 'city' => 'Tokyo', 'active' => true]) + ->set(['name' => 'Hank', 'email' => 'hank@example.com', 'age' => 45, 'city' => 'Tokyo', 'active' => false]) + ->insert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['Tokyo'])]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(2, $rows); + $this->assertEquals('Grace', $rows[0]['name']); + $this->assertEquals('Hank', $rows[1]['name']); + } + + public function testInsertWithReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Ivy', 'email' => 'ivy@example.com', 'age' => 27, 'city' => 'Madrid', 'active' => true]) + ->returning(['id', 'name', 'email']) + ->insert(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Ivy', $rows[0]['name']); + $this->assertEquals('ivy@example.com', $rows[0]['email']); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertGreaterThan(0, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['city' => 'San Francisco']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['city']) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('San Francisco', $rows[0]['city']); + } + + public function testUpdateWithReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['age' => 31]) + ->filter([Query::equal('name', ['Alice'])]) + ->returning(['name', 'age']) + ->update(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testDeleteWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->delete(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(0, $rows); + } + + public function testDeleteWithReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->returning(['id', 'name']) + ->delete(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Charlie', $rows[0]['name']); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 1)]) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertEquals(1, (int) $rows[0]['user_id']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[0]['order_count']); // @phpstan-ignore cast.int + $this->assertEquals(4, (int) $rows[1]['user_id']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $rows[1]['order_count']); // @phpstan-ignore cast.int + } + + public function testSelectWithUnion(): void + { + $query1 = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['London'])]) + ->union($query1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $names = array_column($rows, 'name'); + sort($names); + + $this->assertCount(4, $rows); + $this->assertEquals(['Alice', 'Bob', 'Charlie', 'Eve'], $names); + } + + public function testUpsertOnConflictDoUpdate(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice Updated', 'email' => 'alice@example.com', 'age' => 31, 'city' => 'Boston', 'active' => true]) + ->onConflict(['email'], ['name', 'age', 'city']) + ->upsert(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age', 'city']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice Updated', $rows[0]['name']); + $this->assertEquals(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + $this->assertEquals('Boston', $rows[0]['city']); + } + + public function testInsertOrIgnoreOnConflictDoNothing(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice Duplicate', 'email' => 'alice@example.com', 'age' => 99, 'city' => 'Nowhere', 'active' => false]) + ->insertOrIgnore(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals(30, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testSelectWithCte(): void + { + $cteQuery = (new Builder()) + ->from('users') + ->select(['id', 'name', 'city']) + ->filter([Query::equal('active', [true])]); + + $result = (new Builder()) + ->with('active_users', $cteQuery) + ->from('active_users') + ->select(['name', 'city']) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(4, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + $this->assertEquals('Bob', $rows[1]['name']); + $this->assertEquals('Diana', $rows[2]['name']); + $this->assertEquals('Eve', $rows[3]['name']); + } + + public function testSelectWithWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id', 'product', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->sortAsc('user_id') + ->sortDesc('amount') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertGreaterThan(0, count($rows)); + $this->assertArrayHasKey('rn', $rows[0]); + + $user1Rows = array_filter($rows, fn ($r) => (int) $r['user_id'] === 1); // @phpstan-ignore cast.int + $user1Rows = array_values($user1Rows); + $this->assertEquals(1, (int) $user1Rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertEquals(2, (int) $user1Rows[1]['rn']); // @phpstan-ignore cast.int + } + + public function testSelectWithDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['product']) + ->distinct() + ->sortAsc('product') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertEquals(['Gadget', 'Gizmo', 'Widget'], $products); + } + + public function testSelectWithSubqueryInWhere(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $names = array_column($rows, 'name'); + + $this->assertCount(3, $rows); + $this->assertEquals(['Alice', 'Charlie', 'Eve'], $names); + } + + public function testSelectForUpdate(): void + { + $pdo = $this->connectPostgres(); + $pdo->beginTransaction(); + + try { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('name', ['Alice'])]) + ->forUpdate() + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertEquals('Alice', $rows[0]['name']); + } finally { + $pdo->rollBack(); + } + } +} diff --git a/tests/Integration/ClickHouseClient.php b/tests/Integration/ClickHouseClient.php new file mode 100644 index 0000000..6edac4e --- /dev/null +++ b/tests/Integration/ClickHouseClient.php @@ -0,0 +1,104 @@ + $params + * @return list> + */ + public function execute(string $query, array $params = []): array + { + $url = $this->host . '/?database=' . urlencode($this->database); + + $placeholderIndex = 0; + $paramMap = []; + $sql = preg_replace_callback('/\?/', function () use (&$placeholderIndex, $params, &$paramMap, &$url) { + $key = 'param_p' . $placeholderIndex; + $value = $params[$placeholderIndex] ?? null; + $paramMap[$key] = $value; + $placeholderIndex++; + + $type = match (true) { + is_int($value) => 'Int64', + is_float($value) => 'Float64', + is_bool($value) => 'UInt8', + default => 'String', + }; + + $url .= '¶m_' . $key . '=' . urlencode((string) $value); // @phpstan-ignore cast.string + + return '{' . $key . ':' . $type . '}'; + }, $query); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\n", + 'content' => $sql . ' FORMAT JSONEachRow', + 'ignore_errors' => true, + 'timeout' => 10, + ], + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + throw new \RuntimeException('ClickHouse request failed'); + } + + $statusLine = $http_response_header[0] ?? ''; + if (! str_contains($statusLine, '200')) { + throw new \RuntimeException('ClickHouse error: ' . $response); + } + + $trimmed = trim($response); + if ($trimmed === '') { + return []; + } + + $rows = []; + foreach (explode("\n", $trimmed) as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + /** @var array $decoded */ + $rows[] = $decoded; + } + } + + return $rows; + } + + public function statement(string $sql): void + { + $url = $this->host . '/?database=' . urlencode($this->database); + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\n", + 'content' => $sql, + 'ignore_errors' => true, + 'timeout' => 10, + ], + ]); + + $response = file_get_contents($url, false, $context); + + if ($response === false) { + throw new \RuntimeException('ClickHouse request failed'); + } + + $statusLine = $http_response_header[0] ?? ''; + if (! str_contains($statusLine, '200')) { + throw new \RuntimeException('ClickHouse error: ' . $response); + } + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..3bcf80c --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,153 @@ + */ + private array $mysqlCleanup = []; + + /** @var list */ + private array $postgresCleanup = []; + + /** @var list */ + private array $clickhouseCleanup = []; + + protected function connectMysql(): PDO + { + if ($this->mysql === null) { + $this->mysql = new PDO( + 'mysql:host=127.0.0.1;port=13306;dbname=query_test', + 'root', + 'test', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + return $this->mysql; + } + + protected function connectPostgres(): PDO + { + if ($this->postgres === null) { + $this->postgres = new PDO( + 'pgsql:host=127.0.0.1;port=15432;dbname=query_test', + 'postgres', + 'test', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + return $this->postgres; + } + + protected function connectClickhouse(): ClickHouseClient + { + if ($this->clickhouse === null) { + $this->clickhouse = new ClickHouseClient(); + } + + return $this->clickhouse; + } + + /** + * @return list> + */ + protected function executeOnMysql(BuildResult $result): array + { + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare($result->query); + $stmt->execute($result->bindings); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnPostgres(BuildResult $result): array + { + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare($result->query); + $stmt->execute($result->bindings); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnClickhouse(BuildResult $result): array + { + $ch = $this->connectClickhouse(); + + return $ch->execute($result->query, $result->bindings); + } + + protected function mysqlStatement(string $sql): void + { + $this->connectMysql()->prepare($sql)->execute(); + } + + protected function postgresStatement(string $sql): void + { + $this->connectPostgres()->prepare($sql)->execute(); + } + + protected function clickhouseStatement(string $sql): void + { + $this->connectClickhouse()->statement($sql); + } + + protected function trackMysqlTable(string $table): void + { + $this->mysqlCleanup[] = $table; + } + + protected function trackPostgresTable(string $table): void + { + $this->postgresCleanup[] = $table; + } + + protected function trackClickhouseTable(string $table): void + { + $this->clickhouseCleanup[] = $table; + } + + protected function tearDown(): void + { + foreach ($this->mysqlCleanup as $table) { + $stmt = $this->mysql?->prepare("DROP TABLE IF EXISTS `{$table}`"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + + foreach ($this->postgresCleanup as $table) { + $stmt = $this->postgres?->prepare("DROP TABLE IF EXISTS \"{$table}\" CASCADE"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + + foreach ($this->clickhouseCleanup as $table) { + $this->clickhouse?->statement("DROP TABLE IF EXISTS `{$table}`"); + } + + $this->mysqlCleanup = []; + $this->postgresCleanup = []; + $this->clickhouseCleanup = []; + } +} diff --git a/tests/Integration/Schema/ClickHouseIntegrationTest.php b/tests/Integration/Schema/ClickHouseIntegrationTest.php new file mode 100644 index 0000000..94d2de8 --- /dev/null +++ b/tests/Integration/Schema/ClickHouseIntegrationTest.php @@ -0,0 +1,153 @@ +schema = new ClickHouse(); + } + + public function testCreateTableWithMergeTreeEngine(): void + { + $table = 'test_mergetree_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 100); + $bp->integer('value'); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}' ORDER BY position" + ); + + $columnNames = array_column($rows, 'name'); + $this->assertContains('id', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('value', $columnNames); + + $tables = $ch->execute( + "SELECT engine FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertSame('MergeTree', $tables[0]['engine']); + } + + public function testCreateTableWithNullableColumns(): void + { + $table = 'test_nullable_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('optional_name', 100)->nullable(); + $bp->integer('optional_count')->nullable(); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $typeMap = []; + foreach ($rows as $row) { + $name = $row['name']; + $type = $row['type']; + \assert(\is_string($name) && \is_string($type)); + $typeMap[$name] = $type; + } + + $this->assertStringContainsString('Nullable', $typeMap['optional_name']); + $this->assertStringContainsString('Nullable', $typeMap['optional_count']); + $this->assertStringNotContainsString('Nullable', $typeMap['id']); + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackClickhouseTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->clickhouseStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::String, 200); + }); + $this->clickhouseStatement($alter->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $columnNames = array_column($rows, 'name'); + $this->assertContains('description', $columnNames); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->clickhouseStatement($create->query); + + $drop = $this->schema->drop($table); + $this->clickhouseStatement($drop->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT count() as cnt FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + + $this->assertSame('0', (string) $rows[0]['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithDateTimePrecision(): void + { + $table = 'test_dt64_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->datetime('created_at', 3); + $bp->datetime('updated_at', 6); + }); + + $this->clickhouseStatement($result->query); + + $ch = $this->connectClickhouse(); + $rows = $ch->execute( + "SELECT name, type FROM system.columns WHERE database = 'query_test' AND table = '{$table}'" + ); + + $typeMap = []; + foreach ($rows as $row) { + $name = $row['name']; + $type = $row['type']; + \assert(\is_string($name) && \is_string($type)); + $typeMap[$name] = $type; + } + + $this->assertSame('DateTime64(3)', $typeMap['created_at']); + $this->assertSame('DateTime64(6)', $typeMap['updated_at']); + } +} diff --git a/tests/Integration/Schema/MySQLIntegrationTest.php b/tests/Integration/Schema/MySQLIntegrationTest.php new file mode 100644 index 0000000..09c9034 --- /dev/null +++ b/tests/Integration/Schema/MySQLIntegrationTest.php @@ -0,0 +1,315 @@ +schema = new MySQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('age'); + $bp->string('name', 100); + $bp->boolean('active'); + }); + + $this->mysqlStatement($result->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertContains('age', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('active', $columnNames); + + $nameCol = $this->findColumn($columns, 'name'); + $this->assertSame('varchar', $nameCol['DATA_TYPE']); + $this->assertSame('100', (string) $nameCol['CHARACTER_MAXIMUM_LENGTH']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithPrimaryKeyAndUnique(): void + { + $table = 'test_pk_uniq_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255)->unique(); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COLUMN_NAME, COLUMN_KEY FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + /** @var list> $rows */ + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $idRow = $this->findColumn($rows, 'id'); + $this->assertSame('PRI', $idRow['COLUMN_KEY']); + + $emailRow = $this->findColumn($rows, 'email'); + $this->assertSame('UNI', $emailRow['COLUMN_KEY']); + } + + public function testCreateTableWithAutoIncrement(): void + { + $table = 'test_autoinc_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->id(); + $bp->string('label', 50); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT EXTRA FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND COLUMN_NAME = 'id'" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertStringContainsString('auto_increment', (string) $row['EXTRA']); // @phpstan-ignore cast.string + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::Text); + }); + $this->mysqlStatement($alter->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertContains('description', $columnNames); + } + + public function testAlterTableDropColumn(): void + { + $table = 'test_alter_drop_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->dropColumn('temp'); + }); + $this->mysqlStatement($alter->query); + + $columns = $this->fetchMysqlColumns($table); + $columnNames = array_column($columns, 'COLUMN_NAME'); + + $this->assertNotContains('temp', $columnNames); + } + + public function testAlterTableAddIndex(): void + { + $table = 'test_alter_idx_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addIndex('idx_email', ['email']); + }); + $this->mysqlStatement($alter->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT INDEX_NAME FROM information_schema.STATISTICS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND INDEX_NAME = 'idx_email'" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame('idx_email', $row['INDEX_NAME']); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->mysqlStatement($create->query); + + $drop = $this->schema->drop($table); + $this->mysqlStatement($drop->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) as cnt FROM information_schema.TABLES " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithForeignKey(): void + { + $parentTable = 'test_fk_parent_' . uniqid(); + $childTable = 'test_fk_child_' . uniqid(); + $this->trackMysqlTable($childTable); + $this->trackMysqlTable($parentTable); + + $createParent = $this->schema->create($parentTable, function (Blueprint $bp) { + $bp->id(); + }); + $this->mysqlStatement($createParent->query); + + $createChild = $this->schema->create($childTable, function (Blueprint $bp) use ($parentTable) { + $bp->id(); + $bp->bigInteger('parent_id')->unsigned(); + $bp->foreignKey('parent_id') + ->references('id') + ->on($parentTable) + ->onDelete(ForeignKeyAction::Cascade); + }); + $this->mysqlStatement($createChild->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL" + ); + \assert($stmt !== false); + $stmt->execute([$childTable]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame($parentTable, $row['REFERENCED_TABLE_NAME']); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $table = 'test_null_def_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('nickname', 100)->nullable()->default('anonymous'); + $bp->integer('score')->default(0); + }); + + $this->mysqlStatement($result->query); + + $columns = $this->fetchMysqlColumns($table); + + $nicknameCol = $this->findColumn($columns, 'nickname'); + $this->assertSame('YES', $nicknameCol['IS_NULLABLE']); + $this->assertSame('anonymous', $nicknameCol['COLUMN_DEFAULT']); + + $scoreCol = $this->findColumn($columns, 'score'); + $this->assertSame('0', (string) $scoreCol['COLUMN_DEFAULT']); // @phpstan-ignore cast.string + } + + public function testTruncateTable(): void + { + $table = 'test_truncate_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 50); + }); + $this->mysqlStatement($create->query); + + $pdo = $this->connectMysql(); + $insertStmt = $pdo->prepare("INSERT INTO `{$table}` (`id`, `name`) VALUES (1, 'a'), (2, 'b')"); + \assert($insertStmt !== false); + $insertStmt->execute(); + + $truncate = $this->schema->truncate($table); + $this->mysqlStatement($truncate->query); + + $stmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM `{$table}`"); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + /** + * @return list> + */ + private function fetchMysqlColumns(string $table): array + { + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT * FROM information_schema.COLUMNS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ?" + ); + $stmt->execute([$table]); + + /** @var list> */ + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * @param list> $columns + * @return array + */ + private function findColumn(array $columns, string $name): array + { + foreach ($columns as $col) { + if ($col['COLUMN_NAME'] === $name) { + return $col; + } + } + + $this->fail("Column '{$name}' not found"); + } +} diff --git a/tests/Integration/Schema/PostgreSQLIntegrationTest.php b/tests/Integration/Schema/PostgreSQLIntegrationTest.php new file mode 100644 index 0000000..b65dbe6 --- /dev/null +++ b/tests/Integration/Schema/PostgreSQLIntegrationTest.php @@ -0,0 +1,284 @@ +schema = new PostgreSQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('age'); + $bp->string('name', 100); + $bp->float('score'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertContains('age', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('score', $columnNames); + + $nameCol = $this->findColumn($columns, 'name'); + $this->assertSame('character varying', $nameCol['data_type']); + $this->assertSame('100', (string) $nameCol['character_maximum_length']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithIdentityColumn(): void + { + $table = 'test_identity_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->id(); + $bp->string('label', 50); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT is_identity, identity_generation FROM information_schema.columns " + . "WHERE table_catalog = 'query_test' AND table_name = :table AND column_name = 'id'" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('YES', $row['is_identity']); + $this->assertSame('BY DEFAULT', $row['identity_generation']); + } + + public function testCreateTableWithJsonbColumn(): void + { + $table = 'test_jsonb_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->json('metadata'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + $metaCol = $this->findColumn($columns, 'metadata'); + + $this->assertSame('jsonb', $metaCol['data_type']); + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->addColumn('description', ColumnType::Text); + }); + $this->postgresStatement($alter->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertContains('description', $columnNames); + } + + public function testAlterTableDropColumn(): void + { + $table = 'test_alter_drop_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Blueprint $bp) { + $bp->dropColumn('temp'); + }); + $this->postgresStatement($alter->query); + + $columns = $this->fetchPostgresColumns($table); + $columnNames = array_column($columns, 'column_name'); + + $this->assertNotContains('temp', $columnNames); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + }); + $this->postgresStatement($create->query); + + $drop = $this->schema->drop($table); + $this->postgresStatement($drop->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) as cnt FROM information_schema.tables " + . "WHERE table_catalog = 'query_test' AND table_schema = 'public' AND table_name = :table" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithBooleanAndText(): void + { + $table = 'test_bool_text_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->boolean('is_active'); + $bp->text('bio'); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + + $boolCol = $this->findColumn($columns, 'is_active'); + $this->assertSame('boolean', $boolCol['data_type']); + + $textCol = $this->findColumn($columns, 'bio'); + $this->assertSame('text', $textCol['data_type']); + } + + public function testCreateTableWithUniqueConstraint(): void + { + $table = 'test_unique_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255)->unique(); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT tc.constraint_type FROM information_schema.table_constraints tc " + . "JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name " + . "WHERE tc.table_name = :table AND ccu.column_name = 'email' AND tc.constraint_type = 'UNIQUE'" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame('UNIQUE', $row['constraint_type']); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $table = 'test_null_def_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('nickname', 100)->nullable()->default('anonymous'); + $bp->integer('score')->default(0); + }); + + $this->postgresStatement($result->query); + + $columns = $this->fetchPostgresColumns($table); + + $nicknameCol = $this->findColumn($columns, 'nickname'); + $this->assertSame('YES', $nicknameCol['is_nullable']); + $this->assertStringContainsString('anonymous', (string) $nicknameCol['column_default']); // @phpstan-ignore cast.string + + $scoreCol = $this->findColumn($columns, 'score'); + $this->assertSame('0', (string) $scoreCol['column_default']); // @phpstan-ignore cast.string + } + + public function testTruncateTable(): void + { + $table = 'test_truncate_' . uniqid(); + $this->trackPostgresTable($table); + + $create = $this->schema->create($table, function (Blueprint $bp) { + $bp->integer('id')->primary(); + $bp->string('name', 50); + }); + $this->postgresStatement($create->query); + + $pdo = $this->connectPostgres(); + $insertStmt = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"name\") VALUES (1, 'a'), (2, 'b')"); + \assert($insertStmt !== false); + $insertStmt->execute(); + + $truncate = $this->schema->truncate($table); + $this->postgresStatement($truncate->query); + + $stmt = $pdo->prepare("SELECT COUNT(*) as cnt FROM \"{$table}\""); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('0', (string) $row['cnt']); // @phpstan-ignore cast.string + } + + /** + * @return list> + */ + private function fetchPostgresColumns(string $table): array + { + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare( + "SELECT * FROM information_schema.columns " + . "WHERE table_catalog = 'query_test' AND table_schema = 'public' AND table_name = :table" + ); + $stmt->execute(['table' => $table]); + + /** @var list> */ + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * @param list> $columns + * @return array + */ + private function findColumn(array $columns, string $name): array + { + foreach ($columns as $col) { + if ($col['column_name'] === $name) { + return $col; + } + } + + $this->fail("Column '{$name}' not found"); + } +} diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php new file mode 100644 index 0000000..fa3d6b8 --- /dev/null +++ b/tests/Query/AggregationQueryTest.php @@ -0,0 +1,259 @@ +assertSame(Method::Count, $query->getMethod()); + $this->assertEquals('*', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testCountWithAttribute(): void + { + $query = Query::count('id'); + $this->assertSame(Method::Count, $query->getMethod()); + $this->assertEquals('id', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testCountWithAlias(): void + { + $query = Query::count('*', 'total'); + $this->assertEquals('*', $query->getAttribute()); + $this->assertEquals(['total'], $query->getValues()); + $this->assertEquals('total', $query->getValue()); + } + + public function testSum(): void + { + $query = Query::sum('price'); + $this->assertSame(Method::Sum, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testSumWithAlias(): void + { + $query = Query::sum('price', 'total_price'); + $this->assertEquals(['total_price'], $query->getValues()); + } + + public function testAvg(): void + { + $query = Query::avg('score'); + $this->assertSame(Method::Avg, $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + } + + public function testMin(): void + { + $query = Query::min('price'); + $this->assertSame(Method::Min, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testMax(): void + { + $query = Query::max('price'); + $this->assertSame(Method::Max, $query->getMethod()); + $this->assertEquals('price', $query->getAttribute()); + } + + public function testGroupBy(): void + { + $query = Query::groupBy(['status', 'country']); + $this->assertSame(Method::GroupBy, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(['status', 'country'], $query->getValues()); + } + + public function testHaving(): void + { + $inner = [ + Query::greaterThan('count', 5), + ]; + $query = Query::having($inner); + $this->assertSame(Method::Having, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + } + + public function testAggregateMethodsAreAggregate(): void + { + $this->assertTrue(Method::Count->isAggregate()); + $this->assertTrue(Method::Sum->isAggregate()); + $this->assertTrue(Method::Avg->isAggregate()); + $this->assertTrue(Method::Min->isAggregate()); + $this->assertTrue(Method::Max->isAggregate()); + $this->assertTrue(Method::CountDistinct->isAggregate()); + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + $this->assertCount(6, $aggMethods); + } + + public function testCountWithEmptyStringAttribute(): void + { + $query = Query::count(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testSumWithEmptyAlias(): void + { + $query = Query::sum('price', ''); + $this->assertEquals([], $query->getValues()); + } + + public function testAvgWithAlias(): void + { + $query = Query::avg('score', 'avg_score'); + $this->assertEquals(['avg_score'], $query->getValues()); + $this->assertEquals('avg_score', $query->getValue()); + } + + public function testMinWithAlias(): void + { + $query = Query::min('price', 'min_price'); + $this->assertEquals(['min_price'], $query->getValues()); + } + + public function testMaxWithAlias(): void + { + $query = Query::max('price', 'max_price'); + $this->assertEquals(['max_price'], $query->getValues()); + } + + public function testGroupByEmpty(): void + { + $query = Query::groupBy([]); + $this->assertSame(Method::GroupBy, $query->getMethod()); + $this->assertEquals([], $query->getValues()); + } + + public function testGroupBySingleColumn(): void + { + $query = Query::groupBy(['status']); + $this->assertEquals(['status'], $query->getValues()); + } + + public function testGroupByManyColumns(): void + { + $cols = ['a', 'b', 'c', 'd', 'e', 'f', 'g']; + $query = Query::groupBy($cols); + $this->assertCount(7, $query->getValues()); + } + + public function testGroupByDuplicateColumns(): void + { + $query = Query::groupBy(['status', 'status']); + $this->assertEquals(['status', 'status'], $query->getValues()); + } + + public function testHavingEmpty(): void + { + $query = Query::having([]); + $this->assertSame(Method::Having, $query->getMethod()); + $this->assertEquals([], $query->getValues()); + } + + public function testHavingMultipleConditions(): void + { + $inner = [ + Query::greaterThan('count', 5), + Query::lessThan('total', 1000), + ]; + $query = Query::having($inner); + $this->assertCount(2, $query->getValues()); + $this->assertInstanceOf(Query::class, $query->getValues()[0]); + $this->assertInstanceOf(Query::class, $query->getValues()[1]); + } + + public function testHavingWithLogicalOr(): void + { + $inner = [ + Query::or([ + Query::greaterThan('count', 5), + Query::lessThan('count', 1), + ]), + ]; + $query = Query::having($inner); + $this->assertCount(1, $query->getValues()); + } + + public function testHavingIsNested(): void + { + $query = Query::having([Query::greaterThan('x', 1)]); + $this->assertTrue($query->isNested()); + } + + public function testDistinctIsNotNested(): void + { + $query = Query::distinct(); + $this->assertFalse($query->isNested()); + } + + public function testCountCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::count('id'); + $sql = $query->compile($builder); + $this->assertEquals('COUNT(`id`)', $sql); + } + + public function testSumCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::sum('price', 'total'); + $sql = $query->compile($builder); + $this->assertEquals('SUM(`price`) AS `total`', $sql); + } + + public function testAvgCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::avg('score'); + $sql = $query->compile($builder); + $this->assertEquals('AVG(`score`)', $sql); + } + + public function testMinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::min('price'); + $sql = $query->compile($builder); + $this->assertEquals('MIN(`price`)', $sql); + } + + public function testMaxCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::max('price'); + $sql = $query->compile($builder); + $this->assertEquals('MAX(`price`)', $sql); + } + + public function testGroupByCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::groupBy(['status', 'country']); + $sql = $query->compile($builder); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testHavingCompileDispatchUsesCompileFilter(): void + { + $builder = new MySQL(); + $query = Query::having([Query::greaterThan('total', 5)]); + $sql = $query->compile($builder); + $this->assertEquals('(`total` > ?)', $sql); + $this->assertEquals([5], $builder->getBindings()); + } +} diff --git a/tests/Query/AssertsBindingCount.php b/tests/Query/AssertsBindingCount.php new file mode 100644 index 0000000..5b52741 --- /dev/null +++ b/tests/Query/AssertsBindingCount.php @@ -0,0 +1,25 @@ +countPlaceholders($result->query); + $this->assertSame( + $placeholders, + count($result->bindings), + "Placeholder count ({$placeholders}) != binding count (" . count($result->bindings) . ")\nQuery: {$result->query}" + ); + } + + private function countPlaceholders(string $sql): int + { + // Match `?` but NOT `?|` or `?&` (PostgreSQL JSONB operators) + // and NOT `??` (escaped question mark) + return (int) preg_match_all('/(?assertInstanceOf(Compiler::class, $builder); + } + + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } + + public function testBasicSelect(): void + { + $result = (new Builder()) + ->from('events') + ->select(['name', 'timestamp']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT `name`, `timestamp` FROM `events`', $result->query); + } + + public function testFilterAndSort(): void + { + $result = (new Builder()) + ->from('events') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('count', 10), + ]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 10, 100], $result->bindings); + } + + public function testRegexUsesMatchFunction(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api/v[0-9]+')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api/v[0-9]+'], $result->bindings); + } + + public function testSearchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + } + + public function testRandomOrderUsesLowercaseRand(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); + } + + public function testFinalKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); + } + + public function testFinalWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); + } + + public function testSampleWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5', $result->query); + } + + public function testPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?)', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + + public function testPrewhereWithMultipleConditions(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([ + Query::equal('event_type', ['click']), + Query::greaterThan('timestamp', '2024-01-01'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ?', + $result->query + ); + $this->assertEquals(['click', '2024-01-01'], $result->bindings); + } + + public function testPrewhereWithWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertEquals(['click', 5], $result->bindings); + } + + public function testPrewhereWithJoinAndWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `users`.`age` > ?', + $result->query + ); + $this->assertEquals(['click', 18], $result->bindings); + } + + public function testFinalSamplePrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['click', 5, 100], $result->bindings); + } + + public function testAggregation(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'total') + ->sum('duration', 'total_duration') + ->groupBy(['event_type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, SUM(`duration`) AS `total_duration` FROM `events` GROUP BY `event_type` HAVING `total` > ?', + $result->query + ); + $this->assertEquals([10], $result->bindings); + } + + public function testJoin(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->leftJoin('sessions', 'events.session_id', 'sessions.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` LEFT JOIN `sessions` ON `events`.`session_id` = `sessions`.`id`', + $result->query + ); + } + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->distinct() + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events`', $result->query); + } + + public function testUnion(): void + { + $other = (new Builder())->from('events_archive')->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('year', [2024])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `events` WHERE `year` IN (?)) UNION (SELECT * FROM `events_archive` WHERE `year` IN (?))', + $result->query + ); + $this->assertEquals([2024, 2023], $result->bindings); + } + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` FINAL WHERE `status` IN ('active') LIMIT 10", + $sql + ); + } + + public function testResetClearsClickHouseState(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('logs')->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + + $this->assertSame($builder, $builder->from('t')); + $this->assertSame($builder, $builder->final()); + $this->assertSame($builder, $builder->sample(0.1)); + $this->assertSame($builder, $builder->prewhere([])); + $this->assertSame($builder, $builder->select(['a'])); + $this->assertSame($builder, $builder->filter([])); + $this->assertSame($builder, $builder->sortAsc('a')); + $this->assertSame($builder, $builder->limit(1)); + $this->assertSame($builder, $builder->reset()); + } + + public function testAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->addHook(new AttributeMap(['$id' => '_uid'])) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `_uid` IN (?)', + $result->query + ); + } + + public function testConditionProvider(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }; + + $result = (new Builder()) + ->from('events') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` WHERE `status` IN (?) AND _tenant = ?', + $result->query + ); + $this->assertEquals(['active', 't1'], $result->bindings); + } + + public function testPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + // prewhere bindings come before where bindings + $this->assertEquals(['click', 5, 10], $result->bindings); + } + + public function testCombinedPrewhereWhereJoinGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.user_id', 'users.id') + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('events.amount', 100)]) + ->count('*', 'total') + ->select(['users.country']) + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 5)]) + ->sortDesc('total') + ->limit(50) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + + // Verify clause ordering + $this->assertStringContainsString('SELECT', $query); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN `users`', $query); + $this->assertStringContainsString('PREWHERE `event_type` IN (?)', $query); + $this->assertStringContainsString('WHERE `events`.`amount` > ?', $query); + $this->assertStringContainsString('GROUP BY `users`.`country`', $query); + $this->assertStringContainsString('HAVING `total` > ?', $query); + $this->assertStringContainsString('ORDER BY `total` DESC', $query); + $this->assertStringContainsString('LIMIT ?', $query); + + // Verify ordering: PREWHERE before WHERE + $this->assertLessThan(strpos($query, 'WHERE'), strpos($query, 'PREWHERE')); + } + // 1. PREWHERE comprehensive (40+ tests) + + public function testPrewhereEmptyArray(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testPrewhereSingleEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testPrewhereSingleNotEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEqual('status', 'deleted')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `status` != ?', $result->query); + $this->assertEquals(['deleted'], $result->bindings); + } + + public function testPrewhereLessThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testPrewhereLessThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testPrewhereGreaterThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` > ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testPrewhereGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `score` >= ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testPrewhereBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testPrewhereNotBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notBetween('age', 0, 17)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 17], $result->bindings); + } + + public function testPrewhereStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::startsWith('path', '/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE startsWith(`path`, ?)', $result->query); + $this->assertEquals(['/api'], $result->bindings); + } + + public function testPrewhereNotStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notStartsWith('path', '/admin')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE NOT startsWith(`path`, ?)', $result->query); + $this->assertEquals(['/admin'], $result->bindings); + } + + public function testPrewhereEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::endsWith('file', '.csv')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE endsWith(`file`, ?)', $result->query); + $this->assertEquals(['.csv'], $result->bindings); + } + + public function testPrewhereNotEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEndsWith('file', '.tmp')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE NOT endsWith(`file`, ?)', $result->query); + $this->assertEquals(['.tmp'], $result->bindings); + } + + public function testPrewhereContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::contains('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testPrewhereContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::contains('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testPrewhereContainsAny(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsAny('tag', ['a', 'b', 'c'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `tag` IN (?, ?, ?)', $result->query); + $this->assertEquals(['a', 'b', 'c'], $result->bindings); + } + + public function testPrewhereContainsAll(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsAll('tag', ['x', 'y'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`tag`, ?) > 0 AND position(`tag`, ?) > 0)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); + } + + public function testPrewhereNotContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE position(`name`, ?) = 0', $result->query); + $this->assertEquals(['bad'], $result->bindings); + } + + public function testPrewhereNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertEquals(['bad', 'ugly'], $result->bindings); + } + + public function testPrewhereIsNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testPrewhereIsNotNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNotNull('email')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testPrewhereExists(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::exists(['col_a', 'col_b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NOT NULL AND `col_b` IS NOT NULL)', $result->query); + } + + public function testPrewhereNotExists(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notExists(['col_a'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`col_a` IS NULL)', $result->query); + } + + public function testPrewhereRegex(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api'], $result->bindings); + } + + public function testPrewhereAndLogical(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result->query); + $this->assertEquals([1, 2], $result->bindings); + } + + public function testPrewhereOrLogical(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result->query); + $this->assertEquals([1, 2], $result->bindings); + } + + public function testPrewhereNestedAndOr(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::and([ + Query::or([ + Query::equal('x', [1]), + Query::equal('y', [2]), + ]), + Query::greaterThan('z', 0), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result->query); + $this->assertEquals([1, 2, 0], $result->bindings); + } + + public function testPrewhereRawExpression(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::raw('toDate(created) > ?', ['2024-01-01'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result->query); + $this->assertEquals(['2024-01-01'], $result->bindings); + } + + public function testPrewhereMultipleCallsAdditive(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('a', [1])]) + ->prewhere([Query::equal('b', [2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertEquals([1, 2], $result->bindings); + } + + public function testPrewhereWithWhereFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL PREWHERE `type` IN (?) WHERE `count` > ?', + $result->query + ); + } + + public function testPrewhereWithWhereSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` SAMPLE 0.5 PREWHERE `type` IN (?) WHERE `count` > ?', + $result->query + ); + } + + public function testPrewhereWithWhereFinalSample(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.3) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.3 PREWHERE `type` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertEquals(['click', 5], $result->bindings); + } + + public function testPrewhereWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('GROUP BY `type`', $result->query); + } + + public function testPrewhereWithHaving(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('HAVING `total` > ?', $result->query); + } + + public function testPrewhereWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortAsc('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY `name` ASC', + $result->query + ); + } + + public function testPrewhereWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['click', 10, 20], $result->bindings); + } + + public function testPrewhereWithUnion(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertStringContainsString('UNION (SELECT', $result->query); + } + + public function testPrewhereWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->distinct() + ->select(['user_id']) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + } + + public function testPrewhereWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sum('amount', 'total_amount') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM(`amount`) AS `total_amount`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + } + + public function testPrewhereBindingOrderWithProvider(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant_id = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['click', 5, 't1'], $result->bindings); + } + + public function testPrewhereBindingOrderWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->cursorAfter('abc123') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + // prewhere, where filter, cursor + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals(5, $result->bindings[1]); + $this->assertEquals('abc123', $result->bindings[2]); + } + + public function testPrewhereBindingOrderComplex(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->count('*', 'total') + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->limit(50) + ->offset(100) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + // prewhere, filter, provider, cursor, having, limit, offset, union + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals(5, $result->bindings[1]); + $this->assertEquals('t1', $result->bindings[2]); + $this->assertEquals('cur1', $result->bindings[3]); + } + + public function testPrewhereWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->addHook(new AttributeMap([ + '$id' => '_uid', + ])) + ->prewhere([Query::equal('$id', ['abc'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result->query); + $this->assertEquals(['abc'], $result->bindings); + } + + public function testPrewhereOnlyNoWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('ts', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + // "PREWHERE" contains "WHERE" as a substring, so we check there is no standalone WHERE clause + $withoutPrewhere = str_replace('PREWHERE', '', $result->query); + $this->assertStringNotContainsString('WHERE', $withoutPrewhere); + } + + public function testPrewhereWithEmptyWhereFilter(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['a'])]) + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $withoutPrewhere = str_replace('PREWHERE', '', $result->query); + $this->assertStringNotContainsString('WHERE', $withoutPrewhere); + } + + public function testPrewhereAppearsAfterJoinsBeforeWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereMultipleFiltersInSingleCall(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` > ? AND `c` < ?', + $result->query + ); + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testPrewhereResetClearsPrewhereQueries(): void + { + $builder = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('PREWHERE', $result->query); + } + + public function testPrewhereInToRawSqlOutput(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + // 2. FINAL comprehensive (20+ tests) + + public function testFinalBasicSelect(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->select(['name', 'ts']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT `name`, `ts` FROM `events` FINAL', $result->query); + } + + public function testFinalWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + } + + public function testFinalWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + } + + public function testFinalWithGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('GROUP BY `type`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + } + + public function testFinalWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->distinct() + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT `user_id` FROM `events` FINAL', $result->query); + } + + public function testFinalWithSort(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sortAsc('name') + ->sortDesc('ts') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY `name` ASC, `ts` DESC', $result->query); + } + + public function testFinalWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); + } + + public function testFinalWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testFinalWithUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('UNION (SELECT', $result->query); + } + + public function testFinalWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL PREWHERE `type` IN (?)', $result->query); + } + + public function testFinalWithSampleAlone(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.25) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.25', $result->query); + } + + public function testFinalWithPrewhereSample(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $result->query); + } + + public function testFinalFullPipeline(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->select(['name']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('SELECT `name`', $query); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + } + + public function testFinalCalledMultipleTimesIdempotent(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->final() + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL', $result->query); + // Ensure FINAL appears only once + $this->assertEquals(1, substr_count($result->query, 'FINAL')); + } + + public function testFinalInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['ok'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` FINAL WHERE `status` IN ('ok')", $sql); + } + + public function testFinalPositionAfterTableBeforeJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $finalPos = strpos($query, 'FINAL'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($joinPos, $finalPos); + } + + public function testFinalWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('`col_status`', $result->query); + } + + public function testFinalWithConditionProvider(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); + } + + public function testFinalResetClearsFlag(): void + { + $builder = (new Builder()) + ->from('events') + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testFinalWithWhenConditional(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + + $result2 = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringNotContainsString('FINAL', $result2->query); + } + // 3. SAMPLE comprehensive (23 tests) + + public function testSample10Percent(): void + { + $result = (new Builder())->from('events')->sample(0.1)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $result->query); + } + + public function testSample50Percent(): void + { + $result = (new Builder())->from('events')->sample(0.5)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5', $result->query); + } + + public function testSample1Percent(): void + { + $result = (new Builder())->from('events')->sample(0.01)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.01', $result->query); + } + + public function testSample99Percent(): void + { + $result = (new Builder())->from('events')->sample(0.99)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.99', $result->query); + } + + public function testSampleWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.2) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.2 WHERE `status` IN (?)', $result->query); + } + + public function testSampleWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.3) + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.3', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + } + + public function testSampleWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->count('*', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.1', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); + } + + public function testSampleWithGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 2)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('HAVING', $result->query); + } + + public function testSampleWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->distinct() + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + } + + public function testSampleWithSort(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->sortDesc('ts') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY `ts` DESC', $result->query); + } + + public function testSampleWithLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result->query); + } + + public function testSampleWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->cursorAfter('xyz') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testSampleWithUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testSampleWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1 PREWHERE `type` IN (?)', $result->query); + } + + public function testSampleWithFinalKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.1', $result->query); + } + + public function testSampleWithFinalPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.2) + ->prewhere([Query::equal('t', ['a'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL SAMPLE 0.2 PREWHERE `t` IN (?)', $result->query); + } + + public function testSampleFullPipeline(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->select(['name']) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('SAMPLE 0.1', $query); + $this->assertStringContainsString('SELECT `name`', $query); + $this->assertStringContainsString('WHERE `count` > ?', $query); + } + + public function testSampleInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->filter([Query::equal('x', [1])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` SAMPLE 0.1 WHERE `x` IN (1)", $sql); + } + + public function testSamplePositionAfterFinalBeforeJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $finalPos = strpos($query, 'FINAL'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + } + + public function testSampleResetClearsFraction(): void + { + $builder = (new Builder())->from('events')->sample(0.5); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('SAMPLE', $result->query); + } + + public function testSampleWithWhenConditional(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + + $result2 = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->sample(0.5)) + ->build(); + + $this->assertStringNotContainsString('SAMPLE', $result2->query); + } + + public function testSampleCalledMultipleTimesLastWins(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->sample(0.5) + ->sample(0.9) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.9', $result->query); + } + + public function testSampleWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }) + ->filter([Query::equal('col', ['v'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`r_col`', $result->query); + } + // 4. ClickHouse regex: match() function (20 tests) + + public function testRegexBasicPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', 'error|warn')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals(['error|warn'], $result->bindings); + } + + public function testRegexWithEmptyPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals([''], $result->bindings); + } + + public function testRegexWithSpecialChars(): void + { + $pattern = '^/api/v[0-9]+\\.json$'; + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', $pattern)]) + ->build(); + $this->assertBindingCount($result); + + // Bindings preserve the pattern exactly as provided + $this->assertEquals([$pattern], $result->bindings); + } + + public function testRegexWithVeryLongPattern(): void + { + $longPattern = str_repeat('a', 1000); + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', $longPattern)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertEquals([$longPattern], $result->bindings); + } + + public function testRegexCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::equal('status', [200]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE match(`path`, ?) AND `status` IN (?)', + $result->query + ); + $this->assertEquals(['^/api', 200], $result->bindings); + } + + public function testRegexInPrewhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result->query); + $this->assertEquals(['^/api'], $result->bindings); + } + + public function testRegexInPrewhereAndWhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->filter([Query::regex('msg', 'err')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', + $result->query + ); + $this->assertEquals(['^/api', 'err'], $result->bindings); + } + + public function testRegexWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('logs') + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }) + ->filter([Query::regex('msg', 'test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` WHERE match(`col_msg`, ?)', $result->query); + } + + public function testRegexBindingPreserved(): void + { + $pattern = '(foo|bar)\\d+'; + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', $pattern)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([$pattern], $result->bindings); + } + + public function testMultipleRegexFilters(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::regex('msg', 'error'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE match(`path`, ?) AND match(`msg`, ?)', + $result->query + ); + } + + public function testRegexInAndLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::regex('path', '^/api'), + Query::greaterThan('status', 399), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE (match(`path`, ?) AND `status` > ?)', + $result->query + ); + } + + public function testRegexInOrLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::or([ + Query::regex('path', '^/api'), + Query::regex('path', '^/web'), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `logs` WHERE (match(`path`, ?) OR match(`path`, ?))', + $result->query + ); + } + + public function testRegexInNestedLogical(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::or([ + Query::regex('path', '^/api'), + Query::regex('path', '^/web'), + ]), + Query::equal('status', [500]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('`status` IN (?)', $result->query); + } + + public function testRegexWithFinal(): void + { + $result = (new Builder()) + ->from('logs') + ->final() + ->filter([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `logs` FINAL', $result->query); + $this->assertStringContainsString('match(`path`, ?)', $result->query); + } + + public function testRegexWithSample(): void + { + $result = (new Builder()) + ->from('logs') + ->sample(0.5) + ->filter([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('match(`path`, ?)', $result->query); + } + + public function testRegexInToRawSql(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + + public function testRegexCombinedWithContains(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::contains('msg', ['error']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('position(`msg`, ?) > 0', $result->query); + } + + public function testRegexCombinedWithStartsWith(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', 'complex.*pattern'), + Query::startsWith('msg', 'ERR'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('match(`path`, ?)', $result->query); + $this->assertStringContainsString('startsWith(`msg`, ?)', $result->query); + } + + public function testRegexPrewhereWithRegexWhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->filter([Query::regex('msg', 'error')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE match(`path`, ?)', $result->query); + $this->assertStringContainsString('WHERE match(`msg`, ?)', $result->query); + $this->assertEquals(['^/api', 'error'], $result->bindings); + } + + public function testRegexCombinedWithPrewhereContainsRegex(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([ + Query::regex('path', '^/api'), + Query::equal('level', ['error']), + ]) + ->filter([Query::regex('msg', 'timeout')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['^/api', 'error', 'timeout'], $result->bindings); + } + // 5. Search exception (10 tests) + + public function testSearchThrowsExceptionMessage(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello world')]) + ->build(); + } + + public function testNotSearchThrowsExceptionMessage(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search (MATCH AGAINST) is not supported in ClickHouse'); + + (new Builder()) + ->from('logs') + ->filter([Query::notSearch('content', 'hello world')]) + ->build(); + } + + public function testSearchExceptionContainsHelpfulText(): void + { + try { + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'test')]) + ->build(); + $this->fail('Expected Exception was not thrown'); + } catch (Exception $e) { + $this->assertStringContainsString('contains()', $e->getMessage()); + } + } + + public function testSearchInLogicalAndThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->filter([Query::and([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ])]) + ->build(); + } + + public function testSearchInLogicalOrThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->filter([Query::or([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ])]) + ->build(); + } + + public function testSearchCombinedWithValidFiltersFailsOnSearch(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->filter([ + Query::equal('status', ['active']), + Query::search('content', 'hello'), + ]) + ->build(); + } + + public function testSearchInPrewhereThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->prewhere([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchInPrewhereThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->prewhere([Query::notSearch('content', 'hello')]) + ->build(); + } + + public function testSearchWithFinalStillThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->final() + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testSearchWithSampleStillThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('logs') + ->sample(0.5) + ->filter([Query::search('content', 'hello')]) + ->build(); + } + // 6. ClickHouse rand() (10 tests) + + public function testRandomSortProducesLowercaseRand(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('rand()', $result->query); + $this->assertStringNotContainsString('RAND()', $result->query); + } + + public function testRandomSortCombinedWithAsc(): void + { + $result = (new Builder()) + ->from('events') + ->sortAsc('name') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, rand()', $result->query); + } + + public function testRandomSortCombinedWithDesc(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('ts') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `ts` DESC, rand()', $result->query); + } + + public function testRandomSortCombinedWithAscAndDesc(): void + { + $result = (new Builder()) + ->from('events') + ->sortAsc('name') + ->sortDesc('ts') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY `name` ASC, `ts` DESC, rand()', $result->query); + } + + public function testRandomSortWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` FINAL ORDER BY rand()', $result->query); + } + + public function testRandomSortWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.5 ORDER BY rand()', $result->query); + } + + public function testRandomSortWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` PREWHERE `type` IN (?) ORDER BY rand()', + $result->query + ); + } + + public function testRandomSortWithLimit(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testRandomSortWithFiltersAndJoins(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->filter([Query::equal('status', ['active'])]) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY rand()', $result->query); + } + + public function testRandomSortAlone(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events` ORDER BY rand()', $result->query); + $this->assertEquals([], $result->bindings); + } + // 7. All filter types work correctly (31 tests) + + public function testFilterEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); + $this->assertEquals(['x'], $result->bindings); + } + + public function testFilterEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x', 'y', 'z'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result->query); + $this->assertEquals(['x', 'y', 'z'], $result->bindings); + } + + public function testFilterNotEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', 'x')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` != ?', $result->query); + $this->assertEquals(['x'], $result->bindings); + } + + public function testFilterNotEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); + } + + public function testFilterLessThanValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThan('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` < ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testFilterLessThanEqualValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThanEqual('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` <= ?', $result->query); + } + + public function testFilterGreaterThanValue(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` > ?', $result->query); + } + + public function testFilterGreaterThanEqualValue(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThanEqual('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` >= ?', $result->query); + } + + public function testFilterBetweenValues(): void + { + $result = (new Builder())->from('t')->filter([Query::between('a', 1, 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result->query); + $this->assertEquals([1, 10], $result->bindings); + } + + public function testFilterNotBetweenValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notBetween('a', 1, 10)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` NOT BETWEEN ? AND ?', $result->query); + } + + public function testFilterStartsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('a', 'foo')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE startsWith(`a`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testFilterNotStartsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE NOT startsWith(`a`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testFilterEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE endsWith(`a`, ?)', $result->query); + $this->assertEquals(['bar'], $result->bindings); + } + + public function testFilterNotEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE NOT endsWith(`a`, ?)', $result->query); + $this->assertEquals(['bar'], $result->bindings); + } + + public function testFilterContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testFilterContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 OR position(`a`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testFilterContainsAnyValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAny('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?, ?)', $result->query); + } + + public function testFilterContainsAllValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 AND position(`a`, ?) > 0)', $result->query); + $this->assertEquals(['x', 'y'], $result->bindings); + } + + public function testFilterNotContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE position(`a`, ?) = 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testFilterNotContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (position(`a`, ?) = 0 AND position(`a`, ?) = 0)', $result->query); + } + + public function testFilterIsNullValue(): void + { + $result = (new Builder())->from('t')->filter([Query::isNull('a')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testFilterIsNotNullValue(): void + { + $result = (new Builder())->from('t')->filter([Query::isNotNull('a')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IS NOT NULL', $result->query); + } + + public function testFilterExistsValue(): void + { + $result = (new Builder())->from('t')->filter([Query::exists(['a', 'b'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL)', $result->query); + } + + public function testFilterNotExistsValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notExists(['a', 'b'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result->query); + } + + public function testFilterAndLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::and([Query::equal('a', [1]), Query::equal('b', [2])]), + ])->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?))', $result->query); + } + + public function testFilterOrLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::or([Query::equal('a', [1]), Query::equal('b', [2])]), + ])->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?))', $result->query); + } + + public function testFilterRaw(): void + { + $result = (new Builder())->from('t')->filter([Query::raw('x > ? AND y < ?', [1, 2])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE x > ? AND y < ?', $result->query); + $this->assertEquals([1, 2], $result->bindings); + } + + public function testFilterDeeplyNestedLogical(): void + { + $result = (new Builder())->from('t')->filter([ + Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::and([ + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]), + ]), + Query::equal('d', [4]), + ]), + ])->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`a` IN (?) OR (`b` > ? AND `c` < ?))', $result->query); + $this->assertStringContainsString('`d` IN (?)', $result->query); + } + + public function testFilterWithFloats(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('price', 9.99)])->build(); + $this->assertBindingCount($result); + $this->assertEquals([9.99], $result->bindings); + } + + public function testFilterWithNegativeNumbers(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('temp', -40)])->build(); + $this->assertBindingCount($result); + $this->assertEquals([-40], $result->bindings); + } + + public function testFilterWithEmptyStrings(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); + $this->assertBindingCount($result); + $this->assertEquals([''], $result->bindings); + } + // 8. Aggregation with ClickHouse features (15 tests) + + public function testAggregationCountWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `events` FINAL', $result->query); + } + + public function testAggregationSumWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->sum('amount', 'total_amount') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`amount`) AS `total_amount` FROM `events` SAMPLE 0.1', $result->query); + } + + public function testAggregationAvgWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->avg('price', 'avg_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('AVG(`price`) AS `avg_price`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + } + + public function testAggregationMinWithPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->filter([Query::greaterThan('amount', 0)]) + ->min('price', 'min_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('MIN(`price`) AS `min_price`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testAggregationMaxWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['sale'])]) + ->max('price', 'max_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('MAX(`price`) AS `max_price`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testMultipleAggregationsWithPrewhereGroupByHaving(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->sum('amount', 'total') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('GROUP BY `region`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + } + + public function testAggregationWithJoinFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); + } + + public function testAggregationWithDistinctSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->distinct() + ->count('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + } + + public function testAggregationWithAliasPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'click_count') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `click_count`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testAggregationWithoutAliasFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertStringContainsString('FINAL', $result->query); + } + + public function testCountStarAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testAggregationAllFeaturesUnion(): void + { + $other = (new Builder())->from('archive')->count('*', 'total'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testAggregationAttributeResolverPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->addHook(new AttributeMap([ + 'amt' => 'amount_cents', + ])) + ->prewhere([Query::equal('type', ['sale'])]) + ->sum('amt', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM(`amount_cents`)', $result->query); + } + + public function testAggregationConditionProviderPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['sale'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->count('*', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testGroupByHavingPrewhereFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('GROUP BY', $query); + $this->assertStringContainsString('HAVING', $query); + } + // 9. Join with ClickHouse features (15 tests) + + public function testJoinWithFinalFeature(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', + $result->query + ); + } + + public function testJoinWithSampleFeature(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `events` SAMPLE 0.5 JOIN `users` ON `events`.`uid` = `users`.`id`', + $result->query + ); + } + + public function testJoinWithPrewhereFeature(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testJoinWithPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testJoinAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + } + + public function testLeftJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->leftJoin('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testRightJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->rightJoin('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN `users`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testCrossJoinWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->crossJoin('config') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('CROSS JOIN `config`', $result->query); + } + + public function testMultipleJoinsWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->leftJoin('sessions', 'events.sid', 'sessions.id') + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `sessions`', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testJoinAggregationPrewhereGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['sale'])]) + ->count('*', 'cnt') + ->groupBy(['users.country']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); + } + + public function testJoinPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('users.age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['click', 18], $result->bindings); + } + + public function testJoinAttributeResolverPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->addHook(new AttributeMap([ + 'uid' => 'user_id', + ])) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('uid', ['abc'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `user_id` IN (?)', $result->query); + } + + public function testJoinConditionProviderPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testJoinPrewhereUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testJoinClauseOrdering(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + + $fromPos = strpos($query, 'FROM'); + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($finalPos, $fromPos); + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + // 10. Union with ClickHouse features (10 tests) + + public function testUnionMainHasFinal(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('UNION (SELECT * FROM `archive`)', $result->query); + } + + public function testUnionMainHasSample(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testUnionMainHasPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testUnionMainHasAllClickHouseFeatures(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testUnionAllWithPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testUnionBindingOrderWithPrewhere(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::equal('year', [2024])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + // prewhere, where, union + $this->assertEquals(['click', 2024, 2023], $result->bindings); + } + + public function testMultipleUnionsWithPrewhere(): void + { + $other1 = (new Builder())->from('archive1'); + $other2 = (new Builder())->from('archive2'); + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other1) + ->union($other2) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertEquals(2, substr_count($result->query, 'UNION')); + } + + public function testUnionJoinPrewhere(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testUnionAggregationPrewhereFinal(): void + { + $other = (new Builder())->from('archive')->count('*', 'total'); + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'total') + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testUnionWithComplexMainQuery(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->select(['name', 'count']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('count') + ->limit(10) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('SELECT `name`, `count`', $query); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('UNION', $query); + } + // 11. toRawSql with ClickHouse features (15 tests) + + public function testToRawSqlWithFinalFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` FINAL', $sql); + } + + public function testToRawSqlWithSampleFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` SAMPLE 0.1', $sql); + } + + public function testToRawSqlWithPrewhereFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` PREWHERE `type` IN ('click')", $sql); + } + + public function testToRawSqlWithPrewhereWhere(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + public function testToRawSqlWithAllFeatures(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + public function testToRawSqlAllFeaturesCombined(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10) + ->offset(20) + ->toRawSql(); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $sql); + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + $this->assertStringContainsString('WHERE `count` > 5', $sql); + $this->assertStringContainsString('ORDER BY `ts` DESC', $sql); + $this->assertStringContainsString('LIMIT 10', $sql); + $this->assertStringContainsString('OFFSET 20', $sql); + } + + public function testToRawSqlWithStringBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('name', ['hello world'])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `events` WHERE `name` IN ('hello world')", $sql); + } + + public function testToRawSqlWithNumericBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('count', 42)]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `count` > 42', $sql); + } + + public function testToRawSqlWithBooleanBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('active', [true])]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `active` IN (1)', $sql); + } + + public function testToRawSqlWithNullBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::raw('x = ?', [null])]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE x = NULL', $sql); + } + + public function testToRawSqlWithFloatBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('price', 9.99)]) + ->toRawSql(); + + $this->assertEquals('SELECT * FROM `events` WHERE `price` > 9.99', $sql); + } + + public function testToRawSqlCalledTwiceGivesSameResult(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $sql1 = $builder->toRawSql(); + $sql2 = $builder->toRawSql(); + + $this->assertEquals($sql1, $sql2); + } + + public function testToRawSqlWithUnionPrewhere(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->union($other) + ->toRawSql(); + + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + $this->assertStringContainsString('UNION', $sql); + } + + public function testToRawSqlWithJoinPrewhere(): void + { + $sql = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertStringContainsString('JOIN `users`', $sql); + $this->assertStringContainsString("PREWHERE `type` IN ('click')", $sql); + } + + public function testToRawSqlWithRegexMatch(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + // 12. Reset comprehensive (15 tests) + + public function testResetClearsPrewhereState(): void + { + $builder = (new Builder())->from('events')->prewhere([Query::equal('type', ['click'])]); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('PREWHERE', $result->query); + } + + public function testResetClearsFinalState(): void + { + $builder = (new Builder())->from('events')->final(); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testResetClearsSampleState(): void + { + $builder = (new Builder())->from('events')->sample(0.5); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('SAMPLE', $result->query); + } + + public function testResetClearsAllThreeTogether(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]); + $builder->build(); + $builder->reset(); + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `events`', $result->query); + } + + public function testResetPreservesAttributeResolver(): void + { + $hook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return 'r_' . $attribute; + } + }; + $builder = (new Builder()) + ->from('events') + ->addHook($hook) + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->filter([Query::equal('col', ['v'])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`r_col`', $result->query); + } + + public function testResetPreservesConditionProviders(): void + { + $builder = (new Builder()) + ->from('events') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->final(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testResetClearsTable(): void + { + $builder = (new Builder())->from('events'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('logs')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `logs`', $result->query); + $this->assertStringNotContainsString('events', $result->query); + } + + public function testResetClearsFilters(): void + { + $builder = (new Builder())->from('events')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WHERE', $result->query); + } + + public function testResetClearsUnions(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder())->from('events')->union($other); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testResetClearsBindings(): void + { + $builder = (new Builder())->from('events')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testBuildAfterResetMinimalOutput(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetRebuildWithPrewhere(): void + { + $builder = new Builder(); + $builder->from('events')->final()->build(); + $builder->reset(); + + $result = $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testResetRebuildWithFinal(): void + { + $builder = new Builder(); + $builder->from('events')->prewhere([Query::equal('x', [1])])->build(); + $builder->reset(); + + $result = $builder->from('events')->final()->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('PREWHERE', $result->query); + } + + public function testResetRebuildWithSample(): void + { + $builder = new Builder(); + $builder->from('events')->final()->build(); + $builder->reset(); + + $result = $builder->from('events')->sample(0.5)->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testMultipleResets(): void + { + $builder = new Builder(); + + $builder->from('a')->final()->build(); + $builder->reset(); + $builder->from('b')->sample(0.5)->build(); + $builder->reset(); + $builder->from('c')->prewhere([Query::equal('x', [1])])->build(); + $builder->reset(); + + $result = $builder->from('d')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `d`', $result->query); + $this->assertEquals([], $result->bindings); + } + // 13. when() with ClickHouse features (10 tests) + + public function testWhenTrueAddsPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + } + + public function testWhenFalseDoesNotAddPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('PREWHERE', $result->query); + } + + public function testWhenTrueAddsFinal(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + } + + public function testWhenFalseDoesNotAddFinal(): void + { + $result = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->final()) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testWhenTrueAddsSample(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + } + + public function testWhenWithBothPrewhereAndFilter(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testWhenNestedWithClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->final() + ->when(true, fn (Builder $b2) => $b2->sample(0.5)) + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + } + + public function testWhenChainedMultipleTimesWithClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->when(true, fn (Builder $b) => $b->sample(0.5)) + ->when(true, fn (Builder $b) => $b->prewhere([Query::equal('type', ['click'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.5', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testWhenAddsJoinAndPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->when( + true, + fn (Builder $b) => $b + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testWhenCombinedWithRegularWhen(): void + { + $result = (new Builder()) + ->from('events') + ->when(true, fn (Builder $b) => $b->final()) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + } + // 14. Condition provider with ClickHouse (10 tests) + + public function testProviderWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); + } + + public function testProviderWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); + } + + public function testProviderWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('deleted = ?', $result->query); + } + + public function testProviderPrewhereWhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + // prewhere, filter, provider + $this->assertEquals(['click', 5, 't1'], $result->bindings); + } + + public function testMultipleProvidersPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['o1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['click', 't1', 'o1'], $result->bindings); + } + + public function testProviderPrewhereCursorLimitBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + // prewhere, provider, cursor, limit + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('t1', $result->bindings[1]); + $this->assertEquals('cur1', $result->bindings[2]); + $this->assertEquals(10, $result->bindings[3]); + } + + public function testProviderAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testProviderPrewhereAggregation(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->count('*', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testProviderJoinsPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('tenant = ?', $result->query); + } + + public function testProviderReferencesTableNameFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition($table . '.deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('events.deleted = ?', $result->query); + $this->assertStringContainsString('FINAL', $result->query); + } + // 15. Cursor with ClickHouse features (8 tests) + + public function testCursorAfterWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testCursorBeforeWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorBefore('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('`_cursor` < ?', $result->query); + } + + public function testCursorPrewhereWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testCursorWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testCursorWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + + public function testCursorPrewhereBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('cur1', $result->bindings[1]); + } + + public function testCursorPrewhereProviderBindingOrder(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('click', $result->bindings[0]); + $this->assertEquals('t1', $result->bindings[1]); + $this->assertEquals('cur1', $result->bindings[2]); + } + + public function testCursorFullClickHousePipeline(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('`_cursor` > ?', $query); + $this->assertStringContainsString('LIMIT', $query); + } + // 16. page() with ClickHouse features (5 tests) + + public function testPageWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->page(2, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(['click', 25, 25], $result->bindings); + } + + public function testPageWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); + } + + public function testPageWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->page(1, 50) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertEquals([50, 0], $result->bindings); + } + + public function testPageWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->page(2, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('LIMIT', $result->query); + $this->assertStringContainsString('OFFSET', $result->query); + } + + public function testPageWithComplexClickHouseQuery(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->page(5, 20) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('SAMPLE', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + } + // 17. Fluent chaining comprehensive (5 tests) + + public function testAllClickHouseMethodsReturnSameInstance(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->final()); + $this->assertSame($builder, $builder->sample(0.5)); + $this->assertSame($builder, $builder->prewhere([])); + $this->assertSame($builder, $builder->reset()); + } + + public function testChainingClickHouseMethodsWithBaseMethods(): void + { + $builder = new Builder(); + $result = $builder + ->from('events') + ->final() + ->sample(0.1) + ->select(['name']) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->sortDesc('ts') + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $this->assertNotEmpty($result->query); + } + + public function testChainingOrderDoesNotMatterForOutput(): void + { + $result1 = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + + $result2 = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sample(0.1) + ->filter([Query::greaterThan('count', 5)]) + ->final() + ->build(); + + $this->assertEquals($result1->query, $result2->query); + } + + public function testSameComplexQueryDifferentOrders(): void + { + $result1 = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('ts') + ->limit(10) + ->build(); + + $result2 = (new Builder()) + ->from('events') + ->sortDesc('ts') + ->limit(10) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sample(0.1) + ->final() + ->build(); + + $this->assertEquals($result1->query, $result2->query); + } + + public function testFluentResetThenRebuild(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1); + $builder->build(); + + $result = $builder->reset() + ->from('logs') + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `logs` SAMPLE 0.5', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + } + // 18. SQL clause ordering verification (10 tests) + + public function testClauseOrderSelectFromFinalSampleJoinPrewhereWhereGroupByHavingOrderByLimitOffset(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->select(['users.name']) + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 5)]) + ->sortDesc('cnt') + ->limit(50) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + + $selectPos = strpos($query, 'SELECT'); + $fromPos = strpos($query, 'FROM'); + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + $groupByPos = strpos($query, 'GROUP BY'); + $havingPos = strpos($query, 'HAVING'); + $orderByPos = strpos($query, 'ORDER BY'); + $limitPos = strpos($query, 'LIMIT'); + $offsetPos = strpos($query, 'OFFSET'); + + $this->assertLessThan($fromPos, $selectPos); + $this->assertLessThan($finalPos, $fromPos); + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + $this->assertLessThan($groupByPos, $wherePos); + $this->assertLessThan($havingPos, $groupByPos); + $this->assertLessThan($orderByPos, $havingPos); + $this->assertLessThan($limitPos, $orderByPos); + $this->assertLessThan($offsetPos, $limitPos); + } + + public function testFinalComesAfterTableBeforeJoin(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $tablePos = strpos($query, '`events`'); + $finalPos = strpos($query, 'FINAL'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($finalPos, $tablePos); + $this->assertLessThan($joinPos, $finalPos); + } + + public function testSampleComesAfterFinalBeforeJoin(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $joinPos = strpos($query, 'JOIN'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($joinPos, $samplePos); + } + + public function testPrewhereComesAfterJoinBeforeWhere(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereBeforeGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $prewherePos = strpos($query, 'PREWHERE'); + $groupByPos = strpos($query, 'GROUP BY'); + + $this->assertLessThan($groupByPos, $prewherePos); + } + + public function testPrewhereBeforeOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortDesc('ts') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $prewherePos = strpos($query, 'PREWHERE'); + $orderByPos = strpos($query, 'ORDER BY'); + + $this->assertLessThan($orderByPos, $prewherePos); + } + + public function testPrewhereBeforeLimit(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $prewherePos = strpos($query, 'PREWHERE'); + $limitPos = strpos($query, 'LIMIT'); + + $this->assertLessThan($limitPos, $prewherePos); + } + + public function testFinalSampleBeforePrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $finalPos = strpos($query, 'FINAL'); + $samplePos = strpos($query, 'SAMPLE'); + $prewherePos = strpos($query, 'PREWHERE'); + + $this->assertLessThan($samplePos, $finalPos); + $this->assertLessThan($prewherePos, $samplePos); + } + + public function testWhereBeforeHaving(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $wherePos = strpos($query, 'WHERE'); + $havingPos = strpos($query, 'HAVING'); + + $this->assertLessThan($havingPos, $wherePos); + } + + public function testFullQueryAllClausesAllPositions(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->distinct() + ->select(['name']) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 0)]) + ->count('*', 'cnt') + ->groupBy(['name']) + ->having([Query::greaterThan('cnt', 5)]) + ->sortDesc('cnt') + ->limit(50) + ->offset(10) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + + // All elements present + $this->assertStringContainsString('SELECT DISTINCT', $query); + $this->assertStringContainsString('FINAL', $query); + $this->assertStringContainsString('SAMPLE', $query); + $this->assertStringContainsString('JOIN', $query); + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + $this->assertStringContainsString('GROUP BY', $query); + $this->assertStringContainsString('HAVING', $query); + $this->assertStringContainsString('ORDER BY', $query); + $this->assertStringContainsString('LIMIT', $query); + $this->assertStringContainsString('OFFSET', $query); + $this->assertStringContainsString('UNION', $query); + } + // 19. Batch mode with ClickHouse (5 tests) + + public function testQueriesMethodWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('LIMIT', $result->query); + } + + public function testQueriesMethodWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->queries([ + Query::equal('status', ['active']), + Query::limit(10), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + } + + public function testQueriesMethodWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->queries([ + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testQueriesMethodWithAllClickHouseFeatures(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + } + + public function testQueriesComparedToFluentApiSameSql(): void + { + $resultA = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('ts') + ->limit(10) + ->build(); + + $resultB = (new Builder()) + ->from('events') + ->queries([ + Query::equal('status', ['active']), + Query::orderDesc('ts'), + Query::limit(10), + ]) + ->build(); + + $this->assertEquals($resultA->query, $resultB->query); + $this->assertEquals($resultA->bindings, $resultB->bindings); + } + // 20. Edge cases (10 tests) + + public function testEmptyTableNameWithFinal(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->final() + ->build(); + } + + public function testEmptyTableNameWithSample(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->sample(0.5) + ->build(); + } + + public function testPrewhereWithEmptyFilterValues(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + } + + public function testVeryLongTableNameWithFinalSample(): void + { + $longName = str_repeat('a', 200); + $result = (new Builder()) + ->from($longName) + ->final() + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`' . $longName . '`', $result->query); + $this->assertStringContainsString('FINAL SAMPLE 0.1', $result->query); + } + + public function testMultipleBuildsConsistentOutput(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + $result3 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result2->query, $result3->query); + $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals($result2->bindings, $result3->bindings); + } + + public function testBuildResetsBindingsButNotClickHouseState(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + // ClickHouse state persists + $this->assertStringContainsString('FINAL', $result2->query); + $this->assertStringContainsString('SAMPLE', $result2->query); + $this->assertStringContainsString('PREWHERE', $result2->query); + + // Bindings are consistent + $this->assertEquals($result1->bindings, $result2->bindings); + } + + public function testSampleWithAllBindingTypes(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->cursorAfter('cur1') + ->sortAsc('_cursor') + ->filter([Query::greaterThan('count', 5)]) + ->count('*', 'cnt') + ->groupBy(['type']) + ->having([Query::greaterThan('cnt', 10)]) + ->limit(50) + ->offset(100) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + // Verify all binding types present + $this->assertNotEmpty($result->bindings); + $this->assertGreaterThan(5, count($result->bindings)); + } + + public function testPrewhereAppearsCorrectlyWithoutJoins(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('PREWHERE', $query); + $this->assertStringContainsString('WHERE', $query); + + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereAppearsCorrectlyWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $joinPos = strpos($query, 'JOIN'); + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testFinalSampleTextInOutputWithJoins(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->join('users', 'events.uid', 'users.id') + ->leftJoin('sessions', 'events.sid', 'sessions.id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $query); + $this->assertStringContainsString('JOIN `users`', $query); + $this->assertStringContainsString('LEFT JOIN `sessions`', $query); + + // FINAL SAMPLE appears before JOINs + $finalSamplePos = strpos($query, 'FINAL SAMPLE 0.1'); + $joinPos = strpos($query, 'JOIN'); + $this->assertLessThan($joinPos, $finalSamplePos); + } + // 1. Spatial/Vector/ElemMatch Exception Tests + + public function testFilterCrossesThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::crosses('attr', [1])])->build(); + } + + public function testFilterNotCrossesThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::notCrosses('attr', [1])])->build(); + } + + public function testFilterDistanceEqualThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::distanceEqual('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceNotEqualThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::distanceNotEqual('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceGreaterThanThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::distanceGreaterThan('attr', [0, 0], 1)])->build(); + } + + public function testFilterDistanceLessThanThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1)])->build(); + } + + public function testFilterIntersectsThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::intersects('attr', [1])])->build(); + } + + public function testFilterNotIntersectsThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::notIntersects('attr', [1])])->build(); + } + + public function testFilterOverlapsThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::overlaps('attr', [1])])->build(); + } + + public function testFilterNotOverlapsThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::notOverlaps('attr', [1])])->build(); + } + + public function testFilterTouchesThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::touches('attr', [1])])->build(); + } + + public function testFilterNotTouchesThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::notTouches('attr', [1])])->build(); + } + + public function testFilterVectorDotThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); + } + + public function testFilterVectorCosineThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + } + + public function testFilterVectorEuclideanThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); + } + + public function testFilterElemMatchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); + } + // 2. SAMPLE Boundary Values + + public function testSampleZero(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(0.0); + } + + public function testSampleOne(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(1.0); + } + + public function testSampleNegative(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(-0.5); + } + + public function testSampleGreaterThanOne(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(2.0); + } + + public function testSampleVerySmall(): void + { + $result = (new Builder())->from('t')->sample(0.001)->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('SAMPLE 0.001', $result->query); + } + // 3. Standalone Compiler Method Tests + + public function testCompileFilterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('age', 18)); + $this->assertEquals('`age` > ?', $sql); + $this->assertEquals([18], $builder->getBindings()); + } + + public function testCompileOrderAscStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertEquals('`name` ASC', $sql); + } + + public function testCompileOrderDescStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertEquals('`name` DESC', $sql); + } + + public function testCompileOrderRandomStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertEquals('rand()', $sql); + } + + public function testCompileOrderExceptionStandalone(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileOrder(Query::limit(10)); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(10)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(5)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertEquals([5], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b'])); + $this->assertEquals('`a`, `b`', $sql); + } + + public function testCompileSelectEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select([])); + $this->assertEquals('', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertEquals('`_cursor` > ?', $sql); + $this->assertEquals(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertEquals('`_cursor` < ?', $sql); + $this->assertEquals(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertEquals('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertEquals('SUM(`price`)', $sql); + } + + public function testCompileAggregateAvgWithAliasStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testCompileGroupByEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy([])); + $this->assertEquals('', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'u.id', 'o.uid')); + $this->assertEquals('JOIN `orders` ON `u`.`id` = `o`.`uid`', $sql); + } + + public function testCompileJoinExceptionStandalone(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileJoin(Query::equal('x', [1])); + } + // 4. Union with ClickHouse Features on Both Sides + + public function testUnionBothWithClickHouseFeatures(): void + { + $sub = (new Builder())->from('archive') + ->final() + ->sample(0.5) + ->filter([Query::equal('status', ['closed'])]); + $result = (new Builder())->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('FROM `archive` FINAL SAMPLE 0.5', $result->query); + } + + public function testUnionAllBothWithFinal(): void + { + $sub = (new Builder())->from('b')->final(); + $result = (new Builder())->from('a')->final() + ->unionAll($sub) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `a` FINAL', $result->query); + $this->assertStringContainsString('UNION ALL (SELECT * FROM `b` FINAL)', $result->query); + } + // 5. PREWHERE Binding Order Exhaustive Tests + + public function testPrewhereBindingOrderWithFilterAndHaving(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->groupBy(['type']) + ->having([Query::greaterThan('total', 10)]) + ->build(); + $this->assertBindingCount($result); + // Binding order: prewhere, filter, having + $this->assertEquals(['click', 5, 10], $result->bindings); + } + + public function testPrewhereBindingOrderWithProviderAndCursor(): void + { + $result = (new Builder())->from('t') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + // Binding order: prewhere, filter(none), provider, cursor + $this->assertEquals(['click', 't1', 'abc'], $result->bindings); + } + + public function testPrewhereMultipleFiltersBindingOrder(): void + { + $result = (new Builder())->from('t') + ->prewhere([ + Query::equal('type', ['a']), + Query::greaterThan('priority', 3), + ]) + ->filter([Query::lessThan('age', 30)]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + // prewhere bindings first, then filter, then limit + $this->assertEquals(['a', 3, 30, 10], $result->bindings); + } + // 6. Search Exception in PREWHERE Interaction + + public function testSearchInFilterThrowsExceptionWithMessage(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search'); + (new Builder())->from('t')->filter([Query::search('content', 'hello')])->build(); + } + + public function testSearchInPrewhereThrowsExceptionWithMessage(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->prewhere([Query::search('content', 'hello')])->build(); + } + // 7. Join Combinations with FINAL/SAMPLE + + public function testLeftJoinWithFinalAndSample(): void + { + $result = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->leftJoin('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 LEFT JOIN `users` ON `events`.`uid` = `users`.`id`', + $result->query + ); + } + + public function testRightJoinWithFinalFeature(): void + { + $result = (new Builder())->from('events') + ->final() + ->rightJoin('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `events` FINAL', $result->query); + $this->assertStringContainsString('RIGHT JOIN', $result->query); + } + + public function testCrossJoinWithPrewhereFeature(): void + { + $result = (new Builder())->from('events') + ->crossJoin('colors') + ->prewhere([Query::equal('type', ['a'])]) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); + $this->assertStringContainsString('PREWHERE `type` IN (?)', $result->query); + $this->assertEquals(['a'], $result->bindings); + } + + public function testJoinWithNonDefaultOperator(): void + { + $result = (new Builder())->from('t') + ->join('other', 'a', 'b', '!=') + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('JOIN `other` ON `a` != `b`', $result->query); + } + // 8. Condition Provider Position Verification + + public function testConditionProviderInWhereNotPrewhere(): void + { + $result = (new Builder())->from('t') + ->prewhere([Query::equal('type', ['click'])]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + $query = $result->query; + $prewherePos = strpos($query, 'PREWHERE'); + $wherePos = strpos($query, 'WHERE'); + // Provider should be in WHERE which comes after PREWHERE + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertGreaterThan($prewherePos, $wherePos); + $this->assertStringContainsString('WHERE _tenant = ?', $query); + } + + public function testConditionProviderWithNoFiltersClickHouse(): void + { + $result = (new Builder())->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_deleted = ?', [0]); + } + }) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE _deleted = ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + // 9. Page Boundary Values + + public function testPageZero(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->page(0, 10)->build(); + } + + public function testPageNegative(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->page(-1, 10)->build(); + } + + public function testPageLargeNumber(): void + { + $result = (new Builder())->from('t')->page(1000000, 25)->build(); + $this->assertBindingCount($result); + $this->assertEquals([25, 24999975], $result->bindings); + } + // 10. Build Without From + + public function testBuildWithoutFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->filter([Query::equal('x', [1])])->build(); + } + // 11. toRawSql Edge Cases for ClickHouse + + public function testToRawSqlWithFinalAndSampleEdge(): void + { + $sql = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->filter([Query::equal('type', ['click'])]) + ->toRawSql(); + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.1', $sql); + $this->assertStringContainsString("'click'", $sql); + } + + public function testToRawSqlWithPrewhereEdge(): void + { + $sql = (new Builder())->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + $this->assertStringContainsString('PREWHERE', $sql); + $this->assertStringContainsString("'click'", $sql); + $this->assertStringContainsString('5', $sql); + } + + public function testToRawSqlWithUnionEdge(): void + { + $sub = (new Builder())->from('b')->filter([Query::equal('x', [1])]); + $sql = (new Builder())->from('a')->final() + ->filter([Query::equal('y', [2])]) + ->union($sub) + ->toRawSql(); + $this->assertStringContainsString('FINAL', $sql); + $this->assertStringContainsString('UNION', $sql); + } + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertStringContainsString('0', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t')->filter([Query::raw('col = ?', [null])])->toRawSql(); + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlMixedTypes(): void + { + $sql = (new Builder())->from('t') + ->filter([ + Query::equal('name', ['str']), + Query::greaterThan('age', 42), + Query::lessThan('score', 9.99), + ]) + ->toRawSql(); + $this->assertStringContainsString("'str'", $sql); + $this->assertStringContainsString('42', $sql); + $this->assertStringContainsString('9.99', $sql); + } + // 12. Having with Multiple Sub-Queries + + public function testHavingMultipleSubQueries(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([ + Query::greaterThan('total', 5), + Query::lessThan('total', 100), + ]) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(100, $result->bindings); + } + + public function testHavingWithOrLogic(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::or([ + Query::greaterThan('total', 100), + Query::lessThan('total', 5), + ])]) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); + } + // 13. Reset Property-by-Property Verification + + public function testResetClearsClickHouseProperties(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->limit(10); + + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `other`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('SAMPLE', $result->query); + $this->assertStringNotContainsString('PREWHERE', $result->query); + } + + public function testResetFollowedByUnion(): void + { + $builder = (new Builder())->from('a') + ->final() + ->union((new Builder())->from('old')); + $builder->reset()->from('b'); + $result = $builder->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `b`', $result->query); + $this->assertStringNotContainsString('UNION', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + } + + public function testConditionProviderPersistsAfterReset(): void + { + $builder = (new Builder()) + ->from('t') + ->final() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }); + $builder->build(); + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `other`', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringContainsString('_tenant = ?', $result->query); + } + // 14. Exact Full SQL Assertions + + public function testFinalSamplePrewhereFilterExactSql(): void + { + $result = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 100)]) + ->sortDesc('amount') + ->limit(50) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ? ORDER BY `amount` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['purchase', 100, 50], $result->bindings); + } + + public function testKitchenSinkExactSql(): void + { + $sub = (new Builder())->from('archive')->final()->filter([Query::equal('status', ['closed'])]); + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->distinct() + ->count('*', 'total') + ->select(['event_type']) + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 100)]) + ->groupBy(['event_type']) + ->having([Query::greaterThan('total', 5)]) + ->sortDesc('total') + ->limit(50) + ->offset(10) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals( + '(SELECT DISTINCT COUNT(*) AS `total`, `event_type` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `amount` > ? GROUP BY `event_type` HAVING `total` > ? ORDER BY `total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` FINAL WHERE `status` IN (?))', + $result->query + ); + $this->assertEquals(['purchase', 100, 5, 50, 10, 'closed'], $result->bindings); + } + // 15. Query::compile() Integration Tests + + public function testQueryCompileFilterViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::greaterThan('age', 18)->compile($builder); + $this->assertEquals('`age` > ?', $sql); + } + + public function testQueryCompileRegexViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::regex('path', '^/api')->compile($builder); + $this->assertEquals('match(`path`, ?)', $sql); + } + + public function testQueryCompileOrderRandomViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::orderRandom()->compile($builder); + $this->assertEquals('rand()', $sql); + } + + public function testQueryCompileLimitViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::limit(10)->compile($builder); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testQueryCompileSelectViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::select(['a', 'b'])->compile($builder); + $this->assertEquals('`a`, `b`', $sql); + } + + public function testQueryCompileJoinViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::join('orders', 'u.id', 'o.uid')->compile($builder); + $this->assertEquals('JOIN `orders` ON `u`.`id` = `o`.`uid`', $sql); + } + + public function testQueryCompileGroupByViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::groupBy(['status'])->compile($builder); + $this->assertEquals('`status`', $sql); + } + // 16. Binding Type Assertions with assertSame + + public function testBindingTypesPreservedInt(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('age', 18)])->build(); + $this->assertBindingCount($result); + $this->assertSame([18], $result->bindings); + } + + public function testBindingTypesPreservedFloat(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('score', 9.5)])->build(); + $this->assertBindingCount($result); + $this->assertSame([9.5], $result->bindings); + } + + public function testBindingTypesPreservedBool(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('active', [true])])->build(); + $this->assertBindingCount($result); + $this->assertSame([true], $result->bindings); + } + + public function testBindingTypesPreservedNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('val', [null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `val` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testNotEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); + } + + public function testBindingTypesPreservedString(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('name', ['hello'])])->build(); + $this->assertBindingCount($result); + $this->assertSame(['hello'], $result->bindings); + } + // 17. Raw Inside Logical Groups + + public function testRawInsideLogicalAnd(): void + { + $result = (new Builder())->from('t') + ->filter([Query::and([ + Query::greaterThan('x', 1), + Query::raw('custom_func(y) > ?', [5]), + ])]) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertEquals([1, 5], $result->bindings); + } + + public function testRawInsideLogicalOr(): void + { + $result = (new Builder())->from('t') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::raw('b IS NOT NULL', []), + ])]) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); + } + // 18. Negative/Zero Limit and Offset + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([-1], $result->bindings); + } + + public function testNegativeOffset(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testLimitZero(): void + { + $result = (new Builder())->from('t')->limit(0)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + // 19. Multiple Limits/Offsets/Cursors First Wins + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertBindingCount($result); + $this->assertEquals([10], $result->bindings); + } + + public function testMultipleOffsetsFirstWins(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + } + // 20. Distinct + Union + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertBindingCount($result); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + } + // DML: INSERT (same as standard SQL) + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'click', 'timestamp' => '2024-01-01']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `events` (`name`, `timestamp`) VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['click', '2024-01-01'], $result->bindings); + } + + public function testInsertBatch(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'click', 'ts' => '2024-01-01']) + ->set(['name' => 'view', 'ts' => '2024-01-02']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `events` (`name`, `ts`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['click', '2024-01-01', 'view', '2024-01-02'], $result->bindings); + } + // ClickHouse does not implement Upsert + + public function testDoesNotImplementUpsert(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Upsert::class, $interfaces); + } + // DML: UPDATE uses ALTER TABLE ... UPDATE + + public function testUpdateUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['old'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 'old'], $result->bindings); + } + + public function testUpdateWithFilterHook(): void + { + $hook = new class () implements Filter, Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('events') + ->set(['status' => 'active']) + ->filter([Query::equal('id', [1])]) + ->addHook($hook) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['active', 1, 'tenant_123'], $result->bindings); + } + + public function testUpdateWithoutWhereThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse UPDATE requires a WHERE clause'); + + (new Builder()) + ->from('events') + ->set(['status' => 'active']) + ->update(); + } + // DML: DELETE uses ALTER TABLE ... DELETE + + public function testDeleteUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::lessThan('timestamp', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `events` DELETE WHERE `timestamp` < ?', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); + } + + public function testDeleteWithFilterHook(): void + { + $hook = new class () implements Filter, Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['deleted'])]) + ->addHook($hook) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['deleted', 'tenant_123'], $result->bindings); + } + + public function testDeleteWithoutWhereThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse DELETE requires a WHERE clause'); + + (new Builder()) + ->from('events') + ->delete(); + } + // INTERSECT / EXCEPT (supported in ClickHouse) + + public function testIntersect(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', + $result->query + ); + } + + public function testExcept(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', + $result->query + ); + } + // Feature interfaces (not implemented) + + public function testDoesNotImplementLocking(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Locking::class, $interfaces); + } + + public function testDoesNotImplementTransactions(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Transactions::class, $interfaces); + } + // INSERT...SELECT (supported in ClickHouse) + + public function testInsertSelect(): void + { + $source = (new Builder()) + ->from('events') + ->select(['name', 'timestamp']) + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->into('archived_events') + ->fromSelect(['name', 'timestamp'], $source) + ->insertSelect(); + + $this->assertEquals( + 'INSERT INTO `archived_events` (`name`, `timestamp`) SELECT `name`, `timestamp` FROM `events` WHERE `type` IN (?)', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + // CTEs (supported in ClickHouse) + + public function testCteWith(): void + { + $cte = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('clicks', $cte) + ->from('clicks') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'WITH `clicks` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `clicks`', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + // setRaw with bindings (ClickHouse) + + public function testSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('events') + ->setRaw('count', 'count + ?', [1]) + ->filter([Query::equal('id', [42])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `events` UPDATE `count` = count + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([1, 42], $result->bindings); + } + // Hints feature interface + + public function testImplementsHints(): void + { + $this->assertInstanceOf(Hints::class, new Builder()); + } + + public function testHintAppendsSettings(): void + { + $result = (new Builder()) + ->from('events') + ->hint('max_threads=4') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + } + + public function testMultipleHints(): void + { + $result = (new Builder()) + ->from('events') + ->hint('max_threads=4') + ->hint('max_memory_usage=1000000000') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + + public function testSettingsMethod(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4', 'max_memory_usage' => '1000000000']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + // Window functions + + public function testImplementsWindows(): void + { + $this->assertInstanceOf(Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['timestamp']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `timestamp` ASC) AS `rn`', $result->query); + } + // Does NOT implement Spatial/VectorSearch/Json + + public function testDoesNotImplementSpatial(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(Spatial::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementVectorSearch(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementJson(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(Json::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + // Reset clears hints + + public function testResetClearsHints(): void + { + $builder = (new Builder()) + ->from('events') + ->hint('max_threads=4'); + + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('SETTINGS', $result->query); + } + + public function testPrewhereWithSingleFilter(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testPrewhereWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE `status` IN (?) AND `age` > ?', $result->query); + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testPrewhereBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $prewherePos = strpos($result->query, 'PREWHERE'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testPrewhereBindingOrderBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testPrewhereWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->join('u', 't.uid', 'u.id') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $joinPos = strpos($result->query, 'JOIN'); + $prewherePos = strpos($result->query, 'PREWHERE'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($joinPos); + $this->assertNotFalse($prewherePos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($prewherePos, $joinPos); + $this->assertLessThan($wherePos, $prewherePos); + } + + public function testFinalKeywordInFromClause(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `t` FINAL', $result->query); + } + + public function testFinalAppearsBeforeWhere(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $finalPos = strpos($result->query, 'FINAL'); + $wherePos = strpos($result->query, 'WHERE'); + + $this->assertNotFalse($finalPos); + $this->assertNotFalse($wherePos); + $this->assertLessThan($wherePos, $finalPos); + } + + public function testFinalWithSample(): void + { + $result = (new Builder()) + ->from('t') + ->final() + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `t` FINAL SAMPLE 0.5', $result->query); + } + + public function testSampleFraction(): void + { + $result = (new Builder()) + ->from('t') + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `t` SAMPLE 0.1', $result->query); + } + + public function testSampleZeroThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(0.0); + } + + public function testSampleOneThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(1.0); + } + + public function testSampleNegativeThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->sample(-0.5); + } + + public function testUpdateAlterTableSyntax(): void + { + $result = (new Builder()) + ->from('t') + ->set(['name' => 'Bob']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `t` UPDATE `name` = ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Bob', 1], $result->bindings); + } + + public function testUpdateWithoutWhereClauseThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('WHERE'); + + (new Builder()) + ->from('t') + ->set(['name' => 'Bob']) + ->update(); + } + + public function testUpdateWithoutAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->update(); + } + + public function testUpdateWithRawSet(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('counter', '`counter` + 1') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`counter` = `counter` + 1', $result->query); + $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); + } + + public function testUpdateWithRawSetBindings(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('name', 'CONCAT(?, ?)', ['hello', ' world']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name` = CONCAT(?, ?)', $result->query); + $this->assertEquals(['hello', ' world', 1], $result->bindings); + } + + public function testDeleteAlterTableSyntax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `t` DELETE WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testDeleteWithoutWhereClauseThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->delete(); + } + + public function testDeleteWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['old']), + Query::lessThan('age', 5), + ]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE `status` IN (?) AND `age` < ?', $result->query); + $this->assertEquals(['old', 5], $result->bindings); + } + + public function testStartsWithUsesStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('startsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testNotStartsWithUsesNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT startsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testEndsWithUsesEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('endsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testNotEndsWithUsesNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT endsWith(`name`, ?)', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testContainsSingleValueUsesPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('position(`name`, ?) > 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testContainsMultipleValuesUsesOrPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testContainsAllUsesAndPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(position(`name`, ?) > 0 AND position(`name`, ?) > 0)', $result->query); + $this->assertEquals(['foo', 'bar'], $result->bindings); + } + + public function testNotContainsSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('position(`name`, ?) = 0', $result->query); + $this->assertEquals(['foo'], $result->bindings); + } + + public function testNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertEquals(['a', 'b'], $result->bindings); + } + + public function testRegexUsesMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('match(`name`, ?)', $result->query); + $this->assertEquals(['^test'], $result->bindings); + } + + public function testSearchThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + } + + public function testSettingsKeyValue(): void + { + $result = (new Builder()) + ->from('t') + ->settings(['max_threads' => '4', 'enable_optimize_predicate_expression' => '1']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4, enable_optimize_predicate_expression=1', $result->query); + } + + public function testHintAndSettingsCombined(): void + { + $result = (new Builder()) + ->from('t') + ->hint('max_threads=2') + ->settings(['enable_optimize_predicate_expression' => '1']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=2, enable_optimize_predicate_expression=1', $result->query); + } + + public function testHintsPreserveBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->hint('max_threads=4') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active'], $result->bindings); + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + } + + public function testHintsWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->join('u', 't.uid', 'u.id') + ->hint('max_threads=4') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + // SETTINGS must be at the very end + $this->assertStringEndsWith('SETTINGS max_threads=4', $result->query); + } + + public function testCTE(): void + { + $sub = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('sub', $sub) + ->from('sub') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'WITH `sub` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `sub`', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + } + + public function testCTERecursive(): void + { + $sub = (new Builder()) + ->from('categories') + ->filter([Query::equal('parent_id', [0])]); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WITH RECURSIVE `tree` AS', $result->query); + } + + public function testCTEBindingOrder(): void + { + $sub = (new Builder()) + ->from('events') + ->filter([Query::equal('type', ['click'])]); + + $result = (new Builder()) + ->with('sub', $sub) + ->from('sub') + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + // CTE bindings come before main query bindings + $this->assertEquals(['click', 5], $result->bindings); + } + + public function testWindowFunctionPartitionAndOrder(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); + } + + public function testWindowFunctionOrderDescending(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` DESC) AS `rn`', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['created_at']) + ->selectWindow('SUM(`amount`)', 'total', ['user_id'], null) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER', $result->query); + $this->assertStringContainsString('SUM(`amount`) OVER', $result->query); + } + + public function testSelectCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Unknown']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CASE WHEN `status` = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Unknown'], $result->bindings); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('`role` = ?', '?', ['admin'], ['Admin']) + ->elseResult('?', ['User']) + ->build(); + + $result = (new Builder()) + ->from('t') + ->setRaw('label', $case->sql, $case->bindings) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ALTER TABLE `t` UPDATE', $result->query); + $this->assertStringContainsString('CASE WHEN `role` = ? THEN ? ELSE ? END', $result->query); + $this->assertEquals(['admin', 'Admin', 'User', 1], $result->bindings); + } + + public function testUnionSimple(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringNotContainsString('UNION ALL', $result->query); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testUnionBindingsOrder(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('y', [2])]); + $result = (new Builder()) + ->from('a') + ->filter([Query::equal('x', [1])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([1, 2], $result->bindings); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(2, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([25, 25], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertEquals(['abc'], $result->bindings); + } + + public function testBuildWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->build(); + } + + public function testInsertWithoutRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->insert(); + } + + public function testBatchInsertMismatchedColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'email' => 'bob@example.com']) + ->insert(); + } + + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('t') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `t` (`name`, `age`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + } + + public function testJoinFilterForcedToWhere(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('`active` = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->leftJoin('u', 't.uid', 'u.id') + ->build(); + $this->assertBindingCount($result); + + // ClickHouse forces all join filter conditions to WHERE placement + $this->assertStringContainsString('WHERE `active` = ?', $result->query); + $this->assertStringNotContainsString('ON `t`.`uid` = `u`.`id` AND', $result->query); + } + + public function testToRawSqlClickHouseSyntax(): void + { + $sql = (new Builder()) + ->from('t') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertStringContainsString('FROM `t` FINAL', $sql); + $this->assertStringContainsString("'active'", $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testResetClearsPrewhere(): void + { + $builder = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('PREWHERE', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsSampleAndFinal(): void + { + $builder = (new Builder()) + ->from('t') + ->final() + ->sample(0.5); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertStringNotContainsString('SAMPLE', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` IS NULL', $result->query); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); + $this->assertContains(1, $result->bindings); + } + + public function testNotEqualSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 42)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertContains(42, $result->bindings); + } + + public function testAndFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); + } + + public function testOrFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); + } + + public function testNestedAndInsideOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([ + Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 30)]), + Query::and([Query::greaterThan('score', 80), Query::lessThan('score', 100)]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('((`age` > ? AND `age` < ?) OR (`score` > ? AND `score` < ?))', $result->query); + $this->assertEquals([18, 30, 80, 100], $result->bindings); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + } + + public function testNotExistsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`name` IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['events.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`events`.`name`', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT `name`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`amount`) AS `total`', $result->query); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name` IS NOT NULL', $result->query); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`score` > ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`score` >= ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `b`', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testPrewhereAndFilterBindingOrderVerification(): void + { + $result = (new Builder()) + ->from('t') + ->prewhere([Query::equal('status', ['active'])]) + ->filter([Query::greaterThan('count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 5], $result->bindings); + } + + public function testUpdateRawSetAndFilterBindingOrder(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('count', 'count + ?', [1]) + ->filter([Query::equal('status', ['active'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals([1, 'active'], $result->bindings); + } + + public function testSortRandomUsesRand(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY rand()', $result->query); + } + + // Feature 1: Table Aliases (ClickHouse - alias AFTER FINAL/SAMPLE) + + public function testTableAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` AS `e`', $result->query); + } + + public function testTableAliasWithFinal(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL AS `e`', $result->query); + } + + public function testTableAliasWithSample(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` SAMPLE 0.1 AS `e`', $result->query); + } + + public function testTableAliasWithFinalAndSample(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `events` FINAL SAMPLE 0.5 AS `e`', $result->query); + } + + // Feature 2: Subqueries (ClickHouse) + + public function testFromSubClickHouse(): void + { + $sub = (new Builder())->from('events')->select(['user_id'])->groupBy(['user_id']); + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT `user_id` FROM (SELECT `user_id` FROM `events` GROUP BY `user_id`) AS `sub`', + $result->query + ); + } + + public function testFilterWhereInClickHouse(): void + { + $sub = (new Builder())->from('orders')->select(['user_id']); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`id` IN (SELECT `user_id` FROM `orders`)', $result->query); + } + + // Feature 3: Raw ORDER BY / GROUP BY / HAVING (ClickHouse) + + public function testOrderByRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->orderByRaw('toDate(`created_at`) ASC') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY toDate(`created_at`) ASC', $result->query); + } + + public function testGroupByRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('toDate(`created_at`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY toDate(`created_at`)', $result->query); + } + + // Feature 4: countDistinct (ClickHouse) + + public function testCountDistinctClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `events`', + $result->query + ); + } + + // Feature 5: JoinBuilder (ClickHouse) + + public function testJoinWhereClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (JoinBuilder $join): void { + $join->on('events.user_id', 'users.id'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users` ON `events`.`user_id` = `users`.`id`', $result->query); + } + + // Feature 6: EXISTS Subquery (ClickHouse) + + public function testFilterExistsClickHouse(): void + { + $sub = (new Builder())->from('orders')->select(['id'])->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) + ->from('users') + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); + } + + // Feature 9: EXPLAIN (ClickHouse) + + public function testExplainClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + } + + public function testExplainAnalyzeClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + // Feature: Cross Join Alias (ClickHouse) + + public function testCrossJoinAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->crossJoin('dates', 'd') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `dates` AS `d`', $result->query); + } + + // Subquery bindings (ClickHouse) + + public function testWhereInSubqueryClickHouse(): void + { + $sub = (new Builder())->from('active_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereIn('user_id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`user_id` IN (SELECT `id` FROM `active_users`)', $result->query); + } + + public function testWhereNotInSubqueryClickHouse(): void + { + $sub = (new Builder())->from('banned_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereNotIn('user_id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`user_id` NOT IN (SELECT', $result->query); + } + + public function testSelectSubClickHouse(): void + { + $sub = (new Builder())->from('events')->selectRaw('COUNT(*)'); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'event_count') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(SELECT COUNT(*) FROM `events`) AS `event_count`', $result->query); + } + + public function testFromSubWithGroupByClickHouse(): void + { + $sub = (new Builder())->from('events')->select(['user_id'])->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM (SELECT `user_id` FROM `events`', $result->query); + $this->assertStringContainsString(') AS `sub`', $result->query); + } + + // NOT EXISTS (ClickHouse) + + public function testFilterNotExistsClickHouse(): void + { + $sub = (new Builder())->from('banned')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // HavingRaw (ClickHouse) + + public function testHavingRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('COUNT(*) > ?', [10]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + // Table alias with FINAL and SAMPLE and alias combined + + public function testTableAliasWithFinalSampleAndAlias(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FINAL', $result->query); + $this->assertStringContainsString('SAMPLE', $result->query); + $this->assertStringContainsString('AS `e`', $result->query); + } + + // JoinWhere LEFT JOIN (ClickHouse) + + public function testJoinWhereLeftJoinClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (JoinBuilder $join): void { + $join->on('events.user_id', 'users.id') + ->where('users.active', '=', 1); + }, JoinType::Left) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `users` ON', $result->query); + $this->assertEquals([1], $result->bindings); + } + + // JoinWhere with alias (ClickHouse) + + public function testJoinWhereWithAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->joinWhere('users', function (JoinBuilder $join): void { + $join->on('e.user_id', 'u.id'); + }, JoinType::Inner, 'u') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users` AS `u`', $result->query); + } + + // JoinWhere with multiple ON conditions (ClickHouse) + + public function testJoinWhereMultipleOnsClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->joinWhere('users', function (JoinBuilder $join): void { + $join->on('events.user_id', 'users.id') + ->on('events.tenant_id', 'users.tenant_id'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'ON `events`.`user_id` = `users`.`id` AND `events`.`tenant_id` = `users`.`tenant_id`', + $result->query + ); + } + + // EXPLAIN preserves bindings (ClickHouse) + + public function testExplainPreservesBindings(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + // countDistinct without alias (ClickHouse) + + public function testCountDistinctWithoutAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(DISTINCT `user_id`)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + // Multiple subqueries combined (ClickHouse) + + public function testMultipleSubqueriesCombined(): void + { + $sub1 = (new Builder())->from('active_users')->select(['id']); + $sub2 = (new Builder())->from('banned_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->filterWhereIn('user_id', $sub1) + ->filterWhereNotIn('user_id', $sub2) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('IN (SELECT', $result->query); + $this->assertStringContainsString('NOT IN (SELECT', $result->query); + } + + // PREWHERE with subquery (ClickHouse) + + public function testPrewhereWithSubquery(): void + { + $sub = (new Builder())->from('active_users')->select(['id']); + + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filterWhereIn('user_id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PREWHERE', $result->query); + $this->assertStringContainsString('IN (SELECT', $result->query); + } + + // Settings with subquery (ClickHouse) + + public function testSettingsStillAppear(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']) + ->orderByRaw('`created_at` DESC') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SETTINGS max_threads=4', $result->query); + $this->assertStringContainsString('ORDER BY `created_at` DESC', $result->query); + } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(25) + ->build(); + + $this->assertSame( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total']) + ->filter([ + Query::greaterThan('total', 100), + Query::lessThanEqual('total', 5000), + Query::equal('status', ['paid', 'shipped']), + Query::isNotNull('shipped_at'), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `total` FROM `orders` WHERE `total` > ? AND `total` <= ? AND `status` IN (?, ?) AND `shipped_at` IS NOT NULL', + $result->query + ); + $this->assertEquals([100, 5000, 'paid', 'shipped'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactPrewhere(): void + { + $result = (new Builder()) + ->from('hits') + ->select(['url', 'count']) + ->prewhere([Query::equal('site_id', [42])]) + ->filter([Query::greaterThan('count', 10)]) + ->build(); + + $this->assertSame( + 'SELECT `url`, `count` FROM `hits` PREWHERE `site_id` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertEquals([42, 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->select(['user_id', 'event_type']) + ->build(); + + $this->assertSame( + 'SELECT `user_id`, `event_type` FROM `events` FINAL', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSample(): void + { + $result = (new Builder()) + ->from('pageviews') + ->sample(0.1) + ->select(['url']) + ->build(); + + $this->assertSame( + 'SELECT `url` FROM `pageviews` SAMPLE 0.1', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFinalSamplePrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->sortDesc('timestamp') + ->limit(100) + ->build(); + + $this->assertSame( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['click', 5, 100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSettings(): void + { + $result = (new Builder()) + ->from('logs') + ->select(['message']) + ->filter([Query::equal('level', ['error'])]) + ->settings(['max_threads' => '8']) + ->build(); + + $this->assertSame( + 'SELECT `message` FROM `logs` WHERE `level` IN (?) SETTINGS max_threads=8', + $result->query + ); + $this->assertEquals(['error'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `age`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableUpdate(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived']) + ->filter([Query::equal('year', [2023])]) + ->update(); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `year` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 2023], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableDelete(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `events` DELETE WHERE `created_at` < ?', + $result->query + ); + $this->assertEquals(['2023-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name', 'products.title']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->filter([Query::greaterThan('orders.total', 50)]) + ->build(); + + $this->assertSame( + 'SELECT `orders`.`id`, `users`.`name`, `products`.`title` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `products` ON `orders`.`product_id` = `products`.`id` WHERE `orders`.`total` > ?', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCte(): void + { + $cteQuery = (new Builder()) + ->from('events') + ->select(['user_id']) + ->filter([Query::equal('event_type', ['purchase'])]); + + $result = (new Builder()) + ->with('buyers', $cteQuery) + ->from('users') + ->select(['name', 'email']) + ->filterWhereIn('id', (new Builder())->from('buyers')->select(['user_id'])) + ->build(); + + $this->assertSame( + 'WITH `buyers` AS (SELECT `user_id` FROM `events` WHERE `event_type` IN (?)) SELECT `name`, `email` FROM `users` WHERE `id` IN (SELECT `user_id` FROM `buyers`)', + $result->query + ); + $this->assertEquals(['purchase'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUnionAll(): void + { + $archive = (new Builder()) + ->from('events_2023') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]); + + $result = (new Builder()) + ->from('events_2024') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->unionAll($archive) + ->build(); + + $this->assertSame( + '(SELECT `id`, `name` FROM `events_2024` WHERE `status` IN (?)) UNION ALL (SELECT `id`, `name` FROM `events_2023` WHERE `status` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['employee_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['department_id'], ['-amount']) + ->build(); + + $this->assertSame( + 'SELECT `employee_id`, `amount`, ROW_NUMBER() OVER (PARTITION BY `department_id` ORDER BY `amount` DESC) AS `rn` FROM `sales`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->select(['customer_id']) + ->groupBy(['customer_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->sortDesc('order_count') + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS `order_count`, `customer_id` FROM `orders` GROUP BY `customer_id` HAVING `order_count` > ? ORDER BY `order_count` DESC', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSubqueryWhereIn(): void + { + $sub = (new Builder()) + ->from('blacklist') + ->select(['user_id']) + ->filter([Query::equal('active', [1])]); + + $result = (new Builder()) + ->from('events') + ->select(['id', 'user_id', 'action']) + ->filterWhereNotIn('user_id', $sub) + ->build(); + + $this->assertSame( + 'SELECT `id`, `user_id`, `action` FROM `events` WHERE `user_id` NOT IN (SELECT `user_id` FROM `blacklist` WHERE `active` IN (?))', + $result->query + ); + $this->assertEquals([1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactExistsSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->selectRaw('1') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($sub) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactFromSubquery(): void + { + $sub = (new Builder()) + ->from('events') + ->select(['user_id']) + ->count('*', 'cnt') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id', 'cnt']) + ->filter([Query::greaterThan('cnt', 10)]) + ->build(); + + $this->assertSame( + 'SELECT `user_id`, `cnt` FROM (SELECT COUNT(*) AS `cnt`, `user_id` FROM `events` GROUP BY `user_id`) AS `sub` WHERE `cnt` > ?', + $result->query + ); + $this->assertEquals([10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectSub($sub, 'order_count') + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, (SELECT COUNT(*) AS `cnt` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `order_count` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::and([ + Query::or([ + Query::equal('category', ['electronics']), + Query::equal('category', ['books']), + ]), + Query::greaterThan('price', 10), + Query::lessThan('price', 1000), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, `price` FROM `products` WHERE ((`category` IN (?) OR `category` IN (?)) AND `price` > ? AND `price` < ?)', + $result->query + ); + $this->assertEquals(['electronics', 'books', 10, 1000], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertSelect(): void + { + $source = (new Builder()) + ->from('events') + ->select(['user_id', 'event_type']) + ->filter([Query::equal('year', [2024])]); + + $result = (new Builder()) + ->into('events_archive') + ->fromSelect(['user_id', 'event_type'], $source) + ->insertSelect(); + + $this->assertSame( + 'INSERT INTO `events_archive` (`user_id`, `event_type`) SELECT `user_id`, `event_type` FROM `events` WHERE `year` IN (?)', + $result->query + ); + $this->assertEquals([2024], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('logs') + ->distinct() + ->select(['source', 'level']) + ->limit(20) + ->offset(40) + ->build(); + + $this->assertSame( + 'SELECT DISTINCT `source`, `level` FROM `logs` LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 40], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseBuilder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->when('`status` = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('`status_label`') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectCase($case) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name`, CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `status_label` FROM `users`', + $result->query + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactHintSettings(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::equal('type', ['click'])]) + ->settings([ + 'max_threads' => '4', + 'max_memory_usage' => '10000000000', + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `type` IN (?) SETTINGS max_threads=4, max_memory_usage=10000000000', + $result->query + ); + $this->assertEquals(['click'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactPrewhereWithJoin(): void + { + $result = (new Builder()) + ->from('events') + ->join('users', 'events.user_id', 'users.id') + ->select(['events.id', 'users.name']) + ->prewhere([Query::equal('events.event_type', ['purchase'])]) + ->filter([Query::greaterThan('users.age', 21)]) + ->sortDesc('events.created_at') + ->limit(50) + ->build(); + + $this->assertSame( + 'SELECT `events`.`id`, `users`.`name` FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `events`.`event_type` IN (?) WHERE `users`.`age` > ? ORDER BY `events`.`created_at` DESC LIMIT ?', + $result->query + ); + $this->assertEquals(['purchase', 21, 50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertSame( + 'EXPLAIN SELECT `id`, `name` FROM `events` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorAfterWithFilters(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->filter([Query::greaterThan('age', 18)]) + ->cursorAfter('abc123') + ->sortDesc('created_at') + ->limit(25) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `age` > ? AND `_cursor` > ? ORDER BY `created_at` DESC LIMIT ?', + $result->query + ); + $this->assertEquals([18, 'abc123', 25], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorBefore(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->cursorBefore('xyz789') + ->sortAsc('id') + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` WHERE `_cursor` < ? ORDER BY `id` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['xyz789', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->filter([Query::greaterThan('total', 100)]); + + $cteB = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['customer_id']) + ->build(); + + $this->assertSame( + 'WITH `a` AS (SELECT `customer_id` FROM `orders` WHERE `total` > ?), `b` AS (SELECT `id`, `name` FROM `customers` WHERE `tier` IN (?)) SELECT `customer_id` FROM `a`', + $result->query + ); + $this->assertEquals([100, 'gold'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['employee_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['department_id'], ['-amount']) + ->selectWindow('SUM(`amount`)', 'running_total', ['department_id'], ['created_at']) + ->build(); + + $this->assertSame( + 'SELECT `employee_id`, `amount`, ROW_NUMBER() OVER (PARTITION BY `department_id` ORDER BY `amount` DESC) AS `rn`, SUM(`amount`) OVER (PARTITION BY `department_id` ORDER BY `created_at` ASC) AS `running_total` FROM `sales`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $archive = (new Builder()) + ->from('events_archive') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->sortAsc('id') + ->limit(50) + ->union($archive) + ->build(); + + $this->assertSame( + '(SELECT `id`, `name` FROM `events` ORDER BY `id` ASC LIMIT ?) UNION (SELECT `id`, `name` FROM `events_archive`)', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::or([ + Query::and([ + Query::equal('brand', ['acme']), + Query::greaterThan('price', 50), + ]), + Query::and([ + Query::equal('brand', ['globex']), + Query::lessThan('price', 20), + ]), + ]), + Query::equal('in_stock', [true]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `products` WHERE (((`brand` IN (?) AND `price` > ?) OR (`brand` IN (?) AND `price` < ?)) AND `in_stock` IN (?))', + $result->query + ); + $this->assertEquals(['acme', 50, 'globex', 20, true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedStartsWith(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::startsWith('name', 'John')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE startsWith(`name`, ?)', + $result->query + ); + $this->assertEquals(['John'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEndsWith(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'email']) + ->filter([Query::endsWith('email', '@example.com')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `email` FROM `users` WHERE endsWith(`email`, ?)', + $result->query + ); + $this->assertEquals(['@example.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsSingle(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::contains('title', ['php'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE position(`title`, ?) > 0', + $result->query + ); + $this->assertEquals(['php'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsMultiple(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::contains('title', ['php', 'laravel'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) > 0 OR position(`title`, ?) > 0)', + $result->query + ); + $this->assertEquals(['php', 'laravel'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsAll(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::containsAll('title', ['php', 'laravel'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) > 0 AND position(`title`, ?) > 0)', + $result->query + ); + $this->assertEquals(['php', 'laravel'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedNotContainsSingle(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::notContains('title', ['spam'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE position(`title`, ?) = 0', + $result->query + ); + $this->assertEquals(['spam'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::notContains('title', ['spam', 'junk'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) = 0 AND position(`title`, ?) = 0)', + $result->query + ); + $this->assertEquals(['spam', 'junk'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedRegex(): void + { + $result = (new Builder()) + ->from('logs') + ->select(['id', 'message']) + ->filter([Query::regex('message', '^ERROR.*timeout$')]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `message` FROM `logs` WHERE match(`message`, ?)', + $result->query + ); + $this->assertEquals(['^ERROR.*timeout$'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedPrewhereMultipleConditions(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->prewhere([ + Query::equal('event_type', ['click']), + Query::greaterThan('timestamp', 1000000), + ]) + ->filter([Query::equal('status', ['active'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['click', 1000000, 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedFinalWithFiltersAndOrder(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->final() + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('created_at') + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` FINAL WHERE `status` IN (?) ORDER BY `created_at` DESC', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSampleWithPrewhereAndWhere(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->sample(0.1) + ->prewhere([Query::equal('event_type', ['purchase'])]) + ->filter([Query::greaterThan('amount', 50)]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ?', + $result->query + ); + $this->assertEquals(['purchase', 50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSettingsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->settings([ + 'max_threads' => '4', + 'max_memory_usage' => '10000000', + ]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `events` SETTINGS max_threads=4, max_memory_usage=10000000', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedAlterTableUpdateWithSetRaw(): void + { + $result = (new Builder()) + ->from('events') + ->setRaw('views', '`views` + 1') + ->filter([Query::equal('id', [42])]) + ->update(); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `views` = `views` + 1 WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals([42], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedAlterTableDeleteWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('events') + ->filter([ + Query::equal('status', ['deleted']), + Query::lessThan('created_at', '2023-01-01'), + ]) + ->delete(); + + $this->assertSame( + 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `created_at` < ?', + $result->query + ); + $this->assertEquals(['deleted', '2023-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', [])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedResetClearsPrewhereAndFinal(): void + { + $builder = (new Builder()) + ->from('events') + ->select(['id', 'name']) + ->prewhere([Query::equal('event_type', ['click'])]) + ->final() + ->filter([Query::equal('status', ['active'])]); + + $builder->reset(); + + $result = $builder + ->from('users') + ->select(['id', 'email']) + ->build(); + + $this->assertSame( + 'SELECT `id`, `email` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } +} diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php new file mode 100644 index 0000000..c051d80 --- /dev/null +++ b/tests/Query/Builder/MySQLTest.php @@ -0,0 +1,11345 @@ +assertInstanceOf(Compiler::class, $builder); + } + + public function testImplementsTransactions(): void + { + $this->assertInstanceOf(Transactions::class, new Builder()); + } + + public function testImplementsLocking(): void + { + $this->assertInstanceOf(Locking::class, new Builder()); + } + + public function testImplementsUpsert(): void + { + $this->assertInstanceOf(Upsert::class, new Builder()); + } + + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } + + public function testStandaloneCompile(): void + { + $builder = new Builder(); + + $filter = Query::greaterThan('age', 18); + $sql = $filter->compile($builder); + $this->assertEquals('`age` > ?', $sql); + $this->assertEquals([18], $builder->getBindings()); + } + + public function testFluentSelectFromFilterSortLimitOffset(): void + { + $result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['active', 18, 25, 0], $result->bindings); + } + + public function testBatchModeProducesSameOutput(): void + { + $result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::orderAsc('name'), + Query::limit(25), + Query::offset(0), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['active', 18, 25, 0], $result->bindings); + } + + public function testEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active', 'pending'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result->query); + $this->assertEquals(['active', 'pending'], $result->bindings); + } + + public function testNotEqualSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', 'guest')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` != ?', $result->query); + $this->assertEquals(['guest'], $result->bindings); + } + + public function testNotEqualMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertEquals(['guest', 'banned'], $result->bindings); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('price', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` < ?', $result->query); + $this->assertEquals([100], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('price', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` <= ?', $result->query); + $this->assertEquals([100], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` > ?', $result->query); + $this->assertEquals([18], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 90)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertEquals([90], $result->bindings); + } + + public function testBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'Jo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['Jo%'], $result->bindings); + } + + public function testNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'Jo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); + $this->assertEquals(['Jo%'], $result->bindings); + } + + public function testEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` LIKE ?', $result->query); + $this->assertEquals(['%.com'], $result->bindings); + } + + public function testNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result->query); + $this->assertEquals(['%.com'], $result->bindings); + } + + public function testContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%php%'], $result->bindings); + } + + public function testContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); + } + + public function testContainsAny(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAny('tags', ['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `tags` IN (?, ?)', $result->query); + $this->assertEquals(['a', 'b'], $result->bindings); + } + + public function testContainsAll(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read', 'write'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result->query); + $this->assertEquals(['%read%', '%write%'], $result->bindings); + } + + public function testNotContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertEquals(['%php%'], $result->bindings); + } + + public function testNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); + } + + public function testSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); + $this->assertEquals(['hello'], $result->bindings); + } + + public function testNotSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); + $this->assertEquals(['hello'], $result->bindings); + } + + public function testRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertEquals(['^[a-z]+$'], $result->bindings); + } + + public function testIsNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `deleted` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testIsNotNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('verified')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['legacy'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testAndLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result->query); + $this->assertEquals([18, 'active'], $result->bindings); + } + + public function testOrLogical(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertEquals(['admin', 'mod'], $result->bindings); + } + + public function testDeeplyNested(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::or([ + Query::equal('role', ['admin']), + Query::equal('role', ['mod']), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', + $result->query + ); + $this->assertEquals([18, 'admin', 'mod'], $result->bindings); + } + + public function testSortAsc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC', $result->query); + } + + public function testSortDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortDesc('score') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `score` DESC', $result->query); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND()', $result->query); + } + + public function testMultipleSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC', $result->query); + } + + public function testLimitOnly(): void + { + $result = (new Builder()) + ->from('t') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testOffsetOnly(): void + { + // OFFSET without LIMIT is invalid in MySQL/ClickHouse, so offset is suppressed + $result = (new Builder()) + ->from('t') + ->offset(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc123') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` > ?', $result->query); + $this->assertEquals(['abc123'], $result->bindings); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('t') + ->cursorBefore('xyz789') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `_cursor` < ?', $result->query); + $this->assertEquals(['xyz789'], $result->bindings); + } + + public function testFullCombinedQuery(): void + { + $result = (new Builder()) + ->select(['id', 'name']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->sortDesc('age') + ->limit(25) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['active', 18, 25, 10], $result->bindings); + } + + public function testMultipleFilterCalls(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->filter([Query::equal('b', [2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertEquals([1, 2], $result->bindings); + } + + public function testResetClearsState(): void + { + $builder = (new Builder()) + ->select(['name']) + ->from('users') + ->filter([Query::equal('x', [1])]) + ->limit(10); + + $builder->build(); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `orders` WHERE `total` > ?', $result->query); + $this->assertEquals([100], $result->bindings); + } + + public function testAttributeResolver(): void + { + $result = (new Builder()) + ->from('users') + ->addHook(new AttributeMap([ + '$id' => '_uid', + '$createdAt' => '_createdAt', + ])) + ->filter([Query::equal('$id', ['abc'])]) + ->sortAsc('$createdAt') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', + $result->query + ); + $this->assertEquals(['abc'], $result->bindings); + } + + public function testMultipleAttributeHooksChain(): void + { + $prefixHook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return 'col_' . $attribute; + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap(['name' => 'full_name'])) + ->addHook($prefixHook) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + $this->assertBindingCount($result); + + // First hook maps name→full_name, second prepends col_ + $this->assertEquals( + 'SELECT * FROM `t` WHERE `col_full_name` IN (?)', + $result->query + ); + } + + public function testDualInterfaceHook(): void + { + $hook = new class () implements Filter, Attribute { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + + public function resolve(string $attribute): string + { + return match ($attribute) { + '$id' => '_uid', + default => $attribute, + }; + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->filter([Query::equal('$id', ['abc'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `_uid` IN (?) AND _tenant = ?', + $result->query + ); + $this->assertEquals(['abc', 't1'], $result->bindings); + } + + public function testConditionProvider(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition( + "_uid IN (SELECT _document FROM {$table}_perms WHERE _type = 'read')", + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testConditionProviderWithBindings(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant_abc']); + } + }; + + $result = (new Builder()) + ->from('docs') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', + $result->query + ); + // filter bindings first, then hook bindings + $this->assertEquals(['active', 'tenant_abc'], $result->bindings); + } + + public function testBindingOrderingWithProviderAndCursor(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }; + + $result = (new Builder()) + ->from('docs') + ->addHook($hook) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cursor_val') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + // binding order: filter, hook, cursor, limit, offset + $this->assertEquals(['active', 't1', 'cursor_val', 10, 5], $result->bindings); + } + + public function testDefaultSelectStar(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testCountStar(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCountWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) AS `total` FROM `t`', $result->query); + } + + public function testSumColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('price', 'total_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`price`) AS `total_price` FROM `orders`', $result->query); + } + + public function testAvgColumn(): void + { + $result = (new Builder()) + ->from('t') + ->avg('score') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); + } + + public function testMinColumn(): void + { + $result = (new Builder()) + ->from('t') + ->min('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); + } + + public function testMaxColumn(): void + { + $result = (new Builder()) + ->from('t') + ->max('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); + } + + public function testAggregationWithSelection(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->select(['status']) + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, `status` FROM `orders` GROUP BY `status`', + $result->query + ); + } + + public function testGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', + $result->query + ); + } + + public function testGroupByMultiple(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status', 'country']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `country`', + $result->query + ); + } + + public function testHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING `total` > ?', + $result->query + ); + $this->assertEquals([5], $result->bindings); + } + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT `status` FROM `t`', $result->query); + } + + public function testDistinctStar(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); + } + + public function testJoin(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', + $result->query + ); + } + + public function testLeftJoin(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id`', + $result->query + ); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` RIGHT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', + $result->query + ); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `sizes` CROSS JOIN `colors`', + $result->query + ); + } + + public function testJoinWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ?', + $result->query + ); + $this->assertEquals([100], $result->bindings); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ? AND score < ?', [10, 100])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE score > ? AND score < ?', $result->query); + $this->assertEquals([10, 100], $result->bindings); + } + + public function testRawFilterNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('1 = 1')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testUnion(): void + { + $admins = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'admin'], $result->bindings); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('current') + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', + $result->query + ); + } + + public function testWhenTrue(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testWhenFalse(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['active'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); + } + + public function testPageDefaultPerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 0], $result->bindings); + } + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT * FROM `users` WHERE `status` IN ('active') LIMIT 10", + $sql + ); + } + + public function testToRawSqlNumericBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE `age` > 18", $sql); + } + + public function testCombinedAggregationJoinGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->sum('total', 'total_amount') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('order_count', 5)]) + ->sortDesc('total_amount') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_amount`, `users`.`name` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` GROUP BY `users`.`name` HAVING `order_count` > ? ORDER BY `total_amount` DESC LIMIT ?', + $result->query + ); + $this->assertEquals([5, 10], $result->bindings); + } + + public function testResetClearsUnions(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder()) + ->from('current') + ->union($other); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `fresh`', $result->query); + } + // EDGE CASES & COMBINATIONS + + + public function testCountWithNamedColumn(): void + { + $result = (new Builder()) + ->from('t') + ->count('id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(`id`) FROM `t`', $result->query); + } + + public function testCountWithEmptyStringAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->count('') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + } + + public function testMultipleAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('price', 'total') + ->avg('score', 'avg_score') + ->min('age', 'youngest') + ->max('age', 'oldest') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `cnt`, SUM(`price`) AS `total`, AVG(`score`) AS `avg_score`, MIN(`age`) AS `youngest`, MAX(`age`) AS `oldest` FROM `t`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testAggregationWithoutGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('total', 'grand_total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`total`) AS `grand_total` FROM `orders`', $result->query); + } + + public function testAggregationWithFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->filter([Query::equal('status', ['completed'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `orders` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['completed'], $result->bindings); + } + + public function testAggregationWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->sum('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*), SUM(`price`) FROM `t`', $result->query); + } + + public function testGroupByEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->groupBy([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testMultipleGroupByCalls(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->groupBy(['country']) + ->build(); + $this->assertBindingCount($result); + + // Both groupBy calls should merge since groupByType merges values + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('`status`', $result->query); + $this->assertStringContainsString('`country`', $result->query); + } + + public function testHavingEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('HAVING', $result->query); + } + + public function testHavingMultipleConditions(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->sum('price', 'sum_price') + ->groupBy(['status']) + ->having([ + Query::greaterThan('total', 5), + Query::lessThan('sum_price', 1000), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total`, SUM(`price`) AS `sum_price` FROM `t` GROUP BY `status` HAVING `total` > ? AND `sum_price` < ?', + $result->query + ); + $this->assertEquals([5, 1000], $result->bindings); + } + + public function testHavingWithLogicalOr(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([ + Query::or([ + Query::greaterThan('total', 10), + Query::lessThan('total', 2), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING (`total` > ? OR `total` < ?)', $result->query); + $this->assertEquals([10, 2], $result->bindings); + } + + public function testHavingWithoutGroupBy(): void + { + // SQL allows HAVING without GROUP BY in some engines + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->having([Query::greaterThan('total', 0)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); + } + + public function testMultipleHavingCalls(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 1)]) + ->having([Query::lessThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING `total` > ? AND `total` < ?', $result->query); + $this->assertEquals([1, 100], $result->bindings); + } + + public function testDistinctWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result->query); + } + + public function testDistinctMultipleCalls(): void + { + // Multiple distinct() calls should still produce single DISTINCT keyword + $result = (new Builder()) + ->from('t') + ->distinct() + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); + } + + public function testDistinctWithJoin(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->select(['users.name']) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT DISTINCT `users`.`name` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', + $result->query + ); + } + + public function testDistinctWithFilterAndSort(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->filter([Query::isNotNull('status')]) + ->sortAsc('status') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT DISTINCT `status` FROM `t` WHERE `status` IS NOT NULL ORDER BY `status` ASC', + $result->query + ); + } + + public function testMultipleJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->rightJoin('departments', 'users.dept_id', 'departments.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` RIGHT JOIN `departments` ON `users`.`dept_id` = `departments`.`id`', + $result->query + ); + } + + public function testJoinWithAggregationAndGroupBy(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'order_count') + ->join('orders', 'users.id', 'orders.user_id') + ->groupBy(['users.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `order_count` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` GROUP BY `users`.`name`', + $result->query + ); + } + + public function testJoinWithSortAndPagination(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 50)]) + ->sortDesc('orders.total') + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([50, 10, 20], $result->bindings); + } + + public function testJoinWithCustomOperator(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.val', 'b.val', '!=') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `a` JOIN `b` ON `a`.`val` != `b`.`val`', + $result->query + ); + } + + public function testCrossJoinWithOtherJoins(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->leftJoin('inventory', 'sizes.id', 'inventory.size_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes`.`id` = `inventory`.`size_id`', + $result->query + ); + } + + public function testRawWithMixedBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result->query); + $this->assertEquals(['str', 42, 3.14], $result->bindings); + } + + public function testRawCombinedWithRegularFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['active']), + Query::raw('custom_func(col) > ?', [10]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) AND custom_func(col) > ?', + $result->query + ); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testRawWithEmptySql(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + $this->assertBindingCount($result); + + // Empty raw SQL still appears as a WHERE clause + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testMultipleUnions(): void + { + $q1 = (new Builder())->from('admins'); + $q2 = (new Builder())->from('mods'); + + $result = (new Builder()) + ->from('users') + ->union($q1) + ->union($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION (SELECT * FROM `mods`)', + $result->query + ); + } + + public function testMixedUnionAndUnionAll(): void + { + $q1 = (new Builder())->from('admins'); + $q2 = (new Builder())->from('mods'); + + $result = (new Builder()) + ->from('users') + ->union($q1) + ->unionAll($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) UNION (SELECT * FROM `admins`) UNION ALL (SELECT * FROM `mods`)', + $result->query + ); + } + + public function testUnionWithFiltersAndBindings(): void + { + $q1 = (new Builder())->from('admins')->filter([Query::equal('level', [1])]); + $q2 = (new Builder())->from('mods')->filter([Query::greaterThan('score', 50)]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($q1) + ->unionAll($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `level` IN (?)) UNION ALL (SELECT * FROM `mods` WHERE `score` > ?)', + $result->query + ); + $this->assertEquals(['active', 1, 50], $result->bindings); + } + + public function testUnionWithAggregation(): void + { + $q1 = (new Builder())->from('orders_2023')->count('*', 'total'); + + $result = (new Builder()) + ->from('orders_2024') + ->count('*', 'total') + ->unionAll($q1) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT COUNT(*) AS `total` FROM `orders_2024`) UNION ALL (SELECT COUNT(*) AS `total` FROM `orders_2023`)', + $result->query + ); + } + + public function testWhenNested(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->when(true, fn (Builder $b2) => $b2->filter([Query::equal('a', [1])])); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); + } + + public function testWhenMultipleCalls(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result->query); + $this->assertEquals([1, 3], $result->bindings); + } + + public function testPageZero(): void + { + $this->expectException(ValidationException::class); + (new Builder()) + ->from('t') + ->page(0, 10) + ->build(); + } + + public function testPageOnePerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(5, 1) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([1, 4], $result->bindings); + } + + public function testPageLargeValues(): void + { + $result = (new Builder()) + ->from('t') + ->page(1000, 100) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([100, 99900], $result->bindings); + } + + public function testToRawSqlWithBooleanBindings(): void + { + // Booleans must be handled in toRawSql + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [true])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE active = 1", $sql); + } + + public function testToRawSqlWithNullBinding(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('deleted_at = ?', [null])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE deleted_at = NULL", $sql); + } + + public function testToRawSqlWithFloatBinding(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::raw('price > ?', [9.99])]); + + $sql = $builder->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE price > 9.99", $sql); + } + + public function testToRawSqlComplexQuery(): void + { + $sql = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->offset(10) + ->toRawSql(); + + $this->assertEquals( + "SELECT `name` FROM `users` WHERE `status` IN ('active') AND `age` > 18 ORDER BY `name` ASC LIMIT 25 OFFSET 10", + $sql + ); + } + + public function testCompileFilterUnsupportedType(): void + { + $this->expectException(\ValueError::class); + new Query('totallyInvalid', 'x', [1]); + } + + public function testCompileOrderUnsupportedType(): void + { + $builder = new Builder(); + $query = new Query('equal', 'x', [1]); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Unsupported order type: equal'); + $builder->compileOrder($query); + } + + public function testCompileJoinUnsupportedType(): void + { + $builder = new Builder(); + $query = new Query('equal', 't', ['a', '=', 'b']); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Unsupported join type: equal'); + $builder->compileJoin($query); + } + + public function testBindingOrderFilterProviderCursorLimitOffset(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['tenant1']); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->filter([ + Query::equal('a', ['x']), + Query::greaterThan('b', 5), + ]) + ->cursorAfter('cursor_abc') + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + // Order: filter bindings, hook bindings, cursor, limit, offset + $this->assertEquals(['x', 5, 'tenant1', 'cursor_abc', 10, 20], $result->bindings); + } + + public function testBindingOrderMultipleProviders(): void + { + $hook1 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['v1']); + } + }; + $hook2 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['v2']); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook1) + ->addHook($hook2) + ->filter([Query::equal('a', ['x'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['x', 'v1', 'v2'], $result->bindings); + } + + public function testBindingOrderHavingAfterFilters(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + // Filter bindings, then having bindings, then limit + $this->assertEquals(['active', 5, 10], $result->bindings); + } + + public function testBindingOrderUnionAppendedLast(): void + { + $sub = (new Builder())->from('other')->filter([Query::equal('x', ['y'])]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('a', ['b'])]) + ->limit(5) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + // Main filter, main limit, then union bindings + $this->assertEquals(['b', 5, 'y'], $result->bindings); + } + + public function testBindingOrderComplexMixed(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_org = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addHook($hook) + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->cursorAfter('cur1') + ->limit(10) + ->offset(5) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + // filter, hook, cursor, having, limit, offset, union + $this->assertEquals(['paid', 'org1', 'cur1', 1, 10, 5, 2023], $result->bindings); + } + + public function testAttributeResolverWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap(['$price' => '_price'])) + ->sum('$price', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`_price`) AS `total` FROM `t`', $result->query); + } + + public function testAttributeResolverWithGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap(['$status' => '_status'])) + ->count('*', 'total') + ->groupBy(['$status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS `total` FROM `t` GROUP BY `_status`', + $result->query + ); + } + + public function testAttributeResolverWithJoin(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$id' => '_uid', + '$ref' => '_ref', + ])) + ->join('other', '$id', '$ref') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref`', + $result->query + ); + } + + public function testAttributeResolverWithHaving(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap(['$total' => '_total'])) + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('$total', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING `_total` > ?', $result->query); + } + + public function testConditionProviderWithJoins(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('users.org_id = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->addHook($hook) + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? AND users.org_id = ?', + $result->query + ); + $this->assertEquals([100, 'org1'], $result->bindings); + } + + public function testConditionProviderWithAggregation(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org_id = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addHook($hook) + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE org_id = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testMultipleBuildsConsistentOutput(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); + } + + + public function testEmptyBuilderNoFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->build(); + } + + public function testCursorWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['abc', 10, 5], $result->bindings); + } + + public function testCursorWithPage(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->page(2, 10) + ->build(); + $this->assertBindingCount($result); + + // Cursor + limit from page + offset from page; first limit/offset wins + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testKitchenSinkQuery(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'cnt') + ->sum('total', 'sum_total') + ->select(['status']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('coupons', 'orders.coupon_id', 'coupons.id') + ->filter([ + Query::equal('orders.status', ['paid']), + Query::greaterThan('orders.total', 0), + ]) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['o1']); + } + }) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->sortDesc('sum_total') + ->limit(25) + ->offset(50) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + // Verify structural elements + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`total`) AS `sum_total`', $result->query); + $this->assertStringContainsString('`status`', $result->query); + $this->assertStringContainsString('FROM `orders`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `coupons`', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('GROUP BY `status`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `sum_total` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertStringContainsString('UNION', $result->query); + + // Verify SQL clause ordering + $query = $result->query; + $this->assertLessThan(strpos($query, 'FROM'), strpos($query, 'SELECT')); + $this->assertLessThan(strpos($query, 'JOIN'), (int) strpos($query, 'FROM')); + $this->assertLessThan(strpos($query, 'WHERE'), (int) strpos($query, 'JOIN')); + $this->assertLessThan(strpos($query, 'GROUP BY'), (int) strpos($query, 'WHERE')); + $this->assertLessThan(strpos($query, 'HAVING'), (int) strpos($query, 'GROUP BY')); + $this->assertLessThan(strpos($query, 'ORDER BY'), (int) strpos($query, 'HAVING')); + $this->assertLessThan(strpos($query, 'LIMIT'), (int) strpos($query, 'ORDER BY')); + $this->assertLessThan(strpos($query, 'OFFSET'), (int) strpos($query, 'LIMIT')); + $this->assertLessThan(strpos($query, 'UNION'), (int) strpos($query, 'OFFSET')); + } + + public function testFilterEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testSelectEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + $this->assertBindingCount($result); + + // Empty select produces empty column list + $this->assertEquals('SELECT FROM `t`', $result->query); + } + + public function testLimitZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + + public function testOffsetZero(): void + { + $result = (new Builder()) + ->from('t') + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + // OFFSET without LIMIT is suppressed + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + + $this->assertSame($builder, $builder->from('t')); + $this->assertSame($builder, $builder->select(['a'])); + $this->assertSame($builder, $builder->filter([])); + $this->assertSame($builder, $builder->sortAsc('a')); + $this->assertSame($builder, $builder->sortDesc('a')); + $this->assertSame($builder, $builder->sortRandom()); + $this->assertSame($builder, $builder->limit(1)); + $this->assertSame($builder, $builder->offset(0)); + $this->assertSame($builder, $builder->cursorAfter('x')); + $this->assertSame($builder, $builder->cursorBefore('x')); + $this->assertSame($builder, $builder->queries([])); + $this->assertSame($builder, $builder->count()); + $this->assertSame($builder, $builder->sum('a')); + $this->assertSame($builder, $builder->avg('a')); + $this->assertSame($builder, $builder->min('a')); + $this->assertSame($builder, $builder->max('a')); + $this->assertSame($builder, $builder->groupBy(['a'])); + $this->assertSame($builder, $builder->having([])); + $this->assertSame($builder, $builder->distinct()); + $this->assertSame($builder, $builder->join('t', 'a', 'b')); + $this->assertSame($builder, $builder->leftJoin('t', 'a', 'b')); + $this->assertSame($builder, $builder->rightJoin('t', 'a', 'b')); + $this->assertSame($builder, $builder->crossJoin('t')); + $this->assertSame($builder, $builder->when(false, fn ($b) => $b)); + $this->assertSame($builder, $builder->page(1)); + $this->assertSame($builder, $builder->reset()); + } + + public function testUnionFluentChainingReturnsSameInstance(): void + { + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->union($other)); + + $builder->reset(); + $other2 = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->unionAll($other2)); + } + // 1. SQL-Specific: REGEXP + + public function testRegexWithEmptyPattern(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertEquals([''], $result->bindings); + } + + public function testRegexWithDotChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a.b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` REGEXP ?', $result->query); + $this->assertEquals(['a.b'], $result->bindings); + } + + public function testRegexWithStarChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a*b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['a*b'], $result->bindings); + } + + public function testRegexWithPlusChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a+')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['a+'], $result->bindings); + } + + public function testRegexWithQuestionMarkChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'colou?r')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['colou?r'], $result->bindings); + } + + public function testRegexWithCaretAndDollar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('code', '^[A-Z]+$')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['^[A-Z]+$'], $result->bindings); + } + + public function testRegexWithPipeChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('color', 'red|blue|green')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['red|blue|green'], $result->bindings); + } + + public function testRegexWithBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('path', '\\\\server\\\\share')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['\\\\server\\\\share'], $result->bindings); + } + + public function testRegexWithBracketsAndBraces(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('zip', '[0-9]{5}')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('[0-9]{5}', $result->bindings[0]); + } + + public function testRegexWithParentheses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('phone', '(\\+1)?[0-9]{10}')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['(\\+1)?[0-9]{10}'], $result->bindings); + } + + public function testRegexCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['active']), + Query::regex('slug', '^[a-z-]+$'), + Query::greaterThan('age', 18), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) AND `slug` REGEXP ? AND `age` > ?', + $result->query + ); + $this->assertEquals(['active', '^[a-z-]+$', 18], $result->bindings); + } + + public function testRegexWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$slug' => '_slug', + ])) + ->filter([Query::regex('$slug', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result->query); + $this->assertEquals(['^test'], $result->bindings); + } + + public function testRegexStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::regex('col', '^abc'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('`col` REGEXP ?', $sql); + $this->assertEquals(['^abc'], $builder->getBindings()); + } + + public function testRegexBindingPreservedExactly(): void + { + $pattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$'; + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('email', $pattern)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame($pattern, $result->bindings[0]); + } + + public function testRegexWithVeryLongPattern(): void + { + $pattern = str_repeat('[a-z]', 500); + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('col', $pattern)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals($pattern, $result->bindings[0]); + $this->assertStringContainsString('REGEXP ?', $result->query); + } + + public function testMultipleRegexFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::regex('name', '^A'), + Query::regex('email', '@test\\.com$'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `name` REGEXP ? AND `email` REGEXP ?', + $result->query + ); + $this->assertEquals(['^A', '@test\\.com$'], $result->bindings); + } + + public function testRegexInAndLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::regex('slug', '^[a-z]+$'), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`slug` REGEXP ? AND `status` IN (?))', + $result->query + ); + $this->assertEquals(['^[a-z]+$', 'active'], $result->bindings); + } + + public function testRegexInOrLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::regex('name', '^Admin'), + Query::regex('name', '^Mod'), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`name` REGEXP ? OR `name` REGEXP ?)', + $result->query + ); + $this->assertEquals(['^Admin', '^Mod'], $result->bindings); + } + // 2. SQL-Specific: MATCH AGAINST / Search + + public function testSearchWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?)', $result->query); + $this->assertEquals([''], $result->bindings); + } + + public function testSearchWithSpecialCharacters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello "world" +required -excluded')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['hello "world" +required -excluded'], $result->bindings); + } + + public function testSearchCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello'), + Query::equal('status', ['published']), + Query::greaterThan('views', 100), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `status` IN (?) AND `views` > ?', + $result->query + ); + $this->assertEquals(['hello', 'published', 100], $result->bindings); + } + + public function testNotSearchCombinedWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::notSearch('content', 'spam'), + Query::equal('status', ['published']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?)) AND `status` IN (?)', + $result->query + ); + $this->assertEquals(['spam', 'published'], $result->bindings); + } + + public function testSearchWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$body' => '_body', + ])) + ->filter([Query::search('$body', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(?)', $result->query); + } + + public function testSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::search('body', 'test'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); + $this->assertEquals(['test'], $builder->getBindings()); + } + + public function testNotSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::notSearch('body', 'spam'); + $sql = $builder->compileFilter($query); + + $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); + $this->assertEquals(['spam'], $builder->getBindings()); + } + + public function testSearchBindingPreservedExactly(): void + { + $searchTerm = 'hello world "exact phrase" +required -excluded'; + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', $searchTerm)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame($searchTerm, $result->bindings[0]); + } + + public function testSearchWithVeryLongText(): void + { + $longText = str_repeat('keyword ', 1000); + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', $longText)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals($longText, $result->bindings[0]); + } + + public function testMultipleSearchFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('title', 'hello'), + Query::search('body', 'world'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`title`) AGAINST(?) AND MATCH(`body`) AGAINST(?)', + $result->query + ); + $this->assertEquals(['hello', 'world'], $result->bindings); + } + + public function testSearchInAndLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::search('content', 'hello'), + Query::equal('status', ['active']), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (MATCH(`content`) AGAINST(?) AND `status` IN (?))', + $result->query + ); + } + + public function testSearchInOrLogicalGroup(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::search('title', 'hello'), + Query::search('body', 'hello'), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (MATCH(`title`) AGAINST(?) OR MATCH(`body`) AGAINST(?))', + $result->query + ); + $this->assertEquals(['hello', 'hello'], $result->bindings); + } + + public function testSearchAndRegexCombined(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello world'), + Query::regex('slug', '^[a-z-]+$'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(?) AND `slug` REGEXP ?', + $result->query + ); + $this->assertEquals(['hello world', '^[a-z-]+$'], $result->bindings); + } + + public function testNotSearchStandalone(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'spam')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(?))', $result->query); + $this->assertEquals(['spam'], $result->bindings); + } + // 3. SQL-Specific: RAND() + + public function testRandomSortStandaloneCompile(): void + { + $builder = new Builder(); + $query = Query::orderRandom(); + $sql = $builder->compileOrder($query); + + $this->assertEquals('RAND()', $sql); + } + + public function testRandomSortCombinedWithAscDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortRandom() + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` ORDER BY `name` ASC, RAND(), `age` DESC', + $result->query + ); + } + + public function testRandomSortWithFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY RAND()', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testRandomSortWithLimit(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertEquals([5], $result->bindings); + } + + public function testRandomSortWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['category']) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY RAND()', $result->query); + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + } + + public function testRandomSortWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('ORDER BY RAND()', $result->query); + } + + public function testRandomSortWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT DISTINCT `status` FROM `t` ORDER BY RAND()', + $result->query + ); + } + + public function testRandomSortInBatchMode(): void + { + $result = (new Builder()) + ->from('t') + ->queries([ + Query::orderRandom(), + Query::limit(10), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testRandomSortWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY RAND()', $result->query); + } + + public function testMultipleRandomSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND(), RAND()', $result->query); + } + + public function testRandomSortWithOffset(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 5], $result->bindings); + } + // 5. Standalone Compiler method calls + + public function testCompileFilterEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::equal('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterNotEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEqual('col', 'a')); + $this->assertEquals('`col` != ?', $sql); + $this->assertEquals(['a'], $builder->getBindings()); + } + + public function testCompileFilterLessThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThan('col', 10)); + $this->assertEquals('`col` < ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterLessThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThanEqual('col', 10)); + $this->assertEquals('`col` <= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('col', 10)); + $this->assertEquals('`col` > ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThanEqual('col', 10)); + $this->assertEquals('`col` >= ?', $sql); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testCompileFilterBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::between('col', 1, 100)); + $this->assertEquals('`col` BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); + } + + public function testCompileFilterNotBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notBetween('col', 1, 100)); + $this->assertEquals('`col` NOT BETWEEN ? AND ?', $sql); + $this->assertEquals([1, 100], $builder->getBindings()); + } + + public function testCompileFilterStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::startsWith('col', 'abc')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterNotStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notStartsWith('col', 'abc')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::endsWith('col', 'xyz')); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterNotEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEndsWith('col', 'xyz')); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['val'])); + $this->assertEquals('`col` LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::contains('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? OR `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterContainsAny(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAny('col', ['a', 'b'])); + $this->assertEquals('`col` IN (?, ?)', $sql); + $this->assertEquals(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterContainsAll(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAll('col', ['a', 'b'])); + $this->assertEquals('(`col` LIKE ? AND `col` LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['val'])); + $this->assertEquals('`col` NOT LIKE ?', $sql); + $this->assertEquals(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['a', 'b'])); + $this->assertEquals('(`col` NOT LIKE ? AND `col` NOT LIKE ?)', $sql); + $this->assertEquals(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterIsNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNull('col')); + $this->assertEquals('`col` IS NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterIsNotNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNotNull('col')); + $this->assertEquals('`col` IS NOT NULL', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function testCompileFilterAnd(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ])); + $this->assertEquals('(`a` IN (?) AND `b` > ?)', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterOr(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ])); + $this->assertEquals('(`a` IN (?) OR `b` IN (?))', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::exists(['a', 'b'])); + $this->assertEquals('(`a` IS NOT NULL AND `b` IS NOT NULL)', $sql); + } + + public function testCompileFilterNotExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notExists(['a', 'b'])); + $this->assertEquals('(`a` IS NULL AND `b` IS NULL)', $sql); + } + + public function testCompileFilterRaw(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::raw('x > ? AND y < ?', [1, 2])); + $this->assertEquals('x > ? AND y < ?', $sql); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function testCompileFilterSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::search('body', 'hello')); + $this->assertEquals('MATCH(`body`) AGAINST(?)', $sql); + $this->assertEquals(['hello'], $builder->getBindings()); + } + + public function testCompileFilterNotSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notSearch('body', 'spam')); + $this->assertEquals('NOT (MATCH(`body`) AGAINST(?))', $sql); + $this->assertEquals(['spam'], $builder->getBindings()); + } + + public function testCompileFilterRegex(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::regex('col', '^abc')); + $this->assertEquals('`col` REGEXP ?', $sql); + $this->assertEquals(['^abc'], $builder->getBindings()); + } + + public function testCompileOrderAsc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertEquals('`name` ASC', $sql); + } + + public function testCompileOrderDesc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertEquals('`name` DESC', $sql); + } + + public function testCompileOrderRandom(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertEquals('RAND()', $sql); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(25)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertEquals([25], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(50)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertEquals([50], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b', 'c'])); + $this->assertEquals('`a`, `b`, `c`', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertEquals('`_cursor` > ?', $sql); + $this->assertEquals(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertEquals('`_cursor` < ?', $sql); + $this->assertEquals(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertEquals('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateCountWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count()); + $this->assertEquals('COUNT(*)', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price', 'total')); + $this->assertEquals('SUM(`price`) AS `total`', $sql); + } + + public function testCompileAggregateAvgStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertEquals('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileAggregateMinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price', 'lowest')); + $this->assertEquals('MIN(`price`) AS `lowest`', $sql); + } + + public function testCompileAggregateMaxStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price', 'highest')); + $this->assertEquals('MAX(`price`) AS `highest`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertEquals('`status`, `country`', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); + $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testCompileLeftJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::leftJoin('profiles', 'users.id', 'profiles.uid')); + $this->assertEquals('LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`uid`', $sql); + } + + public function testCompileRightJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::rightJoin('orders', 'users.id', 'orders.uid')); + $this->assertEquals('RIGHT JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testCompileCrossJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::crossJoin('colors')); + $this->assertEquals('CROSS JOIN `colors`', $sql); + } + // 6. Filter edge cases + + public function testEqualWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testEqualWithManyValues(): void + { + $values = range(1, 10); + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', $values)]) + ->build(); + $this->assertBindingCount($result); + + $placeholders = implode(', ', array_fill(0, 10, '?')); + $this->assertEquals("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); + $this->assertEquals($values, $result->bindings); + } + + public function testEqualWithEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 0', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotEqualWithExactlyTwoValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertEquals(['guest', 'banned'], $result->bindings); + } + + public function testBetweenWithSameMinAndMax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 25, 25)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([25, 25], $result->bindings); + } + + public function testStartsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); + } + + public function testEndsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['%'], $result->bindings); + } + + public function testContainsWithSingleEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', [''])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); + } + + public function testContainsWithManyValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['a', 'b', 'c', 'd', 'e'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%a%', '%b%', '%c%', '%d%', '%e%'], $result->bindings); + } + + public function testContainsAllWithSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); + $this->assertEquals(['%read%'], $result->bindings); + } + + public function testNotContainsWithEmptyStringValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', [''])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertEquals(['%%'], $result->bindings); + } + + public function testComparisonWithFloatValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('price', 9.99)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `price` > ?', $result->query); + $this->assertEquals([9.99], $result->bindings); + } + + public function testComparisonWithNegativeValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `balance` < ?', $result->query); + $this->assertEquals([-100], $result->bindings); + } + + public function testComparisonWithZero(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 0)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + + public function testComparisonWithVeryLargeInteger(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('id', 9999999999999)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([9999999999999], $result->bindings); + } + + public function testComparisonWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('name', 'M')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `name` > ?', $result->query); + $this->assertEquals(['M'], $result->bindings); + } + + public function testBetweenWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('created_at', '2024-01-01', '2024-12-31')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); + } + + public function testIsNullCombinedWithIsNotNullOnDifferentColumns(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('verified_at'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testMultipleIsNullFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('a'), + Query::isNull('b'), + Query::isNull('c'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IS NULL AND `b` IS NULL AND `c` IS NULL', + $result->query + ); + } + + public function testExistsWithSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); + } + + public function testExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['a', 'b', 'c', 'd'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NOT NULL AND `b` IS NOT NULL AND `c` IS NOT NULL AND `d` IS NOT NULL)', + $result->query + ); + } + + public function testNotExistsWithManyAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['a', 'b', 'c'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL AND `c` IS NULL)', + $result->query + ); + } + + public function testAndWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testOrWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testAndWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', + $result->query + ); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + } + + public function testOrWithManySubQueries(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + Query::equal('d', [4]), + Query::equal('e', [5]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (`a` IN (?) OR `b` IN (?) OR `c` IN (?) OR `d` IN (?) OR `e` IN (?))', + $result->query + ); + } + + public function testDeeplyNestedAndOrAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + Query::equal('c', [3]), + ]), + Query::equal('d', [4]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', + $result->query + ); + $this->assertEquals([1, 2, 3, 4], $result->bindings); + } + + public function testRawWithManyBindings(): void + { + $bindings = range(1, 10); + $placeholders = implode(' AND ', array_map(fn ($i) => "col{$i} = ?", range(1, 10))); + $result = (new Builder()) + ->from('t') + ->filter([Query::raw($placeholders, $bindings)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals("SELECT * FROM `t` WHERE {$placeholders}", $result->query); + $this->assertEquals($bindings, $result->bindings); + } + + public function testFilterWithDotsInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('table.column', ['value'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `table`.`column` IN (?)', $result->query); + } + + public function testFilterWithUnderscoresInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('my_column_name', ['value'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `my_column_name` IN (?)', $result->query); + } + + public function testFilterWithNumericAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('123', ['value'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `123` IN (?)', $result->query); + } + // 7. Aggregation edge cases + + public function testCountWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->count()->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSumWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->sum('price')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT SUM(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAvgWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->avg('score')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT AVG(`score`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMinWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->min('price')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT MIN(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMaxWithoutAliasNoAsClause(): void + { + $result = (new Builder())->from('t')->max('price')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT MAX(`price`) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testCountWithAlias2(): void + { + $result = (new Builder())->from('t')->count('*', 'cnt')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('AS `cnt`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder())->from('t')->sum('price', 'total')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('AS `total`', $result->query); + } + + public function testAvgWithAlias(): void + { + $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('AS `avg_s`', $result->query); + } + + public function testMinWithAlias(): void + { + $result = (new Builder())->from('t')->min('price', 'lowest')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('AS `lowest`', $result->query); + } + + public function testMaxWithAlias(): void + { + $result = (new Builder())->from('t')->max('price', 'highest')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('AS `highest`', $result->query); + } + + public function testMultipleSameAggregationType(): void + { + $result = (new Builder()) + ->from('t') + ->count('id', 'count_id') + ->count('*', 'count_all') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(`id`) AS `count_id`, COUNT(*) AS `count_all` FROM `t`', + $result->query + ); + } + + public function testAggregationStarAndNamedColumnMixed(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->sum('price', 'price_sum') + ->select(['category']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('SUM(`price`) AS `price_sum`', $result->query); + $this->assertStringContainsString('`category`', $result->query); + } + + public function testAggregationFilterSortLimitCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['category']) + ->sortDesc('cnt') + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('GROUP BY `category`', $result->query); + $this->assertStringContainsString('ORDER BY `cnt` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['paid', 5], $result->bindings); + } + + public function testAggregationJoinGroupByHavingSortLimitFullPipeline(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('total', 'revenue') + ->select(['users.name']) + ->join('users', 'orders.user_id', 'users.id') + ->filter([Query::greaterThan('orders.total', 0)]) + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 2)]) + ->sortDesc('revenue') + ->limit(20) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('SUM(`total`) AS `revenue`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `revenue` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([0, 2, 20, 10], $result->bindings); + } + + public function testAggregationWithAttributeResolver(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$amount' => '_amount', + ])) + ->sum('$amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`_amount`) AS `total` FROM `t`', $result->query); + } + + public function testMinMaxWithStringColumns(): void + { + $result = (new Builder()) + ->from('t') + ->min('name', 'first_name') + ->max('name', 'last_name') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT MIN(`name`) AS `first_name`, MAX(`name`) AS `last_name` FROM `t`', + $result->query + ); + } + // 8. Join edge cases + + public function testSelfJoin(): void + { + $result = (new Builder()) + ->from('employees') + ->join('employees', 'employees.manager_id', 'employees.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `employees` JOIN `employees` ON `employees`.`manager_id` = `employees`.`id`', + $result->query + ); + } + + public function testJoinWithVeryLongTableAndColumnNames(): void + { + $longTable = str_repeat('a', 100); + $longLeft = str_repeat('b', 100); + $longRight = str_repeat('c', 100); + $result = (new Builder()) + ->from('main') + ->join($longTable, $longLeft, $longRight) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("JOIN `{$longTable}`", $result->query); + $this->assertStringContainsString("ON `{$longLeft}` = `{$longRight}`", $result->query); + } + + public function testJoinFilterSortLimitOffsetCombined(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->filter([ + Query::equal('orders.status', ['paid']), + Query::greaterThan('orders.total', 100), + ]) + ->sortDesc('orders.total') + ->limit(25) + ->offset(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE `orders`.`status` IN (?) AND `orders`.`total` > ?', $result->query); + $this->assertStringContainsString('ORDER BY `orders`.`total` DESC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(['paid', 100, 25, 50], $result->bindings); + } + + public function testJoinAggregationGroupByHavingCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->join('users', 'orders.user_id', 'users.id') + ->groupBy(['users.name']) + ->having([Query::greaterThan('cnt', 3)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `cnt`', $result->query); + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('GROUP BY `users`.`name`', $result->query); + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertEquals([3], $result->bindings); + } + + public function testJoinWithDistinct(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->select(['users.name']) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT `users`.`name`', $result->query); + $this->assertStringContainsString('JOIN `orders`', $result->query); + } + + public function testJoinWithUnion(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('JOIN `archived_orders`', $result->query); + } + + public function testFourJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->rightJoin('categories', 'products.cat_id', 'categories.id') + ->crossJoin('promotions') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `users`', $result->query); + $this->assertStringContainsString('LEFT JOIN `products`', $result->query); + $this->assertStringContainsString('RIGHT JOIN `categories`', $result->query); + $this->assertStringContainsString('CROSS JOIN `promotions`', $result->query); + } + + public function testJoinWithAttributeResolverOnJoinColumns(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new AttributeMap([ + '$id' => '_uid', + '$ref' => '_ref_id', + ])) + ->join('other', '$id', '$ref') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` JOIN `other` ON `_uid` = `_ref_id`', + $result->query + ); + } + + public function testCrossJoinCombinedWithFilter(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->filter([Query::equal('sizes.active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `colors`', $result->query); + $this->assertStringContainsString('WHERE `sizes`.`active` IN (?)', $result->query); + } + + public function testCrossJoinFollowedByRegularJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->join('c', 'a.id', 'c.a_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `a` CROSS JOIN `b` JOIN `c` ON `a`.`id` = `c`.`a_id`', + $result->query + ); + } + + public function testMultipleJoinsWithFiltersOnEach(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->filter([ + Query::greaterThan('orders.total', 50), + Query::isNotNull('profiles.avatar'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('LEFT JOIN `profiles`', $result->query); + $this->assertStringContainsString('`orders`.`total` > ?', $result->query); + $this->assertStringContainsString('`profiles`.`avatar` IS NOT NULL', $result->query); + } + + public function testJoinWithCustomOperatorLessThan(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.start', 'b.end', '<') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `a` JOIN `b` ON `a`.`start` < `b`.`end`', + $result->query + ); + } + + public function testFiveJoins(): void + { + $result = (new Builder()) + ->from('t1') + ->join('t2', 't1.id', 't2.t1_id') + ->join('t3', 't2.id', 't3.t2_id') + ->join('t4', 't3.id', 't4.t3_id') + ->join('t5', 't4.id', 't5.t4_id') + ->join('t6', 't5.id', 't6.t5_id') + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $this->assertEquals(5, substr_count($query, 'JOIN')); + } + // 9. Union edge cases + + public function testUnionWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', + $result->query + ); + } + + public function testUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION ALL (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION ALL (SELECT * FROM `c`)', + $result->query + ); + } + + public function testMixedUnionAndUnionAllWithThreeSubQueries(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->unionAll($q2) + ->union($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `main`) UNION (SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`) UNION (SELECT * FROM `c`)', + $result->query + ); + } + + public function testUnionWhereSubQueryHasJoins(): void + { + $sub = (new Builder()) + ->from('archived_users') + ->join('archived_orders', 'archived_users.id', 'archived_orders.user_id'); + + $result = (new Builder()) + ->from('users') + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'UNION (SELECT * FROM `archived_users` JOIN `archived_orders`', + $result->query + ); + } + + public function testUnionWhereSubQueryHasAggregation(): void + { + $sub = (new Builder()) + ->from('orders_2023') + ->count('*', 'cnt') + ->groupBy(['status']); + + $result = (new Builder()) + ->from('orders_2024') + ->count('*', 'cnt') + ->groupBy(['status']) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION (SELECT COUNT(*) AS `cnt` FROM `orders_2023` GROUP BY `status`)', $result->query); + } + + public function testUnionWhereSubQueryHasSortAndLimit(): void + { + $sub = (new Builder()) + ->from('archive') + ->sortDesc('created_at') + ->limit(10); + + $result = (new Builder()) + ->from('current') + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION (SELECT * FROM `archive` ORDER BY `created_at` DESC LIMIT ?)', $result->query); + } + + public function testUnionWithConditionProviders(): void + { + $sub = (new Builder()) + ->from('other') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org2']); + } + }); + + $result = (new Builder()) + ->from('main') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); + $this->assertEquals(['org1', 'org2'], $result->bindings); + } + + public function testUnionBindingOrderWithComplexSubQueries(): void + { + $sub = (new Builder()) + ->from('archive') + ->filter([Query::equal('year', [2023])]) + ->limit(5); + + $result = (new Builder()) + ->from('current') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 10, 2023, 5], $result->bindings); + } + + public function testUnionWithDistinct(): void + { + $sub = (new Builder()) + ->from('archive') + ->distinct() + ->select(['name']); + + $result = (new Builder()) + ->from('current') + ->distinct() + ->select(['name']) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT `name` FROM `current`', $result->query); + $this->assertStringContainsString('UNION (SELECT DISTINCT `name` FROM `archive`)', $result->query); + } + + public function testUnionAfterReset(): void + { + $builder = (new Builder())->from('old'); + $builder->build(); + $builder->reset(); + + $sub = (new Builder())->from('other'); + $result = $builder->from('fresh')->union($sub)->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `fresh`) UNION (SELECT * FROM `other`)', + $result->query + ); + } + + public function testUnionChainedWithComplexBindings(): void + { + $q1 = (new Builder()) + ->from('a') + ->filter([Query::equal('x', [1]), Query::greaterThan('y', 2)]); + $q2 = (new Builder()) + ->from('b') + ->filter([Query::between('z', 10, 20)]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('status', ['active'])]) + ->union($q1) + ->unionAll($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 1, 2, 10, 20], $result->bindings); + } + + public function testUnionWithFourSubQueries(): void + { + $q1 = (new Builder())->from('t1'); + $q2 = (new Builder())->from('t2'); + $q3 = (new Builder())->from('t3'); + $q4 = (new Builder())->from('t4'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->union($q2) + ->union($q3) + ->union($q4) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(4, substr_count($result->query, 'UNION')); + } + + public function testUnionAllWithFilteredSubQueries(): void + { + $q1 = (new Builder())->from('orders_2022')->filter([Query::equal('status', ['paid'])]); + $q2 = (new Builder())->from('orders_2023')->filter([Query::equal('status', ['paid'])]); + $q3 = (new Builder())->from('orders_2024')->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->from('orders_2025') + ->filter([Query::equal('status', ['paid'])]) + ->unionAll($q1) + ->unionAll($q2) + ->unionAll($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['paid', 'paid', 'paid', 'paid'], $result->bindings); + $this->assertEquals(3, substr_count($result->query, 'UNION ALL')); + } + // 10. toRawSql edge cases + + public function testToRawSqlWithAllBindingTypesInOneQuery(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::equal('name', ['Alice']), + Query::greaterThan('age', 18), + Query::raw('active = ?', [true]), + Query::raw('deleted = ?', [null]), + Query::raw('score > ?', [9.5]), + ]) + ->limit(10) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $sql); + $this->assertStringContainsString('18', $sql); + $this->assertStringContainsString('= 1', $sql); + $this->assertStringContainsString('= NULL', $sql); + $this->assertStringContainsString('9.5', $sql); + $this->assertStringContainsString('10', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithEmptyStringBinding(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', [''])]) + ->toRawSql(); + + $this->assertStringContainsString("''", $sql); + } + + public function testToRawSqlWithStringContainingSingleQuotes(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertStringContainsString("O''Brien", $sql); + } + + public function testToRawSqlWithVeryLargeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('id', 99999999999)]) + ->toRawSql(); + + $this->assertStringContainsString('99999999999', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithNegativeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -500)]) + ->toRawSql(); + + $this->assertStringContainsString('-500', $sql); + } + + public function testToRawSqlWithZero(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('count', [0])]) + ->toRawSql(); + + $this->assertStringContainsString('IN (0)', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithFalseBoolean(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [false])]) + ->toRawSql(); + + $this->assertStringContainsString('active = 0', $sql); + } + + public function testToRawSqlWithMultipleNullBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ?', [null, null])]) + ->toRawSql(); + + $this->assertEquals("SELECT * FROM `t` WHERE a = NULL AND b = NULL", $sql); + } + + public function testToRawSqlWithAggregationQuery(): void + { + $sql = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + + $this->assertStringContainsString('COUNT(*) AS `total`', $sql); + $this->assertStringContainsString('HAVING `total` > 5', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithJoinQuery(): void + { + $sql = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->filter([Query::greaterThan('orders.total', 100)]) + ->toRawSql(); + + $this->assertStringContainsString('JOIN `orders`', $sql); + $this->assertStringContainsString('100', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithUnionQuery(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $sql = (new Builder()) + ->from('current') + ->filter([Query::equal('year', [2024])]) + ->union($sub) + ->toRawSql(); + + $this->assertStringContainsString('2024', $sql); + $this->assertStringContainsString('2023', $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithRegexAndSearch(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::regex('slug', '^test'), + Query::search('content', 'hello'), + ]) + ->toRawSql(); + + $this->assertStringContainsString("REGEXP '^test'", $sql); + $this->assertStringContainsString("AGAINST('hello')", $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlCalledTwiceGivesSameResult(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->limit(10); + + $sql1 = $builder->toRawSql(); + $sql2 = $builder->toRawSql(); + + $this->assertEquals($sql1, $sql2); + } + // 11. when() edge cases + + public function testWhenWithComplexCallbackAddingMultipleFeatures(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE `status` IN (?)', $result->query); + $this->assertStringContainsString('ORDER BY `name` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testWhenChainedFiveTimes(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('a', [1])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('b', [2])])) + ->when(false, fn (Builder $b) => $b->filter([Query::equal('c', [3])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('d', [4])])) + ->when(true, fn (Builder $b) => $b->filter([Query::equal('e', [5])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', + $result->query + ); + $this->assertEquals([1, 2, 4, 5], $result->bindings); + } + + public function testWhenInsideWhenThreeLevelsDeep(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, function (Builder $b) { + $b->when(true, function (Builder $b2) { + $b2->when(true, fn (Builder $b3) => $b3->filter([Query::equal('deep', [1])])); + }); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testWhenThatAddsJoins(): void + { + $result = (new Builder()) + ->from('users') + ->when(true, fn (Builder $b) => $b->join('orders', 'users.id', 'orders.uid')) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + } + + public function testWhenThatAddsAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->count('*', 'total')->groupBy(['status'])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('GROUP BY `status`', $result->query); + } + + public function testWhenThatAddsUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->when(true, fn (Builder $b) => $b->union($sub)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION', $result->query); + } + + public function testWhenFalseDoesNotAffectFilters(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('status', ['banned'])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testWhenFalseDoesNotAffectJoins(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->join('other', 'a', 'b')) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('JOIN', $result->query); + } + + public function testWhenFalseDoesNotAffectAggregations(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->count('*', 'total')) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testWhenFalseDoesNotAffectSort(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->sortAsc('name')) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('ORDER BY', $result->query); + } + // 12. Condition provider edge cases + + public function testThreeConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['v1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['v2']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['v3']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', + $result->query + ); + $this->assertEquals(['v1', 'v2', 'v3'], $result->bindings); + } + + public function testProviderReturningEmptyConditionString(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('', []); + } + }) + ->build(); + $this->assertBindingCount($result); + + // Empty string still appears as a WHERE clause element + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testProviderWithManyBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('a IN (?, ?, ?, ?, ?)', [1, 2, 3, 4, 5]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', + $result->query + ); + $this->assertEquals([1, 2, 3, 4, 5], $result->bindings); + } + + public function testProviderCombinedWithCursorFilterHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('status', ['active'])]) + ->cursorAfter('cur1') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('HAVING', $result->query); + // filter, provider, cursor, having + $this->assertEquals(['active', 'org1', 'cur1', 5], $result->bindings); + } + + public function testProviderCombinedWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders`', $result->query); + $this->assertStringContainsString('WHERE tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testProviderCombinedWithUnions(): void + { + $sub = (new Builder())->from('archive'); + + $result = (new Builder()) + ->from('current') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertStringContainsString('UNION', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testProviderCombinedWithAggregations(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS `total`', $result->query); + $this->assertStringContainsString('WHERE org = ?', $result->query); + } + + public function testProviderReferencesTableName(): void + { + $result = (new Builder()) + ->from('users') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition("EXISTS (SELECT 1 FROM {$table}_perms WHERE type = ?)", ['read']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('users_perms', $result->query); + $this->assertEquals(['read'], $result->bindings); + } + + public function testProviderBindingOrderWithComplexQuery(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) + ->filter([ + Query::equal('a', ['va']), + Query::greaterThan('b', 10), + ]) + ->cursorAfter('cur') + ->limit(5) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + // filter, provider1, provider2, cursor, limit, offset + $this->assertEquals(['va', 10, 'pv1', 'pv2', 'cur', 5, 10], $result->bindings); + } + + public function testProviderPreservedAcrossReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('WHERE org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testFourConditionProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('a = ?', [1]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('b = ?', [2]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('c = ?', [3]); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('d = ?', [4]); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', + $result->query + ); + $this->assertEquals([1, 2, 3, 4], $result->bindings); + } + + public function testProviderWithNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('1 = 1', []); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertEquals([], $result->bindings); + } + // 13. Reset edge cases + + public function testResetPreservesAttributeResolver(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Attribute { + public function resolve(string $attribute): string + { + return '_' . $attribute; + } + }) + ->filter([Query::equal('x', [1])]); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->filter([Query::equal('y', [2])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`_y`', $result->query); + } + + public function testResetPreservesConditionProviders(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('org = ?', $result->query); + $this->assertEquals(['org1'], $result->bindings); + } + + public function testResetClearsPendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->sortAsc('name') + ->limit(10); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t2`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + + $builder->reset(); + $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testResetClearsTable(): void + { + $builder = (new Builder())->from('old_table'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new_table')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`new_table`', $result->query); + $this->assertStringNotContainsString('`old_table`', $result->query); + } + + public function testResetClearsUnionsAfterBuild(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testBuildAfterResetProducesMinimalQuery(): void + { + $builder = (new Builder()) + ->from('complex') + ->select(['a', 'b']) + ->filter([Query::equal('x', [1])]) + ->sortAsc('a') + ->limit(10) + ->offset(5); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testMultipleResetCalls(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('a', [1])]); + $builder->build(); + $builder->reset(); + $builder->reset(); + $builder->reset(); + + $result = $builder->from('t2')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t2`', $result->query); + } + + public function testResetBetweenDifferentQueryTypes(): void + { + $builder = new Builder(); + + // First: aggregation query + $builder->from('orders')->count('*', 'total')->groupBy(['status']); + $result1 = $builder->build(); + $this->assertStringContainsString('COUNT(*)', $result1->query); + + $builder->reset(); + + // Second: simple select query + $builder->from('users')->select(['name'])->filter([Query::equal('active', [true])]); + $result2 = $builder->build(); + $this->assertStringNotContainsString('COUNT', $result2->query); + $this->assertStringContainsString('`name`', $result2->query); + } + + public function testResetAfterUnion(): void + { + $sub = (new Builder())->from('other'); + $builder = (new Builder())->from('main')->union($sub); + $builder->build(); + $builder->reset(); + + $result = $builder->from('new')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `new`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testResetAfterComplexQueryWithAllFeatures(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('year', [2023])]); + + $builder = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'cnt') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::equal('status', ['paid'])]) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->sortDesc('cnt') + ->limit(10) + ->offset(5) + ->union($sub); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('simple')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `simple`', $result->query); + $this->assertEquals([], $result->bindings); + } + // 14. Multiple build() calls + + public function testBuildTwiceModifyInBetween(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $result1 = $builder->build(); + + $builder->filter([Query::equal('b', [2])]); + $result2 = $builder->build(); + + $this->assertStringNotContainsString('`b`', $result1->query); + $this->assertStringContainsString('`b`', $result2->query); + } + + public function testBuildDoesNotMutatePendingQueries(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); + } + + public function testBuildResetsBindingsEachTime(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]); + + $builder->build(); + $bindings1 = $builder->getBindings(); + + $builder->build(); + $bindings2 = $builder->getBindings(); + + $this->assertEquals($bindings1, $bindings2); + $this->assertCount(1, $bindings2); + } + + public function testBuildWithConditionProducesConsistentBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('status', ['active'])]); + + $result1 = $builder->build(); + $result2 = $builder->build(); + $result3 = $builder->build(); + + $this->assertEquals($result1->bindings, $result2->bindings); + $this->assertEquals($result2->bindings, $result3->bindings); + } + + public function testBuildAfterAddingMoreQueries(): void + { + $builder = (new Builder())->from('t'); + + $result1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $result1->query); + + $builder->filter([Query::equal('a', [1])]); + $result2 = $builder->build(); + $this->assertStringContainsString('WHERE', $result2->query); + + $builder->sortAsc('a'); + $result3 = $builder->build(); + $this->assertStringContainsString('ORDER BY', $result3->query); + } + + public function testBuildWithUnionProducesConsistentResults(): void + { + $sub = (new Builder())->from('other')->filter([Query::equal('x', [1])]); + $builder = (new Builder())->from('main')->union($sub); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertEquals($result1->query, $result2->query); + $this->assertEquals($result1->bindings, $result2->bindings); + } + + public function testBuildThreeTimesWithIncreasingComplexity(): void + { + $builder = (new Builder())->from('t'); + + $r1 = $builder->build(); + $this->assertEquals('SELECT * FROM `t`', $r1->query); + + $builder->filter([Query::equal('a', [1])]); + $r2 = $builder->build(); + $this->assertEquals('SELECT * FROM `t` WHERE `a` IN (?)', $r2->query); + + $builder->limit(10)->offset(5); + $r3 = $builder->build(); + $this->assertStringContainsString('LIMIT ?', $r3->query); + $this->assertStringContainsString('OFFSET ?', $r3->query); + } + + public function testBuildBindingsNotAccumulated(): void + { + $builder = (new Builder()) + ->from('t') + ->filter([Query::equal('a', [1])]) + ->limit(10); + + $builder->build(); + $builder->build(); + $builder->build(); + + $this->assertCount(2, $builder->getBindings()); + } + + public function testMultipleBuildWithHavingBindings(): void + { + $builder = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]); + + $r1 = $builder->build(); + $r2 = $builder->build(); + + $this->assertEquals([5], $r1->bindings); + $this->assertEquals([5], $r2->bindings); + } + // 15. Binding ordering comprehensive + + public function testBindingOrderMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::greaterThan('b', 10), + Query::between('c', 1, 100), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['v1', 10, 1, 100], $result->bindings); + } + + public function testBindingOrderThreeProviders(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p1 = ?', ['pv1']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p2 = ?', ['pv2']); + } + }) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('p3 = ?', ['pv3']); + } + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['pv1', 'pv2', 'pv3'], $result->bindings); + } + + public function testBindingOrderMultipleUnions(): void + { + $q1 = (new Builder())->from('a')->filter([Query::equal('x', [1])]); + $q2 = (new Builder())->from('b')->filter([Query::equal('y', [2])]); + + $result = (new Builder()) + ->from('main') + ->filter([Query::equal('z', [3])]) + ->limit(5) + ->union($q1) + ->unionAll($q2) + ->build(); + $this->assertBindingCount($result); + + // main filter, main limit, union1 bindings, union2 bindings + $this->assertEquals([3, 5, 1, 2], $result->bindings); + } + + public function testBindingOrderLogicalAndWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderLogicalOrWithMultipleSubFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderNestedAndOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('a', [1]), + Query::or([ + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testBindingOrderRawMixedWithRegularFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', ['v1']), + Query::raw('custom > ?', [10]), + Query::greaterThan('b', 20), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['v1', 10, 20], $result->bindings); + } + + public function testBindingOrderAggregationHavingComplexConditions(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('price', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['category']) + ->having([ + Query::greaterThan('cnt', 5), + Query::lessThan('total', 10000), + ]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + // filter, having1, having2, limit + $this->assertEquals(['active', 5, 10000, 10], $result->bindings); + } + + public function testBindingOrderFullPipelineWithEverything(): void + { + $sub = (new Builder())->from('archive')->filter([Query::equal('archived', [true])]); + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant = ?', ['t1']); + } + }) + ->filter([ + Query::equal('status', ['paid']), + Query::greaterThan('total', 0), + ]) + ->cursorAfter('cursor_val') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->limit(25) + ->offset(50) + ->union($sub) + ->build(); + $this->assertBindingCount($result); + + // filter(paid, 0), provider(t1), cursor(cursor_val), having(1), limit(25), offset(50), union(true) + $this->assertEquals(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); + } + + public function testBindingOrderContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::contains('bio', ['php', 'js', 'go']), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + // contains produces three LIKE bindings, then equal + $this->assertEquals(['%php%', '%js%', '%go%', 'active'], $result->bindings); + } + + public function testBindingOrderBetweenAndComparisons(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::between('age', 18, 65), + Query::greaterThan('score', 50), + Query::lessThan('rank', 100), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([18, 65, 50, 100], $result->bindings); + } + + public function testBindingOrderStartsWithEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::startsWith('name', 'A'), + Query::endsWith('email', '.com'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['A%', '%.com'], $result->bindings); + } + + public function testBindingOrderSearchAndRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::search('content', 'hello'), + Query::regex('slug', '^test'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['hello', '^test'], $result->bindings); + } + + public function testBindingOrderWithCursorBeforeFilterAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org = ?', ['org1']); + } + }) + ->filter([Query::equal('a', ['x'])]) + ->cursorBefore('my_cursor') + ->limit(10) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + // filter, provider, cursor, limit, offset + $this->assertEquals(['x', 'org1', 'my_cursor', 10, 0], $result->bindings); + } + // 16. Empty/minimal queries + + public function testBuildWithNoFromNoFilters(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->build(); + } + + public function testBuildWithOnlyLimit(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->limit(10) + ->build(); + } + + public function testBuildWithOnlyOffset(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->offset(50) + ->build(); + } + + public function testBuildWithOnlySort(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->sortAsc('name') + ->build(); + } + + public function testBuildWithOnlySelect(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->select(['a', 'b']) + ->build(); + } + + public function testBuildWithOnlyAggregationNoFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->from('') + ->count('*', 'total') + ->build(); + } + + public function testBuildWithEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testBuildWithEmptySelectArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT FROM `t`', $result->query); + } + + public function testBuildWithOnlyHavingNoGroupBy(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->having([Query::greaterThan('cnt', 0)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING `cnt` > ?', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); + } + + public function testBuildWithOnlyDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT * FROM `t`', $result->query); + } + // Spatial/Vector/ElemMatch Exception Tests + + + public function testSpatialCrosses(): void + { + $result = (new Builder())->from('t')->filter([Query::crosses('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('ST_Crosses', $result->query); + } + + public function testSpatialDistanceLessThan(): void + { + $result = (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('metre', $result->query); + } + + public function testSpatialIntersects(): void + { + $result = (new Builder())->from('t')->filter([Query::intersects('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('ST_Intersects', $result->query); + } + + public function testSpatialOverlaps(): void + { + $result = (new Builder())->from('t')->filter([Query::overlaps('attr', [[0, 0], [1, 1]])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('ST_Overlaps', $result->query); + } + + public function testSpatialTouches(): void + { + $result = (new Builder())->from('t')->filter([Query::touches('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('ST_Touches', $result->query); + } + + public function testSpatialNotIntersects(): void + { + $result = (new Builder())->from('t')->filter([Query::notIntersects('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + } + + public function testUnsupportedFilterTypeVectorDot(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorDot('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorCosine(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorCosine('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeVectorEuclidean(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::vectorEuclidean('attr', [1.0, 2.0])])->build(); + } + + public function testUnsupportedFilterTypeElemMatch(): void + { + $this->expectException(UnsupportedException::class); + (new Builder())->from('t')->filter([Query::elemMatch('attr', [Query::equal('x', [1])])])->build(); + } + // toRawSql Edge Cases + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertEquals("SELECT * FROM `t` WHERE `active` IN (0)", $sql); + } + + public function testToRawSqlMixedBindingTypes(): void + { + $sql = (new Builder())->from('t') + ->filter([ + Query::equal('name', ['str']), + Query::greaterThan('age', 42), + Query::lessThan('score', 9.99), + Query::equal('active', [true]), + ])->toRawSql(); + $this->assertStringContainsString("'str'", $sql); + $this->assertStringContainsString('42', $sql); + $this->assertStringContainsString('9.99', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t') + ->filter([Query::raw('col = ?', [null])]) + ->toRawSql(); + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlWithUnion(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('x', [1])]); + $sql = (new Builder())->from('a')->filter([Query::equal('y', [2])])->union($other)->toRawSql(); + $this->assertStringContainsString("FROM `a`", $sql); + $this->assertStringContainsString('UNION', $sql); + $this->assertStringContainsString("FROM `b`", $sql); + $this->assertStringContainsString('2', $sql); + $this->assertStringContainsString('1', $sql); + } + + public function testToRawSqlWithAggregationJoinGroupByHaving(): void + { + $sql = (new Builder())->from('orders') + ->count('*', 'total') + ->join('users', 'orders.uid', 'users.id') + ->select(['users.country']) + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 5)]) + ->toRawSql(); + $this->assertStringContainsString('COUNT(*)', $sql); + $this->assertStringContainsString('JOIN', $sql); + $this->assertStringContainsString('GROUP BY', $sql); + $this->assertStringContainsString('HAVING', $sql); + $this->assertStringContainsString('5', $sql); + } + // Kitchen Sink Exact SQL + + public function testKitchenSinkExactSql(): void + { + $other = (new Builder())->from('archive')->filter([Query::equal('status', ['closed'])]); + $result = (new Builder()) + ->from('orders') + ->distinct() + ->count('*', 'total') + ->select(['status']) + ->join('users', 'orders.uid', 'users.id') + ->filter([Query::greaterThan('amount', 100)]) + ->groupBy(['status']) + ->having([Query::greaterThan('total', 5)]) + ->sortAsc('status') + ->limit(10) + ->offset(20) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING `total` > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', + $result->query + ); + $this->assertEquals([100, 5, 10, 20, 'closed'], $result->bindings); + } + // Feature Combination Tests + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertBindingCount($result); + $this->assertEquals('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRawInsideLogicalAnd(): void + { + $result = (new Builder())->from('t') + ->filter([Query::and([ + Query::greaterThan('x', 1), + Query::raw('custom_func(y) > ?', [5]), + ])]) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertEquals([1, 5], $result->bindings); + } + + public function testRawInsideLogicalOr(): void + { + $result = (new Builder())->from('t') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::raw('b IS NOT NULL', []), + ])]) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testAggregationWithCursor(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->cursorAfter('abc') + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertContains('abc', $result->bindings); + } + + public function testGroupBySortCursorUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a') + ->count('*', 'total') + ->groupBy(['status']) + ->sortDesc('total') + ->cursorAfter('xyz') + ->union($other) + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + + public function testConditionProviderWithNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderWithCursorNoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->cursorAfter('abc') + ->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + // Provider bindings come before cursor bindings + $this->assertEquals(['t1', 'abc'], $result->bindings); + } + + public function testConditionProviderWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderPersistsAfterReset(): void + { + $builder = (new Builder()) + ->from('t') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }); + $builder->build(); + $builder->reset()->from('other'); + $result = $builder->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('FROM `other`', $result->query); + $this->assertStringContainsString('_tenant = ?', $result->query); + $this->assertEquals(['t1'], $result->bindings); + } + + public function testConditionProviderWithHaving(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['status']) + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_tenant = ?', ['t1']); + } + }) + ->having([Query::greaterThan('total', 5)]) + ->build(); + $this->assertBindingCount($result); + // Provider should be in WHERE, not HAVING + $this->assertStringContainsString('WHERE _tenant = ?', $result->query); + $this->assertStringContainsString('HAVING `total` > ?', $result->query); + // Provider bindings before having bindings + $this->assertEquals(['t1', 5], $result->bindings); + } + + public function testUnionWithConditionProvider(): void + { + $sub = (new Builder()) + ->from('b') + ->addHook(new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('_deleted = ?', [0]); + } + }); + $result = (new Builder()) + ->from('a') + ->union($sub) + ->build(); + $this->assertBindingCount($result); + // Sub-query should include the condition provider + $this->assertStringContainsString('UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); + $this->assertEquals([0], $result->bindings); + } + // Boundary Value Tests + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([-1], $result->bindings); + } + + public function testNegativeOffset(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(-5)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', ['a', null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` IN (?) OR `col` IS NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testNotEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', [null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `col` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotEqualWithNullAndNonNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` != ? AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testNotEqualWithMultipleNonNullAndNull(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('col', ['a', 'b', null])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE (`col` NOT IN (?, ?) AND `col` IS NOT NULL)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); + } + + public function testBetweenReversedMinMax(): void + { + $result = (new Builder())->from('t')->filter([Query::between('age', 65, 18)])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([65, 18], $result->bindings); + } + + public function testContainsWithSqlWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::contains('bio', ['100%'])])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertEquals(['%100\%%'], $result->bindings); + } + + public function testStartsWithWithWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertEquals(['\%admin%'], $result->bindings); + } + + public function testCursorWithNullValue(): void + { + // Null cursor value is ignored by groupByType since cursor stays null + $result = (new Builder())->from('t')->cursorAfter(null)->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('_cursor', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCursorWithIntegerValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(42)->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([42], $result->bindings); + } + + public function testCursorWithFloatValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(3.14)->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertSame([3.14], $result->bindings); + } + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testMultipleOffsetsFirstWins(): void + { + // OFFSET without LIMIT is suppressed + $result = (new Builder())->from('t')->offset(5)->offset(50)->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); + $this->assertBindingCount($result); + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertStringNotContainsString('`_cursor` < ?', $result->query); + } + + public function testEmptyTableWithJoin(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->from('')->join('other', 'a', 'b')->build(); + } + + public function testBuildWithoutFromCall(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->filter([Query::equal('x', [1])])->build(); + } + // Standalone Compiler Method Tests + + public function testCompileSelectEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileSelect(Query::select([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupByEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy([])); + $this->assertEquals('', $result); + } + + public function testCompileGroupBySingleColumn(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy(['status'])); + $this->assertEquals('`status`', $result); + } + + public function testCompileSumWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertEquals('SUM(`price`)', $sql); + } + + public function testCompileAvgWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score')); + $this->assertEquals('AVG(`score`)', $sql); + } + + public function testCompileMinWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price')); + $this->assertEquals('MIN(`price`)', $sql); + } + + public function testCompileMaxWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price')); + $this->assertEquals('MAX(`price`)', $sql); + } + + public function testCompileLimitZero(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(0)); + $this->assertEquals('LIMIT ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOffsetZero(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(0)); + $this->assertEquals('OFFSET ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOrderException(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileOrder(Query::limit(10)); + } + + public function testCompileJoinException(): void + { + $builder = new Builder(); + $this->expectException(UnsupportedException::class); + $builder->compileJoin(Query::equal('x', [1])); + } + // Query::compile() Integration Tests + + public function testQueryCompileOrderAsc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` ASC', Query::orderAsc('name')->compile($builder)); + } + + public function testQueryCompileOrderDesc(): void + { + $builder = new Builder(); + $this->assertEquals('`name` DESC', Query::orderDesc('name')->compile($builder)); + } + + public function testQueryCompileOrderRandom(): void + { + $builder = new Builder(); + $this->assertEquals('RAND()', Query::orderRandom()->compile($builder)); + } + + public function testQueryCompileLimit(): void + { + $builder = new Builder(); + $this->assertEquals('LIMIT ?', Query::limit(10)->compile($builder)); + $this->assertEquals([10], $builder->getBindings()); + } + + public function testQueryCompileOffset(): void + { + $builder = new Builder(); + $this->assertEquals('OFFSET ?', Query::offset(5)->compile($builder)); + $this->assertEquals([5], $builder->getBindings()); + } + + public function testQueryCompileCursorAfter(): void + { + $builder = new Builder(); + $this->assertEquals('`_cursor` > ?', Query::cursorAfter('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileCursorBefore(): void + { + $builder = new Builder(); + $this->assertEquals('`_cursor` < ?', Query::cursorBefore('x')->compile($builder)); + $this->assertEquals(['x'], $builder->getBindings()); + } + + public function testQueryCompileSelect(): void + { + $builder = new Builder(); + $this->assertEquals('`a`, `b`', Query::select(['a', 'b'])->compile($builder)); + } + + public function testQueryCompileGroupBy(): void + { + $builder = new Builder(); + $this->assertEquals('`status`', Query::groupBy(['status'])->compile($builder)); + } + // Reset Behavior + + public function testResetFollowedByUnion(): void + { + $builder = (new Builder()) + ->from('a') + ->union((new Builder())->from('old')); + $builder->reset()->from('b'); + $result = $builder->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `b`', $result->query); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testResetClearsBindingsAfterBuild(): void + { + $builder = (new Builder())->from('t')->filter([Query::equal('x', [1])]); + $builder->build(); + $this->assertNotEmpty($builder->getBindings()); + $builder->reset()->from('t'); + $result = $builder->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + // Missing Binding Assertions + + public function testSortAscBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortAsc('name')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testSortDescBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortDesc('name')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testSortRandomBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortRandom()->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testDistinctBindingsEmpty(): void + { + $result = (new Builder())->from('t')->distinct()->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testCrossJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->crossJoin('other')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testGroupByBindingsEmpty(): void + { + $result = (new Builder())->from('t')->groupBy(['status'])->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + + public function testCountWithAliasBindingsEmpty(): void + { + $result = (new Builder())->from('t')->count('*', 'total')->build(); + $this->assertBindingCount($result); + $this->assertEquals([], $result->bindings); + } + // DML: INSERT + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'a@b.com'], $result->bindings); + } + + public function testInsertBatch(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'email' => 'b@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'a@b.com', 'Bob', 'b@b.com'], $result->bindings); + } + + public function testInsertNoRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->insert(); + } + + public function testIntoAliasesFrom(): void + { + $builder = new Builder(); + $builder->into('users')->set(['name' => 'Alice'])->insert(); + $this->assertStringContainsString('users', $builder->insert()->query); + } + // DML: UPSERT + + public function testUpsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', + $result->query + ); + $this->assertEquals([1, 'Alice', 'a@b.com'], $result->bindings); + } + + public function testUpsertMultipleConflictColumns(): void + { + $result = (new Builder()) + ->into('user_roles') + ->set(['user_id' => 1, 'role_id' => 2, 'granted_at' => '2024-01-01']) + ->onConflict(['user_id', 'role_id'], ['granted_at']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO `user_roles` (`user_id`, `role_id`, `granted_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `granted_at` = VALUES(`granted_at`)', + $result->query + ); + $this->assertEquals([1, 2, '2024-01-01'], $result->bindings); + } + // DML: UPDATE + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['inactive'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['archived', 'inactive'], $result->bindings); + } + + public function testUpdateWithSetRaw(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Alice']) + ->setRaw('login_count', 'login_count + 1') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE `users` SET `name` = ?, `login_count` = login_count + 1 WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Alice', 1], $result->bindings); + } + + public function testUpdateWithFilterHook(): void + { + $hook = new class () implements Filter, Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('users') + ->set(['status' => 'active']) + ->filter([Query::equal('id', [1])]) + ->addHook($hook) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['active', 1, 'tenant_123'], $result->bindings); + } + + public function testUpdateWithoutWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'active']) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals('UPDATE `users` SET `status` = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testUpdateWithOrderByAndLimit(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('active', [false])]) + ->sortAsc('created_at') + ->limit(100) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE `users` SET `status` = ? WHERE `active` IN (?) ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['archived', false, 100], $result->bindings); + } + + public function testUpdateNoAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('users') + ->update(); + } + // DML: DELETE + + public function testDeleteWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::lessThan('last_login', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'DELETE FROM `users` WHERE `last_login` < ?', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); + } + + public function testDeleteWithFilterHook(): void + { + $hook = new class () implements Filter, Hook { + public function filter(string $table): Condition + { + return new Condition('`_tenant` = ?', ['tenant_123']); + } + }; + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['deleted'])]) + ->addHook($hook) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'DELETE FROM `users` WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertEquals(['deleted', 'tenant_123'], $result->bindings); + } + + public function testDeleteWithoutWhere(): void + { + $result = (new Builder()) + ->from('users') + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals('DELETE FROM `users`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testDeleteWithOrderByAndLimit(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->sortAsc('created_at') + ->limit(1000) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['2023-01-01', 1000], $result->bindings); + } + // DML: Reset clears new state + + public function testResetClearsDmlState(): void + { + $builder = (new Builder()) + ->into('users') + ->set(['name' => 'Alice']) + ->setRaw('count', 'count + 1') + ->onConflict(['id'], ['name']); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->into('users')->insert(); + } + // Validation: Missing table + + public function testInsertWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->set(['name' => 'Alice'])->insert(); + } + + public function testUpdateWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->set(['name' => 'Alice'])->update(); + } + + public function testDeleteWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->delete(); + } + + public function testSelectWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + + (new Builder())->build(); + } + // Validation: Empty rows + + public function testInsertEmptyRowThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('empty row'); + + (new Builder())->into('users')->set([])->insert(); + } + // Validation: Inconsistent batch columns + + public function testInsertInconsistentBatchThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('different columns'); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'phone' => '555-1234']) + ->insert(); + } + // Validation: Upsert without onConflict + + public function testUpsertWithoutConflictKeysThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict keys'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } + + public function testUpsertWithoutConflictUpdateColumnsThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict update columns'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], []) + ->upsert(); + } + + public function testUpsertConflictColumnNotInRowThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("not present in the row data"); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['email']) + ->upsert(); + } + // INTERSECT / EXCEPT + + public function testIntersect(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', + $result->query + ); + } + + public function testIntersectAll(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersectAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) INTERSECT ALL (SELECT * FROM `admins`)', + $result->query + ); + } + + public function testExcept(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', + $result->query + ); + } + + public function testExceptAll(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->exceptAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users`) EXCEPT ALL (SELECT * FROM `banned`)', + $result->query + ); + } + + public function testIntersectWithBindings(): void + { + $other = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + '(SELECT * FROM `users` WHERE `status` IN (?)) INTERSECT (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'admin'], $result->bindings); + } + + public function testExceptWithBindings(): void + { + $other = (new Builder())->from('banned')->filter([Query::equal('reason', ['spam'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 'spam'], $result->bindings); + } + + public function testMixedSetOperations(): void + { + $q1 = (new Builder())->from('a'); + $q2 = (new Builder())->from('b'); + $q3 = (new Builder())->from('c'); + + $result = (new Builder()) + ->from('main') + ->union($q1) + ->intersect($q2) + ->except($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION', $result->query); + $this->assertStringContainsString('INTERSECT', $result->query); + $this->assertStringContainsString('EXCEPT', $result->query); + } + + public function testIntersectFluentReturnsSameInstance(): void + { + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->intersect($other)); + } + + public function testExceptFluentReturnsSameInstance(): void + { + $builder = new Builder(); + $other = (new Builder())->from('t'); + $this->assertSame($builder, $builder->from('t')->except($other)); + } + // Row Locking + + public function testForUpdate(): void + { + $result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testForShare(): void + { + $result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR SHARE', + $result->query + ); + } + + public function testForUpdateWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('accounts') + ->limit(10) + ->offset(5) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `accounts` LIMIT ? OFFSET ? FOR UPDATE', + $result->query + ); + $this->assertEquals([10, 5], $result->bindings); + } + + public function testLockModeResetClears(): void + { + $builder = (new Builder())->from('t')->forUpdate(); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + // Transaction Statements + + public function testBegin(): void + { + $result = (new Builder())->begin(); + $this->assertEquals('BEGIN', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCommit(): void + { + $result = (new Builder())->commit(); + $this->assertEquals('COMMIT', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRollback(): void + { + $result = (new Builder())->rollback(); + $this->assertEquals('ROLLBACK', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testSavepoint(): void + { + $result = (new Builder())->savepoint('sp1'); + $this->assertEquals('SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testReleaseSavepoint(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertEquals('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRollbackToSavepoint(): void + { + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertEquals('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + // INSERT...SELECT + + public function testInsertSelect(): void + { + $source = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]); + + $result = (new Builder()) + ->into('archive') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); + + $this->assertEquals( + 'INSERT INTO `archive` (`name`, `email`) SELECT `name`, `email` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testInsertSelectWithoutSourceThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No SELECT source specified'); + + (new Builder()) + ->into('archive') + ->insertSelect(); + } + + public function testInsertSelectWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + $source = (new Builder())->from('users'); + + (new Builder()) + ->fromSelect(['name'], $source) + ->insertSelect(); + } + + public function testInsertSelectWithAggregation(): void + { + $source = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->count('*', 'order_count') + ->groupBy(['customer_id']); + + $result = (new Builder()) + ->into('customer_stats') + ->fromSelect(['customer_id', 'order_count'], $source) + ->insertSelect(); + + $this->assertStringContainsString('INSERT INTO `customer_stats`', $result->query); + $this->assertStringContainsString('COUNT(*) AS `order_count`', $result->query); + } + + public function testInsertSelectResetClears(): void + { + $source = (new Builder())->from('users'); + $builder = (new Builder()) + ->into('archive') + ->fromSelect(['name'], $source); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->into('archive')->insertSelect(); + } + // CTEs (WITH) + + public function testCteWith(): void + { + $cte = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->select(['customer_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'WITH `paid_orders` AS (SELECT * FROM `orders` WHERE `status` IN (?)) SELECT `customer_id` FROM `paid_orders`', + $result->query + ); + $this->assertEquals(['paid'], $result->bindings); + } + + public function testCteWithRecursive(): void + { + $cte = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $cte) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'WITH RECURSIVE `tree` AS (SELECT * FROM `categories`) SELECT * FROM `tree`', + $result->query + ); + } + + public function testMultipleCtes(): void + { + $cte1 = (new Builder())->from('orders')->filter([Query::equal('status', ['paid'])]); + $cte2 = (new Builder())->from('returns')->filter([Query::equal('status', ['approved'])]); + + $result = (new Builder()) + ->with('paid', $cte1) + ->with('approved_returns', $cte2) + ->from('paid') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('WITH `paid` AS', $result->query); + $this->assertStringContainsString('`approved_returns` AS', $result->query); + $this->assertEquals(['paid', 'approved'], $result->bindings); + } + + public function testCteBindingsComeBefore(): void + { + $cte = (new Builder())->from('orders')->filter([Query::equal('year', [2024])]); + + $result = (new Builder()) + ->with('recent', $cte) + ->from('recent') + ->filter([Query::greaterThan('amount', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals([2024, 100], $result->bindings); + } + + public function testCteResetClears(): void + { + $cte = (new Builder())->from('orders'); + $builder = (new Builder())->with('o', $cte)->from('o'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testMixedRecursiveAndNonRecursiveCte(): void + { + $cte1 = (new Builder())->from('categories'); + $cte2 = (new Builder())->from('products'); + + $result = (new Builder()) + ->with('prods', $cte2) + ->withRecursive('tree', $cte1) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('WITH RECURSIVE', $result->query); + $this->assertStringContainsString('`prods` AS', $result->query); + $this->assertStringContainsString('`tree` AS', $result->query); + } + // CASE/WHEN + selectRaw() + + public function testCaseBuilder(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('label') + ->build(); + + $this->assertEquals( + 'CASE WHEN status = ? THEN ? WHEN status = ? THEN ? ELSE ? END AS label', + $case->sql + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $case->bindings); + } + + public function testCaseBuilderWithoutElse(): void + { + $case = (new CaseBuilder()) + ->when('x > ?', '1', [10]) + ->build(); + + $this->assertEquals('CASE WHEN x > ? THEN 1 END', $case->sql); + $this->assertEquals([10], $case->bindings); + } + + public function testCaseBuilderWithoutAlias(): void + { + $case = (new CaseBuilder()) + ->when('x = 1', "'yes'") + ->elseResult("'no'") + ->build(); + + $this->assertEquals("CASE WHEN x = 1 THEN 'yes' ELSE 'no' END", $case->sql); + } + + public function testCaseBuilderNoWhensThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least one WHEN'); + + (new CaseBuilder())->build(); + } + + public function testCaseExpressionToSql(): void + { + $case = (new CaseBuilder()) + ->when('a = ?', '1', [1]) + ->build(); + + $this->assertEquals('CASE WHEN a = ? THEN 1 END', $case->sql); + $this->assertEquals([1], $case->bindings); + } + + public function testSelectRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->selectRaw('SUM(amount) AS total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(amount) AS total FROM `orders`', $result->query); + } + + public function testSelectRawWithBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->selectRaw('IF(amount > ?, 1, 0) AS big_order', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT IF(amount > ?, 1, 0) AS big_order FROM `orders`', $result->query); + $this->assertEquals([1000], $result->bindings); + } + + public function testSelectRawCombinedWithSelect(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id']) + ->selectRaw('SUM(amount) AS total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT `id`, `customer_id`, SUM(amount) AS total FROM `orders`', $result->query); + } + + public function testSelectRawWithCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectRaw($case->sql, $case->bindings) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); + } + + public function testSelectRawResetClears(): void + { + $builder = (new Builder())->from('t')->selectRaw('1 AS one'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + } + + public function testSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('accounts') + ->set(['name' => 'Alice']) + ->setRaw('balance', 'balance + ?', [100]) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE `accounts` SET `name` = ?, `balance` = balance + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertEquals(['Alice', 100, 1], $result->bindings); + } + + public function testSetRawWithBindingsResetClears(): void + { + $builder = (new Builder())->from('t')->setRaw('x', 'x + ?', [1]); + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->from('t')->update(); + } + + public function testMultipleSelectRaw(): void + { + $result = (new Builder()) + ->from('t') + ->selectRaw('COUNT(*) AS cnt') + ->selectRaw('MAX(price) AS max_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) AS cnt, MAX(price) AS max_price FROM `t`', $result->query); + } + + public function testForUpdateNotInUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder()) + ->from('a') + ->forUpdate() + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE', $result->query); + } + + public function testCteWithUnion(): void + { + $cte = (new Builder())->from('orders'); + $other = (new Builder())->from('archive_orders'); + + $result = (new Builder()) + ->with('o', $cte) + ->from('o') + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('WITH `o` AS', $result->query); + $this->assertStringContainsString('UNION', $result->query); + } + // Spatial feature interface + + public function testImplementsSpatial(): void + { + $this->assertInstanceOf(Spatial::class, new Builder()); + } + + public function testFilterDistanceMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance(ST_SRID(`coords`, 4326), ST_GeomFromText(?, 4326), \'metre\') < ?', $result->query); + $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertEquals(5000.0, $result->bindings[1]); + } + + public function testFilterDistanceNoMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], '>', 100.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance(`coords`, ST_GeomFromText(?)) > ?', $result->query); + } + + public function testFilterIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Intersects(`area`, ST_GeomFromText(?, 4326))', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + } + + public function testFilterNotIntersects(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + } + + public function testFilterCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Contains(`area`, ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterSpatialEquals(): void + { + $result = (new Builder()) + ->from('zones') + ->filterSpatialEquals('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Equals', $result->query); + } + + public function testSpatialWithLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('LINESTRING(0 0, 1 1, 2 2)', $result->bindings[0]); + } + + public function testSpatialWithPolygon(): void + { + $result = (new Builder()) + ->from('areas') + ->filterIntersects('zone', [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]) + ->build(); + $this->assertBindingCount($result); + + /** @var string $wkt */ + $wkt = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $wkt); + } + // JSON feature interface + + public function testImplementsJson(): void + { + $this->assertInstanceOf(Json::class, new Builder()); + } + + public function testFilterJsonContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('tags', 'php') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_CONTAINS(`tags`, ?)', $result->query); + $this->assertEquals('"php"', $result->bindings[0]); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', 'old') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT JSON_CONTAINS(`tags`, ?)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); + $this->assertEquals('["php","go"]', $result->bindings[0]); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("JSON_EXTRACT(`metadata`, '$.level') > ?", $result->query); + $this->assertEquals(5, $result->bindings[0]); + } + + public function testSetJsonAppend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new_tag']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?)', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY()))', $result->query); + } + + public function testSetJsonInsert(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonInsert('tags', 0, 'inserted') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); + } + + public function testSetJsonRemove(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonRemove('tags', 'old_tag') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_REMOVE', $result->query); + } + // Hints feature interface + + public function testImplementsHints(): void + { + $this->assertInstanceOf(Hints::class, new Builder()); + } + + public function testHintInSelect(): void + { + $result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) */', $result->query); + } + + public function testMaxExecutionTime(): void + { + $result = (new Builder()) + ->from('users') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); + } + + public function testMultipleHints(): void + { + $result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->hint('BKA(users)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('/*+ NO_INDEX_MERGE(users) BKA(users) */', $result->query); + } + // Window functions + + public function testImplementsWindows(): void + { + $this->assertInstanceOf(Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `created_at` ASC) AS `rn`', $result->query); + } + + public function testSelectWindowRank(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RANK() OVER (ORDER BY `score` DESC) AS `rank`', $result->query); + } + + public function testSelectWindowPartitionOnly(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('SUM(amount)', 'total', ['dept']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); + } + + public function testSelectWindowNoPartitionNoOrder(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('COUNT(*)', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) OVER () AS `total`', $result->query); + } + // CASE integration + + public function testSelectCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); + } + + public function testSetCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) + ->build(); + + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::greaterThan('id', 0)]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`category` = CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); + $this->assertEquals([18, 'adult', 'minor', 0], $result->bindings); + } + // Query factory methods for JSON + + public function testQueryJsonContainsFactory(): void + { + $q = Query::jsonContains('tags', 'php'); + $this->assertEquals(Method::JsonContains, $q->getMethod()); + $this->assertEquals('tags', $q->getAttribute()); + } + + public function testQueryJsonOverlapsFactory(): void + { + $q = Query::jsonOverlaps('tags', ['php', 'go']); + $this->assertEquals(Method::JsonOverlaps, $q->getMethod()); + } + + public function testQueryJsonPathFactory(): void + { + $q = Query::jsonPath('meta', 'level', '>', 5); + $this->assertEquals(Method::JsonPath, $q->getMethod()); + $this->assertEquals(['level', '>', 5], $q->getValues()); + } + // Does NOT implement VectorSearch + + public function testDoesNotImplementVectorSearch(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(VectorSearch::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + // Reset clears new state + + public function testResetClearsHintsAndJsonSets(): void + { + $builder = (new Builder()) + ->from('users') + ->hint('test') + ->setJsonAppend('tags', ['a']); + + $builder->reset(); + + $result = $builder->from('users')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('/*+', $result->query); + } + + public function testFilterNotIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotIntersects('zone', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + } + + public function testFilterNotCrossesLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterNotCrosses('path', [[0, 0], [1, 1]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Crosses', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING', $binding); + } + + public function testFilterOverlapsPolygon(): void + { + $result = (new Builder()) + ->from('regions') + ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Overlaps', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $binding); + } + + public function testFilterNotOverlaps(): void + { + $result = (new Builder()) + ->from('regions') + ->filterNotOverlaps('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Overlaps', $result->query); + } + + public function testFilterTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Touches', $result->query); + } + + public function testFilterNotTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Touches', $result->query); + } + + public function testFilterNotCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotCovers('region', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Contains', $result->query); + } + + public function testFilterNotSpatialEquals(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Equals', $result->query); + } + + public function testFilterDistanceGreaterThan(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('> ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(500.0, $result->bindings[1]); + } + + public function testFilterDistanceEqual(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '=', 0.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('= ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(0.0, $result->bindings[1]); + } + + public function testFilterDistanceNotEqual(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '!=', 100.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance', $result->query); + $this->assertStringContainsString('!= ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(100.0, $result->bindings[1]); + } + + public function testFilterDistanceWithoutMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance(`loc`, ST_GeomFromText(?)) < ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(50.0, $result->bindings[1]); + } + + public function testFilterIntersectsLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING(0 0, 1 1, 2 2)', $binding); + } + + public function testFilterSpatialEqualsPoint(): void + { + $result = (new Builder()) + ->from('places') + ->filterSpatialEquals('pos', [42.5, -73.2]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Equals', $result->query); + $this->assertEquals('POINT(42.5 -73.2)', $result->bindings[0]); + } + + public function testSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); + $this->assertStringContainsString('JSON_CONTAINS(?, val)', $result->query); + $this->assertStringContainsString('UPDATE `t` SET', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT JSON_CONTAINS(?, val)', $result->query); + $this->assertContains(\json_encode(['x']), $result->bindings); + } + + public function testSetJsonUnique(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_ARRAYAGG', $result->query); + $this->assertStringContainsString('DISTINCT', $result->query); + } + + public function testSetJsonPrependMergeOrder(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_MERGE_PRESERVE(?, IFNULL(', $result->query); + } + + public function testSetJsonInsertWithIndex(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonInsert('items', 2, 'value') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_ARRAY_INSERT', $result->query); + $this->assertContains('$[2]', $result->bindings); + $this->assertContains('value', $result->bindings); + } + + public function testFilterJsonNotContainsCompiles(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT JSON_CONTAINS(`meta`, ?)', $result->query); + } + + public function testFilterJsonOverlapsCompiles(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JSON_OVERLAPS(`tags`, ?)', $result->query); + } + + public function testFilterJsonPathCompiles(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("JSON_EXTRACT(`data`, '$.age') >= ?", $result->query); + $this->assertEquals(21, $result->bindings[0]); + } + + public function testMultipleHintsNoIcpAndBka(): void + { + $result = (new Builder()) + ->from('t') + ->hint('NO_ICP(t)') + ->hint('BKA(t)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('/*+ NO_ICP(t) BKA(t) */', $result->query); + } + + public function testHintWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->hint('SET_VAR(sort_buffer_size=16M)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT /*+', $result->query); + } + + public function testHintPreservesBindings(): void + { + $result = (new Builder()) + ->from('t') + ->hint('NO_ICP(t)') + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active'], $result->bindings); + } + + public function testMaxExecutionTimeValue(): void + { + $result = (new Builder()) + ->from('t') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('/*+ MAX_EXECUTION_TIME(5000) */', $result->query); + } + + public function testSelectWindowWithPartitionOnly(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('SUM(amount)', 'total', ['dept']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM(amount) OVER (PARTITION BY `dept`) AS `total`', $result->query); + } + + public function testSelectWindowWithOrderOnly(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (ORDER BY `created_at` ASC) AS `rn`', $result->query); + } + + public function testSelectWindowNoPartitionNoOrderEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('COUNT(*)', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) OVER () AS `cnt`', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) + ->selectWindow('SUM(amount)', 'running_total', null, ['id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER()', $result->query); + $this->assertStringContainsString('SUM(amount)', $result->query); + } + + public function testSelectWindowWithDescOrder(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('RANK()', 'r', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY `score` DESC', $result->query); + } + + public function testCaseWithMultipleWhens(): void + { + $case = (new CaseBuilder()) + ->when('x = ?', '?', [1], ['one']) + ->when('x = ?', '?', [2], ['two']) + ->when('x = ?', '?', [3], ['three']) + ->build(); + + $this->assertStringContainsString('WHEN x = ? THEN ?', $case->sql); + $this->assertEquals([1, 'one', 2, 'two', 3, 'three'], $case->bindings); + } + + public function testCaseExpressionWithoutElseClause(): void + { + $case = (new CaseBuilder()) + ->when('x > ?', '1', [10]) + ->when('x < ?', '0', [0]) + ->build(); + + $this->assertStringNotContainsString('ELSE', $case->sql); + } + + public function testCaseExpressionWithoutAliasClause(): void + { + $case = (new CaseBuilder()) + ->when('x = 1', "'yes'") + ->build(); + + $this->assertStringNotContainsString(' AS ', $case->sql); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) + ->build(); + + $result = (new Builder()) + ->from('users') + ->setCase('status', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UPDATE', $result->query); + $this->assertStringContainsString('CASE WHEN', $result->query); + $this->assertStringContainsString('END', $result->query); + } + + public function testCaseBuilderThrowsWhenNoWhensAdded(): void + { + $this->expectException(ValidationException::class); + + (new CaseBuilder())->build(); + } + + public function testMultipleCTEsWithTwoSources(): void + { + $cte1 = (new Builder())->from('orders'); + $cte2 = (new Builder())->from('returns'); + + $result = (new Builder()) + ->with('a', $cte1) + ->with('b', $cte2) + ->from('a') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WITH `a` AS', $result->query); + $this->assertStringContainsString('`b` AS', $result->query); + } + + public function testCTEWithBindings(): void + { + $cte = (new Builder())->from('orders')->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->filter([Query::greaterThan('amount', 100)]) + ->build(); + $this->assertBindingCount($result); + + // CTE bindings come BEFORE main query bindings + $this->assertEquals('paid', $result->bindings[0]); + $this->assertEquals(100, $result->bindings[1]); + } + + public function testCTEWithRecursiveMixed(): void + { + $cte1 = (new Builder())->from('products'); + $cte2 = (new Builder())->from('categories'); + + $result = (new Builder()) + ->with('prods', $cte1) + ->withRecursive('tree', $cte2) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('WITH RECURSIVE', $result->query); + $this->assertStringContainsString('`prods` AS', $result->query); + $this->assertStringContainsString('`tree` AS', $result->query); + } + + public function testCTEResetClearedAfterBuild(): void + { + $cte = (new Builder())->from('orders'); + $builder = (new Builder()) + ->with('o', $cte) + ->from('o'); + + $builder->reset(); + + $result = $builder->from('users')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testInsertSelectWithFilter(): void + { + $source = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]); + + $result = (new Builder()) + ->into('archive') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); + + $this->assertStringContainsString('INSERT INTO `archive`', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testInsertSelectThrowsWithoutSource(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('archive') + ->insertSelect(); + } + + public function testInsertSelectThrowsWithoutColumns(): void + { + $this->expectException(ValidationException::class); + + $source = (new Builder())->from('users'); + + (new Builder()) + ->into('archive') + ->fromSelect([], $source) + ->insertSelect(); + } + + public function testInsertSelectMultipleColumns(): void + { + $source = (new Builder()) + ->from('users') + ->select(['name', 'email', 'age']); + + $result = (new Builder()) + ->into('archive') + ->fromSelect(['name', 'email', 'age'], $source) + ->insertSelect(); + + $this->assertStringContainsString('`name`', $result->query); + $this->assertStringContainsString('`email`', $result->query); + $this->assertStringContainsString('`age`', $result->query); + } + + public function testUnionAllCompiles(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('current') + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testIntersectCompiles(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INTERSECT', $result->query); + } + + public function testIntersectAllCompiles(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersectAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INTERSECT ALL', $result->query); + } + + public function testExceptCompiles(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXCEPT', $result->query); + } + + public function testExceptAllCompiles(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->exceptAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXCEPT ALL', $result->query); + } + + public function testUnionWithBindings(): void + { + $other = (new Builder())->from('admins')->filter([Query::equal('role', ['admin'])]); + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['active', 'admin'], $result->bindings); + } + + public function testPageThreeWithTen(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([10, 20], $result->bindings); + } + + public function testPageFirstPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 0], $result->bindings); + } + + public function testCursorAfterWithSort(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('id') + ->cursorAfter(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`_cursor` > ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); + } + + public function testCursorBeforeWithSort(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('id') + ->cursorBefore(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`_cursor` < ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); + } + + public function testToRawSqlWithStrings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', ['Alice'])]) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $sql); + } + + public function testToRawSqlWithIntegers(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 30)]) + ->toRawSql(); + + $this->assertStringContainsString('30', $sql); + $this->assertStringNotContainsString("'30'", $sql); + } + + public function testToRawSqlWithNullValue(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('deleted_at = ?', [null])]) + ->toRawSql(); + + $this->assertStringContainsString('NULL', $sql); + } + + public function testToRawSqlWithBooleans(): void + { + $sqlTrue = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [true])]) + ->toRawSql(); + + $sqlFalse = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [false])]) + ->toRawSql(); + + $this->assertStringContainsString('= 1', $sqlTrue); + $this->assertStringContainsString('= 0', $sqlFalse); + } + + public function testWhenTrueAppliesLimit(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->limit(5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT', $result->query); + } + + public function testWhenFalseSkipsLimit(): void + { + $result = (new Builder()) + ->from('t') + ->when(false, fn (Builder $b) => $b->limit(5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('LIMIT', $result->query); + } + + public function testBuildWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->build(); + } + + public function testInsertWithoutRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('t')->insert(); + } + + public function testInsertWithEmptyRowThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('t')->set([])->insert(); + } + + public function testUpdateWithoutAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('t')->update(); + } + + public function testUpsertWithoutConflictKeysThrowsValidation(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } + + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('t') + ->set(['a' => 1, 'b' => 2]) + ->set(['a' => 3, 'b' => 4]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); + $this->assertEquals([1, 2, 3, 4], $result->bindings); + } + + public function testBatchInsertMismatchedColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->set(['a' => 1, 'b' => 2]) + ->set(['a' => 3, 'c' => 4]) + ->insert(); + } + + public function testEmptyColumnNameThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('t') + ->set(['' => 'val']) + ->insert(); + } + + public function testSearchNotCompiles(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', 'spam')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); + } + + public function testRegexpCompiles(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`slug` REGEXP ?', $result->query); + } + + public function testUpsertUsesOnDuplicateKey(): void + { + $result = (new Builder()) + ->into('t') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $result->query); + } + + public function testForUpdateCompiles(): void + { + $result = (new Builder()) + ->from('accounts') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringEndsWith('FOR UPDATE', $result->query); + } + + public function testForShareCompiles(): void + { + $result = (new Builder()) + ->from('accounts') + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringEndsWith('FOR SHARE', $result->query); + } + + public function testForUpdateWithFilters(): void + { + $result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringEndsWith('FOR UPDATE', $result->query); + } + + public function testBeginTransaction(): void + { + $result = (new Builder())->begin(); + $this->assertEquals('BEGIN', $result->query); + } + + public function testCommitTransaction(): void + { + $result = (new Builder())->commit(); + $this->assertEquals('COMMIT', $result->query); + } + + public function testRollbackTransaction(): void + { + $result = (new Builder())->rollback(); + $this->assertEquals('ROLLBACK', $result->query); + } + + public function testReleaseSavepointCompiles(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertEquals('RELEASE SAVEPOINT `sp1`', $result->query); + } + + public function testResetClearsCTEs(): void + { + $cte = (new Builder())->from('orders'); + $builder = (new Builder()) + ->with('o', $cte) + ->from('o'); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testResetClearsUnionsComprehensive(): void + { + $other = (new Builder())->from('archive'); + $builder = (new Builder()) + ->from('current') + ->union($other); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('UNION', $result->query); + } + + public function testGroupByWithHavingCount(): void + { + $result = (new Builder()) + ->from('employees') + ->count('*', 'cnt') + ->groupBy(['dept']) + ->having([Query::and([Query::greaterThan('COUNT(*)', 5)])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('HAVING', $result->query); + } + + public function testGroupByMultipleColumnsAB(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['a', 'b']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY `a`, `b`', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testEqualWithNullOnlyCompileIn(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`x` IN (?) OR `x` IS NULL)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` IN (?, ?, ?)', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testNotEqualEmptyArrayReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testNotEqualSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertEquals([5], $result->bindings); + } + + public function testNotEqualWithNullOnlyCompileNotIn(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`x` != ? AND `x` IS NOT NULL)', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testNotEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` NOT IN (?, ?, ?)', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testNotEqualSingleNonNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 42)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` != ?', $result->query); + $this->assertEquals([42], $result->bindings); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`score` NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); + } + + public function testBetweenWithStrings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('date', '2024-01-01', '2024-12-31')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`date` BETWEEN ? AND ?', $result->query); + $this->assertEquals(['2024-01-01', '2024-12-31'], $result->bindings); + } + + public function testAndWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`age` > ? AND `age` < ?)', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testOrWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['mod'])])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertEquals(['admin', 'mod'], $result->bindings); + } + + public function testNestedAndInsideOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([Query::greaterThan('a', 1), Query::lessThan('b', 2)]), + Query::equal('c', [3]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('((`a` > ? AND `b` < ?) OR `c` IN (?))', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testEmptyAndReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testEmptyOrReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`name` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`name` IS NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testNotExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`a` IS NULL AND `b` IS NULL)', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRawFilterWithSql(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); + } + + public function testRawFilterWithoutBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('active = 1')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('active = 1', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testStartsWithEscapesPercent(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '100%')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['100\\%%'], $result->bindings); + } + + public function testStartsWithEscapesUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'a_b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['a\\_b%'], $result->bindings); + } + + public function testStartsWithEscapesBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'path\\')]) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('\\\\', $binding); + } + + public function testEndsWithEscapesSpecialChars(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '%test_')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['%\\%test\\_'], $result->bindings); + } + + public function testContainsMultipleValuesUsesOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); + } + + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`bio` LIKE ? AND `bio` LIKE ?)', $result->query); + $this->assertEquals(['%php%', '%js%'], $result->bindings); + } + + public function testNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['x', 'y'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('(`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertEquals(['%x%', '%y%'], $result->bindings); + } + + public function testContainsSingleValueNoParentheses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`bio` LIKE ?', $result->query); + $this->assertStringNotContainsString('(', $result->query); + } + + public function testDottedIdentifierInSelect(): void + { + $result = (new Builder()) + ->from('t') + ->select(['users.name', 'users.email']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`users`.`name`, `users`.`email`', $result->query); + } + + public function testDottedIdentifierInFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('users.id', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`users`.`id` IN (?)', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY `name` ASC, `age` DESC', $result->query); + } + + public function testOrderByWithRandomAndRegular(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('`name` ASC', $result->query); + $this->assertStringContainsString('RAND()', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT `name` FROM `t`', $result->query); + } + + public function testDistinctWithAggregate(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT COUNT(*) FROM `t`', $result->query); + } + + public function testSumWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM(`amount`) AS `total` FROM `t`', $result->query); + } + + public function testAvgWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->avg('score', 'avg_score') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT AVG(`score`) AS `avg_score` FROM `t`', $result->query); + } + + public function testMinWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->min('price', 'cheapest') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT MIN(`price`) AS `cheapest` FROM `t`', $result->query); + } + + public function testMaxWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->max('price', 'priciest') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT MAX(`price`) AS `priciest` FROM `t`', $result->query); + } + + public function testCountWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t`', $result->query); + } + + public function testSelectRawWithRegularSelect(): void + { + $result = (new Builder()) + ->from('t') + ->select(['id']) + ->selectRaw('NOW() as current_time') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT `id`, NOW() as current_time FROM `t`', $result->query); + } + + public function testSelectRawWithBindings2(): void + { + $result = (new Builder()) + ->from('t') + ->selectRaw('COALESCE(?, ?) as result', ['a', 'b']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['a', 'b'], $result->bindings); + } + + public function testRightJoin2(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN `b` ON `a`.`id` = `b`.`a_id`', $result->query); + } + + public function testCrossJoin2(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `b`', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testJoinWithNonEqualOperator(): void + { + $result = (new Builder()) + ->from('a') + ->join('b', 'a.id', 'b.a_id', '!=') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ON `a`.`id` != `b`.`a_id`', $result->query); + } + + public function testJoinInvalidOperatorThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('a') + ->join('b', 'a.id', 'b.a_id', 'INVALID') + ->build(); + } + + public function testMultipleFiltersJoinedWithAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + Query::lessThan('c', 3), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE `a` IN (?) AND `b` > ? AND `c` < ?', $result->query); + $this->assertEquals([1, 2, 3], $result->bindings); + } + + public function testFilterWithRawCombined(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('x', [1]), + Query::raw('y > 5'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`x` IN (?)', $result->query); + $this->assertStringContainsString('y > 5', $result->query); + $this->assertStringContainsString('AND', $result->query); + } + + public function testResetClearsRawSelects2(): void + { + $builder = (new Builder())->from('t')->selectRaw('1 AS one'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertEquals('SELECT * FROM `t`', $result->query); + $this->assertStringNotContainsString('one', $result->query); + } + + public function testAttributeHookResolvesColumn(): void + { + $hook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return match ($attribute) { + 'alias' => 'real_column', + default => $attribute, + }; + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->filter([Query::equal('alias', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`real_column`', $result->query); + $this->assertStringNotContainsString('`alias`', $result->query); + } + + public function testAttributeHookWithSelect(): void + { + $hook = new class () implements Attribute { + public function resolve(string $attribute): string + { + return match ($attribute) { + 'alias' => 'real_column', + default => $attribute, + }; + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->select(['alias']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT `real_column`', $result->query); + } + + public function testMultipleFilterHooks(): void + { + $hook1 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('`tenant` = ?', ['t1']); + } + }; + + $hook2 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('`org` = ?', ['o1']); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook1) + ->addHook($hook2) + ->filter([Query::equal('x', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`tenant` = ?', $result->query); + $this->assertStringContainsString('`org` = ?', $result->query); + $this->assertStringContainsString('AND', $result->query); + $this->assertContains('t1', $result->bindings); + $this->assertContains('o1', $result->bindings); + } + + public function testSearchFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello world')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('MATCH(`body`) AGAINST(?)', $result->query); + $this->assertContains('hello world', $result->bindings); + } + + public function testNotSearchFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', 'spam')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT (MATCH(`body`) AGAINST(?))', $result->query); + $this->assertContains('spam', $result->bindings); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`deleted_at` IS NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name` IS NOT NULL', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testLessThanFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testLessThanEqualFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testGreaterThanFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` > ?', $result->query); + $this->assertEquals([18], $result->bindings); + } + + public function testGreaterThanEqualFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('age', 21)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`age` >= ?', $result->query); + $this->assertEquals([21], $result->bindings); + } + + public function testNotStartsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); + $this->assertEquals(['foo%'], $result->bindings); + } + + public function testNotEndsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'bar')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name` NOT LIKE ?', $result->query); + $this->assertEquals(['%bar'], $result->bindings); + } + + public function testDeleteWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 18)]) + ->sortAsc('id') + ->limit(10) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DELETE FROM `t`', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY `id` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testUpdateWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->set(['status' => 'archived']) + ->filter([Query::lessThan('age', 18)]) + ->sortAsc('id') + ->limit(10) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UPDATE `t` SET', $result->query); + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('ORDER BY `id` ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + // Feature 1: Table Aliases + + public function testTableAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'u.email']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT `u`.`name`, `u`.`email` FROM `users` AS `u`', $result->query); + } + + public function testJoinAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `users` AS `u`', $result->query); + $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); + } + + public function testLeftJoinAlias(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); + } + + public function testRightJoinAlias(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); + } + + public function testCrossJoinAlias(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('colors', 'c') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `colors` AS `c`', $result->query); + } + + // Feature 2: Subqueries + + public function testFilterWhereIn(): void + { + $sub = (new Builder())->from('orders')->select(['user_id'])->filter([Query::greaterThan('total', 100)]); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', + $result->query + ); + $this->assertEquals([100], $result->bindings); + } + + public function testFilterWhereNotIn(): void + { + $sub = (new Builder())->from('blacklist')->select(['user_id']); + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`id` NOT IN (SELECT `user_id` FROM `blacklist`)', $result->query); + } + + public function testSelectSub(): void + { + $sub = (new Builder())->from('orders')->count('*', 'cnt')->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->selectSub($sub, 'order_count') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`name`', $result->query); + $this->assertStringContainsString('(SELECT COUNT(*) AS `cnt` FROM `orders`', $result->query); + $this->assertStringContainsString(') AS `order_count`', $result->query); + } + + public function testFromSub(): void + { + $sub = (new Builder())->from('orders')->select(['user_id'])->groupBy(['user_id']); + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT `user_id` FROM (SELECT `user_id` FROM `orders` GROUP BY `user_id`) AS `sub`', + $result->query + ); + } + + // Feature 3: Raw ORDER BY / GROUP BY / HAVING + + public function testOrderByRaw(): void + { + $result = (new Builder()) + ->from('users') + ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); + } + + public function testGroupByRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupByRaw('YEAR(`created_at`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY YEAR(`created_at`)', $result->query); + } + + public function testHavingRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('COUNT(*) > ?', [5]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING COUNT(*) > ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + // Feature 4: countDistinct + + public function testCountDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `orders`', + $result->query + ); + } + + public function testCountDistinctNoAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT `user_id`) FROM `orders`', + $result->query + ); + } + + // Feature 5: JoinBuilder (complex JOIN ON) + + public function testJoinWhere(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'active'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testJoinWhereMultipleOns(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->on('users.org_id', 'orders.org_id'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND `users`.`org_id` = `orders`.`org_id`', $result->query); + } + + public function testJoinWhereLeftJoin(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id'); + }, JoinType::Left) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + } + + public function testJoinWhereWithAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('u.id', 'o.user_id'); + }, JoinType::Inner, 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `users` AS `u`', $result->query); + $this->assertStringContainsString('JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); + } + + // Feature 6: EXISTS Subquery + + public function testFilterExists(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXISTS (SELECT `id` FROM `orders`', $result->query); + } + + public function testFilterNotExists(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT EXISTS (SELECT `id` FROM `orders`', $result->query); + } + + // Feature 7: insertOrIgnore + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertEquals( + 'INSERT IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); + } + + // Feature 9: EXPLAIN + + public function testExplain(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertStringContainsString('FROM `users`', $result->query); + } + + public function testExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + // Feature 10: Locking Variants + + public function testForUpdateSkipLocked(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testForUpdateNoWait(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); + } + + public function testForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('users') + ->forShareSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); + } + + public function testForShareNoWait(): void + { + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); + } + + // Reset clears new properties + + public function testResetClearsNewProperties(): void + { + $builder = new Builder(); + $sub = (new Builder())->from('t')->select(['id']); + + $builder->from('users', 'u') + ->filterWhereIn('id', $sub) + ->selectSub($sub, 'cnt') + ->orderByRaw('RAND()') + ->groupByRaw('YEAR(created_at)') + ->havingRaw('COUNT(*) > 1') + ->countDistinct('id') + ->filterExists($sub) + ->reset(); + + // After reset, building without setting table should throw + $this->expectException(ValidationException::class); + $builder->build(); + } + + // Case Builder — unit-level tests + + public function testCaseBuilderEmptyWhenThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least one WHEN'); + + $case = new CaseBuilder(); + $case->build(); + } + + public function testCaseBuilderMultipleWhens(): void + { + $case = (new CaseBuilder()) + ->when('`status` = ?', '?', ['active'], ['Active']) + ->when('`status` = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('`label`') + ->build(); + + $this->assertEquals( + 'CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label`', + $case->sql + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $case->bindings); + } + + public function testCaseBuilderWithoutElseClause(): void + { + $case = (new CaseBuilder()) + ->when('`x` > ?', '1', [10]) + ->build(); + + $this->assertEquals('CASE WHEN `x` > ? THEN 1 END', $case->sql); + $this->assertEquals([10], $case->bindings); + } + + public function testCaseBuilderWithoutAliasClause(): void + { + $case = (new CaseBuilder()) + ->when('1=1', '?', [], ['yes']) + ->build(); + + $this->assertStringNotContainsString(' AS ', $case->sql); + } + + public function testCaseExpressionToSqlOutput(): void + { + $expr = new Expression('CASE WHEN 1 THEN 2 END', []); + $this->assertEquals('CASE WHEN 1 THEN 2 END', $expr->sql); + $this->assertEquals([], $expr->bindings); + } + + // JoinBuilder — unit-level tests + + public function testJoinBuilderOnReturnsConditions(): void + { + $jb = new JoinBuilder(); + $jb->on('a.id', 'b.a_id') + ->on('a.tenant', 'b.tenant', '='); + + $ons = $jb->ons; + $this->assertCount(2, $ons); + $this->assertEquals('a.id', $ons[0]->left); + $this->assertEquals('b.a_id', $ons[0]->right); + $this->assertEquals('=', $ons[0]->operator); + } + + public function testJoinBuilderWhereAddsCondition(): void + { + $jb = new JoinBuilder(); + $jb->where('status', '=', 'active'); + + $wheres = $jb->wheres; + $this->assertCount(1, $wheres); + $this->assertEquals('status = ?', $wheres[0]->expression); + $this->assertEquals(['active'], $wheres[0]->bindings); + } + + public function testJoinBuilderOnRaw(): void + { + $jb = new JoinBuilder(); + $jb->onRaw('a.created_at > NOW() - INTERVAL ? DAY', [30]); + + $wheres = $jb->wheres; + $this->assertCount(1, $wheres); + $this->assertEquals([30], $wheres[0]->bindings); + } + + public function testJoinBuilderWhereRaw(): void + { + $jb = new JoinBuilder(); + $jb->whereRaw('`deleted_at` IS NULL'); + + $wheres = $jb->wheres; + $this->assertCount(1, $wheres); + $this->assertEquals('`deleted_at` IS NULL', $wheres[0]->expression); + $this->assertEquals([], $wheres[0]->bindings); + } + + public function testJoinBuilderCombinedOnAndWhere(): void + { + $jb = new JoinBuilder(); + $jb->on('a.id', 'b.a_id') + ->where('b.active', '=', true) + ->onRaw('b.score > ?', [50]); + + $this->assertCount(1, $jb->ons); + $this->assertCount(2, $jb->wheres); + } + + // Subquery binding order + + public function testSubqueryBindingOrderIsCorrect(): void + { + $sub = (new Builder())->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('role', ['admin'])]) + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + // Main filter bindings come before subquery bindings + $this->assertEquals(['admin', 'completed'], $result->bindings); + } + + public function testSelectSubBindingOrder(): void + { + $sub = (new Builder())->from('orders') + ->selectRaw('COUNT(*)') + ->filter([Query::equal('orders.user_id', ['matched'])]); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'order_count') + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + // Sub-select bindings come before main WHERE bindings + $this->assertEquals(['matched', true], $result->bindings); + } + + public function testFromSubBindingOrder(): void + { + $sub = (new Builder())->from('orders') + ->filter([Query::greaterThan('amount', 100)]); + + $result = (new Builder()) + ->fromSub($sub, 'expensive') + ->filter([Query::equal('status', ['shipped'])]) + ->build(); + $this->assertBindingCount($result); + + // FROM sub bindings come before main WHERE bindings + $this->assertEquals([100, 'shipped'], $result->bindings); + } + + // EXISTS with bindings + + public function testFilterExistsBindings(): void + { + $sub = (new Builder())->from('orders') + ->select(['id']) + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXISTS (SELECT', $result->query); + $this->assertEquals([true, 'paid'], $result->bindings); + } + + public function testFilterNotExistsQuery(): void + { + $sub = (new Builder())->from('bans')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // Combined features + + public function testExplainWithFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertEquals([true], $result->bindings); + } + + public function testExplainAnalyzeWithFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + $this->assertEquals([true], $result->bindings); + } + + public function testTableAliasClearsOnNewFrom(): void + { + $builder = (new Builder()) + ->from('users', 'u'); + + // Reset with new from() should clear alias + $result = $builder->from('orders')->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `orders`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testFromSubClearsTable(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $builder = (new Builder()) + ->from('users') + ->fromSub($sub, 'sub'); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('`users`', $result->query); + $this->assertStringContainsString('AS `sub`', $result->query); + } + + public function testFromClearsFromSub(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $builder = (new Builder()) + ->fromSub($sub, 'sub') + ->from('users'); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM `users`', $result->query); + $this->assertStringNotContainsString('sub', $result->query); + } + + // Raw clauses with bindings + + public function testOrderByRawWithBindings(): void + { + $result = (new Builder()) + ->from('users') + ->orderByRaw('FIELD(`status`, ?, ?, ?)', ['active', 'pending', 'inactive']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertEquals(['active', 'pending', 'inactive'], $result->bindings); + } + + public function testGroupByRawWithBindings(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('DATE_FORMAT(`created_at`, ?)', ['%Y-%m']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("GROUP BY DATE_FORMAT(`created_at`, ?)", $result->query); + $this->assertEquals(['%Y-%m'], $result->bindings); + } + + public function testHavingRawWithBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('SUM(`amount`) > ?', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING SUM(`amount`) > ?', $result->query); + $this->assertEquals([1000], $result->bindings); + } + + public function testMultipleRawOrdersCombined(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('name') + ->orderByRaw('FIELD(`role`, ?)', ['admin']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY `name` ASC, FIELD(`role`, ?)', $result->query); + } + + public function testMultipleRawGroupsCombined(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->groupByRaw('YEAR(`created_at`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY `type`, YEAR(`created_at`)', $result->query); + } + + // countDistinct with alias and without + + public function testCountDistinctWithoutAlias(): void + { + $result = (new Builder()) + ->from('users') + ->countDistinct('email') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(DISTINCT `email`)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + // Join alias with various join types + + public function testLeftJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `orders` AS `o`', $result->query); + } + + public function testRightJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->rightJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN `orders` AS `o`', $result->query); + } + + public function testCrossJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `roles` AS `r`', $result->query); + } + + // JoinWhere with LEFT JOIN + + public function testJoinWhereWithLeftJoinType(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'active'); + }, JoinType::Left) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `orders` ON', $result->query); + $this->assertStringContainsString('orders.status = ?', $result->query); + $this->assertEquals(['active'], $result->bindings); + } + + public function testJoinWhereWithTableAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('u.id', 'o.user_id'); + }, JoinType::Inner, 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders` AS `o`', $result->query); + } + + public function testJoinWhereWithMultipleOnConditions(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->on('users.tenant_id', 'orders.tenant_id'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'ON `users`.`id` = `orders`.`user_id` AND `users`.`tenant_id` = `orders`.`tenant_id`', + $result->query + ); + } + + // WHERE IN subquery combined with regular filters + + public function testWhereInSubqueryWithRegularFilters(): void + { + $sub = (new Builder())->from('vip_users')->select(['id']); + + $result = (new Builder()) + ->from('orders') + ->filter([ + Query::greaterThan('amount', 100), + Query::equal('status', ['paid']), + ]) + ->filterWhereIn('user_id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`amount` > ?', $result->query); + $this->assertStringContainsString('`status` IN (?)', $result->query); + $this->assertStringContainsString('`user_id` IN (SELECT', $result->query); + } + + // Multiple subqueries + + public function testMultipleWhereInSubqueries(): void + { + $sub1 = (new Builder())->from('admins')->select(['id']); + $sub2 = (new Builder())->from('departments')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub1) + ->filterWhereNotIn('dept_id', $sub2) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`id` IN (SELECT', $result->query); + $this->assertStringContainsString('`dept_id` NOT IN (SELECT', $result->query); + } + + // insertOrIgnore + + public function testInsertOrIgnoreMySQL(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertStringStartsWith('INSERT IGNORE INTO', $result->query); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); + } + + // toRawSql with various types + + public function testToRawSqlWithMixedTypes(): void + { + $sql = (new Builder()) + ->from('users') + ->filter([ + Query::equal('name', ['O\'Brien']), + Query::equal('active', [true]), + Query::equal('age', [25]), + ]) + ->toRawSql(); + + $this->assertStringContainsString("'O''Brien'", $sql); + $this->assertStringContainsString('1', $sql); + $this->assertStringContainsString('25', $sql); + } + + // page() helper + + public function testPageFirstPageOffsetZero(): void + { + $result = (new Builder()) + ->from('users') + ->page(1, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertContains(10, $result->bindings); + $this->assertContains(0, $result->bindings); + } + + public function testPageThirdPage(): void + { + $result = (new Builder()) + ->from('users') + ->page(3, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertContains(25, $result->bindings); + $this->assertContains(50, $result->bindings); + } + + // when() conditional + + public function testWhenTrueAppliesCallback(): void + { + $result = (new Builder()) + ->from('users') + ->when(true, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testWhenFalseSkipsCallback(): void + { + $result = (new Builder()) + ->from('users') + ->when(false, fn (Builder $b) => $b->filter([Query::equal('active', [true])])) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('WHERE', $result->query); + } + + // Locking combined with query + + public function testLockingAppearsAtEnd(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->limit(1) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringEndsWith('FOR UPDATE', $result->query); + } + + // CTE with main query bindings + + public function testCteBindingOrder(): void + { + $cte = (new Builder())->from('orders') + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->filter([Query::greaterThan('amount', 100)]) + ->build(); + $this->assertBindingCount($result); + + // CTE bindings come first + $this->assertEquals(['paid', 100], $result->bindings); + } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, `email` FROM `users` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['active', 10], $result->bindings); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::greaterThan('price', 10), + Query::lessThanEqual('price', 500), + Query::equal('category', ['electronics']), + Query::startsWith('name', 'Pro'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, `price` FROM `products` WHERE `price` > ? AND `price` <= ? AND `category` IN (?) AND `name` LIKE ?', + $result->query + ); + $this->assertEquals([10, 500, 'electronics', 'Pro%'], $result->bindings); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name', 'products.title']) + ->join('users', 'orders.user_id', 'users.id') + ->leftJoin('products', 'orders.product_id', 'products.id') + ->rightJoin('categories', 'products.category_id', 'categories.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `orders`.`id`, `users`.`name`, `products`.`title` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `products` ON `orders`.`product_id` = `products`.`id` RIGHT JOIN `categories` ON `products`.`category_id` = `categories`.`id`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactCrossJoin(): void + { + $result = (new Builder()) + ->from('sizes') + ->select(['sizes.label', 'colors.name']) + ->crossJoin('colors') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `sizes`.`label`, `colors`.`name` FROM `sizes` CROSS JOIN `colors`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->set(['name' => 'Charlie', 'email' => 'charlie@test.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com', 'Bob', 'bob@test.com', 'Charlie', 'charlie@test.com'], $result->bindings); + } + + public function testExactUpdateWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::lessThan('last_login', '2023-06-01')]) + ->sortAsc('last_login') + ->limit(50) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `last_login` < ? ORDER BY `last_login` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['archived', '2023-06-01', 50], $result->bindings); + } + + public function testExactDeleteWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::lessThan('created_at', '2023-01-01')]) + ->sortAsc('created_at') + ->limit(500) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['2023-01-01', 500], $result->bindings); + } + + public function testExactUpsertOnDuplicateKey(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@new.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@new.com'], $result->bindings); + } + + public function testExactSubqueryWhereIn(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 1000)]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', + $result->query + ); + $this->assertEquals([1000], $result->bindings); + } + + public function testExactExistsSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE EXISTS (SELECT `id` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactCte(): void + { + $cte = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::equal('status', ['paid'])]); + + $result = (new Builder()) + ->with('paid_orders', $cte) + ->from('paid_orders') + ->select(['user_id']) + ->sum('total', 'total_spent') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'WITH `paid_orders` AS (SELECT `user_id`, `total` FROM `orders` WHERE `status` IN (?)) SELECT SUM(`total`) AS `total_spent`, `user_id` FROM `paid_orders` GROUP BY `user_id`', + $result->query + ); + $this->assertEquals(['paid'], $result->bindings); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['inactive'], ['Inactive']) + ->elseResult('?', ['Unknown']) + ->alias('status_label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name`, CASE WHEN status = ? THEN ? WHEN status = ? THEN ? ELSE ? END AS status_label FROM `users`', + $result->query + ); + $this->assertEquals(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->sum('total', 'total_spent') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `order_count`, SUM(`total`) AS `total_spent`, `user_id` FROM `orders` GROUP BY `user_id` HAVING `order_count` > ?', + $result->query + ); + $this->assertEquals([5], $result->bindings); + } + + public function testExactUnion(): void + { + $admins = (new Builder()) + ->from('admins') + ->select(['id', 'name']) + ->filter([Query::equal('role', ['admin'])]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->union($admins) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `name` FROM `users` WHERE `status` IN (?)) UNION (SELECT `id`, `name` FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertEquals(['active', 'admin'], $result->bindings); + } + + public function testExactUnionAll(): void + { + $archive = (new Builder()) + ->from('orders_archive') + ->select(['id', 'total', 'created_at']); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total', 'created_at']) + ->unionAll($archive) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `total`, `created_at` FROM `orders`) UNION ALL (SELECT `id`, `total`, `created_at` FROM `orders_archive`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id', 'total']) + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['total']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `customer_id`, `total`, ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `total` ASC) AS `rn` FROM `orders`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactForUpdate(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [42])]) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR UPDATE', + $result->query + ); + $this->assertEquals([42], $result->bindings); + } + + public function testExactForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('inventory') + ->select(['id', 'quantity']) + ->filter([Query::greaterThan('quantity', 0)]) + ->limit(5) + ->forShareSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `quantity` FROM `inventory` WHERE `quantity` > ? LIMIT ? FOR SHARE SKIP LOCKED', + $result->query + ); + $this->assertEquals([0, 5], $result->bindings); + } + + public function testExactHintMaxExecutionTime(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ MAX_EXECUTION_TIME(5000) */ `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactRawExpressions(): void + { + $result = (new Builder()) + ->from('users') + ->selectRaw('COUNT(*) AS `total`') + ->selectRaw('MAX(`created_at`) AS `latest`') + ->filter([Query::equal('active', [true])]) + ->orderByRaw('FIELD(`role`, ?, ?, ?)', ['admin', 'editor', 'viewer']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `total`, MAX(`created_at`) AS `latest` FROM `users` WHERE `active` IN (?) ORDER BY FIELD(`role`, ?, ?, ?)', + $result->query + ); + $this->assertEquals([true, 'admin', 'editor', 'viewer'], $result->bindings); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::equal('active', [true]), + Query::or([ + Query::equal('role', ['admin']), + Query::greaterThan('karma', 100), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE (`active` IN (?) AND (`role` IN (?) OR `karma` > ?))', + $result->query + ); + $this->assertEquals([true, 'admin', 100], $result->bindings); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('tags') + ->distinct() + ->select(['name']) + ->limit(20) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT DISTINCT `name` FROM `tags` LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 10], $result->bindings); + } + + public function testExactInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('tags') + ->set(['name' => 'php', 'slug' => 'php']) + ->set(['name' => 'mysql', 'slug' => 'mysql']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT IGNORE INTO `tags` (`name`, `slug`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['php', 'php', 'mysql', 'mysql'], $result->bindings); + } + + public function testExactFromSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('total', 'user_total') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id', 'user_total']) + ->filter([Query::greaterThan('user_total', 500)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `user_id`, `user_total` FROM (SELECT SUM(`total`) AS `user_total`, `user_id` FROM `orders` GROUP BY `user_id`) AS `sub` WHERE `user_total` > ?', + $result->query + ); + $this->assertEquals([500], $result->bindings); + } + + public function testExactSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->selectRaw('COUNT(*)') + ->filter([Query::raw('`orders`.`user_id` = `users`.`id`')]); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'order_count') + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `name`, (SELECT COUNT(*) FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `order_count` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedWhenSequence(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('role', ['admin'])]); + }) + ->when(true, function (Builder $b) { + $b->filter([Query::greaterThan('age', 18)]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ?', + $result->query + ); + $this->assertEquals(['active', 18], $result->bindings); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + $this->assertBindingCount($result); + + $this->assertSame( + 'EXPLAIN SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(true); + $this->assertBindingCount($result); + + $this->assertSame( + 'EXPLAIN ANALYZE SELECT `id`, `name` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testExactAdvancedCursorAfter(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortAsc('name') + ->cursorAfter('abc123') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `_cursor` > ? ORDER BY `name` ASC', + $result->query + ); + $this->assertEquals(['abc123'], $result->bindings); + } + + public function testExactAdvancedCursorBefore(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortDesc('name') + ->cursorBefore('xyz789') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `_cursor` < ? ORDER BY `name` DESC', + $result->query + ); + $this->assertEquals(['xyz789'], $result->bindings); + } + + public function testExactAdvancedTransactionBegin(): void + { + $result = (new Builder())->begin(); + $this->assertBindingCount($result); + + $this->assertSame('BEGIN', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedTransactionCommit(): void + { + $result = (new Builder())->commit(); + $this->assertBindingCount($result); + + $this->assertSame('COMMIT', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedTransactionRollback(): void + { + $result = (new Builder())->rollback(); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedSavepoint(): void + { + $result = (new Builder())->savepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedReleaseSavepoint(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedRollbackToSavepoint(): void + { + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::equal('status', ['paid'])]); + + $cteB = (new Builder()) + ->from('returns') + ->select(['user_id', 'amount']) + ->filter([Query::equal('status', ['approved'])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['user_id']) + ->sum('total', 'total_paid') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'WITH `a` AS (SELECT `user_id`, `total` FROM `orders` WHERE `status` IN (?)), `b` AS (SELECT `user_id`, `amount` FROM `returns` WHERE `status` IN (?)) SELECT SUM(`total`) AS `total_paid`, `user_id` FROM `a` GROUP BY `user_id`', + $result->query + ); + $this->assertEquals(['paid', 'approved'], $result->bindings); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'department', 'salary']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['salary']) + ->selectWindow('RANK()', 'salary_rank', ['department'], ['-salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` ASC) AS `row_num`, RANK() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `salary_rank` FROM `employees`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $archive = (new Builder()) + ->from('orders_archive') + ->select(['id', 'total', 'created_at']); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total', 'created_at']) + ->sortDesc('created_at') + ->limit(10) + ->unionAll($archive) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(SELECT `id`, `total`, `created_at` FROM `orders` ORDER BY `created_at` DESC LIMIT ?) UNION ALL (SELECT `id`, `total`, `created_at` FROM `orders_archive`)', + $result->query + ); + $this->assertEquals([10], $result->bindings); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::equal('category', ['electronics']), + Query::or([ + Query::greaterThan('price', 100), + Query::and([ + Query::equal('brand', ['acme']), + Query::lessThan('stock', 50), + ]), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `products` WHERE (`category` IN (?) AND (`price` > ? OR (`brand` IN (?) AND `stock` < ?)))', + $result->query + ); + $this->assertEquals(['electronics', 100, 'acme', 50], $result->bindings); + } + + public function testExactAdvancedForUpdateNoWait(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [1])]) + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR UPDATE NOWAIT', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testExactAdvancedForShareNoWait(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [1])]) + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `balance` FROM `accounts` WHERE `id` IN (?) FOR SHARE NOWAIT', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testExactAdvancedConflictSetRaw(): void + { + $result = (new Builder()) + ->from('counters') + ->set(['id' => 1, 'count' => 1, 'updated_at' => '2024-01-01']) + ->onConflict(['id'], ['count', 'updated_at']) + ->conflictSetRaw('count', '`count` + VALUES(`count`)') + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `counters` (`id`, `count`, `updated_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `count` = `count` + VALUES(`count`), `updated_at` = VALUES(`updated_at`)', + $result->query + ); + $this->assertEquals([1, 1, '2024-01-01'], $result->bindings); + } + + public function testExactAdvancedSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('products') + ->setRaw('price', '`price` * ?', [1.1]) + ->filter([Query::equal('category', ['electronics'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `products` SET `price` = `price` * ? WHERE `category` IN (?)', + $result->query + ); + $this->assertEquals([1.1, 'electronics'], $result->bindings); + } + + public function testExactAdvancedSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('`category` = ?', '`price` * ?', ['electronics'], [1.2]) + ->when('`category` = ?', '`price` * ?', ['clothing'], [0.8]) + ->elseResult('`price`') + ->build(); + + $result = (new Builder()) + ->from('products') + ->setCase('price', $case) + ->filter([Query::greaterThan('stock', 0)]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `products` SET `price` = CASE WHEN `category` = ? THEN `price` * ? WHEN `category` = ? THEN `price` * ? ELSE `price` END WHERE `stock` > ?', + $result->query + ); + $this->assertEquals(['electronics', 1.2, 'clothing', 0.8, 0], $result->bindings); + } + + public function testExactAdvancedEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users`', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('id', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyAndGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 1', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedEmptyOrGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testExactAdvancedSelectRawWithGroupByRawAndHavingRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->selectRaw('DATE(`created_at`) AS `order_date`') + ->selectRaw('SUM(`total`) AS `daily_total`') + ->groupByRaw('DATE(`created_at`)') + ->havingRaw('SUM(`total`) > ?', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT DATE(`created_at`) AS `order_date`, SUM(`total`) AS `daily_total` FROM `orders` GROUP BY DATE(`created_at`) HAVING SUM(`total`) > ?', + $result->query + ); + $this->assertEquals([1000], $result->bindings); + } + + public function testExactAdvancedMultipleHooks(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->addHook(new Tenant(['tenant_a', 'tenant_b'])) + ->addHook(new Permission( + ['role:member', 'role:admin'], + fn (string $table) => $table . '_permissions', + )) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `title` FROM `documents` WHERE `status` IN (?) AND tenant_id IN (?, ?) AND id IN (SELECT DISTINCT document_id FROM documents_permissions WHERE role IN (?, ?) AND type = ?)', + $result->query + ); + $this->assertEquals(['published', 'tenant_a', 'tenant_b', 'role:member', 'role:admin', 'read'], $result->bindings); + } + + public function testExactAdvancedAttributeMapHook(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'display_name', 'email_address']) + ->filter([Query::equal('display_name', ['Alice'])]) + ->addHook(new AttributeMap([ + 'display_name' => 'full_name', + 'email_address' => 'email', + ])) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `full_name`, `email` FROM `users` WHERE `full_name` IN (?)', + $result->query + ); + $this->assertEquals(['Alice'], $result->bindings); + } + + public function testExactAdvancedResetClearsState(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]); + + $builder->build(); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->select(['id', 'total']) + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `total` FROM `orders` WHERE `total` > ?', + $result->query + ); + $this->assertEquals([100], $result->bindings); + } +} diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php new file mode 100644 index 0000000..1b2449b --- /dev/null +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -0,0 +1,3400 @@ +assertInstanceOf(Compiler::class, new Builder()); + } + + public function testImplementsSelects(): void + { + $this->assertInstanceOf(Selects::class, new Builder()); + } + + public function testImplementsAggregates(): void + { + $this->assertInstanceOf(Aggregates::class, new Builder()); + } + + public function testImplementsJoins(): void + { + $this->assertInstanceOf(Joins::class, new Builder()); + } + + public function testImplementsUnions(): void + { + $this->assertInstanceOf(Unions::class, new Builder()); + } + + public function testImplementsCTEs(): void + { + $this->assertInstanceOf(CTEs::class, new Builder()); + } + + public function testImplementsInserts(): void + { + $this->assertInstanceOf(Inserts::class, new Builder()); + } + + public function testImplementsUpdates(): void + { + $this->assertInstanceOf(Updates::class, new Builder()); + } + + public function testImplementsDeletes(): void + { + $this->assertInstanceOf(Deletes::class, new Builder()); + } + + public function testImplementsHooks(): void + { + $this->assertInstanceOf(Hooks::class, new Builder()); + } + + public function testImplementsTransactions(): void + { + $this->assertInstanceOf(Transactions::class, new Builder()); + } + + public function testImplementsLocking(): void + { + $this->assertInstanceOf(Locking::class, new Builder()); + } + + public function testImplementsUpsert(): void + { + $this->assertInstanceOf(Upsert::class, new Builder()); + } + + public function testSelectWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->select(['a', 'b', 'c']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT "a", "b", "c" FROM "t"', $result->query); + } + + public function testFromWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('my_table') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "my_table"', $result->query); + } + + public function testFilterWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('col', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" WHERE "col" IN (?)', $result->query); + } + + public function testSortWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" ORDER BY "name" ASC, "age" DESC', $result->query); + } + + public function testJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.uid') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."uid"', + $result->query + ); + } + + public function testLeftJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->leftJoin('profiles', 'users.id', 'profiles.uid') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM "users" LEFT JOIN "profiles" ON "users"."id" = "profiles"."uid"', + $result->query + ); + } + + public function testRightJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->rightJoin('orders', 'users.id', 'orders.uid') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT * FROM "users" RIGHT JOIN "orders" ON "users"."id" = "orders"."uid"', + $result->query + ); + } + + public function testCrossJoinWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "a" CROSS JOIN "b"', $result->query); + } + + public function testAggregationWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->sum('price', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT SUM("price") AS "total" FROM "t"', $result->query); + } + + public function testGroupByWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status', 'country']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status", "country"', + $result->query + ); + } + + public function testHavingWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); + } + + public function testDistinctWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT DISTINCT "status" FROM "t"', $result->query); + } + + public function testIsNullWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); + } + + public function testRandomUsesRandomFunction(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" ORDER BY RANDOM()', $result->query); + } + + public function testRegexUsesTildeOperator(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" WHERE "slug" ~ ?', $result->query); + $this->assertEquals(['^test'], $result->bindings); + } + + public function testSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" WHERE to_tsvector("body") @@ plainto_tsquery(?)', $result->query); + $this->assertEquals(['hello'], $result->bindings); + } + + public function testNotSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', 'spam')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" WHERE NOT (to_tsvector("body") @@ plainto_tsquery(?))', $result->query); + $this->assertEquals(['spam'], $result->bindings); + } + + public function testUpsertUsesOnConflict(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@example.com'], $result->bindings); + } + + public function testOffsetWithoutLimitEmitsOffset(): void + { + $result = (new Builder()) + ->from('t') + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" OFFSET ?', $result->query); + $this->assertEquals([10], $result->bindings); + } + + public function testOffsetWithLimitEmitsBoth(): void + { + $result = (new Builder()) + ->from('t') + ->limit(25) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM "t" LIMIT ? OFFSET ?', $result->query); + $this->assertEquals([25, 10], $result->bindings); + } + + public function testConditionProviderWithDoubleQuotes(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('raw_condition = 1', []); + } + }; + + $result = (new Builder()) + ->from('t') + ->addHook($hook) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE raw_condition = 1', $result->query); + $this->assertStringContainsString('FROM "t"', $result->query); + } + + public function testInsertWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'INSERT INTO "users" ("name", "age") VALUES (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 30], $result->bindings); + } + + public function testUpdateWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Bob']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'UPDATE "users" SET "name" = ? WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['Bob', 1], $result->bindings); + } + + public function testDeleteWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'DELETE FROM "users" WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals([1], $result->bindings); + } + + public function testSavepointWrapsWithDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertEquals('SAVEPOINT "sp1"', $result->query); + } + + public function testForUpdateWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE', $result->query); + $this->assertStringContainsString('FROM "t"', $result->query); + } + // Spatial feature interface + + public function testImplementsSpatial(): void + { + $this->assertInstanceOf(Spatial::class, new Builder()); + } + + public function testFilterDistanceMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance(("coords"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?', $result->query); + $this->assertEquals('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertEquals(5000.0, $result->bindings[1]); + } + + public function testFilterIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Intersects("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Covers("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterCrosses(): void + { + $result = (new Builder()) + ->from('roads') + ->filterCrosses('path', [[0, 0], [1, 1]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Crosses', $result->query); + } + // VectorSearch feature interface + + public function testImplementsVectorSearch(): void + { + $this->assertInstanceOf(VectorSearch::class, new Builder()); + } + + public function testOrderByVectorDistanceCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <=> ?::vector) ASC', $result->query); + $this->assertEquals('[0.1,0.2,0.3]', $result->bindings[0]); + } + + public function testOrderByVectorDistanceEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Euclidean) + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <-> ?::vector) ASC', $result->query); + } + + public function testOrderByVectorDistanceDot(): void + { + $result = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Dot) + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <#> ?::vector) ASC', $result->query); + } + + public function testVectorFilterCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); + } + + public function testVectorFilterEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorEuclidean('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); + } + + public function testVectorFilterDot(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorDot('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <#> ?::vector)', $result->query); + } + // JSON feature interface + + public function testImplementsJson(): void + { + $this->assertInstanceOf(Json::class, new Builder()); + } + + public function testFilterJsonContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('tags', 'php') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"tags" @> ?::jsonb', $result->query); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', 'old') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ("tags" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("\"tags\" ?| ARRAY", $result->query); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("\"metadata\"->>'level' > ?", $result->query); + $this->assertEquals(5, $result->bindings[0]); + } + + public function testSetJsonAppend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('|| ?::jsonb', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('?::jsonb ||', $result->query); + } + + public function testSetJsonInsert(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonInsert('tags', 0, 'inserted') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('jsonb_insert', $result->query); + } + // Window functions + + public function testImplementsWindows(): void + { + $this->assertInstanceOf(Windows::class, new Builder()); + } + + public function testSelectWindowRowNumber(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "created_at" ASC) AS "rn"', $result->query); + } + + public function testSelectWindowRankDesc(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RANK() OVER (ORDER BY "score" DESC) AS "rank"', $result->query); + } + // CASE integration + + public function testSelectCaseExpression(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->elseResult('?', ['Other']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CASE WHEN status = ? THEN ? ELSE ? END AS label', $result->query); + $this->assertEquals(['active', 'Active', 'Other'], $result->bindings); + } + // Does NOT implement Hints + + public function testDoesNotImplementHints(): void + { + $builder = new Builder(); + $this->assertNotInstanceOf(Hints::class, $builder); // @phpstan-ignore method.alreadyNarrowedType + } + // Reset clears new state + + public function testResetClearsVectorOrder(): void + { + $builder = (new Builder()) + ->from('embeddings') + ->orderByVectorDistance('embedding', [0.1], VectorMetric::Cosine); + + $builder->reset(); + + $result = $builder->from('embeddings')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('<=>', $result->query); + } + + public function testFilterNotIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotIntersects('zone', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Intersects', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + } + + public function testFilterNotCrossesLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterNotCrosses('path', [[0, 0], [1, 1]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Crosses', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('LINESTRING', $binding); + } + + public function testFilterOverlapsPolygon(): void + { + $result = (new Builder()) + ->from('maps') + ->filterOverlaps('area', [[[0, 0], [1, 0], [1, 1], [0, 0]]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Overlaps', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertStringContainsString('POLYGON', $binding); + } + + public function testFilterNotOverlaps(): void + { + $result = (new Builder()) + ->from('maps') + ->filterNotOverlaps('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Overlaps', $result->query); + } + + public function testFilterTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Touches', $result->query); + } + + public function testFilterNotTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Touches', $result->query); + } + + public function testFilterCoversUsesSTCovers(): void + { + $result = (new Builder()) + ->from('regions') + ->filterCovers('region', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Covers', $result->query); + $this->assertStringNotContainsString('ST_Contains', $result->query); + } + + public function testFilterNotCovers(): void + { + $result = (new Builder()) + ->from('regions') + ->filterNotCovers('region', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Covers', $result->query); + } + + public function testFilterSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Equals', $result->query); + } + + public function testFilterNotSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ST_Equals', $result->query); + } + + public function testFilterDistanceGreaterThan(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('> ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(500.0, $result->bindings[1]); + } + + public function testFilterDistanceWithoutMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '<', 50.0, false) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ST_Distance("loc", ST_GeomFromText(?)) < ?', $result->query); + $this->assertEquals('POINT(1 2)', $result->bindings[0]); + $this->assertEquals(50.0, $result->bindings[1]); + } + + public function testVectorOrderWithExistingOrderBy(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('name') + ->orderByVectorDistance('embedding', [0.1], VectorMetric::Cosine) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY', $result->query); + $pos_vector = strpos($result->query, '<=>'); + $pos_name = strpos($result->query, '"name"'); + $this->assertNotFalse($pos_vector); + $this->assertNotFalse($pos_name); + $this->assertLessThan($pos_name, $pos_vector); + } + + public function testVectorOrderWithLimit(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('emb', [0.1, 0.2], VectorMetric::Cosine) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY', $result->query); + $pos_order = strpos($result->query, 'ORDER BY'); + $pos_limit = strpos($result->query, 'LIMIT'); + $this->assertNotFalse($pos_order); + $this->assertNotFalse($pos_limit); + $this->assertLessThan($pos_limit, $pos_order); + + // Vector JSON binding comes before limit value binding + $vectorIdx = array_search('[0.1,0.2]', $result->bindings, true); + $limitIdx = array_search(10, $result->bindings, true); + $this->assertNotFalse($vectorIdx); + $this->assertNotFalse($limitIdx); + $this->assertLessThan($limitIdx, $vectorIdx); + } + + public function testVectorOrderDefaultMetric(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('emb', [0.5]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('<=>', $result->query); + } + + public function testVectorFilterCosineBindings(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <=> ?::vector)', $result->query); + $this->assertEquals(json_encode([0.1, 0.2]), $result->bindings[0]); + } + + public function testVectorFilterEuclideanBindings(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorEuclidean('embedding', [0.1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("embedding" <-> ?::vector)', $result->query); + $this->assertEquals(json_encode([0.1]), $result->bindings[0]); + } + + public function testFilterJsonNotContainsAdmin(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT ("meta" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlapsArray(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"tags" ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))', $result->query); + } + + public function testFilterJsonPathComparison(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("\"data\"->>'age' >= ?", $result->query); + $this->assertEquals(21, $result->bindings[0]); + } + + public function testFilterJsonPathEquality(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('meta', 'status', '=', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("\"meta\"->>'status' = ?", $result->query); + $this->assertEquals('active', $result->bindings[0]); + } + + public function testSetJsonRemove(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonRemove('tags', 'old') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"tags" - ?', $result->query); + $this->assertContains(json_encode('old'), $result->bindings); + } + + public function testSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('jsonb_agg(elem)', $result->query); + $this->assertStringContainsString('elem <@ ?::jsonb', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT elem <@ ?::jsonb', $result->query); + } + + public function testSetJsonUnique(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('jsonb_agg(DISTINCT elem)', $result->query); + } + + public function testSetJsonAppendBindings(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('|| ?::jsonb', $result->query); + $this->assertContains(json_encode(['new']), $result->bindings); + } + + public function testSetJsonPrependPutsNewArrayFirst(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('?::jsonb || COALESCE(', $result->query); + } + + public function testMultipleCTEs(): void + { + $a = (new Builder())->from('x')->filter([Query::equal('status', ['active'])]); + $b = (new Builder())->from('y')->filter([Query::equal('type', ['premium'])]); + + $result = (new Builder()) + ->with('a', $a) + ->with('b', $b) + ->from('a') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WITH "a" AS (', $result->query); + $this->assertStringContainsString('), "b" AS (', $result->query); + } + + public function testCTEWithRecursive(): void + { + $sub = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WITH RECURSIVE', $result->query); + } + + public function testCTEBindingOrder(): void + { + $cteQuery = (new Builder())->from('orders')->filter([Query::equal('status', ['shipped'])]); + + $result = (new Builder()) + ->with('shipped', $cteQuery) + ->from('shipped') + ->filter([Query::equal('total', [100])]) + ->build(); + $this->assertBindingCount($result); + + // CTE bindings come first + $this->assertEquals('shipped', $result->bindings[0]); + $this->assertEquals(100, $result->bindings[1]); + } + + public function testInsertSelectWithFilter(): void + { + $source = (new Builder()) + ->from('orders') + ->select(['customer_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->into('big_orders') + ->fromSelect(['customer_id', 'total'], $source) + ->insertSelect(); + + $this->assertStringContainsString('INSERT INTO "big_orders"', $result->query); + $this->assertStringContainsString('SELECT', $result->query); + $this->assertContains(100, $result->bindings); + } + + public function testInsertSelectThrowsWithoutSource(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('target') + ->insertSelect(); + } + + public function testUnionAll(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNION ALL', $result->query); + } + + public function testIntersect(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INTERSECT', $result->query); + } + + public function testExcept(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXCEPT', $result->query); + } + + public function testUnionWithBindingsOrder(): void + { + $other = (new Builder())->from('b')->filter([Query::equal('type', ['beta'])]); + + $result = (new Builder()) + ->from('a') + ->filter([Query::equal('type', ['alpha'])]) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('alpha', $result->bindings[0]); + $this->assertEquals('beta', $result->bindings[1]); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('items') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals(10, $result->bindings[0]); + $this->assertEquals(20, $result->bindings[1]); + } + + public function testOffsetWithoutLimitEmitsOffsetPostgres(): void + { + $result = (new Builder()) + ->from('items') + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('OFFSET ?', $result->query); + $this->assertEquals([5], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('id') + ->cursorAfter(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('> ?', $result->query); + $this->assertContains(5, $result->bindings); + $this->assertContains(10, $result->bindings); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('id') + ->cursorBefore(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('< ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testSelectWindowWithPartitionOnly(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('SUM("salary")', 'dept_total', ['dept'], null) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('OVER (PARTITION BY "dept")', $result->query); + } + + public function testSelectWindowNoPartitionNoOrder(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('COUNT(*)', 'total', null, null) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('OVER ()', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['id']) + ->selectWindow('RANK()', 'rnk', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ROW_NUMBER()', $result->query); + $this->assertStringContainsString('RANK()', $result->query); + } + + public function testWindowFunctionWithDescOrder(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rnk', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY "score" DESC', $result->query); + } + + public function testCaseMultipleWhens(): void + { + $case = (new CaseBuilder()) + ->when('status = ?', '?', ['active'], ['Active']) + ->when('status = ?', '?', ['pending'], ['Pending']) + ->when('status = ?', '?', ['closed'], ['Closed']) + ->alias('label') + ->build(); + + $result = (new Builder()) + ->from('tickets') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHEN status = ? THEN ?', $result->query); + $this->assertEquals(['active', 'Active', 'pending', 'Pending', 'closed', 'Closed'], $result->bindings); + } + + public function testCaseWithoutElse(): void + { + $case = (new CaseBuilder()) + ->when('active = ?', '?', [1], ['Yes']) + ->alias('lbl') + ->build(); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CASE WHEN active = ? THEN ? END AS lbl', $result->query); + $this->assertStringNotContainsString('ELSE', $result->query); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseBuilder()) + ->when('age >= ?', '?', [18], ['adult']) + ->elseResult('?', ['minor']) + ->build(); + + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UPDATE "users" SET', $result->query); + $this->assertStringContainsString('CASE WHEN age >= ? THEN ? ELSE ? END', $result->query); + $this->assertEquals([18, 'adult', 'minor', 1], $result->bindings); + } + + public function testToRawSqlWithStrings(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Alice'])]) + ->toRawSql(); + + $this->assertStringContainsString("'Alice'", $raw); + $this->assertStringNotContainsString('?', $raw); + } + + public function testToRawSqlEscapesSingleQuotes(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertStringContainsString("'O''Brien'", $raw); + } + + public function testBuildWithoutTableThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->build(); + } + + public function testInsertWithoutRowsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('users')->insert(); + } + + public function testUpdateWithoutAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('users')->filter([Query::equal('id', [1])])->update(); + } + + public function testUpsertWithoutConflictKeysThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->upsert(); + } + + public function testBatchInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('VALUES (?, ?), (?, ?)', $result->query); + $this->assertEquals(['Alice', 30, 'Bob', 25], $result->bindings); + } + + public function testBatchInsertMismatchedColumnsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->insert(); + } + + public function testRegexUsesTildeWithCaretPattern(): void + { + $result = (new Builder()) + ->from('items') + ->filter([Query::regex('s', '^t')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"s" ~ ?', $result->query); + $this->assertEquals(['^t'], $result->bindings); + } + + public function testSearchUsesToTsvectorWithMultipleWords(): void + { + $result = (new Builder()) + ->from('articles') + ->filter([Query::search('body', 'hello world')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('to_tsvector("body") @@ plainto_tsquery(?)', $result->query); + $this->assertEquals(['hello world'], $result->bindings); + } + + public function testUpsertUsesOnConflictDoUpdateSet(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ON CONFLICT ("id") DO UPDATE SET', $result->query); + } + + public function testUpsertConflictUpdateColumnNotInRowThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['nonexistent']) + ->upsert(); + } + + public function testForUpdateLocking(): void + { + $result = (new Builder()) + ->from('accounts') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE', $result->query); + } + + public function testForShareLocking(): void + { + $result = (new Builder()) + ->from('accounts') + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE', $result->query); + } + + public function testBeginCommitRollback(): void + { + $builder = new Builder(); + + $begin = $builder->begin(); + $this->assertEquals('BEGIN', $begin->query); + + $commit = $builder->commit(); + $this->assertEquals('COMMIT', $commit->query); + + $rollback = $builder->rollback(); + $this->assertEquals('ROLLBACK', $rollback->query); + } + + public function testSavepointDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertEquals('SAVEPOINT "sp1"', $result->query); + } + + public function testReleaseSavepointDoubleQuotes(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + + $this->assertEquals('RELEASE SAVEPOINT "sp1"', $result->query); + } + + public function testGroupByWithHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['customer_id']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY "customer_id"', $result->query); + $this->assertStringContainsString('HAVING "cnt" > ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testGroupByMultipleColumns(): void + { + $result = (new Builder()) + ->from('sales') + ->count('*', 'cnt') + ->groupBy(['a', 'b']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY "a", "b"', $result->query); + } + + public function testWhenTrue(): void + { + $result = (new Builder()) + ->from('items') + ->when(true, fn (Builder $b) => $b->limit(5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LIMIT ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testWhenFalse(): void + { + $result = (new Builder()) + ->from('items') + ->when(false, fn (Builder $b) => $b->limit(5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('LIMIT', $result->query); + } + + public function testResetClearsCTEs(): void + { + $sub = (new Builder())->from('orders'); + + $builder = (new Builder()) + ->with('cte', $sub) + ->from('cte'); + + $builder->reset(); + + $result = $builder->from('items')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testResetClearsJsonSets(): void + { + $builder = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']); + + $builder->reset(); + + $result = $builder + ->from('docs') + ->set(['name' => 'test']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('jsonb', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"x" IS NULL', $result->query); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("x" IN (?) OR "x" IS NULL)', $result->query); + $this->assertContains(1, $result->bindings); + } + + public function testNotEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("x" != ? AND "x" IS NOT NULL)', $result->query); + } + + public function testAndWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([Query::greaterThan('age', 18), Query::lessThan('age', 65)])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("age" > ? AND "age" < ?)', $result->query); + } + + public function testOrWithTwoFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([Query::equal('role', ['admin']), Query::equal('role', ['editor'])])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("role" IN (?) OR "role" IN (?))', $result->query); + } + + public function testEmptyAndReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testEmptyOrReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 0', $result->query); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"age" BETWEEN ? AND ?', $result->query); + $this->assertEquals([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"score" NOT BETWEEN ? AND ?', $result->query); + $this->assertEquals([0, 50], $result->bindings); + } + + public function testExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("name" IS NOT NULL)', $result->query); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("name" IS NOT NULL AND "email" IS NOT NULL)', $result->query); + } + + public function testNotExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("name" IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('score > ?', $result->query); + $this->assertContains(10, $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('1 = 1', $result->query); + } + + public function testStartsWithEscapesPercent(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('val', '100%')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"val" LIKE ?', $result->query); + $this->assertEquals(['100\%%'], $result->bindings); + } + + public function testEndsWithEscapesUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('val', 'a_b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"val" LIKE ?', $result->query); + $this->assertEquals(['%a\_b'], $result->bindings); + } + + public function testContainsEscapesBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('path', ['a\\b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"path" LIKE ?', $result->query); + $this->assertEquals(['%a\\\\b%'], $result->bindings); + } + + public function testContainsMultipleUsesOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::contains('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("bio" LIKE ? OR "bio" LIKE ?)', $result->query); + } + + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("bio" LIKE ? AND "bio" LIKE ?)', $result->query); + } + + public function testNotContainsMultipleUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('("bio" NOT LIKE ? AND "bio" NOT LIKE ?)', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['users.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"users"."name"', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY "name" ASC, "age" DESC', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SELECT DISTINCT "name"', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*) AS "cnt"', $result->query); + $this->assertStringContainsString('SUM("amount") AS "total"', $result->query); + } + + public function testCountWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(*)', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RIGHT JOIN "b" ON "a"."id" = "b"."a_id"', $result->query); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('a') + ->crossJoin('b') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN "b"', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testJoinInvalidOperatorThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('a') + ->join('b', 'a.id', 'b.a_id', 'INVALID') + ->build(); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"deleted_at" IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"name" IS NOT NULL', $result->query); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"age" < ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"age" <= ?', $result->query); + $this->assertEquals([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"score" > ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"score" >= ?', $result->query); + $this->assertEquals([50], $result->bindings); + } + + public function testDeleteWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['old'])]) + ->sortAsc('id') + ->limit(100) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DELETE FROM "t"', $result->query); + $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testUpdateWithOrderAndLimit(): void + { + $result = (new Builder()) + ->from('t') + ->set(['status' => 'archived']) + ->filter([Query::equal('active', [false])]) + ->sortAsc('id') + ->limit(50) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UPDATE "t" SET', $result->query); + $this->assertStringContainsString('ORDER BY "id" ASC', $result->query); + $this->assertStringContainsString('LIMIT ?', $result->query); + } + + public function testVectorOrderBindingOrderWithFiltersAndLimit(): void + { + $result = (new Builder()) + ->from('items') + ->filter([Query::equal('status', ['active'])]) + ->orderByVectorDistance('embedding', [0.1, 0.2], VectorMetric::Cosine) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + // Bindings should be: filter bindings, then vector json, then limit value + $this->assertEquals('active', $result->bindings[0]); + $vectorJson = '[0.1,0.2]'; + $vectorIdx = array_search($vectorJson, $result->bindings, true); + $limitIdx = array_search(10, $result->bindings, true); + $this->assertNotFalse($vectorIdx); + $this->assertNotFalse($limitIdx); + $this->assertLessThan($limitIdx, $vectorIdx); + } + + // Feature 7: insertOrIgnore (PostgreSQL) + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertEquals( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertEquals(['John', 'john@example.com'], $result->bindings); + } + + // Feature 8: RETURNING clause + + public function testInsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id', 'name']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING "id", "name"', $result->query); + } + + public function testInsertReturningAll(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning() + ->insert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING *', $result->query); + } + + public function testUpdateReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [1])]) + ->returning(['id', 'name']) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING "id", "name"', $result->query); + } + + public function testDeleteReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING "id"', $result->query); + } + + public function testUpsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'John', 'email' => 'john@example.com']) + ->onConflict(['id'], ['name', 'email']) + ->returning(['id']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING "id"', $result->query); + } + + public function testInsertOrIgnoreReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id']) + ->insertOrIgnore(); + + $this->assertStringContainsString('ON CONFLICT DO NOTHING RETURNING "id"', $result->query); + } + + // Feature 10: LockingOf (PostgreSQL only) + + public function testForUpdateOf(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateOf('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); + } + + public function testForShareOf(): void + { + $result = (new Builder()) + ->from('users') + ->forShareOf('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE OF "users"', $result->query); + } + + // Feature 1: Table Aliases (PostgreSQL quotes) + + public function testTableAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM "users" AS "u"', $result->query); + } + + public function testJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->join('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN "orders" AS "o" ON "u"."id" = "o"."user_id"', $result->query); + } + + // Feature 2: Subqueries (PostgreSQL) + + public function testFromSubPostgreSQL(): void + { + $sub = (new Builder())->from('orders')->select(['user_id'])->groupBy(['user_id']); + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT "user_id" FROM (SELECT "user_id" FROM "orders" GROUP BY "user_id") AS "sub"', + $result->query + ); + } + + // Feature 4: countDistinct (PostgreSQL) + + public function testCountDistinctPostgreSQL(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals( + 'SELECT COUNT(DISTINCT "user_id") AS "unique_users" FROM "orders"', + $result->query + ); + } + + // Feature 9: EXPLAIN (PostgreSQL) + + public function testExplainPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + } + + public function testExplainAnalyzePostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + // Feature 10: Locking Variants (PostgreSQL) + + public function testForUpdateSkipLockedPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testForUpdateNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR UPDATE NOWAIT', $result->query); + } + + // Subquery bindings (PostgreSQL) + + public function testSubqueryBindingOrderPostgreSQL(): void + { + $sub = (new Builder())->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('role', ['admin'])]) + ->filterWhereIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals(['admin', 'completed'], $result->bindings); + } + + public function testFilterNotExistsPostgreSQL(): void + { + $sub = (new Builder())->from('bans')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // Raw clauses (PostgreSQL) + + public function testOrderByRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->orderByRaw('NULLS LAST') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY NULLS LAST', $result->query); + } + + public function testGroupByRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupByRaw('date_trunc(?, "created_at")', ['month']) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GROUP BY date_trunc(?, "created_at")', $result->query); + $this->assertEquals(['month'], $result->bindings); + } + + public function testHavingRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('SUM("amount") > ?', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('HAVING SUM("amount") > ?', $result->query); + } + + // JoinWhere (PostgreSQL) + + public function testJoinWherePostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.amount', '>', 100); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN "orders" ON "users"."id" = "orders"."user_id"', $result->query); + $this->assertStringContainsString('orders.amount > ?', $result->query); + $this->assertEquals([100], $result->bindings); + } + + // Insert or ignore (PostgreSQL) + + public function testInsertOrIgnorePostgreSQL(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->insertOrIgnore(); + + $this->assertStringContainsString('INSERT INTO', $result->query); + $this->assertStringContainsString('ON CONFLICT DO NOTHING', $result->query); + } + + // RETURNING with specific columns + + public function testReturningSpecificColumns(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id', 'created_at']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RETURNING "id", "created_at"', $result->query); + } + + // Locking OF combined + + public function testForUpdateOfWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->forUpdateOf('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); + } + + // PostgreSQL rename uses ALTER TABLE + + public function testFromSubClearsTablePostgreSQL(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $result = (new Builder()) + ->fromSub($sub, 'sub') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FROM (SELECT "id" FROM "orders") AS "sub"', $result->query); + } + + // countDistinct without alias + + public function testCountDistinctWithoutAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->countDistinct('email') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('COUNT(DISTINCT "email")', $result->query); + } + + // Multiple EXISTS subqueries + + public function testMultipleExistsSubqueries(): void + { + $sub1 = (new Builder())->from('orders')->select(['id']); + $sub2 = (new Builder())->from('payments')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterExists($sub1) + ->filterNotExists($sub2) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('EXISTS (SELECT', $result->query); + $this->assertStringContainsString('NOT EXISTS (SELECT', $result->query); + } + + // Left join alias PostgreSQL + + public function testLeftJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->leftJoin('orders', 'u.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN "orders" AS "o"', $result->query); + } + + // Cross join alias PostgreSQL + + public function testCrossJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN "roles" AS "r"', $result->query); + } + + // ForShare locking variants + + public function testForShareSkipLockedPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forShareSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE SKIP LOCKED', $result->query); + } + + public function testForShareNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOR SHARE NOWAIT', $result->query); + } + + // Reset clears new properties (PostgreSQL) + + public function testResetPostgreSQL(): void + { + $sub = (new Builder())->from('t')->select(['id']); + $builder = (new Builder()) + ->from('users', 'u') + ->filterWhereIn('id', $sub) + ->selectSub($sub, 'cnt') + ->orderByRaw('random()') + ->filterExists($sub) + ->reset(); + + $this->expectException(ValidationException::class); + $builder->build(); + } + + public function testExactSimpleSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10) + ->offset(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "email" FROM "users" WHERE "status" IN (?) ORDER BY "name" ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals(['active', 10, 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSelectWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name', 'price']) + ->filter([ + Query::greaterThan('price', 10), + Query::lessThan('price', 100), + Query::equal('category', ['electronics']), + Query::isNotNull('name'), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "price" FROM "products" WHERE "price" > ? AND "price" < ? AND "category" IN (?) AND "name" IS NOT NULL', + $result->query + ); + $this->assertEquals([10, 100, 'electronics'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactMultipleJoins(): void + { + $result = (new Builder()) + ->from('users') + ->select(['users.id', 'orders.total', 'profiles.bio']) + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->build(); + + $this->assertSame( + 'SELECT "users"."id", "orders"."total", "profiles"."bio" FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" LEFT JOIN "profiles" ON "users"."id" = "profiles"."user_id"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->set(['name' => 'Bob', 'email' => 'bob@test.com']) + ->insert(); + + $this->assertSame( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com', 'Bob', 'bob@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->returning(['id']) + ->insert(); + + $this->assertSame( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?) RETURNING "id"', + $result->query + ); + $this->assertEquals(['Alice', 'alice@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpdateReturning(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Updated']) + ->filter([Query::equal('id', [1])]) + ->returning(['*']) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "name" = ? WHERE "id" IN (?) RETURNING *', + $result->query + ); + $this->assertEquals(['Updated', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDeleteReturning(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [5])]) + ->returning(['id']) + ->delete(); + + $this->assertSame( + 'DELETE FROM "users" WHERE "id" IN (?) RETURNING "id"', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpsertOnConflict(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@test.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', + $result->query + ); + $this->assertEquals([1, 'Alice', 'alice@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUpsertOnConflictReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->returning(['id', 'name']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name" RETURNING "id", "name"', + $result->query + ); + $this->assertEquals([1, 'Alice'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->insertOrIgnore(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertEquals([1, 'Alice'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactVectorSearchCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['id', 'title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "embeddings" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['[0.1,0.2,0.3]', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactVectorSearchEuclidean(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['id', 'title']) + ->orderByVectorDistance('embedding', [0.5, 0.6], VectorMetric::Euclidean) + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "embeddings" ORDER BY ("embedding" <-> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['[0.5,0.6]', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonbContains(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filterJsonContains('tags', 'php') + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "documents" WHERE "tags" @> ?::jsonb', + $result->query + ); + $this->assertEquals(['"php"'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonbOverlaps(): void + { + $result = (new Builder()) + ->from('documents') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + + $this->assertSame( + 'SELECT * FROM "documents" WHERE "tags" ?| ARRAY(SELECT jsonb_array_elements_text(?::jsonb))', + $result->query + ); + $this->assertEquals(['["php","js"]'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterJsonPath('metadata', 'key', '=', 'value') + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "metadata"->>\'key\' = ?', + $result->query + ); + $this->assertEquals(['value'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCte(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->with('big_orders', $cteQuery) + ->from('big_orders') + ->select(['user_id', 'total']) + ->build(); + + $this->assertSame( + 'WITH "big_orders" AS (SELECT "user_id", "total" FROM "orders" WHERE "total" > ?) SELECT "user_id", "total" FROM "big_orders"', + $result->query + ); + $this->assertEquals([100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactWindowFunction(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'name', 'department']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['-salary']) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "department", ROW_NUMBER() OVER (PARTITION BY "department" ORDER BY "salary" DESC) AS "row_num" FROM "employees"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactUnion(): void + { + $second = (new Builder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->union($second) + ->build(); + + $this->assertSame( + '(SELECT "id", "name" FROM "users") UNION (SELECT "id", "name" FROM "archived_users")', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactForUpdateOf(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['id', 'balance']) + ->filter([Query::equal('id', [42])]) + ->forUpdateOf('accounts') + ->build(); + + $this->assertSame( + 'SELECT "id", "balance" FROM "accounts" WHERE "id" IN (?) FOR UPDATE OF "accounts"', + $result->query + ); + $this->assertEquals([42], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('jobs') + ->select(['id', 'payload']) + ->filter([Query::equal('status', ['pending'])]) + ->forShareSkipLocked() + ->limit(1) + ->build(); + + $this->assertSame( + 'SELECT "id", "payload" FROM "jobs" WHERE "status" IN (?) LIMIT ? FOR SHARE SKIP LOCKED', + $result->query + ); + $this->assertEquals(['pending', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAggregationGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS "order_count" FROM "orders" GROUP BY "user_id" HAVING "order_count" > ?', + $result->query + ); + $this->assertEquals([5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactSubqueryWhereIn(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 500)]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterWhereIn('id', $subquery) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "id" IN (SELECT "user_id" FROM "orders" WHERE "total" > ?)', + $result->query + ); + $this->assertEquals([500], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::equal('orders.user_id', [1])]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filterExists($subquery) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE EXISTS (SELECT "id" FROM "orders" WHERE "orders"."user_id" IN (?))', + $result->query + ); + $this->assertEquals([1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactNestedWhereGroups(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('age', 18), + Query::equal('role', ['admin']), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "status" IN (?) AND ("age" > ? OR "role" IN (?))', + $result->query + ); + $this->assertEquals(['active', 18, 'admin'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDistinctWithOffset(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->distinct() + ->sortAsc('name') + ->limit(20) + ->offset(10) + ->build(); + + $this->assertSame( + 'SELECT DISTINCT "name", "email" FROM "users" ORDER BY "name" ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertEquals([20, 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenTrue(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(true, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users" WHERE "status" IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedWhenFalse(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->when(false, function (Builder $b) { + $b->filter([Query::equal('status', ['active'])]); + }) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "users"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplain(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertSame( + 'EXPLAIN SELECT "id", "name" FROM "users" WHERE "status" IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::greaterThan('age', 18)]) + ->explain(true); + + $this->assertSame( + 'EXPLAIN ANALYZE SELECT "id", "name" FROM "users" WHERE "age" > ?', + $result->query + ); + $this->assertEquals([18], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedCursorAfterWithFilters(): void + { + $result = (new Builder()) + ->from('posts') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->cursorAfter('abc123') + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "posts" WHERE "status" IN (?) AND "_cursor" > ? LIMIT ?', + $result->query + ); + $this->assertEquals(['published', 'abc123', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleCtes(): void + { + $cteA = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->filter([Query::greaterThan('total', 100)]); + + $cteB = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('active', [true])]); + + $result = (new Builder()) + ->with('a', $cteA) + ->with('b', $cteB) + ->from('a') + ->select(['customer_id']) + ->join('b', 'a.customer_id', 'b.id') + ->build(); + + $this->assertSame( + 'WITH "a" AS (SELECT "customer_id" FROM "orders" WHERE "total" > ?), "b" AS (SELECT "id", "name" FROM "customers" WHERE "active" IN (?)) SELECT "customer_id" FROM "a" JOIN "b" ON "a"."customer_id" = "b"."id"', + $result->query + ); + $this->assertEquals([100, true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['id', 'name', 'department', 'salary']) + ->selectWindow('ROW_NUMBER()', 'row_num', ['department'], ['salary']) + ->selectWindow('RANK()', 'salary_rank', ['department'], ['-salary']) + ->build(); + + $this->assertSame( + 'SELECT "id", "name", "department", "salary", ROW_NUMBER() OVER (PARTITION BY "department" ORDER BY "salary" ASC) AS "row_num", RANK() OVER (PARTITION BY "department" ORDER BY "salary" DESC) AS "salary_rank" FROM "employees"', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUnionWithOrderAndLimit(): void + { + $second = (new Builder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->sortAsc('name') + ->limit(50) + ->union($second) + ->build(); + + $this->assertSame( + '(SELECT "id", "name" FROM "users" ORDER BY "name" ASC LIMIT ?) UNION (SELECT "id", "name" FROM "archived_users")', + $result->query + ); + $this->assertEquals([50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeeplyNestedConditions(): void + { + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->filter([ + Query::and([ + Query::greaterThan('price', 10), + Query::or([ + Query::equal('category', ['electronics']), + Query::and([ + Query::equal('brand', ['acme']), + Query::lessThan('stock', 5), + ]), + ]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT "id", "name" FROM "products" WHERE ("price" > ? AND ("category" IN (?) OR ("brand" IN (?) AND "stock" < ?)))', + $result->query + ); + $this->assertEquals([10, 'electronics', 'acme', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedForUpdateOfWithJoin(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['accounts.id', 'accounts.balance', 'users.name']) + ->join('users', 'accounts.user_id', 'users.id') + ->filter([Query::greaterThan('accounts.balance', 0)]) + ->forUpdateOf('accounts') + ->build(); + + $this->assertSame( + 'SELECT "accounts"."id", "accounts"."balance", "users"."name" FROM "accounts" JOIN "users" ON "accounts"."user_id" = "users"."id" WHERE "accounts"."balance" > ? FOR UPDATE OF "accounts"', + $result->query + ); + $this->assertEquals([0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedForShareOf(): void + { + $result = (new Builder()) + ->from('inventory') + ->select(['id', 'quantity']) + ->filter([Query::equal('warehouse', ['main'])]) + ->forShareOf('inventory') + ->build(); + + $this->assertSame( + 'SELECT "id", "quantity" FROM "inventory" WHERE "warehouse" IN (?) FOR SHARE OF "inventory"', + $result->query + ); + $this->assertEquals(['main'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedConflictSetRaw(): void + { + $result = (new Builder()) + ->from('counters') + ->set(['id' => 'page_views', 'count' => 1]) + ->onConflict(['id'], ['count']) + ->conflictSetRaw('count', '"counters"."count" + EXCLUDED."count"') + ->upsert(); + + $this->assertSame( + 'INSERT INTO "counters" ("id", "count") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "count" = "counters"."count" + EXCLUDED."count"', + $result->query + ); + $this->assertEquals(['page_views', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedUpsertReturningAll(): void + { + $result = (new Builder()) + ->from('settings') + ->set(['key' => 'theme', 'value' => 'dark']) + ->onConflict(['key'], ['value']) + ->returning(['*']) + ->upsert(); + + $this->assertSame( + 'INSERT INTO "settings" ("key", "value") VALUES (?, ?) ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value" RETURNING *', + $result->query + ); + $this->assertEquals(['theme', 'dark'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedDeleteReturningMultiple(): void + { + $result = (new Builder()) + ->from('sessions') + ->filter([Query::lessThan('expires_at', '2024-01-01')]) + ->returning(['id', 'user_id']) + ->delete(); + + $this->assertSame( + 'DELETE FROM "sessions" WHERE "expires_at" < ? RETURNING "id", "user_id"', + $result->query + ); + $this->assertEquals(['2024-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonAppend(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonAppend('tags', ['vip']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = COALESCE("tags", \'[]\'::jsonb) || ?::jsonb WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["vip"]', 1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonPrepend('tags', ['urgent']) + ->filter([Query::equal('id', [2])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = ?::jsonb || COALESCE("tags", \'[]\'::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["urgent"]', 2], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonInsert(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonInsert('tags', 0, 'first') + ->filter([Query::equal('id', [3])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = jsonb_insert("tags", \'{0}\', ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['"first"', 3], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonRemove(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonRemove('tags', 'obsolete') + ->filter([Query::equal('id', [4])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = "tags" - ? WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['"obsolete"', 4], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [5])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT jsonb_agg(elem) FROM jsonb_array_elements("tags") AS elem WHERE elem <@ ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["a","b"]', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonDiff(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonDiff('tags', ['x', 'y']) + ->filter([Query::equal('id', [6])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT COALESCE(jsonb_agg(elem), \'[]\'::jsonb) FROM jsonb_array_elements("tags") AS elem WHERE NOT elem <@ ?::jsonb) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals(['["x","y"]', 6], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedSetJsonUnique(): void + { + $result = (new Builder()) + ->from('users') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [7])]) + ->update(); + + $this->assertSame( + 'UPDATE "users" SET "tags" = (SELECT jsonb_agg(DISTINCT elem) FROM jsonb_array_elements("tags") AS elem) WHERE "id" IN (?)', + $result->query + ); + $this->assertEquals([7], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyInClause(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::equal('status', [])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyAndGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::and([])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 1', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedEmptyOrGroup(): void + { + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::or([])]) + ->build(); + + $this->assertSame( + 'SELECT "id" FROM "users" WHERE 1 = 0', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedVectorSearchWithFilters(): void + { + $result = (new Builder()) + ->from('documents') + ->select(['id', 'title']) + ->filter([Query::equal('status', ['published'])]) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(5) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "documents" WHERE "status" IN (?) ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertEquals(['published', '[0.1,0.2,0.3]', 5], $result->bindings); + $this->assertBindingCount($result); + } +} diff --git a/tests/Query/ConditionTest.php b/tests/Query/ConditionTest.php new file mode 100644 index 0000000..8d35e84 --- /dev/null +++ b/tests/Query/ConditionTest.php @@ -0,0 +1,78 @@ +assertEquals('status = ?', $condition->expression); + } + + public function testGetBindings(): void + { + $condition = new Condition('status = ?', ['active']); + $this->assertEquals(['active'], $condition->bindings); + } + + public function testEmptyBindings(): void + { + $condition = new Condition('1 = 1'); + $this->assertEquals('1 = 1', $condition->expression); + $this->assertEquals([], $condition->bindings); + } + + public function testMultipleBindings(): void + { + $condition = new Condition('age BETWEEN ? AND ?', [18, 65]); + $this->assertEquals('age BETWEEN ? AND ?', $condition->expression); + $this->assertEquals([18, 65], $condition->bindings); + } + + public function testPropertiesAreReadonly(): void + { + $condition = new Condition('x = ?', [1]); + + $ref = new \ReflectionClass($condition); + $this->assertTrue($ref->isReadOnly()); + $this->assertTrue($ref->getProperty('expression')->isReadOnly()); + $this->assertTrue($ref->getProperty('bindings')->isReadOnly()); + } + + public function testExpressionPropertyNotWritable(): void + { + $condition = new Condition('x = ?', [1]); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $condition->expression = 'y = ?'; + } + + public function testBindingsPropertyNotWritable(): void + { + $condition = new Condition('x = ?', [1]); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $condition->bindings = [2]; + } + + public function testSingleBinding(): void + { + $condition = new Condition('id = ?', [42]); + $this->assertSame('id = ?', $condition->expression); + $this->assertSame([42], $condition->bindings); + } + + public function testBindingsPreserveTypes(): void + { + $condition = new Condition('a = ? AND b = ? AND c = ?', [1, 'two', 3.0]); + $this->assertIsInt($condition->bindings[0]); + $this->assertIsString($condition->bindings[1]); + $this->assertIsFloat($condition->bindings[2]); + } +} diff --git a/tests/Query/Exception/UnsupportedExceptionTest.php b/tests/Query/Exception/UnsupportedExceptionTest.php new file mode 100644 index 0000000..07eefd2 --- /dev/null +++ b/tests/Query/Exception/UnsupportedExceptionTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Exception::class, $e); + } + + public function testCatchAllCompatibility(): void + { + $this->expectException(Exception::class); + throw new UnsupportedException('caught by base'); + } + + public function testMessagePreserved(): void + { + $e = new UnsupportedException('Not supported'); + $this->assertEquals('Not supported', $e->getMessage()); + } +} diff --git a/tests/Query/Exception/ValidationExceptionTest.php b/tests/Query/Exception/ValidationExceptionTest.php new file mode 100644 index 0000000..91470b6 --- /dev/null +++ b/tests/Query/Exception/ValidationExceptionTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(Exception::class, $e); + } + + public function testCatchAllCompatibility(): void + { + $this->expectException(Exception::class); + throw new ValidationException('caught by base'); + } + + public function testMessagePreserved(): void + { + $e = new ValidationException('Missing table'); + $this->assertEquals('Missing table', $e->getMessage()); + } +} diff --git a/tests/Query/FilterQueryTest.php b/tests/Query/FilterQueryTest.php index cd3f0ca..659a26a 100644 --- a/tests/Query/FilterQueryTest.php +++ b/tests/Query/FilterQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class FilterQueryTest extends TestCase @@ -10,7 +11,7 @@ class FilterQueryTest extends TestCase public function testEqual(): void { $query = Query::equal('name', ['John', 'Jane']); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John', 'Jane'], $query->getValues()); } @@ -18,7 +19,7 @@ public function testEqual(): void public function testNotEqual(): void { $query = Query::notEqual('name', 'John'); - $this->assertEquals(Query::TYPE_NOT_EQUAL, $query->getMethod()); + $this->assertSame(Method::NotEqual, $query->getMethod()); $this->assertEquals(['John'], $query->getValues()); } @@ -37,7 +38,7 @@ public function testNotEqualWithMap(): void public function testLessThan(): void { $query = Query::lessThan('age', 30); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('age', $query->getAttribute()); $this->assertEquals([30], $query->getValues()); } @@ -45,84 +46,84 @@ public function testLessThan(): void public function testLessThanEqual(): void { $query = Query::lessThanEqual('age', 30); - $this->assertEquals(Query::TYPE_LESSER_EQUAL, $query->getMethod()); + $this->assertSame(Method::LessThanEqual, $query->getMethod()); $this->assertEquals([30], $query->getValues()); } public function testGreaterThan(): void { $query = Query::greaterThan('age', 18); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals([18], $query->getValues()); } public function testGreaterThanEqual(): void { $query = Query::greaterThanEqual('age', 18); - $this->assertEquals(Query::TYPE_GREATER_EQUAL, $query->getMethod()); + $this->assertSame(Method::GreaterThanEqual, $query->getMethod()); $this->assertEquals([18], $query->getValues()); } public function testContains(): void { $query = Query::contains('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Method::Contains, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } public function testContainsAny(): void { $query = Query::containsAny('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS_ANY, $query->getMethod()); + $this->assertSame(Method::ContainsAny, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } public function testNotContains(): void { $query = Query::notContains('tags', ['php']); - $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); + $this->assertSame(Method::NotContains, $query->getMethod()); $this->assertEquals(['php'], $query->getValues()); } public function testContainsDeprecated(): void { $query = Query::contains('tags', ['a', 'b']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); + $this->assertSame(Method::Contains, $query->getMethod()); $this->assertEquals(['a', 'b'], $query->getValues()); } public function testBetween(): void { $query = Query::between('age', 18, 65); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals([18, 65], $query->getValues()); } public function testNotBetween(): void { $query = Query::notBetween('age', 18, 65); - $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); + $this->assertSame(Method::NotBetween, $query->getMethod()); $this->assertEquals([18, 65], $query->getValues()); } public function testSearch(): void { $query = Query::search('content', 'hello world'); - $this->assertEquals(Query::TYPE_SEARCH, $query->getMethod()); + $this->assertSame(Method::Search, $query->getMethod()); $this->assertEquals(['hello world'], $query->getValues()); } public function testNotSearch(): void { $query = Query::notSearch('content', 'hello'); - $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); + $this->assertSame(Method::NotSearch, $query->getMethod()); $this->assertEquals(['hello'], $query->getValues()); } public function testIsNull(): void { $query = Query::isNull('email'); - $this->assertEquals(Query::TYPE_IS_NULL, $query->getMethod()); + $this->assertSame(Method::IsNull, $query->getMethod()); $this->assertEquals('email', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -130,46 +131,46 @@ public function testIsNull(): void public function testIsNotNull(): void { $query = Query::isNotNull('email'); - $this->assertEquals(Query::TYPE_IS_NOT_NULL, $query->getMethod()); + $this->assertSame(Method::IsNotNull, $query->getMethod()); } public function testStartsWith(): void { $query = Query::startsWith('name', 'Jo'); - $this->assertEquals(Query::TYPE_STARTS_WITH, $query->getMethod()); + $this->assertSame(Method::StartsWith, $query->getMethod()); $this->assertEquals(['Jo'], $query->getValues()); } public function testNotStartsWith(): void { $query = Query::notStartsWith('name', 'Jo'); - $this->assertEquals(Query::TYPE_NOT_STARTS_WITH, $query->getMethod()); + $this->assertSame(Method::NotStartsWith, $query->getMethod()); } public function testEndsWith(): void { $query = Query::endsWith('email', '.com'); - $this->assertEquals(Query::TYPE_ENDS_WITH, $query->getMethod()); + $this->assertSame(Method::EndsWith, $query->getMethod()); $this->assertEquals(['.com'], $query->getValues()); } public function testNotEndsWith(): void { $query = Query::notEndsWith('email', '.com'); - $this->assertEquals(Query::TYPE_NOT_ENDS_WITH, $query->getMethod()); + $this->assertSame(Method::NotEndsWith, $query->getMethod()); } public function testRegex(): void { $query = Query::regex('name', '^Jo.*'); - $this->assertEquals(Query::TYPE_REGEX, $query->getMethod()); + $this->assertSame(Method::Regex, $query->getMethod()); $this->assertEquals(['^Jo.*'], $query->getValues()); } public function testExists(): void { $query = Query::exists(['name', 'email']); - $this->assertEquals(Query::TYPE_EXISTS, $query->getMethod()); + $this->assertSame(Method::Exists, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals(['name', 'email'], $query->getValues()); } @@ -177,7 +178,7 @@ public function testExists(): void public function testNotExistsArray(): void { $query = Query::notExists(['name']); - $this->assertEquals(Query::TYPE_NOT_EXISTS, $query->getMethod()); + $this->assertSame(Method::NotExists, $query->getMethod()); $this->assertEquals(['name'], $query->getValues()); } @@ -190,7 +191,7 @@ public function testNotExistsScalar(): void public function testCreatedBefore(): void { $query = Query::createdBefore('2024-01-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2024-01-01'], $query->getValues()); } @@ -198,28 +199,28 @@ public function testCreatedBefore(): void public function testCreatedAfter(): void { $query = Query::createdAfter('2024-01-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); } public function testUpdatedBefore(): void { $query = Query::updatedBefore('2024-06-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); + $this->assertSame(Method::LessThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } public function testUpdatedAfter(): void { $query = Query::updatedAfter('2024-06-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } public function testCreatedBetween(): void { $query = Query::createdBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals('$createdAt', $query->getAttribute()); $this->assertEquals(['2024-01-01', '2024-12-31'], $query->getValues()); } @@ -227,7 +228,7 @@ public function testCreatedBetween(): void public function testUpdatedBetween(): void { $query = Query::updatedBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); + $this->assertSame(Method::Between, $query->getMethod()); $this->assertEquals('$updatedAt', $query->getAttribute()); } } diff --git a/tests/Query/Hook/Attribute/AttributeTest.php b/tests/Query/Hook/Attribute/AttributeTest.php new file mode 100644 index 0000000..e5733c5 --- /dev/null +++ b/tests/Query/Hook/Attribute/AttributeTest.php @@ -0,0 +1,35 @@ + '_uid', + '$createdAt' => '_createdAt', + ]); + + $this->assertEquals('_uid', $hook->resolve('$id')); + $this->assertEquals('_createdAt', $hook->resolve('$createdAt')); + } + + public function testUnmappedPassthrough(): void + { + $hook = new Map(['$id' => '_uid']); + + $this->assertEquals('name', $hook->resolve('name')); + $this->assertEquals('status', $hook->resolve('status')); + } + + public function testEmptyMap(): void + { + $hook = new Map([]); + + $this->assertEquals('anything', $hook->resolve('anything')); + } +} diff --git a/tests/Query/Hook/Filter/FilterTest.php b/tests/Query/Hook/Filter/FilterTest.php new file mode 100644 index 0000000..2bd6fc7 --- /dev/null +++ b/tests/Query/Hook/Filter/FilterTest.php @@ -0,0 +1,270 @@ +filter('users'); + + $this->assertEquals('tenant_id IN (?)', $condition->expression); + $this->assertEquals(['t1'], $condition->bindings); + } + + public function testTenantMultipleIds(): void + { + $hook = new Tenant(['t1', 't2', 't3']); + $condition = $hook->filter('users'); + + $this->assertEquals('tenant_id IN (?, ?, ?)', $condition->expression); + $this->assertEquals(['t1', 't2', 't3'], $condition->bindings); + } + + public function testTenantCustomColumn(): void + { + $hook = new Tenant(['t1'], 'organization_id'); + $condition = $hook->filter('users'); + + $this->assertEquals('organization_id IN (?)', $condition->expression); + $this->assertEquals(['t1'], $condition->bindings); + } + + public function testPermissionWithRoles(): void + { + $hook = new Permission( + roles: ['role:admin', 'role:user'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?, ?) AND type = ?)', + $condition->expression + ); + $this->assertEquals(['role:admin', 'role:user', 'read'], $condition->bindings); + } + + public function testPermissionEmptyRoles(): void + { + $hook = new Permission( + roles: [], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('documents'); + + $this->assertEquals('1 = 0', $condition->expression); + $this->assertEquals([], $condition->bindings); + } + + public function testPermissionCustomType(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + type: 'write', + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?) AND type = ?)', + $condition->expression + ); + $this->assertEquals(['role:admin', 'write'], $condition->bindings); + } + + public function testPermissionCustomDocumentColumn(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + documentColumn: 'doc_id', + ); + $condition = $hook->filter('documents'); + + $this->assertStringStartsWith('doc_id IN', $condition->expression); + } + + public function testPermissionCustomColumns(): void + { + $hook = new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'acl', + documentColumn: 'uid', + permDocumentColumn: 'resource_id', + permRoleColumn: 'principal', + permTypeColumn: 'access', + ); + $condition = $hook->filter('documents'); + + $this->assertEquals( + 'uid IN (SELECT DISTINCT resource_id FROM acl WHERE principal IN (?) AND access = ?)', + $condition->expression + ); + $this->assertEquals(['admin', 'read'], $condition->bindings); + } + + public function testPermissionStaticTable(): void + { + $hook = new Permission( + roles: ['user:123'], + permissionsTable: fn (string $table) => 'permissions', + ); + $condition = $hook->filter('any_table'); + + $this->assertStringContainsString('FROM permissions', $condition->expression); + } + + public function testPermissionWithColumns(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + columns: ['email', 'phone'], + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?, ?)))', + $condition->expression + ); + $this->assertEquals(['role:admin', 'read', 'email', 'phone'], $condition->bindings); + } + + public function testPermissionWithSingleColumn(): void + { + $hook = new Permission( + roles: ['role:user'], + permissionsTable: fn (string $table) => "{$table}_perms", + columns: ['salary'], + ); + $condition = $hook->filter('employees'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM employees_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?)))', + $condition->expression + ); + $this->assertEquals(['role:user', 'read', 'salary'], $condition->bindings); + } + + public function testPermissionWithEmptyColumns(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + columns: [], + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND column IS NULL)', + $condition->expression + ); + $this->assertEquals(['role:admin', 'read'], $condition->bindings); + } + + public function testPermissionWithoutColumnsOmitsClause(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filter('users'); + + $this->assertStringNotContainsString('column', $condition->expression); + } + + public function testPermissionCustomColumnColumn(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => 'acl', + columns: ['email'], + permColumnColumn: 'field', + ); + $condition = $hook->filter('users'); + + $this->assertEquals( + 'id IN (SELECT DISTINCT document_id FROM acl WHERE role IN (?) AND type = ? AND (field IS NULL OR field IN (?)))', + $condition->expression + ); + $this->assertEquals(['role:admin', 'read', 'email'], $condition->bindings); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Permission.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Invalid column name (line 36) ──────────────────────────── + + public function testPermissionInvalidColumnNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name'); + new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'perms', + documentColumn: '123bad', + ); + } + + // ── Invalid permissions table name (line 51) ───────────────── + + public function testPermissionInvalidTableNameThrows(): void + { + $hook = new Permission( + roles: ['admin'], + permissionsTable: fn (string $table) => 'invalid table!', + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid permissions table name'); + $hook->filter('users'); + } + + // ── subqueryFilter (lines 72-74) ───────────────────────────── + + public function testPermissionWithSubqueryFilter(): void + { + $tenantFilter = new Tenant(['t1']); + + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => 'perms', + subqueryFilter: $tenantFilter, + ); + $condition = $hook->filter('users'); + + $this->assertStringContainsString('AND tenant_id IN (?)', $condition->expression); + $this->assertContains('t1', $condition->bindings); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Tenant.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Invalid column name (line 22) ──────────────────────────── + + public function testTenantInvalidColumnNameThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid column name'); + new Tenant(['t1'], '123bad'); + } + + // ── Empty tenantIds (line 29) ──────────────────────────────── + + public function testTenantEmptyTenantIdsReturnsNoMatch(): void + { + $hook = new Tenant([]); + $condition = $hook->filter('users'); + + $this->assertSame('1 = 0', $condition->expression); + $this->assertSame([], $condition->bindings); + } +} diff --git a/tests/Query/Hook/Join/FilterTest.php b/tests/Query/Hook/Join/FilterTest.php new file mode 100644 index 0000000..9299287 --- /dev/null +++ b/tests/Query/Hook/Join/FilterTest.php @@ -0,0 +1,309 @@ +from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ?', $result->query); + $this->assertStringNotContainsString('WHERE', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testWherePlacementForInnerJoin(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::Where, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->join('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertStringContainsString('WHERE active = ?', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testReturnsNullSkipsJoin(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + return null; + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertEquals('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testCrossJoinForcesOnToWhere(): void + { + $hook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->crossJoin('settings') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CROSS JOIN `settings`', $result->query); + $this->assertStringNotContainsString('CROSS JOIN `settings` AND', $result->query); + $this->assertStringContainsString('WHERE active = ?', $result->query); + $this->assertEquals([1], $result->bindings); + } + + public function testMultipleHooksOnSameJoin(): void + { + $hook1 = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('active = ?', [1]), + Placement::On, + ); + } + }; + + $hook2 = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('visible = ?', [true]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook1) + ->addHook($hook2) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ? AND visible = ?', + $result->query + ); + $this->assertEquals([1, true], $result->bindings); + } + + public function testBindingOrderCorrectness(): void + { + $onHook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('on_col = ?', ['on_val']), + Placement::On, + ); + } + }; + + $whereHook = new class () implements JoinFilter { + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('where_col = ?', ['where_val']), + Placement::Where, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($onHook) + ->addHook($whereHook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + // ON bindings come first (during join compilation), then filter bindings, then WHERE join filter bindings + $this->assertEquals(['on_val', 'active', 'where_val'], $result->bindings); + } + + public function testFilterOnlyBackwardCompat(): void + { + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [0]); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + // Filter-only hooks should still apply to WHERE, not to joins + $this->assertStringContainsString('LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertStringContainsString('WHERE deleted = ?', $result->query); + $this->assertEquals([0], $result->bindings); + } + + public function testDualInterfaceHook(): void + { + $hook = new class () implements Filter, JoinFilter { + public function filter(string $table): Condition + { + return new Condition('main_active = ?', [1]); + } + + public function filterJoin(string $table, JoinType $joinType): JoinCondition + { + return new JoinCondition( + new Condition('join_active = ?', [1]), + Placement::On, + ); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + // Filter applies to WHERE for main table + $this->assertStringContainsString('WHERE main_active = ?', $result->query); + // JoinFilter applies to ON for join + $this->assertStringContainsString('ON `users`.`id` = `orders`.`user_id` AND join_active = ?', $result->query); + // ON binding first, then WHERE binding + $this->assertEquals([1, 1], $result->bindings); + } + + public function testPermissionLeftJoinOnPlacement(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filterJoin('orders', JoinType::Left); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::On, $condition->placement); + $this->assertStringContainsString('id IN', $condition->condition->expression); + } + + public function testPermissionInnerJoinWherePlacement(): void + { + $hook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $condition = $hook->filterJoin('orders', JoinType::Inner); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::Where, $condition->placement); + } + + public function testTenantLeftJoinOnPlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', JoinType::Left); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::On, $condition->placement); + $this->assertStringContainsString('tenant_id IN', $condition->condition->expression); + } + + public function testTenantInnerJoinWherePlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', JoinType::Inner); + + $this->assertNotNull($condition); + $this->assertEquals(Placement::Where, $condition->placement); + } + + public function testHookReceivesCorrectTableAndJoinType(): void + { + // Tenant returns On for RIGHT JOIN — verifying it received the correct joinType + $hook = new Tenant(['t1']); + + $rightJoinResult = $hook->filterJoin('orders', JoinType::Right); + $this->assertNotNull($rightJoinResult); + $this->assertEquals(Placement::On, $rightJoinResult->placement); + + // Same hook returns Where for JOIN — verifying joinType discrimination + $innerJoinResult = $hook->filterJoin('orders', JoinType::Inner); + $this->assertNotNull($innerJoinResult); + $this->assertEquals(Placement::Where, $innerJoinResult->placement); + + // Verify table name is used in the condition expression + $permHook = new Permission( + roles: ['role:admin'], + permissionsTable: fn (string $table) => "mydb_{$table}_perms", + ); + $result = $permHook->filterJoin('orders', JoinType::Left); + $this->assertNotNull($result); + $this->assertStringContainsString('mydb_orders_perms', $result->condition->expression); + } +} diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php new file mode 100644 index 0000000..da6a145 --- /dev/null +++ b/tests/Query/JoinQueryTest.php @@ -0,0 +1,143 @@ +assertSame(Method::Join, $query->getMethod()); + $this->assertEquals('orders', $query->getAttribute()); + $this->assertEquals(['users.id', '=', 'orders.user_id'], $query->getValues()); + } + + public function testJoinWithOperator(): void + { + $query = Query::join('orders', 'users.id', 'orders.user_id', '!='); + $this->assertEquals(['users.id', '!=', 'orders.user_id'], $query->getValues()); + } + + public function testLeftJoin(): void + { + $query = Query::leftJoin('profiles', 'users.id', 'profiles.user_id'); + $this->assertSame(Method::LeftJoin, $query->getMethod()); + $this->assertEquals('profiles', $query->getAttribute()); + $this->assertEquals(['users.id', '=', 'profiles.user_id'], $query->getValues()); + } + + public function testRightJoin(): void + { + $query = Query::rightJoin('orders', 'users.id', 'orders.user_id'); + $this->assertSame(Method::RightJoin, $query->getMethod()); + $this->assertEquals('orders', $query->getAttribute()); + } + + public function testCrossJoin(): void + { + $query = Query::crossJoin('colors'); + $this->assertSame(Method::CrossJoin, $query->getMethod()); + $this->assertEquals('colors', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testJoinMethodsAreJoin(): void + { + $this->assertTrue(Method::Join->isJoin()); + $this->assertTrue(Method::LeftJoin->isJoin()); + $this->assertTrue(Method::RightJoin->isJoin()); + $this->assertTrue(Method::CrossJoin->isJoin()); + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + $this->assertCount(4, $joinMethods); + } + + public function testJoinWithEmptyTableName(): void + { + $query = Query::join('', 'left', 'right'); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals(['left', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyLeftColumn(): void + { + $query = Query::join('t', '', 'right'); + $this->assertEquals(['', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyRightColumn(): void + { + $query = Query::join('t', 'left', ''); + $this->assertEquals(['left', '=', ''], $query->getValues()); + } + + public function testJoinWithSpecialOperators(): void + { + $ops = ['!=', '<>', '<', '>', '<=', '>=']; + foreach ($ops as $op) { + $query = Query::join('t', 'a', 'b', $op); + $this->assertEquals(['a', $op, 'b'], $query->getValues()); + } + } + + public function testLeftJoinValues(): void + { + $query = Query::leftJoin('t', 'a.id', 'b.aid', '!='); + $this->assertEquals(['a.id', '!=', 'b.aid'], $query->getValues()); + } + + public function testRightJoinValues(): void + { + $query = Query::rightJoin('t', 'a.id', 'b.aid'); + $this->assertEquals(['a.id', '=', 'b.aid'], $query->getValues()); + } + + public function testCrossJoinEmptyTableName(): void + { + $query = Query::crossJoin(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testJoinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::join('orders', 'users.id', 'orders.uid'); + $sql = $query->compile($builder); + $this->assertEquals('JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testLeftJoinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::leftJoin('p', 'u.id', 'p.uid'); + $sql = $query->compile($builder); + $this->assertEquals('LEFT JOIN `p` ON `u`.`id` = `p`.`uid`', $sql); + } + + public function testRightJoinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::rightJoin('o', 'u.id', 'o.uid'); + $sql = $query->compile($builder); + $this->assertEquals('RIGHT JOIN `o` ON `u`.`id` = `o`.`uid`', $sql); + } + + public function testCrossJoinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::crossJoin('colors'); + $sql = $query->compile($builder); + $this->assertEquals('CROSS JOIN `colors`', $sql); + } + + public function testJoinIsNotNested(): void + { + $query = Query::join('t', 'a', 'b'); + $this->assertFalse($query->isNested()); + } +} diff --git a/tests/Query/LogicalQueryTest.php b/tests/Query/LogicalQueryTest.php index 6e951e9..b3d7ce1 100644 --- a/tests/Query/LogicalQueryTest.php +++ b/tests/Query/LogicalQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class LogicalQueryTest extends TestCase @@ -12,7 +13,7 @@ public function testOr(): void $q1 = Query::equal('name', ['John']); $q2 = Query::equal('name', ['Jane']); $query = Query::or([$q1, $q2]); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); + $this->assertSame(Method::Or, $query->getMethod()); $this->assertCount(2, $query->getValues()); } @@ -21,14 +22,14 @@ public function testAnd(): void $q1 = Query::greaterThan('age', 18); $q2 = Query::lessThan('age', 65); $query = Query::and([$q1, $q2]); - $this->assertEquals(Query::TYPE_AND, $query->getMethod()); + $this->assertSame(Method::And, $query->getMethod()); $this->assertCount(2, $query->getValues()); } public function testContainsAll(): void { $query = Query::containsAll('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS_ALL, $query->getMethod()); + $this->assertSame(Method::ContainsAll, $query->getMethod()); $this->assertEquals(['php', 'js'], $query->getValues()); } @@ -36,7 +37,54 @@ public function testElemMatch(): void { $inner = [Query::equal('field', ['val'])]; $query = Query::elemMatch('items', $inner); - $this->assertEquals(Query::TYPE_ELEM_MATCH, $query->getMethod()); + $this->assertSame(Method::ElemMatch, $query->getMethod()); $this->assertEquals('items', $query->getAttribute()); } + + public function testOrIsNested(): void + { + $query = Query::or([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testAndIsNested(): void + { + $query = Query::and([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testElemMatchIsNested(): void + { + $query = Query::elemMatch('items', [Query::equal('field', ['val'])]); + $this->assertTrue($query->isNested()); + } + + public function testEmptyAnd(): void + { + $query = Query::and([]); + $this->assertEquals([], $query->getValues()); + } + + public function testEmptyOr(): void + { + $query = Query::or([]); + $this->assertEquals([], $query->getValues()); + } + + public function testNestedAndOr(): void + { + $query = Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + ]); + $values = $query->getValues(); + $this->assertCount(1, $values); + /** @var Query $orQuery */ + $orQuery = $values[0]; + $this->assertSame(Method::Or, $orQuery->getMethod()); + $orValues = $orQuery->getValues(); + $this->assertCount(2, $orValues); + } } diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index 22807f4..d04049f 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -3,6 +3,10 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\CursorDirection; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; use Utopia\Query\Query; class QueryHelperTest extends TestCase @@ -35,6 +39,21 @@ public function testIsMethodValid(): void $this->assertTrue(Query::isMethod('containsAll')); $this->assertTrue(Query::isMethod('elemMatch')); $this->assertTrue(Query::isMethod('regex')); + $this->assertTrue(Query::isMethod('count')); + $this->assertTrue(Query::isMethod('sum')); + $this->assertTrue(Query::isMethod('avg')); + $this->assertTrue(Query::isMethod('min')); + $this->assertTrue(Query::isMethod('max')); + $this->assertTrue(Query::isMethod('groupBy')); + $this->assertTrue(Query::isMethod('having')); + $this->assertTrue(Query::isMethod('distinct')); + $this->assertTrue(Query::isMethod('join')); + $this->assertTrue(Query::isMethod('leftJoin')); + $this->assertTrue(Query::isMethod('rightJoin')); + $this->assertTrue(Query::isMethod('crossJoin')); + $this->assertTrue(Query::isMethod('union')); + $this->assertTrue(Query::isMethod('unionAll')); + $this->assertTrue(Query::isMethod('raw')); } public function testIsMethodInvalid(): void @@ -91,7 +110,7 @@ public function testCloneDeepCopiesNestedQueries(): void $clonedValues = $cloned->getValues(); $this->assertInstanceOf(Query::class, $clonedValues[0]); $this->assertNotSame($inner, $clonedValues[0]); - $this->assertEquals('equal', $clonedValues[0]->getMethod()); + $this->assertSame(Method::Equal, $clonedValues[0]->getMethod()); } public function testClonePreservesNonQueryValues(): void @@ -110,10 +129,10 @@ public function testGetByType(): void Query::offset(5), ]; - $filters = Query::getByType($queries, [Query::TYPE_EQUAL, Query::TYPE_GREATER]); + $filters = Query::getByType($queries, [Method::Equal, Method::GreaterThan]); $this->assertCount(2, $filters); - $this->assertEquals('equal', $filters[0]->getMethod()); - $this->assertEquals('greaterThan', $filters[1]->getMethod()); + $this->assertSame(Method::Equal, $filters[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $filters[1]->getMethod()); } public function testGetByTypeClone(): void @@ -121,7 +140,7 @@ public function testGetByTypeClone(): void $original = Query::equal('name', ['John']); $queries = [$original]; - $result = Query::getByType($queries, [Query::TYPE_EQUAL], true); + $result = Query::getByType($queries, [Method::Equal], true); $this->assertNotSame($original, $result[0]); } @@ -130,14 +149,14 @@ public function testGetByTypeNoClone(): void $original = Query::equal('name', ['John']); $queries = [$original]; - $result = Query::getByType($queries, [Query::TYPE_EQUAL], false); + $result = Query::getByType($queries, [Method::Equal], false); $this->assertSame($original, $result[0]); } public function testGetByTypeEmpty(): void { $queries = [Query::equal('x', [1])]; - $result = Query::getByType($queries, [Query::TYPE_LIMIT]); + $result = Query::getByType($queries, [Method::Limit]); $this->assertCount(0, $result); } @@ -152,8 +171,8 @@ public function testGetCursorQueries(): void $cursors = Query::getCursorQueries($queries); $this->assertCount(2, $cursors); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $cursors[0]->getMethod()); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $cursors[1]->getMethod()); + $this->assertSame(Method::CursorAfter, $cursors[0]->getMethod()); + $this->assertSame(Method::CursorBefore, $cursors[1]->getMethod()); } public function testGetCursorQueriesNone(): void @@ -178,21 +197,21 @@ public function testGroupByType(): void $grouped = Query::groupByType($queries); - $this->assertCount(2, $grouped['filters']); - $this->assertEquals('equal', $grouped['filters'][0]->getMethod()); - $this->assertEquals('greaterThan', $grouped['filters'][1]->getMethod()); + $this->assertCount(2, $grouped->filters); + $this->assertSame(Method::Equal, $grouped->filters[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $grouped->filters[1]->getMethod()); - $this->assertCount(1, $grouped['selections']); - $this->assertEquals('select', $grouped['selections'][0]->getMethod()); + $this->assertCount(1, $grouped->selections); + $this->assertSame(Method::Select, $grouped->selections[0]->getMethod()); - $this->assertEquals(25, $grouped['limit']); - $this->assertEquals(10, $grouped['offset']); + $this->assertEquals(25, $grouped->limit); + $this->assertEquals(10, $grouped->offset); - $this->assertEquals(['name', 'age'], $grouped['orderAttributes']); - $this->assertEquals([Query::ORDER_ASC, Query::ORDER_DESC], $grouped['orderTypes']); + $this->assertEquals(['name', 'age'], $grouped->orderAttributes); + $this->assertEquals([OrderDirection::Asc, OrderDirection::Desc], $grouped->orderTypes); - $this->assertEquals('doc123', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertEquals('doc123', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeFirstLimitWins(): void @@ -203,7 +222,7 @@ public function testGroupByTypeFirstLimitWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(10, $grouped['limit']); + $this->assertEquals(10, $grouped->limit); } public function testGroupByTypeFirstOffsetWins(): void @@ -214,7 +233,7 @@ public function testGroupByTypeFirstOffsetWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(5, $grouped['offset']); + $this->assertEquals(5, $grouped->offset); } public function testGroupByTypeFirstCursorWins(): void @@ -225,8 +244,8 @@ public function testGroupByTypeFirstCursorWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('first', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertEquals('first', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeCursorBefore(): void @@ -236,34 +255,714 @@ public function testGroupByTypeCursorBefore(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('doc456', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_BEFORE, $grouped['cursorDirection']); + $this->assertEquals('doc456', $grouped->cursor); + $this->assertSame(CursorDirection::Before, $grouped->cursorDirection); } public function testGroupByTypeEmpty(): void { $grouped = Query::groupByType([]); - $this->assertEquals([], $grouped['filters']); - $this->assertEquals([], $grouped['selections']); - $this->assertNull($grouped['limit']); - $this->assertNull($grouped['offset']); - $this->assertEquals([], $grouped['orderAttributes']); - $this->assertEquals([], $grouped['orderTypes']); - $this->assertNull($grouped['cursor']); - $this->assertNull($grouped['cursorDirection']); + $this->assertEquals([], $grouped->filters); + $this->assertEquals([], $grouped->selections); + $this->assertNull($grouped->limit); + $this->assertNull($grouped->offset); + $this->assertEquals([], $grouped->orderAttributes); + $this->assertEquals([], $grouped->orderTypes); + $this->assertNull($grouped->cursor); + $this->assertNull($grouped->cursorDirection); } public function testGroupByTypeOrderRandom(): void { $queries = [Query::orderRandom()]; $grouped = Query::groupByType($queries); - $this->assertEquals([Query::ORDER_RANDOM], $grouped['orderTypes']); - $this->assertEquals([], $grouped['orderAttributes']); + $this->assertEquals([OrderDirection::Random], $grouped->orderTypes); + $this->assertEquals([], $grouped->orderAttributes); } public function testGroupByTypeSkipsNonQueryInstances(): void { $grouped = Query::groupByType(['not a query', null, 42]); - $this->assertEquals([], $grouped['filters']); + $this->assertEquals([], $grouped->filters); + } + + public function testGroupByTypeAggregations(): void + { + $queries = [ + Query::count('*', 'total'), + Query::sum('price'), + Query::avg('score'), + Query::min('age'), + Query::max('salary'), + ]; + + $grouped = Query::groupByType($queries); + $this->assertCount(5, $grouped->aggregations); + $this->assertSame(Method::Count, $grouped->aggregations[0]->getMethod()); + $this->assertSame(Method::Max, $grouped->aggregations[4]->getMethod()); + } + + public function testGroupByTypeGroupBy(): void + { + $queries = [Query::groupBy(['status', 'country'])]; + $grouped = Query::groupByType($queries); + $this->assertEquals(['status', 'country'], $grouped->groupBy); + } + + public function testGroupByTypeHaving(): void + { + $queries = [Query::having([Query::greaterThan('total', 5)])]; + $grouped = Query::groupByType($queries); + $this->assertCount(1, $grouped->having); + $this->assertSame(Method::Having, $grouped->having[0]->getMethod()); + } + + public function testGroupByTypeDistinct(): void + { + $queries = [Query::distinct()]; + $grouped = Query::groupByType($queries); + $this->assertTrue($grouped->distinct); + } + + public function testGroupByTypeDistinctDefaultFalse(): void + { + $grouped = Query::groupByType([]); + $this->assertFalse($grouped->distinct); + } + + public function testGroupByTypeJoins(): void + { + $queries = [ + Query::join('orders', 'users.id', 'orders.user_id'), + Query::leftJoin('profiles', 'users.id', 'profiles.user_id'), + Query::crossJoin('colors'), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(3, $grouped->joins); + $this->assertSame(Method::Join, $grouped->joins[0]->getMethod()); + $this->assertSame(Method::CrossJoin, $grouped->joins[2]->getMethod()); + } + + public function testGroupByTypeUnions(): void + { + $queries = [ + Query::union([Query::equal('x', [1])]), + Query::unionAll([Query::equal('y', [2])]), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(2, $grouped->unions); + } + + public function testMergeConcatenates(): void + { + $a = [Query::equal('name', ['John'])]; + $b = [Query::greaterThan('age', 18)]; + + $result = Query::merge($a, $b); + $this->assertCount(2, $result); + $this->assertSame(Method::Equal, $result[0]->getMethod()); + $this->assertSame(Method::GreaterThan, $result[1]->getMethod()); + } + + public function testMergeLimitOverrides(): void + { + $a = [Query::limit(10)]; + $b = [Query::limit(50)]; + + $result = Query::merge($a, $b); + $this->assertCount(1, $result); + $this->assertEquals(50, $result[0]->getValue()); + } + + public function testMergeOffsetOverrides(): void + { + $a = [Query::offset(5), Query::equal('x', [1])]; + $b = [Query::offset(100)]; + + $result = Query::merge($a, $b); + $this->assertCount(2, $result); + // equal stays, offset replaced + $this->assertSame(Method::Equal, $result[0]->getMethod()); + $this->assertEquals(100, $result[1]->getValue()); + } + + public function testMergeCursorOverrides(): void + { + $a = [Query::cursorAfter('abc')]; + $b = [Query::cursorAfter('xyz')]; + + $result = Query::merge($a, $b); + $this->assertCount(1, $result); + $this->assertEquals('xyz', $result[0]->getValue()); + } + + public function testDiffReturnsUnique(): void + { + $shared = Query::equal('name', ['John']); + $a = [$shared, Query::greaterThan('age', 18)]; + $b = [$shared]; + + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertSame(Method::GreaterThan, $result[0]->getMethod()); + } + + public function testDiffEmpty(): void + { + $q = Query::equal('x', [1]); + $result = Query::diff([$q], [$q]); + $this->assertCount(0, $result); + } + + public function testDiffNoOverlap(): void + { + $a = [Query::equal('x', [1])]; + $b = [Query::equal('y', [2])]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + } + + public function testValidatePassesAllowed(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('age', 18), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(0, $errors); + } + + public function testValidateFailsInvalid(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('secret', 42), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('secret', $errors[0]); + } + + public function testValidateSkipsNoAttribute(): void + { + $queries = [ + Query::limit(10), + Query::offset(5), + Query::distinct(), + Query::orderRandom(), + ]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateRecursesNested(): void + { + $queries = [ + Query::or([ + Query::equal('name', ['John']), + Query::equal('invalid', ['x']), + ]), + ]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('invalid', $errors[0]); + } + + public function testValidateGroupByColumns(): void + { + $queries = [Query::groupBy(['status', 'bad_col'])]; + $errors = Query::validate($queries, ['status']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('bad_col', $errors[0]); + } + + public function testValidateSkipsStar(): void + { + $queries = [Query::count()]; // attribute = '*' + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testPageStaticHelper(): void + { + $result = Query::page(3, 10); + $this->assertCount(2, $result); + $this->assertSame(Method::Limit, $result[0]->getMethod()); + $this->assertEquals(10, $result[0]->getValue()); + $this->assertSame(Method::Offset, $result[1]->getMethod()); + $this->assertEquals(20, $result[1]->getValue()); + } + + public function testPageStaticHelperFirstPage(): void + { + $result = Query::page(1); + $this->assertEquals(25, $result[0]->getValue()); + $this->assertEquals(0, $result[1]->getValue()); + } + + public function testPageStaticHelperZero(): void + { + $this->expectException(ValidationException::class); + Query::page(0, 10); + } + + public function testPageStaticHelperLarge(): void + { + $result = Query::page(500, 50); + $this->assertEquals(50, $result[0]->getValue()); + $this->assertEquals(24950, $result[1]->getValue()); + } + // ADDITIONAL EDGE CASES + + + public function testGroupByTypeAllNewTypes(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::count('*', 'total'), + Query::sum('price'), + Query::groupBy(['status']), + Query::having([Query::greaterThan('total', 5)]), + Query::distinct(), + Query::join('orders', 'u.id', 'o.uid'), + Query::union([Query::equal('x', [1])]), + Query::select(['name']), + Query::orderAsc('name'), + Query::limit(10), + Query::offset(5), + ]; + + $grouped = Query::groupByType($queries); + + $this->assertCount(1, $grouped->filters); + $this->assertCount(1, $grouped->selections); + $this->assertCount(2, $grouped->aggregations); + $this->assertEquals(['status'], $grouped->groupBy); + $this->assertCount(1, $grouped->having); + $this->assertTrue($grouped->distinct); + $this->assertCount(1, $grouped->joins); + $this->assertCount(1, $grouped->unions); + $this->assertEquals(10, $grouped->limit); + $this->assertEquals(5, $grouped->offset); + $this->assertEquals(['name'], $grouped->orderAttributes); + } + + public function testGroupByTypeMultipleGroupByMerges(): void + { + $queries = [ + Query::groupBy(['a', 'b']), + Query::groupBy(['c']), + ]; + $grouped = Query::groupByType($queries); + $this->assertEquals(['a', 'b', 'c'], $grouped->groupBy); + } + + public function testGroupByTypeMultipleDistinct(): void + { + $queries = [ + Query::distinct(), + Query::distinct(), + ]; + $grouped = Query::groupByType($queries); + $this->assertTrue($grouped->distinct); + } + + public function testGroupByTypeMultipleHaving(): void + { + $queries = [ + Query::having([Query::greaterThan('x', 1)]), + Query::having([Query::lessThan('y', 100)]), + ]; + $grouped = Query::groupByType($queries); + $this->assertCount(2, $grouped->having); + } + + public function testGroupByTypeRawGoesToFilters(): void + { + $queries = [Query::raw('1 = 1')]; + $grouped = Query::groupByType($queries); + $this->assertCount(1, $grouped->filters); + $this->assertSame(Method::Raw, $grouped->filters[0]->getMethod()); + } + + public function testGroupByTypeEmptyNewKeys(): void + { + $grouped = Query::groupByType([]); + $this->assertEquals([], $grouped->aggregations); + $this->assertEquals([], $grouped->groupBy); + $this->assertEquals([], $grouped->having); + $this->assertFalse($grouped->distinct); + $this->assertEquals([], $grouped->joins); + $this->assertEquals([], $grouped->unions); + } + + public function testMergeEmptyA(): void + { + $b = [Query::equal('x', [1])]; + $result = Query::merge([], $b); + $this->assertCount(1, $result); + } + + public function testMergeEmptyB(): void + { + $a = [Query::equal('x', [1])]; + $result = Query::merge($a, []); + $this->assertCount(1, $result); + } + + public function testMergeBothEmpty(): void + { + $result = Query::merge([], []); + $this->assertCount(0, $result); + } + + public function testMergePreservesNonSingularFromBoth(): void + { + $a = [Query::equal('a', [1]), Query::greaterThan('b', 2)]; + $b = [Query::lessThan('c', 3), Query::equal('d', [4])]; + $result = Query::merge($a, $b); + $this->assertCount(4, $result); + } + + public function testMergeBothLimitAndOffset(): void + { + $a = [Query::limit(10), Query::offset(5)]; + $b = [Query::limit(50), Query::offset(100)]; + $result = Query::merge($a, $b); + // Both should be overridden + $this->assertCount(2, $result); + $limits = array_filter($result, fn (Query $q) => $q->getMethod() === Method::Limit); + $offsets = array_filter($result, fn (Query $q) => $q->getMethod() === Method::Offset); + $this->assertEquals(50, array_values($limits)[0]->getValue()); + $this->assertEquals(100, array_values($offsets)[0]->getValue()); + } + + public function testMergeCursorTypesIndependent(): void + { + $a = [Query::cursorAfter('abc')]; + $b = [Query::cursorBefore('xyz')]; + $result = Query::merge($a, $b); + // cursorAfter and cursorBefore are different types, both should exist + $this->assertCount(2, $result); + } + + public function testMergeMixedWithFilters(): void + { + $a = [Query::equal('x', [1]), Query::limit(10), Query::offset(0)]; + $b = [Query::greaterThan('y', 5), Query::limit(50)]; + $result = Query::merge($a, $b); + // equal stays, old limit removed, offset stays, greaterThan added, new limit added + $this->assertCount(4, $result); + } + + public function testDiffEmptyA(): void + { + $result = Query::diff([], [Query::equal('x', [1])]); + $this->assertCount(0, $result); + } + + public function testDiffEmptyB(): void + { + $a = [Query::equal('x', [1]), Query::limit(10)]; + $result = Query::diff($a, []); + $this->assertCount(2, $result); + } + + public function testDiffBothEmpty(): void + { + $result = Query::diff([], []); + $this->assertCount(0, $result); + } + + public function testDiffPartialOverlap(): void + { + $shared1 = Query::equal('a', [1]); + $shared2 = Query::equal('b', [2]); + $unique = Query::greaterThan('c', 3); + + $a = [$shared1, $shared2, $unique]; + $b = [$shared1, $shared2]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertSame(Method::GreaterThan, $result[0]->getMethod()); + } + + public function testDiffByValueNotReference(): void + { + $a = [Query::equal('x', [1])]; + $b = [Query::equal('x', [1])]; // Different objects, same content + $result = Query::diff($a, $b); + $this->assertCount(0, $result); // Should match by value + } + + public function testDiffDoesNotRemoveDuplicatesInA(): void + { + $a = [Query::equal('x', [1]), Query::equal('x', [1])]; + $b = []; + $result = Query::diff($a, $b); + $this->assertCount(2, $result); + } + + public function testDiffComplexNested(): void + { + $nested = Query::or([Query::equal('a', [1]), Query::equal('b', [2])]); + $a = [$nested, Query::limit(10)]; + $b = [$nested]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertSame(Method::Limit, $result[0]->getMethod()); + } + + public function testValidateEmptyQueries(): void + { + $errors = Query::validate([], ['name', 'age']); + $this->assertCount(0, $errors); + } + + public function testValidateEmptyAllowedAttributes(): void + { + $queries = [Query::equal('name', ['John'])]; + $errors = Query::validate($queries, []); + $this->assertCount(1, $errors); + } + + public function testValidateMixedValidAndInvalid(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::greaterThan('age', 18), + Query::equal('secret', ['x']), + Query::lessThan('forbidden', 5), + ]; + $errors = Query::validate($queries, ['name', 'age']); + $this->assertCount(2, $errors); + } + + public function testValidateNestedMultipleLevels(): void + { + $queries = [ + Query::or([ + Query::and([ + Query::equal('name', ['John']), + Query::equal('bad', ['x']), + ]), + Query::equal('also_bad', ['y']), + ]), + ]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(2, $errors); + } + + public function testValidateHavingInnerQueries(): void + { + $queries = [ + Query::having([ + Query::greaterThan('total', 5), + Query::lessThan('bad_col', 100), + ]), + ]; + $errors = Query::validate($queries, ['total']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('bad_col', $errors[0]); + } + + public function testValidateGroupByAllValid(): void + { + $queries = [Query::groupBy(['status', 'country'])]; + $errors = Query::validate($queries, ['status', 'country']); + $this->assertCount(0, $errors); + } + + public function testValidateGroupByMultipleInvalid(): void + { + $queries = [Query::groupBy(['status', 'bad1', 'bad2'])]; + $errors = Query::validate($queries, ['status']); + $this->assertCount(2, $errors); + } + + public function testValidateAggregateWithAttribute(): void + { + $queries = [Query::sum('forbidden_col')]; + $errors = Query::validate($queries, ['allowed_col']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('forbidden_col', $errors[0]); + } + + public function testValidateAggregateWithAllowedAttribute(): void + { + $queries = [Query::sum('price')]; + $errors = Query::validate($queries, ['price']); + $this->assertCount(0, $errors); + } + + public function testValidateDollarSignAttributes(): void + { + $queries = [ + Query::equal('$id', ['abc']), + Query::greaterThan('$createdAt', '2024-01-01'), + ]; + $errors = Query::validate($queries, ['$id', '$createdAt']); + $this->assertCount(0, $errors); + } + + public function testValidateJoinAttributeIsTableName(): void + { + // Join's attribute is the table name, not a column, so it gets validated + $queries = [Query::join('orders', 'u.id', 'o.uid')]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + $this->assertStringContainsString('orders', $errors[0]); + } + + public function testValidateSelectSkipped(): void + { + $queries = [Query::select(['any_col', 'other_col'])]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateExistsSkipped(): void + { + $queries = [Query::exists(['any_col'])]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testValidateOrderAscAttribute(): void + { + $queries = [Query::orderAsc('forbidden')]; + $errors = Query::validate($queries, ['name']); + $this->assertCount(1, $errors); + } + + public function testValidateOrderDescAttribute(): void + { + $queries = [Query::orderDesc('allowed')]; + $errors = Query::validate($queries, ['allowed']); + $this->assertCount(0, $errors); + } + + public function testValidateEmptyAttributeSkipped(): void + { + // Queries with empty string attribute should be skipped + $queries = [Query::orderAsc('')]; + $errors = Query::validate($queries, []); + $this->assertCount(0, $errors); + } + + public function testGetByTypeWithNewTypes(): void + { + $queries = [ + Query::count('*', 'total'), + Query::sum('price'), + Query::join('t', 'a', 'b'), + Query::distinct(), + Query::groupBy(['status']), + ]; + + $aggTypes = array_values(array_filter(Method::cases(), fn (Method $m) => $m->isAggregate())); + $aggs = Query::getByType($queries, $aggTypes); + $this->assertCount(2, $aggs); + + $joinTypes = array_values(array_filter(Method::cases(), fn (Method $m) => $m->isJoin())); + $joins = Query::getByType($queries, $joinTypes); + $this->assertCount(1, $joins); + + $distinct = Query::getByType($queries, [Method::Distinct]); + $this->assertCount(1, $distinct); + } + + // ── Query::diff() edge cases (exercises array_any) ───────── + + public function testDiffIdenticalArraysReturnEmpty(): void + { + $queries = [Query::equal('a', [1]), Query::limit(10), Query::orderAsc('name')]; + $result = Query::diff($queries, $queries); + $this->assertCount(0, $result); + } + + public function testDiffLargeArrayUsesArrayAny(): void + { + $a = []; + $b = []; + for ($i = 0; $i < 100; $i++) { + $a[] = Query::equal('col', [$i]); + if ($i % 2 === 0) { + $b[] = Query::equal('col', [$i]); + } + } + $result = Query::diff($a, $b); + $this->assertCount(50, $result); + } + + public function testDiffPreservesOrder(): void + { + $a = [Query::equal('x', [3]), Query::equal('x', [1]), Query::equal('x', [2])]; + $b = [Query::equal('x', [1])]; + $result = Query::diff($a, $b); + $this->assertCount(2, $result); + $this->assertSame([3], $result[0]->getValues()); + $this->assertSame([2], $result[1]->getValues()); + } + + public function testDiffWithDifferentMethodsSameAttribute(): void + { + $a = [Query::equal('name', ['John']), Query::notEqual('name', 'John')]; + $b = [Query::equal('name', ['John'])]; + $result = Query::diff($a, $b); + $this->assertCount(1, $result); + $this->assertSame(Method::NotEqual, $result[0]->getMethod()); + } + + public function testDiffSingleElementArrays(): void + { + $a = [Query::limit(10)]; + $b = [Query::limit(10)]; + $this->assertCount(0, Query::diff($a, $b)); + + $b = [Query::limit(20)]; + $this->assertCount(1, Query::diff($a, $b)); + } + + // ── #[\Deprecated] on Query::contains() ──────────────────── + + public function testContainsHasDeprecatedAttribute(): void + { + $ref = new \ReflectionMethod(Query::class, 'contains'); + $attrs = $ref->getAttributes(\Deprecated::class); + $this->assertCount(1, $attrs); + + /** @var \Deprecated $instance */ + $instance = $attrs[0]->newInstance(); + $this->assertNotNull($instance->message); + $this->assertStringContainsString('containsAny', $instance->message); + } + + public function testContainsStillFunctions(): void + { + $query = @Query::contains('tags', ['a', 'b']); + $this->assertSame(Method::Contains, $query->getMethod()); + $this->assertSame('tags', $query->getAttribute()); + $this->assertSame(['a', 'b'], $query->getValues()); + } + + // ══════════════════════════════════════════════════════════════ + // Coverage: Query.php uncovered lines + // ══════════════════════════════════════════════════════════════ + + // ── Query::page() with perPage < 1 (line 1152) ────────────── + + public function testPageThrowsOnZeroPerPage(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Per page must be >= 1'); + Query::page(1, 0); + } + + public function testPageThrowsOnNegativePerPage(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Per page must be >= 1'); + Query::page(1, -5); } } diff --git a/tests/Query/QueryParseTest.php b/tests/Query/QueryParseTest.php index 39df897..5babc5b 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Exception; +use Utopia\Query\Method; use Utopia\Query\Query; class QueryParseTest extends TestCase @@ -12,7 +13,7 @@ public function testParseValidJson(): void { $json = '{"method":"equal","attribute":"name","values":["John"]}'; $query = Query::parse($json); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John'], $query->getValues()); } @@ -56,7 +57,7 @@ public function testParseWithDefaultValues(): void { $json = '{"method":"isNull"}'; $query = Query::parse($json); - $this->assertEquals('isNull', $query->getMethod()); + $this->assertSame(Method::IsNull, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -68,7 +69,7 @@ public function testParseQueryFromArray(): void 'attribute' => 'name', 'values' => ['John'], ]); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); } public function testParseNestedLogicalQuery(): void @@ -83,7 +84,7 @@ public function testParseNestedLogicalQuery(): void ]); $query = Query::parse($json); - $this->assertEquals(Query::TYPE_OR, $query->getMethod()); + $this->assertSame(Method::Or, $query->getMethod()); $this->assertCount(2, $query->getValues()); $this->assertInstanceOf(Query::class, $query->getValues()[0]); $this->assertEquals('John', $query->getValues()[0]->getValue()); @@ -96,8 +97,8 @@ public function testParseQueries(): void '{"method":"limit","values":[25]}', ]); $this->assertCount(2, $queries); - $this->assertEquals('equal', $queries[0]->getMethod()); - $this->assertEquals('limit', $queries[1]->getMethod()); + $this->assertSame(Method::Equal, $queries[0]->getMethod()); + $this->assertSame(Method::Limit, $queries[1]->getMethod()); } public function testToArray(): void @@ -187,4 +188,396 @@ public function testRoundTripNestedParseSerialization(): void $this->assertCount(2, $parsed->getValues()); $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); } + + public function testRoundTripCount(): void + { + $original = Query::count('id', 'total'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Count, $parsed->getMethod()); + $this->assertEquals('id', $parsed->getAttribute()); + $this->assertEquals(['total'], $parsed->getValues()); + } + + public function testRoundTripSum(): void + { + $original = Query::sum('price'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Sum, $parsed->getMethod()); + $this->assertEquals('price', $parsed->getAttribute()); + } + + public function testRoundTripGroupBy(): void + { + $original = Query::groupBy(['status', 'country']); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::GroupBy, $parsed->getMethod()); + $this->assertEquals(['status', 'country'], $parsed->getValues()); + } + + public function testRoundTripHaving(): void + { + $original = Query::having([Query::greaterThan('total', 5)]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Having, $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } + + public function testRoundTripDistinct(): void + { + $original = Query::distinct(); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Distinct, $parsed->getMethod()); + } + + public function testRoundTripJoin(): void + { + $original = Query::join('orders', 'users.id', 'orders.user_id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Join, $parsed->getMethod()); + $this->assertEquals('orders', $parsed->getAttribute()); + $this->assertEquals(['users.id', '=', 'orders.user_id'], $parsed->getValues()); + } + + public function testRoundTripCrossJoin(): void + { + $original = Query::crossJoin('colors'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::CrossJoin, $parsed->getMethod()); + $this->assertEquals('colors', $parsed->getAttribute()); + } + + public function testRoundTripRaw(): void + { + $original = Query::raw('score > ?', [10]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Raw, $parsed->getMethod()); + $this->assertEquals('score > ?', $parsed->getAttribute()); + $this->assertEquals([10], $parsed->getValues()); + } + + public function testRoundTripUnion(): void + { + $original = Query::union([Query::equal('x', [1])]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Union, $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } + // ADDITIONAL EDGE CASES + + + public function testRoundTripAvg(): void + { + $original = Query::avg('score', 'avg_score'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Avg, $parsed->getMethod()); + $this->assertEquals('score', $parsed->getAttribute()); + $this->assertEquals(['avg_score'], $parsed->getValues()); + } + + public function testRoundTripMin(): void + { + $original = Query::min('price'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Min, $parsed->getMethod()); + $this->assertEquals('price', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripMax(): void + { + $original = Query::max('age', 'oldest'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Max, $parsed->getMethod()); + $this->assertEquals(['oldest'], $parsed->getValues()); + } + + public function testRoundTripCountWithoutAlias(): void + { + $original = Query::count('id'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Count, $parsed->getMethod()); + $this->assertEquals('id', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripGroupByEmpty(): void + { + $original = Query::groupBy([]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::GroupBy, $parsed->getMethod()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripHavingMultiple(): void + { + $original = Query::having([ + Query::greaterThan('total', 5), + Query::lessThan('total', 100), + ]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertCount(2, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + $this->assertInstanceOf(Query::class, $parsed->getValues()[1]); + } + + public function testRoundTripLeftJoin(): void + { + $original = Query::leftJoin('profiles', 'u.id', 'p.uid'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::LeftJoin, $parsed->getMethod()); + $this->assertEquals('profiles', $parsed->getAttribute()); + $this->assertEquals(['u.id', '=', 'p.uid'], $parsed->getValues()); + } + + public function testRoundTripRightJoin(): void + { + $original = Query::rightJoin('orders', 'u.id', 'o.uid'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::RightJoin, $parsed->getMethod()); + } + + public function testRoundTripJoinWithSpecialOperator(): void + { + $original = Query::join('t', 'a.val', 'b.val', '!='); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals(['a.val', '!=', 'b.val'], $parsed->getValues()); + } + + public function testRoundTripUnionAll(): void + { + $original = Query::unionAll([Query::equal('y', [2])]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::UnionAll, $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + $this->assertInstanceOf(Query::class, $parsed->getValues()[0]); + } + + public function testRoundTripRawNoBindings(): void + { + $original = Query::raw('1 = 1'); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Raw, $parsed->getMethod()); + $this->assertEquals('1 = 1', $parsed->getAttribute()); + $this->assertEquals([], $parsed->getValues()); + } + + public function testRoundTripRawWithMultipleBindings(): void + { + $original = Query::raw('a > ? AND b < ?', [10, 20]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertEquals([10, 20], $parsed->getValues()); + } + + public function testRoundTripComplexNested(): void + { + $original = Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::or([ + Query::equal('b', [2]), + Query::equal('c', [3]), + ]), + ]), + ]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::Or, $parsed->getMethod()); + $this->assertCount(1, $parsed->getValues()); + + /** @var Query $inner */ + $inner = $parsed->getValues()[0]; + $this->assertSame(Method::And, $inner->getMethod()); + $this->assertCount(2, $inner->getValues()); + } + + public function testParseEmptyStringThrows(): void + { + $this->expectException(Exception::class); + Query::parse(''); + } + + public function testParseWhitespaceThrows(): void + { + $this->expectException(Exception::class); + Query::parse(' '); + } + + public function testParseMissingMethodUsesEmptyString(): void + { + // method defaults to '' which is not a valid method + $this->expectException(Exception::class); + $this->expectExceptionMessage('Invalid query method: '); + Query::parse('{"attribute":"x","values":[]}'); + } + + public function testParseMissingAttributeDefaultsToEmpty(): void + { + $query = Query::parse('{"method":"isNull","values":[]}'); + $this->assertEquals('', $query->getAttribute()); + } + + public function testParseMissingValuesDefaultsToEmpty(): void + { + $query = Query::parse('{"method":"isNull"}'); + $this->assertEquals([], $query->getValues()); + } + + public function testParseExtraFieldsIgnored(): void + { + $query = Query::parse('{"method":"equal","attribute":"x","values":[1],"extra":"ignored"}'); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertEquals('x', $query->getAttribute()); + } + + public function testParseNonObjectJsonThrows(): void + { + $this->expectException(Exception::class); + Query::parse('"just a string"'); + } + + public function testParseJsonArrayThrows(): void + { + $this->expectException(Exception::class); + Query::parse('[1,2,3]'); + } + + public function testToArrayCountWithAlias(): void + { + $query = Query::count('id', 'total'); + $array = $query->toArray(); + $this->assertEquals('count', $array['method']); + $this->assertEquals('id', $array['attribute']); + $this->assertEquals(['total'], $array['values']); + } + + public function testToArrayCountWithoutAlias(): void + { + $query = Query::count(); + $array = $query->toArray(); + $this->assertEquals('count', $array['method']); + $this->assertEquals('*', $array['attribute']); + $this->assertEquals([], $array['values']); + } + + public function testToArrayDistinct(): void + { + $query = Query::distinct(); + $array = $query->toArray(); + $this->assertEquals('distinct', $array['method']); + $this->assertArrayNotHasKey('attribute', $array); + $this->assertEquals([], $array['values']); + } + + public function testToArrayJoinPreservesOperator(): void + { + $query = Query::join('t', 'a', 'b', '!='); + $array = $query->toArray(); + $this->assertEquals(['a', '!=', 'b'], $array['values']); + } + + public function testToArrayCrossJoin(): void + { + $query = Query::crossJoin('t'); + $array = $query->toArray(); + $this->assertEquals('crossJoin', $array['method']); + $this->assertEquals('t', $array['attribute']); + $this->assertEquals([], $array['values']); + } + + public function testToArrayHaving(): void + { + $query = Query::having([Query::greaterThan('x', 1), Query::lessThan('y', 10)]); + $array = $query->toArray(); + $this->assertEquals('having', $array['method']); + + /** @var array> $values */ + $values = $array['values'] ?? []; + $this->assertCount(2, $values); + $this->assertEquals('greaterThan', $values[0]['method']); + } + + public function testToArrayUnionAll(): void + { + $query = Query::unionAll([Query::equal('x', [1])]); + $array = $query->toArray(); + $this->assertEquals('unionAll', $array['method']); + + /** @var array> $values */ + $values = $array['values'] ?? []; + $this->assertCount(1, $values); + } + + public function testToArrayRaw(): void + { + $query = Query::raw('a > ?', [10]); + $array = $query->toArray(); + $this->assertEquals('raw', $array['method']); + $this->assertEquals('a > ?', $array['attribute']); + $this->assertEquals([10], $array['values']); + } + + public function testParseQueriesEmpty(): void + { + $result = Query::parseQueries([]); + $this->assertCount(0, $result); + } + + public function testParseQueriesWithNewTypes(): void + { + $queries = Query::parseQueries([ + '{"method":"count","attribute":"*","values":["total"]}', + '{"method":"groupBy","values":["status","country"]}', + '{"method":"distinct","values":[]}', + '{"method":"join","attribute":"orders","values":["u.id","=","o.uid"]}', + ]); + $this->assertCount(4, $queries); + $this->assertSame(Method::Count, $queries[0]->getMethod()); + $this->assertSame(Method::GroupBy, $queries[1]->getMethod()); + $this->assertSame(Method::Distinct, $queries[2]->getMethod()); + $this->assertSame(Method::Join, $queries[3]->getMethod()); + } + + public function testToStringGroupByProducesValidJson(): void + { + $query = Query::groupBy(['a', 'b']); + $json = $query->toString(); + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('groupBy', $decoded['method']); + $this->assertEquals(['a', 'b'], $decoded['values']); + } + + public function testToStringRawProducesValidJson(): void + { + $query = Query::raw('x > ? AND y < ?', [1, 2]); + $json = $query->toString(); + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('raw', $decoded['method']); + $this->assertEquals('x > ? AND y < ?', $decoded['attribute']); + $this->assertEquals([1, 2], $decoded['values']); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index 1fb05bd..5cee4ea 100644 --- a/tests/Query/QueryTest.php +++ b/tests/Query/QueryTest.php @@ -3,6 +3,10 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Builder\MySQL as MySQLBuilder; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; use Utopia\Query\Query; class QueryTest extends TestCase @@ -10,7 +14,7 @@ class QueryTest extends TestCase public function testConstructorDefaults(): void { $query = new Query('equal'); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('', $query->getAttribute()); $this->assertEquals([], $query->getValues()); } @@ -18,26 +22,26 @@ public function testConstructorDefaults(): void public function testConstructorWithAllParams(): void { $query = new Query('equal', 'name', ['John']); - $this->assertEquals('equal', $query->getMethod()); + $this->assertSame(Method::Equal, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); $this->assertEquals(['John'], $query->getValues()); } public function testConstructorOrderAscDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC); + $query = new Query(Method::OrderAsc); $this->assertEquals('', $query->getAttribute()); } public function testConstructorOrderDescDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_DESC); + $query = new Query(Method::OrderDesc); $this->assertEquals('', $query->getAttribute()); } public function testConstructorOrderAscWithAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC, 'name'); + $query = new Query(Method::OrderAsc, 'name'); $this->assertEquals('name', $query->getAttribute()); } @@ -63,7 +67,7 @@ public function testSetMethod(): void { $query = new Query('equal', 'name', ['John']); $result = $query->setMethod('notEqual'); - $this->assertEquals('notEqual', $query->getMethod()); + $this->assertSame(Method::NotEqual, $query->getMethod()); $this->assertSame($query, $result); } @@ -106,31 +110,32 @@ public function testOnArray(): void $this->assertTrue($query->onArray()); } - public function testConstants(): void + public function testMethodEnumValues(): void { - $this->assertEquals('ASC', Query::ORDER_ASC); - $this->assertEquals('DESC', Query::ORDER_DESC); - $this->assertEquals('RANDOM', Query::ORDER_RANDOM); - $this->assertEquals('after', Query::CURSOR_AFTER); - $this->assertEquals('before', Query::CURSOR_BEFORE); + $this->assertEquals('ASC', OrderDirection::Asc->value); + $this->assertEquals('DESC', OrderDirection::Desc->value); + $this->assertEquals('RANDOM', OrderDirection::Random->value); + $this->assertEquals('after', CursorDirection::After->value); + $this->assertEquals('before', CursorDirection::Before->value); } - public function testVectorTypesConstant(): void + public function testVectorMethodsAreVector(): void { - $this->assertContains(Query::TYPE_VECTOR_DOT, Query::VECTOR_TYPES); - $this->assertContains(Query::TYPE_VECTOR_COSINE, Query::VECTOR_TYPES); - $this->assertContains(Query::TYPE_VECTOR_EUCLIDEAN, Query::VECTOR_TYPES); - $this->assertCount(3, Query::VECTOR_TYPES); + $this->assertTrue(Method::VectorDot->isVector()); + $this->assertTrue(Method::VectorCosine->isVector()); + $this->assertTrue(Method::VectorEuclidean->isVector()); + $vectorMethods = array_filter(Method::cases(), fn (Method $m) => $m->isVector()); + $this->assertCount(3, $vectorMethods); } - public function testTypesConstantContainsAll(): void + public function testAllMethodCasesAreValid(): void { - $this->assertContains(Query::TYPE_EQUAL, Query::TYPES); - $this->assertContains(Query::TYPE_REGEX, Query::TYPES); - $this->assertContains(Query::TYPE_AND, Query::TYPES); - $this->assertContains(Query::TYPE_OR, Query::TYPES); - $this->assertContains(Query::TYPE_ELEM_MATCH, Query::TYPES); - $this->assertContains(Query::TYPE_VECTOR_DOT, Query::TYPES); + $this->assertTrue(Query::isMethod(Method::Equal->value)); + $this->assertTrue(Query::isMethod(Method::Regex->value)); + $this->assertTrue(Query::isMethod(Method::And->value)); + $this->assertTrue(Query::isMethod(Method::Or->value)); + $this->assertTrue(Query::isMethod(Method::ElemMatch->value)); + $this->assertTrue(Query::isMethod(Method::VectorDot->value)); } public function testEmptyValues(): void @@ -138,4 +143,503 @@ public function testEmptyValues(): void $query = Query::equal('name', []); $this->assertEquals([], $query->getValues()); } + + public function testMethodContainsNewTypes(): void + { + $this->assertSame(Method::Count, Method::from('count')); + $this->assertSame(Method::Sum, Method::from('sum')); + $this->assertSame(Method::Avg, Method::from('avg')); + $this->assertSame(Method::Min, Method::from('min')); + $this->assertSame(Method::Max, Method::from('max')); + $this->assertSame(Method::GroupBy, Method::from('groupBy')); + $this->assertSame(Method::Having, Method::from('having')); + $this->assertSame(Method::Distinct, Method::from('distinct')); + $this->assertSame(Method::Join, Method::from('join')); + $this->assertSame(Method::LeftJoin, Method::from('leftJoin')); + $this->assertSame(Method::RightJoin, Method::from('rightJoin')); + $this->assertSame(Method::CrossJoin, Method::from('crossJoin')); + $this->assertSame(Method::Union, Method::from('union')); + $this->assertSame(Method::UnionAll, Method::from('unionAll')); + $this->assertSame(Method::Raw, Method::from('raw')); + } + + public function testIsMethodNewTypes(): void + { + $this->assertTrue(Query::isMethod('count')); + $this->assertTrue(Query::isMethod('sum')); + $this->assertTrue(Query::isMethod('avg')); + $this->assertTrue(Query::isMethod('min')); + $this->assertTrue(Query::isMethod('max')); + $this->assertTrue(Query::isMethod('groupBy')); + $this->assertTrue(Query::isMethod('having')); + $this->assertTrue(Query::isMethod('distinct')); + $this->assertTrue(Query::isMethod('join')); + $this->assertTrue(Query::isMethod('leftJoin')); + $this->assertTrue(Query::isMethod('rightJoin')); + $this->assertTrue(Query::isMethod('crossJoin')); + $this->assertTrue(Query::isMethod('union')); + $this->assertTrue(Query::isMethod('unionAll')); + $this->assertTrue(Query::isMethod('raw')); + } + + public function testDistinctFactory(): void + { + $query = Query::distinct(); + $this->assertSame(Method::Distinct, $query->getMethod()); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactory(): void + { + $query = Query::raw('score > ?', [10]); + $this->assertSame(Method::Raw, $query->getMethod()); + $this->assertEquals('score > ?', $query->getAttribute()); + $this->assertEquals([10], $query->getValues()); + } + + public function testUnionFactory(): void + { + $inner = [Query::equal('x', [1])]; + $query = Query::union($inner); + $this->assertSame(Method::Union, $query->getMethod()); + $this->assertCount(1, $query->getValues()); + } + + public function testUnionAllFactory(): void + { + $inner = [Query::equal('x', [1])]; + $query = Query::unionAll($inner); + $this->assertSame(Method::UnionAll, $query->getMethod()); + } + // ADDITIONAL EDGE CASES + + public function testMethodNoDuplicateValues(): void + { + $values = array_map(fn (Method $m) => $m->value, Method::cases()); + $this->assertEquals(count($values), count(array_unique($values))); + } + + public function testAggregateMethodsNoDuplicates(): void + { + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + $values = array_map(fn (Method $m) => $m->value, $aggMethods); + $this->assertEquals(count($values), count(array_unique($values))); + } + + public function testJoinMethodsNoDuplicates(): void + { + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + $values = array_map(fn (Method $m) => $m->value, $joinMethods); + $this->assertEquals(count($values), count(array_unique($values))); + } + + public function testAggregateMethodsAreValidMethods(): void + { + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + foreach ($aggMethods as $method) { + $this->assertSame($method, Method::from($method->value)); + } + } + + public function testJoinMethodsAreValidMethods(): void + { + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + foreach ($joinMethods as $method) { + $this->assertSame($method, Method::from($method->value)); + } + } + + public function testIsMethodCaseSensitive(): void + { + $this->assertFalse(Query::isMethod('COUNT')); + $this->assertFalse(Query::isMethod('Sum')); + $this->assertFalse(Query::isMethod('JOIN')); + $this->assertFalse(Query::isMethod('DISTINCT')); + $this->assertFalse(Query::isMethod('GroupBy')); + $this->assertFalse(Query::isMethod('RAW')); + } + + public function testRawFactoryEmptySql(): void + { + $query = Query::raw(''); + $this->assertEquals('', $query->getAttribute()); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactoryEmptyBindings(): void + { + $query = Query::raw('1 = 1', []); + $this->assertEquals([], $query->getValues()); + } + + public function testRawFactoryMixedBindings(): void + { + $query = Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14]); + $this->assertEquals(['str', 42, 3.14], $query->getValues()); + } + + public function testUnionIsNested(): void + { + $query = Query::union([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testUnionAllIsNested(): void + { + $query = Query::unionAll([Query::equal('x', [1])]); + $this->assertTrue($query->isNested()); + } + + public function testDistinctNotNested(): void + { + $this->assertFalse(Query::distinct()->isNested()); + } + + public function testCountNotNested(): void + { + $this->assertFalse(Query::count()->isNested()); + } + + public function testGroupByNotNested(): void + { + $this->assertFalse(Query::groupBy(['a'])->isNested()); + } + + public function testJoinNotNested(): void + { + $this->assertFalse(Query::join('t', 'a', 'b')->isNested()); + } + + public function testRawNotNested(): void + { + $this->assertFalse(Query::raw('1=1')->isNested()); + } + + public function testHavingNested(): void + { + $this->assertTrue(Query::having([Query::equal('x', [1])])->isNested()); + } + + public function testCloneDeepCopiesHavingQueries(): void + { + $inner = Query::greaterThan('total', 5); + $outer = Query::having([$inner]); + $cloned = clone $outer; + + $clonedValues = $cloned->getValues(); + $this->assertNotSame($inner, $clonedValues[0]); + $this->assertInstanceOf(Query::class, $clonedValues[0]); + + /** @var Query $clonedInner */ + $clonedInner = $clonedValues[0]; + $this->assertSame(Method::GreaterThan, $clonedInner->getMethod()); + } + + public function testCloneDeepCopiesUnionQueries(): void + { + $inner = Query::equal('x', [1]); + $outer = Query::union([$inner]); + $cloned = clone $outer; + + $clonedValues = $cloned->getValues(); + $this->assertNotSame($inner, $clonedValues[0]); + } + + public function testCountEnumValue(): void + { + $this->assertEquals('count', Method::Count->value); + } + + public function testSumEnumValue(): void + { + $this->assertEquals('sum', Method::Sum->value); + } + + public function testAvgEnumValue(): void + { + $this->assertEquals('avg', Method::Avg->value); + } + + public function testMinEnumValue(): void + { + $this->assertEquals('min', Method::Min->value); + } + + public function testMaxEnumValue(): void + { + $this->assertEquals('max', Method::Max->value); + } + + public function testGroupByEnumValue(): void + { + $this->assertEquals('groupBy', Method::GroupBy->value); + } + + public function testHavingEnumValue(): void + { + $this->assertEquals('having', Method::Having->value); + } + + public function testDistinctEnumValue(): void + { + $this->assertEquals('distinct', Method::Distinct->value); + } + + public function testJoinEnumValue(): void + { + $this->assertEquals('join', Method::Join->value); + } + + public function testLeftJoinEnumValue(): void + { + $this->assertEquals('leftJoin', Method::LeftJoin->value); + } + + public function testRightJoinEnumValue(): void + { + $this->assertEquals('rightJoin', Method::RightJoin->value); + } + + public function testCrossJoinEnumValue(): void + { + $this->assertEquals('crossJoin', Method::CrossJoin->value); + } + + public function testUnionEnumValue(): void + { + $this->assertEquals('union', Method::Union->value); + } + + public function testUnionAllEnumValue(): void + { + $this->assertEquals('unionAll', Method::UnionAll->value); + } + + public function testRawEnumValue(): void + { + $this->assertEquals('raw', Method::Raw->value); + } + + public function testCountIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::count()->isSpatialQuery()); + } + + public function testJoinIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::join('t', 'a', 'b')->isSpatialQuery()); + } + + public function testDistinctIsSpatialQueryFalse(): void + { + $this->assertFalse(Query::distinct()->isSpatialQuery()); + } + + public function testToStringReturnsJson(): void + { + $json = Query::equal('name', ['John'])->toString(); + $decoded = \json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertEquals('equal', $decoded['method']); + $this->assertEquals('name', $decoded['attribute']); + $this->assertEquals(['John'], $decoded['values']); + } + + public function testToStringWithNestedQuery(): void + { + $json = Query::and([Query::equal('x', [1])])->toString(); + $decoded = \json_decode($json, true); + $this->assertIsArray($decoded); + /** @var array $decoded */ + $this->assertEquals('and', $decoded['method']); + $this->assertIsArray($decoded['values']); + $this->assertCount(1, $decoded['values']); + /** @var array $inner */ + $inner = $decoded['values'][0]; + $this->assertEquals('equal', $inner['method']); + } + + public function testToStringThrowsOnInvalidJson(): void + { + // Verify that toString returns valid JSON for complex queries + $query = Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ]), + Query::lessThan('c', 3), + ]); + $json = $query->toString(); + $this->assertJson($json); + } + + public function testSetMethodWithEnum(): void + { + $query = new Query('equal'); + $query->setMethod(Method::GreaterThan); + $this->assertSame(Method::GreaterThan, $query->getMethod()); + } + + public function testToArraySimpleFilter(): void + { + $array = Query::equal('age', [25])->toArray(); + $this->assertEquals('equal', $array['method']); + $this->assertEquals('age', $array['attribute']); + $this->assertEquals([25], $array['values']); + } + + public function testToArrayWithEmptyAttribute(): void + { + $array = Query::distinct()->toArray(); + $this->assertArrayNotHasKey('attribute', $array); + } + + public function testToArrayNestedQuery(): void + { + $array = Query::and([Query::equal('x', [1])])->toArray(); + $this->assertIsArray($array['values']); + $this->assertCount(1, $array['values']); + /** @var array $nested */ + $nested = $array['values'][0]; + $this->assertArrayHasKey('method', $nested); + $this->assertArrayHasKey('attribute', $nested); + $this->assertArrayHasKey('values', $nested); + $this->assertEquals('equal', $nested['method']); + } + + public function testCompileOrderAsc(): void + { + $builder = new MySQLBuilder(); + $result = Query::orderAsc('name')->compile($builder); + $this->assertStringContainsString('ASC', $result); + } + + public function testCompileOrderDesc(): void + { + $builder = new MySQLBuilder(); + $result = Query::orderDesc('name')->compile($builder); + $this->assertStringContainsString('DESC', $result); + } + + public function testCompileLimit(): void + { + $builder = new MySQLBuilder(); + $result = Query::limit(10)->compile($builder); + $this->assertStringContainsString('LIMIT ?', $result); + } + + public function testCompileOffset(): void + { + $builder = new MySQLBuilder(); + $result = Query::offset(5)->compile($builder); + $this->assertStringContainsString('OFFSET ?', $result); + } + + public function testCompileAggregate(): void + { + $builder = new MySQLBuilder(); + $result = Query::count('*', 'total')->compile($builder); + $this->assertStringContainsString('COUNT(*)', $result); + $this->assertStringContainsString('total', $result); + } + + public function testIsMethodReturnsFalseForGarbage(): void + { + $this->assertFalse(Query::isMethod('notAMethod')); + } + + public function testIsMethodReturnsFalseForEmpty(): void + { + $this->assertFalse(Query::isMethod('')); + } + + public function testJsonContainsFactory(): void + { + $query = Query::jsonContains('tags', 'php'); + $this->assertSame(Method::JsonContains, $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['php'], $query->getValues()); + } + + public function testJsonNotContainsFactory(): void + { + $query = Query::jsonNotContains('meta', 42); + $this->assertSame(Method::JsonNotContains, $query->getMethod()); + } + + public function testJsonOverlapsFactory(): void + { + $query = Query::jsonOverlaps('tags', ['a', 'b']); + $this->assertSame(Method::JsonOverlaps, $query->getMethod()); + $this->assertEquals([['a', 'b']], $query->getValues()); + } + + public function testJsonPathFactory(): void + { + $query = Query::jsonPath('data', 'name', '=', 'test'); + $this->assertSame(Method::JsonPath, $query->getMethod()); + $this->assertEquals(['name', '=', 'test'], $query->getValues()); + } + + public function testCoversFactory(): void + { + $query = Query::covers('zone', [1.0, 2.0]); + $this->assertSame(Method::Covers, $query->getMethod()); + } + + public function testNotCoversFactory(): void + { + $query = Query::notCovers('zone', [1.0, 2.0]); + $this->assertSame(Method::NotCovers, $query->getMethod()); + } + + public function testSpatialEqualsFactory(): void + { + $query = Query::spatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::SpatialEquals, $query->getMethod()); + } + + public function testNotSpatialEqualsFactory(): void + { + $query = Query::notSpatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::NotSpatialEquals, $query->getMethod()); + } + + public function testIsJsonMethod(): void + { + $this->assertTrue(Method::JsonContains->isJson()); + $this->assertTrue(Method::JsonNotContains->isJson()); + $this->assertTrue(Method::JsonOverlaps->isJson()); + $this->assertTrue(Method::JsonPath->isJson()); + } + + public function testIsJsonMethodFalseForNonJson(): void + { + $this->assertFalse(Method::Equal->isJson()); + } + + public function testIsSpatialMethodCovers(): void + { + $this->assertTrue(Method::Covers->isSpatial()); + $this->assertTrue(Method::NotCovers->isSpatial()); + $this->assertTrue(Method::SpatialEquals->isSpatial()); + $this->assertTrue(Method::NotSpatialEquals->isSpatial()); + } + + public function testIsSpatialMethodFalseForNonSpatial(): void + { + $this->assertFalse(Method::Equal->isSpatial()); + } + + public function testIsFilterMethod(): void + { + $this->assertTrue(Method::Equal->isFilter()); + $this->assertTrue(Method::NotEqual->isFilter()); + } + + public function testIsFilterMethodFalseForNonFilter(): void + { + $this->assertFalse(Method::OrderAsc->isFilter()); + } } diff --git a/tests/Query/Schema/BlueprintTest.php b/tests/Query/Schema/BlueprintTest.php new file mode 100644 index 0000000..5c9a928 --- /dev/null +++ b/tests/Query/Schema/BlueprintTest.php @@ -0,0 +1,400 @@ +assertSame([], $bp->columns); + } + + public function testColumnsPropertyPopulatedByString(): void + { + $bp = new Blueprint(); + $col = $bp->string('name'); + + $this->assertCount(1, $bp->columns); + $this->assertSame($col, $bp->columns[0]); + $this->assertSame('name', $bp->columns[0]->name); + $this->assertSame(ColumnType::String, $bp->columns[0]->type); + } + + public function testColumnsPropertyPopulatedByMultipleMethods(): void + { + $bp = new Blueprint(); + $bp->integer('age'); + $bp->boolean('active'); + $bp->text('bio'); + + $this->assertCount(3, $bp->columns); + $this->assertSame('age', $bp->columns[0]->name); + $this->assertSame('active', $bp->columns[1]->name); + $this->assertSame('bio', $bp->columns[2]->name); + } + + public function testColumnsPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->columns = [new Column('x', ColumnType::String)]; + } + + public function testColumnsPopulatedById(): void + { + $bp = new Blueprint(); + $bp->id('pk'); + + $this->assertCount(1, $bp->columns); + $this->assertSame('pk', $bp->columns[0]->name); + $this->assertTrue($bp->columns[0]->isPrimary); + $this->assertTrue($bp->columns[0]->isAutoIncrement); + $this->assertTrue($bp->columns[0]->isUnsigned); + } + + public function testColumnsPopulatedByAddColumn(): void + { + $bp = new Blueprint(); + $bp->addColumn('score', ColumnType::Integer); + + $this->assertCount(1, $bp->columns); + $this->assertSame('score', $bp->columns[0]->name); + } + + public function testColumnsPopulatedByModifyColumn(): void + { + $bp = new Blueprint(); + $bp->modifyColumn('score', 'integer'); + + $this->assertCount(1, $bp->columns); + $this->assertTrue($bp->columns[0]->isModify); + } + + // ── indexes (public private(set)) ────────────────────────── + + public function testIndexesPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->indexes); + } + + public function testIndexesPopulatedByIndex(): void + { + $bp = new Blueprint(); + $bp->index(['email']); + + $this->assertCount(1, $bp->indexes); + $this->assertInstanceOf(Index::class, $bp->indexes[0]); + $this->assertSame('idx_email', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByUniqueIndex(): void + { + $bp = new Blueprint(); + $bp->uniqueIndex(['email']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('uniq_email', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByFulltextIndex(): void + { + $bp = new Blueprint(); + $bp->fulltextIndex(['body']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('ft_body', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedBySpatialIndex(): void + { + $bp = new Blueprint(); + $bp->spatialIndex(['location']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('sp_location', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByAddIndex(): void + { + $bp = new Blueprint(); + $bp->addIndex('my_idx', ['col1', 'col2']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('my_idx', $bp->indexes[0]->name); + $this->assertSame(['col1', 'col2'], $bp->indexes[0]->columns); + } + + public function testIndexesPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->indexes = []; + } + + // ── foreignKeys (public private(set)) ────────────────────── + + public function testForeignKeysPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->foreignKeys); + } + + public function testForeignKeysPopulatedByForeignKey(): void + { + $bp = new Blueprint(); + $bp->foreignKey('user_id')->references('id')->on('users'); + + $this->assertCount(1, $bp->foreignKeys); + $this->assertInstanceOf(ForeignKey::class, $bp->foreignKeys[0]); + $this->assertSame('user_id', $bp->foreignKeys[0]->column); + } + + public function testForeignKeysPopulatedByAddForeignKey(): void + { + $bp = new Blueprint(); + $bp->addForeignKey('order_id')->references('id')->on('orders'); + + $this->assertCount(1, $bp->foreignKeys); + $this->assertSame('order_id', $bp->foreignKeys[0]->column); + } + + public function testForeignKeysPropertyNotWritableExternally(): void + { + $bp = new Blueprint(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->foreignKeys = []; + } + + // ── dropColumns (public private(set)) ────────────────────── + + public function testDropColumnsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropColumns); + } + + public function testDropColumnsPopulatedByDropColumn(): void + { + $bp = new Blueprint(); + $bp->dropColumn('old_field'); + + $this->assertCount(1, $bp->dropColumns); + $this->assertSame('old_field', $bp->dropColumns[0]); + } + + public function testDropColumnsMultiple(): void + { + $bp = new Blueprint(); + $bp->dropColumn('a'); + $bp->dropColumn('b'); + $bp->dropColumn('c'); + + $this->assertCount(3, $bp->dropColumns); + $this->assertSame(['a', 'b', 'c'], $bp->dropColumns); + } + + // ── renameColumns (public private(set)) ──────────────────── + + public function testRenameColumnsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->renameColumns); + } + + public function testRenameColumnsPopulatedByRenameColumn(): void + { + $bp = new Blueprint(); + $bp->renameColumn('old', 'new'); + + $this->assertCount(1, $bp->renameColumns); + $this->assertInstanceOf(RenameColumn::class, $bp->renameColumns[0]); + $this->assertSame('old', $bp->renameColumns[0]->from); + $this->assertSame('new', $bp->renameColumns[0]->to); + } + + // ── dropIndexes (public private(set)) ────────────────────── + + public function testDropIndexesPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropIndexes); + } + + public function testDropIndexesPopulatedByDropIndex(): void + { + $bp = new Blueprint(); + $bp->dropIndex('idx_old'); + + $this->assertCount(1, $bp->dropIndexes); + $this->assertSame('idx_old', $bp->dropIndexes[0]); + } + + // ── dropForeignKeys (public private(set)) ────────────────── + + public function testDropForeignKeysPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->dropForeignKeys); + } + + public function testDropForeignKeysPopulatedByDropForeignKey(): void + { + $bp = new Blueprint(); + $bp->dropForeignKey('fk_user'); + + $this->assertCount(1, $bp->dropForeignKeys); + $this->assertSame('fk_user', $bp->dropForeignKeys[0]); + } + + // ── rawColumnDefs (public private(set)) ──────────────────── + + public function testRawColumnDefsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->rawColumnDefs); + } + + public function testRawColumnDefsPopulatedByRawColumn(): void + { + $bp = new Blueprint(); + $bp->rawColumn('`my_col` VARCHAR(100) NOT NULL'); + + $this->assertCount(1, $bp->rawColumnDefs); + $this->assertSame('`my_col` VARCHAR(100) NOT NULL', $bp->rawColumnDefs[0]); + } + + // ── rawIndexDefs (public private(set)) ───────────────────── + + public function testRawIndexDefsPropertyIsReadable(): void + { + $bp = new Blueprint(); + $this->assertSame([], $bp->rawIndexDefs); + } + + public function testRawIndexDefsPopulatedByRawIndex(): void + { + $bp = new Blueprint(); + $bp->rawIndex('INDEX `idx_custom` (`col1`)'); + + $this->assertCount(1, $bp->rawIndexDefs); + $this->assertSame('INDEX `idx_custom` (`col1`)', $bp->rawIndexDefs[0]); + } + + // ── Combined / integration ───────────────────────────────── + + public function testAllPropertiesStartEmpty(): void + { + $bp = new Blueprint(); + + $this->assertSame([], $bp->columns); + $this->assertSame([], $bp->indexes); + $this->assertSame([], $bp->foreignKeys); + $this->assertSame([], $bp->dropColumns); + $this->assertSame([], $bp->renameColumns); + $this->assertSame([], $bp->dropIndexes); + $this->assertSame([], $bp->dropForeignKeys); + $this->assertSame([], $bp->rawColumnDefs); + $this->assertSame([], $bp->rawIndexDefs); + } + + public function testMultiplePropertiesPopulatedTogether(): void + { + $bp = new Blueprint(); + $bp->string('name'); + $bp->integer('age'); + $bp->index(['name']); + $bp->foreignKey('team_id')->references('id')->on('teams'); + $bp->rawColumn('`extra` TEXT'); + $bp->rawIndex('INDEX `idx_extra` (`extra`)'); + + $this->assertCount(2, $bp->columns); + $this->assertCount(1, $bp->indexes); + $this->assertCount(1, $bp->foreignKeys); + $this->assertCount(1, $bp->rawColumnDefs); + $this->assertCount(1, $bp->rawIndexDefs); + } + + public function testAlterOperationsPopulateCorrectProperties(): void + { + $bp = new Blueprint(); + $bp->modifyColumn('score', ColumnType::BigInteger); + $bp->renameColumn('old_name', 'new_name'); + $bp->dropColumn('obsolete'); + $bp->dropIndex('idx_dead'); + $bp->dropForeignKey('fk_dead'); + + $this->assertCount(1, $bp->columns); + $this->assertTrue($bp->columns[0]->isModify); + $this->assertCount(1, $bp->renameColumns); + $this->assertCount(1, $bp->dropColumns); + $this->assertCount(1, $bp->dropIndexes); + $this->assertCount(1, $bp->dropForeignKeys); + } + + public function testColumnTypeVariants(): void + { + $bp = new Blueprint(); + $bp->text('body'); + $bp->mediumText('summary'); + $bp->longText('content'); + $bp->bigInteger('count'); + $bp->float('price'); + $bp->boolean('active'); + $bp->datetime('created_at', 3); + $bp->timestamp('updated_at', 6); + $bp->json('meta'); + $bp->binary('data'); + $bp->enum('status', ['draft', 'published']); + $bp->point('location'); + $bp->linestring('route'); + $bp->polygon('area'); + $bp->vector('embedding', 768); + + $this->assertCount(15, $bp->columns); + $this->assertSame(ColumnType::Text, $bp->columns[0]->type); + $this->assertSame(ColumnType::MediumText, $bp->columns[1]->type); + $this->assertSame(ColumnType::LongText, $bp->columns[2]->type); + $this->assertSame(ColumnType::BigInteger, $bp->columns[3]->type); + $this->assertSame(ColumnType::Float, $bp->columns[4]->type); + $this->assertSame(ColumnType::Boolean, $bp->columns[5]->type); + $this->assertSame(ColumnType::Datetime, $bp->columns[6]->type); + $this->assertSame(ColumnType::Timestamp, $bp->columns[7]->type); + $this->assertSame(ColumnType::Json, $bp->columns[8]->type); + $this->assertSame(ColumnType::Binary, $bp->columns[9]->type); + $this->assertSame(ColumnType::Enum, $bp->columns[10]->type); + $this->assertSame(ColumnType::Point, $bp->columns[11]->type); + $this->assertSame(ColumnType::Linestring, $bp->columns[12]->type); + $this->assertSame(ColumnType::Polygon, $bp->columns[13]->type); + $this->assertSame(ColumnType::Vector, $bp->columns[14]->type); + } + + public function testTimestampsHelperAddsTwoColumns(): void + { + $bp = new Blueprint(); + $bp->timestamps(6); + + $this->assertCount(2, $bp->columns); + $this->assertSame('created_at', $bp->columns[0]->name); + $this->assertSame('updated_at', $bp->columns[1]->name); + $this->assertSame(ColumnType::Datetime, $bp->columns[0]->type); + $this->assertSame(ColumnType::Datetime, $bp->columns[1]->type); + } +} diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php new file mode 100644 index 0000000..3ec9cd0 --- /dev/null +++ b/tests/Query/Schema/ClickHouseTest.php @@ -0,0 +1,442 @@ +create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->datetime('created_at', 3); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('CREATE TABLE `events`', $result->query); + $this->assertStringContainsString('`id` Int64', $result->query); + $this->assertStringContainsString('`name` String', $result->query); + $this->assertStringContainsString('`created_at` DateTime64(3)', $result->query); + $this->assertStringContainsString('ENGINE = MergeTree()', $result->query); + $this->assertStringContainsString('ORDER BY (`id`)', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->integer('uint_col')->unsigned(); + $table->bigInteger('big_col'); + $table->bigInteger('ubig_col')->unsigned(); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->json('json_col'); + $table->binary('bin_col'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`int_col` Int32', $result->query); + $this->assertStringContainsString('`uint_col` UInt32', $result->query); + $this->assertStringContainsString('`big_col` Int64', $result->query); + $this->assertStringContainsString('`ubig_col` UInt64', $result->query); + $this->assertStringContainsString('`float_col` Float64', $result->query); + $this->assertStringContainsString('`bool_col` UInt8', $result->query); + $this->assertStringContainsString('`text_col` String', $result->query); + $this->assertStringContainsString('`json_col` String', $result->query); + $this->assertStringContainsString('`bin_col` String', $result->query); + } + + public function testCreateTableNullableWrapping(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('Nullable(String)', $result->query); + } + + public function testCreateTableWithEnum(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->enum('status', ['active', 'inactive']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString("Enum8('active' = 1, 'inactive' = 2)", $result->query); + } + + public function testCreateTableWithVector(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Blueprint $table) { + $table->vector('embedding', 768); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('Array(Float64)', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('geo', function (Blueprint $table) { + $table->point('coords'); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('Tuple(Float64, Float64)', $result->query); + $this->assertStringContainsString('Array(Tuple(Float64, Float64))', $result->query); + $this->assertStringContainsString('Array(Array(Tuple(Float64, Float64)))', $result->query); + } + + public function testCreateTableForeignKeyThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); + + $schema = new Schema(); + $schema->create('t', function (Blueprint $table) { + $table->foreignKey('user_id')->references('id')->on('users'); + }); + } + + public function testCreateTableWithIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->index(['name']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INDEX `idx_name` `name` TYPE minmax GRANULARITY 3', $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->addColumn('score', 'float'); + }); + $this->assertBindingCount($result); + + $this->assertEquals('ALTER TABLE `events` ADD COLUMN `score` Float64', $result->query); + } + + public function testAlterModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->modifyColumn('name', 'string'); + }); + $this->assertBindingCount($result); + + $this->assertEquals('ALTER TABLE `events` MODIFY COLUMN `name` String', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->renameColumn('old', 'new'); + }); + $this->assertBindingCount($result); + + $this->assertEquals('ALTER TABLE `events` RENAME COLUMN `old` TO `new`', $result->query); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->dropColumn('old_col'); + }); + $this->assertBindingCount($result); + + $this->assertEquals('ALTER TABLE `events` DROP COLUMN `old_col`', $result->query); + } + + public function testAlterForeignKeyThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Foreign keys are not supported in ClickHouse'); + + $schema = new Schema(); + $schema->alter('events', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users'); + }); + } + // DROP TABLE / TRUNCATE + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('events'); + $this->assertBindingCount($result); + + $this->assertEquals('DROP TABLE `events`', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('events'); + $this->assertBindingCount($result); + + $this->assertEquals('TRUNCATE TABLE `events`', $result->query); + } + // VIEW + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new ClickHouseBuilder())->from('events')->filter([Query::equal('status', ['active'])]); + $result = $schema->createView('active_events', $builder); + + $this->assertEquals( + 'CREATE VIEW `active_events` AS SELECT * FROM `events` WHERE `status` IN (?)', + $result->query + ); + $this->assertEquals(['active'], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_events'); + + $this->assertEquals('DROP VIEW `active_events`', $result->query); + } + // DROP INDEX (ClickHouse-specific) + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('events', 'idx_name'); + + $this->assertEquals('ALTER TABLE `events` DROP INDEX `idx_name`', $result->query); + } + // Feature interface checks — ClickHouse does NOT implement these + + public function testDoesNotImplementForeignKeys(): void + { + $this->assertNotInstanceOf(ForeignKeys::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementProcedures(): void + { + $this->assertNotInstanceOf(Procedures::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + public function testDoesNotImplementTriggers(): void + { + $this->assertNotInstanceOf(Triggers::class, new Schema()); // @phpstan-ignore method.alreadyNarrowedType + } + + // Edge cases + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('events'); + + $this->assertEquals('DROP TABLE IF EXISTS `events`', $result->query); + } + + public function testCreateTableWithDefaultValue(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->integer('count')->default(0); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DEFAULT 0', $result->query); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name')->comment('User name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString("COMMENT 'User name'", $result->query); + } + + public function testCreateTableMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->datetime('created_at', 3)->primary(); + $table->string('name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ORDER BY (`id`, `created_at`)', $result->query); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->addColumn('score', 'float'); + $table->dropColumn('old_col'); + $table->renameColumn('nm', 'name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD COLUMN `score` Float64', $result->query); + $this->assertStringContainsString('DROP COLUMN `old_col`', $result->query); + $this->assertStringContainsString('RENAME COLUMN `nm` TO `name`', $result->query); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Blueprint $table) { + $table->dropIndex('idx_name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DROP INDEX `idx_name`', $result->query); + } + + public function testCreateTableWithMultipleIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name']); + $table->index(['type']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INDEX `idx_name`', $result->query); + $this->assertStringContainsString('INDEX `idx_type`', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->timestamp('ts_col'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`ts_col` DateTime', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->datetime('dt_col'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`dt_col` DateTime', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableWithCompositeIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name', 'type']); + }); + $this->assertBindingCount($result); + + // Composite index wraps in parentheses + $this->assertStringContainsString('INDEX `idx_name_type` (`name`, `type`) TYPE minmax GRANULARITY 3', $result->query); + } + + public function testAlterForeignKeyStillThrows(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->alter('events', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + } + + public function testExactCreateTableWithEngine(): void + { + $schema = new Schema(); + $result = $schema->create('metrics', function (Blueprint $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->float('value'); + $table->datetime('recorded_at', 3); + }); + + $this->assertSame( + 'CREATE TABLE `metrics` (`id` Int64, `name` String, `value` Float64, `recorded_at` DateTime64(3)) ENGINE = MergeTree() ORDER BY (`id`)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('metrics', function (Blueprint $table) { + $table->addColumn('description', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE `metrics` ADD COLUMN `description` Nullable(String)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('metrics'); + + $this->assertSame('DROP TABLE `metrics`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } +} diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php new file mode 100644 index 0000000..7f4c9ff --- /dev/null +++ b/tests/Query/Schema/MySQLTest.php @@ -0,0 +1,770 @@ +assertInstanceOf(ForeignKeys::class, new Schema()); + } + + public function testImplementsProcedures(): void + { + $this->assertInstanceOf(Procedures::class, new Schema()); + } + + public function testImplementsTriggers(): void + { + $this->assertInstanceOf(Triggers::class, new Schema()); + } + + // CREATE TABLE + + public function testCreateTableBasic(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'CREATE TABLE `users` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), UNIQUE (`email`))', + $result->query + ); + $this->assertEquals([], $result->bindings); + } + + public function testCreateTableAllColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->bigInteger('big_col'); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->datetime('dt_col', 3); + $table->timestamp('ts_col', 6); + $table->json('json_col'); + $table->binary('bin_col'); + $table->enum('status', ['active', 'inactive']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INT NOT NULL', $result->query); + $this->assertStringContainsString('BIGINT NOT NULL', $result->query); + $this->assertStringContainsString('DOUBLE NOT NULL', $result->query); + $this->assertStringContainsString('TINYINT(1) NOT NULL', $result->query); + $this->assertStringContainsString('TEXT NOT NULL', $result->query); + $this->assertStringContainsString('DATETIME(3) NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(6) NOT NULL', $result->query); + $this->assertStringContainsString('JSON NOT NULL', $result->query); + $this->assertStringContainsString('BLOB NOT NULL', $result->query); + $this->assertStringContainsString("ENUM('active','inactive') NOT NULL", $result->query); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->text('bio')->nullable(); + $table->boolean('active')->default(true); + $table->integer('score')->default(0); + $table->string('status')->default('draft'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`bio` TEXT NULL', $result->query); + $this->assertStringContainsString("DEFAULT 1", $result->query); + $this->assertStringContainsString('DEFAULT 0', $result->query); + $this->assertStringContainsString("DEFAULT 'draft'", $result->query); + } + + public function testCreateTableWithUnsigned(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->integer('age')->unsigned(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INT UNSIGNED NOT NULL', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('`created_at` DATETIME(3) NOT NULL', $result->query); + $this->assertStringContainsString('`updated_at` DATETIME(3) NOT NULL', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete('CASCADE')->onUpdate('SET NULL'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testCreateTableWithIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->index(['name', 'email']); + $table->uniqueIndex(['email']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INDEX `idx_name_email` (`name`, `email`)', $result->query); + $this->assertStringContainsString('UNIQUE INDEX `uniq_email` (`email`)', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Blueprint $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('POINT SRID 4326 NOT NULL', $result->query); + $this->assertStringContainsString('LINESTRING SRID 4326 NOT NULL', $result->query); + $this->assertStringContainsString('POLYGON SRID 4326 NOT NULL', $result->query); + } + + public function testCreateTableVectorThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Vector type is not supported in MySQL.'); + + $schema = new Schema(); + $schema->create('embeddings', function (Blueprint $table) { + $table->vector('embedding', 768); + }); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->comment('User display name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString("COMMENT 'User display name'", $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('avatar_url', 'string', 255)->nullable()->after('email'); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(255) NULL AFTER `email`', + $result->query + ); + } + + public function testAlterModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->modifyColumn('name', 'string', 500); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(500) NOT NULL', + $result->query + ); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` RENAME COLUMN `bio` TO `biography`', + $result->query + ); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropColumn('age'); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` DROP COLUMN `age`', + $result->query + ); + } + + public function testAlterAddIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_name', ['name']); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` ADD INDEX `idx_name` (`name`)', + $result->query + ); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropIndex('idx_old'); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` DROP INDEX `idx_old`', + $result->query + ); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addForeignKey('dept_id') + ->references('id')->on('departments'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString( + 'ADD FOREIGN KEY (`dept_id`) REFERENCES `departments` (`id`)', + $result->query + ); + } + + public function testAlterDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + $this->assertBindingCount($result); + + $this->assertEquals( + 'ALTER TABLE `users` DROP FOREIGN KEY `fk_old`', + $result->query + ); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('avatar', 'string', 255)->nullable(); + $table->dropColumn('age'); + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD COLUMN', $result->query); + $this->assertStringContainsString('DROP COLUMN `age`', $result->query); + $this->assertStringContainsString('RENAME COLUMN `bio` TO `biography`', $result->query); + } + // DROP TABLE + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + $this->assertBindingCount($result); + + $this->assertEquals('DROP TABLE `users`', $result->query); + $this->assertEquals([], $result->bindings); + } + + public function testDropTableIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS `users`', $result->query); + } + // RENAME TABLE + + public function testRenameTable(): void + { + $schema = new Schema(); + $result = $schema->rename('users', 'members'); + $this->assertBindingCount($result); + + $this->assertEquals('RENAME TABLE `users` TO `members`', $result->query); + } + // TRUNCATE TABLE + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + $this->assertBindingCount($result); + + $this->assertEquals('TRUNCATE TABLE `users`', $result->query); + } + // CREATE / DROP INDEX (standalone) + + public function testCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertEquals('CREATE INDEX `idx_email` ON `users` (`email`)', $result->query); + } + + public function testCreateUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); + + $this->assertEquals('CREATE UNIQUE INDEX `idx_email` ON `users` (`email`)', $result->query); + } + + public function testCreateFulltextIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('posts', 'idx_body_ft', ['body'], type: 'fulltext'); + + $this->assertEquals('CREATE FULLTEXT INDEX `idx_body_ft` ON `posts` (`body`)', $result->query); + } + + public function testCreateSpatialIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('locations', 'idx_geo', ['coords'], type: 'spatial'); + + $this->assertEquals('CREATE SPATIAL INDEX `idx_geo` ON `locations` (`coords`)', $result->query); + } + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertEquals('DROP INDEX `idx_email` ON `users`', $result->query); + } + // CREATE / DROP VIEW + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createView('active_users', $builder); + + $this->assertEquals( + 'CREATE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertEquals([true], $result->bindings); + } + + public function testCreateOrReplaceView(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createOrReplaceView('active_users', $builder); + + $this->assertEquals( + 'CREATE OR REPLACE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertEquals([true], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertEquals('DROP VIEW `active_users`', $result->query); + } + // FOREIGN KEY (standalone) + + public function testAddForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey( + 'orders', + 'fk_user', + 'user_id', + 'users', + 'id', + onDelete: 'CASCADE', + onUpdate: 'SET NULL' + ); + + $this->assertEquals( + 'ALTER TABLE `orders` ADD CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testAddForeignKeyNoActions(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id'); + + $this->assertEquals( + 'ALTER TABLE `orders` ADD CONSTRAINT `fk_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)', + $result->query + ); + } + + public function testDropForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->dropForeignKey('orders', 'fk_user'); + + $this->assertEquals( + 'ALTER TABLE `orders` DROP FOREIGN KEY `fk_user`', + $result->query + ); + } + // STORED PROCEDURE + + public function testCreateProcedure(): void + { + $schema = new Schema(); + $result = $schema->createProcedure( + 'update_stats', + params: [['IN', 'user_id', 'INT'], ['OUT', 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertEquals( + 'CREATE PROCEDURE `update_stats`(IN `user_id` INT, OUT `total` INT) BEGIN SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id; END', + $result->query + ); + } + + public function testDropProcedure(): void + { + $schema = new Schema(); + $result = $schema->dropProcedure('update_stats'); + + $this->assertEquals('DROP PROCEDURE `update_stats`', $result->query); + } + // TRIGGER + + public function testCreateTrigger(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: 'BEFORE', + event: 'UPDATE', + body: 'SET NEW.updated_at = NOW(3);' + ); + + $this->assertEquals( + 'CREATE TRIGGER `trg_updated_at` BEFORE UPDATE ON `users` FOR EACH ROW BEGIN SET NEW.updated_at = NOW(3); END', + $result->query + ); + } + + public function testDropTrigger(): void + { + $schema = new Schema(); + $result = $schema->dropTrigger('trg_updated_at'); + + $this->assertEquals('DROP TRIGGER `trg_updated_at`', $result->query); + } + + // Schema edge cases + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Blueprint $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + $table->integer('quantity'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PRIMARY KEY (`order_id`, `product_id`)', $result->query); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable()->default(null); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DEFAULT NULL', $result->query); + } + + public function testCreateTableWithNumericDefault(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->float('score')->default(0.5); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DEFAULT 0.5', $result->query); + } + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS `users`', $result->query); + } + + public function testCreateOrReplaceViewFromBuilder(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users'); + $result = $schema->createOrReplaceView('all_users', $builder); + + $this->assertStringStartsWith('CREATE OR REPLACE VIEW', $result->query); + } + + public function testAlterMultipleColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('first_name', 'string', 100); + $table->addColumn('last_name', 'string', 100); + $table->dropColumn('name'); + $table->addIndex('idx_names', ['first_name', 'last_name']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD COLUMN `first_name`', $result->query); + $this->assertStringContainsString('ADD COLUMN `last_name`', $result->query); + $this->assertStringContainsString('DROP COLUMN `name`', $result->query); + $this->assertStringContainsString('ADD INDEX `idx_names`', $result->query); + } + + public function testCreateTableForeignKeyWithAllActions(): void + { + $schema = new Schema(); + $result = $schema->create('comments', function (Blueprint $table) { + $table->id(); + $table->foreignKey('post_id') + ->references('id')->on('posts') + ->onDelete('CASCADE')->onUpdate('RESTRICT'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ON DELETE CASCADE', $result->query); + $this->assertStringContainsString('ON UPDATE RESTRICT', $result->query); + } + + public function testAddForeignKeyStandaloneNoActions(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id'); + + $this->assertStringNotContainsString('ON DELETE', $result->query); + $this->assertStringNotContainsString('ON UPDATE', $result->query); + } + + public function testDropTriggerByName(): void + { + $schema = new Schema(); + $result = $schema->dropTrigger('trg_old'); + + $this->assertEquals('DROP TRIGGER `trg_old`', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->timestamp('ts_col'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('TIMESTAMP NOT NULL', $result->query); + $this->assertStringNotContainsString('TIMESTAMP(', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->datetime('dt_col'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DATETIME NOT NULL', $result->query); + $this->assertStringNotContainsString('DATETIME(', $result->query); + } + + public function testCreateCompositeIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_multi', ['first_name', 'last_name']); + + $this->assertEquals('CREATE INDEX `idx_multi` ON `users` (`first_name`, `last_name`)', $result->query); + } + + public function testAlterAddAndDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users'); + $table->dropForeignKey('fk_old_user'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD FOREIGN KEY', $result->query); + $this->assertStringContainsString('DROP FOREIGN KEY `fk_old_user`', $result->query); + } + + public function testBlueprintAutoGeneratedIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('first'); + $table->string('last'); + $table->index(['first', 'last']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INDEX `idx_first_last`', $result->query); + } + + public function testBlueprintAutoGeneratedUniqueIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('email'); + $table->uniqueIndex(['email']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('UNIQUE INDEX `uniq_email`', $result->query); + } + + public function testExactCreateTableWithColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('products', function (Blueprint $table) { + $table->id(); + $table->string('name', 100); + $table->integer('price'); + $table->boolean('active')->default(true); + $table->index(['name']); + $table->uniqueIndex(['price']); + }); + + $this->assertSame( + 'CREATE TABLE `products` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(100) NOT NULL, `price` INT NOT NULL, `active` TINYINT(1) NOT NULL DEFAULT 1, PRIMARY KEY (`id`), INDEX `idx_name` (`name`), UNIQUE INDEX `uniq_price` (`price`))', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddAndDropColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('phone', 'string', 20)->nullable(); + $table->dropColumn('legacy_field'); + }); + + $this->assertSame( + 'ALTER TABLE `users` ADD COLUMN `phone` VARCHAR(20) NULL, DROP COLUMN `legacy_field`', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('orders', function (Blueprint $table) { + $table->id(); + $table->integer('customer_id'); + $table->foreignKey('customer_id') + ->references('id')->on('customers') + ->onDelete('CASCADE')->onUpdate('CASCADE'); + }); + + $this->assertSame( + 'CREATE TABLE `orders` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `customer_id` INT NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE)', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE `sessions`', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } +} diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php new file mode 100644 index 0000000..d1ca2c1 --- /dev/null +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -0,0 +1,575 @@ +assertInstanceOf(ForeignKeys::class, new Schema()); + } + + public function testImplementsProcedures(): void + { + $this->assertInstanceOf(Procedures::class, new Schema()); + } + + public function testImplementsTriggers(): void + { + $this->assertInstanceOf(Triggers::class, new Schema()); + } + + // CREATE TABLE — PostgreSQL types + + public function testCreateTableBasic(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Blueprint $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"id" BIGINT', $result->query); + $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + $this->assertStringContainsString('"name" VARCHAR(255)', $result->query); + $this->assertStringContainsString('PRIMARY KEY ("id")', $result->query); + $this->assertStringContainsString('UNIQUE ("email")', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Blueprint $table) { + $table->integer('int_col'); + $table->bigInteger('big_col'); + $table->float('float_col'); + $table->boolean('bool_col'); + $table->text('text_col'); + $table->datetime('dt_col', 3); + $table->timestamp('ts_col', 6); + $table->json('json_col'); + $table->binary('bin_col'); + $table->enum('status', ['active', 'inactive']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('INTEGER NOT NULL', $result->query); + $this->assertStringContainsString('BIGINT NOT NULL', $result->query); + $this->assertStringContainsString('DOUBLE PRECISION NOT NULL', $result->query); + $this->assertStringContainsString('BOOLEAN NOT NULL', $result->query); + $this->assertStringContainsString('TEXT NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(3) NOT NULL', $result->query); + $this->assertStringContainsString('TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL', $result->query); + $this->assertStringContainsString('JSONB NOT NULL', $result->query); + $this->assertStringContainsString('BYTEA NOT NULL', $result->query); + $this->assertStringContainsString("CHECK (\"status\" IN ('active', 'inactive'))", $result->query); + } + + public function testCreateTableSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Blueprint $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GEOMETRY(POINT, 4326)', $result->query); + $this->assertStringContainsString('GEOMETRY(LINESTRING, 4326)', $result->query); + $this->assertStringContainsString('GEOMETRY(POLYGON, 4326)', $result->query); + } + + public function testCreateTableVectorType(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Blueprint $table) { + $table->id(); + $table->vector('embedding', 128); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('VECTOR(128)', $result->query); + } + + public function testCreateTableUnsignedIgnored(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->integer('age')->unsigned(); + }); + $this->assertBindingCount($result); + + // PostgreSQL doesn't support UNSIGNED + $this->assertStringNotContainsString('UNSIGNED', $result->query); + $this->assertStringContainsString('INTEGER NOT NULL', $result->query); + } + + public function testCreateTableNoInlineComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->comment('User display name'); + }); + $this->assertBindingCount($result); + + // PostgreSQL doesn't use inline COMMENT + $this->assertStringNotContainsString('COMMENT', $result->query); + } + // AUTO INCREMENT + + public function testAutoIncrementUsesIdentity(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->id(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + $this->assertStringNotContainsString('AUTO_INCREMENT', $result->query); + } + // DROP INDEX — no ON table + + public function testDropIndexNoOnTable(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertEquals('DROP INDEX "idx_email"', $result->query); + } + // CREATE INDEX — USING method + operator class + + public function testCreateIndexWithGin(): void + { + $schema = new Schema(); + $result = $schema->createIndex('documents', 'idx_content_gin', ['content'], method: 'gin', operatorClass: 'gin_trgm_ops'); + + $this->assertEquals( + 'CREATE INDEX "idx_content_gin" ON "documents" USING GIN ("content" gin_trgm_ops)', + $result->query + ); + } + + public function testCreateIndexWithHnsw(): void + { + $schema = new Schema(); + $result = $schema->createIndex('embeddings', 'idx_embedding_hnsw', ['embedding'], method: 'hnsw', operatorClass: 'vector_cosine_ops'); + + $this->assertEquals( + 'CREATE INDEX "idx_embedding_hnsw" ON "embeddings" USING HNSW ("embedding" vector_cosine_ops)', + $result->query + ); + } + + public function testCreateIndexWithGist(): void + { + $schema = new Schema(); + $result = $schema->createIndex('locations', 'idx_coords_gist', ['coords'], method: 'gist'); + + $this->assertEquals( + 'CREATE INDEX "idx_coords_gist" ON "locations" USING GIST ("coords")', + $result->query + ); + } + // PROCEDURES — CREATE FUNCTION + + public function testCreateProcedureUsesFunction(): void + { + $schema = new Schema(); + $result = $schema->createProcedure( + 'update_stats', + params: [['IN', 'user_id', 'INT'], ['OUT', 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertStringContainsString('CREATE FUNCTION "update_stats"', $result->query); + $this->assertStringContainsString('LANGUAGE plpgsql', $result->query); + $this->assertStringNotContainsString('CREATE PROCEDURE', $result->query); + } + + public function testDropProcedureUsesFunction(): void + { + $schema = new Schema(); + $result = $schema->dropProcedure('update_stats'); + + $this->assertEquals('DROP FUNCTION "update_stats"', $result->query); + } + // TRIGGERS — EXECUTE FUNCTION + + public function testCreateTriggerUsesExecuteFunction(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: 'BEFORE', + event: 'UPDATE', + body: 'NEW.updated_at = NOW();' + ); + + $this->assertStringContainsString('EXECUTE FUNCTION', $result->query); + $this->assertStringContainsString('CREATE TRIGGER "trg_updated_at"', $result->query); + $this->assertStringNotContainsString('BEGIN SET', $result->query); + } + // FOREIGN KEY — DROP CONSTRAINT + + public function testDropForeignKeyUsesConstraint(): void + { + $schema = new Schema(); + $result = $schema->dropForeignKey('orders', 'fk_user'); + + $this->assertEquals( + 'ALTER TABLE "orders" DROP CONSTRAINT "fk_user"', + $result->query + ); + } + // ALTER — PostgreSQL specifics + + public function testAlterModifyUsesAlterColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->modifyColumn('name', 'string', 500); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ALTER COLUMN "name" TYPE VARCHAR(500)', $result->query); + } + + public function testAlterAddIndexUsesCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_email', ['email']); + }); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('ADD INDEX', $result->query); + $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testAlterDropIndexIsStandalone(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->dropIndex('idx_email'); + }); + $this->assertBindingCount($result); + + $this->assertEquals('DROP INDEX "idx_email"', $result->query); + } + + public function testAlterColumnAndIndexSeparateStatements(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('score', 'integer'); + $table->addIndex('idx_score', ['score']); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ALTER TABLE "users" ADD COLUMN', $result->query); + $this->assertStringContainsString('; CREATE INDEX "idx_score" ON "users" ("score")', $result->query); + } + + public function testAlterDropForeignKeyUsesConstraint(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->dropForeignKey('fk_old'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DROP CONSTRAINT "fk_old"', $result->query); + } + // EXTENSIONS + + public function testCreateExtension(): void + { + $schema = new Schema(); + $result = $schema->createExtension('vector'); + + $this->assertEquals('CREATE EXTENSION IF NOT EXISTS "vector"', $result->query); + } + + public function testDropExtension(): void + { + $schema = new Schema(); + $result = $schema->dropExtension('vector'); + + $this->assertEquals('DROP EXTENSION IF EXISTS "vector"', $result->query); + } + // Views — double-quote wrapping + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new PgBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createView('active_users', $builder); + + $this->assertEquals( + 'CREATE VIEW "active_users" AS SELECT * FROM "users" WHERE "active" IN (?)', + $result->query + ); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertEquals('DROP VIEW "active_users"', $result->query); + } + // Shared operations — still work with double quotes + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + $this->assertBindingCount($result); + + $this->assertEquals('DROP TABLE "users"', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + $this->assertBindingCount($result); + + $this->assertEquals('TRUNCATE TABLE "users"', $result->query); + } + + public function testRenameTableUsesAlterTable(): void + { + $schema = new Schema(); + $result = $schema->rename('users', 'members'); + $this->assertBindingCount($result); + + $this->assertEquals('ALTER TABLE "users" RENAME TO "members"', $result->query); + } + + // Edge cases + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertEquals('DROP TABLE IF EXISTS "users"', $result->query); + } + + public function testCreateOrReplaceView(): void + { + $schema = new Schema(); + $builder = (new PgBuilder())->from('users'); + $result = $schema->createOrReplaceView('all_users', $builder); + + $this->assertStringStartsWith('CREATE OR REPLACE VIEW', $result->query); + } + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Blueprint $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('PRIMARY KEY ("order_id", "product_id")', $result->query); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Blueprint $table) { + $table->string('name')->nullable()->default(null); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('DEFAULT NULL', $result->query); + } + + public function testAlterAddMultipleColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addColumn('first_name', 'string', 100); + $table->addColumn('last_name', 'string', 100); + $table->dropColumn('name'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD COLUMN "first_name"', $result->query); + $this->assertStringContainsString('DROP COLUMN "name"', $result->query); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Blueprint $table) { + $table->addForeignKey('user_id')->references('id')->on('users')->onDelete('CASCADE'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); + } + + public function testCreateIndexDefault(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertEquals('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testCreateUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); + + $this->assertEquals('CREATE UNIQUE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testCreateIndexMultiColumn(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_name', ['first_name', 'last_name']); + + $this->assertEquals('CREATE INDEX "idx_name" ON "users" ("first_name", "last_name")', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('RENAME COLUMN "bio" TO "biography"', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->timestamps(); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('"created_at" TIMESTAMP(3)', $result->query); + $this->assertStringContainsString('"updated_at" TIMESTAMP(3)', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Blueprint $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete('CASCADE'); + }); + $this->assertBindingCount($result); + + $this->assertStringContainsString('FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE', $result->query); + } + + public function testAddForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey('orders', 'fk_user', 'user_id', 'users', 'id', 'CASCADE', 'SET NULL'); + + $this->assertEquals( + 'ALTER TABLE "orders" ADD CONSTRAINT "fk_user" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL', + $result->query + ); + } + + public function testDropTriggerFunction(): void + { + $schema = new Schema(); + + // dropTrigger should use base SQL dropTrigger + $result = $schema->dropTrigger('trg_old'); + + $this->assertEquals('DROP TRIGGER "trg_old"', $result->query); + } + + public function testAlterWithUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Blueprint $table) { + $table->addIndex('idx_email', ['email']); + $table->addIndex('idx_name', ['name']); + }); + $this->assertBindingCount($result); + + // Both should be standalone CREATE INDEX statements + $this->assertStringContainsString('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + $this->assertStringContainsString('CREATE INDEX "idx_name" ON "users" ("name")', $result->query); + } + + public function testExactCreateTableWithTypes(): void + { + $schema = new Schema(); + $result = $schema->create('accounts', function (Blueprint $table) { + $table->id(); + $table->string('username', 50); + $table->boolean('verified'); + $table->json('metadata'); + }); + + $this->assertSame( + 'CREATE TABLE "accounts" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "username" VARCHAR(50) NOT NULL, "verified" BOOLEAN NOT NULL, "metadata" JSONB NOT NULL, PRIMARY KEY ("id"))', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('accounts', function (Blueprint $table) { + $table->addColumn('bio', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE "accounts" ADD COLUMN "bio" TEXT NULL', + $result->query + ); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE "sessions"', $result->query); + $this->assertEquals([], $result->bindings); + $this->assertBindingCount($result); + } +} diff --git a/tests/Query/SelectionQueryTest.php b/tests/Query/SelectionQueryTest.php index 582cd23..ad5f4b5 100644 --- a/tests/Query/SelectionQueryTest.php +++ b/tests/Query/SelectionQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class SelectionQueryTest extends TestCase @@ -10,14 +11,14 @@ class SelectionQueryTest extends TestCase public function testSelect(): void { $query = Query::select(['name', 'email']); - $this->assertEquals(Query::TYPE_SELECT, $query->getMethod()); + $this->assertSame(Method::Select, $query->getMethod()); $this->assertEquals(['name', 'email'], $query->getValues()); } public function testOrderAsc(): void { $query = Query::orderAsc('name'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); + $this->assertSame(Method::OrderAsc, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); } @@ -30,7 +31,7 @@ public function testOrderAscNoAttribute(): void public function testOrderDesc(): void { $query = Query::orderDesc('name'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); + $this->assertSame(Method::OrderDesc, $query->getMethod()); $this->assertEquals('name', $query->getAttribute()); } @@ -43,34 +44,34 @@ public function testOrderDescNoAttribute(): void public function testOrderRandom(): void { $query = Query::orderRandom(); - $this->assertEquals(Query::TYPE_ORDER_RANDOM, $query->getMethod()); + $this->assertSame(Method::OrderRandom, $query->getMethod()); } public function testLimit(): void { $query = Query::limit(25); - $this->assertEquals(Query::TYPE_LIMIT, $query->getMethod()); + $this->assertSame(Method::Limit, $query->getMethod()); $this->assertEquals([25], $query->getValues()); } public function testOffset(): void { $query = Query::offset(10); - $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); + $this->assertSame(Method::Offset, $query->getMethod()); $this->assertEquals([10], $query->getValues()); } public function testCursorAfter(): void { $query = Query::cursorAfter('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $query->getMethod()); + $this->assertSame(Method::CursorAfter, $query->getMethod()); $this->assertEquals(['doc123'], $query->getValues()); } public function testCursorBefore(): void { $query = Query::cursorBefore('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query->getMethod()); + $this->assertSame(Method::CursorBefore, $query->getMethod()); $this->assertEquals(['doc123'], $query->getValues()); } } diff --git a/tests/Query/SpatialQueryTest.php b/tests/Query/SpatialQueryTest.php index c65984e..51a70a6 100644 --- a/tests/Query/SpatialQueryTest.php +++ b/tests/Query/SpatialQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class SpatialQueryTest extends TestCase @@ -10,7 +11,7 @@ class SpatialQueryTest extends TestCase public function testDistanceEqual(): void { $query = Query::distanceEqual('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_EQUAL, $query->getMethod()); + $this->assertSame(Method::DistanceEqual, $query->getMethod()); $this->assertEquals([[[1.0, 2.0], 100, false]], $query->getValues()); } @@ -23,67 +24,113 @@ public function testDistanceEqualWithMeters(): void public function testDistanceNotEqual(): void { $query = Query::distanceNotEqual('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_NOT_EQUAL, $query->getMethod()); + $this->assertSame(Method::DistanceNotEqual, $query->getMethod()); } public function testDistanceGreaterThan(): void { $query = Query::distanceGreaterThan('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_GREATER_THAN, $query->getMethod()); + $this->assertSame(Method::DistanceGreaterThan, $query->getMethod()); } public function testDistanceLessThan(): void { $query = Query::distanceLessThan('location', [1.0, 2.0], 100); - $this->assertEquals(Query::TYPE_DISTANCE_LESS_THAN, $query->getMethod()); + $this->assertSame(Method::DistanceLessThan, $query->getMethod()); } public function testIntersects(): void { $query = Query::intersects('geo', [[0, 0], [1, 1]]); - $this->assertEquals(Query::TYPE_INTERSECTS, $query->getMethod()); + $this->assertSame(Method::Intersects, $query->getMethod()); $this->assertEquals([[[0, 0], [1, 1]]], $query->getValues()); } public function testNotIntersects(): void { $query = Query::notIntersects('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_INTERSECTS, $query->getMethod()); + $this->assertSame(Method::NotIntersects, $query->getMethod()); } public function testCrosses(): void { $query = Query::crosses('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_CROSSES, $query->getMethod()); + $this->assertSame(Method::Crosses, $query->getMethod()); } public function testNotCrosses(): void { $query = Query::notCrosses('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_CROSSES, $query->getMethod()); + $this->assertSame(Method::NotCrosses, $query->getMethod()); } public function testOverlaps(): void { $query = Query::overlaps('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_OVERLAPS, $query->getMethod()); + $this->assertSame(Method::Overlaps, $query->getMethod()); } public function testNotOverlaps(): void { $query = Query::notOverlaps('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_OVERLAPS, $query->getMethod()); + $this->assertSame(Method::NotOverlaps, $query->getMethod()); } public function testTouches(): void { $query = Query::touches('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_TOUCHES, $query->getMethod()); + $this->assertSame(Method::Touches, $query->getMethod()); } public function testNotTouches(): void { $query = Query::notTouches('geo', [[0, 0]]); - $this->assertEquals(Query::TYPE_NOT_TOUCHES, $query->getMethod()); + $this->assertSame(Method::NotTouches, $query->getMethod()); + } + + public function testCoversFactory(): void + { + $query = Query::covers('zone', [1.0, 2.0]); + $this->assertSame(Method::Covers, $query->getMethod()); + $this->assertEquals('zone', $query->getAttribute()); + } + + public function testNotCoversFactory(): void + { + $query = Query::notCovers('zone', [1.0, 2.0]); + $this->assertSame(Method::NotCovers, $query->getMethod()); + } + + public function testSpatialEqualsFactory(): void + { + $query = Query::spatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::SpatialEquals, $query->getMethod()); + $this->assertEquals([[3.0, 4.0]], $query->getValues()); + } + + public function testNotSpatialEqualsFactory(): void + { + $query = Query::notSpatialEquals('geom', [3.0, 4.0]); + $this->assertSame(Method::NotSpatialEquals, $query->getMethod()); + } + + public function testDistanceLessThanWithMeters(): void + { + $query = Query::distanceLessThan('location', [1.0, 2.0], 500, true); + $values = $query->getValues(); + $this->assertIsArray($values[0]); + $this->assertTrue($values[0][2]); + } + + public function testIsSpatialQueryTrue(): void + { + $query = Query::intersects('geo', [[0, 0]]); + $this->assertTrue($query->isSpatialQuery()); + } + + public function testIsSpatialQueryFalseForFilter(): void + { + $query = Query::equal('x', [1]); + $this->assertFalse($query->isSpatialQuery()); } } diff --git a/tests/Query/VectorQueryTest.php b/tests/Query/VectorQueryTest.php index 40cf24b..8593e92 100644 --- a/tests/Query/VectorQueryTest.php +++ b/tests/Query/VectorQueryTest.php @@ -3,6 +3,7 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\Method; use Utopia\Query\Query; class VectorQueryTest extends TestCase @@ -11,7 +12,7 @@ public function testVectorDot(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorDot('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_DOT, $query->getMethod()); + $this->assertSame(Method::VectorDot, $query->getMethod()); $this->assertEquals([$vector], $query->getValues()); } @@ -19,13 +20,13 @@ public function testVectorCosine(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorCosine('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_COSINE, $query->getMethod()); + $this->assertSame(Method::VectorCosine, $query->getMethod()); } public function testVectorEuclidean(): void { $vector = [0.1, 0.2, 0.3]; $query = Query::vectorEuclidean('embedding', $vector); - $this->assertEquals(Query::TYPE_VECTOR_EUCLIDEAN, $query->getMethod()); + $this->assertSame(Method::VectorEuclidean, $query->getMethod()); } }