diff --git a/.claude/plans/PLAN-sql-tokenizer-ast.md b/.claude/plans/PLAN-sql-tokenizer-ast.md new file mode 100644 index 0000000..a77e01c --- /dev/null +++ b/.claude/plans/PLAN-sql-tokenizer-ast.md @@ -0,0 +1,95 @@ +# Implementation Plan: SQL Tokenizer & AST + +**Status:** Complete +**Created:** 2026-03-24 +**Completed:** 2026-03-24 +**Description:** Add a tokenizer, parser, AST node hierarchy, serializer, visitor pattern, and Builder integration for SQL SELECT queries. Supports per-dialect tokenization/serialization for MySQL, PostgreSQL, ClickHouse, SQLite, and MariaDB. Enables round-trip: SQL string -> tokens -> AST -> modify/validate -> SQL string, plus AST <-> Builder conversion. + +## Phases + +### Phase 1: Token Types & Base Tokenizer +- **Status:** [x] Complete + +### Phase 2: AST Node Hierarchy +- **Status:** [x] Complete + +### Phase 3: Base SQL Parser (Tokens -> AST) +- **Status:** [x] Complete + +### Phase 4: Base SQL Serializer (AST -> SQL) +- **Status:** [x] Complete + +### Phase 5: Dialect-Specific Tokenizers & Serializers +- **Status:** [x] Complete + +### Phase 6: Visitor Pattern for AST Modification & Validation +- **Status:** [x] Complete + +### Phase 7: Builder <-> AST Integration +- **Status:** [x] Complete + +## Progress Log + +### Phase 1 - f871339 +- **Tests added:** 42 (35 initial + 7 review fixes) +- **Files:** TokenType.php, Token.php, Tokenizer.php, TokenizerTest.php +- **Review issues fixed:** C1 (block comment bug), C2 (unknown chars), W1 (backslash escapes), W2 (scientific notation), W3 (quoted identifier escapes), W5 (aggregate function casing), W6 (keyword map constant) + +### Phase 2 - f871339 +- **Tests added:** 27 +- **Files:** Expr.php, 22 AST node classes, SelectStatement.php, NodeTest.php + +### Phase 3 - 4e0f32d +- **Tests added:** 52 +- **Files:** Parser.php, ParserTest.php +- **Review issues fixed:** C1 (FILTER clause stored), C2 (:: cast in parseUnary), C3 (Star schema), C4 (inColumnList reset) + +### Phase 4 - b3243cf +- **Tests added:** 62 +- **Files:** Serializer.php, SerializerTest.php + +### Phase 5 - 5f5ae07 +- **Tests added:** 16 +- **Files:** 5 tokenizer subclasses, 5 serializer subclasses, 6 test files + +### Phase 6 - 36641e3 +- **Tests added:** 17 +- **Files:** Visitor.php, Walker.php, TableRenamer.php, ColumnValidator.php, FilterInjector.php, VisitorTest.php + +### Phase 7 - 188da99 +- **Tests added:** 40 +- **Files:** Builder.php (modified), BuilderIntegrationTest.php + +### Lint/Static Analysis - 4d19662 +- **Files changed:** 43 (formatting + type fixes) + +## Final Summary + +### Tests Added +- 256 new tests total +- 42 tokenizer tests +- 27 AST node tests +- 52 parser tests +- 62 serializer tests +- 16 dialect tokenizer/serializer tests +- 17 visitor tests +- 40 builder integration tests + +### Files Changed +- 55 files created, 3 files modified + +### Commits +- f871339 - Token types, tokenizer, AST nodes +- 4e0f32d - Recursive-descent parser +- b3243cf - Serializer + parser review fixes +- 5f5ae07 - Dialect tokenizers/serializers +- 36641e3 - Visitor pattern +- 188da99 - Builder <-> AST integration +- 4d19662 - Lint and static analysis fixes + +### Verification +- [x] All 4045 tests pass +- [x] Lint passes (Pint) +- [x] Static analysis passes (PHPStan level max) +- [x] No TODOs remaining +- [x] Plan file complete diff --git a/.github/workflows/baseline.yml b/.github/workflows/baseline.yml new file mode 100644 index 0000000..79e6407 --- /dev/null +++ b/.github/workflows/baseline.yml @@ -0,0 +1,108 @@ +name: "Coverage baseline" + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + baseline: + name: Compute baseline coverage + 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 + + mariadb: + image: mariadb:11 + ports: + - 13307:3306 + env: + MARIADB_ROOT_PASSWORD: test + MARIADB_DATABASE: query_test + options: >- + --health-cmd="mariadb-admin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: pgvector/pgvector:pg16 + 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 + env: + CLICKHOUSE_DB: query_test + options: >- + --health-cmd="wget --spider -q http://localhost:8123/ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --eval 'db.runCommand({ping:1})'" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + with: + php-version: '8.4' + extensions: pdo, pdo_mysql, pdo_pgsql, mongodb + coverage: pcov + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Create coverage directory + run: mkdir -p coverage + + - name: Run unit tests with coverage + run: composer test:coverage + + - name: Run integration tests with coverage + run: composer test:integration:coverage + + - name: Merge baseline coverage + run: ./vendor/bin/phpcov merge --clover coverage/baseline.xml coverage + + - name: Save baseline to cache + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage/baseline.xml + key: coverage-baseline-${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9a5f682 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,335 @@ +name: "CI" + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +on: [pull_request] + +permissions: + contents: read + pull-requests: write + +jobs: + unit: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + with: + php-version: "8.4" + coverage: pcov + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Create coverage directory + run: mkdir -p coverage + + - name: Run unit tests with coverage + run: composer test:coverage + + - name: Upload unit coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-unit + path: coverage/unit.cov + retention-days: 1 + + integration: + name: Integration Tests + 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 + + mariadb: + image: mariadb:11 + ports: + - 13307:3306 + env: + MARIADB_ROOT_PASSWORD: test + MARIADB_DATABASE: query_test + options: >- + --health-cmd="mariadb-admin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: pgvector/pgvector:pg16 + 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 + env: + CLICKHOUSE_DB: query_test + options: >- + --health-cmd="wget --spider -q http://localhost:8123/ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd="mongosh --eval 'db.runCommand({ping:1})'" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + with: + php-version: '8.4' + extensions: pdo, pdo_mysql, pdo_pgsql, mongodb + coverage: pcov + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Create coverage directory + run: mkdir -p coverage + + - name: Run integration tests with coverage + run: composer test:integration:coverage + + - name: Upload integration coverage + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-integration + path: coverage/integration.cov + retention-days: 1 + + baseline: + name: Fetch Baseline Coverage + runs-on: ubuntu-latest + + steps: + - name: Restore baseline cache + id: baseline_cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage/baseline.xml + key: coverage-baseline-${{ github.event.pull_request.base.sha }} + + - name: Upload baseline artifact + if: steps.baseline_cache.outputs.cache-hit == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-baseline + path: coverage/baseline.xml + retention-days: 1 + + coverage: + name: Coverage Report + runs-on: ubuntu-latest + needs: [unit, integration, baseline] + # Run even when baseline failed (continue-on-error) — we still want PR-only coverage posted. + if: always() && needs.unit.result == 'success' && needs.integration.result == 'success' + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 + with: + php-version: "8.4" + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Download unit coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage-unit + path: coverage + + - name: Download integration coverage + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage-integration + path: coverage + + - name: Download baseline coverage + id: baseline + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: coverage-baseline + path: coverage-baseline + + - name: Merge PR coverage + run: | + ./vendor/bin/phpcov merge \ + --clover coverage/combined.xml \ + --text coverage/combined.txt \ + coverage + + - name: Parse coverage summary + id: coverage + env: + HAS_BASELINE: ${{ steps.baseline.outcome == 'success' }} + run: | + php -r ' + function summarise(string $path): array { + $xml = simplexml_load_file($path); + $metrics = $xml->project->metrics; + $lines = (int) $metrics["statements"]; + $coveredLines = (int) $metrics["coveredstatements"]; + $methods = (int) $metrics["methods"]; + $coveredMethods = (int) $metrics["coveredmethods"]; + // Clover does not emit coveredclasses at the project level. + // A class counts as covered when every method is covered. + $classes = 0; + $coveredClasses = 0; + foreach ($xml->xpath("//class") as $class) { + $classes++; + $classMethods = (int) $class->metrics["methods"]; + $classCoveredMethods = (int) $class->metrics["coveredmethods"]; + if ($classMethods > 0 && $classCoveredMethods === $classMethods) { + $coveredClasses++; + } + } + return [ + "lineRate" => $lines > 0 ? 100 * $coveredLines / $lines : 0, + "methodRate" => $methods > 0 ? 100 * $coveredMethods / $methods : 0, + "classRate" => $classes > 0 ? 100 * $coveredClasses / $classes : 0, + "lines" => $lines, + "coveredLines" => $coveredLines, + "methods" => $methods, + "coveredMethods" => $coveredMethods, + "classes" => $classes, + "coveredClasses" => $coveredClasses, + ]; + } + $pr = summarise("coverage/combined.xml"); + $out = sprintf( + "lines=%.2f\nmethods=%.2f\nclasses=%.2f\nlines_covered=%d\nlines_total=%d\nmethods_covered=%d\nmethods_total=%d\nclasses_covered=%d\nclasses_total=%d\n", + $pr["lineRate"], $pr["methodRate"], $pr["classRate"], + $pr["coveredLines"], $pr["lines"], + $pr["coveredMethods"], $pr["methods"], + $pr["coveredClasses"], $pr["classes"] + ); + $hasBaseline = getenv("HAS_BASELINE") === "true" && is_file("coverage-baseline/baseline.xml"); + if ($hasBaseline) { + $base = summarise("coverage-baseline/baseline.xml"); + $out .= sprintf( + "has_baseline=1\nbase_lines=%.2f\nbase_methods=%.2f\nbase_classes=%.2f\ndelta_lines=%+.2f\ndelta_methods=%+.2f\ndelta_classes=%+.2f\n", + $base["lineRate"], $base["methodRate"], $base["classRate"], + $pr["lineRate"] - $base["lineRate"], + $pr["methodRate"] - $base["methodRate"], + $pr["classRate"] - $base["classRate"] + ); + } else { + $out .= "has_baseline=0\n"; + } + file_put_contents(getenv("GITHUB_OUTPUT"), $out, FILE_APPEND); + ' + + - name: Render tables + id: render + env: + LINES: ${{ steps.coverage.outputs.lines }} + METHODS: ${{ steps.coverage.outputs.methods }} + CLASSES: ${{ steps.coverage.outputs.classes }} + LINES_COVERED: ${{ steps.coverage.outputs.lines_covered }} + LINES_TOTAL: ${{ steps.coverage.outputs.lines_total }} + METHODS_COVERED: ${{ steps.coverage.outputs.methods_covered }} + METHODS_TOTAL: ${{ steps.coverage.outputs.methods_total }} + CLASSES_COVERED: ${{ steps.coverage.outputs.classes_covered }} + CLASSES_TOTAL: ${{ steps.coverage.outputs.classes_total }} + HAS_BASELINE: ${{ steps.coverage.outputs.has_baseline }} + BASE_LINES: ${{ steps.coverage.outputs.base_lines }} + BASE_METHODS: ${{ steps.coverage.outputs.base_methods }} + BASE_CLASSES: ${{ steps.coverage.outputs.base_classes }} + DELTA_LINES: ${{ steps.coverage.outputs.delta_lines }} + DELTA_METHODS: ${{ steps.coverage.outputs.delta_methods }} + DELTA_CLASSES: ${{ steps.coverage.outputs.delta_classes }} + run: | + if [ "$HAS_BASELINE" = "1" ]; then + { + echo "table<> "$GITHUB_OUTPUT" + else + { + echo "table<> "$GITHUB_OUTPUT" + fi + + - name: Write coverage to job summary + env: + TABLE: ${{ steps.render.outputs.table }} + run: | + { + echo "## Coverage" + echo "" + echo "$TABLE" + echo "" + echo "
Per-file coverage" + echo "" + echo '```' + head -200 coverage/combined.txt + echo '```' + echo "" + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Post coverage comment + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: coverage + message: | + ## 📊 Coverage + + ${{ steps.render.outputs.table }} + + Full per-file breakdown in the [job summary](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 5610cdc..3be2190 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -8,10 +8,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: "8.4" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 53be629..9b09528 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -8,10 +8,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0 with: php-version: "8.4" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 968b022..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Tests" - -concurrency: - group: tests-${{ github.ref }} - cancel-in-progress: true - -on: [pull_request] - -jobs: - unit_test: - name: Unit Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.4" - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - - name: Run Tests - run: composer test diff --git a/.gitignore b/.gitignore index 5e20fe1..6598de5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ .phpunit.result.cache composer.phar /vendor/ +.idea +coverage +coverage.xml +.DS_Store +.claude/worktrees/ +.phpunit.cache/ +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..d3252c3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Utopia Query [![Tests](https://github.com/utopia-php/query/actions/workflows/tests.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/tests.yml) +[![Integration Tests](https://github.com/utopia-php/query/actions/workflows/integration.yml/badge.svg)](https://github.com/utopia-php/query/actions/workflows/integration.yml) [![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 queries and DDL statements. Provides a fluent builder API with parameterized output for MySQL, MariaDB, PostgreSQL, SQLite, ClickHouse, and MongoDB, plus wire protocol parsers and a serializable `Query` value object for passing query definitions between services. ## Installation @@ -12,81 +13,148 @@ 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) + - [Statistical Aggregates](#statistical-aggregates) + - [Bitwise Aggregates](#bitwise-aggregates) + - [Conditional Aggregates](#conditional-aggregates) + - [String Aggregates](#string-aggregates) + - [Group By Modifiers](#group-by-modifiers) + - [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) + - [EXPLAIN](#explain) + - [Conditional Building](#conditional-building) + - [Builder Cloning and Callbacks](#builder-cloning-and-callbacks) + - [Debugging](#debugging) + - [Hooks](#hooks) +- [Dialect-Specific Features](#dialect-specific-features) + - [MySQL](#mysql) + - [MariaDB](#mariadb) + - [PostgreSQL](#postgresql) + - [SQLite](#sqlite) + - [ClickHouse](#clickhouse) + - [MongoDB](#mongodb) + - [Feature Matrix](#feature-matrix) +- [Schema Builder](#schema-builder) + - [Creating Tables](#creating-tables) + - [Altering Tables](#altering-tables) + - [Indexes](#indexes) + - [Foreign Keys](#foreign-keys) + - [Partitions](#partitions) + - [Comments](#comments) + - [Views](#views) + - [Procedures and Triggers](#procedures-and-triggers) + - [PostgreSQL Schema Extensions](#postgresql-schema-extensions) + - [ClickHouse Schema](#clickhouse-schema) + - [SQLite Schema](#sqlite-schema) + - [MongoDB Schema](#mongodb-schema) +- [Wire Protocol Parsers](#wire-protocol-parsers) + - [SQL Parser](#sql-parser) + - [MySQL Parser](#mysql-parser) + - [PostgreSQL Parser](#postgresql-parser) + - [MongoDB Parser](#mongodb-parser) +- [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 +163,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 +210,1794 @@ 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]); +``` + +### Helpers + +```php +// Group queries by type +$grouped = Query::groupByType($queries); +// $grouped->filters, $grouped->limit, $grouped->orderAttributes, etc. + +// 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); ``` -### Grouping Helpers +## Query Builder + +The builder generates parameterized queries from the fluent API. Every `build()`, `insert()`, `update()`, and `delete()` call returns a `Plan` with `->query` (the query string), `->bindings` (the parameter array), and `->readOnly` (whether the query is read-only). + +Six dialect implementations are provided: + +- `Utopia\Query\Builder\MySQL` — MySQL +- `Utopia\Query\Builder\MariaDB` — MariaDB (extends MySQL with dialect-specific spatial handling) +- `Utopia\Query\Builder\PostgreSQL` — PostgreSQL +- `Utopia\Query\Builder\SQLite` — SQLite +- `Utopia\Query\Builder\ClickHouse` — ClickHouse +- `Utopia\Query\Builder\MongoDB` — MongoDB (generates JSON operation documents) -`groupByType` splits an array of queries into categorized buckets: +MySQL, MariaDB, PostgreSQL, and SQLite extend `Builder\SQL` which adds locking, transactions, upsert, spatial queries, and full-text search. ClickHouse and MongoDB extend `Builder` directly with their own dialect-specific 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] +$result->readOnly; // true +``` + +**Batch mode** — pass all queries at once: + +```php +$result = (new Builder()) + ->from('users') + ->queries([ + Query::select(['name', 'email']), + Query::equal('status', ['active']), + Query::orderAsc('name'), + Query::limit(25), + ]) + ->build(); +``` + +**Using with PDO:** + +```php +$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(); +``` + +### 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` > ? +``` + +**Distinct:** + +```php +$result = (new Builder()) + ->from('users') + ->distinct() + ->select(['country']) + ->build(); -// $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' +// SELECT DISTINCT `country` FROM `users` ``` -`getByType` filters queries by one or more method types: +### Statistical Aggregates + +Available on MySQL, PostgreSQL, SQLite, and ClickHouse via the `StatisticalAggregates` interface: ```php -$cursors = Query::getByType($queries, [Query::TYPE_CURSOR_AFTER, Query::TYPE_CURSOR_BEFORE]); +use Utopia\Query\Builder\PostgreSQL as Builder; + +$result = (new Builder()) + ->from('measurements') + ->stddev('value', 'std_dev') + ->stddevPop('value', 'pop_std_dev') + ->stddevSamp('value', 'samp_std_dev') + ->variance('value', 'var') + ->varPop('value', 'pop_var') + ->varSamp('value', 'samp_var') + ->build(); ``` -### Building an Adapter +### Bitwise Aggregates -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: +Available on MySQL, PostgreSQL, SQLite, and ClickHouse via the `BitwiseAggregates` interface: ```php -use Utopia\Query\Query; +$result = (new Builder()) + ->from('permissions') + ->bitAnd('flags', 'combined_and') + ->bitOr('flags', 'combined_or') + ->bitXor('flags', 'combined_xor') + ->build(); +``` + +### Conditional Aggregates + +Available on MySQL, PostgreSQL, SQLite, and ClickHouse via the `ConditionalAggregates` interface: + +```php +use Utopia\Query\Builder\PostgreSQL as Builder; + +$result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->sumWhen('amount', 'status = ?', 'active_total', 'active') + ->build(); + +// PostgreSQL: COUNT(*) FILTER (WHERE status = ?) AS "active_count", SUM("amount") FILTER (WHERE status = ?) AS "active_total" +// MySQL: COUNT(CASE WHEN status = ? THEN 1 END) AS `active_count`, SUM(CASE WHEN status = ? THEN `amount` END) AS `active_total` +// ClickHouse: countIf(status = ?) AS `active_count`, sumIf(`amount`, status = ?) AS `active_total` +``` + +Also available: `avgWhen()`, `minWhen()`, `maxWhen()`. + +### String Aggregates + +Available on MySQL, PostgreSQL, and ClickHouse via the `StringAggregates` interface: + +```php +use Utopia\Query\Builder\MySQL as Builder; + +// Concatenate values into a string +$result = (new Builder()) + ->from('tags') + ->select(['post_id']) + ->groupConcat('name', ', ', 'tag_list', orderBy: ['name']) + ->groupBy(['post_id']) + ->build(); + +// MySQL: GROUP_CONCAT(`name` ORDER BY `name` ASC SEPARATOR ', ') +// PostgreSQL: STRING_AGG("name", ', ' ORDER BY "name" ASC) +// ClickHouse: arrayStringConcat(groupArray(`name`), ', ') + +// JSON array aggregation +$result = (new Builder()) + ->from('items') + ->jsonArrayAgg('name', 'names_json') + ->build(); + +// JSON object aggregation from key/value pairs +$result = (new Builder()) + ->from('settings') + ->jsonObjectAgg('key', 'value', 'settings_json') + ->build(); +``` + +### Group By Modifiers + +Available on MySQL, PostgreSQL, and ClickHouse via the `GroupByModifiers` interface: + +```php +use Utopia\Query\Builder\MySQL as Builder; + +// WITH ROLLUP — adds subtotal and grand total rows +$result = (new Builder()) + ->from('sales') + ->select(['region', 'product']) + ->sum('amount', 'total') + ->groupBy(['region', 'product']) + ->withRollup() + ->build(); + +// WITH CUBE — adds subtotals for all dimension combinations +$result = (new Builder()) + ->from('sales') + ->select(['region', 'product']) + ->sum('amount', 'total') + ->groupBy(['region', 'product']) + ->withCube() + ->build(); + +// WITH TOTALS (ClickHouse) — adds a totals row +$result = (new \Utopia\Query\Builder\ClickHouse()) + ->from('events') + ->select(['event_type']) + ->count('*', 'cnt') + ->groupBy(['event_type']) + ->withTotals() + ->build(); +``` + +### Joins + +```php +$result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->leftJoin('profiles', 'users.id', 'profiles.user_id') + ->rightJoin('notes', 'users.id', 'notes.user_id') + ->crossJoin('colors') + ->naturalJoin('defaults') + ->build(); + +// SELECT * FROM `users` +// JOIN `orders` ON `users`.`id` = `orders`.`user_id` +// LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` +// RIGHT JOIN `notes` ON `users`.`id` = `notes`.`user_id` +// CROSS JOIN `colors` +// NATURAL JOIN `defaults` +``` + +**Complex join conditions** with `joinWhere()`: + +```php +use Utopia\Query\Builder\JoinType; + +$result = (new Builder()) + ->from('users') + ->joinWhere('orders', function ($join) { + $join->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'active'); + }, JoinType::Left) + ->build(); +``` + +**Full outer joins** (PostgreSQL, ClickHouse): + +```php +$result = (new \Utopia\Query\Builder\PostgreSQL()) + ->from('left_table') + ->fullOuterJoin('right_table', 'left_table.id', 'right_table.id') + ->build(); + +// SELECT * FROM "left_table" FULL OUTER JOIN "right_table" ON "left_table"."id" = "right_table"."id" +``` + +**Lateral joins** (MySQL, PostgreSQL): + +```php +$sub = (new Builder())->from('orders')->filter([Query::raw('orders.user_id = users.id')])->limit(3); + +$result = (new Builder()) + ->from('users') + ->joinLateral($sub, 'recent_orders') + ->build(); +``` + +### 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, or `withRecursiveSeedStep()` to construct a recursive CTE from separate seed and step builders: + +```php +$seed = (new Builder())->from('employees')->filter([Query::isNull('manager_id')]); +$step = (new Builder())->from('employees')->join('org', 'employees.manager_id', 'org.id'); + +$result = (new Builder()) + ->withRecursiveSeedStep('org', $seed, $step) + ->from('org') + ->build(); +``` + +### 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']`). + +**Named window definitions** allow reusing the same window across multiple expressions: + +```php +$result = (new Builder()) + ->from('sales') + ->select(['employee', 'amount']) + ->window('w', partitionBy: ['department'], orderBy: ['date']) + ->selectWindow('ROW_NUMBER()', 'row_num', windowName: 'w') + ->selectWindow('SUM(amount)', 'running_total', windowName: 'w') + ->build(); + +// SELECT `employee`, `amount`, +// ROW_NUMBER() OVER `w` AS `row_num`, +// SUM(amount) OVER `w` AS `running_total` +// FROM `sales` +// WINDOW `w` AS (PARTITION BY `department` ORDER BY `date` ASC) +``` + +### CASE Expressions + +Build a CASE expression with `Utopia\Query\Builder\Case\Expression`, then pass it to `selectCase()` or `setCase()`. All columns are quoted by the dialect, and all values are bound as parameters: + +```php +use Utopia\Query\Builder\Case\Expression as CaseExpression; +use Utopia\Query\Builder\Case\Operator; + +$case = (new CaseExpression()) + ->when('amount', Operator::GreaterThan, 1000, 'high') + ->when('amount', Operator::GreaterThan, 100, 'medium') + ->else('low') + ->alias('priority'); + +$result = (new Builder()) + ->from('orders') + ->select(['id']) + ->selectCase($case) + ->build(); + +// SELECT `id`, CASE WHEN `amount` > ? THEN ? WHEN `amount` > ? THEN ? ELSE ? END AS `priority` +// FROM `orders` +``` + +Supported WHEN shapes: + +- `when(string $column, Operator $operator, mixed $value, mixed $then)` — comparison. The operator is a closed enum of the six comparisons: `Operator::Equal`, `Operator::NotEqual`, `Operator::LessThan`, `Operator::LessThanEqual`, `Operator::GreaterThan`, `Operator::GreaterThanEqual`. +- `whenNull(string $column, mixed $then)` and `whenNotNull(string $column, mixed $then)`. +- `whenIn(string $column, array $values, mixed $then)`. +- `whenRaw(string $condition, mixed $then, array $conditionBindings = [])` — escape hatch for complex predicates. The caller owns the SQL fragment; the `$then` value is still bound. + +### 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(); +``` + +### 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, PostgreSQL, and SQLite 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(); +``` + +**Insert or ignore** — skip rows that conflict instead of updating: + +```php +$result = (new Builder()) + ->into('counters') + ->set('key', 'visits') + ->set('value', 1) + ->onConflict(['key']) + ->insertOrIgnore(); + +// MySQL: INSERT IGNORE INTO `counters` ... +// PostgreSQL: INSERT INTO "counters" ... ON CONFLICT ("key") DO NOTHING +// SQLite: INSERT OR IGNORE INTO `counters` ... +``` + +**Upsert from SELECT** — insert from a subquery with conflict resolution: + +```php +$source = (new Builder())->from('staging')->select(['key', 'value']); + +$result = (new Builder()) + ->into('counters') + ->fromSelect($source, ['key', 'value']) + ->onConflict(['key']) + ->upsertSelect(); +``` + +### Locking + +Available on MySQL, PostgreSQL, and SQLite 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()`, `forUpdateSkipLocked()`, `forUpdateNoWait()`, `forShareSkipLocked()`, `forShareNoWait()`. + +PostgreSQL also supports table-specific locking: `forUpdateOf('accounts')`, `forShareOf('accounts')`. + +### Transactions + +Available on MySQL, PostgreSQL, and SQLite builders: + +```php +$builder = new Builder(); + +$builder->begin(); // BEGIN +$builder->savepoint('sp1'); // SAVEPOINT `sp1` +$builder->rollbackToSavepoint('sp1'); +$builder->commit(); // COMMIT +$builder->rollback(); // ROLLBACK +``` + +### EXPLAIN + +Available on all builders. MySQL and PostgreSQL provide extended options: + +```php +// Basic explain +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + +// MySQL — with format +$result = (new \Utopia\Query\Builder\MySQL()) + ->from('users') + ->explain(analyze: true, format: 'JSON'); + +// PostgreSQL — with analyze, verbose, buffers, format +$result = (new \Utopia\Query\Builder\PostgreSQL()) + ->from('users') + ->explain(analyze: true, verbose: true, buffers: true, format: 'JSON'); +``` + +### 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(); +``` + +### Builder Cloning and Callbacks + +**Cloning** creates a deep copy of the builder, useful for branching from a shared base: + +```php +$base = (new Builder())->from('users')->filter([Query::equal('status', ['active'])]); +$withLimit = $base->clone()->limit(10); +$withSort = $base->clone()->sortAsc('name'); +``` + +**Build callbacks** run before or after building: + +```php +$result = (new Builder()) + ->from('users') + ->beforeBuild(fn(Builder $b) => $b->filter([Query::isNotNull('email')])) + ->afterBuild(fn(Plan $r) => new Plan("/* traced */ {$r->query}", $r->bindings, $r->readOnly)) + ->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; + +$result = (new Builder()) + ->from('users') + ->addHook(new Tenant(['tenant_abc'])) + ->filter([Query::equal('status', ['active'])]) + ->build(); + +// SELECT * FROM `users` +// WHERE `status` IN (?) AND `tenant_id` IN (?) +``` -class SQLAdapter +**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\Builder\Condition; +use Utopia\Query\Builder\JoinType; +use Utopia\Query\Hook\Join\Filter as JoinFilter; +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, JoinType $joinType): ?Condition { - $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 Condition( + 'active = ?', + [1], + match ($joinType) { + JoinType::Left, JoinType::Right => Placement::On, + default => Placement::Where, + }, + ); } } ``` -This keeps your application code decoupled from any particular storage engine — swap adapters without changing a single query. +The built-in `Tenant` hook implements both `Filter` and `JoinFilter` — it automatically applies ON placement for LEFT/RIGHT joins and WHERE placement for INNER/CROSS joins. -## Contributing +**Write hooks** decorate rows before writes and run callbacks after create/update/delete operations: -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. +```php +use Utopia\Query\Hook\Write; -```bash -# Install dependencies -composer install +class AuditHook implements Write +{ + public function decorateRow(array $row, array $metadata = []): array { /* ... */ } + public function afterCreate(string $table, array $metadata, mixed $context): void { /* ... */ } + public function afterUpdate(string $table, array $metadata, mixed $context): void { /* ... */ } + public function afterBatchUpdate(string $table, array $updateData, array $metadata, mixed $context): void { /* ... */ } + public function afterDelete(string $table, array $ids, mixed $context): void { /* ... */ } +} +``` + +## Dialect-Specific Features + +### MySQL + +```php +use Utopia\Query\Builder\MySQL as Builder; +``` + +**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, 'axis-order=long-lat'), 'metre') < ? +``` + +All spatial predicates: `filterDistance`, `filterIntersects`, `filterNotIntersects`, `filterCrosses`, `filterNotCrosses`, `filterOverlaps`, `filterNotOverlaps`, `filterTouches`, `filterNotTouches`, `filterCovers`, `filterNotCovers`, `filterSpatialEquals`, `filterNotSpatialEquals`. + +**JSON operations:** + +```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(? IN BOOLEAN MODE)`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE) +``` + +**UPDATE with JOIN:** + +```php +$result = (new Builder()) + ->from('users') + ->set('status', 'premium') + ->updateJoin('orders', 'users.id', 'orders.user_id') + ->filter([Query::greaterThan('orders.total', 1000)]) + ->update(); +``` + +**DELETE with JOIN:** + +```php +$result = (new Builder()) + ->from('users') + ->deleteUsing('u', 'orders', 'u.id', 'orders.user_id') + ->filter([Query::equal('orders.status', ['cancelled'])]) + ->delete(); +``` -# Run tests -composer test +### MariaDB -# Run linter -composer lint +```php +use Utopia\Query\Builder\MariaDB as Builder; +``` + +Extends MySQL with MariaDB-specific spatial handling: +- Uses `ST_DISTANCE_SPHERE()` for meter-based distance calculations +- Uses `ST_GeomFromText()` without the `axis-order` parameter +- Validates that distance-in-meters only works between POINT types + +All other MySQL features (JSON, hints, lateral joins, etc.) are inherited. + +### 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 +use Utopia\Query\Builder\VectorMetric; -# Auto-format code -composer format +$result = (new Builder()) + ->from('documents') + ->select(['title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(10) + ->build(); -# Run static analysis -composer check +// SELECT "title" FROM "documents" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ? +``` + +Metrics: `VectorMetric::Cosine` (`<=>`), `VectorMetric::Euclidean` (`<->`), `VectorMetric::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() @@ websearch_to_tsquery()`: + +```php +$result = (new Builder()) + ->from('articles') + ->filter([Query::search('content', 'hello world')]) + ->build(); + +// WHERE to_tsvector("content") @@ websearch_to_tsquery(?) +``` + +**Regex** — uses PostgreSQL `~` operator instead of `REGEXP`. String matching uses `ILIKE` for case-insensitive comparison. + +**RETURNING** — get affected rows back from INSERT/UPDATE/DELETE: + +```php +$result = (new Builder()) + ->into('users') + ->set('name', 'Alice') + ->returning(['id', 'created_at']) + ->insert(); + +// INSERT INTO "users" ("name") VALUES (?) RETURNING "id", "created_at" +``` + +**DISTINCT ON** — select the first row per group: + +```php +$result = (new Builder()) + ->from('events') + ->distinctOn(['user_id']) + ->select(['user_id', 'event_type', 'created_at']) + ->sortDesc('created_at') + ->build(); + +// SELECT DISTINCT ON ("user_id") "user_id", "event_type", "created_at" +// FROM "events" ORDER BY "created_at" DESC +``` + +**Aggregate FILTER** — per-aggregate WHERE clause (SQL standard): + +```php +$result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('COUNT(*)', 'status = ?', 'active_count', ['active']) + ->selectAggregateFilter('SUM("amount")', 'status = ?', 'active_total', ['active']) + ->build(); + +// SELECT COUNT(*) FILTER (WHERE status = ?) AS "active_count", +// SUM("amount") FILTER (WHERE status = ?) AS "active_total" +// FROM "orders" +``` + +**Ordered-set aggregates:** + +```php +$result = (new Builder()) + ->from('salaries') + ->arrayAgg('name', 'all_names') + ->percentileCont(0.5, 'salary', 'median_salary') + ->percentileDisc(0.9, 'salary', 'p90_salary') + ->boolAnd('is_active', 'all_active') + ->boolOr('is_admin', 'any_admin') + ->every('is_verified', 'all_verified') + ->build(); +``` + +**MERGE** — SQL standard MERGE statement: + +```php +$source = (new Builder())->from('staging'); + +$result = (new Builder()) + ->mergeInto('target') + ->using($source, 's') + ->on('"target"."id" = "s"."id"') + ->whenMatched('UPDATE SET "name" = "s"."name"') + ->whenNotMatched('INSERT ("id", "name") VALUES ("s"."id", "s"."name")') + ->executeMerge(); +``` + +**UPDATE FROM / DELETE USING:** + +```php +// UPDATE ... FROM +$result = (new Builder()) + ->from('users') + ->set('status', 'premium') + ->updateFrom('orders', 'o') + ->updateFromWhere('"users"."id" = "o"."user_id"') + ->update(); + +// DELETE ... USING +$result = (new Builder()) + ->from('users') + ->deleteUsing('old_users', '"users"."id" = "old_users"."id"') + ->delete(); +``` + +**Table sampling:** + +```php +$result = (new Builder()) + ->from('large_table') + ->tablesample(10.0, 'BERNOULLI') + ->count('*', 'approx') + ->build(); + +// SELECT COUNT(*) AS "approx" FROM "large_table" TABLESAMPLE BERNOULLI (10) +``` + +### SQLite + +```php +use Utopia\Query\Builder\SQLite as Builder; +``` + +Extends `Builder\SQL` with SQLite-specific behavior: +- JSON support via `json_each()` and `json_extract()` +- Conditional aggregates using `CASE WHEN` syntax +- `INSERT OR IGNORE` for insertOrIgnore +- Regex and full-text search throw `UnsupportedException` +- Spatial queries throw `UnsupportedException` + +### 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 +``` + +**LIMIT BY** — limit rows per group: + +```php +$result = (new Builder()) + ->from('events') + ->select(['user_id', 'event_type']) + ->limitBy(3, ['user_id']) + ->build(); + +// SELECT `user_id`, `event_type` FROM `events` LIMIT 3 BY `user_id` +``` + +**ARRAY JOIN** — unnest array columns into rows: + +```php +$result = (new Builder()) + ->from('events') + ->select(['name']) + ->arrayJoin('tags', 'tag') + ->build(); + +// SELECT `name`, `tags` AS `tag` FROM `events` ARRAY JOIN `tags` AS `tag` + +// LEFT variant preserves rows with empty arrays +$result = (new Builder()) + ->from('events') + ->leftArrayJoin('tags', 'tag') + ->build(); +``` + +**ASOF JOIN** — join on the closest matching row (time-series): + +```php +$result = (new Builder()) + ->from('trades') + ->asofJoin('quotes', 'trades.timestamp', 'quotes.timestamp', 'q') + ->build(); + +// SELECT * FROM `trades` ASOF JOIN `quotes` AS `q` ON `trades`.`timestamp` >= `quotes`.`timestamp` + +// LEFT variant +$result = (new Builder()) + ->from('trades') + ->asofLeftJoin('quotes', 'trades.timestamp', 'quotes.timestamp') + ->build(); +``` + +**ORDER BY ... WITH FILL** — fill gaps in ordered results: + +```php +$result = (new Builder()) + ->from('daily_stats') + ->select(['date', 'count']) + ->orderWithFill('date', 'ASC', from: '2024-01-01', to: '2024-01-31', step: 1) + ->build(); + +// SELECT `date`, `count` FROM `daily_stats` ORDER BY `date` ASC WITH FILL FROM '2024-01-01' TO '2024-01-31' STEP 1 +``` + +**Approximate aggregates** — ClickHouse-native probabilistic functions: + +```php +$result = (new Builder()) + ->from('events') + ->quantile(0.95, 'response_time', 'p95') + ->quantileExact(0.99, 'response_time', 'p99') + ->median('response_time', 'med') + ->uniq('user_id', 'approx_users') + ->uniqExact('user_id', 'exact_users') + ->uniqCombined('user_id', 'combined_users') + ->build(); + +// SELECT quantile(0.95)(`response_time`) AS `p95`, +// quantileExact(0.99)(`response_time`) AS `p99`, +// median(`response_time`) AS `med`, +// uniq(`user_id`) AS `approx_users`, +// uniqExact(`user_id`) AS `exact_users`, +// uniqCombined(`user_id`) AS `combined_users` +// FROM `events` +``` + +Additional approximate aggregates: `argMin()`, `argMax()`, `topK()`, `topKWeighted()`, `anyValue()`, `anyLastValue()`, `groupUniqArray()`, `groupArrayMovingAvg()`, `groupArrayMovingSum()`. + +**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. + +### MongoDB + +```php +use Utopia\Query\Builder\MongoDB as Builder; +``` + +The MongoDB builder generates JSON operation documents instead of SQL. The `Plan->query` contains a JSON-encoded operation and `Plan->bindings` contains parameter values. + +**Basic queries:** + +```php +$result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(25) + ->build(); + +// Generates a find operation with filter, sort, limit, and projection +``` + +**Array operations:** + +```php +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('_id', ['user_1'])]) + ->push('tags', 'new-tag') + ->update(); + +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('_id', ['user_1'])]) + ->pull('tags', 'old-tag') + ->addToSet('roles', 'editor') + ->increment('login_count', 1) + ->update(); +``` + +**Field update operations:** + +```php +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('_id', ['user_1'])]) + ->rename('old_field', 'new_field') + ->multiply('score', 1.5) + ->updateMin('low_score', 10) + ->updateMax('high_score', 100) + ->currentDate('last_modified') + ->update(); + +// Array element removal +$result = (new Builder()) + ->from('lists') + ->filter([Query::equal('_id', ['list_1'])]) + ->popFirst('items') // Remove first element + ->popLast('queue') // Remove last element + ->pullAll('tags', ['deprecated', 'old']) + ->update(); + +// Remove fields entirely +$result = (new Builder()) + ->from('users') + ->filter([Query::equal('_id', ['user_1'])]) + ->unsetFields('legacy_field', 'temp_data') + ->update(); +``` + +**Advanced array push** with position, slice, and sort modifiers: + +```php +$result = (new Builder()) + ->from('feeds') + ->filter([Query::equal('_id', ['feed_1'])]) + ->pushEach('items', [['score' => 5], ['score' => 3]], position: 0, slice: 10, sort: ['score' => -1]) + ->update(); +``` + +**Conditional array updates** with array filters: + +```php +$result = (new Builder()) + ->from('orders') + ->filter([Query::equal('_id', ['order_1'])]) + ->arrayFilter('elem', ['elem.status' => 'pending']) + ->set(['items.$[elem].status' => 'shipped']) + ->update(); +``` + +**Upsert:** + +```php +$result = (new Builder()) + ->into('counters') + ->set(['key' => 'visits', 'value' => 1]) + ->onConflict(['key']) + ->upsert(); +``` + +**Pipeline aggregation stages:** + +```php +// Bucket — group documents into fixed-size ranges +$result = (new Builder()) + ->from('sales') + ->bucket('price', [0, 50, 100, 500], defaultBucket: 'other', output: ['count' => ['$sum' => 1]]) + ->build(); + +// BucketAuto — automatically determine bucket boundaries +$result = (new Builder()) + ->from('sales') + ->bucketAuto('price', 5, output: ['count' => ['$sum' => 1]]) + ->build(); + +// Facet — run multiple aggregation pipelines in parallel +$byStatus = (new Builder())->from('orders')->groupBy(['status'])->count('*', 'count'); +$byRegion = (new Builder())->from('orders')->groupBy(['region'])->sum('amount', 'total'); + +$result = (new Builder()) + ->from('orders') + ->facet(['by_status' => $byStatus, 'by_region' => $byRegion]) + ->build(); + +// GraphLookup — recursive graph traversal +$result = (new Builder()) + ->from('employees') + ->graphLookup( + from: 'employees', + startWith: '$manager_id', + connectFromField: 'manager_id', + connectToField: '_id', + as: 'reporting_chain', + maxDepth: 5, + depthField: 'level' + ) + ->build(); + +// Merge results into another collection +$result = (new Builder()) + ->from('daily_stats') + ->mergeIntoCollection('monthly_stats', on: ['month'], whenMatched: ['$set' => ['total' => '$total']]) + ->build(); + +// Output to a new collection +$result = (new Builder()) + ->from('raw_data') + ->outputToCollection('processed_data', database: 'analytics') + ->build(); + +// Replace the root document +$result = (new Builder()) + ->from('orders') + ->replaceRoot('$shipping_address') + ->build(); +``` + +**Atlas Search:** + +```php +// Full-text search with Atlas Search +$result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'mongodb', 'path' => 'content']], index: 'default') + ->build(); + +// Search metadata (facet counts, etc.) +$result = (new Builder()) + ->from('articles') + ->searchMeta(['facet' => ['facets' => ['categories' => ['type' => 'string', 'path' => 'category']]]], index: 'default') + ->build(); + +// Atlas Vector Search +$result = (new Builder()) + ->from('documents') + ->vectorSearch( + path: 'embedding', + queryVector: [0.1, 0.2, 0.3], + numCandidates: 100, + limit: 10, + index: 'vector_index', + filter: ['category' => 'tech'] + ) + ->build(); +``` + +**Table sampling:** + +```php +$result = (new Builder()) + ->from('large_collection') + ->tablesample(10.0) + ->build(); +``` + +**Full-text search** (non-Atlas): + +```php +$result = (new Builder()) + ->from('articles') + ->filterSearch('content', 'hello world') + ->build(); +``` + +### 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 | MariaDB | PostgreSQL | SQLite | ClickHouse | MongoDB | +|---------|:-------:|:---:|:-----:|:-------:|:----------:|:------:|:----------:|:-------:| +| Selects, Filters, Aggregates, Joins, Unions, CTEs, Inserts, Updates, Deletes, Hooks | x | | | | | | | | +| Windows | x | | | | | | | | +| Locking, Transactions, Upsert | | x | | | | | | | +| Spatial, Full-Text Search | | x | | | | | | | +| Statistical Aggregates | | x | | | | | x | | +| Bitwise Aggregates | | x | | | | | x | | +| Conditional Aggregates | | | x | x | x | x | x | | +| JSON | | | x | x | x | x | | | +| Hints | | | x | x | | | x | | +| Lateral Joins | | | x | x | x | | | | +| String Aggregates | | | x | x | x | | x | | +| Group By Modifiers | | | x | x | x | | x | | +| Full Outer Joins | | | | | x | | x | | +| Table Sampling | | | | | x | | x | x | +| Merge | | | | | x | | | | +| Returning | | | | | x | | | | +| Vector Search | | | | | x | | | | +| DISTINCT ON | | | | | x | | | | +| Aggregate FILTER | | | | | x | | | | +| Ordered-Set Aggregates | | | | | x | | | | +| PREWHERE, FINAL, SAMPLE | | | | | | | x | | +| LIMIT BY | | | | | | | x | | +| ARRAY JOIN | | | | | | | x | | +| ASOF JOIN | | | | | | | x | | +| WITH FILL | | | | | | | x | | +| Approximate Aggregates | | | | | | | x | | +| Upsert | | | | | | | | x | +| Full-Text Search | | | | | | | | x | +| Field Updates | | | | | | | | x | +| Array Push Modifiers | | | | | | | | x | +| Conditional Array Updates | | | | | | | | x | +| Pipeline Stages | | | | | | | | x | +| Atlas Search | | | | | | | | 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, SQLite, MongoDB +``` + +### 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` (...) +``` + +Use `createIfNotExists()` to add `IF NOT EXISTS`: + +```php +$result = $schema->createIfNotExists('users', function ($table) { + $table->id(); + $table->string('name', 255); +}); +``` + +Available column types: `id`, `string`, `text`, `mediumText`, `longText`, `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)`, `collation($collation)`. + +### 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, operator classes, and concurrent creation: + +```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'); + +// Concurrent index creation (non-blocking) +$result = $schema->createIndex('users', 'idx_email', ['email'], concurrently: true); + +// Concurrent index drop +$result = $schema->dropIndexConcurrently('idx_email'); +``` + +### Foreign Keys + +```php +use Utopia\Query\Schema\ForeignKeyAction; + +$result = $schema->addForeignKey('orders', 'fk_user', 'user_id', + 'users', 'id', onDelete: ForeignKeyAction::Cascade); + +$result = $schema->dropForeignKey('orders', 'fk_user'); +``` + +Available actions: `ForeignKeyAction::Cascade`, `SetNull`, `SetDefault`, `Restrict`, `NoAction`. + +### Partitions + +Available on MySQL, PostgreSQL, and ClickHouse: + +```php +// Define partition strategy in table creation +$result = $schema->create('events', function ($table) { + $table->id(); + $table->datetime('created_at'); + $table->partitionByRange('created_at'); +}); + +// Create a child partition (MySQL, PostgreSQL) +$result = $schema->createPartition('events', 'events_2024', "VALUES LESS THAN ('2025-01-01')"); + +// Drop a partition +$result = $schema->dropPartition('events', 'events_2024'); +``` + +Partition strategies: `partitionByRange()`, `partitionByList()`, `partitionByHash()`. + +### Comments + +Table and column comments are available via the `TableComments` and `ColumnComments` interfaces: + +```php +// Table comments (MySQL, PostgreSQL, ClickHouse) +$result = $schema->commentOnTable('users', 'Main user accounts table'); + +// Column comments (PostgreSQL, ClickHouse) +$result = $schema->commentOnColumn('users', 'email', 'Primary contact email'); +``` + +### 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 +use Utopia\Query\Schema\ParameterDirection; +use Utopia\Query\Schema\TriggerTiming; +use Utopia\Query\Schema\TriggerEvent; + +// Procedure +$result = $schema->createProcedure('update_stats', [ + [ParameterDirection::In, 'user_id', 'INT'], +], 'UPDATE stats SET count = count + 1 WHERE id = user_id;'); + +// Trigger +$result = $schema->createTrigger('before_insert_users', 'users', + TriggerTiming::Before, TriggerEvent::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', [ + [ParameterDirection::In, 'p_id', 'INTEGER'], +], ' +BEGIN + UPDATE counters SET value = value + 1 WHERE id = p_id; +END; +'); + +// Custom types +$result = $schema->createType('status_type', ['active', 'inactive', 'banned']); +$result = $schema->dropType('status_type'); + +// Sequences +$result = $schema->createSequence('order_seq', start: 1000, incrementBy: 1); +$result = $schema->dropSequence('order_seq'); +$result = $schema->nextVal('order_seq'); + +// Collations +$result = $schema->createCollation('custom_collation', ['locale' => 'en-US-u-ks-level2']); + +// Alter column type with optional USING expression +$result = $schema->alterColumnType('users', 'age', 'BIGINT', using: '"age"::BIGINT'); + +// 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`. + +Supports `TableComments`, `ColumnComments`, and `DropPartition` interfaces. + +### SQLite Schema + +```php +$schema = new \Utopia\Query\Schema\SQLite(); +``` + +SQLite uses simplified type mappings: `INTEGER` for booleans, `TEXT` for datetimes/JSON, `REAL` for floats, `BLOB` for binary. Auto-increment uses `AUTOINCREMENT`. Vector and spatial types are not supported. Foreign keys, stored procedures, and triggers throw `UnsupportedException`. + +### MongoDB Schema + +```php +$schema = new \Utopia\Query\Schema\MongoDB(); +``` + +The MongoDB schema generates JSON commands for collection management with BSON type validation. + +**Creating collections** with JSON Schema validation: + +```php +$result = $schema->create('users', function ($table) { + $table->string('name', 255); + $table->string('email', 255)->unique(); + $table->integer('age')->nullable(); + $table->boolean('active')->default(true); + $table->json('metadata'); +}); + +// Generates a create command with bsonType validators +``` + +**Altering collections:** + +```php +$result = $schema->alter('users', function ($table) { + $table->string('phone', 20)->nullable(); +}); + +// Generates a collMod command to update the validator +``` + +**Indexes:** + +```php +$result = $schema->createIndex('users', 'idx_email', ['email'], unique: true); +$result = $schema->dropIndex('users', 'idx_email'); +``` + +**Collection operations:** + +```php +$result = $schema->drop('users'); +$result = $schema->rename('old_name', 'new_name'); +$result = $schema->truncate('users'); +$result = $schema->analyzeTable('users'); +``` + +**Views:** + +```php +$query = (new \Utopia\Query\Builder\MongoDB())->from('users')->filter([Query::equal('active', [true])]); +$result = $schema->createView('active_users', $query); +``` + +**Database management:** + +```php +$result = $schema->createDatabase('analytics'); +$result = $schema->dropDatabase('analytics'); +``` + +Column types map to BSON types: `string` → `string`, `integer`/`bigInteger` → `int`, `float`/`double` → `double`, `boolean` → `bool`, `datetime`/`timestamp` → `date`, `json` → `object`, `binary` → `binData`. + +## Wire Protocol Parsers + +The `Parser` interface classifies raw database traffic into query types (`Read`, `Write`, `TransactionBegin`, `TransactionEnd`, `Unknown`). This is useful for connection proxies, audit logging, and read/write splitting. + +```php +use Utopia\Query\Parser; +use Utopia\Query\Type; +``` + +### SQL Parser + +The abstract `Parser\SQL` class provides keyword-based classification for SQL dialects: + +```php +use Utopia\Query\Parser\SQL; + +// Classify SQL text directly +$type = $parser->classifySQL('SELECT * FROM users'); // Type::Read +$type = $parser->classifySQL('INSERT INTO users ...'); // Type::Write +$type = $parser->classifySQL('BEGIN'); // Type::TransactionBegin +$type = $parser->classifySQL('COMMIT'); // Type::TransactionEnd +``` + +Read keywords: `SELECT`, `SHOW`, `DESCRIBE`, `DESC`, `EXPLAIN`, `WITH` (when followed by a read), `TABLE`, `VALUES`. + +Write keywords: `INSERT`, `UPDATE`, `DELETE`, `ALTER`, `DROP`, `CREATE`, `TRUNCATE`, `RENAME`, `REPLACE`, `LOAD`, `GRANT`, `REVOKE`, `MERGE`, `CALL`, `EXECUTE`, `DO`, `HANDLER`, `IMPORT`. + +Transaction keywords: `BEGIN`, `START` → `TransactionBegin`; `COMMIT`, `ROLLBACK`, `SAVEPOINT`, `RELEASE` → `TransactionEnd`. + +Special handling: `COPY` is classified based on direction (`FROM STDIN` = Write, `TO STDOUT` = Read). `SET` is classified as `TransactionEnd` (session configuration). + +### MySQL Parser + +Parses MySQL wire protocol binary packets: + +```php +use Utopia\Query\Parser\MySQL; + +$parser = new MySQL(); +$type = $parser->parse($rawPacketData); // Type::Read, Write, TransactionBegin, etc. +``` + +Recognizes MySQL command bytes including `COM_QUERY` (classifies via SQL text), `COM_STMT_PREPARE`, `COM_STMT_EXECUTE`, `COM_INIT_DB`, `COM_QUIT`, and others. + +### PostgreSQL Parser + +Parses PostgreSQL wire protocol messages: + +```php +use Utopia\Query\Parser\PostgreSQL; + +$parser = new PostgreSQL(); +$type = $parser->parse($rawMessageData); // Type::Read, Write, TransactionBegin, etc. +``` + +Handles message types including `Q` (simple query), `P` (parse/prepared statement), `X` (terminate), and startup messages. + +### MongoDB Parser + +Parses MongoDB OP_MSG binary protocol messages: + +```php +use Utopia\Query\Parser\MongoDB; + +$parser = new MongoDB(); +$type = $parser->parse($rawOpMsgData); // Type::Read, Write, TransactionBegin, etc. +``` + +Extracts the command name from BSON documents and classifies: + +Read commands: `find`, `aggregate`, `count`, `distinct`, `listCollections`, `listDatabases`, `listIndexes`, `dbStats`, `collStats`, `explain`, `getMore`, `serverStatus`, `buildInfo`, `connectionStatus`, `ping`, `isMaster`, `hello`. + +Write commands: `insert`, `update`, `delete`, `findAndModify`, `create`, `drop`, `createIndexes`, `dropIndexes`, `dropDatabase`, `renameCollection`. + +Transaction detection: checks for `startTransaction: true` in the BSON document (`TransactionBegin`) or `commitTransaction`/`abortTransaction` commands (`TransactionEnd`). + +## 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) +``` + +**Integration tests** require Docker: + +```bash +docker compose -f docker-compose.test.yml up -d # Start MySQL, PostgreSQL, ClickHouse +composer test:integration # Run integration tests +docker compose -f docker-compose.test.yml down # Stop containers ``` ## License diff --git a/composer.json b/composer.json index e645108..18439ad 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,16 @@ }, "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/paratest --testsuite Query --processes=auto --exclude-group=performance", + "test:coverage": "vendor/bin/paratest --testsuite Query --processes=auto --exclude-group=performance --coverage-php coverage/unit.cov", + "test:performance": "vendor/bin/phpunit --testsuite Query --group=performance", + "test:integration": "vendor/bin/phpunit --testsuite Integration", + "test:integration:coverage": "vendor/bin/phpunit --testsuite Integration --coverage-php coverage/integration.cov", "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" @@ -27,6 +32,9 @@ "require-dev": { "phpunit/phpunit": "^12.0", "laravel/pint": "*", - "phpstan/phpstan": "*" + "phpstan/phpstan": "*", + "mongodb/mongodb": "^2.0", + "brianium/paratest": "*", + "phpunit/phpcov": "*" } } diff --git a/composer.lock b/composer.lock index 344b397..55f72ef 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,223 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8d3a806ee195c6aba374e449e59510e7", + "content-hash": "482400e406b1b2643b2a7d0c4577786b", "packages": [], "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.20.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.7 || ^8.0.7", + "symfony/process": "^7.4.5 || ^8.0.5" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-03-29T15:46:14+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "laravel/pint", "version": "v1.27.1", @@ -74,6 +288,83 @@ }, "time": "2026-02-10T20:00:20+00:00" }, + { + "name": "mongodb/mongodb", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "bbb13f969e37e047fd822527543df55fdc1c9298" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/bbb13f969e37e047fd822527543df55fdc1c9298", + "reference": "bbb13f969e37e047fd822527543df55fdc1c9298", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.2", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.32" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.3.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "~6.14.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/2.2.0" + }, + "time": "2026-02-11T11:39:56+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.4", @@ -709,6 +1000,68 @@ ], "time": "2025-02-07T04:59:38+00:00" }, + { + "name": "phpunit/phpcov", + "version": "11.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpcov.git", + "reference": "33d0c31e5fbed58ecef62283fe14974eea80bb5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpcov/zipball/33d0c31e5fbed58ecef62283fe14974eea80bb5a", + "reference": "33d0c31e5fbed58ecef62283fe14974eea80bb5a", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/phpunit": "^12.5.9", + "sebastian/cli-parser": "^4.2", + "sebastian/diff": "^7.0", + "sebastian/version": "^6.0" + }, + "bin": [ + "phpcov" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "CLI frontend for php-code-coverage", + "homepage": "https://github.com/sebastianbergmann/phpcov", + "support": { + "issues": "https://github.com/sebastianbergmann/phpcov/issues", + "source": "https://github.com/sebastianbergmann/phpcov/tree/11.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2026-02-06T06:43:00+00:00" + }, { "name": "phpunit/phpunit", "version": "12.5.14", @@ -816,85 +1169,188 @@ "time": "2026-02-18T12:38:40+00:00" }, { - "name": "sebastian/cli-parser", - "version": "4.2.0", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=8.3" - }, - "require-dev": { - "phpunit/phpunit": "^12.0" + "php": ">=7.4.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.2-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Psr\\Container\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", - "type": "tidelift" - } - ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "sebastian/comparator", - "version": "7.1.4", + "name": "psr/log", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2025-09-14T09:36:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", "shasum": "" }, @@ -1764,6 +2220,820 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/console", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.36.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-07-15T11:30:57+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "theseer/tokenizer", "version": "2.0.1", diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..dc99dd4 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,47 @@ +services: + mysql: + image: mysql:8.4 + ports: + - "13306:3306" + environment: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: query_test + tmpfs: + - /var/lib/mysql + + mariadb: + image: mariadb:11 + ports: + - "13307:3306" + environment: + MARIADB_ROOT_PASSWORD: test + MARIADB_DATABASE: query_test + tmpfs: + - /var/lib/mysql + + postgres: + image: pgvector/pgvector:pg16 + 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" + environment: + CLICKHOUSE_DB: query_test + tmpfs: + - /var/lib/clickhouse + + mongodb: + image: mongo:7 + ports: + - "27017:27017" + tmpfs: + - /data/db diff --git a/phpunit.xml b/phpunit.xml index 2ac99d0..04b6461 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,10 +3,14 @@ xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" colors="true" beStrictAboutTestsThatDoNotTestAnything="true" - bootstrap="vendor/autoload.php"> + bootstrap="vendor/autoload.php" + cacheDirectory=".phpunit.cache"> - tests + tests/Query + + + tests/Integration diff --git a/src/Query/AST/Call/Func.php b/src/Query/AST/Call/Func.php new file mode 100644 index 0000000..cb23387 --- /dev/null +++ b/src/Query/AST/Call/Func.php @@ -0,0 +1,19 @@ +tokens = $tokens; + $this->tokenCount = count($tokens); + $this->pos = 0; + $this->depth = 0; + $this->inColumnList = false; + + return $this->parseSelect(); + } + + private function parseSelect(): Select + { + $ctes = []; + $recursive = false; + + if ($this->matchKeyword('WITH')) { + $this->advance(); + if ($this->matchKeyword('RECURSIVE')) { + $recursive = true; + $this->advance(); + } + $ctes = $this->parseCteList($recursive); + } + + $this->consumeKeyword('SELECT'); + + $distinct = false; + if ($this->matchKeyword('DISTINCT')) { + $distinct = true; + $this->advance(); + } + + $columns = $this->parseColumnList(); + + $from = null; + $joins = []; + $where = null; + $groupBy = []; + $having = null; + $windows = []; + $orderBy = []; + $limit = null; + $offset = null; + + if ($this->matchKeyword('FROM')) { + $this->advance(); + $from = $this->parseTableSource(); + $joins = $this->parseJoins(); + } + + if ($this->matchKeyword('WHERE')) { + $this->advance(); + $where = $this->parseExpression(); + } + + if ($this->matchKeyword('GROUP')) { + $this->advance(); + $this->consumeKeyword('BY'); + $groupBy = $this->parseExpressionList(); + } + + if ($this->matchKeyword('HAVING')) { + $this->advance(); + $having = $this->parseExpression(); + } + + if ($this->matchKeyword('WINDOW')) { + $this->advance(); + $windows = $this->parseWindowDefinitions(); + } + + if ($this->matchKeyword('ORDER')) { + $this->advance(); + $this->consumeKeyword('BY'); + $orderBy = $this->parseOrderByList(); + } + + if ($this->matchKeyword('LIMIT')) { + $this->advance(); + $limit = $this->parseExpression(); + } + + if ($this->matchKeyword('OFFSET')) { + $this->advance(); + $offset = $this->parseExpression(); + } + + if ($this->matchKeyword('FETCH')) { + $this->advance(); + if (!$this->matchKeyword('FIRST', 'NEXT')) { + throw new Exception('Expected FIRST or NEXT after FETCH at position ' . $this->current()->position); + } + $this->advance(); + $limit = $this->parseExpression(); + if (!$this->matchKeyword('ROW', 'ROWS')) { + throw new Exception('Expected ROW or ROWS at position ' . $this->current()->position); + } + $this->advance(); + $this->expectIdentifierValue('ONLY'); + } + + return new Select( + columns: $columns, + from: $from, + joins: $joins, + where: $where, + groupBy: $groupBy, + having: $having, + orderBy: $orderBy, + limit: $limit, + offset: $offset, + distinct: $distinct, + ctes: $ctes, + windows: $windows, + ); + } + + /** + * @return Cte[] + */ + private function parseCteList(bool $recursive): array + { + $ctes = []; + do { + $ctes[] = $this->parseCteDefinition($recursive); + } while ($this->matchAndConsume(TokenType::Comma)); + + return $ctes; + } + + private function parseCteDefinition(bool $recursive): Cte + { + $name = $this->expectIdentifier(); + $columns = []; + + if ($this->current()->type === TokenType::LeftParen) { + if ($this->peekIsColumnList()) { + $this->expect(TokenType::LeftParen); + $columns[] = $this->expectIdentifier(); + while ($this->matchAndConsume(TokenType::Comma)) { + $columns[] = $this->expectIdentifier(); + } + $this->expect(TokenType::RightParen); + } + } + + $this->consumeKeyword('AS'); + $this->expect(TokenType::LeftParen); + $query = $this->parseSelect(); + $this->expect(TokenType::RightParen); + + return new Cte($name, $query, $columns, $recursive); + } + + private function peekIsColumnList(): bool + { + $depth = 0; + + for ($i = $this->pos; $i < $this->tokenCount; $i++) { + $t = $this->tokens[$i]; + if ($t->type === TokenType::LeftParen) { + $depth++; + } elseif ($t->type === TokenType::RightParen) { + $depth--; + if ($depth === 0) { + $next = $i + 1 < $this->tokenCount ? $this->tokens[$i + 1] : null; + return $next !== null + && $next->type === TokenType::Keyword + && strtoupper($next->value) === 'AS'; + } + } + } + + return false; + } + + /** + * @return Expression[] + */ + private function parseColumnList(): array + { + $this->inColumnList = true; + $columns = []; + $columns[] = $this->parseSelectColumn(); + + while ($this->matchAndConsume(TokenType::Comma)) { + $columns[] = $this->parseSelectColumn(); + } + + $this->inColumnList = false; + return $columns; + } + + private function parseSelectColumn(): Expression + { + $expression = $this->parseExpression(); + + if ($this->matchKeyword('AS')) { + $this->advance(); + $alias = $this->expectIdentifier(); + return new Aliased($expression, $alias); + } + + if ($this->inColumnList && $this->isImplicitAlias()) { + $alias = $this->expectIdentifier(); + return new Aliased($expression, $alias); + } + + return $expression; + } + + private function isImplicitAlias(): bool + { + $token = $this->current(); + if ($token->type === TokenType::Identifier) { + return true; + } + if ($token->type === TokenType::QuotedIdentifier) { + return true; + } + return false; + } + + private function parseExpression(): Expression + { + if ($this->depth >= self::MAX_DEPTH) { + throw new ValidationException('Expression nesting too deep'); + } + + $this->depth++; + + try { + return $this->parseOr(); + } finally { + $this->depth--; + } + } + + private function parseOr(): Expression + { + $left = $this->parseAnd(); + + while ($this->matchKeyword('OR')) { + $this->advance(); + $right = $this->parseAnd(); + $left = new Binary($left, 'OR', $right); + } + + return $left; + } + + private function parseAnd(): Expression + { + $left = $this->parseNot(); + + while ($this->matchKeyword('AND')) { + $this->advance(); + $right = $this->parseNot(); + $left = new Binary($left, 'AND', $right); + } + + return $left; + } + + private function parseNot(): Expression + { + if ($this->matchKeyword('NOT')) { + if ($this->peekKeyword(1, 'EXISTS')) { + $this->advance(); // consume NOT + $this->advance(); // consume EXISTS + $this->expect(TokenType::LeftParen); + $subquery = $this->parseSelect(); + $this->expect(TokenType::RightParen); + return new Exists($subquery, true); + } + + $this->advance(); + $operand = $this->parseNot(); + return new Unary('NOT', $operand); + } + + return $this->parseComparison(); + } + + private function parseComparison(): Expression + { + $left = $this->parseAddition(); + + $left = $this->parsePostfixModifiers($left); + + return $left; + } + + private function parsePostfixModifiers(Expression $left): Expression + { + // IS [NOT] NULL + if ($this->matchKeyword('IS')) { + $this->advance(); + if ($this->matchKeyword('NOT')) { + $this->advance(); + $this->expectNull(); + return new Unary('IS NOT NULL', $left, false); + } + $this->expectNull(); + return new Unary('IS NULL', $left, false); + } + + // NOT IN / NOT BETWEEN / NOT LIKE / NOT ILIKE + if ($this->matchKeyword('NOT')) { + if ($this->peekKeyword(1, 'IN')) { + $this->advance(); // NOT + $this->advance(); // IN + return $this->parseInList($left, true); + } + if ($this->peekKeyword(1, 'BETWEEN')) { + $this->advance(); // NOT + $this->advance(); // BETWEEN + return $this->parseBetween($left, true); + } + if ($this->peekKeyword(1, 'LIKE')) { + $this->advance(); // NOT + $this->advance(); // LIKE + $right = $this->parseAddition(); + return new Binary($left, 'NOT LIKE', $right); + } + if ($this->peekKeyword(1, 'ILIKE')) { + $this->advance(); // NOT + $this->advance(); // ILIKE + $right = $this->parseAddition(); + return new Binary($left, 'NOT ILIKE', $right); + } + } + + // IN + if ($this->matchKeyword('IN')) { + $this->advance(); + return $this->parseInList($left, false); + } + + // BETWEEN + if ($this->matchKeyword('BETWEEN')) { + $this->advance(); + return $this->parseBetween($left, false); + } + + // LIKE / ILIKE + if ($this->matchKeyword('LIKE')) { + $this->advance(); + $right = $this->parseAddition(); + return new Binary($left, 'LIKE', $right); + } + if ($this->matchKeyword('ILIKE')) { + $this->advance(); + $right = $this->parseAddition(); + return new Binary($left, 'ILIKE', $right); + } + + // Comparison operators: =, !=, <>, <, >, <=, >= + if ($this->current()->type === TokenType::Operator) { + $op = $this->current()->value; + if (in_array($op, ['=', '!=', '<>', '<', '>', '<=', '>='], true)) { + $this->advance(); + $right = $this->parseAddition(); + $result = new Binary($left, $op, $right); + return $this->parsePostfixModifiers($result); + } + } + + return $left; + } + + private function parseInList(Expression $left, bool $negated): In + { + $this->expect(TokenType::LeftParen); + + if ($this->matchKeyword('SELECT') || $this->matchKeyword('WITH')) { + $subquery = $this->parseSelect(); + $this->expect(TokenType::RightParen); + return new In($left, $subquery, $negated); + } + + $list = []; + $list[] = $this->parseExpression(); + while ($this->matchAndConsume(TokenType::Comma)) { + $list[] = $this->parseExpression(); + } + $this->expect(TokenType::RightParen); + + return new In($left, $list, $negated); + } + + private function parseBetween(Expression $left, bool $negated): Between + { + $low = $this->parseAddition(); + $this->consumeKeyword('AND'); + $high = $this->parseAddition(); + + return new Between($left, $low, $high, $negated); + } + + private function parseAddition(): Expression + { + $left = $this->parseMultiplication(); + + while (true) { + $token = $this->current(); + if ($token->type === TokenType::Operator && in_array($token->value, ['+', '-', '||'], true)) { + $op = $token->value; + $this->advance(); + $right = $this->parseMultiplication(); + $left = new Binary($left, $op, $right); + } else { + break; + } + } + + return $left; + } + + private function parseMultiplication(): Expression + { + $left = $this->parseUnary(); + + while (true) { + $token = $this->current(); + if ($token->type === TokenType::Star) { + $this->advance(); + $right = $this->parseUnary(); + $left = new Binary($left, '*', $right); + } elseif ($token->type === TokenType::Operator && in_array($token->value, ['/', '%'], true)) { + $op = $token->value; + $this->advance(); + $right = $this->parseUnary(); + $left = new Binary($left, $op, $right); + } else { + break; + } + } + + return $left; + } + + private function parseUnary(): Expression + { + $token = $this->current(); + + if ($token->type === TokenType::Operator && ($token->value === '-' || $token->value === '+')) { + $op = $token->value; + $this->advance(); + $operand = $this->parseUnary(); + return new Unary($op, $operand); + } + + $expression = $this->parsePrimary(); + + // Handle PostgreSQL-style :: cast at this level so it works everywhere + while ($this->current()->type === TokenType::Operator && $this->current()->value === '::') { + $this->advance(); + $type = $this->expectIdentifier(); + $expression = new Cast($expression, $type); + } + + return $expression; + } + + private function parsePrimary(): Expression + { + $token = $this->current(); + + if ($token->type === TokenType::Integer) { + $this->advance(); + return new Literal((int)$token->value); + } + + if ($token->type === TokenType::Float) { + $this->advance(); + return new Literal((float)$token->value); + } + + if ($token->type === TokenType::String) { + $this->advance(); + $raw = $token->value; + $inner = substr($raw, 1, -1); + $inner = str_replace("''", "'", $inner); + $inner = str_replace("\\'", "'", $inner); + return new Literal($inner); + } + + if ($token->type === TokenType::Boolean) { + $this->advance(); + return new Literal(strtoupper($token->value) === 'TRUE'); + } + + if ($token->type === TokenType::Null) { + $this->advance(); + return new Literal(null); + } + + if ($token->type === TokenType::Placeholder) { + $this->advance(); + return new Placeholder($token->value); + } + if ($token->type === TokenType::NamedPlaceholder) { + $this->advance(); + return new Placeholder($token->value); + } + if ($token->type === TokenType::NumberedPlaceholder) { + $this->advance(); + return new Placeholder($token->value); + } + + if ($token->type === TokenType::Star) { + $this->advance(); + return new Star(); + } + + if ($token->type === TokenType::LeftParen) { + $this->advance(); + if ($this->matchKeyword('SELECT') || $this->matchKeyword('WITH')) { + $subquery = $this->parseSelect(); + $this->expect(TokenType::RightParen); + return new Subquery($subquery); + } + $expression = $this->parseExpression(); + $this->expect(TokenType::RightParen); + return $expression; + } + + if ($this->matchKeyword('CASE')) { + return $this->parseCaseExpression(); + } + + if ($this->matchKeyword('CAST')) { + return $this->parseCastExpression(); + } + + if ($this->matchKeyword('EXISTS')) { + $this->advance(); + $this->expect(TokenType::LeftParen); + $subquery = $this->parseSelect(); + $this->expect(TokenType::RightParen); + return new Exists($subquery); + } + + if ($token->type === TokenType::Identifier || $token->type === TokenType::QuotedIdentifier) { + return $this->parseIdentifierExpression(); + } + + if ($token->type === TokenType::Keyword) { + if ($this->peek(1)->type === TokenType::LeftParen) { + return $this->parseIdentifierExpression(); + } + } + + throw new Exception( + "Unexpected token '{$token->value}' ({$token->type->name}) at position {$token->position}" + ); + } + + private function parseIdentifierExpression(): Expression + { + $token = $this->current(); + $name = $this->extractIdentifier($token); + $this->advance(); + + $next = $this->current(); + + if ($next->type === TokenType::LeftParen) { + return $this->parseFunctionCallExpression($name); + } + + if ($next->type === TokenType::Dot) { + $this->advance(); + $afterDot = $this->current(); + + if ($afterDot->type === TokenType::Star) { + $this->advance(); + return new Star($name); + } + + $second = $this->extractIdentifier($afterDot); + $this->advance(); + $afterSecond = $this->current(); + + if ($afterSecond->type === TokenType::Dot) { + $this->advance(); + $afterSecondDot = $this->current(); + + if ($afterSecondDot->type === TokenType::Star) { + $this->advance(); + return new Star($second, $name); + } + + $third = $this->extractIdentifier($afterSecondDot); + $this->advance(); + return new Column($third, $second, $name); + } + + return new Column($second, $name); + } + + return new Column($name); + } + + private function parseFunctionCallExpression(string $name): Expression + { + $upperName = strtoupper($name); + $this->expect(TokenType::LeftParen); + + if ($this->current()->type === TokenType::Star) { + $this->advance(); + $this->expect(TokenType::RightParen); + $function = new Func($upperName, [new Star()]); + return $this->parseFunctionPostfix($function); + } + + if ($this->current()->type === TokenType::RightParen) { + $this->advance(); + $function = new Func($upperName); + return $this->parseFunctionPostfix($function); + } + + $distinct = false; + if ($this->matchKeyword('DISTINCT')) { + $distinct = true; + $this->advance(); + } + + $args = []; + $args[] = $this->parseExpression(); + while ($this->matchAndConsume(TokenType::Comma)) { + $args[] = $this->parseExpression(); + } + + $this->expect(TokenType::RightParen); + $function = new Func($upperName, $args, $distinct); + return $this->parseFunctionPostfix($function); + } + + private function parseFunctionPostfix(Func $function): Expression + { + if ($this->matchKeyword('FILTER')) { + $this->advance(); + $this->expect(TokenType::LeftParen); + $this->consumeKeyword('WHERE'); + $filterExpression = $this->parseExpression(); + $this->expect(TokenType::RightParen); + $function = new Func($function->name, $function->arguments, $function->distinct, $filterExpression); + } + + if ($this->matchKeyword('OVER')) { + $this->advance(); + + if ($this->current()->type === TokenType::Identifier) { + $windowName = $this->extractIdentifier($this->current()); + $this->advance(); + return new Window($function, windowName: $windowName); + } + + $this->expect(TokenType::LeftParen); + $specification = $this->parseWindowSpecification(); + $this->expect(TokenType::RightParen); + return new Window($function, specification: $specification); + } + + return $function; + } + + private function parseCaseExpression(): Conditional + { + $this->consumeKeyword('CASE'); + + $operand = null; + if (!$this->matchKeyword('WHEN')) { + $operand = $this->parseExpression(); + } + + $whens = []; + while ($this->matchKeyword('WHEN')) { + $this->advance(); + $condition = $this->parseExpression(); + $this->consumeKeyword('THEN'); + $result = $this->parseExpression(); + $whens[] = new CaseWhen($condition, $result); + } + + $else = null; + if ($this->matchKeyword('ELSE')) { + $this->advance(); + $else = $this->parseExpression(); + } + + $this->consumeKeyword('END'); + + return new Conditional($operand, $whens, $else); + } + + private function parseCastExpression(): Cast + { + $this->consumeKeyword('CAST'); + $this->expect(TokenType::LeftParen); + $expression = $this->parseExpression(); + $this->consumeKeyword('AS'); + $type = $this->expectIdentifier(); + $this->expect(TokenType::RightParen); + + return new Cast($expression, $type); + } + + /** + * @return Table|SubquerySource + */ + private function parseTableSource(): Table|SubquerySource + { + if ($this->current()->type === TokenType::LeftParen) { + $this->advance(); + $subquery = $this->parseSelect(); + $this->expect(TokenType::RightParen); + + if ($this->matchKeyword('AS')) { + $this->advance(); + } + $alias = $this->expectIdentifier(); + + return new SubquerySource($subquery, $alias); + } + + return $this->parseTableReference(); + } + + private function parseTableReference(): Table + { + $name = $this->expectIdentifier(); + $schema = null; + $alias = null; + + if ($this->current()->type === TokenType::Dot) { + $this->advance(); + $schema = $name; + $name = $this->expectIdentifier(); + } + + if ($this->matchKeyword('AS')) { + $this->advance(); + $alias = $this->expectIdentifier(); + } elseif ($this->isTableAlias()) { + $alias = $this->expectIdentifier(); + } + + return new Table($name, $alias, $schema); + } + + private function isTableAlias(): bool + { + $token = $this->current(); + if ($token->type === TokenType::Identifier || $token->type === TokenType::QuotedIdentifier) { + return true; + } + return false; + } + + /** + * @return JoinClause[] + */ + private function parseJoins(): array + { + $joins = []; + + while (true) { + $joinType = $this->tryParseJoinType(); + if ($joinType === null) { + break; + } + + $table = $this->parseTableSource(); + + $condition = null; + if ($joinType !== 'CROSS JOIN' && $joinType !== 'NATURAL JOIN') { + if ($this->matchKeyword('ON')) { + $this->advance(); + $condition = $this->parseExpression(); + } + } + + $joins[] = new JoinClause($joinType, $table, $condition); + } + + return $joins; + } + + private function tryParseJoinType(): ?string + { + if ($this->matchKeyword('JOIN')) { + $this->advance(); + return 'JOIN'; + } + + if ($this->matchKeyword('INNER')) { + $this->advance(); + $this->consumeKeyword('JOIN'); + return 'INNER JOIN'; + } + + if ($this->matchKeyword('LEFT')) { + $this->advance(); + if ($this->matchKeyword('OUTER')) { + $this->advance(); + } + $this->consumeKeyword('JOIN'); + return 'LEFT JOIN'; + } + + if ($this->matchKeyword('RIGHT')) { + $this->advance(); + if ($this->matchKeyword('OUTER')) { + $this->advance(); + } + $this->consumeKeyword('JOIN'); + return 'RIGHT JOIN'; + } + + if ($this->matchKeyword('FULL')) { + $this->advance(); + if ($this->matchKeyword('OUTER')) { + $this->advance(); + } + $this->consumeKeyword('JOIN'); + return 'FULL OUTER JOIN'; + } + + if ($this->matchKeyword('CROSS')) { + $this->advance(); + $this->consumeKeyword('JOIN'); + return 'CROSS JOIN'; + } + + if ($this->matchKeyword('NATURAL')) { + $this->advance(); + $this->consumeKeyword('JOIN'); + return 'NATURAL JOIN'; + } + + return null; + } + + /** + * @return Expression[] + */ + private function parseExpressionList(): array + { + $expressions = []; + $expressions[] = $this->parseExpression(); + + while ($this->matchAndConsume(TokenType::Comma)) { + $expressions[] = $this->parseExpression(); + } + + return $expressions; + } + + /** + * @return OrderByItem[] + */ + private function parseOrderByList(): array + { + $items = []; + $items[] = $this->parseOrderByItem(); + + while ($this->matchAndConsume(TokenType::Comma)) { + $items[] = $this->parseOrderByItem(); + } + + return $items; + } + + private function parseOrderByItem(): OrderByItem + { + $expression = $this->parseExpression(); + + $direction = OrderDirection::Asc; + if ($this->matchKeyword('ASC')) { + $this->advance(); + $direction = OrderDirection::Asc; + } elseif ($this->matchKeyword('DESC')) { + $this->advance(); + $direction = OrderDirection::Desc; + } + + $nulls = null; + if ($this->matchKeyword('NULLS')) { + $this->advance(); + if ($this->matchKeyword('FIRST')) { + $this->advance(); + $nulls = NullsPosition::First; + } elseif ($this->matchKeyword('LAST')) { + $this->advance(); + $nulls = NullsPosition::Last; + } else { + throw new Exception( + "Expected FIRST or LAST after NULLS at position {$this->current()->position}, got '{$this->current()->value}'" + ); + } + } + + return new OrderByItem($expression, $direction, $nulls); + } + + /** + * @return WindowDefinition[] + */ + private function parseWindowDefinitions(): array + { + $defs = []; + + do { + $name = $this->expectIdentifier(); + $this->consumeKeyword('AS'); + $this->expect(TokenType::LeftParen); + $specification = $this->parseWindowSpecification(); + $this->expect(TokenType::RightParen); + $defs[] = new WindowDefinition($name, $specification); + } while ($this->matchAndConsume(TokenType::Comma)); + + return $defs; + } + + private function parseWindowSpecification(): WindowSpecification + { + $partitionBy = []; + $orderBy = []; + $frameType = null; + $frameStart = null; + $frameEnd = null; + + if ($this->matchKeyword('PARTITION')) { + $this->advance(); + $this->consumeKeyword('BY'); + $partitionBy = $this->parseExpressionList(); + } + + if ($this->matchKeyword('ORDER')) { + $this->advance(); + $this->consumeKeyword('BY'); + $orderBy = $this->parseOrderByList(); + } + + if ($this->matchKeyword('ROWS') || $this->matchKeyword('RANGE')) { + $frameType = strtoupper($this->current()->value); + $this->advance(); + + if ($this->matchKeyword('BETWEEN')) { + $this->advance(); + $frameStart = $this->parseFrameBound(); + $this->consumeKeyword('AND'); + $frameEnd = $this->parseFrameBound(); + } else { + $frameStart = $this->parseFrameBound(); + } + } + + return new WindowSpecification($partitionBy, $orderBy, $frameType, $frameStart, $frameEnd); + } + + private function parseFrameBound(): string + { + if ($this->matchKeyword('UNBOUNDED')) { + $this->advance(); + if ($this->matchKeyword('PRECEDING')) { + $this->advance(); + return 'UNBOUNDED PRECEDING'; + } + if ($this->matchKeyword('FOLLOWING')) { + $this->advance(); + return 'UNBOUNDED FOLLOWING'; + } + throw new Exception( + "Expected PRECEDING or FOLLOWING after UNBOUNDED at position {$this->current()->position}" + ); + } + + if ($this->matchKeyword('CURRENT')) { + $this->advance(); + $this->consumeKeyword('ROW'); + return 'CURRENT ROW'; + } + + if ($this->current()->type === TokenType::Integer) { + $n = $this->current()->value; + $this->advance(); + if ($this->matchKeyword('PRECEDING')) { + $this->advance(); + return $n . ' PRECEDING'; + } + if ($this->matchKeyword('FOLLOWING')) { + $this->advance(); + return $n . ' FOLLOWING'; + } + throw new Exception( + "Expected PRECEDING or FOLLOWING after number at position {$this->current()->position}" + ); + } + + throw new Exception( + "Unexpected frame bound token '{$this->current()->value}' at position {$this->current()->position}" + ); + } + + private function current(): Token + { + return $this->tokens[$this->pos]; + } + + private function peek(int $offset = 1): Token + { + $idx = $this->pos + $offset; + if ($idx < $this->tokenCount) { + return $this->tokens[$idx]; + } + return $this->tokens[$this->tokenCount - 1]; + } + + private function advance(): Token + { + $token = $this->tokens[$this->pos]; + if ($this->pos < $this->tokenCount - 1) { + $this->pos++; + } + return $token; + } + + private function expect(TokenType $type, ?string $value = null): Token + { + $token = $this->current(); + if ($token->type !== $type) { + $expected = $value !== null ? "'{$value}'" : $type->name; + throw new Exception( + "Expected {$expected} at position {$token->position}, got '{$token->value}'" + ); + } + if ($value !== null && strtoupper($token->value) !== strtoupper($value)) { + throw new Exception( + "Expected '{$value}' at position {$token->position}, got '{$token->value}'" + ); + } + $this->advance(); + return $token; + } + + private function matchKeyword(string ...$keywords): bool + { + $token = $this->current(); + if ($token->type !== TokenType::Keyword) { + return false; + } + $upper = strtoupper($token->value); + foreach ($keywords as $keyword) { + if ($upper === strtoupper($keyword)) { + return true; + } + } + return false; + } + + private function peekKeyword(int $offset, string $keyword): bool + { + $token = $this->peek($offset); + return $token->type === TokenType::Keyword && strtoupper($token->value) === strtoupper($keyword); + } + + private function consumeKeyword(string $keyword): Token + { + $token = $this->current(); + if ($token->type !== TokenType::Keyword || strtoupper($token->value) !== strtoupper($keyword)) { + throw new Exception( + "Expected keyword '{$keyword}' at position {$token->position}, got '{$token->value}'" + ); + } + $this->advance(); + return $token; + } + + private function expectNull(): Token + { + $token = $this->current(); + if ($token->type !== TokenType::Null) { + throw new Exception( + "Expected NULL at position {$token->position}, got '{$token->value}'" + ); + } + $this->advance(); + return $token; + } + + private function expectIdentifierValue(string $value): Token + { + $token = $this->current(); + $upper = strtoupper($value); + if ( + ($token->type === TokenType::Identifier || $token->type === TokenType::Keyword) + && strtoupper($token->value) === $upper + ) { + $this->advance(); + return $token; + } + throw new Exception( + "Expected '{$value}' at position {$token->position}, got '{$token->value}'" + ); + } + + private function matchAndConsume(TokenType $type): bool + { + if ($this->current()->type === $type) { + $this->advance(); + return true; + } + return false; + } + + private function expectIdentifier(): string + { + $token = $this->current(); + if ($token->type === TokenType::Identifier) { + $this->advance(); + return $token->value; + } + if ($token->type === TokenType::QuotedIdentifier) { + $this->advance(); + return $this->unquoteIdentifier($token->value); + } + if ($token->type === TokenType::Keyword) { + $this->advance(); + return $token->value; + } + throw new Exception( + "Expected identifier at position {$token->position}, got '{$token->value}' ({$token->type->name})" + ); + } + + private function extractIdentifier(Token $token): string + { + if ($token->type === TokenType::Identifier) { + return $token->value; + } + if ($token->type === TokenType::QuotedIdentifier) { + return $this->unquoteIdentifier($token->value); + } + if ($token->type === TokenType::Keyword) { + return $token->value; + } + throw new Exception( + "Expected identifier at position {$token->position}, got '{$token->value}' ({$token->type->name})" + ); + } + + /** + * Strip the quote delimiters from a quoted identifier token value and + * un-double any doubled delimiters inside (SQL convention for escaping + * the delimiter character within a quoted identifier). + */ + private function unquoteIdentifier(string $raw): string + { + $open = $raw[0]; + $inner = substr($raw, 1, -1); + + return match ($open) { + '`' => str_replace('``', '`', $inner), + '"' => str_replace('""', '"', $inner), + '[' => str_replace(']]', ']', $inner), + default => $inner, + }; + } +} diff --git a/src/Query/AST/Placeholder.php b/src/Query/AST/Placeholder.php new file mode 100644 index 0000000..9aa639e --- /dev/null +++ b/src/Query/AST/Placeholder.php @@ -0,0 +1,11 @@ +ctes)) { + $parts[] = $this->serializeCtes($stmt->ctes); + } + + $select = 'SELECT'; + if ($stmt->distinct) { + $select .= ' DISTINCT'; + } + + $columns = []; + foreach ($stmt->columns as $col) { + $columns[] = $this->serializeExpression($col); + } + $select .= ' ' . implode(', ', $columns); + $parts[] = $select; + + if ($stmt->from !== null) { + $parts[] = 'FROM ' . $this->serializeTableSource($stmt->from); + } + + foreach ($stmt->joins as $join) { + $parts[] = $this->serializeJoin($join); + } + + if ($stmt->where !== null) { + $parts[] = 'WHERE ' . $this->serializeExpression($stmt->where); + } + + if (!empty($stmt->groupBy)) { + $expressions = []; + foreach ($stmt->groupBy as $expression) { + $expressions[] = $this->serializeExpression($expression); + } + $parts[] = 'GROUP BY ' . implode(', ', $expressions); + } + + if ($stmt->having !== null) { + $parts[] = 'HAVING ' . $this->serializeExpression($stmt->having); + } + + if (!empty($stmt->windows)) { + $defs = []; + foreach ($stmt->windows as $win) { + $defs[] = $this->quoteIdentifier($win->name) . ' AS (' . $this->serializeWindowSpecification($win->specification) . ')'; + } + $parts[] = 'WINDOW ' . implode(', ', $defs); + } + + if (!empty($stmt->orderBy)) { + $items = []; + foreach ($stmt->orderBy as $item) { + $items[] = $this->serializeOrderByItem($item); + } + $parts[] = 'ORDER BY ' . implode(', ', $items); + } + + if ($stmt->limit !== null) { + $parts[] = 'LIMIT ' . $this->serializeExpression($stmt->limit); + } + + if ($stmt->offset !== null) { + $parts[] = 'OFFSET ' . $this->serializeExpression($stmt->offset); + } + + return implode(' ', $parts); + } + + public function serializeExpression(Expression $expression): string + { + return match (true) { + $expression instanceof Aliased => $this->serializeExpression($expression->expression) . ' AS ' . $this->quoteIdentifier($expression->alias), + $expression instanceof Window => $this->serializeWindowExpression($expression), + $expression instanceof Binary => $this->serializeBinary($expression, null), + $expression instanceof Unary => $this->serializeUnary($expression), + $expression instanceof Column => $this->serializeColumnReference($expression), + $expression instanceof Literal => $this->serializeLiteral($expression), + $expression instanceof Star => $this->serializeStar($expression), + $expression instanceof Placeholder => $expression->value, + $expression instanceof Raw => $expression->sql, + $expression instanceof Func => $this->serializeFunctionCall($expression), + $expression instanceof In => $this->serializeIn($expression), + $expression instanceof Between => $this->serializeBetween($expression), + $expression instanceof Exists => $this->serializeExists($expression), + $expression instanceof Conditional => $this->serializeConditional($expression), + $expression instanceof Cast => $this->serializeCast($expression), + $expression instanceof Subquery => '(' . $this->serialize($expression->query) . ')', + default => throw new Exception('Unsupported expression type: ' . get_class($expression)), + }; + } + + protected function quoteIdentifier(string $name): string + { + return '`' . str_replace('`', '``', $name) . '`'; + } + + private function operatorPrecedence(string $op): int + { + return match (strtoupper($op)) { + 'OR' => 1, + 'AND' => 2, + '=', '!=', '<>', '<', '>', '<=', '>=' => 3, + 'LIKE', 'ILIKE', 'NOT LIKE', 'NOT ILIKE' => 3, + '+', '-', '||' => 4, + '*', '/', '%' => 5, + default => 0, + }; + } + + private function serializeBinary(Binary $expression, ?int $parentPrecedence): string + { + $prec = $this->operatorPrecedence($expression->operator); + + // Non-commutative left-associative operators require a stricter precedence + // on the right child so equal-precedence right subtrees keep their grouping. + $rightPrec = in_array(strtoupper($expression->operator), ['-', '/', '%'], true) + ? $prec + 1 + : $prec; + + $left = $this->serializeBinaryChild($expression->left, $prec); + $right = $this->serializeBinaryChild($expression->right, $rightPrec); + + $sql = $left . ' ' . $expression->operator . ' ' . $right; + + if ($parentPrecedence !== null && $prec < $parentPrecedence) { + return '(' . $sql . ')'; + } + + return $sql; + } + + private function serializeBinaryChild(Expression $child, int $parentPrecedence): string + { + if ($child instanceof Binary) { + return $this->serializeBinary($child, $parentPrecedence); + } + + return $this->serializeExpression($child); + } + + private function serializeUnary(Unary $expression): string + { + if ($expression->prefix) { + $operand = $this->serializeExpression($expression->operand); + // Always separate prefix operator from operand with parens so a + // negative numeric literal or nested unary cannot produce '--', + // which MySQL parses as the start of a line comment. + return $expression->operator . ' (' . $operand . ')'; + } + + $operand = $this->serializeExpression($expression->operand); + return $operand . ' ' . $expression->operator; + } + + private function serializeColumnReference(Column $expression): string + { + $parts = []; + if ($expression->schema !== null) { + $parts[] = $this->quoteIdentifier($expression->schema); + } + if ($expression->table !== null) { + $parts[] = $this->quoteIdentifier($expression->table); + } + $parts[] = $this->quoteIdentifier($expression->name); + return implode('.', $parts); + } + + private function serializeLiteral(Literal $expression): string + { + if ($expression->value === null) { + return 'NULL'; + } + if (is_bool($expression->value)) { + return $expression->value ? 'TRUE' : 'FALSE'; + } + if (is_int($expression->value)) { + return (string) $expression->value; + } + if (is_float($expression->value)) { + return (string) $expression->value; + } + $escaped = str_replace(['\\', "'"], ['\\\\', "''"], $expression->value); + return "'" . $escaped . "'"; + } + + private function serializeStar(Star $expression): string + { + if ($expression->schema !== null && $expression->table !== null) { + return $this->quoteIdentifier($expression->schema) . '.' . $this->quoteIdentifier($expression->table) . '.*'; + } + if ($expression->table !== null) { + return $this->quoteIdentifier($expression->table) . '.*'; + } + return '*'; + } + + private function serializeFunctionCall(Func $expression): string + { + if (count($expression->arguments) === 1 && $expression->arguments[0] instanceof Star) { + return $expression->name . '(*)'; + } + + if (empty($expression->arguments)) { + return $expression->name . '()'; + } + + $args = []; + foreach ($expression->arguments as $arg) { + $args[] = $this->serializeExpression($arg); + } + + $prefix = $expression->distinct ? 'DISTINCT ' : ''; + $sql = $expression->name . '(' . $prefix . implode(', ', $args) . ')'; + + if ($expression->filter !== null) { + $sql .= ' FILTER (WHERE ' . $this->serializeExpression($expression->filter) . ')'; + } + + return $sql; + } + + private function serializeIn(In $expression): string + { + $left = $this->serializeExpression($expression->expression); + $keyword = $expression->negated ? 'NOT IN' : 'IN'; + + if ($expression->list instanceof Select) { + return $left . ' ' . $keyword . ' (' . $this->serialize($expression->list) . ')'; + } + + $items = []; + foreach ($expression->list as $item) { + $items[] = $this->serializeExpression($item); + } + return $left . ' ' . $keyword . ' (' . implode(', ', $items) . ')'; + } + + private function serializeBetween(Between $expression): string + { + $left = $this->serializeExpression($expression->expression); + $keyword = $expression->negated ? 'NOT BETWEEN' : 'BETWEEN'; + $low = $this->serializeExpression($expression->low); + $high = $this->serializeExpression($expression->high); + return $left . ' ' . $keyword . ' ' . $low . ' AND ' . $high; + } + + private function serializeExists(Exists $expression): string + { + $keyword = $expression->negated ? 'NOT EXISTS' : 'EXISTS'; + return $keyword . ' (' . $this->serialize($expression->subquery) . ')'; + } + + private function serializeConditional(Conditional $expression): string + { + $sql = 'CASE'; + if ($expression->operand !== null) { + $sql .= ' ' . $this->serializeExpression($expression->operand); + } + + foreach ($expression->whens as $when) { + $sql .= ' WHEN ' . $this->serializeExpression($when->condition); + $sql .= ' THEN ' . $this->serializeExpression($when->result); + } + + if ($expression->else !== null) { + $sql .= ' ELSE ' . $this->serializeExpression($expression->else); + } + + $sql .= ' END'; + return $sql; + } + + private function serializeCast(Cast $expression): string + { + return 'CAST(' . $this->serializeExpression($expression->expression) . ' AS ' . $expression->type . ')'; + } + + private function serializeWindowExpression(Window $expression): string + { + $function = $this->serializeExpression($expression->function); + + if ($expression->windowName !== null) { + return $function . ' OVER ' . $this->quoteIdentifier($expression->windowName); + } + + if ($expression->specification !== null) { + return $function . ' OVER (' . $this->serializeWindowSpecification($expression->specification) . ')'; + } + + return $function . ' OVER ()'; + } + + private function serializeWindowSpecification(WindowSpecification $specification): string + { + $parts = []; + + if (!empty($specification->partitionBy)) { + $expressions = []; + foreach ($specification->partitionBy as $expression) { + $expressions[] = $this->serializeExpression($expression); + } + $parts[] = 'PARTITION BY ' . implode(', ', $expressions); + } + + if (!empty($specification->orderBy)) { + $items = []; + foreach ($specification->orderBy as $item) { + $items[] = $this->serializeOrderByItem($item); + } + $parts[] = 'ORDER BY ' . implode(', ', $items); + } + + if ($specification->frameType !== null) { + $frame = $specification->frameType; + if ($specification->frameEnd !== null) { + $frame .= ' BETWEEN ' . $specification->frameStart . ' AND ' . $specification->frameEnd; + } else { + $frame .= ' ' . $specification->frameStart; + } + $parts[] = $frame; + } + + return implode(' ', $parts); + } + + private function serializeOrderByItem(OrderByItem $item): string + { + $sql = $this->serializeExpression($item->expression) . ' ' . $item->direction->value; + if ($item->nulls !== null) { + $sql .= ' NULLS ' . $item->nulls->value; + } + return $sql; + } + + private function serializeTableSource(Table|SubquerySource $source): string + { + if ($source instanceof SubquerySource) { + return '(' . $this->serialize($source->query) . ') AS ' . $this->quoteIdentifier($source->alias); + } + + return $this->serializeTableReference($source); + } + + private function serializeTableReference(Table $reference): string + { + $sql = ''; + if ($reference->schema !== null) { + $sql .= $this->quoteIdentifier($reference->schema) . '.'; + } + $sql .= $this->quoteIdentifier($reference->name); + if ($reference->alias !== null) { + $sql .= ' AS ' . $this->quoteIdentifier($reference->alias); + } + return $sql; + } + + private function serializeJoin(JoinClause $join): string + { + $sql = $join->type . ' ' . $this->serializeTableSource($join->table); + if ($join->condition !== null) { + $sql .= ' ON ' . $this->serializeExpression($join->condition); + } + return $sql; + } + + /** + * @param Cte[] $ctes + */ + private function serializeCtes(array $ctes): string + { + $recursive = false; + foreach ($ctes as $cte) { + if ($cte->recursive) { + $recursive = true; + break; + } + } + + $defs = []; + foreach ($ctes as $cte) { + $def = $this->quoteIdentifier($cte->name); + if (!empty($cte->columns)) { + $cols = []; + foreach ($cte->columns as $col) { + $cols[] = $this->quoteIdentifier($col); + } + $def .= ' (' . implode(', ', $cols) . ')'; + } + $def .= ' AS (' . $this->serialize($cte->query) . ')'; + $defs[] = $def; + } + + $keyword = $recursive ? 'WITH RECURSIVE' : 'WITH'; + return $keyword . ' ' . implode(', ', $defs); + } +} diff --git a/src/Query/AST/Serializer/ClickHouse.php b/src/Query/AST/Serializer/ClickHouse.php new file mode 100644 index 0000000..5847ed4 --- /dev/null +++ b/src/Query/AST/Serializer/ClickHouse.php @@ -0,0 +1,9 @@ +columns, + from: $from === false ? $this->from : $from, + joins: $joins ?? $this->joins, + where: $where === false ? $this->where : $where, + groupBy: $groupBy ?? $this->groupBy, + having: $having === false ? $this->having : $having, + orderBy: $orderBy ?? $this->orderBy, + limit: $limit === false ? $this->limit : $limit, + offset: $offset === false ? $this->offset : $offset, + distinct: $distinct ?? $this->distinct, + ctes: $ctes ?? $this->ctes, + windows: $windows ?? $this->windows, + ); + } +} diff --git a/src/Query/AST/SubquerySource.php b/src/Query/AST/SubquerySource.php new file mode 100644 index 0000000..c31060b --- /dev/null +++ b/src/Query/AST/SubquerySource.php @@ -0,0 +1,14 @@ +name, $this->allowedColumns, true)) { + throw new Exception("Column '{$expression->name}' is not in the allowed list"); + } + } elseif ($expression instanceof Star && !$this->allowStar) { + throw new Exception('Wildcard (*) selection is not allowed; list explicit columns or construct ColumnValidator with allowStar: true'); + } + return $expression; + } + + #[\Override] + public function visitTableReference(Table $reference): Table + { + return $reference; + } + + #[\Override] + public function visitSelect(Select $stmt): Select + { + return $stmt; + } +} diff --git a/src/Query/AST/Visitor/FilterInjector.php b/src/Query/AST/Visitor/FilterInjector.php new file mode 100644 index 0000000..cc5c381 --- /dev/null +++ b/src/Query/AST/Visitor/FilterInjector.php @@ -0,0 +1,49 @@ +visitSelect($stmt); + */ + #[\Override] + public function visitSelect(Select $stmt): Select + { + if ($stmt->where === null) { + return $stmt->with(where: $this->condition); + } + + $combined = new Binary($stmt->where, 'AND', $this->condition); + return $stmt->with(where: $combined); + } +} diff --git a/src/Query/AST/Visitor/TableRenamer.php b/src/Query/AST/Visitor/TableRenamer.php new file mode 100644 index 0000000..1432a0b --- /dev/null +++ b/src/Query/AST/Visitor/TableRenamer.php @@ -0,0 +1,61 @@ + $renames map of old name to new name */ + public function __construct(private readonly array $renames) + { + } + + #[\Override] + public function visitExpression(Expression $expression): Expression + { + if ($expression instanceof Column && $expression->table !== null) { + $newTable = $this->renames[$expression->table] ?? null; + if ($newTable !== null) { + return new Column($expression->name, $newTable, $expression->schema); + } + } + + if ($expression instanceof Star && $expression->table !== null) { + $newTable = $this->renames[$expression->table] ?? null; + if ($newTable !== null) { + return new Star($newTable, $expression->schema); + } + } + + return $expression; + } + + #[\Override] + public function visitTableReference(Table $reference): Table + { + $newName = $this->renames[$reference->name] ?? null; + $newAlias = $reference->alias !== null ? ($this->renames[$reference->alias] ?? null) : null; + + if ($newName !== null || $newAlias !== null) { + return new Table( + $newName ?? $reference->name, + $newAlias ?? $reference->alias, + $reference->schema, + ); + } + + return $reference; + } + + #[\Override] + public function visitSelect(Select $stmt): Select + { + return $stmt; + } +} diff --git a/src/Query/AST/Walker.php b/src/Query/AST/Walker.php new file mode 100644 index 0000000..3c5ac5a --- /dev/null +++ b/src/Query/AST/Walker.php @@ -0,0 +1,432 @@ +walkStatement($stmt, $visitor); + return $visitor->visitSelect($stmt); + } + + private function walkStatement(Select $stmt, Visitor $visitor): Select + { + $columns = $this->walkExpressionArray($stmt->columns, $visitor); + $columnsChanged = $columns !== $stmt->columns; + + $from = $stmt->from; + if ($from instanceof Table) { + $from = $visitor->visitTableReference($from); + } elseif ($from instanceof SubquerySource) { + $from = $this->walkSubquerySource($from, $visitor); + } + $fromChanged = $from !== $stmt->from; + + $joins = []; + $joinsChanged = false; + foreach ($stmt->joins as $i => $join) { + $walkedJoin = $this->walkJoin($join, $visitor); + if ($walkedJoin !== $join) { + $joinsChanged = true; + } + $joins[$i] = $walkedJoin; + } + + $where = $stmt->where !== null ? $this->walkExpression($stmt->where, $visitor) : null; + $whereChanged = $where !== $stmt->where; + + $groupBy = $this->walkExpressionArray($stmt->groupBy, $visitor); + $groupByChanged = $groupBy !== $stmt->groupBy; + + $having = $stmt->having !== null ? $this->walkExpression($stmt->having, $visitor) : null; + $havingChanged = $having !== $stmt->having; + + $orderBy = []; + $orderByChanged = false; + foreach ($stmt->orderBy as $i => $item) { + $walkedItem = $this->walkOrderByItem($item, $visitor); + if ($walkedItem !== $item) { + $orderByChanged = true; + } + $orderBy[$i] = $walkedItem; + } + + $limit = $stmt->limit !== null ? $this->walkExpression($stmt->limit, $visitor) : null; + $limitChanged = $limit !== $stmt->limit; + + $offset = $stmt->offset !== null ? $this->walkExpression($stmt->offset, $visitor) : null; + $offsetChanged = $offset !== $stmt->offset; + + $ctes = []; + $ctesChanged = false; + foreach ($stmt->ctes as $i => $cte) { + $walkedCte = $this->walkCte($cte, $visitor); + if ($walkedCte !== $cte) { + $ctesChanged = true; + } + $ctes[$i] = $walkedCte; + } + + $windows = []; + $windowsChanged = false; + foreach ($stmt->windows as $i => $win) { + $walkedWin = $this->walkWindowDefinition($win, $visitor); + if ($walkedWin !== $win) { + $windowsChanged = true; + } + $windows[$i] = $walkedWin; + } + + // Identity fast-path: if no child was replaced, return the original + // Select to avoid allocating a fresh node for pure-inspection visitors. + if ( + ! $columnsChanged + && ! $fromChanged + && ! $joinsChanged + && ! $whereChanged + && ! $groupByChanged + && ! $havingChanged + && ! $orderByChanged + && ! $limitChanged + && ! $offsetChanged + && ! $ctesChanged + && ! $windowsChanged + ) { + return $stmt; + } + + return new Select( + columns: $columns, + from: $from, + joins: $joins, + where: $where, + groupBy: $groupBy, + having: $having, + orderBy: $orderBy, + limit: $limit, + offset: $offset, + distinct: $stmt->distinct, + ctes: $ctes, + windows: $windows, + ); + } + + private function walkExpression(Expression $expression, Visitor $visitor): Expression + { + $walked = match (true) { + $expression instanceof Binary => $this->walkBinary($expression, $visitor), + $expression instanceof Unary => $this->walkUnary($expression, $visitor), + $expression instanceof Func => $this->walkFunctionCall($expression, $visitor), + $expression instanceof Aliased => $this->walkAliased($expression, $visitor), + $expression instanceof In => $this->walkInExpression($expression, $visitor), + $expression instanceof Between => $this->walkBetween($expression, $visitor), + $expression instanceof Exists => $this->walkExists($expression, $visitor), + $expression instanceof Conditional => $this->walkConditionalExpression($expression, $visitor), + $expression instanceof Cast => $this->walkCast($expression, $visitor), + $expression instanceof Subquery => $this->walkSubquery($expression, $visitor), + $expression instanceof Window => $this->walkWindowExpression($expression, $visitor), + default => $expression, + }; + + return $visitor->visitExpression($walked); + } + + private function walkBinary(Binary $expression, Visitor $visitor): Binary + { + $left = $this->walkExpression($expression->left, $visitor); + $right = $this->walkExpression($expression->right, $visitor); + + if ($left === $expression->left && $right === $expression->right) { + return $expression; + } + + return new Binary($left, $expression->operator, $right); + } + + private function walkUnary(Unary $expression, Visitor $visitor): Unary + { + $operand = $this->walkExpression($expression->operand, $visitor); + + if ($operand === $expression->operand) { + return $expression; + } + + return new Unary($expression->operator, $operand, $expression->prefix); + } + + private function walkAliased(Aliased $expression, Visitor $visitor): Aliased + { + $inner = $this->walkExpression($expression->expression, $visitor); + + if ($inner === $expression->expression) { + return $expression; + } + + return new Aliased($inner, $expression->alias); + } + + private function walkBetween(Between $expression, Visitor $visitor): Between + { + $inner = $this->walkExpression($expression->expression, $visitor); + $low = $this->walkExpression($expression->low, $visitor); + $high = $this->walkExpression($expression->high, $visitor); + + if ( + $inner === $expression->expression + && $low === $expression->low + && $high === $expression->high + ) { + return $expression; + } + + return new Between($inner, $low, $high, $expression->negated); + } + + private function walkExists(Exists $expression, Visitor $visitor): Exists + { + $walked = $this->walk($expression->subquery, $visitor); + + if ($walked === $expression->subquery) { + return $expression; + } + + return new Exists($walked, $expression->negated); + } + + private function walkCast(Cast $expression, Visitor $visitor): Cast + { + $inner = $this->walkExpression($expression->expression, $visitor); + + if ($inner === $expression->expression) { + return $expression; + } + + return new Cast($inner, $expression->type); + } + + private function walkSubquery(Subquery $expression, Visitor $visitor): Subquery + { + $walked = $this->walk($expression->query, $visitor); + + if ($walked === $expression->query) { + return $expression; + } + + return new Subquery($walked); + } + + /** + * @param Expression[] $expressions + * @return Expression[] + */ + private function walkExpressionArray(array $expressions, Visitor $visitor): array + { + $result = []; + $changed = false; + foreach ($expressions as $i => $expression) { + $walked = $this->walkExpression($expression, $visitor); + if ($walked !== $expression) { + $changed = true; + } + $result[$i] = $walked; + } + return $changed ? $result : $expressions; + } + + private function walkFunctionCall(Func $expression, Visitor $visitor): Func + { + $args = $this->walkExpressionArray($expression->arguments, $visitor); + $filter = $expression->filter !== null ? $this->walkExpression($expression->filter, $visitor) : null; + + if ($args === $expression->arguments && $filter === $expression->filter) { + return $expression; + } + + return new Func( + $expression->name, + $args, + $expression->distinct, + $filter, + ); + } + + private function walkInExpression(In $expression, Visitor $visitor): In + { + $walked = $this->walkExpression($expression->expression, $visitor); + + if ($expression->list instanceof Select) { + $list = $this->walk($expression->list, $visitor); + } else { + $list = $this->walkExpressionArray($expression->list, $visitor); + } + + if ($walked === $expression->expression && $list === $expression->list) { + return $expression; + } + + return new In($walked, $list, $expression->negated); + } + + private function walkConditionalExpression(Conditional $expression, Visitor $visitor): Conditional + { + $operand = $expression->operand !== null ? $this->walkExpression($expression->operand, $visitor) : null; + $operandChanged = $operand !== $expression->operand; + + $whens = []; + $whensChanged = false; + foreach ($expression->whens as $i => $when) { + $condition = $this->walkExpression($when->condition, $visitor); + $result = $this->walkExpression($when->result, $visitor); + + if ($condition === $when->condition && $result === $when->result) { + $whens[$i] = $when; + } else { + $whens[$i] = new CaseWhen($condition, $result); + $whensChanged = true; + } + } + + $else = $expression->else !== null ? $this->walkExpression($expression->else, $visitor) : null; + $elseChanged = $else !== $expression->else; + + if (! $operandChanged && ! $whensChanged && ! $elseChanged) { + return $expression; + } + + return new Conditional($operand, $whens, $else); + } + + private function walkWindowExpression(Window $expression, Visitor $visitor): Window + { + $function = $this->walkExpression($expression->function, $visitor); + $specification = $expression->specification !== null ? $this->walkWindowSpecification($expression->specification, $visitor) : null; + + if ($function === $expression->function && $specification === $expression->specification) { + return $expression; + } + + return new Window($function, $expression->windowName, $specification); + } + + private function walkWindowSpecification(WindowSpecification $specification, Visitor $visitor): WindowSpecification + { + $partitionBy = $this->walkExpressionArray($specification->partitionBy, $visitor); + $partitionChanged = $partitionBy !== $specification->partitionBy; + + $orderBy = []; + $orderByChanged = false; + foreach ($specification->orderBy as $i => $item) { + $walkedItem = $this->walkOrderByItem($item, $visitor); + if ($walkedItem !== $item) { + $orderByChanged = true; + } + $orderBy[$i] = $walkedItem; + } + + if (! $partitionChanged && ! $orderByChanged) { + return $specification; + } + + return new WindowSpecification( + $partitionBy, + $orderBy, + $specification->frameType, + $specification->frameStart, + $specification->frameEnd, + ); + } + + private function walkWindowDefinition(WindowDefinition $win, Visitor $visitor): WindowDefinition + { + $specification = $this->walkWindowSpecification($win->specification, $visitor); + + if ($specification === $win->specification) { + return $win; + } + + return new WindowDefinition($win->name, $specification); + } + + private function walkOrderByItem(OrderByItem $item, Visitor $visitor): OrderByItem + { + $expression = $this->walkExpression($item->expression, $visitor); + + if ($expression === $item->expression) { + return $item; + } + + return new OrderByItem( + $expression, + $item->direction, + $item->nulls, + ); + } + + private function walkJoin(JoinClause $join, Visitor $visitor): JoinClause + { + $table = $join->table; + if ($table instanceof Table) { + $table = $visitor->visitTableReference($table); + } elseif ($table instanceof SubquerySource) { + $table = $this->walkSubquerySource($table, $visitor); + } + + $condition = $join->condition !== null ? $this->walkExpression($join->condition, $visitor) : null; + + if ($table === $join->table && $condition === $join->condition) { + return $join; + } + + return new JoinClause($join->type, $table, $condition); + } + + private function walkSubquerySource(SubquerySource $source, Visitor $visitor): SubquerySource + { + $walked = $this->walk($source->query, $visitor); + + if ($walked === $source->query) { + return $source; + } + + return new SubquerySource($walked, $source->alias); + } + + private function walkCte(Cte $cte, Visitor $visitor): Cte + { + $walkedQuery = $this->walk($cte->query, $visitor); + + if ($walkedQuery === $cte->query) { + return $cte; + } + + return new Cte( + $cte->name, + $walkedQuery, + $cte->columns, + $cte->recursive, + ); + } +} diff --git a/src/Query/Builder.php b/src/Query/Builder.php new file mode 100644 index 0000000..b2c5298 --- /dev/null +++ b/src/Query/Builder.php @@ -0,0 +1,2729 @@ + */ + protected const COLUMN_PREDICATE_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=']; + + protected string $table = ''; + + protected string $alias = ''; + + /** + * @var array + */ + protected array $pendingQueries = []; + + /** + * @var list + */ + protected array $bindings = []; + + /** + * @var list + */ + protected array $unions = []; + + /** @var list */ + protected array $filterHooks = []; + + /** @var list */ + protected array $attributeHooks = []; + + /** + * Per-build memo of resolveAttribute() results keyed by raw name. + * + * Populated on first resolution, cleared at the top of build() and on + * reset(). The same attribute is resolved many times per build (SELECT, + * WHERE, GROUP BY, ORDER BY, JOIN ON, UPDATE SET) and each resolution + * iterates every registered attribute hook. + * + * @var array + */ + protected array $resolvedAttributeCache = []; + + /** @var list */ + protected array $joinFilterHooks = []; + + /** @var list> */ + protected array $rows = []; + + /** @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 $windowDefinitions = []; + + /** @var ?array{percent: float, method: string} */ + protected ?array $sample = null; + + /** @var list */ + protected array $cases = []; + + /** @var array */ + protected array $caseSets = []; + + /** @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 $tableless = false; + + /** @var list */ + protected array $rawOrders = []; + + /** @var list */ + protected array $rawGroups = []; + + /** @var list */ + protected array $rawHavings = []; + + /** @var list */ + protected array $rawWheres = []; + + /** @var list */ + protected array $columnPredicates = []; + + /** @var array */ + protected array $joins = []; + + /** @var list */ + protected array $existsSubqueries = []; + + /** @var list */ + protected array $lateralJoins = []; + + /** @var list */ + protected array $beforeBuildCallbacks = []; + + /** @var list */ + protected array $afterBuildCallbacks = []; + + /** @var (\Closure(Statement): (array|int))|null */ + protected ?\Closure $executor = null; + + protected bool $qualify = false; + + /** @var array */ + protected array $aggregationAliases = []; + + protected ?int $fetchCount = null; + + protected bool $fetchWithTies = false; + + 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; + + protected function buildTableClause(): string + { + if ($this->tableless) { + return ''; + } + + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub->subquery->build(); + $this->addBindings($subResult->bindings); + + return 'FROM (' . $subResult->query . ') AS ' . $this->quote($fromSub->alias); + } + + $sql = 'FROM ' . $this->quote($this->table); + + if ($this->alias !== '') { + $sql .= ' AS ' . $this->quote($this->alias); + } + + if ($this->sample !== null) { + $sql .= ' TABLESAMPLE ' . $this->sample['method'] . '(' . $this->sample['percent'] . ')'; + } + + return $sql; + } + + /** + * Hook called after JOIN clauses and before WHERE. Override to inject + * dialect-specific clauses such as PREWHERE (ClickHouse) or ARRAY JOIN. + * Implementations must add any bindings they emit via $this->addBindings() + * at the moment their fragment is emitted so ordering is preserved. + */ + protected function buildAfterJoinsClause(ParsedQuery $grouped): string + { + return ''; + } + + /** + * Hook called after GROUP BY and before HAVING. Override to emit + * dialect-specific group-by modifiers (e.g. ClickHouse WITH TOTALS). + */ + protected function buildAfterGroupByClause(): string + { + return ''; + } + + /** + * Hook called after ORDER BY and before LIMIT. Override to emit + * dialect-specific clauses that bind between ordering and pagination + * (e.g. ClickHouse LIMIT BY). + */ + protected function buildAfterOrderByClause(): string + { + return ''; + } + + /** + * Hook called at the very end of the SELECT statement (just before any + * UNION suffix). Override to emit dialect-specific settings fragments + * (e.g. ClickHouse SETTINGS). + */ + protected function buildSettingsClause(): string + { + return ''; + } + + /** + * Compile a CASE expression to SQL, appending bindings to $this->bindings + * in the order WHEN-value, THEN-value, ..., ELSE-value. + */ + protected function compileCase(CaseExpression $case): string + { + $whens = $case->getWhens(); + + if ($whens === []) { + throw new ValidationException('CASE expression requires at least one WHEN clause.'); + } + + $sql = 'CASE'; + + foreach ($whens as $when) { + $sql .= ' WHEN ' . $this->compileWhenCondition($when) . ' THEN ?'; + $this->addBinding($when->then); + } + + if ($case->hasElse()) { + $sql .= ' ELSE ?'; + $this->addBinding($case->getElse()); + } + + $sql .= ' END'; + + $alias = $case->getAlias(); + + if ($alias !== '') { + $sql .= ' AS ' . $this->quote($alias); + } + + return $sql; + } + + /** + * Compile the predicate of a single WHEN clause, adding any operand + * bindings to $this->bindings in left-to-right order. + */ + private function compileWhenCondition(WhenClause $when): string + { + switch ($when->kind) { + case CaseKind::Comparison: + if ($when->column === null || $when->operator === null) { + throw new ValidationException('Comparison WHEN clause requires column and operator.'); + } + + $this->addBinding($when->value); + + return $this->quote($when->column) . ' ' . $when->operator->sqlOperator() . ' ?'; + + case CaseKind::Null: + if ($when->column === null) { + throw new ValidationException('Null WHEN clause requires column.'); + } + + return $this->quote($when->column) . ' IS NULL'; + + case CaseKind::NotNull: + if ($when->column === null) { + throw new ValidationException('NotNull WHEN clause requires column.'); + } + + return $this->quote($when->column) . ' IS NOT NULL'; + + case CaseKind::In: + if ($when->column === null) { + throw new ValidationException('In WHEN clause requires column.'); + } + + if ($when->values === []) { + throw new ValidationException('In WHEN clause requires at least one value.'); + } + + $placeholders = \implode(', ', \array_fill(0, \count($when->values), '?')); + + foreach ($when->values as $value) { + $this->addBinding($value); + } + + return $this->quote($when->column) . ' IN (' . $placeholders . ')'; + + case CaseKind::Raw: + if ($when->rawCondition === null) { + throw new ValidationException('Raw WHEN clause requires condition.'); + } + + foreach ($when->rawBindings as $binding) { + $this->addBinding($binding); + } + + return $when->rawCondition; + } + } + + /** + * Build an INSERT ... ON CONFLICT/DUPLICATE KEY UPDATE statement. + * Requires onConflict() to be called first to configure conflict keys and update columns. + */ + public function upsert(): Statement + { + throw new UnsupportedException('UPSERT is not supported by this dialect.'); + } + + #[\Override] + public function build(): Statement + { + $this->bindings = []; + $this->resolvedAttributeCache = []; + + foreach ($this->beforeBuildCallbacks as $callback) { + $callback($this); + } + + $this->validateTable(); + + $ctePrefix = $this->buildCtePrefix(); + + $grouped = Query::groupByType($this->pendingQueries); + + $this->prepareAliasQualification($grouped); + + $joinFilterWhereClauses = []; + $parts = [$this->buildSelectClause($grouped)]; + $this->appendIfNotEmpty($parts, $this->buildFromClause()); + $this->appendIfNotEmpty($parts, $this->buildJoinsClause($grouped, $joinFilterWhereClauses)); + $this->appendIfNotEmpty($parts, $this->buildAfterJoinsClause($grouped)); + $this->appendIfNotEmpty($parts, $this->buildWhereClause($grouped, $joinFilterWhereClauses)); + $this->appendIfNotEmpty($parts, $this->buildGroupByClause($grouped)); + $this->appendIfNotEmpty($parts, $this->buildAfterGroupByClause()); + $this->appendIfNotEmpty($parts, $this->buildHavingClause($grouped)); + $this->appendIfNotEmpty($parts, $this->buildWindowClause()); + $this->appendIfNotEmpty($parts, $this->buildOrderByClause()); + $this->appendIfNotEmpty($parts, $this->buildAfterOrderByClause()); + $this->appendIfNotEmpty($parts, $this->buildLimitClause($grouped)); + $this->appendIfNotEmpty($parts, $this->buildLockingClause()); + $this->appendIfNotEmpty($parts, $this->buildSettingsClause()); + + $sql = \implode(' ', $parts); + + $unionSuffix = $this->buildUnionSuffix(); + if ($unionSuffix !== '') { + $sql = $this->wrapUnionMember($sql) . $unionSuffix; + } + + $sql = $ctePrefix . $sql; + + $result = new Statement($sql, $this->bindings, readOnly: true, executor: $this->executor); + + foreach ($this->afterBuildCallbacks as $callback) { + $result = $callback($result); + } + + return $result; + } + + /** + * Append $fragment to $parts only when it is a non-empty string. + * Keeps build() free of repetitive `if ($fragment !== '')` guards. + * + * @param list $parts + */ + private function appendIfNotEmpty(array &$parts, string $fragment): void + { + if ($fragment !== '') { + $parts[] = $fragment; + } + } + + /** + * Build the optional WITH / WITH RECURSIVE prefix. Adds CTE bindings to + * $this->bindings in document order. Returns an empty string when no + * CTEs are registered. + */ + private function buildCtePrefix(): string + { + if (empty($this->ctes)) { + return ''; + } + + $hasRecursive = false; + $cteParts = []; + foreach ($this->ctes as $cte) { + if ($cte->recursive) { + $hasRecursive = true; + } + $this->addBindings($cte->bindings); + $cteName = $this->quote($cte->name); + if (! empty($cte->columns)) { + $cteName .= '(' . \implode(', ', \array_map(fn (string $col): string => $this->quote($col), $cte->columns)) . ')'; + } + $cteParts[] = $cteName . ' AS (' . $cte->query . ')'; + } + + $keyword = $hasRecursive ? 'WITH RECURSIVE' : 'WITH'; + + return $keyword . ' ' . \implode(', ', $cteParts) . ' '; + } + + /** + * Configure alias-qualification state prior to emitting SELECT. When joins + * are present and the base table has an alias, column references must be + * fully qualified — except aggregation aliases, which are captured here + * so they can be emitted bare. + */ + private function prepareAliasQualification(ParsedQuery $grouped): void + { + $this->qualify = false; + $this->aggregationAliases = []; + + if (empty($grouped->joins) || $this->alias === '') { + return; + } + + $this->qualify = true; + foreach ($grouped->aggregations as $agg) { + /** @var string $aggAlias */ + $aggAlias = $agg->getValue(''); + if ($aggAlias !== '') { + $this->aggregationAliases[$aggAlias] = true; + } + } + } + + /** + * Compile the SELECT [DISTINCT] ... clause, including aggregations, + * column selections, sub-selects, raw selects, window function selects, + * and CASE selects. Always returns a non-empty fragment (falls back + * to `SELECT *`). + */ + private function buildSelectClause(ParsedQuery $grouped): string + { + $selectParts = []; + + foreach ($grouped->aggregations as $agg) { + $selectParts[] = $this->compileAggregate($agg); + } + + if (! empty($grouped->selections)) { + $selectParts[] = $this->compileSelect($grouped->selections[0]); + } + + foreach ($this->subSelects as $subSelect) { + $subResult = $subSelect->subquery->build(); + $selectParts[] = '(' . $subResult->query . ') AS ' . $this->quote($subSelect->alias); + $this->addBindings($subResult->bindings); + } + + foreach ($this->rawSelects as $rawSelect) { + $selectParts[] = $rawSelect->expression; + $this->addBindings($rawSelect->bindings); + } + + foreach ($this->windowSelects as $win) { + $selectParts[] = $this->compileWindowSelect($win); + } + + foreach ($this->cases as $caseSelect) { + $selectParts[] = $this->compileCase($caseSelect); + } + + $selectSQL = ! empty($selectParts) ? \implode(', ', $selectParts) : '*'; + $selectKeyword = $grouped->distinct ? 'SELECT DISTINCT' : 'SELECT'; + + return $selectKeyword . ' ' . $selectSQL; + } + + /** + * Compile a single window-function SELECT item (inline or named window). + */ + private function compileWindowSelect(WindowSelect $win): string + { + if ($win->windowName !== null) { + return $win->function . ' OVER ' . $this->quote($win->windowName) . ' AS ' . $this->quote($win->alias); + } + + $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 !== []) { + $overParts[] = 'ORDER BY ' . $this->compileOrderByList($win->orderBy); + } + + if ($win->frame !== null) { + $overParts[] = $win->frame->toSql(); + } + + return $win->function . ' OVER (' . \implode(' ', $overParts) . ') AS ' . $this->quote($win->alias); + } + + /** + * Compile a list of ORDER BY column tokens (prefixed with '-' for DESC) + * into a comma-separated SQL fragment. + * + * @param list $orderBy + */ + private function compileOrderByList(array $orderBy): string + { + $orderCols = []; + foreach ($orderBy as $col) { + if (\str_starts_with($col, '-')) { + $orderCols[] = $this->resolveAndWrap(\substr($col, 1)) . ' DESC'; + } else { + $orderCols[] = $this->resolveAndWrap($col) . ' ASC'; + } + } + + return \implode(', ', $orderCols); + } + + /** + * Compile the FROM clause. Delegates the table/subquery portion to + * buildTableClause() so dialects can override it precisely. + */ + private function buildFromClause(): string + { + return $this->buildTableClause(); + } + + /** + * Compile the JOIN section, including any lateral joins. Deferred join + * filter conditions that must land in WHERE are appended to the + * $joinFilterWhereClauses out-parameter. + * + * @param list $joinFilterWhereClauses + */ + private function buildJoinsClause(ParsedQuery $grouped, array &$joinFilterWhereClauses): string + { + $joinParts = []; + + if (! empty($grouped->joins)) { + $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->joins[$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, + Method::FullOuterJoin => JoinType::FullOuter, + Method::NaturalJoin => JoinType::Natural, + default => throw new UnsupportedException('Unsupported join method: ' . $joinQuery->getMethod()->value), + }; + $isCrossJoin = $joinType === JoinType::Cross || $joinType === JoinType::Natural; + + $joinValues = $joinQuery->getValues(); + if ($isCrossJoin) { + /** @var string $joinAlias */ + $joinAlias = $joinValues[0] ?? ''; + } else { + /** @var string $joinAlias */ + $joinAlias = $joinValues[3] ?? ''; + } + $effectiveJoinTable = $joinAlias !== '' ? $joinAlias : $joinTable; + + foreach ($this->joinFilterHooks as $hook) { + $result = $hook->filterJoin($effectiveJoinTable, $joinType); + if ($result === null) { + continue; + } + + $placement = $this->resolveJoinFilterPlacement($result->placement, $isCrossJoin); + + if ($placement === Placement::On) { + $joinSQL .= ' AND ' . $result->condition->expression; + $this->addBindings($result->condition->bindings); + } else { + $joinFilterWhereClauses[] = $result->condition; + } + } + + $joinParts[] = $joinSQL; + } + } + + foreach ($this->lateralJoins as $lateral) { + $subResult = $lateral->subquery->build(); + $this->addBindings($subResult->bindings); + $joinKeyword = match ($lateral->type) { + JoinType::Left => 'LEFT JOIN', + default => 'JOIN', + }; + $joinParts[] = $joinKeyword . ' LATERAL (' . $subResult->query . ') AS ' . $this->quote($lateral->alias) . ' ON true'; + } + + return \implode(' ', $joinParts); + } + + /** + * Compile the WHERE clause from query filters, filter hooks, deferred + * join-filter conditions, WHERE IN / NOT IN subqueries, EXISTS + * subqueries, and cursor pagination. + * + * @param list $joinFilterWhereClauses + */ + private function buildWhereClause(ParsedQuery $grouped, array $joinFilterWhereClauses): string + { + $whereClauses = []; + + foreach ($grouped->filters as $filter) { + $whereClauses[] = $this->compileFilter($filter); + } + + foreach ($this->filterHooks as $hook) { + $condition = $hook->filter($this->alias ?: $this->table); + $whereClauses[] = $condition->expression; + $this->addBindings($condition->bindings); + } + + foreach ($joinFilterWhereClauses as $condition) { + $whereClauses[] = $condition->expression; + $this->addBindings($condition->bindings); + } + + foreach ($this->whereInSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT IN' : 'IN'; + $whereClauses[] = $this->resolveAndWrap($sub->column) . ' ' . $prefix . ' (' . $subResult->query . ')'; + $this->addBindings($subResult->bindings); + } + + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + $this->addBindings($subResult->bindings); + } + + 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; + } + } + } + + foreach ($this->rawWheres as $rawWhere) { + $whereClauses[] = $rawWhere->expression; + $this->addBindings($rawWhere->bindings); + } + + foreach ($this->columnPredicates as $predicate) { + $whereClauses[] = $this->resolveAndWrap($predicate->left) + . ' ' . $predicate->operator . ' ' + . $this->resolveAndWrap($predicate->right); + } + + if (empty($whereClauses)) { + return ''; + } + + return 'WHERE ' . \implode(' AND ', $whereClauses); + } + + /** + * Compile the GROUP BY clause, including any raw group expressions. + */ + private function buildGroupByClause(ParsedQuery $grouped): string + { + $groupByParts = []; + if (! empty($grouped->groupBy)) { + foreach ($grouped->groupBy as $col) { + $groupByParts[] = $this->resolveAndWrap($col); + } + } + + foreach ($this->rawGroups as $rawGroup) { + $groupByParts[] = $rawGroup->expression; + $this->addBindings($rawGroup->bindings); + } + + if (empty($groupByParts)) { + return ''; + } + + return 'GROUP BY ' . \implode(', ', $groupByParts); + } + + /** + * Compile the HAVING clause, resolving aggregation aliases to their + * underlying expressions so filters against alias names work portably. + */ + private function buildHavingClause(ParsedQuery $grouped): string + { + $aliasToExpr = $this->buildAggregationAliasMap($grouped); + + $havingClauses = []; + if (! empty($grouped->having)) { + foreach ($grouped->having as $havingQuery) { + foreach ($havingQuery->getValues() as $subQuery) { + /** @var Query $subQuery */ + $attr = $subQuery->getAttribute(); + if (isset($aliasToExpr[$attr])) { + $havingClauses[] = $this->compileHavingCondition($subQuery, $aliasToExpr[$attr]); + } else { + $havingClauses[] = $this->compileFilter($subQuery); + } + } + } + } + + foreach ($this->rawHavings as $rawHaving) { + $havingClauses[] = $rawHaving->expression; + $this->addBindings($rawHaving->bindings); + } + + if (empty($havingClauses)) { + return ''; + } + + return 'HAVING ' . \implode(' AND ', $havingClauses); + } + + /** + * Build a map of aggregation alias -> compiled aggregate expression so + * HAVING can refer to aliases portably across dialects that don't allow + * SELECT-list aliases in HAVING. + * + * @return array + */ + private function buildAggregationAliasMap(ParsedQuery $grouped): array + { + $aliasToExpr = []; + foreach ($grouped->aggregations as $agg) { + /** @var string $alias */ + $alias = $agg->getValue(''); + if ($alias === '') { + continue; + } + + $method = $agg->getMethod(); + $attr = $agg->getAttribute(); + $col = match (true) { + $attr === '*', $attr === '' => '*', + \is_numeric($attr) => $attr, + default => $this->resolveAndWrap($attr), + }; + + if ($method === Method::CountDistinct) { + $aliasToExpr[$alias] = 'COUNT(DISTINCT ' . $col . ')'; + + continue; + } + + $func = $method->sqlFunction() ?? $method->value; + $aliasToExpr[$alias] = $func . '(' . $col . ')'; + } + + return $aliasToExpr; + } + + /** + * Compile the named-window (WINDOW w AS (...)) clause. + */ + private function buildWindowClause(): string + { + if (empty($this->windowDefinitions)) { + return ''; + } + + $windowParts = []; + foreach ($this->windowDefinitions as $winDef) { + $overParts = []; + if ($winDef->partitionBy !== null && $winDef->partitionBy !== []) { + $partCols = \array_map(fn (string $col): string => $this->resolveAndWrap($col), $winDef->partitionBy); + $overParts[] = 'PARTITION BY ' . \implode(', ', $partCols); + } + if ($winDef->orderBy !== null && $winDef->orderBy !== []) { + $overParts[] = 'ORDER BY ' . $this->compileOrderByList($winDef->orderBy); + } + if ($winDef->frame !== null) { + $overParts[] = $winDef->frame->toSql(); + } + $windowParts[] = $this->quote($winDef->name) . ' AS (' . \implode(' ', $overParts) . ')'; + } + + return 'WINDOW ' . \implode(', ', $windowParts); + } + + /** + * Compile the ORDER BY clause, including vector-distance ordering, raw + * order expressions, and ordinary ORDER ASC/DESC/RANDOM queries. + */ + private function buildOrderByClause(): string + { + $orderClauses = []; + + $vectorOrderExpr = $this->compileVectorOrderExpr(); + if ($vectorOrderExpr !== null) { + $orderClauses[] = $vectorOrderExpr->expression; + $this->addBindings($vectorOrderExpr->bindings); + } + + foreach ($this->rawOrders as $rawOrder) { + $orderClauses[] = $rawOrder->expression; + $this->addBindings($rawOrder->bindings); + } + + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], false); + foreach ($orderQueries as $orderQuery) { + $orderClauses[] = $this->compileOrder($orderQuery); + } + + if (empty($orderClauses)) { + return ''; + } + + return 'ORDER BY ' . \implode(', ', $orderClauses); + } + + /** + * Compile the LIMIT / OFFSET / FETCH FIRST pagination tail. Emitted as + * a single space-joined fragment so bindings are added in document order. + */ + private function buildLimitClause(ParsedQuery $grouped): string + { + $limitParts = []; + + if ($grouped->limit !== null) { + $limitParts[] = 'LIMIT ?'; + $this->addBinding($grouped->limit); + } + + if ($this->shouldEmitOffset($grouped->offset, $grouped->limit)) { + $limitParts[] = 'OFFSET ?'; + $this->addBinding($grouped->offset); + } + + if ($this->fetchCount !== null) { + $this->addBinding($this->fetchCount); + $limitParts[] = $this->fetchWithTies + ? 'FETCH FIRST ? ROWS WITH TIES' + : 'FETCH FIRST ? ROWS ONLY'; + } + + return \implode(' ', $limitParts); + } + + /** + * Compile the locking clause (FOR UPDATE / FOR SHARE / ...), optionally + * scoped with OF . + */ + private function buildLockingClause(): string + { + if ($this->lockMode === null) { + return ''; + } + + $lockSql = $this->lockMode->toSql(); + if ($this->lockOfTable !== null) { + $lockSql .= ' OF ' . $this->quote($this->lockOfTable); + } + + return $lockSql; + } + + /** + * Compile the trailing UNION chain. Returns the suffix to concatenate + * after the parenthesized primary query (including the leading space), + * or an empty string when no unions are registered. + */ + private function buildUnionSuffix(): string + { + if (empty($this->unions)) { + return ''; + } + + $suffix = ''; + foreach ($this->unions as $union) { + $suffix .= ' ' . $union->type->value . ' ' . $this->wrapUnionMember($union->query); + $this->addBindings($union->bindings); + } + + return $suffix; + } + + /** + * Wrap a compound-SELECT member for inclusion in a UNION chain. The + * default wraps each arm in parentheses, matching the shape most + * dialects expect. Override in dialects whose parsers reject the + * parenthesised form (e.g. SQLite). + */ + protected function wrapUnionMember(string $sql): string + { + return '(' . $sql . ')'; + } + + /** + * 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->rows 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->quote($this->insertAlias); + } + + $sql = 'INSERT INTO ' . $tablePart + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); + + return [$sql, $bindings]; + } + + /** + * @return list + */ + protected function compileAssignments(): array + { + $assignments = []; + + if (! empty($this->rows)) { + foreach ($this->rows[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) . ' = ' . $this->compileCase($caseData); + } + + return $assignments; + } + + /** + * @param array $parts + */ + protected function compileWhereClauses(array &$parts, ?ParsedQuery $grouped = null): 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->alias ?: $this->table); + $whereClauses[] = $condition->expression; + $this->addBindings($condition->bindings); + } + + // 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 . ')'; + $this->addBindings($subResult->bindings); + } + + // EXISTS subqueries + foreach ($this->existsSubqueries as $sub) { + $subResult = $sub->subquery->build(); + $prefix = $sub->not ? 'NOT EXISTS' : 'EXISTS'; + $whereClauses[] = $prefix . ' (' . $subResult->query . ')'; + $this->addBindings($subResult->bindings); + } + + foreach ($this->rawWheres as $rawWhere) { + $whereClauses[] = $rawWhere->expression; + $this->addBindings($rawWhere->bindings); + } + + foreach ($this->columnPredicates as $predicate) { + $whereClauses[] = $this->resolveAndWrap($predicate->left) + . ' ' . $predicate->operator . ' ' + . $this->resolveAndWrap($predicate->right); + } + + if (! empty($whereClauses)) { + $parts[] = 'WHERE ' . \implode(' AND ', $whereClauses); + } + } + + /** + * @param array $parts + */ + protected function compileOrderAndLimit(array &$parts, ?ParsedQuery $grouped = null): void + { + $grouped ??= Query::groupByType($this->pendingQueries); + + $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; + $this->addBindings($rawOrder->bindings); + } + if (! empty($orderClauses)) { + $parts[] = 'ORDER BY ' . \implode(', ', $orderClauses); + } + + if ($grouped->limit !== null) { + $parts[] = 'LIMIT ?'; + $this->addBinding($grouped->limit); + } + } + + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + if ($offset === null) { + return false; + } + + if ($limit === null) { + throw new ValidationException( + 'OFFSET requires LIMIT on this engine. Set a limit or use the dialect\'s native no-limit form.', + ); + } + + return true; + } + + /** + * Hook for subclasses to inject a vector distance ORDER BY expression. + */ + protected function compileVectorOrderExpr(): ?Condition + { + return null; + } + + protected function validateTable(): void + { + if ($this->tableless) { + 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->rows)) { + throw new ValidationException("No rows to {$operation}. Call set() before {$operation}()."); + } + + foreach ($this->rows 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->rows[0]); + + foreach ($columns as $col) { + if ($col === '') { + throw new ValidationException('Column names must be non-empty strings.'); + } + } + + if (\count($this->rows) > 1) { + $expectedKeys = $columns; + \sort($expectedKeys); + + foreach ($this->rows 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; + } + + public function clone(): static + { + return clone $this; + } + + public function __clone(): void + { + if ($this->insertSelectSource !== null) { + $this->insertSelectSource = clone $this->insertSelectSource; + } + if ($this->fromSubquery !== null) { + $this->fromSubquery = new SubSelect(clone $this->fromSubquery->subquery, $this->fromSubquery->alias); + } + $this->subSelects = \array_map(fn (SubSelect $s) => new SubSelect(clone $s->subquery, $s->alias), $this->subSelects); + $this->whereInSubqueries = \array_map(fn (WhereInSubquery $s) => new WhereInSubquery($s->column, clone $s->subquery, $s->not), $this->whereInSubqueries); + $this->existsSubqueries = \array_map(fn (ExistsSubquery $s) => new ExistsSubquery(clone $s->subquery, $s->not), $this->existsSubqueries); + $this->joins = \array_map(fn (JoinBuilder $j) => clone $j, $this->joins); + $this->pendingQueries = \array_map(fn (Query $q) => clone $q, $this->pendingQueries); + $this->lateralJoins = \array_map(fn (LateralJoin $l) => new LateralJoin(clone $l->subquery, $l->alias, $l->type), $this->lateralJoins); + } + + #[\Override] + 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 => $query->onArray() ? $this->compileIn($attribute, $values) : $this->compileContains($attribute, $values), + Method::ContainsAll => $this->compileContainsAll($attribute, $values), + Method::NotContains => $this->compileNotContains($attribute, $values), + Method::Search => throw new UnsupportedException('Full-text search is not supported by this dialect.'), + Method::NotSearch => throw new UnsupportedException('Full-text search is not supported by this dialect.'), + 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), + }; + } + + protected function compileHavingCondition(Query $query, string $expression): string + { + $method = $query->getMethod(); + $values = $query->getValues(); + + return match ($method) { + Method::Equal => $this->compileIn($expression, $values), + Method::NotEqual => $this->compileNotIn($expression, $values), + Method::LessThan => $this->compileComparison($expression, '<', $values), + Method::LessThanEqual => $this->compileComparison($expression, '<=', $values), + Method::GreaterThan => $this->compileComparison($expression, '>', $values), + Method::GreaterThanEqual => $this->compileComparison($expression, '>=', $values), + Method::Between => $this->compileBetween($expression, $values, false), + Method::NotBetween => $this->compileBetween($expression, $values, true), + Method::IsNull => $expression . ' IS NULL', + Method::IsNotNull => $expression . ' IS NOT NULL', + default => throw new UnsupportedException('Unsupported HAVING condition type: ' . $method->value), + }; + } + + #[\Override] + public function compileOrder(Query $query): string + { + $sql = 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), + }; + + $nulls = $query->getValue(null); + if ($nulls instanceof NullsPosition) { + $sql .= ' NULLS ' . $nulls->value; + } + + return $sql; + } + + #[\Override] + public function compileLimit(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'LIMIT ?'; + } + + #[\Override] + public function compileOffset(Query $query): string + { + $this->addBinding($query->getValue()); + + return 'OFFSET ?'; + } + + #[\Override] + 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); + } + + #[\Override] + public function compileCursor(Query $query): string + { + $value = $query->getValue(); + $this->addBinding($value); + + $operator = $query->getMethod() === Method::CursorAfter ? '>' : '<'; + + return $this->quote('_cursor') . ' ' . $operator . ' ?'; + } + + #[\Override] + 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 = $method->sqlFunction() ?? throw new ValidationException("Unknown aggregate: {$method->value}"); + $attr = $query->getAttribute(); + $col = match (true) { + $attr === '*', $attr === '' => '*', + \is_numeric($attr) => $attr, + default => $this->resolveAndWrap($attr), + }; + /** @var string $alias */ + $alias = $query->getValue(''); + $sql = $func . '(' . $col . ')'; + + if ($alias !== '') { + $sql .= ' AS ' . $this->quote($alias); + } + + return $sql; + } + + #[\Override] + 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); + } + + #[\Override] + 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', + Method::FullOuterJoin => 'FULL OUTER JOIN', + Method::NaturalJoin => 'NATURAL JOIN', + default => throw new UnsupportedException('Unsupported join type: ' . $query->getMethod()->value), + }; + + $table = $this->quote($query->getAttribute()); + $values = $query->getValues(); + + // Handle alias for cross join and natural join (alias is values[0]) + if ($query->getMethod() === Method::CrossJoin || $query->getMethod() === Method::NaturalJoin) { + /** @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', + Method::FullOuterJoin => 'FULL OUTER JOIN', + Method::NaturalJoin => 'NATURAL 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 || $query->getMethod() === Method::NaturalJoin) { + /** @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; + $this->addBindings($where->bindings); + } + + if (empty($onParts)) { + return $type . ' ' . $table; + } + + return $type . ' ' . $table . ' ON ' . \implode(' AND ', $onParts); + } + + protected function resolveAttribute(string $attribute): string + { + if (isset($this->resolvedAttributeCache[$attribute])) { + return $this->resolvedAttributeCache[$attribute]; + } + + $raw = $attribute; + foreach ($this->attributeHooks as $hook) { + $attribute = $hook->resolve($attribute); + } + + $this->resolvedAttributeCache[$raw] = $attribute; + + return $attribute; + } + + protected function resolveAndWrap(string $attribute): string + { + $resolved = $this->resolveAttribute($attribute); + + if ($this->qualify + && $resolved !== '*' + && ! \str_contains($resolved, '.') + && ! isset($this->aggregationAliases[$resolved]) + ) { + $resolved = $this->alias . '.' . $resolved; + } + + return $this->quote($resolved); + } + + protected function addBinding(mixed $value): void + { + $this->bindings[] = $value; + } + + /** + * @param array $bindings + */ + protected function addBindings(array $bindings): void + { + \array_push($this->bindings, ...$bindings); + } + + /** + * @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); + $like = $this->getLikeKeyword(); + $keyword = $not ? 'NOT ' . $like : $like; + + return $attribute . ' ' . $keyword . ' ?'; + } + + /** + * @param array $values + */ + protected function compileContains(string $attribute, array $values): string + { + $like = $this->getLikeKeyword(); + /** @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 + { + $like = $this->getLikeKeyword(); + /** @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 + { + $like = $this->getLikeKeyword(); + /** @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) . ')'; + } + + protected function getLikeKeyword(): string + { + return 'LIKE'; + } + + /** + * Escape LIKE metacharacters in user input before wrapping with wildcards. + */ + protected function escapeLikeValue(mixed $value): string + { + if (\is_array($value)) { + $value = \json_encode($value) ?: ''; + } elseif (\is_int($value) || \is_float($value) || \is_bool($value)) { + $value = (string) $value; + } elseif (!\is_string($value)) { + $value = ''; + } + + 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 + */ + protected 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 + */ + protected 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 + */ + protected 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; + } + + public function toAst(): Select + { + $grouped = Query::groupByType($this->pendingQueries); + + $columns = $this->buildAstColumns($grouped); + $from = $this->buildAstFrom(); + $joins = $this->buildAstJoins($grouped); + $where = $this->buildAstWhere($grouped); + $groupByExprs = $this->buildAstGroupBy($grouped); + $having = $this->buildAstHaving($grouped); + $orderByItems = $this->buildAstOrderBy(); + $limit = $grouped->limit !== null ? new Literal($grouped->limit) : null; + $offset = $grouped->offset !== null ? new Literal($grouped->offset) : null; + $cteDefinitions = $this->buildAstCtes(); + + return new Select( + columns: $columns, + from: $from, + joins: $joins, + where: $where, + groupBy: $groupByExprs, + having: $having, + orderBy: $orderByItems, + limit: $limit, + offset: $offset, + distinct: $grouped->distinct, + ctes: $cteDefinitions, + ); + } + + /** + * @return Expression[] + */ + private function buildAstColumns(ParsedQuery $grouped): array + { + $columns = []; + + foreach ($grouped->aggregations as $agg) { + $columns[] = $this->aggregateQueryToAstExpression($agg); + } + + if (!empty($grouped->selections)) { + /** @var array $selectedCols */ + $selectedCols = $grouped->selections[0]->getValues(); + foreach ($selectedCols as $col) { + $columns[] = $this->columnNameToAstExpression($col); + } + } + + if (empty($columns)) { + $columns[] = new Star(); + } + + return $columns; + } + + private function columnNameToAstExpression(string $col): Expression + { + if ($col === '*') { + return new Star(); + } + + if (\str_contains($col, '.')) { + $parts = \explode('.', $col, 3); + if (\count($parts) === 3) { + if ($parts[2] === '*') { + return new Star($parts[1], $parts[0]); + } + return new Column($parts[2], $parts[1], $parts[0]); + } + if ($parts[1] === '*') { + return new Star($parts[0]); + } + return new Column($parts[1], $parts[0]); + } + + return new Column($col); + } + + private function aggregateQueryToAstExpression(Query $query): Expression + { + $method = $query->getMethod(); + $attr = $query->getAttribute(); + /** @var string $alias */ + $alias = $query->getValue(''); + + $funcName = $method->sqlFunction() ?? \strtoupper($method->value); + + $arg = ($attr === '*' || $attr === '') ? new Star() : new Column($attr); + $distinct = $method === Method::CountDistinct; + + $funcCall = new Func($funcName, [$arg], $distinct); + + if ($alias !== '') { + return new Aliased($funcCall, $alias); + } + + return $funcCall; + } + + private function buildAstFrom(): ?Table + { + if ($this->tableless) { + return null; + } + + if ($this->table === '') { + return null; + } + + $alias = $this->alias !== '' ? $this->alias : null; + return new Table($this->table, $alias); + } + + /** + * @return AstJoinClause[] + */ + private function buildAstJoins(ParsedQuery $grouped): array + { + $joins = []; + + foreach ($grouped->joins as $joinQuery) { + $joinMethod = $joinQuery->getMethod(); + $table = $joinQuery->getAttribute(); + $values = $joinQuery->getValues(); + + $type = match ($joinMethod) { + Method::Join => 'JOIN', + Method::LeftJoin => 'LEFT JOIN', + Method::RightJoin => 'RIGHT JOIN', + Method::CrossJoin => 'CROSS JOIN', + Method::FullOuterJoin => 'FULL OUTER JOIN', + Method::NaturalJoin => 'NATURAL JOIN', + default => 'JOIN', + }; + + $isCrossOrNatural = $joinMethod === Method::CrossJoin || $joinMethod === Method::NaturalJoin; + + if ($isCrossOrNatural) { + /** @var string $joinAlias */ + $joinAlias = $values[0] ?? ''; + $tableRef = new Table($table, $joinAlias !== '' ? $joinAlias : null); + $joins[] = new AstJoinClause($type, $tableRef, null); + } else { + /** @var string $leftCol */ + $leftCol = $values[0] ?? ''; + /** @var string $operator */ + $operator = $values[1] ?? '='; + /** @var string $rightCol */ + $rightCol = $values[2] ?? ''; + /** @var string $joinAlias */ + $joinAlias = $values[3] ?? ''; + + $tableRef = new Table($table, $joinAlias !== '' ? $joinAlias : null); + + $condition = null; + if ($leftCol !== '' && $rightCol !== '') { + $condition = new Binary( + $this->columnNameToAstExpression($leftCol), + $operator, + $this->columnNameToAstExpression($rightCol), + ); + } + + $joins[] = new AstJoinClause($type, $tableRef, $condition); + } + } + + return $joins; + } + + private function buildAstWhere(ParsedQuery $grouped): ?Expression + { + if (empty($grouped->filters)) { + return null; + } + + $exprs = []; + foreach ($grouped->filters as $filter) { + $exprs[] = $this->queryToAstExpression($filter); + } + + return $this->combineAstExpressions($exprs, 'AND'); + } + + private function queryToAstExpression(Query $query): Expression + { + $method = $query->getMethod(); + $attr = $query->getAttribute(); + $values = $query->getValues(); + + return match ($method) { + Method::Equal => $this->buildEqualAstExpression($attr, $values), + Method::NotEqual => $this->buildNotEqualAstExpression($attr, $values), + Method::GreaterThan => new Binary(new Column($attr), '>', $this->toLiteral($values[0] ?? null)), + Method::GreaterThanEqual => new Binary(new Column($attr), '>=', $this->toLiteral($values[0] ?? null)), + Method::LessThan => new Binary(new Column($attr), '<', $this->toLiteral($values[0] ?? null)), + Method::LessThanEqual => new Binary(new Column($attr), '<=', $this->toLiteral($values[0] ?? null)), + Method::Between => new Between(new Column($attr), $this->toLiteral($values[0] ?? null), $this->toLiteral($values[1] ?? null)), + Method::NotBetween => new Between(new Column($attr), $this->toLiteral($values[0] ?? null), $this->toLiteral($values[1] ?? null), true), + Method::IsNull => new Unary('IS NULL', new Column($attr), false), + Method::IsNotNull => new Unary('IS NOT NULL', new Column($attr), false), + Method::Contains => $this->buildContainsAstExpression($attr, $values, false), + Method::ContainsAny => $this->buildContainsAstExpression($attr, $values, false), + Method::NotContains => $this->buildContainsAstExpression($attr, $values, true), + Method::StartsWith => new Binary(new Column($attr), 'LIKE', new Literal($this->toScalar($values[0] ?? '') . '%')), + Method::NotStartsWith => new Binary(new Column($attr), 'NOT LIKE', new Literal($this->toScalar($values[0] ?? '') . '%')), + Method::EndsWith => new Binary(new Column($attr), 'LIKE', new Literal('%' . $this->toScalar($values[0] ?? ''))), + Method::NotEndsWith => new Binary(new Column($attr), 'NOT LIKE', new Literal('%' . $this->toScalar($values[0] ?? ''))), + Method::And => $this->buildLogicalAstExpression($query, 'AND'), + Method::Or => $this->buildLogicalAstExpression($query, 'OR'), + Method::Raw => new Raw($attr), + default => new Raw($attr !== '' ? $attr : '1 = 1'), + }; + } + + private function toLiteral(mixed $value): Literal + { + if ($value === null || \is_string($value) || \is_int($value) || \is_float($value) || \is_bool($value)) { + return new Literal($value); + } + + /** @var scalar $value */ + return new Literal((string) $value); + } + + private function toScalar(mixed $value): string + { + if (\is_string($value)) { + return $value; + } + + if (\is_int($value) || \is_float($value) || \is_bool($value)) { + return (string) $value; + } + + return ''; + } + + /** + * @param array $values + */ + private function buildEqualAstExpression(string $attr, array $values): Expression + { + if (\count($values) === 1) { + if ($values[0] === null) { + return new Unary('IS NULL', new Column($attr), false); + } + return new Binary(new Column($attr), '=', $this->toLiteral($values[0])); + } + + $literals = \array_map(fn ($v) => $this->toLiteral($v), $values); + return new In(new Column($attr), $literals); + } + + /** + * @param array $values + */ + private function buildNotEqualAstExpression(string $attr, array $values): Expression + { + if (\count($values) === 1) { + if ($values[0] === null) { + return new Unary('IS NOT NULL', new Column($attr), false); + } + return new Binary(new Column($attr), '!=', $this->toLiteral($values[0])); + } + + $literals = \array_map(fn ($v) => $this->toLiteral($v), $values); + return new In(new Column($attr), $literals, true); + } + + /** + * @param array $values + */ + private function buildContainsAstExpression(string $attr, array $values, bool $negated): Expression + { + if (\count($values) === 1) { + $op = $negated ? 'NOT LIKE' : 'LIKE'; + return new Binary(new Column($attr), $op, new Literal('%' . $this->toScalar($values[0]) . '%')); + } + + $parts = []; + $op = $negated ? 'NOT LIKE' : 'LIKE'; + foreach ($values as $value) { + $parts[] = new Binary(new Column($attr), $op, new Literal('%' . $this->toScalar($value) . '%')); + } + + $combinator = $negated ? 'AND' : 'OR'; + return $this->combineAstExpressions($parts, $combinator); + } + + private function buildLogicalAstExpression(Query $query, string $operator): Expression + { + $parts = []; + foreach ($query->getValues() as $subQuery) { + if ($subQuery instanceof Query) { + $parts[] = $this->queryToAstExpression($subQuery); + } + } + + if (empty($parts)) { + return new Literal($operator === 'OR' ? false : true); + } + + return $this->combineAstExpressions($parts, $operator); + } + + /** + * @param Expression[] $expressions + */ + private function combineAstExpressions(array $expressions, string $operator): Expression + { + $n = \count($expressions); + if ($n === 1) { + return $expressions[0]; + } + + $result = $expressions[0]; + for ($i = 1; $i < $n; $i++) { + $result = new Binary($result, $operator, $expressions[$i]); + } + + return $result; + } + + /** + * @return Expression[] + */ + private function buildAstGroupBy(ParsedQuery $grouped): array + { + $exprs = []; + foreach ($grouped->groupBy as $col) { + $exprs[] = $this->columnNameToAstExpression($col); + } + return $exprs; + } + + private function buildAstHaving(ParsedQuery $grouped): ?Expression + { + if (empty($grouped->having)) { + return null; + } + + $parts = []; + foreach ($grouped->having as $havingQuery) { + foreach ($havingQuery->getValues() as $subQuery) { + if ($subQuery instanceof Query) { + $parts[] = $this->queryToAstExpression($subQuery); + } + } + } + + if (empty($parts)) { + return null; + } + + return $this->combineAstExpressions($parts, 'AND'); + } + + /** + * @return OrderByItem[] + */ + private function buildAstOrderBy(): array + { + $items = []; + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom, + ], false); + + foreach ($orderQueries as $orderQuery) { + $method = $orderQuery->getMethod(); + + if ($method === Method::OrderRandom) { + $items[] = new OrderByItem(new Raw('RAND()'), OrderDirection::Asc); + continue; + } + + $direction = $method === Method::OrderAsc ? OrderDirection::Asc : OrderDirection::Desc; + $attr = $orderQuery->getAttribute(); + $expr = $this->columnNameToAstExpression($attr); + + $nulls = null; + $nullsVal = $orderQuery->getValue(null); + if ($nullsVal instanceof NullsPosition) { + $nulls = $nullsVal; + } + + $items[] = new OrderByItem($expr, $direction, $nulls); + } + + return $items; + } + + /** + * @return CteDefinition[] + */ + private function buildAstCtes(): array + { + $defs = []; + foreach ($this->ctes as $cte) { + $innerStmt = $this->parseSqlToAst($cte->query); + $defs[] = new CteDefinition($cte->name, $innerStmt, $cte->columns, $cte->recursive); + } + return $defs; + } + + private function parseSqlToAst(string $sql): Select + { + $tokenizer = new Tokenizer(); + $tokens = Tokenizer::filter($tokenizer->tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + protected function createAstSerializer(): Serializer + { + return new Serializer(); + } + + public static function fromAst(Select $ast): static + { + $builder = new static(); // @phpstan-ignore new.static + + if ($ast->from instanceof Table) { + $builder->from($ast->from->name, $ast->from->alias ?? ''); + } + + $builder->applyAstColumns($ast); + $builder->applyAstJoins($ast); + $builder->applyAstWhere($ast); + $builder->applyAstGroupBy($ast); + $builder->applyAstHaving($ast); + $builder->applyAstOrderBy($ast); + $builder->applyAstLimitOffset($ast); + $builder->applyAstCtes($ast); + + if ($ast->distinct) { + $builder->distinct(); + } + + return $builder; + } + + private function applyAstColumns(Select $ast): void + { + $selectCols = []; + $hasNonStar = false; + + foreach ($ast->columns as $col) { + if ($col instanceof Star && $col->table === null) { + continue; + } + + if ($col instanceof Aliased && $col->expression instanceof Func) { + $this->applyAstAggregateColumn($col); + $hasNonStar = true; + continue; + } + + if ($col instanceof Func) { + $this->applyAstUnaliasedFunctionColumn($col); + $hasNonStar = true; + continue; + } + + if ($col instanceof Column) { + $selectCols[] = $this->astColumnReferenceToString($col); + $hasNonStar = true; + continue; + } + + if ($col instanceof Star) { + $selectCols[] = $col->table !== null ? $col->table . '.*' : '*'; + $hasNonStar = true; + continue; + } + + if ($col instanceof Aliased && $col->expression instanceof Column) { + $colStr = $this->astColumnReferenceToString($col->expression); + if ($col->alias !== '') { + $colStr .= ' AS ' . $col->alias; + } + $selectCols[] = $colStr; + $hasNonStar = true; + continue; + } + + $serializer = $this->createAstSerializer(); + $this->select($serializer->serializeExpression($col)); + $hasNonStar = true; + } + + if (!empty($selectCols)) { + $this->select($selectCols); + } + } + + private function applyAstAggregateColumn(Aliased $aliased): void + { + $fn = $aliased->expression; + if (!$fn instanceof Func) { + return; + } + + $name = \strtoupper($fn->name); + $attr = $this->astFuncArgToAttribute($fn); + $alias = $aliased->alias; + + if ($fn->distinct && $name === 'COUNT') { + $this->pendingQueries[] = Query::countDistinct($attr, $alias); + return; + } + + $method = match ($name) { + 'COUNT' => Method::Count, + 'SUM' => Method::Sum, + 'AVG' => Method::Avg, + 'MIN' => Method::Min, + 'MAX' => Method::Max, + 'STDDEV' => Method::Stddev, + 'STDDEV_POP' => Method::StddevPop, + 'STDDEV_SAMP' => Method::StddevSamp, + 'VARIANCE' => Method::Variance, + 'VAR_POP' => Method::VarPop, + 'VAR_SAMP' => Method::VarSamp, + 'BIT_AND' => Method::BitAnd, + 'BIT_OR' => Method::BitOr, + 'BIT_XOR' => Method::BitXor, + default => null, + }; + + if ($method !== null) { + $this->pendingQueries[] = new Query($method, $attr, $alias !== '' ? [$alias] : []); + return; + } + + $serializer = $this->createAstSerializer(); + $this->select($serializer->serializeExpression($aliased)); + } + + private function applyAstUnaliasedFunctionColumn(Func $fn): void + { + $name = \strtoupper($fn->name); + $attr = $this->astFuncArgToAttribute($fn); + + if ($fn->distinct && $name === 'COUNT') { + $this->pendingQueries[] = Query::countDistinct($attr); + return; + } + + $method = match ($name) { + 'COUNT' => Method::Count, + 'SUM' => Method::Sum, + 'AVG' => Method::Avg, + 'MIN' => Method::Min, + 'MAX' => Method::Max, + default => null, + }; + + if ($method !== null) { + $this->pendingQueries[] = new Query($method, $attr, []); + return; + } + + $serializer = $this->createAstSerializer(); + $this->select($serializer->serializeExpression($fn)); + } + + private function astFuncArgToAttribute(Func $fn): string + { + if (empty($fn->arguments)) { + return '*'; + } + + $firstArg = $fn->arguments[0]; + if ($firstArg instanceof Star) { + return '*'; + } + if ($firstArg instanceof Column) { + return $this->astColumnReferenceToString($firstArg); + } + + return '*'; + } + + private function astColumnReferenceToString(Column $reference): string + { + $parts = []; + if ($reference->schema !== null) { + $parts[] = $reference->schema; + } + if ($reference->table !== null) { + $parts[] = $reference->table; + } + $parts[] = $reference->name; + return \implode('.', $parts); + } + + private function applyAstJoins(Select $ast): void + { + foreach ($ast->joins as $join) { + if (!$join->table instanceof Table) { + continue; + } + + $table = $join->table->name; + $alias = $join->table->alias ?? ''; + $type = \strtoupper($join->type); + + if ($type === 'CROSS JOIN') { + $this->crossJoin($table, $alias); + continue; + } + + if ($type === 'NATURAL JOIN') { + $this->naturalJoin($table, $alias); + continue; + } + + $leftCol = ''; + $operator = '='; + $rightCol = ''; + + if ($join->condition instanceof Binary) { + $leftCol = $this->astExpressionToColumnString($join->condition->left); + $operator = $join->condition->operator; + $rightCol = $this->astExpressionToColumnString($join->condition->right); + } + + $method = match ($type) { + 'LEFT JOIN', 'LEFT OUTER JOIN' => Method::LeftJoin, + 'RIGHT JOIN', 'RIGHT OUTER JOIN' => Method::RightJoin, + 'FULL OUTER JOIN', 'FULL JOIN' => Method::FullOuterJoin, + 'INNER JOIN', 'JOIN' => Method::Join, + default => Method::Join, + }; + + $values = [$leftCol, $operator, $rightCol]; + if ($alias !== '') { + $values[] = $alias; + } + $this->pendingQueries[] = new Query($method, $table, $values); + } + } + + private function astExpressionToColumnString(Expression $expression): string + { + if ($expression instanceof Column) { + return $this->astColumnReferenceToString($expression); + } + + $serializer = $this->createAstSerializer(); + return $serializer->serializeExpression($expression); + } + + private function applyAstWhere(Select $ast): void + { + if ($ast->where === null) { + return; + } + + $queries = $this->astWhereToQueries($ast->where); + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + } + + /** + * @return Query[] + */ + private function astWhereToQueries(Expression $expression): array + { + if ($expression instanceof Binary && \strtoupper($expression->operator) === 'AND') { + $left = $this->astWhereToQueries($expression->left); + $right = $this->astWhereToQueries($expression->right); + \array_push($left, ...$right); + return $left; + } + + $query = $this->astExpressionToSingleQuery($expression); + if ($query !== null) { + return [$query]; + } + + $serializer = $this->createAstSerializer(); + return [Query::raw($serializer->serializeExpression($expression))]; + } + + private function astExpressionToSingleQuery(Expression $expression): ?Query + { + if ($expression instanceof Binary) { + $op = \strtoupper($expression->operator); + + if ($op === 'AND') { + $leftQueries = $this->astWhereToQueries($expression->left); + $rightQueries = $this->astWhereToQueries($expression->right); + \array_push($leftQueries, ...$rightQueries); + return Query::and($leftQueries); + } + + if ($op === 'OR') { + $leftQ = $this->astExpressionToSingleQuery($expression->left); + $rightQ = $this->astExpressionToSingleQuery($expression->right); + $parts = []; + if ($leftQ !== null) { + $parts[] = $leftQ; + } + if ($rightQ !== null) { + $parts[] = $rightQ; + } + if (!empty($parts)) { + return Query::or($parts); + } + return null; + } + + if ($expression->left instanceof Column && $expression->right instanceof Literal) { + $attr = $this->astColumnReferenceToString($expression->left); + /** @var string|int|float|bool|null $val */ + $val = $expression->right->value; + + return match ($op) { + '=' => Query::equal($attr, [$val]), + '!=' , '<>' => Query::notEqual($attr, \is_bool($val) ? (int) $val : $val), + '>' => Query::greaterThan($attr, \is_string($val) || \is_int($val) || \is_float($val) ? $val : (string) $val), + '>=' => Query::greaterThanEqual($attr, \is_string($val) || \is_int($val) || \is_float($val) ? $val : (string) $val), + '<' => Query::lessThan($attr, \is_string($val) || \is_int($val) || \is_float($val) ? $val : (string) $val), + '<=' => Query::lessThanEqual($attr, \is_string($val) || \is_int($val) || \is_float($val) ? $val : (string) $val), + 'LIKE' => $this->likeToQuery($attr, (string) $val), + 'NOT LIKE' => $this->notLikeToQuery($attr, (string) $val), + default => null, + }; + } + } + + if ($expression instanceof In && $expression->expression instanceof Column && \is_array($expression->list)) { + $attr = $this->astColumnReferenceToString($expression->expression); + $values = \array_map(fn (Expression $item) => $item instanceof Literal ? $item->value : null, $expression->list); + if ($expression->negated) { + return Query::notEqual($attr, $values); + } + return Query::equal($attr, $values); + } + + if ($expression instanceof Between && $expression->expression instanceof Column) { + $attr = $this->astColumnReferenceToString($expression->expression); + $lowRaw = $expression->low instanceof Literal ? $expression->low->value : 0; + $highRaw = $expression->high instanceof Literal ? $expression->high->value : 0; + $low = \is_string($lowRaw) || \is_int($lowRaw) || \is_float($lowRaw) ? $lowRaw : (string) $lowRaw; + $high = \is_string($highRaw) || \is_int($highRaw) || \is_float($highRaw) ? $highRaw : (string) $highRaw; + if ($expression->negated) { + return Query::notBetween($attr, $low, $high); + } + return Query::between($attr, $low, $high); + } + + if ($expression instanceof Unary) { + $op = \strtoupper($expression->operator); + if ($expression->operand instanceof Column) { + $attr = $this->astColumnReferenceToString($expression->operand); + return match ($op) { + 'IS NULL' => Query::isNull($attr), + 'IS NOT NULL' => Query::isNotNull($attr), + default => null, + }; + } + } + + return null; + } + + private function likeToQuery(string $attr, string $val): Query + { + $str = $val; + if (\str_starts_with($str, '%') && \str_ends_with($str, '%') && \strlen($str) > 2) { + return new Query(Method::Contains, $attr, [\substr($str, 1, -1)]); + } + if (\str_ends_with($str, '%') && !\str_starts_with($str, '%')) { + return Query::startsWith($attr, \substr($str, 0, -1)); + } + if (\str_starts_with($str, '%') && !\str_ends_with($str, '%')) { + return Query::endsWith($attr, \substr($str, 1)); + } + return Query::raw($attr . ' LIKE ?', [$val]); + } + + private function notLikeToQuery(string $attr, string $val): Query + { + $str = $val; + if (\str_starts_with($str, '%') && \str_ends_with($str, '%') && \strlen($str) > 2) { + return new Query(Method::NotContains, $attr, [\substr($str, 1, -1)]); + } + if (\str_ends_with($str, '%') && !\str_starts_with($str, '%')) { + return Query::notStartsWith($attr, \substr($str, 0, -1)); + } + if (\str_starts_with($str, '%') && !\str_ends_with($str, '%')) { + return Query::notEndsWith($attr, \substr($str, 1)); + } + return Query::raw($attr . ' NOT LIKE ?', [$val]); + } + + private function applyAstGroupBy(Select $ast): void + { + if (empty($ast->groupBy)) { + return; + } + + $cols = []; + foreach ($ast->groupBy as $expression) { + if ($expression instanceof Column) { + $cols[] = $this->astColumnReferenceToString($expression); + } + } + + if (!empty($cols)) { + $this->groupBy($cols); + } + } + + private function applyAstHaving(Select $ast): void + { + if ($ast->having === null) { + return; + } + + $queries = $this->astWhereToQueries($ast->having); + if (!empty($queries)) { + $this->having($queries); + } + } + + private function applyAstOrderBy(Select $ast): void + { + foreach ($ast->orderBy as $item) { + if ($item->expression instanceof Column) { + $attr = $this->astColumnReferenceToString($item->expression); + + if ($item->direction === OrderDirection::Desc) { + $this->sortDesc($attr, $item->nulls); + } else { + $this->sortAsc($attr, $item->nulls); + } + } else { + $serializer = $this->createAstSerializer(); + $rawExpr = $serializer->serializeExpression($item->expression); + $dir = $item->direction === OrderDirection::Desc ? ' DESC' : ' ASC'; + $this->orderByRaw($rawExpr . $dir); + } + } + } + + private function applyAstLimitOffset(Select $ast): void + { + if ($ast->limit instanceof Literal && ($ast->limit->value !== null)) { + $this->limit((int) $ast->limit->value); + } + + if ($ast->offset instanceof Literal && ($ast->offset->value !== null)) { + $this->offset((int) $ast->offset->value); + } + } + + private function applyAstCtes(Select $ast): void + { + foreach ($ast->ctes as $cte) { + $serializer = $this->createAstSerializer(); + $cteSql = $serializer->serialize($cte->query); + + $this->ctes[] = new CteClause( + $cte->name, + $cteSql, + [], + $cte->recursive, + array_values($cte->columns), + ); + } + } +} diff --git a/src/Query/Builder/Case/Expression.php b/src/Query/Builder/Case/Expression.php new file mode 100644 index 0000000..ed183f9 --- /dev/null +++ b/src/Query/Builder/Case/Expression.php @@ -0,0 +1,159 @@ + */ + private array $whens = []; + + private bool $hasElse = false; + + private mixed $elseValue = null; + + private string $alias = ''; + + /** + * Add a WHEN THEN clause. + * + * The column is quoted as an identifier per dialect. The operator is a + * closed enum of the six comparison operators (Operator::Equal, + * Operator::NotEqual, Operator::LessThan, Operator::LessThanEqual, + * Operator::GreaterThan, Operator::GreaterThanEqual). $value and $then are + * bound as parameters. + */ + public function when(string $column, Operator $operator, mixed $value, mixed $then): static + { + $this->whens[] = new WhenClause( + kind: Kind::Comparison, + column: $column, + operator: $operator, + value: $value, + then: $then, + ); + + return $this; + } + + /** + * Add a WHEN IS NULL THEN clause. + */ + public function whenNull(string $column, mixed $then): static + { + $this->whens[] = new WhenClause( + kind: Kind::Null, + column: $column, + operator: null, + value: null, + then: $then, + ); + + return $this; + } + + /** + * Add a WHEN IS NOT NULL THEN clause. + */ + public function whenNotNull(string $column, mixed $then): static + { + $this->whens[] = new WhenClause( + kind: Kind::NotNull, + column: $column, + operator: null, + value: null, + then: $then, + ); + + return $this; + } + + /** + * Add a WHEN IN (?, ?, ...) THEN clause. + * + * @param list $values + */ + public function whenIn(string $column, array $values, mixed $then): static + { + if ($values === []) { + throw new ValidationException('whenIn() requires at least one value.'); + } + + $this->whens[] = new WhenClause( + kind: Kind::In, + column: $column, + operator: null, + value: null, + then: $then, + values: $values, + ); + + return $this; + } + + /** + * Escape hatch for complex predicates. Caller owns the SQL fragment; bindings + * are bound as-is. The $then value is still bound as a parameter. + * + * @param list $conditionBindings + */ + public function whenRaw(string $condition, mixed $then, array $conditionBindings = []): static + { + $this->whens[] = new WhenClause( + kind: Kind::Raw, + column: null, + operator: null, + value: null, + then: $then, + rawCondition: $condition, + rawBindings: $conditionBindings, + ); + + return $this; + } + + /** + * Set the ELSE value (bound as parameter). + */ + public function else(mixed $value): static + { + $this->hasElse = true; + $this->elseValue = $value; + + return $this; + } + + /** + * Set the alias (builder quotes it per dialect). + */ + public function alias(string $alias): static + { + $this->alias = $alias; + + return $this; + } + + /** + * @return list + */ + public function getWhens(): array + { + return $this->whens; + } + + public function hasElse(): bool + { + return $this->hasElse; + } + + public function getElse(): mixed + { + return $this->elseValue; + } + + public function getAlias(): string + { + return $this->alias; + } +} diff --git a/src/Query/Builder/Case/Kind.php b/src/Query/Builder/Case/Kind.php new file mode 100644 index 0000000..970863b --- /dev/null +++ b/src/Query/Builder/Case/Kind.php @@ -0,0 +1,12 @@ + '=', + self::NotEqual => '!=', + self::LessThan => '<', + self::LessThanEqual => '<=', + self::GreaterThan => '>', + self::GreaterThanEqual => '>=', + }; + } +} diff --git a/src/Query/Builder/Case/WhenClause.php b/src/Query/Builder/Case/WhenClause.php new file mode 100644 index 0000000..ef857cc --- /dev/null +++ b/src/Query/Builder/Case/WhenClause.php @@ -0,0 +1,22 @@ + $values + * @param list $rawBindings + */ + public function __construct( + public Kind $kind, + public ?string $column, + public ?Operator $operator, + public mixed $value, + public mixed $then, + public array $values = [], + public ?string $rawCondition = null, + public array $rawBindings = [], + ) { + } +} diff --git a/src/Query/Builder/ClickHouse.php b/src/Query/Builder/ClickHouse.php new file mode 100644 index 0000000..32290cb --- /dev/null +++ b/src/Query/Builder/ClickHouse.php @@ -0,0 +1,559 @@ + + */ + protected array $prewhereQueries = []; + + protected bool $useFinal = false; + + protected ?float $sampleFraction = null; + + /** @var list */ + protected array $hints = []; + + /** @var ?array{count: int, columns: list} */ + protected ?array $limitByClause = null; + + /** @var list */ + protected array $arrayJoins = []; + + /** @var list */ + protected array $rawJoinClauses = []; + + /** + * 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; + } + + #[\Override] + 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; + } + + #[\Override] + public function tablesample(float $percent, string $method = 'BERNOULLI'): static + { + return $this->sample($percent / 100); + } + + #[\Override] + public function countWhen(string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('count', null, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function sumWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('sum', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function avgWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('avg', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function minWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('min', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function maxWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('max', $column, $condition, $alias, \array_values($bindings)); + } + + /** + * Emit a conditional aggregate using ClickHouse's `-If` combinator. + * + * @param list $bindings + */ + private function aggregateFilter(string $aggregate, ?string $column, string $condition, string $alias, array $bindings): static + { + $arguments = $column === null + ? $condition + : $this->resolveAndWrap($column) . ', ' . $condition; + $expr = $aggregate . 'If(' . $arguments . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, $bindings); + } + + /** + * ClickHouse has no bare STDDEV function. Emit stddevPop (population + * standard deviation) which matches the ISO SQL standard semantics. + */ + #[\Override] + public function stddev(string $attribute, string $alias = ''): static + { + $expr = 'stddevPop(' . $this->resolveAndWrap($attribute) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + /** + * ClickHouse has no bare VARIANCE function. Emit varPop (population + * variance) which matches the ISO SQL standard semantics. + */ + #[\Override] + public function variance(string $attribute, string $alias = ''): static + { + $expr = 'varPop(' . $this->resolveAndWrap($attribute) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + protected function groupConcatExpr(string $column, string $orderBy): string + { + return 'arrayStringConcat(groupArray(' . $column . '), ?)'; + } + + #[\Override] + protected function jsonArrayAggExpr(string $column): string + { + return 'toJSONString(groupArray(' . $column . '))'; + } + + #[\Override] + protected function jsonObjectAggExpr(string $keyColumn, string $valueColumn): string + { + return 'toJSONString(CAST((groupArray(' . $keyColumn . '), groupArray(' . $valueColumn . ')) AS Map(String, String)))'; + } + + #[\Override] + public function withTotals(): static + { + $this->groupByModifier = 'WITH TOTALS'; + + return $this; + } + + #[\Override] + public function withRollup(): static + { + $this->groupByModifier = 'WITH ROLLUP'; + + return $this; + } + + #[\Override] + public function withCube(): static + { + $this->groupByModifier = 'WITH CUBE'; + + return $this; + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->prewhereQueries = []; + $this->useFinal = false; + $this->sampleFraction = null; + $this->hints = []; + $this->limitByClause = null; + $this->arrayJoins = []; + $this->rawJoinClauses = []; + $this->resetGroupByModifier(); + + return $this; + } + + #[\Override] + protected function compileRandom(): string + { + return 'rand()'; + } + + /** + * ClickHouse uses the match(column, pattern) function instead of REGEXP + * + * @param array $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return 'match(' . $attribute . ', ?)'; + } + + /** + * ClickHouse uses startsWith()/endsWith() functions instead of LIKE with wildcards. + * + * @param array $values + */ + #[\Override] + 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 + */ + #[\Override] + 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 + */ + #[\Override] + 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 + */ + #[\Override] + 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) . ')'; + } + + #[\Override] + public function update(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $assignments = $this->compileAssignments(); + + 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 Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function delete(): Statement + { + $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 Statement($sql, $this->bindings, executor: $this->executor); + } + + /** + * ClickHouse does not support subqueries in JOIN ON conditions. + * Force all join filter conditions to WHERE placement. + */ + #[\Override] + protected function resolveJoinFilterPlacement(Placement $requested, bool $isCrossJoin): Placement + { + return Placement::Where; + } + + #[\Override] + protected function buildTableClause(): string + { + $fromSub = $this->fromSubquery; + if ($fromSub !== null) { + $subResult = $fromSub->subquery->build(); + $this->addBindings($subResult->bindings); + + 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->alias !== '') { + $sql .= ' AS ' . $this->quote($this->alias); + } + + return $sql; + } + + /** + * Emit PREWHERE (before reading all columns), ARRAY JOIN, and raw ASOF + * joins between the JOIN section and WHERE. These are structural + * ClickHouse clauses that do not carry bindings. + */ + #[\Override] + protected function buildAfterJoinsClause(ParsedQuery $grouped): string + { + $parts = []; + + if (! empty($this->arrayJoins)) { + $arrayJoinParts = []; + foreach ($this->arrayJoins as $aj) { + $clause = $aj['type'] . ' ' . $this->resolveAndWrap($aj['column']); + if ($aj['alias'] !== '') { + $clause .= ' AS ' . $this->quote($aj['alias']); + } + $arrayJoinParts[] = $clause; + } + $parts[] = \implode(' ', $arrayJoinParts); + } + + if (! empty($this->rawJoinClauses)) { + $parts[] = \implode(' ', $this->rawJoinClauses); + } + + if (! empty($this->prewhereQueries)) { + $clauses = []; + foreach ($this->prewhereQueries as $query) { + $clauses[] = $this->compileFilter($query); + } + $parts[] = 'PREWHERE ' . \implode(' AND ', $clauses); + } + + return \implode(' ', $parts); + } + + /** + * Emit the ClickHouse GROUP BY modifier (WITH TOTALS / WITH ROLLUP / + * WITH CUBE) between GROUP BY and HAVING. + */ + #[\Override] + protected function buildAfterGroupByClause(): string + { + return $this->groupByModifier ?? ''; + } + + /** + * Emit LIMIT BY between ORDER BY and LIMIT. The count binding is added + * here so ordering is naturally correct: LIMIT BY binding precedes the + * outer LIMIT binding emitted by the parent. + */ + #[\Override] + protected function buildAfterOrderByClause(): string + { + if ($this->limitByClause === null) { + return ''; + } + + $cols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $this->limitByClause['columns'] + ); + + $this->addBinding($this->limitByClause['count']); + + return 'LIMIT ? BY ' . \implode(', ', $cols); + } + + /** + * Emit the trailing SETTINGS fragment from registered hints. + */ + #[\Override] + protected function buildSettingsClause(): string + { + if (empty($this->hints)) { + return ''; + } + + return 'SETTINGS ' . \implode(', ', $this->hints); + } +} diff --git a/src/Query/Builder/ClickHouse/AsofOperator.php b/src/Query/Builder/ClickHouse/AsofOperator.php new file mode 100644 index 0000000..edb87b1 --- /dev/null +++ b/src/Query/Builder/ClickHouse/AsofOperator.php @@ -0,0 +1,11 @@ +'; + case GreaterThanEqual = '>='; +} diff --git a/src/Query/Builder/ColumnPredicate.php b/src/Query/Builder/ColumnPredicate.php new file mode 100644 index 0000000..78fd01d --- /dev/null +++ b/src/Query/Builder/ColumnPredicate.php @@ -0,0 +1,13 @@ + $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..d4fb4b6 --- /dev/null +++ b/src/Query/Builder/CteClause.php @@ -0,0 +1,19 @@ + $bindings + * @param list $columns + */ + public function __construct( + public string $name, + public string $query, + public array $bindings, + public bool $recursive, + public array $columns = [], + ) { + } +} 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/BitwiseAggregates.php b/src/Query/Builder/Feature/BitwiseAggregates.php new file mode 100644 index 0000000..8782cb6 --- /dev/null +++ b/src/Query/Builder/Feature/BitwiseAggregates.php @@ -0,0 +1,12 @@ + $columns + */ + public function with(string $name, Builder $query, array $columns = []): static; + + /** + * @param list $columns + */ + public function withRecursive(string $name, Builder $query, array $columns = []): static; + + /** + * @param list $columns + */ + public function withRecursiveSeedStep(string $name, Builder $seed, Builder $step, array $columns = []): static; +} diff --git a/src/Query/Builder/Feature/ClickHouse/ApproximateAggregates.php b/src/Query/Builder/Feature/ClickHouse/ApproximateAggregates.php new file mode 100644 index 0000000..f84d335 --- /dev/null +++ b/src/Query/Builder/Feature/ClickHouse/ApproximateAggregates.php @@ -0,0 +1,45 @@ + $levels Quantile levels in [0, 1]. Must be non-empty. + */ + public function quantiles(array $levels, string $column, string $alias = ''): static; + + public function quantileExact(float $level, string $column, string $alias = ''): static; + + public function median(string $column, string $alias = ''): static; + + public function uniq(string $column, string $alias = ''): static; + + public function uniqExact(string $column, string $alias = ''): static; + + public function uniqCombined(string $column, string $alias = ''): static; + + public function argMin(string $valueColumn, string $argColumn, string $alias = ''): static; + + public function argMax(string $valueColumn, string $argColumn, string $alias = ''): static; + + public function topK(int $k, string $column, string $alias = ''): static; + + public function topKWeighted(int $k, string $column, string $weightColumn, string $alias = ''): static; + + public function anyValue(string $column, string $alias = ''): static; + + public function anyLastValue(string $column, string $alias = ''): static; + + public function groupUniqArray(string $column, string $alias = ''): static; + + public function groupArrayMovingAvg(string $column, string $alias = ''): static; + + public function groupArrayMovingSum(string $column, string $alias = ''): static; +} diff --git a/src/Query/Builder/Feature/ClickHouse/ArrayJoins.php b/src/Query/Builder/Feature/ClickHouse/ArrayJoins.php new file mode 100644 index 0000000..127250d --- /dev/null +++ b/src/Query/Builder/Feature/ClickHouse/ArrayJoins.php @@ -0,0 +1,16 @@ + $equiPairs Equi-join columns as ['leftCol' => 'rightCol']. Must be non-empty. + * @param string $leftInequality Left column of the inequality (e.g. 'trades.ts'). + * @param AsofOperator $operator Inequality operator. + * @param string $rightInequality Right column of the inequality (e.g. 'quotes.ts'). + */ + public function asofJoin( + string $table, + array $equiPairs, + string $leftInequality, + AsofOperator $operator, + string $rightInequality, + string $alias = '', + ): static; + + /** + * ClickHouse ASOF LEFT JOIN — left-join variant; unmatched left rows appear + * with NULLs on the right side. + * + * @param array $equiPairs + */ + public function asofLeftJoin( + string $table, + array $equiPairs, + string $leftInequality, + AsofOperator $operator, + string $rightInequality, + string $alias = '', + ): static; +} diff --git a/src/Query/Builder/Feature/ClickHouse/LimitBy.php b/src/Query/Builder/Feature/ClickHouse/LimitBy.php new file mode 100644 index 0000000..4c06ba1 --- /dev/null +++ b/src/Query/Builder/Feature/ClickHouse/LimitBy.php @@ -0,0 +1,14 @@ + $columns + */ + public function limitBy(int $count, array $columns): static; +} diff --git a/src/Query/Builder/Feature/ClickHouse/WithFill.php b/src/Query/Builder/Feature/ClickHouse/WithFill.php new file mode 100644 index 0000000..6535a35 --- /dev/null +++ b/src/Query/Builder/Feature/ClickHouse/WithFill.php @@ -0,0 +1,11 @@ + $row + */ + public function set(array $row): static; + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + public function onConflict(array $keys, array $updateColumns): static; + + public function insert(): Statement; + + public function insertDefaultValues(): Statement; + + /** + * @param list $columns + */ + public function fromSelect(array $columns, Builder $source): static; + + public function insertSelect(): Statement; +} diff --git a/src/Query/Builder/Feature/Joins.php b/src/Query/Builder/Feature/Joins.php new file mode 100644 index 0000000..71e5e8e --- /dev/null +++ b/src/Query/Builder/Feature/Joins.php @@ -0,0 +1,23 @@ + $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; + + /** + * Set a JSON path to a value (or NULL to clear). + * + * Dialect mapping: + * MySQL: JSON_SET(, , ) + * PostgreSQL: jsonb_set(, , to_jsonb(), true) + * + * The must start with '$' in MySQL / JSONPath style. The implementation + * translates to PG's text[] form for jsonb_set. + */ + public function setJsonPath(string $column, string $path, mixed $value): static; +} diff --git a/src/Query/Builder/Feature/LateralJoins.php b/src/Query/Builder/Feature/LateralJoins.php new file mode 100644 index 0000000..41193d0 --- /dev/null +++ b/src/Query/Builder/Feature/LateralJoins.php @@ -0,0 +1,13 @@ + $columns + */ + public function returning(array $columns = ['*']): static; +} diff --git a/src/Query/Builder/Feature/MongoDB/ArrayPushModifiers.php b/src/Query/Builder/Feature/MongoDB/ArrayPushModifiers.php new file mode 100644 index 0000000..c464db0 --- /dev/null +++ b/src/Query/Builder/Feature/MongoDB/ArrayPushModifiers.php @@ -0,0 +1,12 @@ + $values + * @param array|null $sort + */ + public function pushEach(string $field, array $values, ?int $position = null, ?int $slice = null, ?array $sort = null): static; +} diff --git a/src/Query/Builder/Feature/MongoDB/AtlasSearch.php b/src/Query/Builder/Feature/MongoDB/AtlasSearch.php new file mode 100644 index 0000000..3334ca1 --- /dev/null +++ b/src/Query/Builder/Feature/MongoDB/AtlasSearch.php @@ -0,0 +1,22 @@ + $searchDefinition + */ + public function search(array $searchDefinition, ?string $index = null): static; + + /** + * @param array $searchDefinition + */ + public function searchMeta(array $searchDefinition, ?string $index = null): static; + + /** + * @param array $queryVector + * @param array|null $filter + */ + public function vectorSearch(string $path, array $queryVector, int $numCandidates, int $limit, ?string $index = null, ?array $filter = null): static; +} diff --git a/src/Query/Builder/Feature/MongoDB/ConditionalArrayUpdates.php b/src/Query/Builder/Feature/MongoDB/ConditionalArrayUpdates.php new file mode 100644 index 0000000..8ce0224 --- /dev/null +++ b/src/Query/Builder/Feature/MongoDB/ConditionalArrayUpdates.php @@ -0,0 +1,11 @@ + $condition + */ + public function arrayFilter(string $identifier, array $condition): static; +} diff --git a/src/Query/Builder/Feature/MongoDB/FieldUpdates.php b/src/Query/Builder/Feature/MongoDB/FieldUpdates.php new file mode 100644 index 0000000..a71bb7a --- /dev/null +++ b/src/Query/Builder/Feature/MongoDB/FieldUpdates.php @@ -0,0 +1,25 @@ + $values + */ + public function pullAll(string $field, array $values): static; + + public function updateMin(string $field, mixed $value): static; + + public function updateMax(string $field, mixed $value): static; + + public function currentDate(string $field, string $type = 'date'): static; +} diff --git a/src/Query/Builder/Feature/MongoDB/PipelineStages.php b/src/Query/Builder/Feature/MongoDB/PipelineStages.php new file mode 100644 index 0000000..0ff9dbc --- /dev/null +++ b/src/Query/Builder/Feature/MongoDB/PipelineStages.php @@ -0,0 +1,37 @@ + $boundaries + * @param array> $output + */ + public function bucket(string $groupBy, array $boundaries, ?string $defaultBucket = null, array $output = []): static; + + /** + * @param array> $output + */ + public function bucketAuto(string $groupBy, int $buckets, array $output = []): static; + + /** + * @param array $facets + */ + public function facet(array $facets): static; + + public function graphLookup(string $from, string $startWith, string $connectFromField, string $connectToField, string $as, ?int $maxDepth = null, ?string $depthField = null): static; + + /** + * @param array|null $on + * @param array|null $whenMatched + * @param array|null $whenNotMatched + */ + public function mergeIntoCollection(string $collection, ?array $on = null, ?array $whenMatched = null, ?array $whenNotMatched = null): static; + + public function outputToCollection(string $collection, ?string $database = null): static; + + public function replaceRoot(string $newRootExpression): static; +} diff --git a/src/Query/Builder/Feature/PostgreSQL/AggregateFilter.php b/src/Query/Builder/Feature/PostgreSQL/AggregateFilter.php new file mode 100644 index 0000000..8d31da3 --- /dev/null +++ b/src/Query/Builder/Feature/PostgreSQL/AggregateFilter.php @@ -0,0 +1,13 @@ + $bindings + */ + public function selectAggregateFilter(string $aggregateExpr, string $filterCondition, string $alias = '', array $bindings = []): static; +} diff --git a/src/Query/Builder/Feature/PostgreSQL/DistinctOn.php b/src/Query/Builder/Feature/PostgreSQL/DistinctOn.php new file mode 100644 index 0000000..0333736 --- /dev/null +++ b/src/Query/Builder/Feature/PostgreSQL/DistinctOn.php @@ -0,0 +1,11 @@ + $columns + */ + public function distinctOn(array $columns): static; +} diff --git a/src/Query/Builder/Feature/PostgreSQL/LockingOf.php b/src/Query/Builder/Feature/PostgreSQL/LockingOf.php new file mode 100644 index 0000000..1ca5fbd --- /dev/null +++ b/src/Query/Builder/Feature/PostgreSQL/LockingOf.php @@ -0,0 +1,10 @@ +)` — the most frequent value. + * + * Adds to SELECT. Column is quoted per dialect. + */ + public function mode(string $column, string $alias = ''): static; + + public function percentileCont(float $fraction, string $orderColumn, string $alias = ''): static; + + public function percentileDisc(float $fraction, string $orderColumn, string $alias = ''): static; +} diff --git a/src/Query/Builder/Feature/PostgreSQL/Returning.php b/src/Query/Builder/Feature/PostgreSQL/Returning.php new file mode 100644 index 0000000..a683224 --- /dev/null +++ b/src/Query/Builder/Feature/PostgreSQL/Returning.php @@ -0,0 +1,11 @@ + $columns + */ + public function returning(array $columns = ['*']): static; +} diff --git a/src/Query/Builder/Feature/PostgreSQL/VectorSearch.php b/src/Query/Builder/Feature/PostgreSQL/VectorSearch.php new file mode 100644 index 0000000..74f9dc3 --- /dev/null +++ b/src/Query/Builder/Feature/PostgreSQL/VectorSearch.php @@ -0,0 +1,15 @@ + $vector The query vector + */ + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static; +} diff --git a/src/Query/Builder/Feature/Selects.php b/src/Query/Builder/Feature/Selects.php new file mode 100644 index 0000000..5eceb84 --- /dev/null +++ b/src/Query/Builder/Feature/Selects.php @@ -0,0 +1,63 @@ + $columns + * @param list $bindings + */ + public function select(string|array $columns, 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 selectCast(string $column, string $type, string $alias = ''): static; + + public function sortAsc(string $attribute, ?NullsPosition $nulls = null): static; + + public function sortDesc(string $attribute, ?NullsPosition $nulls = null): static; + + public function sortRandom(): static; + + public function limit(int $value): static; + + public function offset(int $value): static; + + public function fetch(int $count, bool $withTies = false): 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(): Statement; + + public function toRawSql(): string; + + /** + * @return list + */ + public function getBindings(): array; + + public function reset(): static; +} diff --git a/src/Query/Builder/Feature/Sequences.php b/src/Query/Builder/Feature/Sequences.php new file mode 100644 index 0000000..acb6402 --- /dev/null +++ b/src/Query/Builder/Feature/Sequences.php @@ -0,0 +1,18 @@ +) as a select expression — advances the named + * sequence and returns the next value. + */ + public function nextVal(string $sequence, string $alias = ''): static; + + /** + * Emit CURRVAL() as a select expression — returns the current + * (session-local) value of the named sequence. + */ + public function currVal(string $sequence, string $alias = ''): 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/StatisticalAggregates.php b/src/Query/Builder/Feature/StatisticalAggregates.php new file mode 100644 index 0000000..d9bfb5d --- /dev/null +++ b/src/Query/Builder/Feature/StatisticalAggregates.php @@ -0,0 +1,18 @@ +|null $orderBy Columns for ORDER BY (prefix with - for DESC) + */ + public function groupConcat(string $column, string $separator = ',', string $alias = '', ?array $orderBy = null): static; + + /** + * Aggregate values into a JSON array. + * Compiles to JSON_ARRAYAGG (MySQL) or JSON_AGG (PostgreSQL). + */ + public function jsonArrayAgg(string $column, string $alias = ''): static; + + /** + * Aggregate key-value pairs into a JSON object. + * Compiles to JSON_OBJECTAGG (MySQL) or JSON_OBJECT_AGG (PostgreSQL). + */ + public function jsonObjectAgg(string $keyColumn, string $valueColumn, string $alias = ''): static; +} diff --git a/src/Query/Builder/Feature/TableSampling.php b/src/Query/Builder/Feature/TableSampling.php new file mode 100644 index 0000000..0238f2f --- /dev/null +++ b/src/Query/Builder/Feature/TableSampling.php @@ -0,0 +1,8 @@ + $row + */ + public function set(array $row): static; + + /** + * @param list $bindings + */ + public function setRaw(string $column, string $expression, array $bindings = []): static; + + public function update(): Statement; +} diff --git a/src/Query/Builder/Feature/Upsert.php b/src/Query/Builder/Feature/Upsert.php new file mode 100644 index 0000000..ce9f586 --- /dev/null +++ b/src/Query/Builder/Feature/Upsert.php @@ -0,0 +1,14 @@ +|null $partitionBy Columns for PARTITION BY + * @param list|null $orderBy Columns for ORDER BY (prefix with - for DESC) + * @param string|null $windowName Named window to reference instead of inline OVER (...) + */ + public function selectWindow(string $function, string $alias, ?array $partitionBy = null, ?array $orderBy = null, ?string $windowName = null, ?WindowFrame $frame = null): static; + + /** + * Define a named window. + * + * @param list|null $partitionBy Columns for PARTITION BY + * @param list|null $orderBy Columns for ORDER BY (prefix with - for DESC) + */ + public function window(string $name, ?array $partitionBy = null, ?array $orderBy = null, ?WindowFrame $frame = null): static; +} diff --git a/src/Query/Builder/JoinBuilder.php b/src/Query/Builder/JoinBuilder.php new file mode 100644 index 0000000..376ab88 --- /dev/null +++ b/src/Query/Builder/JoinBuilder.php @@ -0,0 +1,83 @@ +', '<=', '>=', '<>']; + + /** @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 (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $left)) { + throw new ValidationException('Invalid column name: ' . $left); + } + + if (!\preg_match('/^[a-zA-Z_][a-zA-Z0-9_.]*$/', $right)) { + throw new ValidationException('Invalid column name: ' . $right); + } + + 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/MariaDB.php b/src/Query/Builder/MariaDB.php new file mode 100644 index 0000000..a9ba842 --- /dev/null +++ b/src/Query/Builder/MariaDB.php @@ -0,0 +1,131 @@ +appendReturning(parent::insert()); + } + + #[\Override] + public function insertOrIgnore(): Statement + { + return $this->appendReturning(parent::insertOrIgnore()); + } + + #[\Override] + public function update(): Statement + { + return $this->appendReturning(parent::update()); + } + + #[\Override] + public function delete(): Statement + { + return $this->appendReturning(parent::delete()); + } + + #[\Override] + public function upsert(): Statement + { + if (! empty($this->returningColumns)) { + throw new ValidationException( + 'MariaDB does not support RETURNING with ON DUPLICATE KEY UPDATE. ' + . 'Call returning([]) to clear before upsert(), or use a separate update() statement.' + ); + } + + return parent::upsert(); + } + + #[\Override] + public function upsertSelect(): Statement + { + if (! empty($this->returningColumns)) { + throw new ValidationException( + 'MariaDB does not support RETURNING with ON DUPLICATE KEY UPDATE. ' + . 'Call returning([]) to clear before upsert(), or use a separate update() statement.' + ); + } + + return parent::upsertSelect(); + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->resetReturning(); + + return $this; + } + + #[\Override] + protected function compileSpatialFilter(Method $method, string $attribute, Query $query): string + { + if (\in_array($method, [Method::DistanceLessThan, Method::DistanceGreaterThan, Method::DistanceEqual, Method::DistanceNotEqual], true)) { + $values = $query->getValues(); + /** @var array{0: string|array, 1: float, 2: bool} $tuple */ + $tuple = $values[0]; + $filter = SpatialDistanceFilter::fromTuple($tuple); + + if ($filter->meters && $query->getAttributeType() !== '') { + $wkt = \is_array($filter->geometry) ? $this->geometryToWkt($filter->geometry) : $filter->geometry; + $pos = \strpos($wkt, '('); + $wktType = $pos !== false ? \strtolower(\trim(\substr($wkt, 0, $pos))) : ''; + $attrType = \strtolower($query->getAttributeType()); + + if ($wktType !== ColumnType::Point->value || $attrType !== ColumnType::Point->value) { + throw new ValidationException('Distance in meters is not supported between ' . $attrType . ' and ' . $wktType); + } + } + } + + return parent::compileSpatialFilter($method, $attribute, $query); + } + + #[\Override] + protected function geomFromText(int $srid): string + { + return "ST_GeomFromText(?, {$srid})"; + } + + #[\Override] + protected function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string|array, 1: float, 2: bool} $tuple */ + $tuple = $values[0]; + $filter = SpatialDistanceFilter::fromTuple($tuple); + $wkt = \is_array($filter->geometry) ? $this->geometryToWkt($filter->geometry) : $filter->geometry; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + if ($filter->meters) { + return 'ST_DISTANCE_SPHERE(' . $attribute . ', ST_GeomFromText(?, 4326)) ' . $operator . ' ?'; + } + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?, 4326)) ' . $operator . ' ?'; + } +} diff --git a/src/Query/Builder/MergeClause.php b/src/Query/Builder/MergeClause.php new file mode 100644 index 0000000..7b7aa96 --- /dev/null +++ b/src/Query/Builder/MergeClause.php @@ -0,0 +1,16 @@ + $bindings + */ + public function __construct( + public string $action, + public bool $matched, + public array $bindings = [], + ) { + } +} diff --git a/src/Query/Builder/MongoDB.php b/src/Query/Builder/MongoDB.php new file mode 100644 index 0000000..dd62163 --- /dev/null +++ b/src/Query/Builder/MongoDB.php @@ -0,0 +1,1783 @@ +value. + * Each entry maps field-name => payload (value, modifier dict, rename target, etc.). + * + * @var array> + */ + protected array $updateOperations = []; + + protected ?string $textSearchTerm = null; + + protected ?float $sampleSize = null; + + /** @var list> */ + protected array $arrayFilters = []; + + /** @var array|null */ + protected ?array $bucketStage = null; + + /** @var array|null */ + protected ?array $bucketAutoStage = null; + + /** @var array>, bindings: list}>|null */ + protected ?array $facetStages = null; + + /** @var array|null */ + protected ?array $graphLookupStage = null; + + /** @var array|null */ + protected ?array $mergeStage = null; + + /** @var array|null */ + protected ?array $outStage = null; + + protected ?string $replaceRootExpr = null; + + /** @var array|null */ + protected ?array $searchStage = null; + + /** @var array|null */ + protected ?array $searchMetaStage = null; + + /** @var array|null */ + protected ?array $vectorSearchStage = null; + + /** @var string|array|null */ + protected string|array|null $indexHint = null; + + #[\Override] + protected function quote(string $identifier): string + { + return $identifier; + } + + #[\Override] + protected function compileRandom(): string + { + return '$rand'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEX ?'; + } + + private function validateFieldName(string $field): void + { + if ($field === '' || \str_starts_with($field, '$')) { + throw new ValidationException('Invalid MongoDB field name: ' . $field); + } + } + + private function setUpdateField(UpdateOperator $operator, string $field, mixed $payload): void + { + $this->updateOperations[$operator->value][$field] = $payload; + } + + #[\Override] + public function set(array $row): static + { + foreach (\array_keys($row) as $field) { + $this->validateFieldName((string) $field); + } + + return parent::set($row); + } + + public function push(string $field, mixed $value): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Push, $field, $value); + + return $this; + } + + public function pull(string $field, mixed $value): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Pull, $field, $value); + + return $this; + } + + public function addToSet(string $field, mixed $value): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::AddToSet, $field, $value); + + return $this; + } + + public function increment(string $field, int|float $amount = 1): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Increment, $field, $amount); + + return $this; + } + + public function unsetFields(string ...$fields): static + { + foreach ($fields as $field) { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Unset, $field, ''); + } + + return $this; + } + + #[\Override] + public function filterSearch(string $attribute, string $value): static + { + $this->textSearchTerm = $value; + + return $this; + } + + #[\Override] + public function filterNotSearch(string $attribute, string $value): static + { + throw new UnsupportedException('MongoDB does not support negated full-text search.'); + } + + #[\Override] + public function tablesample(float $percent, string $method = 'BERNOULLI'): static + { + $this->sampleSize = $percent; + + return $this; + } + + /** + * @param string|array $hint + */ + public function hint(string|array $hint): static + { + $this->indexHint = $hint; + + return $this; + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->updateOperations = []; + $this->textSearchTerm = null; + $this->sampleSize = null; + $this->arrayFilters = []; + $this->bucketStage = null; + $this->bucketAutoStage = null; + $this->facetStages = null; + $this->graphLookupStage = null; + $this->mergeStage = null; + $this->outStage = null; + $this->replaceRootExpr = null; + $this->searchStage = null; + $this->searchMetaStage = null; + $this->vectorSearchStage = null; + $this->indexHint = null; + + return $this; + } + + /** + * @param list $bindings + */ + #[\Override] + public function whereRaw(string $expression, array $bindings = []): static + { + throw new ValidationException('whereRaw() is not supported on the MongoDB builder.'); + } + + #[\Override] + public function whereColumn(string $left, string $operator, string $right): static + { + throw new ValidationException('whereColumn() is not supported on the MongoDB builder.'); + } + + #[\Override] + public function build(): Statement + { + $this->bindings = []; + + foreach ($this->beforeBuildCallbacks as $callback) { + $callback($this); + } + + $this->validateTable(); + + $grouped = Query::groupByType($this->pendingQueries); + + if ($this->needsAggregation($grouped)) { + $result = $this->buildAggregate($grouped); + } else { + $result = $this->buildFind($grouped); + } + + foreach ($this->afterBuildCallbacks as $callback) { + $result = $callback($result); + } + + return $result; + } + + #[\Override] + public function insert(): Statement + { + $this->bindings = []; + $this->validateTable(); + $this->validateRows('insert'); + + $documents = []; + foreach ($this->rows as $row) { + $doc = []; + foreach ($row as $col => $value) { + $this->addBinding($value); + $doc[$col] = '?'; + } + $documents[] = $doc; + } + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::InsertMany->value, + 'documents' => $documents, + ]; + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + executor: $this->executor, + ); + } + + #[\Override] + public function update(): Statement + { + $this->bindings = []; + $this->validateTable(); + + if (! empty($this->rawSets) || ! empty($this->caseSets) || ! empty($this->conflictRawSets)) { + throw new UnsupportedException( + 'setRaw()/setCase() are not supported on the MongoDB builder. ' + . 'Use typed set()/updateInc/updatePush/etc. or raw pipeline stages instead.' + ); + } + + $grouped = Query::groupByType($this->pendingQueries); + $filter = $this->buildFilter($grouped); + + $update = $this->buildUpdate(); + + if (empty($update)) { + throw new ValidationException('No update operations specified. Call set() before update().'); + } + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::UpdateMany->value, + 'filter' => ! empty($filter) ? $filter : new stdClass(), + 'update' => $update, + ]; + + if (! empty($this->arrayFilters)) { + $operation['options'] = ['arrayFilters' => $this->arrayFilters]; + } + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + executor: $this->executor, + ); + } + + #[\Override] + public function delete(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $grouped = Query::groupByType($this->pendingQueries); + $filter = $this->buildFilter($grouped); + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::DeleteMany->value, + 'filter' => ! empty($filter) ? $filter : new stdClass(), + ]; + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + executor: $this->executor, + ); + } + + #[\Override] + public function upsert(): Statement + { + $this->bindings = []; + $this->validateTable(); + $this->validateRows('upsert'); + + $row = $this->rows[0]; + + $filter = []; + foreach ($this->conflictKeys as $key) { + if (! isset($row[$key])) { + throw new ValidationException("Conflict key '{$key}' not found in row data."); + } + $this->addBinding($row[$key]); + $filter[$key] = '?'; + } + + $updateColumns = $this->conflictUpdateColumns; + if (empty($updateColumns)) { + $updateColumns = \array_diff(\array_keys($row), $this->conflictKeys); + } + + $setDoc = []; + foreach ($updateColumns as $col) { + $this->addBinding($row[$col] ?? null); + $setDoc[$col] = '?'; + } + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::UpdateOne->value, + 'filter' => $filter, + 'update' => [UpdateOperator::Set->value => $setDoc], + 'options' => ['upsert' => true], + ]; + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + executor: $this->executor, + ); + } + + #[\Override] + public function insertOrIgnore(): Statement + { + // Build the operation descriptor directly instead of round-tripping through + // insert() + json_decode(): a round-trip would coerce empty stdClass values + // (used elsewhere to encode `{}`) into `[]`. + $this->bindings = []; + $this->validateTable(); + $this->validateRows('insert'); + + $documents = []; + foreach ($this->rows as $row) { + $document = []; + foreach ($row as $column => $value) { + $this->addBinding($value); + $document[$column] = '?'; + } + $documents[] = $document; + } + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::InsertMany->value, + 'documents' => $documents, + 'options' => ['ordered' => false], + ]; + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + executor: $this->executor, + ); + } + + #[\Override] + public function upsertSelect(): Statement + { + throw new UnsupportedException('upsertSelect() is not supported in MongoDB builder.'); + } + + private function needsAggregation(ParsedQuery $grouped): bool + { + if (! empty(Query::getByType($this->pendingQueries, [Method::OrderRandom], false))) { + return true; + } + + return $this->hasPipelineOnlyFeature($grouped) + || $this->hasSubqueryFeature($grouped); + } + + private function hasPipelineOnlyFeature(ParsedQuery $grouped): bool + { + return ! empty($grouped->aggregations) + || ! empty($grouped->groupBy) + || ! empty($grouped->having) + || ! empty($this->windowSelects) + || $grouped->distinct + || $this->textSearchTerm !== null + || $this->sampleSize !== null + || $this->bucketStage !== null + || $this->bucketAutoStage !== null + || $this->facetStages !== null + || $this->graphLookupStage !== null + || $this->mergeStage !== null + || $this->outStage !== null + || $this->replaceRootExpr !== null + || $this->searchStage !== null + || $this->searchMetaStage !== null + || $this->vectorSearchStage !== null; + } + + private function hasSubqueryFeature(ParsedQuery $grouped): bool + { + return ! empty($grouped->joins) + || ! empty($this->unions) + || ! empty($this->ctes) + || ! empty($this->subSelects) + || ! empty($this->rawSelects) + || ! empty($this->lateralJoins) + || ! empty($this->whereInSubqueries) + || ! empty($this->existsSubqueries); + } + + private function buildFind(ParsedQuery $grouped): Statement + { + $filter = $this->buildFilter($grouped); + $projection = $this->buildProjection($grouped); + $sort = $this->buildSort(); + + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::Find->value, + ]; + + if (! empty($filter)) { + $operation['filter'] = $filter; + } + + if (! empty($projection)) { + $operation['projection'] = $projection; + } + + if (! empty($sort)) { + $operation['sort'] = $sort; + } + + if ($grouped->offset !== null) { + $operation['skip'] = $grouped->offset; + } + + if ($grouped->limit !== null) { + $operation['limit'] = $grouped->limit; + } + + if ($this->indexHint !== null) { + $operation['hint'] = $this->indexHint; + } + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + readOnly: true, + executor: $this->executor, + ); + } + + private function buildAggregate(ParsedQuery $grouped): Statement + { + $pipeline = []; + + // $searchMeta short-circuits: returns metadata only, no further stages. + if ($this->searchMetaStage !== null) { + $pipeline[] = [PipelineStage::SearchMeta->value => $this->searchMetaStage]; + + return $this->buildAggregateStatement($pipeline); + } + + $this->appendSearchStages($pipeline); + $this->appendJoinStages($pipeline, $grouped); + $this->appendFilterStages($pipeline, $grouped); + $this->appendGroupingStages($pipeline, $grouped); + $this->appendWindowStages($pipeline); + $this->appendProjectionStage($pipeline, $grouped); + $this->appendUnionStages($pipeline); + $this->appendOrderingStages($pipeline); + $this->appendPaginationStages($pipeline, $grouped); + $this->appendOutputStages($pipeline); + + return $this->buildAggregateStatement($pipeline); + } + + /** + * @param list> $pipeline + */ + private function buildAggregateStatement(array $pipeline): Statement + { + $operation = [ + 'collection' => $this->table, + 'operation' => Operation::Aggregate->value, + 'pipeline' => $pipeline, + ]; + + if ($this->indexHint !== null) { + $operation['hint'] = $this->indexHint; + } + + return new Statement( + \json_encode($operation, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $this->bindings, + readOnly: true, + executor: $this->executor, + ); + } + + /** + * Atlas $search, $vectorSearch, full-text $match, and $sample stages. + * Atlas search stages must be first in the pipeline. + * + * @param list> $pipeline + */ + private function appendSearchStages(array &$pipeline): void + { + if ($this->searchStage !== null) { + $pipeline[] = [PipelineStage::Search->value => $this->searchStage]; + } + + if ($this->vectorSearchStage !== null) { + $pipeline[] = [PipelineStage::VectorSearch->value => $this->vectorSearchStage]; + } + + if ($this->textSearchTerm !== null) { + $this->addBinding($this->textSearchTerm); + $pipeline[] = [PipelineStage::Match->value => [PipelineStage::Text->value => ['$search' => '?']]]; + } + + if ($this->sampleSize !== null) { + $size = (int) \ceil($this->sampleSize); + $pipeline[] = [PipelineStage::Sample->value => ['size' => $size]]; + } + } + + /** + * $lookup stages for JOINs and $graphLookup for recursive traversal. + * + * @param list> $pipeline + */ + private function appendJoinStages(array &$pipeline, ParsedQuery $grouped): void + { + foreach ($grouped->joins as $joinQuery) { + foreach ($this->buildJoinStages($joinQuery) as $stage) { + $pipeline[] = $stage; + } + } + + if ($this->graphLookupStage !== null) { + $pipeline[] = [PipelineStage::GraphLookup->value => $this->graphLookupStage]; + } + } + + /** + * Subquery $lookups (WHERE IN / EXISTS) and the main $match stage for WHERE filters. + * + * @param list> $pipeline + */ + private function appendFilterStages(array &$pipeline, ParsedQuery $grouped): void + { + foreach ($this->whereInSubqueries as $idx => $sub) { + foreach ($this->buildWhereInSubquery($sub, $idx) as $stage) { + $pipeline[] = $stage; + } + } + + foreach ($this->existsSubqueries as $idx => $sub) { + foreach ($this->buildExistsSubquery($sub, $idx) as $stage) { + $pipeline[] = $stage; + } + } + + $filter = $this->buildFilter($grouped); + if (! empty($filter)) { + $pipeline[] = [PipelineStage::Match->value => $filter]; + } + } + + /** + * DISTINCT, $bucket/$bucketAuto, $group (with reshape $project), + * $replaceRoot, and HAVING $match. + * + * @param list> $pipeline + */ + private function appendGroupingStages(array &$pipeline, ParsedQuery $grouped): void + { + if ($grouped->distinct && empty($grouped->groupBy) && empty($grouped->aggregations)) { + foreach ($this->buildDistinct($grouped) as $stage) { + $pipeline[] = $stage; + } + } + + if ($this->bucketStage !== null) { + $pipeline[] = [PipelineStage::Bucket->value => $this->bucketStage]; + } elseif ($this->bucketAutoStage !== null) { + $pipeline[] = [PipelineStage::BucketAuto->value => $this->bucketAutoStage]; + } elseif (! empty($grouped->groupBy) || ! empty($grouped->aggregations)) { + $pipeline[] = [PipelineStage::Group->value => $this->buildGroup($grouped)]; + + $reshape = $this->buildProjectFromGroup($grouped); + if (! empty($reshape)) { + $pipeline[] = [PipelineStage::Project->value => $reshape]; + } + } + + if ($this->replaceRootExpr !== null) { + $pipeline[] = [PipelineStage::ReplaceRoot->value => ['newRoot' => $this->replaceRootExpr]]; + } + + if (! empty($grouped->having) || ! empty($this->rawHavings)) { + $havingFilter = $this->buildHaving($grouped); + if (! empty($havingFilter)) { + $pipeline[] = [PipelineStage::Match->value => $havingFilter]; + } + } + } + + /** + * $setWindowFields stages for window functions. + * + * @param list> $pipeline + */ + private function appendWindowStages(array &$pipeline): void + { + if (empty($this->windowSelects)) { + return; + } + + foreach ($this->buildWindowFunctions() as $stage) { + $pipeline[] = $stage; + } + } + + /** + * SELECT $project stage (only applies when no group/distinct/bucket stage + * has already reshaped the document). + * + * @param list> $pipeline + */ + private function appendProjectionStage(array &$pipeline, ParsedQuery $grouped): void + { + if (! empty($grouped->groupBy) || ! empty($grouped->aggregations) || $grouped->distinct) { + return; + } + if ($this->bucketStage !== null || $this->bucketAutoStage !== null) { + return; + } + + $projection = $this->buildProjection($grouped); + if (empty($projection)) { + return; + } + + // Preserve window function output aliases in the projection. + foreach ($this->windowSelects as $win) { + $projection[$win->alias] = 1; + } + $pipeline[] = [PipelineStage::Project->value => $projection]; + } + + /** + * $facet stage and $unionWith stages for UNIONs. + * + * @param list> $pipeline + */ + private function appendUnionStages(array &$pipeline): void + { + if ($this->facetStages !== null) { + $facetDoc = []; + foreach ($this->facetStages as $name => $data) { + $facetDoc[$name] = $data['pipeline']; + foreach ($data['bindings'] as $binding) { + $this->addBinding($binding); + } + } + $pipeline[] = [PipelineStage::Facet->value => $facetDoc]; + } + + foreach ($this->unions as $union) { + /** @var array|null $subOp */ + $subOp = \json_decode($union->query, true); + if ($subOp === null) { + throw new UnsupportedException('Cannot parse union query for MongoDB.'); + } + + $subPipeline = $this->operationToPipeline($subOp); + $unionWith = ['coll' => $subOp['collection']]; + if (! empty($subPipeline)) { + $unionWith['pipeline'] = $subPipeline; + } + $pipeline[] = [PipelineStage::UnionWith->value => $unionWith]; + $this->addBindings($union->bindings); + } + } + + /** + * ORDER BY $sort stage (including random ordering via $addFields + $unset). + * + * @param list> $pipeline + */ + private function appendOrderingStages(array &$pipeline): void + { + $hasRandomOrder = ! empty(Query::getByType($this->pendingQueries, [Method::OrderRandom], false)); + if ($hasRandomOrder) { + $pipeline[] = [PipelineStage::AddFields->value => ['_rand' => ['$rand' => new stdClass()]]]; + } + + $sort = $this->buildSort(); + if ($hasRandomOrder) { + $sort['_rand'] = 1; + } + if (! empty($sort)) { + $pipeline[] = [PipelineStage::Sort->value => $sort]; + } + + if ($hasRandomOrder) { + $pipeline[] = [PipelineStage::Unset->value => '_rand']; + } + } + + /** + * OFFSET $skip and LIMIT $limit stages. + * + * @param list> $pipeline + */ + private function appendPaginationStages(array &$pipeline, ParsedQuery $grouped): void + { + if ($grouped->offset !== null) { + $pipeline[] = [PipelineStage::Skip->value => $grouped->offset]; + } + + if ($grouped->limit !== null) { + $pipeline[] = [PipelineStage::Limit->value => $grouped->limit]; + } + } + + /** + * Terminal $merge or $out stage (only one of the two is emitted). + * + * @param list> $pipeline + */ + private function appendOutputStages(array &$pipeline): void + { + if ($this->mergeStage !== null) { + $pipeline[] = [PipelineStage::Merge->value => $this->mergeStage]; + + return; + } + + if ($this->outStage !== null) { + if (isset($this->outStage['db'])) { + $pipeline[] = [PipelineStage::Out->value => $this->outStage]; + } else { + $pipeline[] = [PipelineStage::Out->value => $this->outStage['coll']]; + } + } + } + + /** + * @return array + */ + private function buildFilter(ParsedQuery $grouped): array + { + $conditions = []; + + foreach ($grouped->filters as $filter) { + $conditions[] = $this->buildFilterQuery($filter); + } + + if (\count($conditions) === 0) { + return []; + } + + if (\count($conditions) === 1) { + return $conditions[0]; + } + + return ['$and' => $conditions]; + } + + /** + * @return array + */ + private function buildFilterQuery(Query $query): array + { + $method = $query->getMethod(); + $attribute = $this->resolveAttribute($query->getAttribute()); + $values = $query->getValues(); + + return match ($method) { + Method::Equal => $this->buildIn($attribute, $values), + Method::NotEqual => $this->buildNotIn($attribute, $values), + Method::LessThan => $this->buildComparison($attribute, '$lt', $values), + Method::LessThanEqual => $this->buildComparison($attribute, '$lte', $values), + Method::GreaterThan => $this->buildComparison($attribute, '$gt', $values), + Method::GreaterThanEqual => $this->buildComparison($attribute, '$gte', $values), + Method::Between => $this->buildBetween($attribute, $values, false), + Method::NotBetween => $this->buildBetween($attribute, $values, true), + Method::StartsWith => $this->buildRegexFilter($attribute, $values, '^', ''), + Method::NotStartsWith => $this->buildNotRegexFilter($attribute, $values, '^', ''), + Method::EndsWith => $this->buildRegexFilter($attribute, $values, '', '$'), + Method::NotEndsWith => $this->buildNotRegexFilter($attribute, $values, '', '$'), + Method::Contains => $this->buildContains($attribute, $values), + Method::ContainsAny => $query->onArray() + ? $this->buildIn($attribute, $values) + : $this->buildContains($attribute, $values), + Method::ContainsAll => $this->buildContainsAll($attribute, $values), + Method::NotContains => $this->buildNotContains($attribute, $values), + Method::Regex => $this->buildUserRegex($attribute, $values), + Method::IsNull => [$attribute => null], + Method::IsNotNull => [$attribute => ['$ne' => null]], + Method::And => $this->buildLogical($query, '$and'), + Method::Or => $this->buildLogical($query, '$or'), + Method::Exists => $this->buildFieldExists($query, true), + Method::NotExists => $this->buildFieldExists($query, false), + default => throw new UnsupportedException('Unsupported filter type for MongoDB: ' . $method->value), + }; + } + + /** + * @param array $values + * @return array + */ + private function buildIn(string $attribute, array $values): array + { + $nonNulls = []; + $hasNull = false; + + foreach ($values as $value) { + if ($value === null) { + $hasNull = true; + } else { + $nonNulls[] = $value; + } + } + + if ($hasNull && empty($nonNulls)) { + return [$attribute => null]; + } + + if (\count($nonNulls) === 1 && ! $hasNull) { + $this->addBinding($nonNulls[0]); + + return [$attribute => '?']; + } + + $placeholders = []; + foreach ($nonNulls as $value) { + $this->addBinding($value); + $placeholders[] = '?'; + } + + if ($hasNull) { + return ['$or' => [ + [$attribute => ['$in' => $placeholders]], + [$attribute => null], + ]]; + } + + return [$attribute => ['$in' => $placeholders]]; + } + + /** + * @param array $values + * @return array + */ + private function buildNotIn(string $attribute, array $values): array + { + $nonNulls = []; + $hasNull = false; + + foreach ($values as $value) { + if ($value === null) { + $hasNull = true; + } else { + $nonNulls[] = $value; + } + } + + if ($hasNull && empty($nonNulls)) { + return [$attribute => ['$ne' => null]]; + } + + if (\count($nonNulls) === 1 && ! $hasNull) { + $this->addBinding($nonNulls[0]); + + return [$attribute => ['$ne' => '?']]; + } + + $placeholders = []; + foreach ($nonNulls as $value) { + $this->addBinding($value); + $placeholders[] = '?'; + } + + if ($hasNull) { + return ['$and' => [ + [$attribute => ['$nin' => $placeholders]], + [$attribute => ['$ne' => null]], + ]]; + } + + return [$attribute => ['$nin' => $placeholders]]; + } + + /** + * @param array $values + * @return array + */ + private function buildComparison(string $attribute, string $operator, array $values): array + { + $this->addBinding($values[0]); + + return [$attribute => [$operator => '?']]; + } + + /** + * @param array $values + * @return array + */ + private function buildBetween(string $attribute, array $values, bool $not): array + { + $this->addBinding($values[0]); + $this->addBinding($values[1]); + + if ($not) { + return ['$or' => [ + [$attribute => ['$lt' => '?']], + [$attribute => ['$gt' => '?']], + ]]; + } + + return [$attribute => ['$gte' => '?', '$lte' => '?']]; + } + + /** + * @param array $values + * @return array + */ + private function buildRegexFilter(string $attribute, array $values, string $prefix, string $suffix): array + { + /** @var string $rawVal */ + $rawVal = $values[0]; + $pattern = $prefix . \preg_quote($rawVal, '/') . $suffix; + $this->addBinding($pattern); + + return [$attribute => ['$regex' => '?']]; + } + + /** + * @param array $values + * @return array + */ + private function buildNotRegexFilter(string $attribute, array $values, string $prefix, string $suffix): array + { + /** @var string $rawVal */ + $rawVal = $values[0]; + $pattern = $prefix . \preg_quote($rawVal, '/') . $suffix; + $this->addBinding($pattern); + + return [$attribute => ['$not' => ['$regex' => '?']]]; + } + + /** + * @param array $values + * @return array + */ + private function buildContains(string $attribute, array $values): array + { + /** @var array $values */ + if (\count($values) === 1) { + $pattern = \preg_quote((string) $values[0], '/'); + $this->addBinding($pattern); + + return [$attribute => ['$regex' => '?']]; + } + + $conditions = []; + foreach ($values as $value) { + $pattern = \preg_quote((string) $value, '/'); + $this->addBinding($pattern); + $conditions[] = [$attribute => ['$regex' => '?']]; + } + + return ['$or' => $conditions]; + } + + /** + * @param array $values + * @return array + */ + private function buildContainsAll(string $attribute, array $values): array + { + /** @var array $values */ + $conditions = []; + foreach ($values as $value) { + $pattern = \preg_quote((string) $value, '/'); + $this->addBinding($pattern); + $conditions[] = [$attribute => ['$regex' => '?']]; + } + + return ['$and' => $conditions]; + } + + /** + * @param array $values + * @return array + */ + private function buildNotContains(string $attribute, array $values): array + { + /** @var array $values */ + if (\count($values) === 1) { + $pattern = \preg_quote((string) $values[0], '/'); + $this->addBinding($pattern); + + return [$attribute => ['$not' => ['$regex' => '?']]]; + } + + $conditions = []; + foreach ($values as $value) { + $pattern = \preg_quote((string) $value, '/'); + $this->addBinding($pattern); + $conditions[] = [$attribute => ['$not' => ['$regex' => '?']]]; + } + + return ['$and' => $conditions]; + } + + /** + * @param array $values + * @return array + */ + private function buildUserRegex(string $attribute, array $values): array + { + /** @var string $rawVal */ + $rawVal = $values[0]; + $this->addBinding($rawVal); + + return [$attribute => ['$regex' => '?']]; + } + + /** + * @return array + */ + private function buildLogical(Query $query, string $operator): array + { + $conditions = []; + foreach ($query->getValues() as $subQuery) { + /** @var Query $subQuery */ + $conditions[] = $this->buildFilterQuery($subQuery); + } + + if (empty($conditions)) { + return $operator === '$or' ? ['$expr' => false] : []; + } + + return [$operator => $conditions]; + } + + /** + * @return array + */ + private function buildFieldExists(Query $query, bool $exists): array + { + // SQL parity: IS NULL is true only for an explicit NULL value; IS NOT NULL is + // true only for a non-null present value. Missing fields are neither IS NULL nor + // IS NOT NULL under that reading. + // + // MongoDB: + // - {field: {$type: 10}} matches documents where the field EXISTS and is + // explicitly BSON null — mirrors SQL IS NULL. + // - {field: {$exists: true, $ne: null}} matches documents where the field + // EXISTS and is non-null — mirrors SQL IS NOT NULL. + $conditions = []; + foreach ($query->getValues() as $attr) { + /** @var string $attr */ + $field = $this->resolveAttribute($attr); + if ($exists) { + $conditions[] = [$field => ['$exists' => true, '$ne' => null]]; + } else { + $conditions[] = [$field => ['$type' => 10]]; + } + } + + if (\count($conditions) === 1) { + return $conditions[0]; + } + + return ['$and' => $conditions]; + } + + /** + * @return array + */ + private function buildProjection(ParsedQuery $grouped): array + { + if (empty($grouped->selections)) { + return []; + } + + $projection = []; + /** @var array $values */ + $values = $grouped->selections[0]->getValues(); + + foreach ($values as $field) { + $resolved = $this->resolveAttribute($field); + $projection[$resolved] = 1; + } + + if (! isset($projection['_id'])) { + $projection['_id'] = 0; + } + + return $projection; + } + + /** + * @return array + */ + private function buildSort(): array + { + $sort = []; + + $orderQueries = Query::getByType($this->pendingQueries, [ + Method::OrderAsc, + Method::OrderDesc, + ], false); + + foreach ($orderQueries as $query) { + $attr = $this->resolveAttribute($query->getAttribute()); + match ($query->getMethod()) { + Method::OrderAsc => $sort[$attr] = 1, + Method::OrderDesc => $sort[$attr] = -1, + default => null, + }; + } + + return $sort; + } + + /** + * @return array + */ + private function buildGroup(ParsedQuery $grouped): array + { + $group = []; + + if (! empty($grouped->groupBy)) { + if (\count($grouped->groupBy) === 1) { + $group['_id'] = '$' . $grouped->groupBy[0]; + } else { + $id = []; + foreach ($grouped->groupBy as $col) { + $id[$col] = '$' . $col; + } + $group['_id'] = $id; + } + } else { + $group['_id'] = null; + } + + foreach ($grouped->aggregations as $agg) { + $method = $agg->getMethod(); + $attr = $agg->getAttribute(); + /** @var string $alias */ + $alias = $agg->getValue(''); + if ($alias === '') { + $alias = $method->value; + } + + $group[$alias] = match ($method) { + Method::Count => ['$sum' => 1], + Method::Sum => ['$sum' => '$' . $attr], + Method::Avg => ['$avg' => '$' . $attr], + Method::Min => ['$min' => '$' . $attr], + Method::Max => ['$max' => '$' . $attr], + default => throw new UnsupportedException('Unsupported aggregation for MongoDB: ' . $method->value), + }; + } + + return $group; + } + + /** + * After $group, reshape the output to flatten the _id fields back to top-level. + * + * @return array + */ + private function buildProjectFromGroup(ParsedQuery $grouped): array + { + $project = ['_id' => 0]; + + if (! empty($grouped->groupBy)) { + if (\count($grouped->groupBy) === 1) { + $project[$grouped->groupBy[0]] = '$_id'; + } else { + foreach ($grouped->groupBy as $col) { + $project[$col] = '$_id.' . $col; + } + } + } + + foreach ($grouped->aggregations as $agg) { + /** @var string $alias */ + $alias = $agg->getValue(''); + if ($alias === '') { + $alias = $agg->getMethod()->value; + } + $project[$alias] = 1; + } + + return $project; + } + + /** + * @return list> + */ + private function buildJoinStages(Query $joinQuery): array + { + $table = $joinQuery->getAttribute(); + $values = $joinQuery->getValues(); + $stages = []; + + if ($joinQuery->getMethod() === Method::CrossJoin || $joinQuery->getMethod() === Method::NaturalJoin) { + throw new UnsupportedException('Cross/natural joins are not supported in MongoDB builder.'); + } + + if (empty($values)) { + throw new ValidationException('Join query must have values.'); + } + + /** @var string $leftCol */ + $leftCol = $values[0]; + /** @var string $operator */ + $operator = $values[1] ?? '='; + /** @var string $rightCol */ + $rightCol = $values[2]; + /** @var string $alias */ + $alias = $values[3] ?? $table; + + if ($operator !== '=') { + throw new UnsupportedException( + 'MongoDB $lookup in localField/foreignField form only supports equality joins. ' + . "Got operator '{$operator}'. Use a pipeline-form \$lookup via a raw stage for non-equality joins." + ); + } + + $localField = $this->stripTablePrefix($leftCol); + $foreignField = $this->stripTablePrefix($rightCol); + + $stages[] = [PipelineStage::Lookup->value => [ + 'from' => $table, + 'localField' => $localField, + 'foreignField' => $foreignField, + 'as' => $alias, + ]]; + + $isLeftJoin = $joinQuery->getMethod() === Method::LeftJoin; + + if ($isLeftJoin) { + $stages[] = [PipelineStage::Unwind->value => ['path' => '$' . $alias, 'preserveNullAndEmptyArrays' => true]]; + } else { + $stages[] = [PipelineStage::Unwind->value => '$' . $alias]; + } + + return $stages; + } + + /** + * @return list> + */ + private function buildDistinct(ParsedQuery $grouped): array + { + $stages = []; + + if (! empty($grouped->selections)) { + /** @var array $fields */ + $fields = $grouped->selections[0]->getValues(); + + // Resolve each attribute once — attribute hooks can be O(hooks) per call. + $resolved = []; + foreach ($fields as $field) { + $resolved[$field] = $this->resolveAttribute($field); + } + + $id = []; + foreach ($fields as $field) { + $id[$resolved[$field]] = '$' . $resolved[$field]; + } + + $stages[] = [PipelineStage::Group->value => ['_id' => $id]]; + + $project = ['_id' => 0]; + foreach ($fields as $field) { + $project[$resolved[$field]] = '$_id.' . $resolved[$field]; + } + $stages[] = [PipelineStage::Project->value => $project]; + } + + return $stages; + } + + /** + * @return array + */ + private function buildHaving(ParsedQuery $grouped): array + { + $conditions = []; + + if (! empty($grouped->having)) { + foreach ($grouped->having as $havingQuery) { + foreach ($havingQuery->getValues() as $subQuery) { + /** @var Query $subQuery */ + $conditions[] = $this->buildFilterQuery($subQuery); + } + } + } + + if (\count($conditions) === 0) { + return []; + } + + if (\count($conditions) === 1) { + return $conditions[0]; + } + + return ['$and' => $conditions]; + } + + /** + * @return list> + */ + private function buildWindowFunctions(): array + { + $stages = []; + + foreach ($this->windowSelects as $win) { + $output = []; + $func = \strtolower(\trim($win->function, '()')); + + $mongoFunc = match ($func) { + 'row_number' => '$documentNumber', + 'rank' => '$rank', + 'dense_rank' => '$denseRank', + default => null, + }; + + $isRankingFunction = $mongoFunc !== null; + + if ($isRankingFunction) { + // MongoDB's $rank/$denseRank/$documentNumber require a sortBy at runtime; + // surface this as a build-time error for a clearer failure mode. + if ($win->orderBy === null || $win->orderBy === []) { + throw new ValidationException( + "Window function '{$win->function}' requires an ORDER BY clause on MongoDB." + ); + } + + $output[$win->alias] = [$mongoFunc => new stdClass()]; + } else { + if (\preg_match('/^(\w+)\((.+)\)$/i', $win->function, $matches)) { + $argument = \trim($matches[2]); + + // Reject multi-argument window functions (e.g. COVAR(a, b)) — the + // localField/pipeline $setWindowFields shape only accepts a single expression. + if (\str_contains($argument, ',')) { + throw new UnsupportedException( + "Multi-argument window functions are not supported on MongoDB: {$win->function}" + ); + } + + $aggFunc = \strtolower($matches[1]); + $aggCol = $argument; + $mongoAggFunc = match ($aggFunc) { + 'sum' => '$sum', + 'avg' => '$avg', + 'min' => '$min', + 'max' => '$max', + 'count' => '$sum', + default => throw new UnsupportedException("Unsupported window function: {$win->function}"), + }; + $output[$win->alias] = [ + $mongoAggFunc => $aggFunc === 'count' ? 1 : '$' . $aggCol, + 'window' => ['documents' => ['unbounded', 'current']], + ]; + } else { + throw new UnsupportedException("Unsupported window function: {$win->function}"); + } + } + + $stage = [PipelineStage::SetWindowFields->value => ['output' => $output]]; + + if ($win->partitionBy !== null && $win->partitionBy !== []) { + if (\count($win->partitionBy) === 1) { + $stage[PipelineStage::SetWindowFields->value]['partitionBy'] = '$' . $win->partitionBy[0]; + } else { + $partitionBy = []; + foreach ($win->partitionBy as $col) { + $partitionBy[$col] = '$' . $col; + } + $stage[PipelineStage::SetWindowFields->value]['partitionBy'] = $partitionBy; + } + } + + if ($win->orderBy !== null && $win->orderBy !== []) { + $sortBy = []; + foreach ($win->orderBy as $col) { + if (\str_starts_with($col, '-')) { + $sortBy[\substr($col, 1)] = -1; + } else { + $sortBy[$col] = 1; + } + } + $stage[PipelineStage::SetWindowFields->value]['sortBy'] = $sortBy; + } + + $stages[] = $stage; + } + + return $stages; + } + + /** + * @return list> + */ + private function buildWhereInSubquery(WhereInSubquery $sub, int $idx): array + { + $stages = []; + $subResult = $sub->subquery->build(); + + /** @var array|null $subOp */ + $subOp = \json_decode($subResult->query, true); + if ($subOp === null) { + throw new UnsupportedException('Cannot parse subquery for MongoDB WHERE IN.'); + } + + $this->addBindings($subResult->bindings); + + $subCollection = $subOp['collection'] ?? ''; + $subPipeline = $this->operationToPipeline($subOp); + + $subField = $this->extractProjectionField($subPipeline); + $lookupAlias = '_sub_' . $idx; + + $stages[] = [PipelineStage::Lookup->value => [ + 'from' => $subCollection, + 'pipeline' => $subPipeline, + 'as' => $lookupAlias, + ]]; + + $stages[] = [PipelineStage::AddFields->value => [ + '_sub_ids_' . $idx => ['$map' => [ + 'input' => '$' . $lookupAlias, + 'as' => 's', + 'in' => '$$s.' . $subField, + ]], + ]]; + + $column = $this->resolveAttribute($sub->column); + + if ($sub->not) { + $stages[] = [PipelineStage::Match->value => [ + '$expr' => ['$not' => ['$in' => ['$' . $column, '$_sub_ids_' . $idx]]], + ]]; + } else { + $stages[] = [PipelineStage::Match->value => [ + '$expr' => ['$in' => ['$' . $column, '$_sub_ids_' . $idx]], + ]]; + } + + $stages[] = [PipelineStage::Unset->value => [$lookupAlias, '_sub_ids_' . $idx]]; + + return $stages; + } + + /** + * @return list> + */ + private function buildExistsSubquery(ExistsSubquery $sub, int $idx): array + { + $stages = []; + $subResult = $sub->subquery->build(); + + /** @var array|null $subOp */ + $subOp = \json_decode($subResult->query, true); + if ($subOp === null) { + throw new UnsupportedException('Cannot parse subquery for MongoDB EXISTS.'); + } + + $this->addBindings($subResult->bindings); + + $subCollection = $subOp['collection'] ?? ''; + $subPipeline = $this->operationToPipeline($subOp); + + // Ensure limit 1 for exists checks + $hasLimit = false; + foreach ($subPipeline as $stage) { + if (isset($stage[PipelineStage::Limit->value])) { + $hasLimit = true; + break; + } + } + if (! $hasLimit) { + $subPipeline[] = [PipelineStage::Limit->value => 1]; + } + + $lookupAlias = '_exists_' . $idx; + + $stages[] = [PipelineStage::Lookup->value => [ + 'from' => $subCollection, + 'pipeline' => $subPipeline, + 'as' => $lookupAlias, + ]]; + + if ($sub->not) { + $stages[] = [PipelineStage::Match->value => [$lookupAlias => ['$size' => 0]]]; + } else { + $stages[] = [PipelineStage::Match->value => [$lookupAlias => ['$ne' => []]]]; + } + + $stages[] = [PipelineStage::Unset->value => $lookupAlias]; + + return $stages; + } + + /** + * @return array + */ + private function buildUpdate(): array + { + $update = []; + + if (! empty($this->rows)) { + $setDoc = []; + foreach ($this->rows[0] as $col => $value) { + $this->addBinding($value); + $setDoc[$col] = '?'; + } + $update[UpdateOperator::Set->value] = $setDoc; + } + + foreach ($this->updateOperations as $operatorValue => $fields) { + if (empty($fields)) { + continue; + } + + $update[$operatorValue] = $this->emitUpdateOperator($operatorValue, $fields); + } + + return $update; + } + + /** + * Shape a single update operator's field map into the final payload (with bindings). + * + * @param array $fields + * @return array|array> + */ + private function emitUpdateOperator(string $operatorValue, array $fields): array + { + return match ($operatorValue) { + UpdateOperator::Push->value => $this->emitPushFields($fields), + UpdateOperator::Pull->value, + UpdateOperator::AddToSet->value, + UpdateOperator::Min->value, + UpdateOperator::Max->value => $this->emitBoundValues($fields), + UpdateOperator::PullAll->value => $this->emitPullAllFields($fields), + UpdateOperator::Increment->value, + UpdateOperator::Multiply->value, + UpdateOperator::Unset->value, + UpdateOperator::Rename->value, + UpdateOperator::Pop->value, + UpdateOperator::CurrentDate->value => $fields, + default => $fields, + }; + } + + /** + * @param array $fields + * @return array + */ + private function emitPushFields(array $fields): array + { + $pushDoc = []; + foreach ($fields as $field => $value) { + if (\is_array($value) && \array_key_exists('__each', $value)) { + /** @var array{values: list, position?: int, slice?: int, sort?: mixed} $modifier */ + $modifier = $value['__each']; + $eachValues = []; + foreach ($modifier['values'] as $val) { + $this->addBinding($val); + $eachValues[] = '?'; + } + $eachDoc = ['$each' => $eachValues]; + if (isset($modifier['position'])) { + $eachDoc['$position'] = $modifier['position']; + } + if (isset($modifier['slice'])) { + $eachDoc['$slice'] = $modifier['slice']; + } + if (isset($modifier['sort'])) { + $eachDoc['$sort'] = $modifier['sort']; + } + $pushDoc[$field] = $eachDoc; + } else { + $this->addBinding($value); + $pushDoc[$field] = '?'; + } + } + + return $pushDoc; + } + + /** + * @param array $fields + * @return array + */ + private function emitBoundValues(array $fields): array + { + $doc = []; + foreach ($fields as $field => $value) { + $this->addBinding($value); + $doc[$field] = '?'; + } + + return $doc; + } + + /** + * @param array $fields + * @return array> + */ + private function emitPullAllFields(array $fields): array + { + $doc = []; + foreach ($fields as $field => $values) { + /** @var array $values */ + $placeholders = []; + foreach ($values as $val) { + $this->addBinding($val); + $placeholders[] = '?'; + } + $doc[$field] = $placeholders; + } + + return $doc; + } + + private function stripTablePrefix(string $field): string + { + $parts = \explode('.', $field); + + return \count($parts) > 1 ? $parts[\count($parts) - 1] : $field; + } + + /** + * Convert a MongoDB operation descriptor to a pipeline. + * For `aggregate` operations, returns the pipeline as-is. + * For `find` operations, converts filter/projection/sort/skip/limit to pipeline stages. + * + * @param array $op + * @return list> + */ + private function operationToPipeline(array $op): array + { + if (($op['operation'] ?? '') === Operation::Aggregate->value) { + /** @var list> */ + return $op['pipeline'] ?? []; + } + + $pipeline = []; + + if (! empty($op['filter'])) { + $pipeline[] = [PipelineStage::Match->value => $op['filter']]; + } + if (! empty($op['projection'])) { + $pipeline[] = [PipelineStage::Project->value => $op['projection']]; + } + if (! empty($op['sort'])) { + $pipeline[] = [PipelineStage::Sort->value => $op['sort']]; + } + if (isset($op['skip'])) { + $pipeline[] = [PipelineStage::Skip->value => $op['skip']]; + } + if (isset($op['limit'])) { + $pipeline[] = [PipelineStage::Limit->value => $op['limit']]; + } + + return $pipeline; + } + + /** + * Extract the first projected field name from a pipeline's $project stage. + * + * @param list> $pipeline + */ + private function extractProjectionField(array $pipeline): string + { + foreach ($pipeline as $stage) { + if (isset($stage[PipelineStage::Project->value])) { + /** @var array $projection */ + $projection = $stage[PipelineStage::Project->value]; + foreach ($projection as $field => $value) { + if ($field !== '_id' && $value === 1) { + return $field; + } + } + } + } + + return '_id'; + } +} diff --git a/src/Query/Builder/MongoDB/Operation.php b/src/Query/Builder/MongoDB/Operation.php new file mode 100644 index 0000000..b003c3f --- /dev/null +++ b/src/Query/Builder/MongoDB/Operation.php @@ -0,0 +1,13 @@ + $fields field => value (or field => modifier dict, for push-with-each) + */ + public function __construct( + public UpdateOperator $operator, + public array $fields, + ) { + } +} diff --git a/src/Query/Builder/MongoDB/UpdateOperator.php b/src/Query/Builder/MongoDB/UpdateOperator.php new file mode 100644 index 0000000..265adf3 --- /dev/null +++ b/src/Query/Builder/MongoDB/UpdateOperator.php @@ -0,0 +1,20 @@ + $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' REGEXP ?'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSearchExpr(string $attribute, array $values, bool $not): string + { + /** @var string $term */ + $term = $values[0] ?? ''; + $exact = \str_ends_with($term, '"') && \str_starts_with($term, '"'); + + $specialChars = '@,+,-,*,),(,<,>,~,"'; + $sanitized = \str_replace(\explode(',', $specialChars), ' ', $term); + $sanitized = \preg_replace('/\s+/', ' ', $sanitized) ?? ''; + $sanitized = \trim($sanitized); + + if ($sanitized === '') { + return $not ? '1 = 1' : '1 = 0'; + } + + if ($exact) { + $sanitized = '"' . $sanitized . '"'; + } else { + $sanitized .= '*'; + } + + $this->addBinding($sanitized); + + if ($not) { + return 'NOT (MATCH(' . $attribute . ') AGAINST(? IN BOOLEAN MODE))'; + } + + return 'MATCH(' . $attribute . ') AGAINST(? IN BOOLEAN MODE)'; + } + + #[\Override] + protected function compileConflictHeader(): string + { + return 'ON DUPLICATE KEY UPDATE'; + } + + #[\Override] + protected function compileConflictAssignment(string $wrapped): string + { + return 'VALUES(' . $wrapped . ')'; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + public function setJsonPath(string $column, string $path, mixed $value): static + { + if (! \str_starts_with($path, '$')) { + throw new ValidationException('JSON path must start with \'$\': ' . $path); + } + + $this->jsonSets[$column] = new Condition( + 'JSON_SET(' . $this->resolveAndWrap($column) . ', ?, ?)', + [$path, $value], + ); + + return $this; + } + + public function maxExecutionTime(int $ms): static + { + return $this->hint("MAX_EXECUTION_TIME({$ms})"); + } + + #[\Override] + public function insertOrIgnore(): Statement + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + $this->addBindings($bindings); + + // Replace "INSERT INTO" with "INSERT IGNORE INTO" + $sql = \preg_replace('/^INSERT INTO/', 'INSERT IGNORE INTO', $sql, 1) ?? $sql; + + return new Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function explain(bool $analyze = false, string $format = ''): Statement + { + $result = $this->build(); + $prefix = 'EXPLAIN'; + if ($analyze) { + $prefix .= ' ANALYZE'; + } + if ($format !== '') { + $prefix .= ' FORMAT=' . \strtoupper($format); + } + + return new Statement($prefix . ' ' . $result->query, $result->bindings, readOnly: true, executor: $this->executor); + } + + #[\Override] + public function build(): Statement + { + $result = parent::build(); + $query = $result->query; + + if ($this->groupByModifier !== null) { + $groupByPos = \strpos($query, 'GROUP BY '); + if ($groupByPos !== false) { + $afterGroupBy = $groupByPos + 9; + $endPos = null; + foreach (['HAVING ', 'WINDOW ', 'ORDER BY ', 'LIMIT ', 'FOR '] as $keyword) { + $pos = \strpos($query, $keyword, $afterGroupBy); + if ($pos !== false && ($endPos === null || $pos < $endPos)) { + $endPos = $pos; + } + } + $insertAt = $endPos !== null ? $endPos : \strlen($query); + $query = \rtrim(\substr($query, 0, $insertAt)) . ' ' . $this->groupByModifier . ($endPos !== null ? ' ' . \substr($query, $endPos) : ''); + } + } + + if (! empty($this->hints)) { + $hintStr = '/*+ ' . \implode(' ', $this->hints) . ' */'; + $query = \preg_replace('/^SELECT(\s+DISTINCT)?/', 'SELECT$1 ' . $hintStr, $query, 1) ?? $query; + } + + if ($query !== $result->query) { + return new Statement($query, $result->bindings, $result->readOnly, $this->executor); + } + + return $result; + } + + public function updateJoin(string $table, string $left, string $right, string $alias = ''): static + { + $this->updateJoinTable = $table; + $this->updateJoinLeft = $left; + $this->updateJoinRight = $right; + $this->updateJoinAlias = $alias; + + return $this; + } + + #[\Override] + public function update(): Statement + { + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); + } + + if ($this->updateJoinTable !== '') { + $result = $this->buildUpdateJoin(); + $this->jsonSets = []; + + return $result; + } + + $result = parent::update(); + $this->jsonSets = []; + + return $result; + } + + private function buildUpdateJoin(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $joinTable = $this->quote($this->updateJoinTable); + if ($this->updateJoinAlias !== '') { + $joinTable .= ' AS ' . $this->quote($this->updateJoinAlias); + } + + $sql = 'UPDATE ' . $this->quote($this->table) + . ' JOIN ' . $joinTable + . ' ON ' . $this->resolveAndWrap($this->updateJoinLeft) . ' = ' . $this->resolveAndWrap($this->updateJoinRight); + + $assignments = $this->compileAssignments(); + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $sql .= ' SET ' . \implode(', ', $assignments); + + $parts = [$sql]; + $this->compileWhereClauses($parts); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } + + public function deleteJoin(string $alias, string $table, string $left, string $right): static + { + $this->deleteAlias = $alias; + $this->deleteJoinTable = $table; + $this->deleteJoinLeft = $left; + $this->deleteJoinRight = $right; + + return $this; + } + + #[\Override] + public function delete(): Statement + { + if ($this->deleteAlias !== '') { + return $this->buildDeleteJoin(); + } + + return parent::delete(); + } + + private function buildDeleteJoin(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $sql = 'DELETE ' . $this->quote($this->deleteAlias) + . ' FROM ' . $this->quote($this->table) . ' AS ' . $this->quote($this->deleteAlias) + . ' JOIN ' . $this->quote($this->deleteJoinTable) + . ' ON ' . $this->resolveAndWrap($this->deleteJoinLeft) . ' = ' . $this->resolveAndWrap($this->deleteJoinRight); + + $parts = [$sql]; + $this->compileWhereClauses($parts); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } + + #[\Override] + protected function groupConcatExpr(string $column, string $orderBy): string + { + $suffix = $orderBy === '' ? '' : ' ' . $orderBy; + + return 'GROUP_CONCAT(' . $column . $suffix . ' SEPARATOR ?)'; + } + + #[\Override] + protected function jsonArrayAggExpr(string $column): string + { + return 'JSON_ARRAYAGG(' . $column . ')'; + } + + #[\Override] + protected function jsonObjectAggExpr(string $keyColumn, string $valueColumn): string + { + return 'JSON_OBJECTAGG(' . $keyColumn . ', ' . $valueColumn . ')'; + } + + #[\Override] + public function insertDefaultValues(): Statement + { + $this->bindings = []; + $this->validateTable(); + + return new Statement('INSERT INTO ' . $this->quote($this->table) . ' () VALUES ()', $this->bindings, executor: $this->executor); + } + + #[\Override] + public function withRollup(): static + { + $this->groupByModifier = 'WITH ROLLUP'; + + return $this; + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->hints = []; + $this->jsonSets = []; + $this->resetGroupByModifier(); + $this->updateJoinTable = ''; + $this->updateJoinLeft = ''; + $this->updateJoinRight = ''; + $this->updateJoinAlias = ''; + $this->deleteAlias = ''; + $this->deleteJoinTable = ''; + $this->deleteJoinLeft = ''; + $this->deleteJoinRight = ''; + + return $this; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string|array, 1: float, 2: bool} $tuple */ + $tuple = $values[0]; + $filter = SpatialDistanceFilter::fromTuple($tuple); + $wkt = \is_array($filter->geometry) ? $this->geometryToWkt($filter->geometry) : $filter->geometry; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + if ($filter->meters) { + return 'ST_Distance(ST_SRID(' . $attribute . ', 4326), ' . $this->geomFromText(4326) . ', \'metre\') ' . $operator . ' ?'; + } + + return 'ST_Distance(ST_SRID(' . $attribute . ', 0), ' . $this->geomFromText(0) . ') ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + #[\Override] + protected 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 . ', ' . $this->geomFromText(4326) . ')'; + + return $not ? 'NOT ' . $expr : $expr; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialCoversPredicate(string $attribute, array $values, bool $not): string + { + return $this->compileSpatialPredicate('ST_Contains', $attribute, $values, $not); + } + + protected function geomFromText(int $srid): string + { + return "ST_GeomFromText(?, {$srid}, 'axis-order=long-lat')"; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonContainsExpr(string $attribute, array $values, bool $not): string + { + $this->addBinding($this->encodeJsonPayload($values[0])); + $expr = 'JSON_CONTAINS(' . $attribute . ', ?)'; + + return $not ? 'NOT ' . $expr : $expr; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonOverlapsExpr(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $this->addBinding($this->encodeJsonPayload($arr)); + + return 'JSON_OVERLAPS(' . $attribute . ', ?)'; + } + + private function encodeJsonPayload(mixed $payload): string + { + try { + return \json_encode($payload, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new ValidationException('Invalid JSON payload: ' . $exception->getMessage()); + } + } + + /** + * @param array $values + */ + #[\Override] + protected 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 'JSON_EXTRACT(' . $attribute . ', \'$.' . $path . '\') ' . $operator . ' ?'; + } + +} diff --git a/src/Query/Builder/ParsedQuery.php b/src/Query/Builder/ParsedQuery.php new file mode 100644 index 0000000..b064dc5 --- /dev/null +++ b/src/Query/Builder/ParsedQuery.php @@ -0,0 +1,34 @@ + $filters + * @param list $selections + * @param list $aggregations + * @param list $groupBy + * @param list $having + * @param list $joins + * @param list $unions + */ + 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 mixed $cursor = null, + public ?CursorDirection $cursorDirection = null, + ) { + } +} diff --git a/src/Query/Builder/PostgreSQL.php b/src/Query/Builder/PostgreSQL.php new file mode 100644 index 0000000..8585c8c --- /dev/null +++ b/src/Query/Builder/PostgreSQL.php @@ -0,0 +1,885 @@ +, metric: VectorMetric} */ + protected ?array $vectorOrder = null; + + protected ?UpdateFrom $updateFrom = null; + + protected ?DeleteUsing $deleteUsing = null; + + protected ?MergeTarget $mergeTarget = null; + + /** @var list */ + protected array $mergeClauses = []; + + /** @var list */ + protected array $distinctOnColumns = []; + + #[\Override] + protected function compileRandom(): string + { + return 'RANDOM()'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values): string + { + $this->addBinding($values[0]); + + return $attribute . ' ~ ?'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSearchExpr(string $attribute, array $values, bool $not): string + { + /** @var string $term */ + $term = $values[0] ?? ''; + $exact = \str_ends_with($term, '"') && \str_starts_with($term, '"'); + + $specialChars = ['@', '+', '-', '*', '.', "'", '"', ')', '(', '<', '>', '~']; + $sanitized = \str_replace($specialChars, ' ', $term); + $sanitized = \preg_replace('/\s+/', ' ', $sanitized) ?? ''; + $sanitized = \trim($sanitized); + + if ($sanitized === '') { + return $not ? '1 = 1' : '1 = 0'; + } + + if ($exact) { + $sanitized = '"' . $sanitized . '"'; + } else { + $sanitized = \str_replace(' ', ' or ', $sanitized); + } + + $this->addBinding($sanitized); + $tsvector = "to_tsvector(regexp_replace(" . $attribute . ", '[^\\w]+', ' ', 'g'))"; + + if ($not) { + return 'NOT (' . $tsvector . ' @@ websearch_to_tsquery(?))'; + } + + return $tsvector . ' @@ websearch_to_tsquery(?)'; + } + + #[\Override] + protected function compileConflictHeader(): string + { + $wrappedKeys = \array_map( + fn (string $key): string => $this->resolveAndWrap($key), + $this->conflictKeys + ); + + return 'ON CONFLICT (' . \implode(', ', $wrappedKeys) . ') DO UPDATE SET'; + } + + #[\Override] + protected function compileConflictAssignment(string $wrapped): string + { + return 'EXCLUDED.' . $wrapped; + } + + #[\Override] + protected function shouldEmitOffset(?int $offset, ?int $limit): bool + { + return $offset !== null; + } + + #[\Override] + public function tablesample(float $percent, string $method = 'BERNOULLI'): static + { + $normalized = \strtoupper($method); + if (! \in_array($normalized, ['BERNOULLI', 'SYSTEM'], true)) { + throw new ValidationException('Invalid TABLESAMPLE method: ' . $method); + } + $this->sample = ['percent' => $percent, 'method' => $normalized]; + + return $this; + } + + #[\Override] + public function insertOrIgnore(): Statement + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + $this->addBindings($bindings); + + $sql .= ' ON CONFLICT DO NOTHING'; + + return $this->appendReturning(new Statement($sql, $this->bindings, executor: $this->executor)); + } + + #[\Override] + public function insert(): Statement + { + $result = parent::insert(); + + return $this->appendReturning($result); + } + + public function updateFrom(string $table, string $alias = ''): static + { + $current = $this->updateFrom; + $this->updateFrom = new UpdateFrom( + table: $table, + alias: $alias, + condition: $current === null ? '' : $current->condition, + bindings: $current === null ? [] : $current->bindings, + ); + + return $this; + } + + public function updateFromWhere(string $condition, mixed ...$bindings): static + { + $current = $this->updateFrom; + $this->updateFrom = new UpdateFrom( + table: $current === null ? '' : $current->table, + alias: $current === null ? '' : $current->alias, + condition: $condition, + bindings: \array_values($bindings), + ); + + return $this; + } + + #[\Override] + public function update(): Statement + { + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); + } + + if ($this->updateFrom !== null && $this->updateFrom->table !== '') { + $result = $this->buildUpdateFrom(); + $this->jsonSets = []; + + return $this->appendReturning($result); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $this->appendReturning($result); + } + + private function buildUpdateFrom(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $updateFrom = $this->updateFrom; + if ($updateFrom === null) { + throw new ValidationException('No UPDATE FROM target specified.'); + } + + $assignments = $this->compileAssignments(); + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $fromClause = $this->quote($updateFrom->table); + if ($updateFrom->alias !== '') { + $fromClause .= ' AS ' . $this->quote($updateFrom->alias); + } + + $sql = 'UPDATE ' . $this->quote($this->table) + . ' SET ' . \implode(', ', $assignments) + . ' FROM ' . $fromClause; + + $parts = [$sql]; + + $extraWhere = []; + if ($updateFrom->condition !== '') { + $extraWhere[] = $updateFrom->condition; + foreach ($updateFrom->bindings as $binding) { + $this->addBinding($binding); + } + } + + $this->compileWhereClauses($parts); + $this->mergeIntoWhereClause($parts, $extraWhere); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } + + public function deleteUsing(string $table, string $condition, mixed ...$bindings): static + { + $this->deleteUsing = new DeleteUsing( + table: $table, + condition: $condition, + bindings: \array_values($bindings), + ); + + return $this; + } + + #[\Override] + public function delete(): Statement + { + if ($this->deleteUsing !== null && $this->deleteUsing->table !== '') { + $result = $this->buildDeleteUsing(); + + return $this->appendReturning($result); + } + + $result = parent::delete(); + + return $this->appendReturning($result); + } + + private function buildDeleteUsing(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $deleteUsing = $this->deleteUsing; + if ($deleteUsing === null) { + throw new ValidationException('No DELETE USING target specified.'); + } + + $sql = 'DELETE FROM ' . $this->quote($this->table) + . ' USING ' . $this->quote($deleteUsing->table); + + $parts = [$sql]; + + $extraWhere = []; + if ($deleteUsing->condition !== '') { + $extraWhere[] = $deleteUsing->condition; + foreach ($deleteUsing->bindings as $binding) { + $this->addBinding($binding); + } + } + + $this->compileWhereClauses($parts); + $this->mergeIntoWhereClause($parts, $extraWhere); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } + + /** + * Merge additional conditions into the trailing WHERE clause in $parts. + * If the last part already begins with "WHERE ", append with AND; otherwise + * push a new WHERE fragment. No-op when $extra is empty. + * + * @param array $parts + * @param list $extra + */ + private function mergeIntoWhereClause(array &$parts, array $extra): void + { + if (empty($extra)) { + return; + } + + $lastPart = \end($parts); + if (\is_string($lastPart) && \str_starts_with($lastPart, 'WHERE ')) { + $parts[\count($parts) - 1] = $lastPart . ' AND ' . \implode(' AND ', $extra); + + return; + } + + $parts[] = 'WHERE ' . \implode(' AND ', $extra); + } + + #[\Override] + public function upsert(): Statement + { + $result = parent::upsert(); + + return $this->appendReturning($result); + } + + #[\Override] + public function upsertSelect(): Statement + { + $result = parent::upsertSelect(); + + return $this->appendReturning($result); + } + + #[\Override] + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb) || ?::jsonb', + [\json_encode($values)], + ); + + return $this; + } + + #[\Override] + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + '?::jsonb || COALESCE(' . $this->resolveAndWrap($column) . ', \'[]\'::jsonb)', + [\json_encode($values)], + ); + + return $this; + } + + #[\Override] + 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; + } + + #[\Override] + public function setJsonRemove(string $column, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + $this->resolveAndWrap($column) . ' - ?', + [\json_encode($value)], + ); + + return $this; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + public function setJsonPath(string $column, string $path, mixed $value): static + { + if (! \str_starts_with($path, '$')) { + throw new ValidationException('JSON path must start with \'$\': ' . $path); + } + + $trimmed = \ltrim(\substr($path, 1), '.'); + + if ($trimmed === '') { + throw new ValidationException('JSON path must reference at least one key: ' . $path); + } + + $segments = \explode('.', $trimmed); + foreach ($segments as $segment) { + if ($segment === '') { + throw new ValidationException('JSON path contains an empty segment: ' . $path); + } + } + + $pathArray = '{' . \implode(',', $segments) . '}'; + + $this->jsonSets[$column] = new Condition( + 'jsonb_set(' . $this->resolveAndWrap($column) . ', ?, to_jsonb(?::text)::jsonb, true)', + [$pathArray, $value], + ); + + return $this; + } + + #[\Override] + public function explain(bool $analyze = false, bool $verbose = false, bool $buffers = false, string $format = ''): Statement + { + $normalizedFormat = \strtoupper($format); + if (! \in_array($normalizedFormat, ['', 'TEXT', 'XML', 'JSON', 'YAML'], true)) { + throw new ValidationException('Invalid EXPLAIN format: ' . $format); + } + $result = $this->build(); + $options = []; + if ($analyze) { + $options[] = 'ANALYZE'; + } + if ($verbose) { + $options[] = 'VERBOSE'; + } + if ($buffers) { + $options[] = 'BUFFERS'; + } + if ($normalizedFormat !== '') { + $options[] = 'FORMAT ' . $normalizedFormat; + } + $prefix = empty($options) ? 'EXPLAIN' : 'EXPLAIN (' . \implode(', ', $options) . ')'; + + return new Statement($prefix . ' ' . $result->query, $result->bindings, readOnly: true, executor: $this->executor); + } + + #[\Override] + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + + if ($method->isVector()) { + $attribute = $this->resolveAndWrap($query->getAttribute()); + + return $this->compileVectorFilter($method, $attribute, $query); + } + + if ($query->getAttributeType() === ColumnType::Object->value) { + return $this->compileObjectFilter($query); + } + + return parent::compileFilter($query); + } + + protected function compileObjectFilter(Query $query): string + { + $method = $query->getMethod(); + $rawAttr = $query->getAttribute(); + $isNested = \str_contains($rawAttr, '.'); + + if ($isNested) { + $attribute = $this->buildJsonbPath($rawAttr); + + return match ($method) { + Method::Equal => $this->compileIn($attribute, $query->getValues()), + Method::NotEqual => $this->compileNotIn($attribute, $query->getValues()), + Method::LessThan => $this->compileComparison($attribute, '<', $query->getValues()), + Method::LessThanEqual => $this->compileComparison($attribute, '<=', $query->getValues()), + Method::GreaterThan => $this->compileComparison($attribute, '>', $query->getValues()), + Method::GreaterThanEqual => $this->compileComparison($attribute, '>=', $query->getValues()), + Method::StartsWith => $this->compileLike($attribute, $query->getValues(), '', '%', false), + Method::NotStartsWith => $this->compileLike($attribute, $query->getValues(), '', '%', true), + Method::EndsWith => $this->compileLike($attribute, $query->getValues(), '%', '', false), + Method::NotEndsWith => $this->compileLike($attribute, $query->getValues(), '%', '', true), + Method::Contains => $this->compileLike($attribute, $query->getValues(), '%', '%', false), + Method::NotContains => $this->compileLike($attribute, $query->getValues(), '%', '%', true), + Method::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + default => parent::compileFilter($query), + }; + } + + $attribute = $this->resolveAndWrap($rawAttr); + + return match ($method) { + Method::Equal, Method::NotEqual => $this->compileJsonbContainment($attribute, $query->getValues(), $method === Method::NotEqual, false), + Method::Contains, Method::ContainsAny, Method::ContainsAll, Method::NotContains => $this->compileJsonbContainment($attribute, $query->getValues(), $method === Method::NotContains, true), + Method::StartsWith => $this->compileLike($attribute . '::text', $query->getValues(), '', '%', false), + Method::NotStartsWith => $this->compileLike($attribute . '::text', $query->getValues(), '', '%', true), + Method::EndsWith => $this->compileLike($attribute . '::text', $query->getValues(), '%', '', false), + Method::NotEndsWith => $this->compileLike($attribute . '::text', $query->getValues(), '%', '', true), + Method::IsNull => $attribute . ' IS NULL', + Method::IsNotNull => $attribute . ' IS NOT NULL', + default => parent::compileFilter($query), + }; + } + + /** + * @param array $values + */ + protected function compileJsonbContainment(string $attribute, array $values, bool $not, bool $wrapScalars): string + { + $conditions = []; + foreach ($values as $value) { + if ($wrapScalars && \is_array($value) && \count($value) === 1) { + $jsonKey = \array_key_first($value); + $jsonValue = $value[$jsonKey]; + if (!\is_array($jsonValue)) { + $value[$jsonKey] = [$jsonValue]; + } + } + $this->addBinding(\json_encode($value)); + $fragment = $attribute . ' @> ?::jsonb'; + $conditions[] = $not ? 'NOT (' . $fragment . ')' : $fragment; + } + $separator = $not ? ' AND ' : ' OR '; + + return '(' . \implode($separator, $conditions) . ')'; + } + + #[\Override] + protected function getLikeKeyword(): string + { + return 'ILIKE'; + } + + protected function buildJsonbPath(string $path): string + { + $parts = \explode('.', $path); + if (\count($parts) === 1) { + return $this->resolveAndWrap($parts[0]); + } + + $base = $this->quote($this->resolveAttribute($parts[0])); + $lastKey = \array_pop($parts); + \array_shift($parts); + + $chain = $base; + foreach ($parts as $key) { + $chain .= "->'" . $key . "'"; + } + + return $chain . "->>'" . $lastKey . "'"; + } + + #[\Override] + 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], + ); + } + + #[\Override] + public function countWhen(string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('COUNT', null, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function sumWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('SUM', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function avgWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('AVG', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function minWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('MIN', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function maxWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('MAX', $column, $condition, $alias, \array_values($bindings)); + } + + /** + * Emit a conditional aggregate using PostgreSQL's FILTER (WHERE ...) clause. + * + * @param list $bindings + */ + private function aggregateFilter(string $aggregate, ?string $column, string $condition, string $alias, array $bindings): static + { + $argument = $column === null ? '*' : $this->resolveAndWrap($column); + $expr = $aggregate . '(' . $argument . ') FILTER (WHERE ' . $condition . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, $bindings); + } + + #[\Override] + protected function groupConcatExpr(string $column, string $orderBy): string + { + $suffix = $orderBy === '' ? '' : ' ' . $orderBy; + + return 'STRING_AGG(' . $column . ', ?' . $suffix . ')'; + } + + #[\Override] + protected function jsonArrayAggExpr(string $column): string + { + return 'JSON_AGG(' . $column . ')'; + } + + #[\Override] + protected function jsonObjectAggExpr(string $keyColumn, string $valueColumn): string + { + return 'JSON_OBJECT_AGG(' . $keyColumn . ', ' . $valueColumn . ')'; + } + + #[\Override] + public function insertDefaultValues(): Statement + { + $result = parent::insertDefaultValues(); + + return $this->appendReturning($result); + } + + #[\Override] + public function withRollup(): static + { + $this->groupByModifier = 'ROLLUP'; + + return $this; + } + + #[\Override] + public function withCube(): static + { + $this->groupByModifier = 'CUBE'; + + return $this; + } + + #[\Override] + public function build(): Statement + { + $result = parent::build(); + $query = $result->query; + $modified = false; + + if (! empty($this->distinctOnColumns)) { + $cols = \array_map( + fn (string $col): string => $this->resolveAndWrap($col), + $this->distinctOnColumns + ); + $distinctOnClause = 'SELECT DISTINCT ON (' . \implode(', ', $cols) . ')'; + $query = \preg_replace('/^SELECT(\s+DISTINCT)?/', $distinctOnClause, $query, 1) ?? $query; + $modified = true; + } + + if ($this->groupByModifier !== null) { + $groupByPos = \strpos($query, 'GROUP BY '); + if ($groupByPos !== false) { + $afterGroupBy = $groupByPos + 9; + $endPos = null; + foreach (['HAVING ', 'WINDOW ', 'ORDER BY ', 'LIMIT ', 'OFFSET ', 'FETCH ', 'FOR '] as $keyword) { + $pos = \strpos($query, $keyword, $afterGroupBy); + if ($pos !== false && ($endPos === null || $pos < $endPos)) { + $endPos = $pos; + } + } + $columns = $endPos !== null + ? \rtrim(\substr($query, $afterGroupBy, $endPos - $afterGroupBy)) + : \substr($query, $afterGroupBy); + $replacement = 'GROUP BY ' . $this->groupByModifier . '(' . $columns . ')'; + $query = \substr($query, 0, $groupByPos) . $replacement . ($endPos !== null ? ' ' . \substr($query, $endPos) : ''); + } + $modified = true; + } + + if ($modified) { + return new Statement($query, $result->bindings, $result->readOnly, $this->executor); + } + + return $result; + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->jsonSets = []; + $this->vectorOrder = null; + $this->resetReturning(); + $this->updateFrom = null; + $this->deleteUsing = null; + $this->mergeTarget = null; + $this->mergeClauses = []; + $this->distinctOnColumns = []; + $this->resetGroupByModifier(); + + return $this; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + /** @var array{0: string|array, 1: float, 2: bool} $tuple */ + $tuple = $values[0]; + $filter = SpatialDistanceFilter::fromTuple($tuple); + $wkt = \is_array($filter->geometry) ? $this->geometryToWkt($filter->geometry) : $filter->geometry; + + $operator = match ($method) { + Method::DistanceLessThan => '<', + Method::DistanceGreaterThan => '>', + Method::DistanceEqual => '=', + Method::DistanceNotEqual => '!=', + default => '<', + }; + + $this->addBinding($wkt); + $this->addBinding($filter->distance); + + if ($filter->meters) { + return 'ST_Distance((' . $attribute . '::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) ' . $operator . ' ?'; + } + + return 'ST_Distance(' . $attribute . ', ST_GeomFromText(?, 4326)) ' . $operator . ' ?'; + } + + /** + * @param array $values + */ + #[\Override] + protected 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; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialCoversPredicate(string $attribute, array $values, bool $not): string + { + return $this->compileSpatialPredicate('ST_Covers', $attribute, $values, $not); + } + + /** + * @param array $values + */ + #[\Override] + protected 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 + */ + #[\Override] + protected function compileJsonOverlapsExpr(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + + $conditions = []; + foreach ($arr as $value) { + $this->addBinding(\json_encode($value)); + $conditions[] = $attribute . ' @> ?::jsonb'; + } + + return '(' . \implode(' OR ', $conditions) . ')'; + } + + /** + * @param array $values + */ + #[\Override] + protected 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/PostgreSQL/DeleteUsing.php b/src/Query/Builder/PostgreSQL/DeleteUsing.php new file mode 100644 index 0000000..d7094b2 --- /dev/null +++ b/src/Query/Builder/PostgreSQL/DeleteUsing.php @@ -0,0 +1,16 @@ + $bindings + */ + public function __construct( + public string $table, + public string $condition = '', + public array $bindings = [], + ) { + } +} diff --git a/src/Query/Builder/PostgreSQL/MergeTarget.php b/src/Query/Builder/PostgreSQL/MergeTarget.php new file mode 100644 index 0000000..241e023 --- /dev/null +++ b/src/Query/Builder/PostgreSQL/MergeTarget.php @@ -0,0 +1,20 @@ + $bindings + */ + public function __construct( + public string $target, + public ?Builder $source = null, + public string $alias = '', + public string $condition = '', + public array $bindings = [], + ) { + } +} diff --git a/src/Query/Builder/PostgreSQL/UpdateFrom.php b/src/Query/Builder/PostgreSQL/UpdateFrom.php new file mode 100644 index 0000000..eec7afc --- /dev/null +++ b/src/Query/Builder/PostgreSQL/UpdateFrom.php @@ -0,0 +1,17 @@ + $bindings + */ + public function __construct( + public string $table, + public string $alias = '', + public string $condition = '', + public array $bindings = [], + ) { + } +} diff --git a/src/Query/Builder/SQL.php b/src/Query/Builder/SQL.php new file mode 100644 index 0000000..edf87b4 --- /dev/null +++ b/src/Query/Builder/SQL.php @@ -0,0 +1,188 @@ + */ + protected array $jsonSets = []; + + abstract public function insertOrIgnore(): Statement; + + abstract protected function compileConflictHeader(): string; + + abstract protected function compileConflictAssignment(string $wrapped): string; + + 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 . ' = ' . $this->compileConflictAssignment($wrapped); + } + } + + return $this->compileConflictHeader() . ' ' . \implode(', ', $updates); + } + + #[\Override] + public function compileFilter(Query $query): string + { + $method = $query->getMethod(); + $attribute = $this->resolveAndWrap($query->getAttribute()); + + if ($method === Method::Search) { + return $this->compileSearchExpr($attribute, $query->getValues(), false); + } + + if ($method === Method::NotSearch) { + return $this->compileSearchExpr($attribute, $query->getValues(), true); + } + + if ($method->isSpatial()) { + return $this->compileSpatialFilter($method, $attribute, $query); + } + + $attrType = $query->getAttributeType(); + $isSpatialAttr = \in_array($attrType, [ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value], true); + if ($isSpatialAttr) { + $spatialMethod = match ($method) { + Method::Equal => Method::SpatialEquals, + Method::NotEqual => Method::NotSpatialEquals, + Method::Contains => Method::Covers, + Method::NotContains => Method::NotCovers, + default => null, + }; + if ($spatialMethod !== null) { + return $this->compileSpatialFilter($spatialMethod, $attribute, $query); + } + } + + if ($method->isJson()) { + return $this->compileJsonFilter($method, $attribute, $query); + } + + if ($query->onArray() && \in_array($method, [Method::Contains, Method::ContainsAny, Method::NotContains, Method::ContainsAll], true)) { + return $this->compileArrayFilter($method, $attribute, $query); + } + + return parent::compileFilter($query); + } + + protected function compileArrayFilter(Method $method, string $attribute, Query $query): string + { + $values = $query->getValues(); + + return match ($method) { + Method::Contains, + Method::ContainsAny => $this->compileJsonOverlapsExpr($attribute, [$values]), + Method::NotContains => 'NOT ' . $this->compileJsonOverlapsExpr($attribute, [$values]), + Method::ContainsAll => $this->compileJsonContainsExpr($attribute, [$values], false), + default => parent::compileFilter($query), + }; + } + + protected 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->compileSpatialCoversPredicate($attribute, $values, false), + Method::NotCovers => $this->compileSpatialCoversPredicate($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 + */ + abstract protected function compileSpatialDistance(Method $method, string $attribute, array $values): string; + + /** + * @param array $values + */ + abstract protected function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string; + + /** + * Compile covers/not-covers spatial predicate. MySQL uses ST_Contains, PostgreSQL uses ST_Covers. + * + * @param array $values + */ + abstract protected function compileSpatialCoversPredicate(string $attribute, array $values, bool $not): string; + + /** + * @param array $values + */ + abstract protected function compileSearchExpr(string $attribute, array $values, bool $not): string; + + protected 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 + */ + abstract protected function compileJsonContainsExpr(string $attribute, array $values, bool $not): string; + + /** + * @param array $values + */ + abstract protected function compileJsonOverlapsExpr(string $attribute, array $values): string; + + /** + * @param array $values + */ + abstract protected function compileJsonPathExpr(string $attribute, array $values): string; +} diff --git a/src/Query/Builder/SQLite.php b/src/Query/Builder/SQLite.php new file mode 100644 index 0000000..4625fab --- /dev/null +++ b/src/Query/Builder/SQLite.php @@ -0,0 +1,318 @@ + */ + protected array $jsonSets = []; + + #[\Override] + protected function createAstSerializer(): Serializer + { + return new SQLiteSerializer(); + } + + #[\Override] + protected function compileRandom(): string + { + return 'RANDOM()'; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileRegex(string $attribute, array $values): string + { + throw new UnsupportedException('REGEXP is not natively supported in SQLite.'); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSearchExpr(string $attribute, array $values, bool $not): string + { + throw new UnsupportedException('Full-text search is not supported in the SQLite query builder.'); + } + + #[\Override] + protected function compileConflictHeader(): string + { + $wrappedKeys = \array_map( + fn (string $key): string => $this->resolveAndWrap($key), + $this->conflictKeys + ); + + return 'ON CONFLICT (' . \implode(', ', $wrappedKeys) . ') DO UPDATE SET'; + } + + #[\Override] + protected function compileConflictAssignment(string $wrapped): string + { + return 'excluded.' . $wrapped; + } + + #[\Override] + public function insertOrIgnore(): Statement + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + foreach ($bindings as $binding) { + $this->addBinding($binding); + } + + $sql = \preg_replace('/^INSERT INTO/', 'INSERT OR IGNORE INTO', $sql, 1) ?? $sql; + + return new Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function setJsonAppend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'json_group_array(value) FROM (SELECT value FROM json_each(IFNULL(' . $this->resolveAndWrap($column) . ', \'[]\')) UNION ALL SELECT value FROM json_each(?))', + [\json_encode($values)], + ); + + return $this; + } + + #[\Override] + public function setJsonPrepend(string $column, array $values): static + { + $this->jsonSets[$column] = new Condition( + 'json_group_array(value) FROM (SELECT value FROM json_each(?) UNION ALL SELECT value FROM json_each(IFNULL(' . $this->resolveAndWrap($column) . ', \'[]\')))', + [\json_encode($values)], + ); + + return $this; + } + + #[\Override] + public function setJsonInsert(string $column, int $index, mixed $value): static + { + $this->jsonSets[$column] = new Condition( + 'json_insert(' . $this->resolveAndWrap($column) . ', \'$[' . $index . ']\', json(?))', + [\json_encode($value)], + ); + + return $this; + } + + #[\Override] + public function setJsonRemove(string $column, mixed $value): static + { + $wrapped = $this->resolveAndWrap($column); + $this->jsonSets[$column] = new Condition( + '(SELECT json_group_array(value) FROM json_each(' . $wrapped . ') WHERE value != json(?))', + [\json_encode($value)], + ); + + return $this; + } + + #[\Override] + public function setJsonIntersect(string $column, array $values): static + { + $wrapped = $this->resolveAndWrap($column); + $this->setRaw($column, '(SELECT json_group_array(value) FROM json_each(IFNULL(' . $wrapped . ', \'[]\')) WHERE value IN (SELECT value FROM json_each(?)))', [\json_encode($values)]); + + return $this; + } + + #[\Override] + public function setJsonDiff(string $column, array $values): static + { + $wrapped = $this->resolveAndWrap($column); + $this->setRaw($column, '(SELECT json_group_array(value) FROM json_each(IFNULL(' . $wrapped . ', \'[]\')) WHERE value NOT IN (SELECT value FROM json_each(?)))', [\json_encode($values)]); + + return $this; + } + + #[\Override] + public function setJsonUnique(string $column): static + { + $wrapped = $this->resolveAndWrap($column); + $this->setRaw($column, '(SELECT json_group_array(DISTINCT value) FROM json_each(IFNULL(' . $wrapped . ', \'[]\')))'); + + return $this; + } + + #[\Override] + public function setJsonPath(string $column, string $path, mixed $value): static + { + if (! \str_starts_with($path, '$')) { + throw new ValidationException('JSON path must start with \'$\': ' . $path); + } + + $this->jsonSets[$column] = new Condition( + 'json_set(' . $this->resolveAndWrap($column) . ', ?, ?)', + [$path, $value], + ); + + return $this; + } + + #[\Override] + public function update(): Statement + { + foreach ($this->jsonSets as $col => $condition) { + $this->setRaw($col, $condition->expression, $condition->bindings); + } + + $result = parent::update(); + $this->jsonSets = []; + + return $result; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialDistance(Method $method, string $attribute, array $values): string + { + throw new UnsupportedException('Spatial distance queries are not supported in SQLite.'); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialPredicate(string $function, string $attribute, array $values, bool $not): string + { + throw new UnsupportedException('Spatial predicates are not supported in SQLite.'); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileSpatialCoversPredicate(string $attribute, array $values, bool $not): string + { + throw new UnsupportedException('Spatial covers predicates are not supported in SQLite.'); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonContainsExpr(string $attribute, array $values, bool $not): string + { + /** @var array $arr */ + $arr = $values[0]; + $placeholders = []; + foreach ((array) $arr as $item) { + $this->addBinding(\json_encode($item)); + $placeholders[] = '?'; + } + + $conditions = \array_map( + fn (string $p) => 'EXISTS (SELECT 1 FROM json_each(' . $attribute . ') WHERE json_each.value = json(' . $p . '))', + $placeholders + ); + + $expr = '(' . \implode(' AND ', $conditions) . ')'; + + return $not ? 'NOT ' . $expr : $expr; + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonOverlapsExpr(string $attribute, array $values): string + { + /** @var array $arr */ + $arr = $values[0]; + $placeholders = []; + foreach ((array) $arr as $item) { + $this->addBinding(\json_encode($item)); + $placeholders[] = '?'; + } + + $conditions = \array_map( + fn (string $p) => 'EXISTS (SELECT 1 FROM json_each(' . $attribute . ') WHERE json_each.value = json(' . $p . '))', + $placeholders + ); + + return '(' . \implode(' OR ', $conditions) . ')'; + } + + /** + * @param array $values + */ + #[\Override] + protected 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 'json_extract(' . $attribute . ', \'$.' . $path . '\') ' . $operator . ' ?'; + } + + #[\Override] + protected function groupConcatExpr(string $column, string $orderBy): string + { + $suffix = $orderBy === '' ? '' : ' ' . $orderBy; + + return 'GROUP_CONCAT(' . $column . $suffix . ', ?)'; + } + + #[\Override] + protected function jsonArrayAggExpr(string $column): string + { + return 'json_group_array(' . $column . ')'; + } + + #[\Override] + protected function jsonObjectAggExpr(string $keyColumn, string $valueColumn): string + { + return 'json_group_object(' . $keyColumn . ', ' . $valueColumn . ')'; + } + + #[\Override] + public function reset(): static + { + parent::reset(); + $this->jsonSets = []; + + return $this; + } + + #[\Override] + protected function wrapUnionMember(string $sql): string + { + // SQLite's compound-SELECT parser rejects parenthesised members, + // so emit the bare SELECT and rely on the UNION keyword alone. + return $sql; + } +} diff --git a/src/Query/Builder/SpatialDistanceFilter.php b/src/Query/Builder/SpatialDistanceFilter.php new file mode 100644 index 0000000..547d6aa --- /dev/null +++ b/src/Query/Builder/SpatialDistanceFilter.php @@ -0,0 +1,32 @@ + $geometry WKT string, or a coordinate array ([x, y] / ring / polygon). + * @param float $distance Distance threshold to compare against. + * @param bool $meters Whether the distance is expressed in meters (sphere / geography path). + */ + public function __construct( + public string|array $geometry, + public float $distance, + public bool $meters, + ) { + } + + /** + * Normalize the raw 3-tuple produced by Query::distance*() into a typed DTO. + * + * @param array{0: string|array, 1: float|int, 2: bool} $tuple + */ + public static function fromTuple(array $tuple): self + { + return new self( + $tuple[0], + (float) $tuple[1], + $tuple[2], + ); + } +} diff --git a/src/Query/Builder/Statement.php b/src/Query/Builder/Statement.php new file mode 100644 index 0000000..5f550aa --- /dev/null +++ b/src/Query/Builder/Statement.php @@ -0,0 +1,35 @@ + $bindings + * @param (\Closure(Statement): (array|int))|null $executor + */ + public function __construct( + public string $query, + public array $bindings, + public bool $readOnly = false, + private ?\Closure $executor = null, + ) { + } + + /** + * @return array|int + */ + public function execute(): array|int + { + if ($this->executor === null) { + throw new \BadMethodCallException('No executor configured on this plan'); + } + + return ($this->executor)($this); + } + + public function withExecutor(\Closure $executor): self + { + return new self($this->query, $this->bindings, $this->readOnly, $executor); + } +} 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 @@ +pendingQueries[] = Query::count($attribute, $alias); + + return $this; + } + + #[\Override] + public function countDistinct(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::countDistinct($attribute, $alias); + + return $this; + } + + #[\Override] + public function sum(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::sum($attribute, $alias); + + return $this; + } + + #[\Override] + public function avg(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::avg($attribute, $alias); + + return $this; + } + + #[\Override] + public function min(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::min($attribute, $alias); + + return $this; + } + + #[\Override] + public function max(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::max($attribute, $alias); + + return $this; + } + + /** + * @param array $columns + */ + #[\Override] + public function groupBy(array $columns): static + { + $this->pendingQueries[] = Query::groupBy($columns); + + return $this; + } + + /** + * @param array $queries + */ + #[\Override] + public function having(array $queries): static + { + $this->pendingQueries[] = Query::having($queries); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/BitwiseAggregates.php b/src/Query/Builder/Trait/BitwiseAggregates.php new file mode 100644 index 0000000..ce06c2a --- /dev/null +++ b/src/Query/Builder/Trait/BitwiseAggregates.php @@ -0,0 +1,32 @@ +pendingQueries[] = Query::bitAnd($attribute, $alias); + + return $this; + } + + #[\Override] + public function bitOr(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::bitOr($attribute, $alias); + + return $this; + } + + #[\Override] + public function bitXor(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::bitXor($attribute, $alias); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/CTEs.php b/src/Query/Builder/Trait/CTEs.php new file mode 100644 index 0000000..6b82445 --- /dev/null +++ b/src/Query/Builder/Trait/CTEs.php @@ -0,0 +1,48 @@ + $columns + */ + #[\Override] + public function with(string $name, Builder $query, array $columns = []): static + { + $result = $query->build(); + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, false, $columns); + + return $this; + } + + /** + * @param list $columns + */ + #[\Override] + public function withRecursive(string $name, Builder $query, array $columns = []): static + { + $result = $query->build(); + $this->ctes[] = new CteClause($name, $result->query, $result->bindings, true, $columns); + + return $this; + } + + /** + * @param list $columns + */ + #[\Override] + public function withRecursiveSeedStep(string $name, Builder $seed, Builder $step, array $columns = []): static + { + $seedResult = $seed->build(); + $stepResult = $step->build(); + $query = $seedResult->query . ' UNION ALL ' . $stepResult->query; + $bindings = \array_merge($seedResult->bindings, $stepResult->bindings); + $this->ctes[] = new CteClause($name, $query, $bindings, true, $columns); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/ClickHouse/ApproximateAggregates.php b/src/Query/Builder/Trait/ClickHouse/ApproximateAggregates.php new file mode 100644 index 0000000..41c6681 --- /dev/null +++ b/src/Query/Builder/Trait/ClickHouse/ApproximateAggregates.php @@ -0,0 +1,197 @@ +resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + /** + * @param list $levels + */ + #[\Override] + public function quantiles(array $levels, string $column, string $alias = ''): static + { + if ($levels === []) { + throw new ValidationException('quantiles() requires at least one level.'); + } + + foreach ($levels as $level) { + if ($level < 0.0 || $level > 1.0) { + throw new ValidationException('quantiles() levels must be in the range [0, 1].'); + } + } + + $expr = 'quantiles(' . \implode(', ', $levels) . ')(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function quantileExact(float $level, string $column, string $alias = ''): static + { + $expr = 'quantileExact(' . $level . ')(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function median(string $column, string $alias = ''): static + { + $expr = 'median(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function uniq(string $column, string $alias = ''): static + { + $expr = 'uniq(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function uniqExact(string $column, string $alias = ''): static + { + $expr = 'uniqExact(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function uniqCombined(string $column, string $alias = ''): static + { + $expr = 'uniqCombined(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function argMin(string $valueColumn, string $argColumn, string $alias = ''): static + { + $expr = 'argMin(' . $this->resolveAndWrap($valueColumn) . ', ' . $this->resolveAndWrap($argColumn) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function argMax(string $valueColumn, string $argColumn, string $alias = ''): static + { + $expr = 'argMax(' . $this->resolveAndWrap($valueColumn) . ', ' . $this->resolveAndWrap($argColumn) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function topK(int $k, string $column, string $alias = ''): static + { + $expr = 'topK(' . $k . ')(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function topKWeighted(int $k, string $column, string $weightColumn, string $alias = ''): static + { + $expr = 'topKWeighted(' . $k . ')(' . $this->resolveAndWrap($column) . ', ' . $this->resolveAndWrap($weightColumn) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function anyValue(string $column, string $alias = ''): static + { + $expr = 'any(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function anyLastValue(string $column, string $alias = ''): static + { + $expr = 'anyLast(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function groupUniqArray(string $column, string $alias = ''): static + { + $expr = 'groupUniqArray(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function groupArrayMovingAvg(string $column, string $alias = ''): static + { + $expr = 'groupArrayMovingAvg(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function groupArrayMovingSum(string $column, string $alias = ''): static + { + $expr = 'groupArrayMovingSum(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } +} diff --git a/src/Query/Builder/Trait/ClickHouse/ArrayJoins.php b/src/Query/Builder/Trait/ClickHouse/ArrayJoins.php new file mode 100644 index 0000000..f299d5e --- /dev/null +++ b/src/Query/Builder/Trait/ClickHouse/ArrayJoins.php @@ -0,0 +1,22 @@ +arrayJoins[] = ['type' => 'ARRAY JOIN', 'column' => $column, 'alias' => $alias]; + + return $this; + } + + #[\Override] + public function leftArrayJoin(string $column, string $alias = ''): static + { + $this->arrayJoins[] = ['type' => 'LEFT ARRAY JOIN', 'column' => $column, 'alias' => $alias]; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/ClickHouse/AsofJoins.php b/src/Query/Builder/Trait/ClickHouse/AsofJoins.php new file mode 100644 index 0000000..270b1a2 --- /dev/null +++ b/src/Query/Builder/Trait/ClickHouse/AsofJoins.php @@ -0,0 +1,89 @@ + $equiPairs + */ + #[\Override] + public function asofJoin( + string $table, + array $equiPairs, + string $leftInequality, + AsofOperator $operator, + string $rightInequality, + string $alias = '', + ): static { + $this->rawJoinClauses[] = $this->buildAsofJoin( + keyword: 'ASOF JOIN', + table: $table, + equiPairs: $equiPairs, + leftInequality: $leftInequality, + operator: $operator, + rightInequality: $rightInequality, + alias: $alias, + ); + + return $this; + } + + /** + * @param array $equiPairs + */ + #[\Override] + public function asofLeftJoin( + string $table, + array $equiPairs, + string $leftInequality, + AsofOperator $operator, + string $rightInequality, + string $alias = '', + ): static { + $this->rawJoinClauses[] = $this->buildAsofJoin( + keyword: 'ASOF LEFT JOIN', + table: $table, + equiPairs: $equiPairs, + leftInequality: $leftInequality, + operator: $operator, + rightInequality: $rightInequality, + alias: $alias, + ); + + return $this; + } + + /** + * @param array $equiPairs + */ + private function buildAsofJoin( + string $keyword, + string $table, + array $equiPairs, + string $leftInequality, + AsofOperator $operator, + string $rightInequality, + string $alias, + ): string { + if ($equiPairs === []) { + throw new ValidationException('ASOF JOIN requires at least one equi-join column pair.'); + } + + $tableExpr = $this->quote($table); + if ($alias !== '') { + $tableExpr .= ' AS ' . $this->quote($alias); + } + + $conditions = []; + foreach ($equiPairs as $left => $right) { + $conditions[] = $this->resolveAndWrap($left) . ' = ' . $this->resolveAndWrap($right); + } + $conditions[] = $this->resolveAndWrap($leftInequality) . ' ' . $operator->value . ' ' . $this->resolveAndWrap($rightInequality); + + return $keyword . ' ' . $tableExpr . ' ON ' . \implode(' AND ', $conditions); + } +} diff --git a/src/Query/Builder/Trait/ClickHouse/LimitBy.php b/src/Query/Builder/Trait/ClickHouse/LimitBy.php new file mode 100644 index 0000000..9bc1aa0 --- /dev/null +++ b/src/Query/Builder/Trait/ClickHouse/LimitBy.php @@ -0,0 +1,17 @@ + $columns + */ + #[\Override] + public function limitBy(int $count, array $columns): static + { + $this->limitByClause = ['count' => $count, 'columns' => $columns]; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/ClickHouse/WithFill.php b/src/Query/Builder/Trait/ClickHouse/WithFill.php new file mode 100644 index 0000000..853d0fe --- /dev/null +++ b/src/Query/Builder/Trait/ClickHouse/WithFill.php @@ -0,0 +1,38 @@ +resolveAndWrap($column) . ' ' . $normalized . ' WITH FILL'; + $bindings = []; + + if ($from !== null) { + $expr .= ' FROM ?'; + $bindings[] = $from; + } + if ($to !== null) { + $expr .= ' TO ?'; + $bindings[] = $to; + } + if ($step !== null) { + $expr .= ' STEP ?'; + $bindings[] = $step; + } + + $this->rawOrders[] = new Condition($expr, $bindings); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/ConditionalAggregates.php b/src/Query/Builder/Trait/ConditionalAggregates.php new file mode 100644 index 0000000..f517278 --- /dev/null +++ b/src/Query/Builder/Trait/ConditionalAggregates.php @@ -0,0 +1,55 @@ +aggregateFilter('COUNT', null, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function sumWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('SUM', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function avgWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('AVG', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function minWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('MIN', $column, $condition, $alias, \array_values($bindings)); + } + + #[\Override] + public function maxWhen(string $column, string $condition, string $alias = '', mixed ...$bindings): static + { + return $this->aggregateFilter('MAX', $column, $condition, $alias, \array_values($bindings)); + } + + /** + * Emit a conditional aggregate using the portable `CASE WHEN ... THEN ... END` pattern. + * + * For COUNT we emit `CASE WHEN cond THEN 1 END` (matching rows counted regardless of column). + * For other aggregates we emit `CASE WHEN cond THEN column END` (NULL branches excluded by SQL aggregates). + * + * @param list $bindings + */ + private function aggregateFilter(string $aggregate, ?string $column, string $condition, string $alias, array $bindings): static + { + $thenBranch = $column === null ? '1' : $this->resolveAndWrap($column); + $expr = $aggregate . '(CASE WHEN ' . $condition . ' THEN ' . $thenBranch . ' END)'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, $bindings); + } +} diff --git a/src/Query/Builder/Trait/Deletes.php b/src/Query/Builder/Trait/Deletes.php new file mode 100644 index 0000000..9575b0c --- /dev/null +++ b/src/Query/Builder/Trait/Deletes.php @@ -0,0 +1,26 @@ +bindings = []; + $this->validateTable(); + + $grouped = Query::groupByType($this->pendingQueries); + + $parts = ['DELETE FROM ' . $this->quote($this->table)]; + + $this->compileWhereClauses($parts, $grouped); + + $this->compileOrderAndLimit($parts, $grouped); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/FullOuterJoins.php b/src/Query/Builder/Trait/FullOuterJoins.php new file mode 100644 index 0000000..4698584 --- /dev/null +++ b/src/Query/Builder/Trait/FullOuterJoins.php @@ -0,0 +1,16 @@ +pendingQueries[] = Query::fullOuterJoin($table, $left, $right, $operator, $alias); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/FullTextSearch.php b/src/Query/Builder/Trait/FullTextSearch.php new file mode 100644 index 0000000..aa2c9df --- /dev/null +++ b/src/Query/Builder/Trait/FullTextSearch.php @@ -0,0 +1,24 @@ +pendingQueries[] = Query::search($attribute, $value); + + return $this; + } + + #[\Override] + public function filterNotSearch(string $attribute, string $value): static + { + $this->pendingQueries[] = Query::notSearch($attribute, $value); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/GroupByModifiers.php b/src/Query/Builder/Trait/GroupByModifiers.php new file mode 100644 index 0000000..35c5a14 --- /dev/null +++ b/src/Query/Builder/Trait/GroupByModifiers.php @@ -0,0 +1,33 @@ +groupByModifier = null; + } +} diff --git a/src/Query/Builder/Trait/Hints.php b/src/Query/Builder/Trait/Hints.php new file mode 100644 index 0000000..2d1905e --- /dev/null +++ b/src/Query/Builder/Trait/Hints.php @@ -0,0 +1,23 @@ + */ + protected array $hints = []; + + #[\Override] + 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; + } +} diff --git a/src/Query/Builder/Trait/Hooks.php b/src/Query/Builder/Trait/Hooks.php new file mode 100644 index 0000000..4995ec2 --- /dev/null +++ b/src/Query/Builder/Trait/Hooks.php @@ -0,0 +1,27 @@ +filterHooks[] = $hook; + } + if ($hook instanceof Attribute) { + $this->attributeHooks[] = $hook; + } + if ($hook instanceof JoinFilter) { + $this->joinFilterHooks[] = $hook; + } + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Inserts.php b/src/Query/Builder/Trait/Inserts.php new file mode 100644 index 0000000..a6424c2 --- /dev/null +++ b/src/Query/Builder/Trait/Inserts.php @@ -0,0 +1,157 @@ + */ + protected array $conflictRawSets = []; + + /** @var array> */ + protected array $conflictRawSetBindings = []; + + #[\Override] + 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 + */ + #[\Override] + public function set(array $row): static + { + $this->rows[] = $row; + + return $this; + } + + /** + * @param string[] $keys + * @param string[] $updateColumns + */ + #[\Override] + 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; + } + + /** + * @param list $columns + */ + #[\Override] + public function fromSelect(array $columns, Builder $source): static + { + $this->insertSelectColumns = $columns; + $this->insertSelectSource = $source; + + return $this; + } + + #[\Override] + public function insert(): Statement + { + $this->bindings = []; + [$sql, $bindings] = $this->compileInsertBody(); + $this->addBindings($bindings); + + return new Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function insertDefaultValues(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $sql = 'INSERT INTO ' . $this->quote($this->table) . ' DEFAULT VALUES'; + + return new Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function insertSelect(): Statement + { + $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; + + $this->addBindings($sourceResult->bindings); + + return new Statement($sql, $this->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/Joins.php b/src/Query/Builder/Trait/Joins.php new file mode 100644 index 0000000..93352f0 --- /dev/null +++ b/src/Query/Builder/Trait/Joins.php @@ -0,0 +1,87 @@ +pendingQueries[] = Query::join($table, $left, $right, $operator, $alias); + + return $this; + } + + #[\Override] + 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; + } + + #[\Override] + 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; + } + + #[\Override] + public function crossJoin(string $table, string $alias = ''): static + { + $this->pendingQueries[] = Query::crossJoin($table, $alias); + + return $this; + } + + #[\Override] + public function naturalJoin(string $table, string $alias = ''): static + { + $this->pendingQueries[] = Query::naturalJoin($table, $alias); + + return $this; + } + + /** + * @param \Closure(JoinBuilder): void $callback + */ + #[\Override] + 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, + JoinType::FullOuter => Method::FullOuterJoin, + JoinType::Natural => Method::NaturalJoin, + default => Method::Join, + }; + + if ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + $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->joins[$index] = $joinBuilder; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Json.php b/src/Query/Builder/Trait/Json.php new file mode 100644 index 0000000..4e69e4a --- /dev/null +++ b/src/Query/Builder/Trait/Json.php @@ -0,0 +1,39 @@ +pendingQueries[] = Query::jsonContains($attribute, $value); + + return $this; + } + + public function filterJsonNotContains(string $attribute, mixed $value): static + { + $this->pendingQueries[] = Query::jsonNotContains($attribute, $value); + + return $this; + } + + /** + * @param array $values + */ + 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; + } +} diff --git a/src/Query/Builder/Trait/LateralJoins.php b/src/Query/Builder/Trait/LateralJoins.php new file mode 100644 index 0000000..d63935c --- /dev/null +++ b/src/Query/Builder/Trait/LateralJoins.php @@ -0,0 +1,24 @@ +lateralJoins[] = new LateralJoin($subquery, $alias, $type); + + return $this; + } + + #[\Override] + public function leftJoinLateral(BaseBuilder $subquery, string $alias): static + { + return $this->joinLateral($subquery, $alias, JoinType::Left); + } +} diff --git a/src/Query/Builder/Trait/Locking.php b/src/Query/Builder/Trait/Locking.php new file mode 100644 index 0000000..3352300 --- /dev/null +++ b/src/Query/Builder/Trait/Locking.php @@ -0,0 +1,56 @@ +lockMode = LockMode::ForUpdate; + + return $this; + } + + #[\Override] + public function forShare(): static + { + $this->lockMode = LockMode::ForShare; + + return $this; + } + + #[\Override] + public function forUpdateSkipLocked(): static + { + $this->lockMode = LockMode::ForUpdateSkipLocked; + + return $this; + } + + #[\Override] + public function forUpdateNoWait(): static + { + $this->lockMode = LockMode::ForUpdateNoWait; + + return $this; + } + + #[\Override] + public function forShareSkipLocked(): static + { + $this->lockMode = LockMode::ForShareSkipLocked; + + return $this; + } + + #[\Override] + public function forShareNoWait(): static + { + $this->lockMode = LockMode::ForShareNoWait; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/MariaDB/Sequences.php b/src/Query/Builder/Trait/MariaDB/Sequences.php new file mode 100644 index 0000000..bceab8c --- /dev/null +++ b/src/Query/Builder/Trait/MariaDB/Sequences.php @@ -0,0 +1,43 @@ +emitSequenceCall('NEXTVAL', $sequence, $alias); + } + + /** + * MariaDB exposes the session-local last value via LASTVAL(seq), not + * CURRVAL(seq). The feature interface uses currVal() for dialect-neutral + * callers and is compiled to LASTVAL() here. + */ + #[\Override] + public function currVal(string $sequence, string $alias = ''): static + { + return $this->emitSequenceCall('LASTVAL', $sequence, $alias); + } + + private function emitSequenceCall(string $function, string $sequence, string $alias): static + { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $sequence)) { + throw new ValidationException('Invalid sequence name: ' . $sequence); + } + + $expression = $function . '(' . $this->quote($sequence) . ')'; + + if ($alias !== '') { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $alias)) { + throw new ValidationException('Invalid sequence alias: ' . $alias); + } + $expression .= ' AS ' . $this->quote($alias); + } + + return $this->selectRaw($expression); + } +} diff --git a/src/Query/Builder/Trait/MongoDB/ArrayPushModifiers.php b/src/Query/Builder/Trait/MongoDB/ArrayPushModifiers.php new file mode 100644 index 0000000..e0e7ef0 --- /dev/null +++ b/src/Query/Builder/Trait/MongoDB/ArrayPushModifiers.php @@ -0,0 +1,33 @@ + $values + * @param array|null $sort + */ + #[\Override] + public function pushEach(string $field, array $values, ?int $position = null, ?int $slice = null, ?array $sort = null): static + { + $this->validateFieldName($field); + $modifier = ['values' => \array_values($values)]; + if ($position !== null) { + $modifier['position'] = $position; + } + if ($slice !== null) { + $modifier['slice'] = $slice; + } + if ($sort !== null) { + $modifier['sort'] = $sort; + } + // Stored under Push with an '__each' marker wrapper so buildUpdate() + // can distinguish modifier-form entries from plain push values. + $this->updateOperations[UpdateOperator::Push->value][$field] = ['__each' => $modifier]; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/MongoDB/AtlasSearch.php b/src/Query/Builder/Trait/MongoDB/AtlasSearch.php new file mode 100644 index 0000000..03b888e --- /dev/null +++ b/src/Query/Builder/Trait/MongoDB/AtlasSearch.php @@ -0,0 +1,60 @@ + $searchDefinition + */ + #[\Override] + public function search(array $searchDefinition, ?string $index = null): static + { + $stage = $searchDefinition; + if ($index !== null) { + $stage['index'] = $index; + } + $this->searchStage = $stage; + + return $this; + } + + /** + * @param array $searchDefinition + */ + #[\Override] + public function searchMeta(array $searchDefinition, ?string $index = null): static + { + $stage = $searchDefinition; + if ($index !== null) { + $stage['index'] = $index; + } + $this->searchMetaStage = $stage; + + return $this; + } + + /** + * @param array $queryVector + * @param array|null $filter + */ + #[\Override] + public function vectorSearch(string $path, array $queryVector, int $numCandidates, int $limit, ?string $index = null, ?array $filter = null): static + { + $stage = [ + 'path' => $path, + 'queryVector' => $queryVector, + 'numCandidates' => $numCandidates, + 'limit' => $limit, + ]; + if ($index !== null) { + $stage['index'] = $index; + } + if ($filter !== null) { + $stage['filter'] = $filter; + } + $this->vectorSearchStage = $stage; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/MongoDB/ConditionalArrayUpdates.php b/src/Query/Builder/Trait/MongoDB/ConditionalArrayUpdates.php new file mode 100644 index 0000000..a1e5d96 --- /dev/null +++ b/src/Query/Builder/Trait/MongoDB/ConditionalArrayUpdates.php @@ -0,0 +1,22 @@ + ['$gte' => 85]]`). + * + * @param array $condition + */ + #[\Override] + public function arrayFilter(string $identifier, array $condition): static + { + $this->arrayFilters[] = $condition; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/MongoDB/FieldUpdates.php b/src/Query/Builder/Trait/MongoDB/FieldUpdates.php new file mode 100644 index 0000000..6241a87 --- /dev/null +++ b/src/Query/Builder/Trait/MongoDB/FieldUpdates.php @@ -0,0 +1,84 @@ +validateFieldName($oldField); + $this->validateFieldName($newField); + $this->setUpdateField(UpdateOperator::Rename, $oldField, $newField); + + return $this; + } + + #[\Override] + public function multiply(string $field, int|float $factor): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Multiply, $field, $factor); + + return $this; + } + + #[\Override] + public function popFirst(string $field): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Pop, $field, -1); + + return $this; + } + + #[\Override] + public function popLast(string $field): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Pop, $field, 1); + + return $this; + } + + /** + * @param array $values + */ + #[\Override] + public function pullAll(string $field, array $values): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::PullAll, $field, $values); + + return $this; + } + + #[\Override] + public function updateMin(string $field, mixed $value): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Min, $field, $value); + + return $this; + } + + #[\Override] + public function updateMax(string $field, mixed $value): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::Max, $field, $value); + + return $this; + } + + #[\Override] + public function currentDate(string $field, string $type = 'date'): static + { + $this->validateFieldName($field); + $this->setUpdateField(UpdateOperator::CurrentDate, $field, ['$type' => $type]); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/MongoDB/PipelineStages.php b/src/Query/Builder/Trait/MongoDB/PipelineStages.php new file mode 100644 index 0000000..5e3237e --- /dev/null +++ b/src/Query/Builder/Trait/MongoDB/PipelineStages.php @@ -0,0 +1,135 @@ + $boundaries + * @param array $output + */ + #[\Override] + public function bucket(string $groupBy, array $boundaries, ?string $defaultBucket = null, array $output = []): static + { + $stage = [ + 'groupBy' => '$' . $groupBy, + 'boundaries' => $boundaries, + ]; + if ($defaultBucket !== null) { + $stage['default'] = $defaultBucket; + } + if (! empty($output)) { + $stage['output'] = $output; + } + $this->bucketStage = $stage; + + return $this; + } + + /** + * @param array $output + */ + #[\Override] + public function bucketAuto(string $groupBy, int $buckets, array $output = []): static + { + $stage = [ + 'groupBy' => '$' . $groupBy, + 'buckets' => $buckets, + ]; + if (! empty($output)) { + $stage['output'] = $output; + } + $this->bucketAutoStage = $stage; + + return $this; + } + + /** + * @param array $facets + */ + #[\Override] + public function facet(array $facets): static + { + $this->facetStages = []; + foreach ($facets as $name => $builder) { + $result = $builder->build(); + /** @var array|null $subOp */ + $subOp = \json_decode($result->query, true); + if ($subOp === null) { + throw new UnsupportedException('Cannot parse facet query for MongoDB.'); + } + $this->facetStages[$name] = [ + 'pipeline' => $this->operationToPipeline($subOp), + 'bindings' => $result->bindings, + ]; + } + + return $this; + } + + #[\Override] + public function graphLookup(string $from, string $startWith, string $connectFromField, string $connectToField, string $as, ?int $maxDepth = null, ?string $depthField = null): static + { + $stage = [ + 'from' => $from, + 'startWith' => '$' . $startWith, + 'connectFromField' => $connectFromField, + 'connectToField' => $connectToField, + 'as' => $as, + ]; + if ($maxDepth !== null) { + $stage['maxDepth'] = $maxDepth; + } + if ($depthField !== null) { + $stage['depthField'] = $depthField; + } + $this->graphLookupStage = $stage; + + return $this; + } + + /** + * @param array|null $on + * @param array|null $whenMatched + * @param array|null $whenNotMatched + */ + #[\Override] + public function mergeIntoCollection(string $collection, ?array $on = null, ?array $whenMatched = null, ?array $whenNotMatched = null): static + { + $stage = ['into' => $collection]; + if ($on !== null) { + $stage['on'] = $on; + } + if ($whenMatched !== null) { + $stage['whenMatched'] = $whenMatched; + } + if ($whenNotMatched !== null) { + $stage['whenNotMatched'] = $whenNotMatched; + } + $this->mergeStage = $stage; + + return $this; + } + + #[\Override] + public function outputToCollection(string $collection, ?string $database = null): static + { + if ($database !== null) { + $this->outStage = ['db' => $database, 'coll' => $collection]; + } else { + $this->outStage = ['coll' => $collection]; + } + + return $this; + } + + #[\Override] + public function replaceRoot(string $newRootExpression): static + { + $this->replaceRootExpr = $newRootExpression; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/AggregateFilter.php b/src/Query/Builder/Trait/PostgreSQL/AggregateFilter.php new file mode 100644 index 0000000..1eba03b --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/AggregateFilter.php @@ -0,0 +1,20 @@ + $bindings + */ + #[\Override] + public function selectAggregateFilter(string $aggregateExpr, string $filterCondition, string $alias = '', array $bindings = []): static + { + $expr = $aggregateExpr . ' FILTER (WHERE ' . $filterCondition . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, $bindings); + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/DistinctOn.php b/src/Query/Builder/Trait/PostgreSQL/DistinctOn.php new file mode 100644 index 0000000..0ac5400 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/DistinctOn.php @@ -0,0 +1,17 @@ + $columns + */ + #[\Override] + public function distinctOn(array $columns): static + { + $this->distinctOnColumns = $columns; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/LockingOf.php b/src/Query/Builder/Trait/PostgreSQL/LockingOf.php new file mode 100644 index 0000000..daff474 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/LockingOf.php @@ -0,0 +1,26 @@ +lockMode = LockMode::ForUpdate; + $this->lockOfTable = $table; + + return $this; + } + + #[\Override] + public function forShareOf(string $table): static + { + $this->lockMode = LockMode::ForShare; + $this->lockOfTable = $table; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/Merge.php b/src/Query/Builder/Trait/PostgreSQL/Merge.php new file mode 100644 index 0000000..ca0ba33 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/Merge.php @@ -0,0 +1,110 @@ +mergeTarget; + $this->mergeTarget = new MergeTarget( + target: $target, + source: $current === null ? null : $current->source, + alias: $current === null ? '' : $current->alias, + condition: $current === null ? '' : $current->condition, + bindings: $current === null ? [] : $current->bindings, + ); + + return $this; + } + + #[\Override] + public function using(BaseBuilder $source, string $alias): static + { + $current = $this->mergeTarget; + $this->mergeTarget = new MergeTarget( + target: $current === null ? '' : $current->target, + source: $source, + alias: $alias, + condition: $current === null ? '' : $current->condition, + bindings: $current === null ? [] : $current->bindings, + ); + + return $this; + } + + #[\Override] + public function on(string $condition, mixed ...$bindings): static + { + $current = $this->mergeTarget; + $this->mergeTarget = new MergeTarget( + target: $current === null ? '' : $current->target, + source: $current === null ? null : $current->source, + alias: $current === null ? '' : $current->alias, + condition: $condition, + bindings: \array_values($bindings), + ); + + return $this; + } + + #[\Override] + public function whenMatched(string $action, mixed ...$bindings): static + { + $this->mergeClauses[] = new MergeClause($action, true, \array_values($bindings)); + + return $this; + } + + #[\Override] + public function whenNotMatched(string $action, mixed ...$bindings): static + { + $this->mergeClauses[] = new MergeClause($action, false, \array_values($bindings)); + + return $this; + } + + #[\Override] + public function executeMerge(): Statement + { + $merge = $this->mergeTarget; + + if ($merge === null || $merge->target === '') { + throw new ValidationException('No merge target specified. Call mergeInto() before executeMerge().'); + } + if ($merge->source === null) { + throw new ValidationException('No merge source specified. Call using() before executeMerge().'); + } + if ($merge->condition === '') { + throw new ValidationException('No merge condition specified. Call on() before executeMerge().'); + } + + $this->bindings = []; + + $sourceResult = $merge->source->build(); + $this->addBindings($sourceResult->bindings); + + $sql = 'MERGE INTO ' . $this->quote($merge->target) + . ' USING (' . $sourceResult->query . ') AS ' . $this->quote($merge->alias) + . ' ON ' . $merge->condition; + + foreach ($merge->bindings as $binding) { + $this->addBinding($binding); + } + + foreach ($this->mergeClauses as $clause) { + $keyword = $clause->matched ? 'WHEN MATCHED THEN' : 'WHEN NOT MATCHED THEN'; + $sql .= ' ' . $keyword . ' ' . $clause->action; + $this->addBindings($clause->bindings); + } + + return new Statement($sql, $this->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/OrderedSetAggregates.php b/src/Query/Builder/Trait/PostgreSQL/OrderedSetAggregates.php new file mode 100644 index 0000000..3d1e476 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/OrderedSetAggregates.php @@ -0,0 +1,83 @@ +resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function boolAnd(string $column, string $alias = ''): static + { + $expr = 'BOOL_AND(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function boolOr(string $column, string $alias = ''): static + { + $expr = 'BOOL_OR(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function every(string $column, string $alias = ''): static + { + $expr = 'EVERY(' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function mode(string $column, string $alias = ''): static + { + $expr = 'MODE() WITHIN GROUP (ORDER BY ' . $this->resolveAndWrap($column) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function percentileCont(float $fraction, string $orderColumn, string $alias = ''): static + { + $expr = 'PERCENTILE_CONT(?) WITHIN GROUP (ORDER BY ' . $this->resolveAndWrap($orderColumn) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, [$fraction]); + } + + #[\Override] + public function percentileDisc(float $fraction, string $orderColumn, string $alias = ''): static + { + $expr = 'PERCENTILE_DISC(?) WITHIN GROUP (ORDER BY ' . $this->resolveAndWrap($orderColumn) . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, [$fraction]); + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/Sequences.php b/src/Query/Builder/Trait/PostgreSQL/Sequences.php new file mode 100644 index 0000000..6fe01e9 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/Sequences.php @@ -0,0 +1,38 @@ +emitSequenceCall('nextval', $sequence, $alias); + } + + #[\Override] + public function currVal(string $sequence, string $alias = ''): static + { + return $this->emitSequenceCall('currval', $sequence, $alias); + } + + private function emitSequenceCall(string $function, string $sequence, string $alias): static + { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $sequence)) { + throw new ValidationException('Invalid sequence name: ' . $sequence); + } + + $expression = $function . "('" . $sequence . "')"; + + if ($alias !== '') { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $alias)) { + throw new ValidationException('Invalid sequence alias: ' . $alias); + } + $expression .= ' AS ' . $this->quote($alias); + } + + return $this->selectRaw($expression); + } +} diff --git a/src/Query/Builder/Trait/PostgreSQL/VectorSearch.php b/src/Query/Builder/Trait/PostgreSQL/VectorSearch.php new file mode 100644 index 0000000..88d91f1 --- /dev/null +++ b/src/Query/Builder/Trait/PostgreSQL/VectorSearch.php @@ -0,0 +1,23 @@ + $vector + */ + #[\Override] + public function orderByVectorDistance(string $attribute, array $vector, VectorMetric $metric = VectorMetric::Cosine): static + { + $this->vectorOrder = [ + 'attribute' => $attribute, + 'vector' => $vector, + 'metric' => $metric, + ]; + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Returning.php b/src/Query/Builder/Trait/Returning.php new file mode 100644 index 0000000..826da8a --- /dev/null +++ b/src/Query/Builder/Trait/Returning.php @@ -0,0 +1,45 @@ + */ + protected array $returningColumns = []; + + /** + * @param list $columns + */ + #[\Override] + public function returning(array $columns = ['*']): static + { + $this->returningColumns = $columns; + + return $this; + } + + protected function appendReturning(Statement $result): Statement + { + if (empty($this->returningColumns)) { + return $result; + } + + $columns = \array_map( + fn (string $col): string => $col === '*' ? '*' : $this->resolveAndWrap($col), + $this->returningColumns + ); + + return new Statement( + $result->query . ' RETURNING ' . \implode(', ', $columns), + $result->bindings, + executor: $this->executor, + ); + } + + protected function resetReturning(): void + { + $this->returningColumns = []; + } +} diff --git a/src/Query/Builder/Trait/Selects.php b/src/Query/Builder/Trait/Selects.php new file mode 100644 index 0000000..a53dc6e --- /dev/null +++ b/src/Query/Builder/Trait/Selects.php @@ -0,0 +1,455 @@ +table = $table; + $this->alias = $alias; + $this->fromSubquery = null; + $this->tableless = ($table === ''); + + return $this; + } + + public function fromNone(): static + { + return $this->from(''); + } + + public function fromSub(Builder $subquery, string $alias): static + { + $this->fromSubquery = new SubSelect($subquery, $alias); + $this->table = ''; + + return $this; + } + + public function selectSub(Builder $subquery, string $alias): static + { + $this->subSelects[] = new SubSelect($subquery, $alias); + + 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 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; + } + + /** + * @param string|array $columns + * @param list $bindings + */ + #[\Override] + public function select(string|array $columns, array $bindings = []): static + { + if (\is_string($columns)) { + $this->rawSelects[] = new Condition($columns, $bindings); + } else { + $this->pendingQueries[] = Query::select($columns); + } + + return $this; + } + + /** + * @param list $bindings + */ + public function selectRaw(string $expression, array $bindings = []): static + { + return $this->select($expression, $bindings); + } + + #[\Override] + public function distinct(): static + { + $this->pendingQueries[] = Query::distinct(); + + return $this; + } + + /** + * @param array $queries + */ + #[\Override] + public function filter(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + /** + * @param array $queries + */ + #[\Override] + public function queries(array $queries): static + { + foreach ($queries as $query) { + $this->pendingQueries[] = $query; + } + + return $this; + } + + #[\Override] + public function selectCast(string $column, string $type, string $alias = ''): static + { + if (!\preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $type)) { + throw new ValidationException('Invalid cast type: ' . $type); + } + + $expr = 'CAST(' . $this->resolveAndWrap($column) . ' AS ' . $type . ')'; + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + $this->rawSelects[] = new Condition($expr, []); + + return $this; + } + + #[\Override] + public function sortAsc(string $attribute, ?NullsPosition $nulls = null): static + { + $this->pendingQueries[] = Query::orderAsc($attribute, $nulls); + + return $this; + } + + #[\Override] + public function sortDesc(string $attribute, ?NullsPosition $nulls = null): static + { + $this->pendingQueries[] = Query::orderDesc($attribute, $nulls); + + return $this; + } + + #[\Override] + public function sortRandom(): static + { + $this->pendingQueries[] = Query::orderRandom(); + + return $this; + } + + #[\Override] + public function limit(int $value): static + { + $this->pendingQueries[] = Query::limit($value); + + return $this; + } + + #[\Override] + public function offset(int $value): static + { + $this->pendingQueries[] = Query::offset($value); + + return $this; + } + + #[\Override] + public function fetch(int $count, bool $withTies = false): static + { + $this->fetchCount = $count; + $this->fetchWithTies = $withTies; + + return $this; + } + + #[\Override] + 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; + } + + #[\Override] + public function cursorAfter(mixed $value): static + { + $this->pendingQueries[] = Query::cursorAfter($value); + + return $this; + } + + #[\Override] + public function cursorBefore(mixed $value): static + { + $this->pendingQueries[] = Query::cursorBefore($value); + + return $this; + } + + #[\Override] + public function when(bool $condition, Closure $callback): static + { + if ($condition) { + $callback($this); + } + + 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; + } + + /** + * Append a raw WHERE fragment with its own bindings. + * + * Caller owns the SQL fragment - no column or operator validation is performed. + * Use this sparingly; prefer `filter()` with typed `Query::*` factories when possible. + * + * @param list $bindings + */ + public function whereRaw(string $expression, array $bindings = []): static + { + $this->rawWheres[] = new Condition($expression, $bindings); + + return $this; + } + + /** + * Append a column-to-column WHERE predicate (e.g. `users.id = orders.user_id`). + * + * Both columns are quoted per dialect. The operator is validated against + * an allowlist: =, !=, <>, <, >, <=, >=. + */ + public function whereColumn(string $left, string $operator, string $right): static + { + if (! \in_array($operator, self::COLUMN_PREDICATE_OPERATORS, true)) { + throw new ValidationException('Invalid whereColumn operator: ' . $operator); + } + + $this->columnPredicates[] = new ColumnPredicate($left, $operator, $right); + + return $this; + } + + public function selectCase(CaseExpression $case): static + { + $this->cases[] = $case; + + return $this; + } + + public function setCase(string $column, CaseExpression $case): static + { + $this->caseSets[$column] = $case; + + return $this; + } + + public function beforeBuild(Closure $callback): static + { + $this->beforeBuildCallbacks[] = $callback; + + return $this; + } + + public function afterBuild(Closure $callback): static + { + $this->afterBuildCallbacks[] = $callback; + + return $this; + } + + /** + * @param \Closure(Statement): (array|int) $executor + */ + public function setExecutor(\Closure $executor): static + { + $this->executor = $executor; + + return $this; + } + + public function explain(bool $analyze = false): Statement + { + $result = $this->build(); + $prefix = $analyze ? 'EXPLAIN ANALYZE ' : 'EXPLAIN '; + + return new Statement($prefix . $result->query, $result->bindings, readOnly: true, executor: $this->executor); + } + + /** + * @return array|int + */ + public function execute(): array|int + { + return $this->build()->execute(); + } + + #[\Override] + 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; + } + + /** + * @return list + */ + #[\Override] + public function getBindings(): array + { + return $this->bindings; + } + + #[\Override] + public function reset(): static + { + $this->pendingQueries = []; + $this->bindings = []; + $this->resolvedAttributeCache = []; + $this->table = ''; + $this->alias = ''; + $this->unions = []; + $this->rows = []; + $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->windowDefinitions = []; + $this->sample = null; + $this->cases = []; + $this->caseSets = []; + $this->whereInSubqueries = []; + $this->subSelects = []; + $this->fromSubquery = null; + $this->tableless = false; + $this->rawOrders = []; + $this->rawGroups = []; + $this->rawHavings = []; + $this->rawWheres = []; + $this->columnPredicates = []; + $this->joins = []; + $this->existsSubqueries = []; + $this->lateralJoins = []; + $this->beforeBuildCallbacks = []; + $this->afterBuildCallbacks = []; + $this->fetchCount = null; + $this->fetchWithTies = false; + // Transient build state — set by prepareAliasQualification() on every + // build(). Clearing them here keeps reset() audit-complete: every + // field mutated in build*/compile* paths is reset. Hook arrays and + // the executor closure are user-installed infrastructure (see + // testResetPreservesAttributeResolver / testResetPreservesConditionProviders) + // and intentionally survive reset(). + $this->qualify = false; + $this->aggregationAliases = []; + + return $this; + } + +} diff --git a/src/Query/Builder/Trait/Spatial.php b/src/Query/Builder/Trait/Spatial.php new file mode 100644 index 0000000..06dfaf3 --- /dev/null +++ b/src/Query/Builder/Trait/Spatial.php @@ -0,0 +1,227 @@ + $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) . ')'; + } + + // Single [lon, lat] pair wrapped in array -> POINT + if (\count($geometry) === 1) { + /** @var array $point */ + $point = $geometry[0]; + + return 'POINT(' . (float) $point[0] . ' ' . (float) $point[1] . ')'; + } + + // 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 . ')'; + } + + protected function getSpatialTypeFromWkt(string $wkt): string + { + $upper = \strtoupper(\trim($wkt)); + if (\str_starts_with($upper, 'POINT')) { + return ColumnType::Point->value; + } + if (\str_starts_with($upper, 'LINESTRING')) { + return ColumnType::Linestring->value; + } + if (\str_starts_with($upper, 'POLYGON')) { + return ColumnType::Polygon->value; + } + + return 'unknown'; + } + + /** + * @param array $point + */ + #[\Override] + 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; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::intersects($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotIntersects(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notIntersects($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::crosses($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotCrosses(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCrosses($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::overlaps($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotOverlaps(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notOverlaps($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::touches($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotTouches(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notTouches($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::covers($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotCovers(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notCovers($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::spatialEquals($attribute, $geometry); + + return $this; + } + + /** + * @param array $geometry + */ + #[\Override] + public function filterNotSpatialEquals(string $attribute, array $geometry): static + { + $this->pendingQueries[] = Query::notSpatialEquals($attribute, $geometry); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/StatisticalAggregates.php b/src/Query/Builder/Trait/StatisticalAggregates.php new file mode 100644 index 0000000..74d32c4 --- /dev/null +++ b/src/Query/Builder/Trait/StatisticalAggregates.php @@ -0,0 +1,56 @@ +pendingQueries[] = Query::stddev($attribute, $alias); + + return $this; + } + + #[\Override] + public function stddevPop(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::stddevPop($attribute, $alias); + + return $this; + } + + #[\Override] + public function stddevSamp(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::stddevSamp($attribute, $alias); + + return $this; + } + + #[\Override] + public function variance(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::variance($attribute, $alias); + + return $this; + } + + #[\Override] + public function varPop(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::varPop($attribute, $alias); + + return $this; + } + + #[\Override] + public function varSamp(string $attribute, string $alias = ''): static + { + $this->pendingQueries[] = Query::varSamp($attribute, $alias); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/StringAggregates.php b/src/Query/Builder/Trait/StringAggregates.php new file mode 100644 index 0000000..64bdbc4 --- /dev/null +++ b/src/Query/Builder/Trait/StringAggregates.php @@ -0,0 +1,71 @@ +|null $orderBy + */ + #[\Override] + public function groupConcat(string $column, string $separator = ',', string $alias = '', ?array $orderBy = null): static + { + $orderByFragment = ''; + if ($orderBy !== null && $orderBy !== []) { + $cols = []; + foreach ($orderBy as $col) { + if (\str_starts_with($col, '-')) { + $cols[] = $this->resolveAndWrap(\substr($col, 1)) . ' DESC'; + } else { + $cols[] = $this->resolveAndWrap($col) . ' ASC'; + } + } + $orderByFragment = 'ORDER BY ' . \implode(', ', $cols); + } + + $expr = $this->groupConcatExpr($this->resolveAndWrap($column), $orderByFragment); + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr, [$separator]); + } + + #[\Override] + public function jsonArrayAgg(string $column, string $alias = ''): static + { + $expr = $this->jsonArrayAggExpr($this->resolveAndWrap($column)); + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + #[\Override] + public function jsonObjectAgg(string $keyColumn, string $valueColumn, string $alias = ''): static + { + $expr = $this->jsonObjectAggExpr( + $this->resolveAndWrap($keyColumn), + $this->resolveAndWrap($valueColumn), + ); + if ($alias !== '') { + $expr .= ' AS ' . $this->quote($alias); + } + + return $this->select($expr); + } + + /** + * Build the dialect-specific GROUP_CONCAT / STRING_AGG expression. + * Receives the already-wrapped column identifier and a compiled + * `ORDER BY ...` fragment (empty string when no ordering requested). + * Implementations must include the single `?` placeholder where the + * separator literal should bind. + */ + abstract protected function groupConcatExpr(string $column, string $orderBy): string; + + abstract protected function jsonArrayAggExpr(string $column): string; + + abstract protected function jsonObjectAggExpr(string $keyColumn, string $valueColumn): string; +} diff --git a/src/Query/Builder/Trait/Transactions.php b/src/Query/Builder/Trait/Transactions.php new file mode 100644 index 0000000..5fab801 --- /dev/null +++ b/src/Query/Builder/Trait/Transactions.php @@ -0,0 +1,44 @@ +executor); + } + + #[\Override] + public function commit(): Statement + { + return new Statement('COMMIT', [], executor: $this->executor); + } + + #[\Override] + public function rollback(): Statement + { + return new Statement('ROLLBACK', [], executor: $this->executor); + } + + #[\Override] + public function savepoint(string $name): Statement + { + return new Statement('SAVEPOINT ' . $this->quote($name), [], executor: $this->executor); + } + + #[\Override] + public function releaseSavepoint(string $name): Statement + { + return new Statement('RELEASE SAVEPOINT ' . $this->quote($name), [], executor: $this->executor); + } + + #[\Override] + public function rollbackToSavepoint(string $name): Statement + { + return new Statement('ROLLBACK TO SAVEPOINT ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/Unions.php b/src/Query/Builder/Trait/Unions.php new file mode 100644 index 0000000..35bbbe4 --- /dev/null +++ b/src/Query/Builder/Trait/Unions.php @@ -0,0 +1,64 @@ +build(); + $this->unions[] = new UnionClause(UnionType::Union, $result->query, $result->bindings); + + return $this; + } + + #[\Override] + public function unionAll(Builder $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::UnionAll, $result->query, $result->bindings); + + return $this; + } + + #[\Override] + public function intersect(Builder $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::Intersect, $result->query, $result->bindings); + + return $this; + } + + #[\Override] + public function intersectAll(Builder $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::IntersectAll, $result->query, $result->bindings); + + return $this; + } + + #[\Override] + public function except(Builder $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::Except, $result->query, $result->bindings); + + return $this; + } + + #[\Override] + public function exceptAll(Builder $other): static + { + $result = $other->build(); + $this->unions[] = new UnionClause(UnionType::ExceptAll, $result->query, $result->bindings); + + return $this; + } +} diff --git a/src/Query/Builder/Trait/Updates.php b/src/Query/Builder/Trait/Updates.php new file mode 100644 index 0000000..5af443e --- /dev/null +++ b/src/Query/Builder/Trait/Updates.php @@ -0,0 +1,45 @@ + $bindings + */ + #[\Override] + public function setRaw(string $column, string $expression, array $bindings = []): static + { + $this->rawSets[$column] = $expression; + $this->rawSetBindings[$column] = $bindings; + + return $this; + } + + #[\Override] + public function update(): Statement + { + $this->bindings = []; + $this->validateTable(); + + $assignments = $this->compileAssignments(); + + if (empty($assignments)) { + throw new ValidationException('No assignments for UPDATE. Call set() or setRaw() before update().'); + } + + $grouped = Query::groupByType($this->pendingQueries); + + $parts = ['UPDATE ' . $this->quote($this->table) . ' SET ' . \implode(', ', $assignments)]; + + $this->compileWhereClauses($parts, $grouped); + + $this->compileOrderAndLimit($parts, $grouped); + + return new Statement(\implode(' ', $parts), $this->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/Upsert.php b/src/Query/Builder/Trait/Upsert.php new file mode 100644 index 0000000..c539e8d --- /dev/null +++ b/src/Query/Builder/Trait/Upsert.php @@ -0,0 +1,102 @@ +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->rows 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->quote($this->insertAlias); + } + + $sql = 'INSERT INTO ' . $tablePart + . ' (' . \implode(', ', $wrappedColumns) . ')' + . ' VALUES ' . \implode(', ', $rowPlaceholders); + + $sql .= ' ' . $this->compileConflictClause(); + + return new Statement($sql, $this->bindings, executor: $this->executor); + } + + #[\Override] + public function upsertSelect(): Statement + { + $this->bindings = []; + $this->validateTable(); + + if ($this->insertSelectSource === null) { + throw new ValidationException('No SELECT source specified. Call fromSelect() before upsertSelect().'); + } + if (empty($this->insertSelectColumns)) { + throw new ValidationException('No columns specified. Call fromSelect() with columns before upsertSelect().'); + } + if (empty($this->conflictKeys)) { + throw new ValidationException('No conflict keys specified. Call onConflict() before upsertSelect().'); + } + if (empty($this->conflictUpdateColumns)) { + throw new ValidationException('No conflict update columns specified. Call onConflict() with update columns before upsertSelect().'); + } + + $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; + + $this->addBindings($sourceResult->bindings); + + $sql .= ' ' . $this->compileConflictClause(); + + return new Statement($sql, $this->bindings, executor: $this->executor); + } +} diff --git a/src/Query/Builder/Trait/Windows.php b/src/Query/Builder/Trait/Windows.php new file mode 100644 index 0000000..ad0ab4b --- /dev/null +++ b/src/Query/Builder/Trait/Windows.php @@ -0,0 +1,31 @@ +windowSelects[] = new WindowSelect($function, $alias, $partitionBy, $orderBy, $windowName, $frame); + + return $this; + } + + #[\Override] + public function window(string $name, ?array $partitionBy = null, ?array $orderBy = null, ?WindowFrame $frame = null): static + { + $this->windowDefinitions[] = new WindowDefinition($name, $partitionBy, $orderBy, $frame); + + return $this; + } +} diff --git a/src/Query/Builder/UnionClause.php b/src/Query/Builder/UnionClause.php new file mode 100644 index 0000000..f75603c --- /dev/null +++ b/src/Query/Builder/UnionClause.php @@ -0,0 +1,16 @@ + $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 $name, + public ?array $partitionBy, + public ?array $orderBy, + public ?WindowFrame $frame = null, + ) { + } +} diff --git a/src/Query/Builder/WindowFrame.php b/src/Query/Builder/WindowFrame.php new file mode 100644 index 0000000..3ffb791 --- /dev/null +++ b/src/Query/Builder/WindowFrame.php @@ -0,0 +1,22 @@ +end === null) { + return $this->type . ' ' . $this->start; + } + + return $this->type . ' BETWEEN ' . $this->start . ' AND ' . $this->end; + } +} diff --git a/src/Query/Builder/WindowSelect.php b/src/Query/Builder/WindowSelect.php new file mode 100644 index 0000000..ffa4334 --- /dev/null +++ b/src/Query/Builder/WindowSelect.php @@ -0,0 +1,20 @@ + $partitionBy + * @param ?list $orderBy + */ + public function __construct( + public string $function, + public string $alias, + public ?array $partitionBy, + public ?array $orderBy, + public ?string $windowName = null, + public ?WindowFrame $frame = null, + ) { + } +} 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 @@ + $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 @@ + $row The row data to write + * @param array $metadata Context about the document (e.g. tenant, permissions) + * @return array The decorated row + */ + public function decorateRow(array $row, array $metadata = []): array; + + /** + * Execute after rows are created in a table. + * + * @param list> $metadata Context for each created document + */ + public function afterCreate(string $table, array $metadata, mixed $context): void; + + /** + * Execute after a row is updated. + * + * @param array $metadata Context about the updated document + */ + public function afterUpdate(string $table, array $metadata, mixed $context): void; + + /** + * Execute after rows are updated in batch. + * + * @param array $updateData The update payload + * @param list> $metadata Context for each updated document + */ + public function afterBatchUpdate(string $table, array $updateData, array $metadata, mixed $context): void; + + /** + * Execute after rows are deleted. + * + * @param list $ids The IDs of deleted rows + */ + public function afterDelete(string $table, array $ids, mixed $context): void; +} diff --git a/src/Query/Method.php b/src/Query/Method.php new file mode 100644 index 0000000..bcd57f4 --- /dev/null +++ b/src/Query/Method.php @@ -0,0 +1,267 @@ + 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, + self::Stddev, + self::StddevPop, + self::StddevSamp, + self::Variance, + self::VarPop, + self::VarSamp, + self::BitAnd, + self::BitOr, + self::BitXor => true, + default => false, + }; + } + + public function isJoin(): bool + { + return match ($this) { + self::Join, + self::LeftJoin, + self::RightJoin, + self::CrossJoin, + self::FullOuterJoin, + self::NaturalJoin => true, + default => false, + }; + } + + /** + * Return the standard SQL function name for aggregation methods, + * or null if this method has no direct SQL-function mapping. + */ + public function sqlFunction(): ?string + { + return match ($this) { + self::Sum => 'SUM', + self::Count => 'COUNT', + self::CountDistinct => 'COUNT', + self::Avg => 'AVG', + self::Min => 'MIN', + self::Max => 'MAX', + self::Stddev => 'STDDEV', + self::StddevPop => 'STDDEV_POP', + self::StddevSamp => 'STDDEV_SAMP', + self::Variance => 'VARIANCE', + self::VarPop => 'VAR_POP', + self::VarSamp => 'VAR_SAMP', + self::BitAnd => 'BIT_AND', + self::BitOr => 'BIT_OR', + self::BitXor => 'BIT_XOR', + default => null, + }; + } +} diff --git a/src/Query/NullsPosition.php b/src/Query/NullsPosition.php new file mode 100644 index 0000000..812cdee --- /dev/null +++ b/src/Query/NullsPosition.php @@ -0,0 +1,9 @@ + + */ + private const READ_COMMANDS = [ + 'find' => true, + 'aggregate' => true, + 'count' => true, + 'distinct' => true, + 'listCollections' => true, + 'listDatabases' => true, + 'listIndexes' => true, + 'dbStats' => true, + 'collStats' => true, + 'explain' => true, + 'getMore' => true, + 'serverStatus' => true, + 'buildInfo' => true, + 'connectionStatus' => true, + 'ping' => true, + 'isMaster' => true, + 'ismaster' => true, + 'hello' => true, + ]; + + /** + * Write command names (lowercase) + * + * @var array + */ + private const WRITE_COMMANDS = [ + 'insert' => true, + 'update' => true, + 'delete' => true, + 'findAndModify' => true, + 'create' => true, + 'drop' => true, + 'createIndexes' => true, + 'dropIndexes' => true, + 'dropDatabase' => true, + 'renameCollection' => true, + ]; + + /** + * Commands that may legitimately carry a `startTransaction: true` flag + * alongside their payload. Only these commands require a BSON scan for + * the transaction flag — for everything else the command name alone + * determines the Type, avoiding the linear scan on the hot path. + * + * @var array + */ + private const TRANSACTION_ELIGIBLE_COMMANDS = [ + 'find' => true, + 'insert' => true, + 'update' => true, + 'delete' => true, + 'aggregate' => true, + 'findAndModify' => true, + ]; + + /** + * MongoDB OP_MSG opcode + */ + private const OP_MSG = 2013; + + /** + * Minimum OP_MSG size: header (16) + flags (4) + section kind (1) + min BSON doc (5) + */ + private const MIN_MSG_SIZE = 26; + + public function parse(string $data): Type + { + $len = \strlen($data); + if ($len < self::MIN_MSG_SIZE) { + return Type::Unknown; + } + + // Verify opcode is OP_MSG (2013) + $opcode = $this->readUint32($data, 12); + if ($opcode !== self::OP_MSG) { + return Type::Unknown; + } + + // Byte 20: section kind (0 = body) + if (\ord($data[20]) !== 0) { + return Type::Unknown; + } + + // BSON document starts at byte 21 + // BSON doc: 4-byte length, then elements + // Each element: type byte, cstring name, value + $bsonOffset = 21; + + // Extract the command name (first BSON key) up front. The command + // name alone determines the Type for the >99% case; only CRUD + // commands can piggy-back a `startTransaction: true` flag and + // therefore warrant the full BSON scan. + $commandName = $this->extractFirstBsonKey($data, $bsonOffset); + + if ($commandName === null) { + return Type::Unknown; + } + + // Transaction end is determined by the command name itself — no scan. + if ($commandName === 'commitTransaction' || $commandName === 'abortTransaction') { + return Type::TransactionEnd; + } + + // Only scan for the startTransaction flag on commands that can + // legitimately carry it. This avoids a linear BSON walk for pings, + // hellos, listCollections, serverStatus, etc. on every packet. + if (isset(self::TRANSACTION_ELIGIBLE_COMMANDS[$commandName]) + && $this->hasBsonKey($data, $bsonOffset, 'startTransaction') + ) { + return Type::TransactionBegin; + } + + // Read commands + if (isset(self::READ_COMMANDS[$commandName])) { + return Type::Read; + } + + // Write commands + if (isset(self::WRITE_COMMANDS[$commandName])) { + return Type::Write; + } + + return Type::Unknown; + } + + /** + * Not applicable — MongoDB does not use SQL + */ + public function classifySQL(string $query): Type + { + return Type::Unknown; + } + + /** + * Not applicable — MongoDB does not use SQL + */ + public function extractKeyword(string $query): string + { + return ''; + } + + /** + * Extract the first key name from a BSON document + * + * BSON element: type byte (1) + cstring key + value + * We only need the key name, not the value. + */ + private function extractFirstBsonKey(string $data, int $bsonOffset): ?string + { + $len = \strlen($data); + + if ($bsonOffset + 4 > $len) { + return null; + } + + $docLen = $this->readUint32($data, $bsonOffset); + + // Reject negative (32-bit PHP signed overflow) or out-of-bounds lengths. + // A valid BSON document is at least 5 bytes (length prefix + terminator). + if ($docLen < 5 || $bsonOffset + $docLen > $len) { + return null; + } + + $docEnd = $bsonOffset + $docLen; + + // Skip BSON document length (4 bytes) + $pos = $bsonOffset + 4; + + if ($pos >= $docEnd) { + return null; + } + + // Type byte (0x00 = end of document) + $type = \ord($data[$pos]); + if ($type === 0x00) { + return null; + } + + $pos++; + + // Read cstring key (null-terminated), bounded by the declared doc length. + $keyStart = $pos; + while ($pos < $docEnd && $data[$pos] !== "\x00") { + $pos++; + } + + if ($pos >= $docEnd) { + return null; + } + + return \substr($data, $keyStart, $pos - $keyStart); + } + + /** + * Check if a BSON document contains a specific key + * + * Scans through BSON elements looking for the key name. + * Skips values based on BSON type to advance through elements. + */ + private function hasBsonKey(string $data, int $bsonOffset, string $targetKey): bool + { + $len = \strlen($data); + + if ($bsonOffset + 4 > $len) { + return false; + } + + $docLen = $this->readUint32($data, $bsonOffset); + + // Reject negative (32-bit PHP signed overflow) or out-of-bounds lengths. + // A valid BSON document is at least 5 bytes (length prefix + terminator). + if ($docLen < 5 || $bsonOffset + $docLen > $len) { + return false; + } + + $docEnd = $bsonOffset + $docLen; + $pos = $bsonOffset + 4; + + while ($pos < $docEnd) { + $type = \ord($data[$pos]); + if ($type === 0x00) { + break; + } + $pos++; + + // Read key name (cstring) + $keyStart = $pos; + while ($pos < $docEnd && $data[$pos] !== "\x00") { + $pos++; + } + + if ($pos >= $docEnd) { + break; + } + + $key = \substr($data, $keyStart, $pos - $keyStart); + $pos++; // skip null terminator + + if ($key === $targetKey) { + return true; + } + + // Skip value based on type + $pos = $this->skipBsonValue($data, $pos, $type, $docEnd); + if ($pos === false) { + break; + } + } + + return false; + } + + /** + * Skip a BSON value to advance past it + * + * @return int|false New position after the value, or false on error + */ + private function skipBsonValue(string $data, int $pos, int $type, int $limit): int|false + { + return match ($type) { + 0x01 => $this->advance($pos, 8, $limit), // double (8 bytes) + 0x02, 0x0D, 0x0E => $this->skipBsonString($data, $pos, $limit), // string, JavaScript, Symbol + 0x03, 0x04 => $this->skipBsonDocument($data, $pos, $limit), // document, array + 0x05 => $this->skipBsonBinary($data, $pos, $limit), // binary + 0x06, 0x0A, 0xFF, 0x7F => $pos, // undefined, null, min/max key (0 bytes) + 0x07 => $this->advance($pos, 12, $limit), // ObjectId (12 bytes) + 0x08 => $this->advance($pos, 1, $limit), // boolean (1 byte) + 0x09, 0x11, 0x12 => $this->advance($pos, 8, $limit), // datetime, timestamp, int64 + 0x0B => $this->skipBsonRegex($data, $pos, $limit), // regex (2 cstrings) + 0x0C => $this->skipBsonDbPointer($data, $pos, $limit), // DBPointer + 0x10 => $this->advance($pos, 4, $limit), // int32 (4 bytes) + 0x13 => $this->advance($pos, 16, $limit), // decimal128 (16 bytes) + default => false, + }; + } + + /** + * Advance $pos by a fixed number of bytes, validating against $limit. + * + * @return int|false New position, or false if the advance overruns the buffer. + */ + private function advance(int $pos, int $bytes, int $limit): int|false + { + if ($pos + $bytes > $limit) { + return false; + } + + return $pos + $bytes; + } + + private function skipBsonString(string $data, int $pos, int $limit): int|false + { + if ($pos + 4 > $limit) { + return false; + } + $strLen = $this->readUint32($data, $pos); + + // On 32-bit PHP `V` yields a signed int; treat negative as invalid. + // Also reject lengths that would advance past the buffer. + if ($strLen < 0 || $strLen > ($limit - $pos - 4)) { + return false; + } + + return $pos + 4 + $strLen; + } + + private function skipBsonDocument(string $data, int $pos, int $limit): int|false + { + if ($pos + 4 > $limit) { + return false; + } + $docLen = $this->readUint32($data, $pos); + + // On 32-bit PHP `V` yields a signed int; treat negative as invalid. + // A valid BSON document is at least 5 bytes (length prefix + terminator). + if ($docLen < 5 || $docLen > $limit - $pos) { + return false; + } + + return $pos + $docLen; + } + + private function skipBsonBinary(string $data, int $pos, int $limit): int|false + { + if ($pos + 4 > $limit) { + return false; + } + $binLen = $this->readUint32($data, $pos); + + // On 32-bit PHP `V` yields a signed int; treat negative as invalid. + // Also reject lengths that would advance past the buffer. + if ($binLen < 0 || $binLen > ($limit - $pos - 5)) { + return false; + } + + return $pos + 4 + 1 + $binLen; // length + subtype byte + data + } + + private function skipBsonRegex(string $data, int $pos, int $limit): int|false + { + // Two cstrings: pattern + options + while ($pos < $limit && $data[$pos] !== "\x00") { + $pos++; + } + if ($pos >= $limit) { + return false; + } + $pos++; // skip null + while ($pos < $limit && $data[$pos] !== "\x00") { + $pos++; + } + if ($pos >= $limit) { + return false; + } + + return $pos + 1; + } + + private function skipBsonDbPointer(string $data, int $pos, int $limit): int|false + { + // string (4-byte len + data) + 12-byte ObjectId + $newPos = $this->skipBsonString($data, $pos, $limit); + if ($newPos === false) { + return false; + } + + return $this->advance($newPos, 12, $limit); + } + + /** + * Read a little-endian uint32 at $offset. Caller must ensure bounds. + */ + private function readUint32(string $data, int $offset): int + { + /** @var array{1: int}|false $unpacked */ + $unpacked = \unpack('V', $data, $offset); + if ($unpacked === false) { + return 0; + } + + return $unpacked[1]; + } +} diff --git a/src/Query/Parser/MySQL.php b/src/Query/Parser/MySQL.php new file mode 100644 index 0000000..6a05b58 --- /dev/null +++ b/src/Query/Parser/MySQL.php @@ -0,0 +1,72 @@ +classifySQL($query); + } + + // Prepared statement commands — always route to primary + if ( + $command === self::COM_STMT_PREPARE + || $command === self::COM_STMT_EXECUTE + || $command === self::COM_STMT_SEND_LONG_DATA + ) { + return Type::Write; + } + + // COM_STMT_CLOSE and COM_STMT_RESET are maintenance — route to primary + if ($command === self::COM_STMT_CLOSE || $command === self::COM_STMT_RESET) { + return Type::Write; + } + + return Type::Unknown; + } +} diff --git a/src/Query/Parser/PostgreSQL.php b/src/Query/Parser/PostgreSQL.php new file mode 100644 index 0000000..22d5539 --- /dev/null +++ b/src/Query/Parser/PostgreSQL.php @@ -0,0 +1,53 @@ +classifySQL($query); + } + + // Extended Query protocol — always route to primary for safety + if ($type === 'P' || $type === 'B' || $type === 'E') { + return Type::Write; + } + + return Type::Unknown; + } +} diff --git a/src/Query/Parser/SQL.php b/src/Query/Parser/SQL.php new file mode 100644 index 0000000..7630770 --- /dev/null +++ b/src/Query/Parser/SQL.php @@ -0,0 +1,501 @@ + + */ + private const READ_KEYWORDS = [ + 'SELECT' => true, + 'SHOW' => true, + 'DESCRIBE' => true, + 'DESC' => true, + 'EXPLAIN' => true, + 'TABLE' => true, + 'VALUES' => true, + ]; + + /** + * Write keywords lookup (uppercase) + * + * @var array + */ + private const WRITE_KEYWORDS = [ + 'INSERT' => true, + 'UPDATE' => true, + 'DELETE' => true, + 'CREATE' => true, + 'DROP' => true, + 'ALTER' => true, + 'TRUNCATE' => true, + 'GRANT' => true, + 'REVOKE' => true, + 'LOCK' => true, + 'CALL' => true, + 'DO' => true, + ]; + + /** + * Transaction-begin keywords (uppercase) + * + * @var array + */ + private const TRANSACTION_BEGIN_KEYWORDS = [ + 'BEGIN' => true, + 'START' => true, + ]; + + /** + * Transaction-end keywords (uppercase) + * + * @var array + */ + private const TRANSACTION_END_KEYWORDS = [ + 'COMMIT' => true, + 'ROLLBACK' => true, + ]; + + /** + * Other transaction keywords (uppercase) + * + * @var array + */ + private const TRANSACTION_KEYWORDS = [ + 'SAVEPOINT' => true, + 'RELEASE' => true, + 'SET' => true, + ]; + + /** + * Classify a SQL query string by its leading keyword + * + * Handles: + * - Leading whitespace (spaces, tabs, newlines) + * - SQL comments: line comments (--) and block comments + * - Mixed case keywords + * - COPY ... TO (read) vs COPY ... FROM (write) + * - CTE: WITH ... SELECT (read) vs WITH ... INSERT/UPDATE/DELETE (write) + */ + public function classifySQL(string $query): Type + { + $keyword = $this->extractKeyword($query); + + if ($keyword === '') { + return Type::Unknown; + } + + // Fast hash-based lookup + if (isset(self::READ_KEYWORDS[$keyword])) { + return Type::Read; + } + + if (isset(self::WRITE_KEYWORDS[$keyword])) { + return Type::Write; + } + + if (isset(self::TRANSACTION_BEGIN_KEYWORDS[$keyword])) { + return Type::TransactionBegin; + } + + if (isset(self::TRANSACTION_END_KEYWORDS[$keyword])) { + return Type::TransactionEnd; + } + + if (isset(self::TRANSACTION_KEYWORDS[$keyword])) { + return Type::Transaction; + } + + // COPY requires directional analysis: COPY ... TO = read, COPY ... FROM = write + if ($keyword === 'COPY') { + return $this->classifyCopy($query); + } + + // WITH (CTE): look at the final statement keyword + if ($keyword === 'WITH') { + return $this->classifyCTE($query); + } + + return Type::Unknown; + } + + /** + * Extract the first SQL keyword from a query string + * + * Skips leading whitespace, SQL comments, and string/identifier literals + * before the first token. Returns the keyword in uppercase. + */ + public function extractKeyword(string $query): string + { + $len = \strlen($query); + $pos = $this->skipInsignificant($query, 0, $len); + + if ($pos >= $len) { + return ''; + } + + // Read keyword until whitespace, '(', ';', or end + $start = $pos; + while ($pos < $len) { + $c = $query[$pos]; + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === '(' || $c === ';') { + break; + } + $pos++; + } + + if ($pos === $start) { + return ''; + } + + return \strtoupper(\substr($query, $start, $pos - $start)); + } + + /** + * Advance past whitespace, comments, and quoted literals/identifiers. + * + * Returns the new position, which may be $len if the rest of the input + * was entirely insignificant. + */ + private function skipInsignificant(string $query, int $pos, int $len): int + { + while ($pos < $len) { + $c = $query[$pos]; + + // Whitespace + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === "\f") { + $pos++; + + continue; + } + + // Line comment: -- ... + if ($c === '-' && ($pos + 1) < $len && $query[$pos + 1] === '-') { + $pos = $this->skipLineComment($query, $pos + 2, $len); + + continue; + } + + // Block comment: /* ... */ + if ($c === '/' && ($pos + 1) < $len && $query[$pos + 1] === '*') { + $pos = $this->skipBlockComment($query, $pos + 2, $len); + + continue; + } + + // Single-quoted string literal + if ($c === "'") { + $pos = $this->skipSingleQuoted($query, $pos + 1, $len); + + continue; + } + + // Double-quoted identifier + if ($c === '"') { + $pos = $this->skipDoubleQuoted($query, $pos + 1, $len); + + continue; + } + + // Backtick-quoted identifier (MySQL) + if ($c === '`') { + $pos = $this->skipBacktickQuoted($query, $pos + 1, $len); + + continue; + } + + // Dollar-quoted string ($tag$...$tag$) + if ($c === '$') { + $skipped = $this->tryskipDollarQuoted($query, $pos, $len); + if ($skipped !== null) { + $pos = $skipped; + + continue; + } + } + + break; + } + + return $pos; + } + + private function skipLineComment(string $query, int $pos, int $len): int + { + while ($pos < $len && $query[$pos] !== "\n") { + $pos++; + } + if ($pos < $len) { + $pos++; // consume the newline + } + + return $pos; + } + + private function skipBlockComment(string $query, int $pos, int $len): int + { + while ($pos < ($len - 1)) { + if ($query[$pos] === '*' && $query[$pos + 1] === '/') { + return $pos + 2; + } + $pos++; + } + + return $len; + } + + private function skipSingleQuoted(string $query, int $pos, int $len): int + { + while ($pos < $len) { + $c = $query[$pos]; + if ($c === "\\" && ($pos + 1) < $len) { + $pos += 2; + + continue; + } + if ($c === "'") { + // Doubled-up single quote is an escape for ' inside the literal + if (($pos + 1) < $len && $query[$pos + 1] === "'") { + $pos += 2; + + continue; + } + + return $pos + 1; + } + $pos++; + } + + return $len; + } + + private function skipDoubleQuoted(string $query, int $pos, int $len): int + { + while ($pos < $len) { + $c = $query[$pos]; + if ($c === '"') { + if (($pos + 1) < $len && $query[$pos + 1] === '"') { + $pos += 2; + + continue; + } + + return $pos + 1; + } + $pos++; + } + + return $len; + } + + private function skipBacktickQuoted(string $query, int $pos, int $len): int + { + while ($pos < $len) { + $c = $query[$pos]; + if ($c === '`') { + if (($pos + 1) < $len && $query[$pos + 1] === '`') { + $pos += 2; + + continue; + } + + return $pos + 1; + } + $pos++; + } + + return $len; + } + + /** + * Try to parse and skip a dollar-quoted PostgreSQL string: $tag$...$tag$. + * + * Returns the new position if a valid dollar-quoted block is found, + * or null if the `$` at $pos does not start a dollar-quoted string. + */ + private function tryskipDollarQuoted(string $query, int $pos, int $len): ?int + { + // Find the closing '$' that ends the opening tag + $tagStart = $pos + 1; + $tagEnd = $tagStart; + while ($tagEnd < $len) { + $c = $query[$tagEnd]; + if ($c === '$') { + break; + } + // Valid tag: letters, digits, underscore + if (! (($c >= 'A' && $c <= 'Z') || ($c >= 'a' && $c <= 'z') || ($c >= '0' && $c <= '9') || $c === '_')) { + return null; + } + $tagEnd++; + } + + if ($tagEnd >= $len) { + return null; + } + + $tag = \substr($query, $pos, $tagEnd - $pos + 1); // includes both $ delimiters + $tagLen = \strlen($tag); + + $scan = $tagEnd + 1; + while ($scan < $len) { + if ($query[$scan] === '$' && ($scan + $tagLen) <= $len + && \substr($query, $scan, $tagLen) === $tag + ) { + return $scan + $tagLen; + } + $scan++; + } + + return $len; + } + + /** + * Classify COPY statement direction + * + * COPY ... TO stdout/file = READ (export) + * COPY ... FROM stdin/file = WRITE (import) + * Default to WRITE for safety + */ + private function classifyCopy(string $query): Type + { + $toPos = \stripos($query, ' TO '); + $fromPos = \stripos($query, ' FROM '); + + if ($toPos !== false && ($fromPos === false || $toPos < $fromPos)) { + return Type::Read; + } + + return Type::Write; + } + + /** + * Classify CTE (WITH ... AS (...) SELECT/INSERT/UPDATE/DELETE ...) + * + * After the CTE definitions, the first read/write keyword at + * parenthesis depth 0 is the main statement. The scanner skips over + * string literals, quoted identifiers, and comments so embedded + * keywords or parens inside literals/comments cannot fool classification. + * + * Default to READ since most CTEs are used with SELECT. + */ + private function classifyCTE(string $query): Type + { + $len = \strlen($query); + $pos = 0; + $depth = 0; + $seenParen = false; + + while ($pos < $len) { + $skipped = $this->skipLiteralOrComment($query, $pos, $len); + if ($skipped !== $pos) { + $pos = $skipped; + + continue; + } + + $c = $query[$pos]; + + if ($c === '(') { + $depth++; + $seenParen = true; + $pos++; + + continue; + } + + if ($c === ')') { + $depth--; + $pos++; + + continue; + } + + // Only look for keywords at depth 0, after we've seen at least one CTE block + if ($depth === 0 && $seenParen && (($c >= 'A' && $c <= 'Z') || ($c >= 'a' && $c <= 'z'))) { + $wordStart = $pos; + while ($pos < $len) { + $ch = $query[$pos]; + if (($ch >= 'A' && $ch <= 'Z') || ($ch >= 'a' && $ch <= 'z') || ($ch >= '0' && $ch <= '9') || $ch === '_') { + $pos++; + } else { + break; + } + } + $word = \strtoupper(\substr($query, $wordStart, $pos - $wordStart)); + + if (isset(self::READ_KEYWORDS[$word])) { + return Type::Read; + } + + if (isset(self::WRITE_KEYWORDS[$word])) { + return Type::Write; + } + + continue; + } + + $pos++; + } + + return Type::Read; + } + + /** + * If $pos starts a string literal, quoted identifier, or comment, + * advance past it and return the new position. Otherwise return $pos + * unchanged. + */ + private function skipLiteralOrComment(string $query, int $pos, int $len): int + { + if ($pos >= $len) { + return $pos; + } + + $c = $query[$pos]; + + if ($c === '-' && ($pos + 1) < $len && $query[$pos + 1] === '-') { + return $this->skipLineComment($query, $pos + 2, $len); + } + + if ($c === '/' && ($pos + 1) < $len && $query[$pos + 1] === '*') { + return $this->skipBlockComment($query, $pos + 2, $len); + } + + if ($c === "'") { + return $this->skipSingleQuoted($query, $pos + 1, $len); + } + + if ($c === '"') { + return $this->skipDoubleQuoted($query, $pos + 1, $len); + } + + if ($c === '`') { + return $this->skipBacktickQuoted($query, $pos + 1, $len); + } + + if ($c === '$') { + $skipped = $this->tryskipDollarQuoted($query, $pos, $len); + if ($skipped !== null) { + return $skipped; + } + } + + return $pos; + } +} diff --git a/src/Query/Query.php b/src/Query/Query.php index 49b3c7d..c78a744 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -3,194 +3,25 @@ namespace Utopia\Query; use JsonException; +use Utopia\Query\Builder\ParsedQuery; use Utopia\Query\Exception as QueryException; +use Utopia\Query\Exception\ValidationException; /** @phpstan-consistent-constructor */ class Query { - // Filter methods - public const TYPE_EQUAL = 'equal'; - - public const TYPE_NOT_EQUAL = 'notEqual'; - - public const TYPE_LESSER = 'lessThan'; - - public const TYPE_LESSER_EQUAL = 'lessThanEqual'; - - public const TYPE_GREATER = 'greaterThan'; - - public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; - - public const TYPE_CONTAINS = 'contains'; - - public const TYPE_CONTAINS_ANY = 'containsAny'; - - public const TYPE_NOT_CONTAINS = 'notContains'; - - public const TYPE_SEARCH = 'search'; - - public const TYPE_NOT_SEARCH = 'notSearch'; - - public const TYPE_IS_NULL = 'isNull'; - - public const TYPE_IS_NOT_NULL = 'isNotNull'; - - public const TYPE_BETWEEN = 'between'; - - public const TYPE_NOT_BETWEEN = 'notBetween'; - - public const TYPE_STARTS_WITH = 'startsWith'; - - public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; - - public const TYPE_ENDS_WITH = 'endsWith'; - - public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; - - public const TYPE_REGEX = 'regex'; - - public const TYPE_EXISTS = 'exists'; - - public const TYPE_NOT_EXISTS = 'notExists'; - - // Spatial methods - public const TYPE_CROSSES = 'crosses'; - - public const TYPE_NOT_CROSSES = 'notCrosses'; - - public const TYPE_DISTANCE_EQUAL = 'distanceEqual'; - - public const TYPE_DISTANCE_NOT_EQUAL = 'distanceNotEqual'; - - public const TYPE_DISTANCE_GREATER_THAN = 'distanceGreaterThan'; - - public const TYPE_DISTANCE_LESS_THAN = 'distanceLessThan'; - - public const TYPE_INTERSECTS = 'intersects'; - - public const TYPE_NOT_INTERSECTS = 'notIntersects'; - - public const TYPE_OVERLAPS = 'overlaps'; - - public const TYPE_NOT_OVERLAPS = 'notOverlaps'; - - public const TYPE_TOUCHES = 'touches'; - - public const TYPE_NOT_TOUCHES = 'notTouches'; - - // Vector query methods - public const TYPE_VECTOR_DOT = 'vectorDot'; - - public const TYPE_VECTOR_COSINE = 'vectorCosine'; - - public const TYPE_VECTOR_EUCLIDEAN = 'vectorEuclidean'; - - public const TYPE_SELECT = 'select'; - - // Order methods - public const TYPE_ORDER_DESC = 'orderDesc'; - - public const TYPE_ORDER_ASC = 'orderAsc'; - - public const TYPE_ORDER_RANDOM = 'orderRandom'; - - // Pagination methods - public const TYPE_LIMIT = 'limit'; - - public const TYPE_OFFSET = 'offset'; - - public const TYPE_CURSOR_AFTER = 'cursorAfter'; - - public const TYPE_CURSOR_BEFORE = 'cursorBefore'; - - // Logical methods - public const TYPE_AND = 'and'; - - public const TYPE_OR = 'or'; - - public const TYPE_CONTAINS_ALL = 'containsAll'; - - public const TYPE_ELEM_MATCH = 'elemMatch'; - public const DEFAULT_ALIAS = 'main'; - // Order direction constants (inlined from Database) - public const ORDER_ASC = 'ASC'; - - public const ORDER_DESC = 'DESC'; - - public const ORDER_RANDOM = 'RANDOM'; - - // Cursor direction constants (inlined from Database) - public const CURSOR_AFTER = 'after'; - - public const CURSOR_BEFORE = 'before'; - - public const TYPES = [ - 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_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_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - self::TYPE_EXISTS, - self::TYPE_NOT_EXISTS, - self::TYPE_SELECT, - self::TYPE_ORDER_DESC, - self::TYPE_ORDER_ASC, - self::TYPE_ORDER_RANDOM, - self::TYPE_LIMIT, - self::TYPE_OFFSET, - self::TYPE_CURSOR_AFTER, - self::TYPE_CURSOR_BEFORE, - self::TYPE_AND, - self::TYPE_OR, - self::TYPE_CONTAINS_ALL, - self::TYPE_ELEM_MATCH, - self::TYPE_REGEX, - ]; - - public const VECTOR_TYPES = [ - self::TYPE_VECTOR_DOT, - self::TYPE_VECTOR_COSINE, - self::TYPE_VECTOR_EUCLIDEAN, - ]; - - protected const LOGICAL_TYPES = [ - self::TYPE_AND, - self::TYPE_OR, - self::TYPE_ELEM_MATCH, + /** + * @var list + */ + protected const array LOGICAL_TYPES = [ + Method::And, + Method::Or, + Method::ElemMatch, ]; - protected string $method = ''; + protected Method $method; protected string $attribute = ''; @@ -208,9 +39,9 @@ class Query * * @param array $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 +55,7 @@ public function __clone(): void } } - public function getMethod(): string + public function getMethod(): Method { return $this->method; } @@ -250,9 +81,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 +125,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,29 +133,20 @@ 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(); } /** - * Parse query + * Parse query from a JSON string. + * + * Raw queries (`Method::Raw`) are rejected by default: they bypass the + * binding/escaping pipeline and are a known SQL injection vector when + * the JSON comes from an untrusted source. Set `$allowRaw = true` only + * when the JSON is trusted (e.g. round-tripped from an in-process cache). * * @throws QueryException */ - public static function parse(string $query): static + public static function parse(string $query, bool $allowRaw = false): static { try { $query = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); @@ -388,17 +159,19 @@ public static function parse(string $query): static } /** @var array $query */ - return static::parseQuery($query); + return static::parseQuery($query, $allowRaw); } /** - * Parse query + * Parse query from a decoded array. + * + * Raw queries (`Method::Raw`) are rejected by default — see `parse()`. * * @param array $query * * @throws QueryException */ - public static function parseQuery(array $query): static + public static function parseQuery(array $query, bool $allowRaw = false): static { $method = $query['method'] ?? ''; $attribute = $query['attribute'] ?? ''; @@ -420,30 +193,38 @@ 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 === Method::Raw && ! $allowRaw) { + throw new ValidationException('Raw queries cannot be parsed from untrusted input; construct via Query::raw() in code'); + } + + if ($methodEnum->isNested()) { foreach ($values as $index => $value) { /** @var array $value */ - $values[$index] = static::parseQuery($value); + $values[$index] = static::parseQuery($value, $allowRaw); } } - return new static($method, $attribute, $values); + return new static($methodEnum, $attribute, $values); } /** - * Parse an array of queries + * Parse an array of queries. + * + * Raw queries (`Method::Raw`) are rejected by default — see `parse()`. * * @param array $queries * @return array * * @throws QueryException */ - public static function parseQueries(array $queries): array + public static function parseQueries(array $queries, bool $allowRaw = false): array { $parsed = []; foreach ($queries as $query) { - $parsed[] = static::parse($query); + $parsed[] = static::parse($query, $allowRaw); } return $parsed; @@ -526,7 +307,7 @@ public function shape(): string $id = \spl_object_id($node); if (! \in_array($node->method, self::LOGICAL_TYPES, true)) { - $shapes[$id] = $node->method.':'.$node->attribute; + $shapes[$id] = $node->method->value.':'.$node->attribute; continue; } @@ -540,7 +321,7 @@ public function shape(): string \sort($childShapes); // Attribute is empty for and/or; meaningful for elemMatch (the field being matched). - $shapes[$id] = $node->method.':'.$node->attribute.'('.\implode('|', $childShapes).')'; + $shapes[$id] = $node->method->value.':'.$node->attribute.'('.\implode('|', $childShapes).')'; } return $shapes[\spl_object_id($this)]; @@ -551,13 +332,13 @@ public function shape(): string */ 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(); @@ -572,6 +353,47 @@ 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, + Method::Stddev, + Method::StddevPop, + Method::StddevSamp, + Method::Variance, + Method::VarPop, + Method::VarSamp, + Method::BitAnd, + Method::BitOr, + Method::BitXor => $compiler->compileAggregate($this), + Method::GroupBy => $compiler->compileGroupBy($this), + Method::Join, + Method::LeftJoin, + Method::RightJoin, + Method::CrossJoin, + Method::FullOuterJoin, + Method::NaturalJoin => $compiler->compileJoin($this), + Method::Having => $compiler->compileFilter($this), + default => $compiler->compileFilter($this), + }; + } + /** * @throws QueryException */ @@ -587,26 +409,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); } /** @@ -614,7 +436,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]); } /** @@ -622,7 +444,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]); } /** @@ -630,7 +452,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]); } /** @@ -638,19 +460,31 @@ 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. + * Helper method to create Query with contains method. * * @param array $values + * + * @deprecated Use containsString() for string substring matching or containsAny() for array attributes. */ + #[\Deprecated('Use containsString() for string substring matching or containsAny() for array attributes.')] public static function contains(string $attribute, array $values): static { - return new static(self::TYPE_CONTAINS, $attribute, $values); + return new static(Method::Contains, $attribute, $values); + } + + /** + * Helper method to create Query for string substring matching. + * Compiles to LIKE '%value%' for each given value. + * + * @param array $values + */ + public static function containsString(string $attribute, array $values): static + { + return new static(Method::Contains, $attribute, $values); } /** @@ -661,7 +495,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); } /** @@ -671,7 +505,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); } /** @@ -679,7 +513,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]); } /** @@ -687,7 +521,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]); } /** @@ -695,7 +529,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]); } /** @@ -703,7 +537,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]); } /** @@ -713,23 +547,23 @@ 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); } /** * Helper method to create Query with orderDesc method */ - public static function orderDesc(string $attribute = ''): static + public static function orderDesc(string $attribute = '', ?NullsPosition $nulls = null): static { - return new static(self::TYPE_ORDER_DESC, $attribute); + return new static(Method::OrderDesc, $attribute, $nulls !== null ? [$nulls] : []); } /** * Helper method to create Query with orderAsc method */ - public static function orderAsc(string $attribute = ''): static + public static function orderAsc(string $attribute = '', ?NullsPosition $nulls = null): static { - return new static(self::TYPE_ORDER_ASC, $attribute); + return new static(Method::OrderAsc, $attribute, $nulls !== null ? [$nulls] : []); } /** @@ -737,7 +571,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); } /** @@ -745,7 +579,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]); } /** @@ -753,7 +587,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]); } /** @@ -761,7 +595,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]); } /** @@ -769,7 +603,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]); } /** @@ -777,7 +611,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); } /** @@ -785,27 +619,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]); } /** @@ -861,7 +695,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); } /** @@ -869,7 +703,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); } /** @@ -877,14 +711,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 @@ -909,8 +743,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 ); @@ -920,25 +754,19 @@ public static function getCursorQueries(array $queries, bool $clone = true): arr * Iterates through queries and groups them by type * * @param array $queries - * @return array{ - * filters: list, - * selections: list, - * limit: int|null, - * offset: int|null, - * orderAttributes: list, - * orderTypes: list, - * cursor: mixed, - * cursorDirection: string|null - * } - */ - public static function groupByType(array $queries): array + */ + public static function groupByType(array $queries): ParsedQuery { $filters = []; $selections = []; + $aggregations = []; + $groupBy = []; + $having = []; + $distinct = false; + $joins = []; + $unions = []; $limit = null; $offset = null; - $orderAttributes = []; - $orderTypes = []; $cursor = null; $cursorDirection = null; @@ -948,53 +776,88 @@ public static function groupByType(array $queries): array } $method = $query->getMethod(); - $attribute = $query->getAttribute(); $values = $query->getValues(); - switch ($method) { - case Query::TYPE_ORDER_ASC: - case Query::TYPE_ORDER_DESC: - case Query::TYPE_ORDER_RANDOM: - if (! empty($attribute)) { - $orderAttributes[] = $attribute; - } + switch (true) { + case $method === Method::OrderAsc: + case $method === Method::OrderDesc: + case $method === Method::OrderRandom: + // Ordering is compiled directly from the pending query list + // in Builder::compileOrderAndLimit; no aggregation needed + // here. + break; - $orderTypes[] = match ($method) { - Query::TYPE_ORDER_ASC => self::ORDER_ASC, - Query::TYPE_ORDER_DESC => self::ORDER_DESC, - Query::TYPE_ORDER_RANDOM => self::ORDER_RANDOM, - }; + case $method === Method::Limit: + if ($limit === null && isset($values[0]) && \is_numeric($values[0])) { + $limit = \intval($values[0]); + } + break; + case $method === Method::Offset: + if ($offset === null && isset($values[0]) && \is_numeric($values[0])) { + $offset = \intval($values[0]); + } break; - case Query::TYPE_LIMIT: - // Keep the 1st limit encountered and ignore the rest - if ($limit !== null) { - break; + + case $method === Method::CursorAfter: + case $method === Method::CursorBefore: + if ($cursor === null) { + $cursor = $values[0] ?? null; + $cursorDirection = $method === Method::CursorAfter + ? CursorDirection::After + : CursorDirection::Before; } + break; - $limit = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $limit; + case $method === Method::Select: + $selections[] = clone $query; break; - case Query::TYPE_OFFSET: - // Keep the 1st offset encountered and ignore the rest - if ($offset !== null) { - break; - } - $offset = isset($values[0]) && \is_numeric($values[0]) ? \intval($values[0]) : $offset; + case $method === Method::Count: + case $method === Method::CountDistinct: + case $method === Method::Sum: + case $method === Method::Avg: + case $method === Method::Min: + case $method === Method::Max: + case $method === Method::Stddev: + case $method === Method::StddevPop: + case $method === Method::StddevSamp: + case $method === Method::Variance: + case $method === Method::VarPop: + case $method === Method::VarSamp: + case $method === Method::BitAnd: + case $method === Method::BitOr: + case $method === Method::BitXor: + $aggregations[] = clone $query; break; - case Query::TYPE_CURSOR_AFTER: - case Query::TYPE_CURSOR_BEFORE: - // Keep the 1st cursor encountered and ignore the rest - if ($cursor !== null) { - break; + + case $method === Method::GroupBy: + /** @var array $values */ + foreach ($values as $col) { + $groupBy[] = $col; } + break; - $cursor = $values[0] ?? $limit; - $cursorDirection = $method === Query::TYPE_CURSOR_AFTER ? self::CURSOR_AFTER : self::CURSOR_BEFORE; + case $method === Method::Having: + $having[] = clone $query; break; - case Query::TYPE_SELECT: - $selections[] = clone $query; + case $method === Method::Distinct: + $distinct = true; + break; + + case $method === Method::Join: + case $method === Method::LeftJoin: + case $method === Method::RightJoin: + case $method === Method::CrossJoin: + case $method === Method::FullOuterJoin: + case $method === Method::NaturalJoin: + $joins[] = clone $query; + break; + + case $method === Method::Union: + case $method === Method::UnionAll: + $unions[] = clone $query; break; default: @@ -1003,16 +866,20 @@ public static function groupByType(array $queries): array } } - return [ - 'filters' => $filters, - 'selections' => $selections, - 'limit' => $limit, - 'offset' => $offset, - 'orderAttributes' => $orderAttributes, - 'orderTypes' => $orderTypes, - 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, - ]; + return new ParsedQuery( + filters: $filters, + selections: $selections, + aggregations: $aggregations, + groupBy: $groupBy, + having: $having, + distinct: $distinct, + joins: $joins, + unions: $unions, + limit: $limit, + offset: $offset, + cursor: $cursor, + cursorDirection: $cursorDirection, + ); } /** @@ -1020,11 +887,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 @@ -1056,7 +919,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]]); } /** @@ -1066,7 +929,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]]); } /** @@ -1076,7 +939,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]]); } /** @@ -1086,7 +949,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]]); } /** @@ -1096,7 +959,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]); } /** @@ -1106,7 +969,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]); } /** @@ -1116,7 +979,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]); } /** @@ -1126,7 +989,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]); } /** @@ -1136,7 +999,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]); } /** @@ -1146,7 +1009,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]); } /** @@ -1156,7 +1019,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]); } /** @@ -1166,7 +1029,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]); } /** @@ -1176,7 +1039,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]); } /** @@ -1186,7 +1049,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]); } /** @@ -1196,7 +1059,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]); } /** @@ -1204,7 +1067,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]); } /** @@ -1214,7 +1077,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); } /** @@ -1224,7 +1087,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]); } /** @@ -1232,6 +1095,389 @@ 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] : []); + } + + public static function stddev(string $attribute, string $alias = ''): static + { + return new static(Method::Stddev, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function stddevPop(string $attribute, string $alias = ''): static + { + return new static(Method::StddevPop, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function stddevSamp(string $attribute, string $alias = ''): static + { + return new static(Method::StddevSamp, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function variance(string $attribute, string $alias = ''): static + { + return new static(Method::Variance, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function varPop(string $attribute, string $alias = ''): static + { + return new static(Method::VarPop, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function varSamp(string $attribute, string $alias = ''): static + { + return new static(Method::VarSamp, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function bitAnd(string $attribute, string $alias = ''): static + { + return new static(Method::BitAnd, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function bitOr(string $attribute, string $alias = ''): static + { + return new static(Method::BitOr, $attribute, $alias !== '' ? [$alias] : []); + } + + public static function bitXor(string $attribute, string $alias = ''): static + { + return new static(Method::BitXor, $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] : []); + } + + public static function fullOuterJoin(string $table, string $left, string $right, string $operator = '=', string $alias = ''): static + { + $values = [$left, $operator, $right]; + if ($alias !== '') { + $values[] = $alias; + } + + return new static(Method::FullOuterJoin, $table, $values); + } + + public static function naturalJoin(string $table, string $alias = ''): static + { + return new static(Method::NaturalJoin, $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 ValidationException('Page must be >= 1, got ' . $page); + } + if ($perPage < 1) { + throw new 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(); + \array_push($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..6a4b7c6 --- /dev/null +++ b/src/Query/QuotesIdentifiers.php @@ -0,0 +1,44 @@ +wrapChar + . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $identifier) + . $this->wrapChar; + } + + $segments = \explode('.', $identifier); + $lastIndex = \count($segments) - 1; + $wrapped = []; + + foreach ($segments as $index => $segment) { + if ($segment === '*' && $index === $lastIndex) { + $wrapped[] = '*'; + continue; + } + + $wrapped[] = $this->wrapChar + . \str_replace($this->wrapChar, $this->wrapChar . $this->wrapChar, $segment) + . $this->wrapChar; + } + + return \implode('.', $wrapped); + } +} diff --git a/src/Query/Schema.php b/src/Query/Schema.php new file mode 100644 index 0000000..3105a65 --- /dev/null +++ b/src/Query/Schema.php @@ -0,0 +1,471 @@ +|int))|null */ + protected ?Closure $executor = null; + + /** + * @param Closure(Statement): (array|int) $executor + */ + public function setExecutor(Closure $executor): static + { + $this->executor = $executor; + + return $this; + } + + abstract protected function quote(string $identifier): string; + + abstract protected function compileColumnType(Column $column): string; + + abstract protected function compileAutoIncrement(): string; + + /** + * @param callable(Table): void $definition + */ + public function createIfNotExists(string $table, callable $definition): Statement + { + return $this->create($table, $definition, true); + } + + /** + * @param callable(Table): void $definition + */ + public function create(string $table, callable $definition, bool $ifNotExists = false): Statement + { + $blueprint = new Table(); + $definition($blueprint); + + if ($blueprint->ttl !== null) { + throw new UnsupportedException('TTL is only supported in ClickHouse.'); + } + + $columnDefs = []; + $primaryKeys = []; + $uniqueColumns = []; + + foreach ($blueprint->columns as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + if ($column->isUnique) { + $uniqueColumns[] = $column->name; + } + } + + if (! empty($blueprint->compositePrimaryKey) && ! empty($primaryKeys)) { + throw new ValidationException('Cannot combine column-level primary() with Table::primary() composite key.'); + } + + // 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) . ')'; + } elseif (! empty($blueprint->compositePrimaryKey)) { + $columnDefs[] = 'PRIMARY KEY (' + . \implode(', ', \array_map(fn (string $c): string => $this->quote($c), $blueprint->compositePrimaryKey)) + . ')'; + } + + // Inline UNIQUE constraints for columns marked unique + foreach ($uniqueColumns as $col) { + $columnDefs[] = 'UNIQUE (' . $this->quote($col) . ')'; + } + + // Table-level CHECK constraints + foreach ($blueprint->checks as $check) { + $columnDefs[] = 'CONSTRAINT ' . $this->quote($check->name) . ' CHECK (' . $check->expression . ')'; + } + + // 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->toSql(); + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->toSql(); + } + $columnDefs[] = $def; + } + + $sql = 'CREATE TABLE ' . ($ifNotExists ? 'IF NOT EXISTS ' : '') . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')'; + + if ($blueprint->partitionType !== null) { + $sql .= ' PARTITION BY ' . $blueprint->partitionType->value . '(' . $blueprint->partitionExpression . ')'; + if ($blueprint->partitionCount !== null) { + $sql .= ' PARTITIONS ' . $blueprint->partitionCount; + } + } + + return new Statement($sql, [], executor: $this->executor); + } + + /** + * @param callable(Table): void $definition + */ + public function alter(string $table, callable $definition): Statement + { + $blueprint = new Table(); + $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->toSql(); + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->toSql(); + } + $alterations[] = $def; + } + + foreach ($blueprint->dropForeignKeys as $name) { + $alterations[] = 'DROP FOREIGN KEY ' . $this->quote($name); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new Statement($sql, [], executor: $this->executor); + } + + public function drop(string $table): Statement + { + return new Statement('DROP TABLE ' . $this->quote($table), [], executor: $this->executor); + } + + public function dropIfExists(string $table): Statement + { + return new Statement('DROP TABLE IF EXISTS ' . $this->quote($table), [], executor: $this->executor); + } + + public function rename(string $from, string $to): Statement + { + return new Statement( + 'RENAME TABLE ' . $this->quote($from) . ' TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } + + public function truncate(string $table): Statement + { + return new Statement('TRUNCATE TABLE ' . $this->quote($table), [], executor: $this->executor); + } + + /** + * @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 = [], + ): Statement { + $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 Statement($sql, [], executor: $this->executor); + } + + public function dropIndex(string $table, string $name): Statement + { + return new Statement( + 'DROP INDEX ' . $this->quote($name) . ' ON ' . $this->quote($table), + [], + executor: $this->executor, + ); + } + + public function createView(string $name, Builder $query): Statement + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new Statement($sql, $result->bindings, executor: $this->executor); + } + + public function createOrReplaceView(string $name, Builder $query): Statement + { + $result = $query->build(); + $sql = 'CREATE OR REPLACE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new Statement($sql, $result->bindings, executor: $this->executor); + } + + public function dropView(string $name): Statement + { + return new Statement('DROP VIEW ' . $this->quote($name), [], executor: $this->executor); + } + + protected function compileColumnDefinition(Column $column): string + { + if ($column->ttl !== null) { + throw new UnsupportedException('TTL is only supported in ClickHouse.'); + } + + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->isUnsigned) { + $unsigned = $this->compileUnsigned(); + if ($unsigned !== '') { + $parts[] = $unsigned; + } + } + + if ($column->generatedExpression !== null) { + $parts[] = $this->compileGeneratedClause($column); + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->checkExpression !== null) { + $parts[] = 'CHECK (' . $column->checkExpression . ')'; + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace(['\\', "'"], ['\\\\', "''"], $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + 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->checkExpression !== null) { + $parts[] = 'CHECK (' . $column->checkExpression . ')'; + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace(['\\', "'"], ['\\\\', "''"], $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + /** + * Compile the `GENERATED ALWAYS AS (...) [STORED|VIRTUAL]` clause. + * + * Default storage is VIRTUAL when unspecified, per SQL standard. + */ + protected function compileGeneratedClause(Column $column): string + { + $clause = 'GENERATED ALWAYS AS (' . $column->generatedExpression . ')'; + $stored = $column->generatedStored ?? false; + + return $clause . ' ' . ($stored ? 'STORED' : 'VIRTUAL'); + } + + 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. + * + * @throws ValidationException if a collation or order value is not safe to emit inline. + */ + protected function compileIndexColumns(Schema\Index $index): string + { + $parts = []; + + foreach ($index->columns as $col) { + $part = $this->quote($col); + + if (isset($index->collations[$col])) { + $collation = $index->collations[$col]; + if (! \preg_match('/^[A-Za-z0-9_]+$/', $collation)) { + throw new ValidationException('Invalid collation: ' . $collation); + } + $part .= ' COLLATE ' . $collation; + } + + if (isset($index->lengths[$col])) { + $part .= '(' . $index->lengths[$col] . ')'; + } + + if ($index->operatorClass !== '') { + $part .= ' ' . $index->operatorClass; + } + + if (isset($index->orders[$col])) { + $order = \strtoupper($index->orders[$col]); + if ($order !== OrderDirection::Asc->value && $order !== OrderDirection::Desc->value) { + throw new ValidationException('Invalid index order: ' . $index->orders[$col]); + } + $part .= ' ' . $order; + } + + $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): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . ' RENAME INDEX ' . $this->quote($from) . ' TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } + + public function createDatabase(string $name): Statement + { + return new Statement('CREATE DATABASE ' . $this->quote($name), [], executor: $this->executor); + } + + public function dropDatabase(string $name): Statement + { + return new Statement('DROP DATABASE ' . $this->quote($name), [], executor: $this->executor); + } + + public function analyzeTable(string $table): Statement + { + return new Statement('ANALYZE TABLE ' . $this->quote($table), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/CheckConstraint.php b/src/Query/Schema/CheckConstraint.php new file mode 100644 index 0000000..b93f6ac --- /dev/null +++ b/src/Query/Schema/CheckConstraint.php @@ -0,0 +1,17 @@ +userTypeName !== null) { + throw new UnsupportedException('User-defined types are not supported in ClickHouse.'); + } + + $type = match ($column->type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'String', + ColumnType::Text => 'String', + ColumnType::MediumText, ColumnType::LongText => 'String', + ColumnType::Integer => $column->isUnsigned ? 'UInt32' : 'Int32', + ColumnType::BigInteger, ColumnType::Id => $column->isUnsigned ? 'UInt64' : 'Int64', + ColumnType::Float, ColumnType::Double => 'Float64', + ColumnType::Boolean => 'UInt8', + ColumnType::Datetime => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Timestamp => $column->precision ? 'DateTime64(' . $column->precision . ')' : 'DateTime', + ColumnType::Json, ColumnType::Object => '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::Uuid7 => 'FixedString(36)', + ColumnType::Vector => 'Array(Float64)', + ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => throw new UnsupportedException('SERIAL types are not supported in ClickHouse.'), + }; + + if ($column->isNullable) { + $type = 'Nullable(' . $type . ')'; + } + + return $type; + } + + protected function compileAutoIncrement(): string + { + return ''; + } + + protected function compileUnsigned(): string + { + return ''; + } + + protected function compileColumnDefinition(Column $column): string + { + if ($column->generatedExpression !== null) { + throw new UnsupportedException('Generated columns are not supported in ClickHouse.'); + } + + if ($column->checkExpression !== null) { + throw new UnsupportedException('CHECK constraints are not supported in ClickHouse.'); + } + + $parts = [ + $this->quote($column->name), + $this->compileColumnType($column), + ]; + + if ($column->hasDefault) { + $parts[] = 'DEFAULT ' . $this->compileDefaultValue($column->default); + } + + if ($column->ttl !== null) { + $parts[] = 'TTL ' . $column->ttl; + } + + if ($column->comment !== null) { + $parts[] = "COMMENT '" . \str_replace(['\\', "'"], ['\\\\', "''"], $column->comment) . "'"; + } + + return \implode(' ', $parts); + } + + public function dropIndex(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP INDEX ' . $this->quote($name), + [], + executor: $this->executor, + ); + } + + /** + * @param callable(Table): void $definition + */ + public function alter(string $table, callable $definition): Statement + { + $blueprint = new Table(); + $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.'); + } + + if (empty($alterations)) { + throw new ValidationException('ALTER TABLE requires at least one alteration.'); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ' . \implode(', ', $alterations); + + return new Statement($sql, [], executor: $this->executor); + } + + /** + * @param callable(Table): void $definition + */ + public function create(string $table, callable $definition, bool $ifNotExists = false): Statement + { + $blueprint = new Table(); + $definition($blueprint); + + $columnDefs = []; + $primaryKeys = []; + + foreach ($blueprint->columns as $column) { + $def = $this->compileColumnDefinition($column); + $columnDefs[] = $def; + + if ($column->isPrimary) { + $primaryKeys[] = $this->quote($column->name); + } + } + + if (! empty($blueprint->compositePrimaryKey) && ! empty($primaryKeys)) { + throw new ValidationException('Cannot combine column-level primary() with Table::primary() composite key.'); + } + + if (empty($primaryKeys) && ! empty($blueprint->compositePrimaryKey)) { + $primaryKeys = \array_map(fn (string $c): string => $this->quote($c), $blueprint->compositePrimaryKey); + } + + // 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.'); + } + + if (! empty($blueprint->checks)) { + throw new UnsupportedException('CHECK constraints are not supported in ClickHouse.'); + } + + $engine = $blueprint->engine ?? Engine::MergeTree; + + $sql = 'CREATE TABLE ' . ($ifNotExists ? 'IF NOT EXISTS ' : '') . $this->quote($table) + . ' (' . \implode(', ', $columnDefs) . ')' + . ' ENGINE = ' . $this->compileEngine($engine, $blueprint->engineArgs); + + if ($blueprint->partitionType !== null) { + $sql .= ' PARTITION BY ' . $blueprint->partitionExpression; + } + + if ($engine->requiresOrderBy()) { + $sql .= ! empty($primaryKeys) + ? ' ORDER BY (' . \implode(', ', $primaryKeys) . ')' + : ' ORDER BY tuple()'; + } + + if ($blueprint->ttl !== null) { + $sql .= ' TTL ' . $blueprint->ttl; + } + + return new Statement($sql, [], executor: $this->executor); + } + + /** + * Compile an engine declaration: `` or `()`. + * + * Identifier-type args (version column, sign column, column lists) are + * quoted. Zookeeper path and replica name for ReplicatedMergeTree are + * emitted as single-quoted string literals. + * + * @param list $args + */ + private function compileEngine(Engine $engine, array $args): string + { + return match ($engine) { + Engine::MergeTree, + Engine::AggregatingMergeTree => $engine->value . '()', + + Engine::ReplacingMergeTree => $engine->value . '(' + . (isset($args[0]) ? $this->quote($args[0]) : '') + . ')', + + Engine::SummingMergeTree => $engine->value . '(' + . (empty($args) + ? '' + : \implode(', ', \array_map(fn (string $c): string => $this->quote($c), $args))) + . ')', + + Engine::CollapsingMergeTree => $engine->value . '(' . $this->quote($args[0]) . ')', + + Engine::ReplicatedMergeTree => $engine->value + . "('" . \str_replace("'", "''", $args[0]) . "'" + . ", '" . \str_replace("'", "''", $args[1]) . "')", + + Engine::Memory, + Engine::Log, + Engine::TinyLog, + Engine::StripeLog => $engine->value, + }; + } + + public function createView(string $name, Builder $query): Statement + { + $result = $query->build(); + $sql = 'CREATE VIEW ' . $this->quote($name) . ' AS ' . $result->query; + + return new Statement($sql, $result->bindings, executor: $this->executor); + } + + /** + * @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) . ')'; + } + + public function commentOnTable(string $table, string $comment): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . " MODIFY COMMENT '" . str_replace(['\\', "'"], ['\\\\', "''"], $comment) . "'", + [], + executor: $this->executor, + ); + } + + public function commentOnColumn(string $table, string $column, string $comment): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . ' COMMENT COLUMN ' . $this->quote($column) . " '" . str_replace(['\\', "'"], ['\\\\', "''"], $comment) . "'", + [], + executor: $this->executor, + ); + } + + public function dropPartition(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . " DROP PARTITION '" . str_replace(['\\', "'"], ['\\\\', "''"], $name) . "'", + [], + executor: $this->executor, + ); + } +} diff --git a/src/Query/Schema/ClickHouse/Engine.php b/src/Query/Schema/ClickHouse/Engine.php new file mode 100644 index 0000000..c796e75 --- /dev/null +++ b/src/Query/Schema/ClickHouse/Engine.php @@ -0,0 +1,28 @@ + false, + default => true, + }; + } +} diff --git a/src/Query/Schema/Column.php b/src/Query/Schema/Column.php new file mode 100644 index 0000000..2d960db --- /dev/null +++ b/src/Query/Schema/Column.php @@ -0,0 +1,248 @@ +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; + } + + /** + * @param string[] $values + */ + public function enum(array $values): static + { + $this->enumValues = $values; + + return $this; + } + + public function srid(int $srid): static + { + $this->srid = $srid; + + return $this; + } + + public function dimensions(int $dimensions): static + { + $this->dimensions = $dimensions; + + return $this; + } + + public function modify(): static + { + $this->isModify = true; + + return $this; + } + + /** + * Attach a column-level CHECK constraint. + * + * The expression is emitted verbatim inside `CHECK (...)` and must come from + * trusted (developer-controlled) source — never from untrusted input. + */ + public function check(string $expression): static + { + $this->checkExpression = $expression; + + return $this; + } + + /** + * Mark the column as a generated column computed from the given expression. + * + * The expression is emitted verbatim inside `GENERATED ALWAYS AS (...)` and + * must come from trusted (developer-controlled) source — never from untrusted + * input. + */ + public function generatedAs(string $expression): static + { + $this->generatedExpression = $expression; + + return $this; + } + + /** + * Mark a generated column as STORED. Mutually exclusive with {@see virtual()}. + */ + public function stored(): static + { + $this->generatedStored = true; + + return $this; + } + + /** + * Mark a generated column as VIRTUAL. Mutually exclusive with {@see stored()}. + */ + public function virtual(): static + { + $this->generatedStored = false; + + return $this; + } + + /** + * Attach a column-level TTL expression (ClickHouse only). + * + * Emitted verbatim as `TTL ` inline with the column + * definition. Other dialects throw UnsupportedException when compiling + * the column. + * + * @throws ValidationException if the expression is empty or contains a semicolon. + */ + public function ttl(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('TTL expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('TTL expression must not contain ";".'); + } + + $this->ttl = $trimmed; + + return $this; + } + + /** + * Reference a user-defined type (e.g. a PostgreSQL enum type created via CREATE TYPE). + * + * The column's emitted type will be the quoted identifier, overriding the mapping + * implied by its ColumnType. Only supported by dialects that implement user-defined + * types (currently PostgreSQL); other dialects throw UnsupportedException when + * compiling the column. + * + * @throws ValidationException if $name is not a valid identifier. + */ + public function userType(string $name): static + { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new ValidationException('Invalid user-defined type name: ' . $name); + } + + $this->userTypeName = $name; + + return $this; + } +} diff --git a/src/Query/Schema/ColumnType.php b/src/Query/Schema/ColumnType.php new file mode 100644 index 0000000..a7ff7a1 --- /dev/null +++ b/src/Query/Schema/ColumnType.php @@ -0,0 +1,33 @@ + $params + */ + public function createProcedure(string $name, array $params, string $body): Statement; + + public function dropProcedure(string $name): Statement; +} diff --git a/src/Query/Schema/Feature/Sequences.php b/src/Query/Schema/Feature/Sequences.php new file mode 100644 index 0000000..be8a8e5 --- /dev/null +++ b/src/Query/Schema/Feature/Sequences.php @@ -0,0 +1,14 @@ + $values + */ + public function createType(string $name, array $values): Statement; + + public function dropType(string $name): Statement; +} diff --git a/src/Query/Schema/ForeignKey.php b/src/Query/Schema/ForeignKey.php new file mode 100644 index 0000000..83d3d3f --- /dev/null +++ b/src/Query/Schema/ForeignKey.php @@ -0,0 +1,47 @@ +refColumn = $column; + + return $this; + } + + public function on(string $table): static + { + $this->refTable = $table; + + return $this; + } + + public function onDelete(ForeignKeyAction $action): static + { + $this->onDelete = $action; + + return $this; + } + + public function onUpdate(ForeignKeyAction $action): static + { + $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..95f3f74 --- /dev/null +++ b/src/Query/Schema/ForeignKeyAction.php @@ -0,0 +1,23 @@ + 'CASCADE', + self::SetNull => 'SET NULL', + self::SetDefault => 'SET DEFAULT', + self::Restrict => 'RESTRICT', + self::NoAction => 'NO ACTION', + }; + } +} diff --git a/src/Query/Schema/Index.php b/src/Query/Schema/Index.php new file mode 100644 index 0000000..f0c48c2 --- /dev/null +++ b/src/Query/Schema/Index.php @@ -0,0 +1,39 @@ + $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..cace146 --- /dev/null +++ b/src/Query/Schema/IndexType.php @@ -0,0 +1,18 @@ +userTypeName !== null) { + throw new UnsupportedException('User-defined types are not supported in MongoDB.'); + } + + return match ($column->type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'string', + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'string', + ColumnType::Integer, ColumnType::BigInteger, ColumnType::Id, + ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => 'int', + ColumnType::Float, ColumnType::Double => 'double', + ColumnType::Boolean => 'bool', + ColumnType::Datetime, ColumnType::Timestamp => 'date', + ColumnType::Json, ColumnType::Object => 'object', + ColumnType::Binary => 'binData', + ColumnType::Enum => 'string', + ColumnType::Point => 'object', + ColumnType::Linestring, ColumnType::Polygon => 'object', + ColumnType::Uuid7 => 'string', + ColumnType::Vector => 'array', + }; + } + + protected function compileAutoIncrement(): string + { + return ''; + } + + /** + * @param callable(Table): void $definition + */ + public function create(string $table, callable $definition, bool $ifNotExists = false): Statement + { + $blueprint = new Table(); + $definition($blueprint); + + if (! empty($blueprint->compositePrimaryKey)) { + throw new UnsupportedException('Composite primary keys are not supported in MongoDB; documents use "_id" implicitly.'); + } + + $properties = []; + $required = []; + + foreach ($blueprint->columns as $column) { + $bsonType = $this->compileColumnType($column); + + $prop = ['bsonType' => $bsonType]; + + if ($column->comment !== null) { + $prop['description'] = $column->comment; + } + + if ($column->type === ColumnType::Enum && ! empty($column->enumValues)) { + $prop['enum'] = $column->enumValues; + } + + $properties[$column->name] = $prop; + + if (! $column->isNullable && ! $column->hasDefault) { + $required[] = $column->name; + } + } + + $validator = []; + if (! empty($properties)) { + $schema = [ + 'bsonType' => 'object', + 'properties' => $properties, + ]; + if (! empty($required)) { + $schema['required'] = $required; + } + $validator = ['$jsonSchema' => $schema]; + } + + $command = [ + 'command' => 'createCollection', + 'collection' => $table, + ]; + + if (! empty($validator)) { + $command['validator'] = $validator; + } + + return new Statement( + \json_encode($command, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + [], + executor: $this->executor, + ); + } + + /** + * @param callable(Table): void $definition + */ + public function alter(string $table, callable $definition): Statement + { + $blueprint = new Table(); + $definition($blueprint); + + if (! empty($blueprint->dropColumns) || ! empty($blueprint->renameColumns)) { + throw new UnsupportedException('MongoDB does not support dropping or renaming columns via schema. Use $unset/$rename update operators.'); + } + + $properties = []; + $required = []; + + foreach ($blueprint->columns as $column) { + $bsonType = $this->compileColumnType($column); + $prop = ['bsonType' => $bsonType]; + + if ($column->comment !== null) { + $prop['description'] = $column->comment; + } + + $properties[$column->name] = $prop; + + if (! $column->isNullable && ! $column->hasDefault) { + $required[] = $column->name; + } + } + + $validator = []; + if (! empty($properties)) { + $schema = [ + 'bsonType' => 'object', + 'properties' => $properties, + ]; + if (! empty($required)) { + $schema['required'] = $required; + } + $validator = ['$jsonSchema' => $schema]; + } + + $command = [ + 'command' => 'collMod', + 'collection' => $table, + ]; + + if (! empty($validator)) { + $command['validator'] = $validator; + } + + return new Statement( + \json_encode($command, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + [], + executor: $this->executor, + ); + } + + public function drop(string $table): Statement + { + return new Statement( + \json_encode(['command' => 'drop', 'collection' => $table], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + public function dropIfExists(string $table): Statement + { + return $this->drop($table); + } + + public function rename(string $from, string $to): Statement + { + return new Statement( + \json_encode([ + 'command' => 'renameCollection', + 'from' => $from, + 'to' => $to, + ], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + public function truncate(string $table): Statement + { + return new Statement( + \json_encode([ + 'command' => 'deleteMany', + 'collection' => $table, + 'filter' => new stdClass(), + ], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + /** + * @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 = [], + ): Statement { + $keys = []; + foreach ($columns as $col) { + $direction = 1; + if (isset($orders[$col])) { + $direction = \strtolower($orders[$col]) === 'desc' ? -1 : 1; + } + $keys[$col] = $direction; + } + + $index = [ + 'key' => $keys, + 'name' => $name, + ]; + + if ($unique) { + $index['unique'] = true; + } + + $command = [ + 'command' => 'createIndex', + 'collection' => $table, + 'index' => $index, + ]; + + return new Statement( + \json_encode($command, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + [], + executor: $this->executor, + ); + } + + public function dropIndex(string $table, string $name): Statement + { + return new Statement( + \json_encode([ + 'command' => 'dropIndex', + 'collection' => $table, + 'index' => $name, + ], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + public function createView(string $name, Builder $query): Statement + { + $result = $query->build(); + + /** @var array|null $op */ + $op = \json_decode($result->query, true); + if ($op === null) { + throw new UnsupportedException('Cannot parse query for MongoDB view creation.'); + } + + $command = [ + 'command' => 'createView', + 'view' => $name, + 'source' => $op['collection'] ?? '', + 'pipeline' => $op['pipeline'] ?? [], + ]; + + return new Statement( + \json_encode($command, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $result->bindings, + executor: $this->executor, + ); + } + + public function createDatabase(string $name): Statement + { + return new Statement( + \json_encode(['command' => 'createDatabase', 'database' => $name], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + public function dropDatabase(string $name): Statement + { + return new Statement( + \json_encode(['command' => 'dropDatabase', 'database' => $name], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } + + public function analyzeTable(string $table): Statement + { + return new Statement( + \json_encode(['command' => 'collStats', 'collection' => $table], JSON_THROW_ON_ERROR), + [], + executor: $this->executor, + ); + } +} diff --git a/src/Query/Schema/MySQL.php b/src/Query/Schema/MySQL.php new file mode 100644 index 0000000..707df77 --- /dev/null +++ b/src/Query/Schema/MySQL.php @@ -0,0 +1,108 @@ +userTypeName !== null) { + throw new UnsupportedException('User-defined types are not supported in MySQL.'); + } + + return match ($column->type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text => 'TEXT', + ColumnType::MediumText => 'MEDIUMTEXT', + ColumnType::LongText => 'LONGTEXT', + ColumnType::Integer, ColumnType::Serial => 'INT', + ColumnType::BigInteger, ColumnType::Id, ColumnType::BigSerial => 'BIGINT', + ColumnType::SmallSerial => 'SMALLINT', + ColumnType::Float, ColumnType::Double => 'DOUBLE', + ColumnType::Boolean => 'TINYINT(1)', + ColumnType::Datetime => $column->precision ? 'DATETIME(' . $column->precision . ')' : 'DATETIME', + ColumnType::Timestamp => $column->precision ? 'TIMESTAMP(' . $column->precision . ')' : 'TIMESTAMP', + ColumnType::Json, ColumnType::Object => '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::Uuid7 => 'VARCHAR(36)', + ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in MySQL.'), + }; + } + + protected function compileAutoIncrement(): string + { + return 'AUTO_INCREMENT'; + } + + public function createDatabase(string $name): Statement + { + return new Statement( + 'CREATE DATABASE ' . $this->quote($name) . ' /*!40100 DEFAULT CHARACTER SET utf8mb4 */', + [], + executor: $this->executor, + ); + } + + /** + * MySQL CHANGE COLUMN: rename and/or retype a column in one statement. + */ + public function changeColumn(string $table, string $oldName, string $newName, string $type): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' CHANGE COLUMN ' . $this->quote($oldName) . ' ' . $this->quote($newName) . ' ' . $type, + [], + executor: $this->executor, + ); + } + + /** + * MySQL MODIFY COLUMN: retype a column without renaming. + */ + public function modifyColumn(string $table, string $name, string $type): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' MODIFY ' . $this->quote($name) . ' ' . $type, + [], + executor: $this->executor, + ); + } + + public function commentOnTable(string $table, string $comment): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . " COMMENT = '" . str_replace(['\\', "'"], ['\\\\', "''"], $comment) . "'", + [], + executor: $this->executor, + ); + } + + public function createPartition(string $parent, string $name, string $expression): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($parent) . ' ADD PARTITION (PARTITION ' . $this->quote($name) . ' ' . $expression . ')', + [], + executor: $this->executor, + ); + } + + public function dropPartition(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) . ' DROP PARTITION ' . $this->quote($name), + [], + executor: $this->executor, + ); + } +} 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 @@ +userTypeName !== null) { + return $this->quote($column->userTypeName); + } + + return match ($column->type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::Integer => 'INTEGER', + ColumnType::BigInteger, ColumnType::Id => 'BIGINT', + ColumnType::Float, ColumnType::Double => '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, ColumnType::Object => '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::Uuid7 => 'VARCHAR(36)', + ColumnType::Vector => 'VECTOR(' . ($column->dimensions ?? 0) . ')', + ColumnType::Serial => 'SERIAL', + ColumnType::BigSerial => 'BIGSERIAL', + ColumnType::SmallSerial => 'SMALLSERIAL', + }; + } + + 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->generatedExpression !== null) { + $parts[] = $this->compileGeneratedClause($column); + + if (! $column->isNullable) { + $parts[] = 'NOT NULL'; + } else { + $parts[] = 'NULL'; + } + + if ($column->checkExpression !== null) { + $parts[] = 'CHECK (' . $column->checkExpression . ')'; + } + + return \implode(' ', $parts); + } + + $isSerial = $column->type === ColumnType::Serial + || $column->type === ColumnType::BigSerial + || $column->type === ColumnType::SmallSerial; + + if ($column->isAutoIncrement && ! $isSerial) { + $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) . '))'; + } + + if ($column->checkExpression !== null) { + $parts[] = 'CHECK (' . $column->checkExpression . ')'; + } + + // No inline COMMENT in PostgreSQL (use COMMENT ON COLUMN separately) + + return \implode(' ', $parts); + } + + /** + * PostgreSQL only supports STORED generated columns. Virtual generated columns + * are rejected with UnsupportedException. + * + * @throws UnsupportedException if a VIRTUAL generated column is requested. + */ + #[\Override] + protected function compileGeneratedClause(Column $column): string + { + if ($column->generatedStored === false) { + throw new UnsupportedException( + 'PostgreSQL does not support VIRTUAL generated columns. Use stored() instead.' + ); + } + + return 'GENERATED ALWAYS AS (' . $column->generatedExpression . ') STORED'; + } + + /** + * @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 = [], + bool $concurrently = false, + ): Statement { + 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'; + + if ($concurrently) { + $keyword .= ' CONCURRENTLY'; + } + + $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 Statement($sql, [], executor: $this->executor); + } + + public function dropIndex(string $table, string $name): Statement + { + return new Statement( + 'DROP INDEX ' . $this->quote($name), + [], + executor: $this->executor, + ); + } + + public function dropForeignKey(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP CONSTRAINT ' . $this->quote($name), + [], + executor: $this->executor, + ); + } + + /** + * Create a PL/pgSQL function. + * + * $body is emitted verbatim inside a dollar-quoted ($$...$$) block and must + * come from trusted (developer-controlled) source — never from untrusted + * input. A literal `$$` inside the body would close the dollar-quoted + * string early and is rejected. + * + * @param list $params + * + * @throws ValidationException if $body contains a `$$` sequence. + */ + public function createProcedure(string $name, array $params, string $body): Statement + { + $this->assertSafeDollarQuotedBody($body); + + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE FUNCTION ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' RETURNS VOID LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' END; $$'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropProcedure(string $name): Statement + { + return new Statement('DROP FUNCTION ' . $this->quote($name), [], executor: $this->executor); + } + + /** + * Create a trigger backed by a PL/pgSQL function. + * + * $body is emitted verbatim inside a dollar-quoted ($$...$$) block and must + * come from trusted (developer-controlled) source — never from untrusted + * input. A literal `$$` inside the body would close the dollar-quoted + * string early and is rejected. + * + * @throws ValidationException if $body contains a `$$` sequence. + */ + public function createTrigger( + string $name, + string $table, + TriggerTiming $timing, + TriggerEvent $event, + string $body, + ): Statement { + $this->assertSafeDollarQuotedBody($body); + + $funcName = $name . '_func'; + + $sql = 'CREATE FUNCTION ' . $this->quote($funcName) + . '() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN ' . $body . ' RETURN NEW; END; $$; ' + . 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timing->value . ' ' . $event->value + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW EXECUTE FUNCTION ' . $this->quote($funcName) . '()'; + + return new Statement($sql, [], executor: $this->executor); + } + + /** + * Reject bodies that would break out of the surrounding `$$ ... $$` + * dollar-quoted string. + * + * @throws ValidationException if $body contains `$$`. + */ + private function assertSafeDollarQuotedBody(string $body): void + { + if (\str_contains($body, '$$')) { + throw new ValidationException('Procedure/trigger body must not contain the dollar-quote terminator "$$"'); + } + } + + /** + * @param callable(Table): void $definition + */ + public function alter(string $table, callable $definition): Statement + { + $blueprint = new Table(); + $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->toSql(); + } + if ($fk->onUpdate !== null) { + $def .= ' ON UPDATE ' . $fk->onUpdate->toSql(); + } + $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 Statement(\implode('; ', $statements), [], executor: $this->executor); + } + + public function rename(string $from, string $to): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } + + public function createExtension(string $name): Statement + { + return new Statement('CREATE EXTENSION IF NOT EXISTS ' . $this->quote($name), [], executor: $this->executor); + } + + public function dropExtension(string $name): Statement + { + return new Statement('DROP EXTENSION IF EXISTS ' . $this->quote($name), [], executor: $this->executor); + } + + /** + * 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): Statement + { + $optParts = []; + foreach ($options as $key => $value) { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) { + throw new ValidationException('Invalid collation option key: ' . $key); + } + $optParts[] = $key . " = '" . \str_replace(['\\', "'"], ['\\\\', "''"], $value) . "'"; + } + $optParts[] = 'deterministic = ' . ($deterministic ? 'true' : 'false'); + + $sql = 'CREATE COLLATION IF NOT EXISTS ' . $this->quote($name) + . ' (' . \implode(', ', $optParts) . ')'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function renameIndex(string $table, string $from, string $to): Statement + { + return new Statement( + 'ALTER INDEX ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } + + /** + * PostgreSQL uses schemas instead of databases for namespace isolation. + */ + public function createDatabase(string $name): Statement + { + return new Statement('CREATE SCHEMA ' . $this->quote($name), [], executor: $this->executor); + } + + public function dropDatabase(string $name): Statement + { + return new Statement('DROP SCHEMA IF EXISTS ' . $this->quote($name) . ' CASCADE', [], executor: $this->executor); + } + + public function analyzeTable(string $table): Statement + { + return new Statement('ANALYZE ' . $this->quote($table), [], executor: $this->executor); + } + + /** + * Alter a column's type with an optional USING expression for type casting. + * + * @throws ValidationException if $type or $using contains disallowed characters. + */ + public function alterColumnType(string $table, string $column, string $type, string $using = ''): Statement + { + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $type)) { + throw new ValidationException('Invalid column type: ' . $type); + } + + if ($using !== '') { + $this->assertSafeExpression($using, 'USING expression'); + } + + $sql = 'ALTER TABLE ' . $this->quote($table) + . ' ALTER COLUMN ' . $this->quote($column) + . ' TYPE ' . $type; + + if ($using !== '') { + $sql .= ' USING ' . $using; + } + + return new Statement($sql, [], executor: $this->executor); + } + + /** + * Reject expressions that could chain additional statements or comments. + * + * Partition expressions and USING casts may legitimately contain parens, + * function calls, and casts, so an allowlist is too restrictive. We reject + * statement terminators and comment markers instead. + * + * @throws ValidationException if $expression is too long or contains a disallowed sequence. + */ + private function assertSafeExpression(string $expression, string $label): void + { + if (\strlen($expression) > 1024) { + throw new ValidationException($label . ' exceeds 1024 character limit'); + } + + if (\str_contains($expression, ';') + || \str_contains($expression, '--') + || \str_contains($expression, '/*') + ) { + throw new ValidationException('Invalid ' . $label . ': ' . $expression); + } + } + + public function dropIndexConcurrently(string $name): Statement + { + return new Statement('DROP INDEX CONCURRENTLY ' . $this->quote($name), [], executor: $this->executor); + } + + public function createType(string $name, array $values): Statement + { + $escaped = array_map(fn (string $v): string => "'" . str_replace(['\\', "'"], ['\\\\', "''"], $v) . "'", $values); + + return new Statement( + 'CREATE TYPE ' . $this->quote($name) . ' AS ENUM (' . implode(', ', $escaped) . ')', + [], + executor: $this->executor, + ); + } + + public function dropType(string $name): Statement + { + return new Statement('DROP TYPE ' . $this->quote($name), [], executor: $this->executor); + } + + public function createSequence(string $name, int $start = 1, int $incrementBy = 1): Statement + { + return new Statement( + 'CREATE SEQUENCE ' . $this->quote($name) . ' START ' . $start . ' INCREMENT BY ' . $incrementBy, + [], + executor: $this->executor, + ); + } + + public function dropSequence(string $name): Statement + { + return new Statement('DROP SEQUENCE ' . $this->quote($name), [], executor: $this->executor); + } + + public function nextVal(string $name): Statement + { + return new Statement( + "SELECT nextval('" . str_replace(['\\', "'"], ['\\\\', "''"], $name) . "')", + [], + executor: $this->executor, + ); + } + + public function commentOnTable(string $table, string $comment): Statement + { + return new Statement( + 'COMMENT ON TABLE ' . $this->quote($table) . " IS '" . str_replace(['\\', "'"], ['\\\\', "''"], $comment) . "'", + [], + executor: $this->executor, + ); + } + + public function commentOnColumn(string $table, string $column, string $comment): Statement + { + return new Statement( + 'COMMENT ON COLUMN ' . $this->quote($table) . '.' . $this->quote($column) . " IS '" . str_replace(['\\', "'"], ['\\\\', "''"], $comment) . "'", + [], + executor: $this->executor, + ); + } + + /** + * @throws ValidationException if $expression is too long or contains disallowed sequences. + */ + public function createPartition(string $parent, string $name, string $expression): Statement + { + $this->assertSafeExpression($expression, 'partition expression'); + + return new Statement( + 'CREATE TABLE ' . $this->quote($name) . ' PARTITION OF ' . $this->quote($parent) . ' FOR VALUES ' . $expression, + [], + executor: $this->executor, + ); + } + + public function dropPartition(string $table, string $name): Statement + { + return new Statement( + 'DROP TABLE ' . $this->quote($name), + [], + executor: $this->executor, + ); + } +} 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 @@ +quote($table) + . ' ADD CONSTRAINT ' . $this->quote($name) + . ' FOREIGN KEY (' . $this->quote($column) . ')' + . ' REFERENCES ' . $this->quote($refTable) + . ' (' . $this->quote($refColumn) . ')'; + + if ($onDelete !== null) { + $sql .= ' ON DELETE ' . $onDelete->toSql(); + } + if ($onUpdate !== null) { + $sql .= ' ON UPDATE ' . $onUpdate->toSql(); + } + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropForeignKey(string $table, string $name): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($table) + . ' DROP FOREIGN KEY ' . $this->quote($name), + [], + executor: $this->executor, + ); + } + + /** + * Validate and compile a procedure parameter list. + * + * @param list $params + * @return list + */ + protected function compileProcedureParams(array $params): array + { + $paramList = []; + foreach ($params as $param) { + $direction = $param[0]->value; + $name = $this->quote($param[1]); + + if (! \preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\s+[A-Za-z_][A-Za-z0-9_]*)*(\s*\(\s*[A-Za-z0-9_,\s]+\s*\))?$/', $param[2])) { + throw new ValidationException('Invalid procedure parameter type: ' . $param[2]); + } + + $paramList[] = $direction . ' ' . $name . ' ' . $param[2]; + } + + return $paramList; + } + + /** + * Create a stored procedure. + * + * $body is emitted verbatim into the generated DDL and must come from + * trusted (developer-controlled) source — never from untrusted input. + * + * @param list $params + */ + public function createProcedure(string $name, array $params, string $body): Statement + { + $paramList = $this->compileProcedureParams($params); + + $sql = 'CREATE PROCEDURE ' . $this->quote($name) + . '(' . \implode(', ', $paramList) . ')' + . ' BEGIN ' . $body . ' END'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropProcedure(string $name): Statement + { + return new Statement('DROP PROCEDURE ' . $this->quote($name), [], executor: $this->executor); + } + + /** + * Create a trigger. + * + * $body is emitted verbatim into the generated DDL and must come from + * trusted (developer-controlled) source — never from untrusted input. + */ + public function createTrigger( + string $name, + string $table, + TriggerTiming $timing, + TriggerEvent $event, + string $body, + ): Statement { + $sql = 'CREATE TRIGGER ' . $this->quote($name) + . ' ' . $timing->value . ' ' . $event->value + . ' ON ' . $this->quote($table) + . ' FOR EACH ROW BEGIN ' . $body . ' END'; + + return new Statement($sql, [], executor: $this->executor); + } + + public function dropTrigger(string $name): Statement + { + return new Statement('DROP TRIGGER ' . $this->quote($name), [], executor: $this->executor); + } +} diff --git a/src/Query/Schema/SQLite.php b/src/Query/Schema/SQLite.php new file mode 100644 index 0000000..8ae69a6 --- /dev/null +++ b/src/Query/Schema/SQLite.php @@ -0,0 +1,76 @@ +userTypeName !== null) { + throw new UnsupportedException('User-defined types are not supported in SQLite.'); + } + + return match ($column->type) { + ColumnType::String, ColumnType::Varchar, ColumnType::Relationship => 'VARCHAR(' . ($column->length ?? 255) . ')', + ColumnType::Text, ColumnType::MediumText, ColumnType::LongText => 'TEXT', + ColumnType::Integer, ColumnType::BigInteger, ColumnType::Id, + ColumnType::Serial, ColumnType::BigSerial, ColumnType::SmallSerial => 'INTEGER', + ColumnType::Float, ColumnType::Double => 'REAL', + ColumnType::Boolean => 'INTEGER', + ColumnType::Datetime, ColumnType::Timestamp => 'TEXT', + ColumnType::Json, ColumnType::Object => 'TEXT', + ColumnType::Binary => 'BLOB', + ColumnType::Enum => 'TEXT', + ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon => 'TEXT', + ColumnType::Uuid7 => 'VARCHAR(36)', + ColumnType::Vector => throw new UnsupportedException('Vector type is not supported in SQLite.'), + }; + } + + protected function compileAutoIncrement(): string + { + return 'AUTOINCREMENT'; + } + + protected function compileUnsigned(): string + { + return ''; + } + + public function createDatabase(string $name): Statement + { + throw new UnsupportedException('SQLite does not support CREATE DATABASE.'); + } + + public function dropDatabase(string $name): Statement + { + throw new UnsupportedException('SQLite does not support DROP DATABASE.'); + } + + public function rename(string $from, string $to): Statement + { + return new Statement( + 'ALTER TABLE ' . $this->quote($from) . ' RENAME TO ' . $this->quote($to), + [], + executor: $this->executor, + ); + } + + public function truncate(string $table): Statement + { + return new Statement('DELETE FROM ' . $this->quote($table), [], executor: $this->executor); + } + + public function dropIndex(string $table, string $name): Statement + { + return new Statement('DROP INDEX ' . $this->quote($name), [], executor: $this->executor); + } + + public function renameIndex(string $table, string $from, string $to): Statement + { + throw new UnsupportedException('SQLite does not support renaming indexes directly.'); + } +} diff --git a/src/Query/Schema/Table.php b/src/Query/Schema/Table.php new file mode 100644 index 0000000..b43a2c3 --- /dev/null +++ b/src/Query/Schema/Table.php @@ -0,0 +1,546 @@ + */ + 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 = []; + + /** @var list */ + public private(set) array $checks = []; + + /** @var list */ + public private(set) array $compositePrimaryKey = []; + + public private(set) ?PartitionType $partitionType = null; + public private(set) string $partitionExpression = ''; + public private(set) ?int $partitionCount = null; + + public private(set) ?Engine $engine = null; + + /** @var list */ + public private(set) array $engineArgs = []; + + public private(set) ?string $ttl = null; + + /** + * Add a table-level CHECK constraint. + * + * The expression is emitted verbatim inside `CHECK (...)` and must come from + * trusted (developer-controlled) source — never from untrusted input. The + * constraint name is validated as a standard SQL identifier. + * + * @throws ValidationException if $name is not a valid identifier. + */ + public function check(string $name, string $expression): static + { + $this->checks[] = new CheckConstraint($name, $expression); + + return $this; + } + + /** + * Declare a composite PRIMARY KEY across two or more columns. + * + * For a single-column primary key, use {@see Column::primary()} instead. + * + * @param list $columns + * + * @throws ValidationException if fewer than two columns are provided or any column name is invalid. + */ + public function primary(array $columns): static + { + if (\count($columns) < 2) { + throw new ValidationException('Table::primary(array) requires at least two columns; use Column::primary() for single-column keys.'); + } + + foreach ($columns as $column) { + if (! \preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + throw new ValidationException('Invalid column name in composite primary key: ' . $column); + } + } + + $this->compositePrimaryKey = $columns; + + return $this; + } + + public function id(string $name = 'id'): Column + { + $col = (new Column($name, ColumnType::BigInteger)) + ->unsigned() + ->autoIncrement() + ->primary(); + $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; + } + + /** + * Auto-incrementing integer column (PostgreSQL SERIAL; INT AUTO_INCREMENT on MySQL; + * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + */ + public function serial(string $name): Column + { + $col = (new Column($name, ColumnType::Serial)) + ->autoIncrement(); + $this->columns[] = $col; + + return $col; + } + + /** + * Auto-incrementing big integer column (PostgreSQL BIGSERIAL; BIGINT AUTO_INCREMENT on MySQL; + * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + */ + public function bigSerial(string $name): Column + { + $col = (new Column($name, ColumnType::BigSerial)) + ->autoIncrement(); + $this->columns[] = $col; + + return $col; + } + + /** + * Auto-incrementing small integer column (PostgreSQL SMALLSERIAL; SMALLINT AUTO_INCREMENT on MySQL; + * INTEGER on SQLite; throws UnsupportedException on ClickHouse/MongoDB). + */ + public function smallSerial(string $name): Column + { + $col = (new Column($name, ColumnType::SmallSerial)) + ->autoIncrement(); + $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)) + ->enum($values); + $this->columns[] = $col; + + return $col; + } + + public function point(string $name, int $srid = 4326): Column + { + $col = (new Column($name, ColumnType::Point)) + ->srid($srid); + $this->columns[] = $col; + + return $col; + } + + public function linestring(string $name, int $srid = 4326): Column + { + $col = (new Column($name, ColumnType::Linestring)) + ->srid($srid); + $this->columns[] = $col; + + return $col; + } + + public function polygon(string $name, int $srid = 4326): Column + { + $col = (new Column($name, ColumnType::Polygon)) + ->srid($srid); + $this->columns[] = $col; + + return $col; + } + + public function vector(string $name, int $dimensions): Column + { + $col = (new Column($name, ColumnType::Vector)) + ->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)) + ->modify(); + $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; + } + + public function partitionByRange(string $expression): void + { + $this->partitionType = PartitionType::Range; + $this->partitionExpression = $expression; + $this->partitionCount = null; + } + + public function partitionByList(string $expression): void + { + $this->partitionType = PartitionType::List; + $this->partitionExpression = $expression; + $this->partitionCount = null; + } + + /** + * Partition by hash of the given expression. + * + * When $partitions is non-null, the DDL emits `PARTITIONS `. Per + * MySQL/MariaDB semantics, this only applies to HASH (and KEY/LINEAR HASH/ + * LINEAR KEY variants) partitioning. + * + * @throws ValidationException if $partitions is less than 1. + */ + public function partitionByHash(string $expression, ?int $partitions = null): static + { + if ($partitions !== null && $partitions < 1) { + throw new ValidationException('Partition count must be at least 1.'); + } + + $this->partitionType = PartitionType::Hash; + $this->partitionExpression = $expression; + $this->partitionCount = $partitions; + + return $this; + } + + /** + * Select the table engine (ClickHouse only). Other dialects ignore this. + * + * Engine-specific arguments are validated against the engine variant: + * - CollapsingMergeTree requires exactly one sign column. + * - ReplicatedMergeTree requires a zookeeper path and replica name. + * + * @throws ValidationException if required engine arguments are missing. + */ + public function engine(Engine $engine, string ...$args): static + { + if ($engine === Engine::CollapsingMergeTree && ! isset($args[0])) { + throw new ValidationException('CollapsingMergeTree requires a sign column.'); + } + + if ($engine === Engine::ReplicatedMergeTree && (! isset($args[0]) || ! isset($args[1]))) { + throw new ValidationException('ReplicatedMergeTree requires zookeeper_path and replica_name.'); + } + + $this->engine = $engine; + $this->engineArgs = \array_values($args); + + return $this; + } + + /** + * Attach a table-level TTL expression (ClickHouse only). + * + * Emitted verbatim as `TTL ` after ORDER BY/PARTITION BY. + * Other dialects throw UnsupportedException when compiling the blueprint. + * + * @throws ValidationException if the expression is empty or contains a semicolon. + */ + public function ttl(string $expression): static + { + $trimmed = \trim($expression); + + if ($trimmed === '') { + throw new ValidationException('TTL expression must not be empty.'); + } + + if (\str_contains($trimmed, ';')) { + throw new ValidationException('TTL expression must not contain ";".'); + } + + $this->ttl = $trimmed; + + return $this; + } +} 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 @@ +replaceHashComments($sql); + return parent::tokenize($sql); + } + + /** + * Replace MySQL-specific # line comments with standard -- line comments + * so the base tokenizer handles them correctly. + */ + private function replaceHashComments(string $sql): string + { + $result = ''; + $len = strlen($sql); + $i = 0; + + while ($i < $len) { + $char = $sql[$i]; + + if ($char === '\'') { + $result .= $char; + $i++; + while ($i < $len) { + $c = $sql[$i]; + if ($c === '\\') { + $result .= $c; + $i++; + if ($i < $len) { + $result .= $sql[$i]; + $i++; + } + continue; + } + if ($c === '\'') { + $result .= $c; + $i++; + if ($i < $len && $sql[$i] === '\'') { + $result .= $sql[$i]; + $i++; + continue; + } + break; + } + $result .= $c; + $i++; + } + continue; + } + + if ($char === '`') { + $result .= $char; + $i++; + while ($i < $len) { + $c = $sql[$i]; + if ($c === '`') { + $result .= $c; + $i++; + if ($i < $len && $sql[$i] === '`') { + $result .= $sql[$i]; + $i++; + continue; + } + break; + } + $result .= $c; + $i++; + } + continue; + } + + if ($char === '"') { + $result .= $char; + $i++; + while ($i < $len) { + $c = $sql[$i]; + if ($c === '\\') { + $result .= $c; + $i++; + if ($i < $len) { + $result .= $sql[$i]; + $i++; + } + continue; + } + if ($c === '"') { + $result .= $c; + $i++; + if ($i < $len && $sql[$i] === '"') { + $result .= $sql[$i]; + $i++; + continue; + } + break; + } + $result .= $c; + $i++; + } + continue; + } + + if ($char === '#') { + $result .= '-- '; + $i++; + continue; + } + + $result .= $char; + $i++; + } + + return $result; + } +} diff --git a/src/Query/Tokenizer/PostgreSQL.php b/src/Query/Tokenizer/PostgreSQL.php new file mode 100644 index 0000000..ef9b7ab --- /dev/null +++ b/src/Query/Tokenizer/PostgreSQL.php @@ -0,0 +1,75 @@ +mergePostgresOperators($tokens); + } + + /** + * Merge adjacent operator tokens into PostgreSQL-specific multi-char operators. + * Handles: @>, <@, <=>, <->, <#>, ?|, ?& + * + * @param Token[] $tokens + * @return Token[] + */ + private function mergePostgresOperators(array $tokens): array + { + $result = []; + $count = count($tokens); + $i = 0; + + while ($i < $count) { + $token = $tokens[$i]; + + if ($i + 2 < $count && $this->isOp($token) && $this->isOp($tokens[$i + 1]) && $this->isOp($tokens[$i + 2])) { + $three = $token->value . $tokens[$i + 1]->value . $tokens[$i + 2]->value; + if (in_array($three, ['<->', '<#>'], true)) { + $result[] = new Token(TokenType::Operator, $three, $token->position); + $i += 3; + continue; + } + } + + if ($i + 1 < $count && $this->isOp($token) && $this->isOp($tokens[$i + 1])) { + $two = $token->value . $tokens[$i + 1]->value; + if (in_array($two, ['@>', '<@', '<=>'], true)) { + $result[] = new Token(TokenType::Operator, $two, $token->position); + $i += 2; + continue; + } + } + + if ($i + 1 < $count && $token->type === TokenType::Placeholder && $token->value === '?') { + $next = $tokens[$i + 1]; + if ($next->type === TokenType::Operator && ($next->value === '|' || $next->value === '&')) { + $result[] = new Token(TokenType::Operator, '?' . $next->value, $token->position); + $i += 2; + continue; + } + } + + $result[] = $token; + $i++; + } + + return $result; + } + + private function isOp(Token $token): bool + { + return $token->type === TokenType::Operator || $token->type === TokenType::Star; + } +} diff --git a/src/Query/Tokenizer/SQLite.php b/src/Query/Tokenizer/SQLite.php new file mode 100644 index 0000000..7aad276 --- /dev/null +++ b/src/Query/Tokenizer/SQLite.php @@ -0,0 +1,11 @@ + true, 'FROM' => true, 'WHERE' => true, 'AND' => true, + 'OR' => true, 'NOT' => true, 'JOIN' => true, 'LEFT' => true, + 'RIGHT' => true, 'INNER' => true, 'OUTER' => true, 'FULL' => true, + 'CROSS' => true, 'NATURAL' => true, 'ON' => true, 'AS' => true, + 'ORDER' => true, 'BY' => true, 'GROUP' => true, 'HAVING' => true, + 'LIMIT' => true, 'OFFSET' => true, 'ASC' => true, 'DESC' => true, + 'IN' => true, 'BETWEEN' => true, 'LIKE' => true, 'ILIKE' => true, + 'IS' => true, 'CASE' => true, 'WHEN' => true, 'THEN' => true, + 'ELSE' => true, 'END' => true, 'EXISTS' => true, 'DISTINCT' => true, + 'ALL' => true, 'UNION' => true, 'INTERSECT' => true, 'EXCEPT' => true, + 'WITH' => true, 'RECURSIVE' => true, 'SET' => true, 'INSERT' => true, + 'INTO' => true, 'VALUES' => true, 'UPDATE' => true, 'DELETE' => true, + 'CREATE' => true, 'ALTER' => true, 'DROP' => true, 'TABLE' => true, + 'INDEX' => true, 'VIEW' => true, 'OVER' => true, 'PARTITION' => true, + 'WINDOW' => true, 'ROWS' => true, 'RANGE' => true, 'UNBOUNDED' => true, + 'PRECEDING' => true, 'FOLLOWING' => true, 'CURRENT' => true, + 'ROW' => true, 'FETCH' => true, 'NEXT' => true, 'FIRST' => true, + 'LAST' => true, 'NULLS' => true, 'CAST' => true, 'FILTER' => true, + 'WITHIN' => true, + ]; + + /** + * Single-character operator lookup table. Used by tryReadOperator to + * avoid allocating a haystack array on every character. + */ + private const SINGLE_OPERATORS = [ + '=' => true, + '<' => true, + '>' => true, + '+' => true, + '-' => true, + '/' => true, + '%' => true, + ]; + + private string $sql; + + private int $length; + + private int $pos; + + /** + * @return Token[] + */ + public function tokenize(string $sql): array + { + $this->sql = $sql; + $this->length = strlen($sql); + $this->pos = 0; + + $tokens = []; + $quoteChar = $this->getIdentifierQuoteChar(); + + while ($this->pos < $this->length) { + $start = $this->pos; + $char = $this->sql[$this->pos]; + + $tokens[] = match (true) { + $char === ' ' || $char === "\t" || $char === "\n" || $char === "\r" => $this->readWhitespace($start), + $char === '-' => $this->readDashPrefix($start), + $char === '/' => $this->readSlashPrefix($start), + $char === '\'' => $this->readString($start), + $char === $quoteChar => $this->readQuotedIdentifier($start, $quoteChar), + $char === '"' => $this->readQuotedIdentifier($start, '"'), + $char >= '0' && $char <= '9' => $this->readNumber($start), + ($char >= 'a' && $char <= 'z') || ($char >= 'A' && $char <= 'Z') || $char === '_' => $this->readIdentifierOrKeyword($start), + $char === '(' => $this->consumeSingleChar(TokenType::LeftParen, '(', $start), + $char === ')' => $this->consumeSingleChar(TokenType::RightParen, ')', $start), + $char === ',' => $this->consumeSingleChar(TokenType::Comma, ',', $start), + $char === ';' => $this->consumeSingleChar(TokenType::Semicolon, ';', $start), + $char === '.' => $this->readDot($start), + $char === '*' => $this->consumeSingleChar(TokenType::Star, '*', $start), + $char === '?' => $this->consumeSingleChar(TokenType::Placeholder, '?', $start), + $char === ':' => $this->readColonPrefix($start), + $char === '$' => $this->readDollarPrefix($start), + default => $this->readOperatorOrUnknown($start, $char), + }; + } + + $tokens[] = new Token(TokenType::Eof, '', $this->pos); + + return $tokens; + } + + private function consumeSingleChar(TokenType $type, string $value, int $start): Token + { + $this->pos++; + return new Token($type, $value, $start); + } + + private function readDashPrefix(int $start): Token + { + if ($this->peek(1) === '-') { + return $this->readLineComment($start); + } + + return $this->readOperatorOrUnknown($start, '-'); + } + + private function readSlashPrefix(int $start): Token + { + if ($this->peek(1) === '*') { + return $this->readBlockComment($start); + } + + return $this->readOperatorOrUnknown($start, '/'); + } + + private function readDot(int $start): Token + { + $next = $this->peek(1); + if ($next !== null && $this->isDigit($next)) { + return $this->readNumber($start); + } + + $this->pos++; + return new Token(TokenType::Dot, '.', $start); + } + + private function readColonPrefix(int $start): Token + { + $next = $this->peek(1); + if ($next === ':') { + $this->pos += 2; + return new Token(TokenType::Operator, '::', $start); + } + if ($next !== null && $this->isIdentStart($next)) { + return $this->readNamedPlaceholder($start); + } + + $this->pos++; + return new Token(TokenType::Operator, ':', $start); + } + + private function readDollarPrefix(int $start): Token + { + $next = $this->peek(1); + if ($next !== null && $this->isDigit($next)) { + return $this->readNumberedPlaceholder($start); + } + + $this->pos++; + return new Token(TokenType::Operator, '$', $start); + } + + private function readOperatorOrUnknown(int $start, string $char): Token + { + $op = $this->tryReadOperator($start); + if ($op !== null) { + return $op; + } + + // Emit unknown characters as single-char operator tokens + $this->pos++; + return new Token(TokenType::Operator, $char, $start); + } + + /** + * @param Token[] $tokens + * @return Token[] + */ + public static function filter(array $tokens): array + { + $result = []; + foreach ($tokens as $token) { + if ( + $token->type !== TokenType::Whitespace + && $token->type !== TokenType::LineComment + && $token->type !== TokenType::BlockComment + ) { + $result[] = $token; + } + } + return $result; + } + + protected function getIdentifierQuoteChar(): string + { + return '`'; + } + + private function peek(int $offset): ?string + { + $idx = $this->pos + $offset; + return $idx < $this->length ? $this->sql[$idx] : null; + } + + private function isDigit(string $char): bool + { + return $char >= '0' && $char <= '9'; + } + + private function isIdentStart(string $char): bool + { + return ($char >= 'a' && $char <= 'z') + || ($char >= 'A' && $char <= 'Z') + || $char === '_'; + } + + private function isIdentChar(string $char): bool + { + return $this->isIdentStart($char) || $this->isDigit($char); + } + + private function readWhitespace(int $start): Token + { + while ($this->pos < $this->length) { + $c = $this->sql[$this->pos]; + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r") { + $this->pos++; + } else { + break; + } + } + + return new Token(TokenType::Whitespace, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readLineComment(int $start): Token + { + $this->pos += 2; + while ($this->pos < $this->length && $this->sql[$this->pos] !== "\n") { + $this->pos++; + } + + return new Token(TokenType::LineComment, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readBlockComment(int $start): Token + { + $this->pos += 2; + $terminated = false; + while ($this->pos < $this->length - 1) { + if ($this->sql[$this->pos] === '*' && $this->sql[$this->pos + 1] === '/') { + $this->pos += 2; + $terminated = true; + break; + } + $this->pos++; + } + + if (!$terminated) { + $this->pos = $this->length; + } + + return new Token(TokenType::BlockComment, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readString(int $start): Token + { + $this->pos++; + $terminated = false; + while ($this->pos < $this->length) { + $char = $this->sql[$this->pos]; + if ($char === '\\') { + // Backslash escape: skip next character. Reject trailing backslash at EOF. + if ($this->pos + 1 >= $this->length) { + throw new ValidationException('Unterminated string literal'); + } + $this->pos += 2; + continue; + } + if ($char === '\'') { + $this->pos++; + // Check for escaped quote '' + if ($this->pos < $this->length && $this->sql[$this->pos] === '\'') { + $this->pos++; + continue; + } + $terminated = true; + break; + } + $this->pos++; + } + + if (!$terminated) { + throw new ValidationException('Unterminated string literal'); + } + + return new Token(TokenType::String, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readQuotedIdentifier(int $start, string $quote): Token + { + $this->pos++; + $terminated = false; + while ($this->pos < $this->length) { + if ($this->sql[$this->pos] === $quote) { + $this->pos++; + // Check for escaped quote (doubled) + if ($this->pos < $this->length && $this->sql[$this->pos] === $quote) { + $this->pos++; + continue; + } + $terminated = true; + break; + } + $this->pos++; + } + + if (!$terminated) { + throw new ValidationException('Unterminated quoted identifier'); + } + + return new Token(TokenType::QuotedIdentifier, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readNumber(int $start): Token + { + $isFloat = false; + + // Handle dot-prefixed floats like .5 + if ($this->pos < $this->length && $this->sql[$this->pos] === '.') { + $isFloat = true; + $this->pos++; + } + + while ($this->pos < $this->length && $this->isDigit($this->sql[$this->pos])) { + $this->pos++; + } + + if (!$isFloat && $this->pos < $this->length && $this->sql[$this->pos] === '.') { + $next = $this->peek(1); + if ($next !== null && $this->isDigit($next)) { + $isFloat = true; + $this->pos++; + while ($this->pos < $this->length && $this->isDigit($this->sql[$this->pos])) { + $this->pos++; + } + } + } + + // Handle scientific notation (e.g. 1.5e10, 1e-3, 2.5E+8) + if ($this->pos < $this->length) { + $c = $this->sql[$this->pos]; + if ($c === 'e' || $c === 'E') { + $nextIdx = $this->pos + 1; + if ($nextIdx < $this->length && ($this->sql[$nextIdx] === '+' || $this->sql[$nextIdx] === '-')) { + $nextIdx++; + } + if ($nextIdx < $this->length && $this->isDigit($this->sql[$nextIdx])) { + $isFloat = true; + $this->pos = $nextIdx; + while ($this->pos < $this->length && $this->isDigit($this->sql[$this->pos])) { + $this->pos++; + } + } + } + } + + $value = substr($this->sql, $start, $this->pos - $start); + $type = $isFloat ? TokenType::Float : TokenType::Integer; + + return new Token($type, $value, $start); + } + + private function readIdentifierOrKeyword(int $start): Token + { + while ($this->pos < $this->length && $this->isIdentChar($this->sql[$this->pos])) { + $this->pos++; + } + + $value = substr($this->sql, $start, $this->pos - $start); + $upper = strtoupper($value); + + if ($upper === 'NULL') { + return new Token(TokenType::Null, $upper, $start); + } + + if ($upper === 'TRUE' || $upper === 'FALSE') { + return new Token(TokenType::Boolean, $upper, $start); + } + + if (isset(self::KEYWORD_MAP[$upper])) { + return new Token(TokenType::Keyword, $upper, $start); + } + + return new Token(TokenType::Identifier, $value, $start); + } + + private function readNamedPlaceholder(int $start): Token + { + $this->pos++; + while ($this->pos < $this->length && $this->isIdentChar($this->sql[$this->pos])) { + $this->pos++; + } + + return new Token(TokenType::NamedPlaceholder, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function readNumberedPlaceholder(int $start): Token + { + $this->pos++; + while ($this->pos < $this->length && $this->isDigit($this->sql[$this->pos])) { + $this->pos++; + } + + return new Token(TokenType::NumberedPlaceholder, substr($this->sql, $start, $this->pos - $start), $start); + } + + private function tryReadOperator(int $start): ?Token + { + $char = $this->sql[$this->pos]; + $next = $this->peek(1); + + $twoChar = match (true) { + $char === '<' && $next === '=' => '<=', + $char === '>' && $next === '=' => '>=', + $char === '!' && $next === '=' => '!=', + $char === '<' && $next === '>' => '<>', + $char === '|' && $next === '|' => '||', + default => null, + }; + + if ($twoChar !== null) { + $this->pos += 2; + return new Token(TokenType::Operator, $twoChar, $start); + } + + if (isset(self::SINGLE_OPERATORS[$char])) { + $this->pos++; + return new Token(TokenType::Operator, $char, $start); + } + + return null; + } +} diff --git a/src/Query/Type.php b/src/Query/Type.php new file mode 100644 index 0000000..8504493 --- /dev/null +++ b/src/Query/Type.php @@ -0,0 +1,13 @@ +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 = ReplacingMergeTree() + 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->assertSame('Charlie', $rows[0]['name']); + $this->assertSame('Alice', $rows[1]['name']); + $this->assertSame('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->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Charlie', $rows[1]['name']); + $this->assertSame('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(4, $rows); + foreach ($rows as $row) { + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('Frank', $rows[0]['name']); + $this->assertSame('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->assertSame('Alice', $rows[0]['name']); + $this->assertSame('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(4, $rows); + $this->assertSame(1, (int) $rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertSame(2, (int) $rows[1]['rn']); // @phpstan-ignore cast.int + $this->assertSame(3, (int) $rows[2]['rn']); // @phpstan-ignore cast.int + $this->assertSame(4, (int) $rows[3]['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->assertSame(['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 + { + $this->trackClickhouseTable('ch_sample'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_sample`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_sample` ( + `id` UInt32, + `name` String + ) ENGINE = MergeTree() + ORDER BY `id` + SAMPLE BY `id` + '); + $this->clickhouseStatement("INSERT INTO `ch_sample` VALUES (1, 'A'), (2, 'B'), (3, 'C'), (4, 'D'), (5, 'E')"); + + $result = (new Builder()) + ->from('ch_sample') + ->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->assertSame('click', $rows[0]['action']); + } + + public function testSelectWithBetween(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'age']) + ->filter([Query::between('age', 25, 30)]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $ages = array_column($rows, 'age'); + foreach ($ages as $age) { + $this->assertGreaterThanOrEqual(25, (int) $age); // @phpstan-ignore cast.int + $this->assertLessThanOrEqual(30, (int) $age); // @phpstan-ignore cast.int + } + $this->assertContains('Alice', array_column($rows, 'name')); + } + + public function testSelectWithStartsWithAndContains(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name', 'email']) + ->filter([ + Query::startsWith('email', 'a'), + Query::containsString('name', ['Alice']), + ]) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + } + + public function testSelectWithCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('age', Operator::LessThan, 30, 'young') + ->when('age', Operator::LessThan, 35, 'mid') + ->else('senior') + ->alias('bucket'); + + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->selectCase($case) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(5, $rows); + $buckets = array_column($rows, 'bucket'); + $this->assertContains('young', $buckets); + $this->assertContains('mid', $buckets); + $this->assertContains('senior', $buckets); + } + + public function testSelectWithArrayJoin(): void + { + $this->trackClickhouseTable('ch_tags'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_tags`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_tags` ( + `id` UInt32, + `name` String, + `tags` Array(String) + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_tags` (`id`, `name`, `tags`) VALUES + (1, 'Post A', ['news', 'sport']), + (2, 'Post B', ['tech']), + (3, 'Post C', ['news', 'tech', 'culture']) + "); + + $result = (new Builder()) + ->from('ch_tags') + ->select(['id', 'name']) + ->arrayJoin('tags', 'tag') + ->filter([Query::equal('tag', ['news'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $this->assertSame('Post A', $rows[0]['name']); + $this->assertSame('Post C', $rows[1]['name']); + } + + public function testSelectWithExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('ch_events') + ->filter([ + Query::equal('action', ['purchase']), + Query::equal('user_id', [1]), + ]); + + $result = (new Builder()) + ->from('ch_users') + ->select(['id', 'name']) + ->filterExists($subquery) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // Subquery has rows, so all users are returned. + $this->assertCount(5, $rows); + } + + public function testAsofJoin(): void + { + $this->trackClickhouseTable('ch_quotes'); + $this->trackClickhouseTable('ch_trades'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_quotes`'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_trades`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_quotes` ( + `symbol` String, + `ts` DateTime, + `bid` Float64 + ) ENGINE = MergeTree() + ORDER BY (`symbol`, `ts`) + '); + $this->clickhouseStatement(' + CREATE TABLE `ch_trades` ( + `symbol` String, + `ts` DateTime, + `price` Float64 + ) ENGINE = MergeTree() + ORDER BY (`symbol`, `ts`) + '); + $this->clickhouseStatement(" + INSERT INTO `ch_quotes` (`symbol`, `ts`, `bid`) VALUES + ('AAPL', '2026-01-01 10:00:00', 100.0), + ('AAPL', '2026-01-01 10:00:05', 101.0), + ('AAPL', '2026-01-01 10:00:10', 102.0) + "); + $this->clickhouseStatement(" + INSERT INTO `ch_trades` (`symbol`, `ts`, `price`) VALUES + ('AAPL', '2026-01-01 10:00:03', 100.5), + ('AAPL', '2026-01-01 10:00:07', 101.5) + "); + + $result = (new Builder()) + ->from('ch_trades', 't') + ->select(['t.symbol', 't.ts', 't.price', 'q.bid']) + ->asofJoin( + 'ch_quotes', + ['t.symbol' => 'q.symbol'], + 't.ts', + AsofOperator::GreaterThanEqual, + 'q.ts', + 'q', + ) + ->sortAsc('t.ts') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $this->assertSame(100.0, (float) $rows[0]['bid']); // @phpstan-ignore cast.double + $this->assertSame(101.0, (float) $rows[1]['bid']); // @phpstan-ignore cast.double + } + + public function testAsofLeftJoin(): void + { + $this->trackClickhouseTable('ch_quotes'); + $this->trackClickhouseTable('ch_trades'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_quotes`'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_trades`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_quotes` ( + `symbol` String, + `ts` DateTime, + `bid` Nullable(Float64) + ) ENGINE = MergeTree() + ORDER BY (`symbol`, `ts`) + '); + $this->clickhouseStatement(' + CREATE TABLE `ch_trades` ( + `symbol` String, + `ts` DateTime, + `price` Float64 + ) ENGINE = MergeTree() + ORDER BY (`symbol`, `ts`) + '); + $this->clickhouseStatement(" + INSERT INTO `ch_quotes` (`symbol`, `ts`, `bid`) VALUES + ('AAPL', '2026-01-01 10:00:00', 100.0) + "); + $this->clickhouseStatement(" + INSERT INTO `ch_trades` (`symbol`, `ts`, `price`) VALUES + ('AAPL', '2026-01-01 10:00:05', 100.5), + ('MSFT', '2026-01-01 10:00:05', 200.5) + "); + + $result = (new Builder()) + ->from('ch_trades', 't') + ->select(['t.symbol', 't.ts', 't.price', 'q.bid']) + ->asofLeftJoin( + 'ch_quotes', + ['t.symbol' => 'q.symbol'], + 't.ts', + AsofOperator::GreaterThanEqual, + 'q.ts', + 'q', + ) + ->sortAsc('t.symbol') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(2, $rows); + $this->assertSame('AAPL', $rows[0]['symbol']); + $this->assertSame(100.0, (float) $rows[0]['bid']); // @phpstan-ignore cast.double + $this->assertSame('MSFT', $rows[1]['symbol']); + $this->assertNull($rows[1]['bid']); + } + + public function testApproxDistinctCount(): void + { + $this->trackClickhouseTable('ch_approx'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_approx`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_approx` ( + `id` UInt64, + `user_id` UInt64, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + $values = []; + for ($id = 1; $id <= 120; $id++) { + $userId = (($id - 1) % 10) + 1; + $value = (float) $id; + $values[] = '(' . $id . ', ' . $userId . ', ' . $value . ')'; + } + $this->clickhouseStatement( + 'INSERT INTO `ch_approx` (`id`, `user_id`, `value`) VALUES ' + . \implode(', ', $values) + ); + + $result = (new Builder()) + ->from('ch_approx') + ->uniq('user_id', 'approx_distinct') + ->uniqExact('user_id', 'exact_distinct') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $exact = (int) $rows[0]['exact_distinct']; // @phpstan-ignore cast.int + $approx = (int) $rows[0]['approx_distinct']; // @phpstan-ignore cast.int + + $this->assertSame(10, $exact); + $this->assertGreaterThanOrEqual((int) \floor(10 * 0.95), $approx); + $this->assertLessThanOrEqual((int) \ceil(10 * 1.05), $approx); + } + + public function testApproxQuantile(): void + { + $this->trackClickhouseTable('ch_approx'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_approx`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_approx` ( + `id` UInt64, + `user_id` UInt64, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + $values = []; + for ($id = 1; $id <= 101; $id++) { + $userId = (($id - 1) % 10) + 1; + $value = (float) $id; + $values[] = '(' . $id . ', ' . $userId . ', ' . $value . ')'; + } + $this->clickhouseStatement( + 'INSERT INTO `ch_approx` (`id`, `user_id`, `value`) VALUES ' + . \implode(', ', $values) + ); + + $result = (new Builder()) + ->from('ch_approx') + ->quantile(0.5, 'value', 'p50') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $median = (float) $rows[0]['p50']; // @phpstan-ignore cast.double + + // Values are 1..101; true median is 51. Allow +/-10% tolerance. + $this->assertGreaterThanOrEqual(51.0 * 0.9, $median); + $this->assertLessThanOrEqual(51.0 * 1.1, $median); + } + + public function testApproxQuantiles(): void + { + $this->trackClickhouseTable('ch_approx'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_approx`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_approx` ( + `id` UInt64, + `user_id` UInt64, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + $values = []; + for ($id = 1; $id <= 100; $id++) { + $userId = (($id - 1) % 10) + 1; + $value = (float) $id; + $values[] = '(' . $id . ', ' . $userId . ', ' . $value . ')'; + } + $this->clickhouseStatement( + 'INSERT INTO `ch_approx` (`id`, `user_id`, `value`) VALUES ' + . \implode(', ', $values) + ); + + $result = (new Builder()) + ->from('ch_approx') + ->quantiles([0.25, 0.5, 0.75], 'value', 'qs') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $this->assertArrayHasKey('qs', $rows[0]); + $qs = $rows[0]['qs']; + $this->assertIsArray($qs); + $this->assertCount(3, $qs); + + $q25 = (float) $qs[0]; // @phpstan-ignore cast.double + $q50 = (float) $qs[1]; // @phpstan-ignore cast.double + $q75 = (float) $qs[2]; // @phpstan-ignore cast.double + + $this->assertLessThanOrEqual($q50, $q25); + $this->assertLessThanOrEqual($q75, $q50); + } + + public function testWindowFunctionWithRowsFrame(): void + { + $this->trackClickhouseTable('ch_window'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_window`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_window` ( + `id` UInt64, + `user_id` UInt64, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY (`user_id`, `id`) + '); + + $this->clickhouseStatement(" + INSERT INTO `ch_window` (`id`, `user_id`, `value`) VALUES + (1, 1, 10.0), + (2, 1, 20.0), + (3, 1, 30.0), + (4, 1, 40.0), + (5, 1, 50.0), + (6, 2, 100.0), + (7, 2, 200.0), + (8, 2, 300.0) + "); + + $frame = new WindowFrame('ROWS', '2 PRECEDING', 'CURRENT ROW'); + + $result = (new Builder()) + ->from('ch_window') + ->select(['id', 'user_id', 'value']) + ->selectWindow('sum(`value`)', 'rolling_sum', ['user_id'], ['id'], null, $frame) + ->filter([Query::equal('user_id', [1])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(5, $rows); + $sums = \array_map( + static fn (array $row): float => (float) $row['rolling_sum'], // @phpstan-ignore cast.double + $rows, + ); + + // user_id=1 values are 10,20,30,40,50 ordered by id. + // Rolling sum (2 preceding + current): + // id=1 -> 10 + // id=2 -> 10+20 = 30 + // id=3 -> 10+20+30 = 60 + // id=4 -> 20+30+40 = 90 + // id=5 -> 30+40+50 = 120 + $this->assertSame(10.0, $sums[0]); + $this->assertSame(30.0, $sums[1]); + $this->assertSame(60.0, $sums[2]); + $this->assertSame(90.0, $sums[3]); + $this->assertSame(120.0, $sums[4]); + } + + public function testOrderWithFillFillsGaps(): void + { + $this->trackClickhouseTable('ch_fill'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_fill`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_fill` ( + `ts` UInt32, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `ts` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_fill` (`ts`, `value`) VALUES + (1, 10.0), + (3, 30.0), + (5, 50.0) + "); + + $result = (new Builder()) + ->from('ch_fill') + ->select(['ts', 'value']) + ->orderWithFill('ts', 'ASC', 1, 5, 1) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // WITH FILL fills the gaps at ts=2 and ts=4 -> 5 rows. + $this->assertCount(5, $rows); + $timestamps = \array_map( + static fn (array $row): int => (int) $row['ts'], // @phpstan-ignore cast.int + $rows, + ); + $this->assertSame([1, 2, 3, 4, 5], $timestamps); + } + + public function testLimitByKeepsTopN(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['user_id', 'id', 'action']) + ->sortAsc('user_id') + ->sortAsc('id') + ->limitBy(1, ['user_id']) + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // user_ids are 1,2,3,4,5 — limit 1 per user_id -> 5 rows, one per user. + $this->assertCount(5, $rows); + $userIds = \array_map( + static fn (array $row): int => (int) $row['user_id'], // @phpstan-ignore cast.int + $rows, + ); + $this->assertSame([1, 2, 3, 4, 5], $userIds); + } + + public function testGroupConcat(): void + { + $result = (new Builder()) + ->from('ch_users') + ->select(['country']) + ->groupConcat('name', ',', 'names') + ->groupBy(['country']) + ->sortAsc('country') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(3, $rows); + $byCountry = []; + foreach ($rows as $row) { + $country = $row['country']; + $names = $row['names']; + \assert(\is_string($country) && \is_string($names)); + $byCountry[$country] = $names; + } + + $this->assertSame('Diana', $byCountry['DE']); + + $ukNames = \explode(',', $byCountry['UK']); + \sort($ukNames); + $this->assertSame(['Bob', 'Eve'], $ukNames); + + $usNames = \explode(',', $byCountry['US']); + \sort($usNames); + $this->assertSame(['Alice', 'Charlie'], $usNames); + } + + public function testStddev(): void + { + $this->trackClickhouseTable('ch_stats'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_stats`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_stats` ( + `id` UInt32, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_stats` (`id`, `value`) VALUES + (1, 2.0), + (2, 4.0), + (3, 4.0), + (4, 4.0), + (5, 5.0), + (6, 5.0), + (7, 7.0), + (8, 9.0) + "); + + $result = (new Builder()) + ->from('ch_stats') + ->stddev('value', 'sd') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $sd = (float) $rows[0]['sd']; // @phpstan-ignore cast.double + + // Population stddev of [2,4,4,4,5,5,7,9] is 2.0. + $this->assertGreaterThanOrEqual(1.9, $sd); + $this->assertLessThanOrEqual(2.1, $sd); + } + + public function testVariance(): void + { + $this->trackClickhouseTable('ch_stats'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_stats`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_stats` ( + `id` UInt32, + `value` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_stats` (`id`, `value`) VALUES + (1, 2.0), + (2, 4.0), + (3, 4.0), + (4, 4.0), + (5, 5.0), + (6, 5.0), + (7, 7.0), + (8, 9.0) + "); + + $result = (new Builder()) + ->from('ch_stats') + ->variance('value', 'var') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $var = (float) $rows[0]['var']; // @phpstan-ignore cast.double + + // Population variance of the same set is 4.0. + $this->assertGreaterThanOrEqual(3.8, $var); + $this->assertLessThanOrEqual(4.2, $var); + } + + public function testBitAnd(): void + { + $this->trackClickhouseTable('ch_flags'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_flags`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_flags` ( + `id` UInt32, + `flags` UInt32 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(' + INSERT INTO `ch_flags` (`id`, `flags`) VALUES + (1, 7), + (2, 5), + (3, 6) + '); + + $result = (new Builder()) + ->from('ch_flags') + ->bitAnd('flags', 'and_flags') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // 7 & 5 & 6 = 4 + $this->assertCount(1, $rows); + $this->assertSame(4, (int) $rows[0]['and_flags']); // @phpstan-ignore cast.int + } + + public function testBitOr(): void + { + $this->trackClickhouseTable('ch_flags'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_flags`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_flags` ( + `id` UInt32, + `flags` UInt32 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(' + INSERT INTO `ch_flags` (`id`, `flags`) VALUES + (1, 1), + (2, 2), + (3, 4) + '); + + $result = (new Builder()) + ->from('ch_flags') + ->bitOr('flags', 'or_flags') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // 1 | 2 | 4 = 7 + $this->assertCount(1, $rows); + $this->assertSame(7, (int) $rows[0]['or_flags']); // @phpstan-ignore cast.int + } + + public function testBitXor(): void + { + $this->trackClickhouseTable('ch_flags'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_flags`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_flags` ( + `id` UInt32, + `flags` UInt32 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(' + INSERT INTO `ch_flags` (`id`, `flags`) VALUES + (1, 3), + (2, 5), + (3, 6) + '); + + $result = (new Builder()) + ->from('ch_flags') + ->bitXor('flags', 'xor_flags') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // 3 ^ 5 ^ 6 = 0 + $this->assertCount(1, $rows); + $this->assertSame(0, (int) $rows[0]['xor_flags']); // @phpstan-ignore cast.int + } + + public function testHintSetting(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['id', 'action']) + ->sortAsc('id') + ->hint('max_threads=2') + ->build(); + + $this->assertStringContainsString('SETTINGS max_threads=2', $result->query); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(8, $rows); + $this->assertSame('click', $rows[0]['action']); + } + + public function testCountIf(): void + { + $result = (new Builder()) + ->from('ch_events') + ->countWhen('`action` = ?', 'clicks', 'click') + ->countWhen('`action` = ?', 'purchases', 'purchase') + ->build(); + + $this->assertStringContainsString('countIf(`action` = ?)', $result->query); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $this->assertSame(4, (int) $rows[0]['clicks']); // @phpstan-ignore cast.int + $this->assertSame(3, (int) $rows[0]['purchases']); // @phpstan-ignore cast.int + } + + public function testSumIf(): void + { + $result = (new Builder()) + ->from('ch_events') + ->sumWhen('value', '`action` = ?', 'purchase_total', 'purchase') + ->build(); + + $this->assertStringContainsString('sumIf(`value`, `action` = ?)', $result->query); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + // Purchases: 99.99 + 49.99 + 199.99 = 349.97 + $total = (float) $rows[0]['purchase_total']; // @phpstan-ignore cast.double + $this->assertGreaterThanOrEqual(349.96, $total); + $this->assertLessThanOrEqual(349.98, $total); + } + + public function testTableSample(): void + { + $this->trackClickhouseTable('ch_sampled'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_sampled`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_sampled` ( + `id` UInt32, + `name` String + ) ENGINE = MergeTree() + ORDER BY `id` + SAMPLE BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_sampled` (`id`, `name`) VALUES + (1, 'A'), (2, 'B'), (3, 'C'), (4, 'D'), (5, 'E') + "); + + $result = (new Builder()) + ->from('ch_sampled') + ->select(['id', 'name']) + ->tablesample(50.0) + ->build(); + + $this->assertStringContainsString('SAMPLE 0.5', $result->query); + + $rows = $this->executeOnClickhouse($result); + + $this->assertLessThanOrEqual(5, \count($rows)); + foreach ($rows as $row) { + $this->assertArrayHasKey('id', $row); + $this->assertArrayHasKey('name', $row); + } + } + + public function testGroupByWithRollup(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['user_id', 'action']) + ->count('*', 'cnt') + ->groupBy(['user_id', 'action']) + ->withRollup() + ->sortAsc('user_id') + ->sortAsc('action') + ->build(); + + $this->assertStringContainsString('WITH ROLLUP', $result->query); + + $rows = $this->executeOnClickhouse($result); + + // With ROLLUP: leaf rows (user_id, action), subtotals per user_id + // (action NULL/empty), and a grand total (both NULL/empty). + // There are 7 distinct (user_id, action) pairs in the seed data, + // plus 5 user subtotals, plus 1 grand total = 13. + $this->assertSame(13, \count($rows)); + } + + public function testGroupByWithTotals(): void + { + $result = (new Builder()) + ->from('ch_events') + ->select(['action']) + ->count('*', 'cnt') + ->groupBy(['action']) + ->withTotals() + ->sortAsc('action') + ->build(); + + $this->assertStringContainsString('WITH TOTALS', $result->query); + + $rows = $this->executeOnClickhouse($result); + + // JSONEachRow emits only data rows for WITH TOTALS (totals are in a + // separate section), so we see one row per distinct action. + $actions = \array_column($rows, 'action'); + $this->assertContains('click', $actions); + $this->assertContains('purchase', $actions); + $this->assertContains('view', $actions); + } + + public function testFullOuterJoin(): void + { + $this->trackClickhouseTable('ch_left'); + $this->trackClickhouseTable('ch_right'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_left`'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_right`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_left` ( + `id` UInt32, + `label` String + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(' + CREATE TABLE `ch_right` ( + `id` UInt32, + `label` String + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_left` (`id`, `label`) VALUES + (1, 'L1'), + (2, 'L2') + "); + $this->clickhouseStatement(" + INSERT INTO `ch_right` (`id`, `label`) VALUES + (2, 'R2'), + (3, 'R3') + "); + + $result = (new Builder()) + ->from('ch_left', 'l') + ->select(['l.label', 'r.label']) + ->fullOuterJoin('ch_right', 'l.id', 'r.id', '=', 'r') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + // Full outer join: matched (2) + left-only (1) + right-only (3) = 3 rows. + $this->assertCount(3, $rows); + } + + public function testTopK(): void + { + $this->trackClickhouseTable('ch_topk'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_topk`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_topk` ( + `id` UInt32, + `category` String + ) ENGINE = MergeTree() + ORDER BY `id` + '); + + // Heavy-hitter distribution so topK results are deterministic: + // 'a' x 50, 'b' x 30, 'c' x 15, 'd' x 5. + $values = []; + $id = 1; + foreach (['a' => 50, 'b' => 30, 'c' => 15, 'd' => 5] as $category => $count) { + for ($i = 0; $i < $count; $i++) { + $values[] = '(' . $id . ", '" . $category . "')"; + $id++; + } + } + $this->clickhouseStatement( + 'INSERT INTO `ch_topk` (`id`, `category`) VALUES ' . \implode(', ', $values) + ); + + $result = (new Builder()) + ->from('ch_topk') + ->topK(3, 'category', 'top') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $top = $rows[0]['top']; + $this->assertIsArray($top); + $this->assertCount(3, $top); + $this->assertSame(['a', 'b', 'c'], $top); + } + + public function testArgMinArgMax(): void + { + $this->trackClickhouseTable('ch_arg'); + $this->clickhouseStatement('DROP TABLE IF EXISTS `ch_arg`'); + $this->clickhouseStatement(' + CREATE TABLE `ch_arg` ( + `id` UInt32, + `name` String, + `score` Float64 + ) ENGINE = MergeTree() + ORDER BY `id` + '); + $this->clickhouseStatement(" + INSERT INTO `ch_arg` (`id`, `name`, `score`) VALUES + (1, 'Alice', 10.0), + (2, 'Bob', 25.0), + (3, 'Charlie', 5.0), + (4, 'Diana', 40.0) + "); + + $result = (new Builder()) + ->from('ch_arg') + ->argMin('name', 'score', 'min_name') + ->argMax('name', 'score', 'max_name') + ->build(); + + $rows = $this->executeOnClickhouse($result); + + $this->assertCount(1, $rows); + $this->assertSame('Charlie', $rows[0]['min_name']); + $this->assertSame('Diana', $rows[0]['max_name']); + } +} diff --git a/tests/Integration/Builder/MariaDBIntegrationTest.php b/tests/Integration/Builder/MariaDBIntegrationTest.php new file mode 100644 index 0000000..1076be9 --- /dev/null +++ b/tests/Integration/Builder/MariaDBIntegrationTest.php @@ -0,0 +1,899 @@ +builder = new Builder(); + $pdo = $this->connectMariadb(); + + $this->trackMariadbTable('users'); + $this->trackMariadbTable('orders'); + + $this->mariadbStatement('DROP TABLE IF EXISTS `orders`'); + $this->mariadbStatement('DROP TABLE IF EXISTS `users`'); + + $this->mariadbStatement(' + 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->mariadbStatement(' + 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->executeOnMariadb($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->executeOnMariadb($result); + + $this->assertCount(3, $rows); + $this->assertSame('Charlie', $rows[0]['name']); + $this->assertSame('Alice', $rows[1]['name']); + $this->assertSame('Diana', $rows[2]['name']); + } + + public function testSelectWithOffset(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->sortAsc('name') + ->limit(2) + ->offset(2) + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(2, $rows); + $this->assertSame('Charlie', $rows[0]['name']); + $this->assertSame('Diana', $rows[1]['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->executeOnMariadb($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->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + $names = array_column($rows, 'name'); + $this->assertContains('Eve', $names); + } + + public function testSelectWithRightJoin(): void + { + $result = $this->fresh() + ->from('orders', 'o') + ->select(['u.name', 'o.product']) + ->rightJoin('users', 'o.user_id', 'u.id', '=', 'u') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + $names = array_column($rows, 'name'); + $this->assertContains('Eve', $names); + } + + public function testSelectWithCrossJoin(): void + { + $this->mariadbStatement('CREATE TEMPORARY TABLE `labels` (`label` VARCHAR(10) NOT NULL)'); + $this->mariadbStatement("INSERT INTO `labels` (`label`) VALUES ('X'), ('Y')"); + + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'l.label']) + ->crossJoin('labels', 'l') + ->filter([Query::equal('u.city', ['Paris'])]) + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(2, $rows); + $labels = array_column($rows, 'label'); + $this->assertContains('X', $labels); + $this->assertContains('Y', $labels); + } + + public function testInsertSingleRow(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Frank', 'email' => 'frank@example.com', 'age' => 40, 'city' => 'Berlin', 'active' => 1]) + ->insert(); + + $this->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame('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->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $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->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['active']) + ->filter([Query::equal('name', ['Bob'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(0, (int) $rows[0]['active']); // @phpstan-ignore cast.int + } + + public function testDeleteWithWhere(): void + { + $this->mariadbStatement('DELETE FROM `orders` WHERE `user_id` = 3'); + + $result = $this->fresh() + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->delete(); + + $this->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('name', ['Charlie'])]) + ->build() + ); + + $this->assertCount(0, $rows); + } + + public function testSelectWithGroupBy(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(4, $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->executeOnMariadb($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->executeOnMariadb($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 testSelectWithUnionAll(): void + { + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]) + ->unionAll( + (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['New York'])]) + ) + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(4, $rows); + } + + public function testSelectWithCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('age', Operator::LessThan, 25, 'young') + ->whenRaw('`age` BETWEEN 25 AND 30', 'mid') + ->else('senior') + ->alias('age_group'); + + $result = $this->fresh() + ->from('users') + ->select(['name']) + ->selectCase($case) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(5, $rows); + $map = array_column($rows, 'age_group', 'name'); + $this->assertSame('mid', $map['Alice']); + $this->assertSame('mid', $map['Bob']); + $this->assertSame('senior', $map['Charlie']); + $this->assertSame('mid', $map['Diana']); + $this->assertSame('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->executeOnMariadb($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') + ->select('1') + ->filter([Query::equal('o.status', ['completed'])]); + + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($subquery) + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(5, $rows); + + $noMatchSubquery = (new Builder()) + ->from('orders', 'o') + ->select('1') + ->filter([Query::equal('o.status', ['refunded'])]); + + $emptyResult = $this->fresh() + ->from('users', 'u') + ->select(['u.name']) + ->filterExists($noMatchSubquery) + ->build(); + + $emptyRows = $this->executeOnMariadb($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->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertGreaterThan(30, (float) $row['total']); // @phpstan-ignore cast.double + } + } + + public function testRecursiveCte(): void + { + $seed = (new Builder()) + ->from() + ->select('1 AS n'); + + $step = (new Builder()) + ->from('t') + ->select('n + 1') + ->filter([Query::lessThan('n', 5)]); + + $result = $this->fresh() + ->withRecursiveSeedStep('t', $seed, $step, ['n']) + ->from('t') + ->select(['n']) + ->sortAsc('n') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(5, $rows); + $values = array_map(fn (array $row): int => (int) $row['n'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 2, 3, 4, 5], $values); + } + + 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->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(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->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertArrayHasKey('rn', $row); + $this->assertGreaterThanOrEqual(1, (int) $row['rn']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithRankWindowFunction(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id', 'amount']) + ->selectWindow('RANK()', 'rnk', ['user_id'], ['-amount']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertArrayHasKey('rnk', $row); + $this->assertGreaterThanOrEqual(1, (int) $row['rnk']); // @phpstan-ignore cast.int + } + } + + public function testSelectWithAggregateWindow(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id', 'amount']) + ->selectWindow('SUM(`amount`)', 'running_total', ['user_id'], ['id']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertNotEmpty($rows); + foreach ($rows as $row) { + $this->assertArrayHasKey('running_total', $row); + } + } + + public function testSelectWithDistinct(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['product']) + ->distinct() + ->sortAsc('product') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(3, $rows); + $products = array_column($rows, 'product'); + $this->assertSame(['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->executeOnMariadb($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->executeOnMariadb($result); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + } + + public function testSelectForUpdate(): void + { + $pdo = $this->connectMariadb(); + $pdo->beginTransaction(); + + try { + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('name', ['Alice'])]) + ->forUpdate() + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } + + public function testInsertWithReturning(): void + { + $result = $this->fresh() + ->into('users') + ->set(['name' => 'Gina', 'email' => 'gina@example.com', 'age' => 27, 'city' => 'Madrid', 'active' => 1]) + ->returning(['id', 'name']) + ->insert(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(1, $rows); + $this->assertSame('Gina', $rows[0]['name']); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertGreaterThan(0, (int) $rows[0]['id']); // @phpstan-ignore cast.int + + $verify = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('id', [(int) $rows[0]['id']])]) // @phpstan-ignore cast.int + ->build() + ); + + $this->assertCount(1, $verify); + $this->assertSame('gina@example.com', $verify[0]['email']); + } + + public function testSequences(): void + { + $this->trackMariadbTable('seq_user_id'); + + $this->mariadbStatement('DROP SEQUENCE IF EXISTS `seq_user_id`'); + $this->mariadbStatement('CREATE SEQUENCE `seq_user_id` START WITH 1000 INCREMENT BY 1'); + + $source = (new Builder()) + ->fromNone() + ->nextVal('seq_user_id') + ->selectRaw('?', ['SeqUser']) + ->selectRaw('?', ['seq@example.com']) + ->selectRaw('?', [21]) + ->selectRaw('?', ['Lisbon']) + ->selectRaw('?', [1]); + + $result = $this->fresh() + ->into('users') + ->fromSelect(['id', 'name', 'email', 'age', 'city', 'active'], $source) + ->insertSelect(); + + $this->executeOnMariadb($result); + + $rows = $this->executeOnMariadb( + $this->fresh() + ->from('users') + ->select(['id']) + ->filter([Query::equal('email', ['seq@example.com'])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(1000, (int) $rows[0]['id']); // @phpstan-ignore cast.int + + $pdo = $this->connectMariadb(); + $stmt = $pdo->query('SELECT NEXTVAL(`seq_user_id`) AS v'); + $this->assertNotFalse($stmt); + /** @var array $next */ + $next = $stmt->fetch(\PDO::FETCH_ASSOC); + $this->assertSame(1001, (int) $next['v']); // @phpstan-ignore cast.int + } + + public function testGroupConcat(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id']) + ->groupConcat('product', ',', 'products', ['product']) + ->groupBy(['user_id']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $map = []; + foreach ($rows as $row) { + /** @var int $userId */ + $userId = (int) $row['user_id']; // @phpstan-ignore cast.int + /** @var string $products */ + $products = (string) $row['products']; // @phpstan-ignore cast.string + $map[$userId] = $products; + } + + $this->assertSame('Gadget,Widget', $map[1]); + $this->assertSame('Widget', $map[2]); + $this->assertSame('Gizmo', $map[3]); + $this->assertSame('Gadget,Widget', $map[4]); + } + + public function testCountWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->countWhen('`status` = ?', 'completed_count', 'completed') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(1, $rows); + $this->assertSame(3, (int) $rows[0]['completed_count']); // @phpstan-ignore cast.int + } + + public function testSumWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->sumWhen('amount', '`status` = ?', 'completed_total', 'completed') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $this->assertCount(1, $rows); + $this->assertSame('99.97', (string) $rows[0]['completed_total']); // @phpstan-ignore cast.string + } + + private function createJsonDocsTable(): void + { + $this->trackMariadbTable('json_docs'); + $this->mariadbStatement('DROP TABLE IF EXISTS `json_docs`'); + $this->mariadbStatement(' + CREATE TABLE `json_docs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tags` JSON NOT NULL, + `metadata` JSON NOT NULL + ) ENGINE=InnoDB + '); + + $pdo = $this->connectMariadb(); + $stmt = $pdo->prepare('INSERT INTO `json_docs` (`tags`, `metadata`) VALUES (?, ?), (?, ?), (?, ?), (?, ?)'); + $stmt->execute([ + '["php", "mariadb"]', '{"level": 3, "active": true}', + '["go", "mariadb"]', '{"level": 7, "active": true}', + '["rust"]', '{"level": 10, "active": false}', + '["php", "rust"]', '{"level": 5, "active": true}', + ]); + } + + public function testJsonFilterContains(): void + { + $this->createJsonDocsTable(); + + $result = $this->fresh() + ->from('json_docs') + ->select(['id']) + ->filterJsonContains('tags', 'php') + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $ids = array_map(fn (array $row): int => (int) $row['id'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 4], $ids); + } + + public function testJsonFilterPath(): void + { + $this->createJsonDocsTable(); + + $result = $this->fresh() + ->from('json_docs') + ->select(['id']) + ->filterJsonPath('metadata', 'level', '>', 5) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $ids = array_map(fn (array $row): int => (int) $row['id'], $rows); // @phpstan-ignore cast.int + $this->assertSame([2, 3], $ids); + } + + public function testJsonSetPath(): void + { + $this->createJsonDocsTable(); + + $update = $this->fresh() + ->from('json_docs') + ->setJsonPath('metadata', '$.level', 42) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->executeOnMariadb($update); + + $pdo = $this->connectMariadb(); + $stmt = $pdo->prepare('SELECT `metadata` FROM `json_docs` WHERE `id` = 1'); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + /** @var string $metadataJson */ + $metadataJson = $row['metadata']; + $metadata = \json_decode($metadataJson, true); + $this->assertIsArray($metadata); + $this->assertSame(42, $metadata['level']); + $this->assertTrue($metadata['active']); + } + + private function createPlacesTable(): void + { + $this->trackMariadbTable('places'); + $this->mariadbStatement('DROP TABLE IF EXISTS `places`'); + $this->mariadbStatement(' + CREATE TABLE `places` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL, + `location` POINT NOT NULL, + SPATIAL INDEX `sp_location` (`location`) + ) ENGINE=InnoDB + '); + + $pdo = $this->connectMariadb(); + $stmt = $pdo->prepare( + 'INSERT INTO `places` (`name`, `location`) VALUES ' + . '(?, ST_GeomFromText(?, 4326)), ' + . '(?, ST_GeomFromText(?, 4326)), ' + . '(?, ST_GeomFromText(?, 4326)), ' + . '(?, ST_GeomFromText(?, 4326))' + ); + $stmt->execute([ + 'Inside1', 'POINT(0.5 0.5)', + 'Inside2', 'POINT(0.2 0.8)', + 'Outside1', 'POINT(5 5)', + 'Outside2', 'POINT(-1 -1)', + ]); + } + + public function testSpatialIntersects(): void + { + $this->createPlacesTable(); + + $polygon = [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]]; + + $result = $this->fresh() + ->from('places') + ->select(['name']) + ->filterIntersects('location', $polygon) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMariadb($result); + + $names = array_column($rows, 'name'); + $this->assertSame(['Inside1', 'Inside2'], $names); + } +} diff --git a/tests/Integration/Builder/MongoDBIntegrationTest.php b/tests/Integration/Builder/MongoDBIntegrationTest.php new file mode 100644 index 0000000..38a9356 --- /dev/null +++ b/tests/Integration/Builder/MongoDBIntegrationTest.php @@ -0,0 +1,1169 @@ +connectMongoDB(); + + $this->trackMongoCollection('mg_users'); + $this->trackMongoCollection('mg_orders'); + + $client = $this->mongoClient; + $this->assertNotNull($client); + + $client->dropCollection('mg_users'); + $client->dropCollection('mg_orders'); + + $client->insertMany('mg_users', [ + ['id' => 1, 'name' => 'Alice', 'email' => 'alice@test.com', 'age' => 30, 'country' => 'US', 'active' => true], + ['id' => 2, 'name' => 'Bob', 'email' => 'bob@test.com', 'age' => 25, 'country' => 'UK', 'active' => true], + ['id' => 3, 'name' => 'Charlie', 'email' => 'charlie@test.com', 'age' => 35, 'country' => 'US', 'active' => false], + ['id' => 4, 'name' => 'Diana', 'email' => 'diana@test.com', 'age' => 28, 'country' => 'DE', 'active' => true], + ['id' => 5, 'name' => 'Eve', 'email' => 'eve@test.com', 'age' => 22, 'country' => 'UK', 'active' => true], + ]); + + $client->insertMany('mg_orders', [ + ['id' => 1, 'user_id' => 1, 'product' => 'Widget', 'amount' => 29.99, 'status' => 'completed'], + ['id' => 2, 'user_id' => 1, 'product' => 'Gadget', 'amount' => 49.99, 'status' => 'completed'], + ['id' => 3, 'user_id' => 2, 'product' => 'Widget', 'amount' => 29.99, 'status' => 'pending'], + ['id' => 4, 'user_id' => 3, 'product' => 'Gizmo', 'amount' => 99.99, 'status' => 'completed'], + ['id' => 5, 'user_id' => 4, 'product' => 'Widget', 'amount' => 29.99, 'status' => 'cancelled'], + ['id' => 6, 'user_id' => 4, 'product' => 'Gadget', 'amount' => 49.99, 'status' => 'pending'], + ['id' => 7, 'user_id' => 5, 'product' => 'Gizmo', 'amount' => 99.99, 'status' => 'completed'], + ]); + } + + public function testSelectWithWhere(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['id', 'name', 'country']) + ->filter([Query::equal('country', ['US'])]) + ->build(); + + $rows = $this->executeOnMongoDB($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('mg_users') + ->select(['id', 'name', 'age']) + ->sortDesc('age') + ->limit(3) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(3, $rows); + $this->assertSame('Charlie', $rows[0]['name']); + $this->assertSame('Alice', $rows[1]['name']); + $this->assertSame('Diana', $rows[2]['name']); + } + + public function testSelectWithJoin(): void + { + $result = (new Builder()) + ->from('mg_orders') + ->select(['id', 'product', 'u.name']) + ->join('mg_users', 'mg_orders.user_id', 'mg_users.id', '=', 'u') + ->filter([Query::equal('status', ['completed'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(4, $rows); + /** @var array $joined */ + $joined = $rows[0]['u']; + $this->assertSame('Alice', $joined['name']); + } + + public function testSelectWithLeftJoin(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->leftJoin('mg_orders', 'mg_users.id', 'mg_orders.user_id', '=', 'o') + ->filter([Query::equal('o.status', ['cancelled'])]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(1, $rows); + $this->assertSame('Diana', $rows[0]['name']); + } + + public function testInsertSingleRow(): void + { + $insert = (new Builder()) + ->into('mg_users') + ->set(['id' => 10, 'name' => 'Frank', 'email' => 'frank@test.com', 'age' => 40, 'country' => 'FR', 'active' => true]) + ->insert(); + + $this->executeOnMongoDB($insert); + + $select = (new Builder()) + ->from('mg_users') + ->select(['id', 'name']) + ->filter([Query::equal('id', [10])]) + ->build(); + + $rows = $this->executeOnMongoDB($select); + + $this->assertCount(1, $rows); + $this->assertSame('Frank', $rows[0]['name']); + } + + public function testInsertMultipleRows(): void + { + $insert = (new Builder()) + ->into('mg_users') + ->set(['id' => 10, 'name' => 'Frank', 'email' => 'frank@test.com', 'age' => 40, 'country' => 'FR', 'active' => true]) + ->set(['id' => 11, 'name' => 'Grace', 'email' => 'grace@test.com', 'age' => 33, 'country' => 'FR', 'active' => true]) + ->insert(); + + $this->executeOnMongoDB($insert); + + $select = (new Builder()) + ->from('mg_users') + ->select(['id', 'name']) + ->filter([Query::equal('country', ['FR'])]) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMongoDB($select); + + $this->assertCount(2, $rows); + $this->assertSame('Frank', $rows[0]['name']); + $this->assertSame('Grace', $rows[1]['name']); + } + + public function testUpdateWithWhere(): void + { + $update = (new Builder()) + ->from('mg_users') + ->set(['country' => 'CA']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + + $this->executeOnMongoDB($update); + + $select = (new Builder()) + ->from('mg_users') + ->select(['country']) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + + $rows = $this->executeOnMongoDB($select); + + $this->assertCount(1, $rows); + $this->assertSame('CA', $rows[0]['country']); + } + + public function testDeleteWithWhere(): void + { + $delete = (new Builder()) + ->from('mg_users') + ->filter([Query::equal('name', ['Eve'])]) + ->delete(); + + $this->executeOnMongoDB($delete); + + $select = (new Builder()) + ->from('mg_users') + ->filter([Query::equal('name', ['Eve'])]) + ->build(); + + $rows = $this->executeOnMongoDB($select); + + $this->assertCount(0, $rows); + } + + public function testSelectWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('mg_orders') + ->select(['status']) + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 1)]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $statuses = \array_column($rows, 'status'); + $this->assertContains('completed', $statuses); + foreach ($rows as $row) { + /** @var int $cnt */ + $cnt = $row['cnt']; + $this->assertGreaterThan(1, $cnt); + } + } + + public function testSelectWithUnionAll(): void + { + $first = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]); + + $second = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->filter([Query::equal('country', ['UK'])]); + + $result = $first->unionAll($second)->build(); + + $rows = $this->executeOnMongoDB($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 testSelectWithDistinct(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['country']) + ->distinct() + ->sortAsc('country') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(3, $rows); + $countries = \array_column($rows, 'country'); + $this->assertSame(['DE', 'UK', 'US'], $countries); + } + + public function testSelectWithSubqueryInWhere(): void + { + $subquery = (new Builder()) + ->from('mg_orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('mg_users') + ->select(['id', 'name']) + ->filterWhereIn('id', $subquery) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(3, $rows); + $names = \array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Charlie', $names); + $this->assertContains('Eve', $names); + } + + public function testUpsertOnConflict(): void + { + $result = (new Builder()) + ->into('mg_users') + ->set(['email' => 'alice@test.com', 'name' => 'Alice Updated', 'age' => 31, 'country' => 'US', 'active' => true]) + ->onConflict(['email'], ['name', 'age']) + ->upsert(); + + $this->executeOnMongoDB($result); + + $check = (new Builder()) + ->from('mg_users') + ->select(['name', 'age']) + ->filter([Query::equal('email', ['alice@test.com'])]) + ->build(); + + $rows = $this->executeOnMongoDB($check); + + $this->assertCount(1, $rows); + $this->assertSame('Alice Updated', $rows[0]['name']); + $this->assertSame(31, $rows[0]['age']); + } + + public function testSelectWithWindowFunction(): void + { + $result = (new Builder()) + ->from('mg_orders') + ->select(['user_id', 'product', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->sortAsc('user_id') + ->sortDesc('amount') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertGreaterThan(0, \count($rows)); + $this->assertArrayHasKey('rn', $rows[0]); + + // Check first user's rows are numbered + $user1Rows = \array_values(\array_filter($rows, fn ($r) => $r['user_id'] === 1)); + $this->assertSame(1, $user1Rows[0]['rn']); + $this->assertSame(2, $user1Rows[1]['rn']); + } + + public function testFilterStartsWith(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->filter([Query::startsWith('name', 'Al')]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + } + + public function testFilterContains(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->filter([Query::containsString('email', ['test.com'])]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(5, $rows); + } + + public function testFilterBetween(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name', 'age']) + ->filter([Query::between('age', 25, 30)]) + ->sortAsc('age') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(3, $rows); + foreach ($rows as $row) { + $this->assertGreaterThanOrEqual(25, $row['age']); + $this->assertLessThanOrEqual(30, $row['age']); + } + } + + public function testSelectWithOffset(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->sortAsc('name') + ->limit(2) + ->offset(1) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(2, $rows); + $this->assertSame('Bob', $rows[0]['name']); + $this->assertSame('Charlie', $rows[1]['name']); + } + + public function testFilterRegex(): void + { + $result = (new Builder()) + ->from('mg_users') + ->select(['name']) + ->filter([Query::regex('name', '^[A-C]')]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(3, $rows); + $names = \array_column($rows, 'name'); + $this->assertSame(['Alice', 'Bob', 'Charlie'], $names); + } + + public function testAggregateSum(): void + { + $result = (new Builder()) + ->from('mg_orders') + ->sum('amount', 'total') + ->groupBy(['user_id']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertGreaterThan(0, \count($rows)); + // User 1 has orders of 29.99 + 49.99 = 79.98 + $user1 = \array_values(\array_filter($rows, fn ($r) => $r['user_id'] === 1))[0]; + $this->assertEqualsWithDelta(79.98, $user1['total'], 0.01); + } + + public function testFieldUpdateSet(): void + { + $this->trackMongoCollection('mg_field_updates'); + $this->mongoClient?->dropCollection('mg_field_updates'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_field_updates') + ->set(['id' => 1, 'name' => 'Alice', 'age' => 30, 'email' => 'alice@example.com']) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->set(['nickname' => 'Ally']) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->select(['id', 'name', 'nickname']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame('Ally', $rows[0]['nickname']); + $this->assertSame('Alice', $rows[0]['name']); + } + + public function testFieldUpdateInc(): void + { + $this->trackMongoCollection('mg_field_updates'); + $this->mongoClient?->dropCollection('mg_field_updates'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_field_updates') + ->set(['id' => 2, 'name' => 'Bob', 'age' => 25, 'email' => 'bob@example.com']) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->increment('age', 1) + ->filter([Query::equal('id', [2])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->select(['id', 'age']) + ->filter([Query::equal('id', [2])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(26, $rows[0]['age']); + } + + public function testFieldUpdateRename(): void + { + $this->trackMongoCollection('mg_field_updates'); + $this->mongoClient?->dropCollection('mg_field_updates'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_field_updates') + ->set(['id' => 3, 'name' => 'Carol', 'age' => 40, 'email' => 'carol@example.com']) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->rename('email', 'contact') + ->filter([Query::equal('id', [3])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->select(['id', 'email', 'contact']) + ->filter([Query::equal('id', [3])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame('carol@example.com', $rows[0]['contact']); + $this->assertArrayNotHasKey('email', $rows[0]); + } + + public function testFieldUpdateUnset(): void + { + $this->trackMongoCollection('mg_field_updates'); + $this->mongoClient?->dropCollection('mg_field_updates'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_field_updates') + ->set(['id' => 4, 'name' => 'Dave', 'age' => 35, 'email' => 'dave@example.com']) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->unsetFields('email') + ->filter([Query::equal('id', [4])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_field_updates') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('id', [4])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame('Dave', $rows[0]['name']); + $this->assertArrayNotHasKey('email', $rows[0]); + } + + public function testArrayPush(): void + { + $this->trackMongoCollection('mg_array_push'); + $this->mongoClient?->dropCollection('mg_array_push'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_array_push') + ->set(['id' => 1, 'tags' => []]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->push('tags', 'alpha') + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $this->assertSame(['alpha'], \array_values($tags)); + } + + public function testArrayAddToSet(): void + { + $this->trackMongoCollection('mg_array_push'); + $this->mongoClient?->dropCollection('mg_array_push'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_array_push') + ->set(['id' => 2, 'tags' => ['alpha']]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->addToSet('tags', 'alpha') + ->filter([Query::equal('id', [2])]) + ->update() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->addToSet('tags', 'beta') + ->filter([Query::equal('id', [2])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [2])]) + ->build() + ); + + $this->assertCount(1, $rows); + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $values = \array_values($tags); + $this->assertSame(['alpha', 'beta'], $values); + } + + public function testArrayPushEach(): void + { + $this->trackMongoCollection('mg_array_push'); + $this->mongoClient?->dropCollection('mg_array_push'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_array_push') + ->set(['id' => 3, 'tags' => []]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->pushEach('tags', ['x', 'y', 'z']) + ->filter([Query::equal('id', [3])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [3])]) + ->build() + ); + + $this->assertCount(1, $rows); + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $this->assertSame(['x', 'y', 'z'], \array_values($tags)); + } + + public function testArrayPushEachWithSlice(): void + { + $this->trackMongoCollection('mg_array_push'); + $this->mongoClient?->dropCollection('mg_array_push'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_array_push') + ->set(['id' => 4, 'tags' => ['a', 'b']]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->pushEach('tags', ['c', 'd', 'e'], slice: 3) + ->filter([Query::equal('id', [4])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_array_push') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [4])]) + ->build() + ); + + $this->assertCount(1, $rows); + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $values = \array_values($tags); + $this->assertCount(3, $values); + $this->assertSame(['a', 'b', 'c'], $values); + } + + public function testPipelineFacet(): void + { + $this->trackMongoCollection('mg_scores'); + $this->mongoClient?->dropCollection('mg_scores'); + + $documents = []; + for ($i = 1; $i <= 10; $i++) { + $documents[] = ['id' => $i, 'score' => $i * 10]; + } + $this->mongoClient?->insertMany('mg_scores', $documents); + + $high = (new Builder()) + ->from('mg_scores') + ->count('*', 'cnt') + ->filter([Query::greaterThan('score', 50)]); + + $low = (new Builder()) + ->from('mg_scores') + ->count('*', 'cnt') + ->filter([Query::lessThanEqual('score', 50)]); + + $result = (new Builder()) + ->from('mg_scores') + ->facet(['highScores' => $high, 'lowScores' => $low]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(1, $rows); + /** @var array> $highBucket */ + $highBucket = (array) $rows[0]['highScores']; + /** @var array> $lowBucket */ + $lowBucket = (array) $rows[0]['lowScores']; + + $highFirst = (array) \array_values($highBucket)[0]; + $lowFirst = (array) \array_values($lowBucket)[0]; + + $this->assertSame(5, $highFirst['cnt']); + $this->assertSame(5, $lowFirst['cnt']); + } + + public function testPipelineBucket(): void + { + $this->trackMongoCollection('mg_scores'); + $this->mongoClient?->dropCollection('mg_scores'); + + $documents = [ + ['id' => 1, 'score' => 10], + ['id' => 2, 'score' => 20], + ['id' => 3, 'score' => 40], + ['id' => 4, 'score' => 50], + ['id' => 5, 'score' => 60], + ['id' => 6, 'score' => 80], + ['id' => 7, 'score' => 95], + ]; + $this->mongoClient?->insertMany('mg_scores', $documents); + + $result = (new Builder()) + ->from('mg_scores') + ->bucket('score', [0, 50, 100], 'other', ['count' => ['$sum' => 1]]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $counts = []; + foreach ($rows as $row) { + /** @var int $count */ + $count = $row['count']; + $counts[] = $count; + } + \sort($counts); + + $this->assertSame([3, 4], $counts); + } + + /** + * Atlas $search requires a `mongot` sidecar process and an Atlas Search index. + * The vanilla `mongo:7` image shipped by our docker-compose does NOT include + * mongot, so `db.runCommand({aggregate, pipeline:[{$search:...}]})` is rejected + * with "$search is not allowed in this atlas tier". This is as close as we can + * get to "real" integration coverage without standing up Atlas Local + * (`mongodb/mongodb-atlas-local`) — we assert on the shape of the Statement the + * Builder produces and skip the actual round-trip. + */ + public function testAtlasSearchQueryStructure(): void + { + $result = (new Builder()) + ->from('mg_articles') + ->search(['text' => ['query' => 'hello world', 'path' => 'body']], 'default') + ->build(); + + /** @var array $op */ + $op = \json_decode($result->query, true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame('mg_articles', $op['collection']); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertNotEmpty($pipeline); + $this->assertArrayHasKey('$search', $pipeline[0]); + + /** @var array $searchStage */ + $searchStage = $pipeline[0]['$search']; + $this->assertSame('default', $searchStage['index']); + $this->assertSame(['query' => 'hello world', 'path' => 'body'], $searchStage['text']); + } + + public function testArrayFilterUpdate(): void + { + $this->trackMongoCollection('mg_students'); + $this->mongoClient?->dropCollection('mg_students'); + + $this->mongoClient?->insertMany('mg_students', [ + [ + 'id' => 1, + 'name' => 'Alice', + 'grades' => [ + ['subject' => 'math', 'grade' => 90, 'mean' => 0], + ['subject' => 'history', 'grade' => 70, 'mean' => 0], + ['subject' => 'science', 'grade' => 85, 'mean' => 0], + ], + ], + ]); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_students') + ->set(['grades.$[elem].mean' => 100]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_students') + ->select(['id', 'grades']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + /** @var list> $grades */ + $grades = (array) $rows[0]['grades']; + $bySubject = []; + foreach ($grades as $entry) { + $row = (array) $entry; + /** @var string $subject */ + $subject = $row['subject']; + $bySubject[$subject] = $row; + } + + $this->assertSame(100, $bySubject['math']['mean']); + $this->assertSame(100, $bySubject['science']['mean']); + $this->assertSame(0, $bySubject['history']['mean']); + } + + public function testFieldUpdateMultiply(): void + { + $this->trackMongoCollection('mg_products'); + $this->mongoClient?->dropCollection('mg_products'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_products') + ->set(['id' => 1, 'name' => 'Widget', 'price' => 10.0]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_products') + ->multiply('price', 1.5) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_products') + ->select(['id', 'price']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertEqualsWithDelta(15.0, $rows[0]['price'], 0.0001); + } + + public function testFieldUpdatePopFirst(): void + { + $this->trackMongoCollection('mg_pop'); + $this->mongoClient?->dropCollection('mg_pop'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_pop') + ->set(['id' => 1, 'tags' => ['a', 'b', 'c']]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pop') + ->popFirst('tags') + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pop') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $this->assertSame(['b', 'c'], \array_values($tags)); + } + + public function testFieldUpdatePopLast(): void + { + $this->trackMongoCollection('mg_pop'); + $this->mongoClient?->dropCollection('mg_pop'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_pop') + ->set(['id' => 2, 'tags' => ['a', 'b', 'c']]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pop') + ->popLast('tags') + ->filter([Query::equal('id', [2])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pop') + ->select(['id', 'tags']) + ->filter([Query::equal('id', [2])]) + ->build() + ); + + /** @var array $tags */ + $tags = (array) $rows[0]['tags']; + $this->assertSame(['a', 'b'], \array_values($tags)); + } + + public function testFieldUpdatePullAll(): void + { + $this->trackMongoCollection('mg_pull'); + $this->mongoClient?->dropCollection('mg_pull'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_pull') + ->set(['id' => 1, 'scores' => [10, 20, 30, 20, 40]]) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pull') + ->pullAll('scores', [20, 40]) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_pull') + ->select(['id', 'scores']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + /** @var array $scores */ + $scores = (array) $rows[0]['scores']; + $this->assertSame([10, 30], \array_values($scores)); + } + + public function testFieldUpdateMin(): void + { + $this->trackMongoCollection('mg_minmax'); + $this->mongoClient?->dropCollection('mg_minmax'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_minmax') + ->set(['id' => 1, 'low_score' => 50]) + ->insert() + ); + + // $min with 30 (smaller) should update to 30. + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->updateMin('low_score', 30) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + // $min with 80 (larger) should NOT update. + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->updateMin('low_score', 80) + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->select(['id', 'low_score']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(30, $rows[0]['low_score']); + } + + public function testFieldUpdateMax(): void + { + $this->trackMongoCollection('mg_minmax'); + $this->mongoClient?->dropCollection('mg_minmax'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_minmax') + ->set(['id' => 2, 'high_score' => 50]) + ->insert() + ); + + // $max with 80 (larger) should update to 80. + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->updateMax('high_score', 80) + ->filter([Query::equal('id', [2])]) + ->update() + ); + + // $max with 20 (smaller) should NOT update. + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->updateMax('high_score', 20) + ->filter([Query::equal('id', [2])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_minmax') + ->select(['id', 'high_score']) + ->filter([Query::equal('id', [2])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertSame(80, $rows[0]['high_score']); + } + + public function testFieldUpdateCurrentDate(): void + { + $this->trackMongoCollection('mg_dates'); + $this->mongoClient?->dropCollection('mg_dates'); + + $this->executeOnMongoDB( + (new Builder()) + ->into('mg_dates') + ->set(['id' => 1, 'name' => 'Alice']) + ->insert() + ); + + $this->executeOnMongoDB( + (new Builder()) + ->from('mg_dates') + ->currentDate('modified', 'date') + ->filter([Query::equal('id', [1])]) + ->update() + ); + + $rows = $this->executeOnMongoDB( + (new Builder()) + ->from('mg_dates') + ->select(['id', 'modified']) + ->filter([Query::equal('id', [1])]) + ->build() + ); + + $this->assertCount(1, $rows); + $this->assertArrayHasKey('modified', $rows[0]); + // Executed with MongoDB driver, $currentDate produces a BSON date/UTCDateTime. + $this->assertNotNull($rows[0]['modified']); + } + + public function testPipelineGraphLookup(): void + { + $this->trackMongoCollection('mg_employees'); + $this->mongoClient?->dropCollection('mg_employees'); + + // CEO (null manager) -> VP -> Dir -> Eng (self-referencing by manager field). + $this->mongoClient?->insertMany('mg_employees', [ + ['id' => 1, 'name' => 'CEO', 'manager' => null], + ['id' => 2, 'name' => 'VP', 'manager' => 1], + ['id' => 3, 'name' => 'Director', 'manager' => 2], + ['id' => 4, 'name' => 'Engineer', 'manager' => 3], + ]); + + $result = (new Builder()) + ->from('mg_employees') + ->graphLookup('mg_employees', 'manager', 'manager', 'id', 'reporting_chain') + ->filter([Query::equal('id', [4])]) + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(1, $rows); + /** @var list> $chain */ + $chain = (array) $rows[0]['reporting_chain']; + $names = []; + foreach ($chain as $entry) { + $row = (array) $entry; + /** @var string $name */ + $name = $row['name']; + $names[] = $name; + } + \sort($names); + + // Engineer's reporting chain traverses upward through Director, VP, CEO. + $this->assertSame(['CEO', 'Director', 'VP'], $names); + } + + public function testPipelineReplaceRoot(): void + { + $this->trackMongoCollection('mg_orders_nested'); + $this->mongoClient?->dropCollection('mg_orders_nested'); + + $this->mongoClient?->insertMany('mg_orders_nested', [ + ['id' => 1, 'customer' => ['name' => 'Alice', 'city' => 'NY']], + ['id' => 2, 'customer' => ['name' => 'Bob', 'city' => 'LA']], + ]); + + $result = (new Builder()) + ->from('mg_orders_nested') + ->replaceRoot('$customer') + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMongoDB($result); + + $this->assertCount(2, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('NY', $rows[0]['city']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('LA', $rows[1]['city']); + // Original top-level `id` is gone because root was replaced. + $this->assertArrayNotHasKey('id', $rows[0]); + } +} diff --git a/tests/Integration/Builder/MySQLIntegrationTest.php b/tests/Integration/Builder/MySQLIntegrationTest.php new file mode 100644 index 0000000..bf707e7 --- /dev/null +++ b/tests/Integration/Builder/MySQLIntegrationTest.php @@ -0,0 +1,918 @@ +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->assertSame('Charlie', $rows[0]['name']); + $this->assertSame('Alice', $rows[1]['name']); + $this->assertSame('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->assertSame('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->assertSame(0, (int) $rows[0]['active']); // @phpstan-ignore cast.int + } + + 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 CaseExpression()) + ->when('age', Operator::LessThan, 25, 'young') + ->whenRaw('`age` BETWEEN 25 AND 30', 'mid') + ->else('senior') + ->alias('age_group'); + + $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->assertSame('mid', $map['Alice']); + $this->assertSame('mid', $map['Bob']); + $this->assertSame('senior', $map['Charlie']); + $this->assertSame('mid', $map['Diana']); + $this->assertSame('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') + ->select('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') + ->select('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->assertSame(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->assertSame(['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->assertSame('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->assertSame('Alice', $rows[0]['name']); + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } + + public function testRecursiveCte(): void + { + $seed = (new Builder()) + ->from() + ->select('1 AS n'); + + $step = (new Builder()) + ->from('t') + ->select('n + 1') + ->filter([Query::lessThan('n', 5)]); + + $result = $this->fresh() + ->withRecursiveSeedStep('t', $seed, $step, ['n']) + ->from('t') + ->select(['n']) + ->sortAsc('n') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + $values = array_map(fn (array $row): int => (int) $row['n'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 2, 3, 4, 5], $values); + } + + private function createJsonDocsTable(): void + { + $this->trackMysqlTable('json_docs'); + $this->mysqlStatement('DROP TABLE IF EXISTS `json_docs`'); + $this->mysqlStatement(' + CREATE TABLE `json_docs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tags` JSON NOT NULL, + `metadata` JSON NOT NULL + ) ENGINE=InnoDB + '); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare('INSERT INTO `json_docs` (`tags`, `metadata`) VALUES (?, ?), (?, ?), (?, ?), (?, ?)'); + $stmt->execute([ + '["php", "mysql"]', '{"level": 3, "active": true}', + '["go", "mysql"]', '{"level": 7, "active": true}', + '["rust"]', '{"level": 10, "active": false}', + '["php", "rust"]', '{"level": 5, "active": true}', + ]); + } + + public function testJsonFilterContains(): void + { + $this->createJsonDocsTable(); + + $result = $this->fresh() + ->from('json_docs') + ->select(['id']) + ->filterJsonContains('tags', 'php') + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMysql($result); + + $ids = array_map(fn (array $row): int => (int) $row['id'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 4], $ids); + } + + public function testJsonFilterPath(): void + { + $this->createJsonDocsTable(); + + $result = $this->fresh() + ->from('json_docs') + ->select(['id']) + ->filterJsonPath('metadata', 'level', '>', 5) + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnMysql($result); + + $ids = array_map(fn (array $row): int => (int) $row['id'], $rows); // @phpstan-ignore cast.int + $this->assertSame([2, 3], $ids); + } + + public function testJsonSetAppend(): void + { + $this->createJsonDocsTable(); + + $update = $this->fresh() + ->from('json_docs') + ->setJsonAppend('tags', ['added']) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->executeOnMysql($update); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare('SELECT `tags` FROM `json_docs` WHERE `id` = 1'); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + /** @var string $tagsJson */ + $tagsJson = $row['tags']; + $tags = \json_decode($tagsJson, true); + $this->assertIsArray($tags); + $this->assertContains('added', $tags); + $this->assertContains('php', $tags); + } + + public function testJsonSetRemove(): void + { + $this->createJsonDocsTable(); + + $update = $this->fresh() + ->from('json_docs') + ->setJsonRemove('tags', 'mysql') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->executeOnMysql($update); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare('SELECT `tags` FROM `json_docs` WHERE `id` = 1'); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + /** @var string $tagsJson */ + $tagsJson = $row['tags']; + $tags = \json_decode($tagsJson, true); + $this->assertIsArray($tags); + $this->assertNotContains('mysql', $tags); + $this->assertContains('php', $tags); + } + + public function testJsonSetPath(): void + { + $this->createJsonDocsTable(); + + $update = $this->fresh() + ->from('json_docs') + ->setJsonPath('metadata', '$.level', 42) + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->executeOnMysql($update); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare('SELECT `metadata` FROM `json_docs` WHERE `id` = 1'); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + /** @var string $metadataJson */ + $metadataJson = $row['metadata']; + $metadata = \json_decode($metadataJson, true); + $this->assertIsArray($metadata); + $this->assertSame(42, $metadata['level']); + $this->assertTrue($metadata['active']); + } + + public function testHintUsesIndex(): void + { + $this->mysqlStatement('CREATE INDEX `idx_users_age` ON `users`(`age`)'); + + $result = $this->fresh() + ->from('users') + ->select(['name', 'age']) + ->hint('INDEX(`users` `idx_users_age`)') + ->filter([Query::greaterThan('age', 20)]) + ->sortAsc('age') + ->build(); + + $this->assertStringContainsString('/*+ INDEX(`users` `idx_users_age`) */', $result->query); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(5, $rows); + $this->assertSame('Eve', $rows[0]['name']); + } + + public function testLateralJoin(): void + { + $topOrder = (new Builder()) + ->from('orders') + ->select(['product', 'amount']) + ->whereColumn('user_id', '=', 'u.id') + ->sortDesc('amount') + ->limit(1); + + $result = $this->fresh() + ->from('users', 'u') + ->select(['u.name', 'top_order.product', 'top_order.amount']) + ->joinLateral($topOrder, 'top_order') + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $byName = []; + foreach ($rows as $row) { + /** @var string $name */ + $name = $row['name']; + $byName[$name] = $row; + } + + $this->assertCount(4, $rows); + $this->assertSame('Gadget', $byName['Alice']['product']); + $this->assertSame('49.99', (string) $byName['Alice']['amount']); // @phpstan-ignore cast.string + $this->assertSame('Widget', $byName['Bob']['product']); + $this->assertSame('Gizmo', $byName['Charlie']['product']); + $this->assertSame('Gadget', $byName['Diana']['product']); + } + + public function testGroupConcat(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['user_id']) + ->groupConcat('product', ',', 'products', ['product']) + ->groupBy(['user_id']) + ->sortAsc('user_id') + ->build(); + + $rows = $this->executeOnMysql($result); + + $map = []; + foreach ($rows as $row) { + /** @var int $userId */ + $userId = (int) $row['user_id']; // @phpstan-ignore cast.int + /** @var string $products */ + $products = (string) $row['products']; // @phpstan-ignore cast.string + $map[$userId] = $products; + } + + $this->assertSame('Gadget,Widget', $map[1]); + $this->assertSame('Widget', $map[2]); + $this->assertSame('Gizmo', $map[3]); + $this->assertSame('Gadget,Widget', $map[4]); + } + + public function testGroupByWithRollup(): void + { + $result = $this->fresh() + ->from('orders') + ->select(['status']) + ->count('*', 'total') + ->groupBy(['status']) + ->withRollup() + ->build(); + + $rows = $this->executeOnMysql($result); + + $statuses = array_column($rows, 'status'); + $this->assertContains(null, $statuses, 'Expected a NULL rollup row'); + + $grandTotal = 0; + foreach ($rows as $row) { + if ($row['status'] === null) { + $grandTotal = (int) $row['total']; // @phpstan-ignore cast.int + } + } + $this->assertSame(6, $grandTotal); + } + + public function testCountWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->countWhen('`status` = ?', 'completed_count', 'completed') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertSame(3, (int) $rows[0]['completed_count']); // @phpstan-ignore cast.int + } + + public function testSumWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->sumWhen('amount', '`status` = ?', 'completed_total', 'completed') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertSame('99.97', (string) $rows[0]['completed_total']); // @phpstan-ignore cast.string + } + + public function testAvgWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->avgWhen('amount', '`status` = ?', 'completed_avg', 'completed') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertEqualsWithDelta(33.32, (float) $rows[0]['completed_avg'], 0.01); // @phpstan-ignore cast.double + } + + public function testMaxWhen(): void + { + $result = $this->fresh() + ->from('orders') + ->maxWhen('amount', '`status` = ?', 'completed_max', 'completed') + ->build(); + + $rows = $this->executeOnMysql($result); + + $this->assertCount(1, $rows); + $this->assertSame('49.99', (string) $rows[0]['completed_max']); // @phpstan-ignore cast.string + } + + private function createPlacesTable(): void + { + $this->trackMysqlTable('places'); + $this->mysqlStatement('DROP TABLE IF EXISTS `places`'); + $this->mysqlStatement(' + CREATE TABLE `places` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL, + `location` POINT SRID 4326 NOT NULL, + SPATIAL INDEX `sp_location` (`location`) + ) ENGINE=InnoDB + '); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + 'INSERT INTO `places` (`name`, `location`) VALUES ' + . "(?, ST_GeomFromText(?, 4326, 'axis-order=long-lat')), " + . "(?, ST_GeomFromText(?, 4326, 'axis-order=long-lat')), " + . "(?, ST_GeomFromText(?, 4326, 'axis-order=long-lat')), " + . "(?, ST_GeomFromText(?, 4326, 'axis-order=long-lat'))" + ); + $stmt->execute([ + 'Inside1', 'POINT(0.5 0.5)', + 'Inside2', 'POINT(0.2 0.8)', + 'Outside1', 'POINT(5 5)', + 'Outside2', 'POINT(-1 -1)', + ]); + } + + public function testSpatialIntersects(): void + { + $this->createPlacesTable(); + + $polygon = [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]]; + + $result = $this->fresh() + ->from('places') + ->select(['name']) + ->filterIntersects('location', $polygon) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $names = array_column($rows, 'name'); + $this->assertSame(['Inside1', 'Inside2'], $names); + } + + public function testSpatialDistance(): void + { + $this->createPlacesTable(); + + $result = $this->fresh() + ->from('places') + ->select(['name']) + ->filterDistance('location', [0.0, 0.0], '<', 2.0) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnMysql($result); + + $names = array_column($rows, 'name'); + $this->assertContains('Inside1', $names); + $this->assertContains('Inside2', $names); + $this->assertContains('Outside2', $names); + $this->assertNotContains('Outside1', $names); + } + + public function testFullTextSearch(): void + { + $this->trackMysqlTable('articles'); + $this->mysqlStatement('DROP TABLE IF EXISTS `articles`'); + $this->mysqlStatement(' + CREATE TABLE `articles` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(200) NOT NULL, + `body` TEXT NOT NULL, + FULLTEXT KEY `ft_body` (`body`) + ) ENGINE=InnoDB + '); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare('INSERT INTO `articles` (`title`, `body`) VALUES (?, ?), (?, ?), (?, ?)'); + $stmt->execute([ + 'Gardening', 'Statementting tomatoes in the garden is rewarding', + 'Cooking', 'A great recipe uses fresh tomatoes and basil', + 'Tech', 'Database internals and query optimization', + ]); + + $result = $this->fresh() + ->from('articles') + ->select(['title']) + ->filterSearch('body', 'tomatoes') + ->sortAsc('title') + ->build(); + + $rows = $this->executeOnMysql($result); + + $titles = array_column($rows, 'title'); + $this->assertSame(['Cooking', 'Gardening'], $titles); + } +} diff --git a/tests/Integration/Builder/PostgreSQLIntegrationTest.php b/tests/Integration/Builder/PostgreSQLIntegrationTest.php new file mode 100644 index 0000000..ec2eccf --- /dev/null +++ b/tests/Integration/Builder/PostgreSQLIntegrationTest.php @@ -0,0 +1,1005 @@ +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') + "); + + $this->setUpEmbeddings(); + } + + private function setUpEmbeddings(): void + { + try { + $this->postgresStatement('CREATE EXTENSION IF NOT EXISTS vector'); + } catch (PDOException $e) { + $this->markTestSkipped('pgvector extension is not available: ' . $e->getMessage()); + } + + $this->trackPostgresTable('embeddings'); + $this->postgresStatement('DROP TABLE IF EXISTS "embeddings" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "embeddings" ( + "id" SERIAL PRIMARY KEY, + "label" TEXT NOT NULL, + "vec" vector(3) NOT NULL + ) + '); + $this->postgresStatement(" + INSERT INTO \"embeddings\" (\"label\", \"vec\") VALUES + ('mixed', '[0.5,0.5,0.01]'), + ('x-axis', '[1,0.01,0.01]'), + ('y-axis', '[0.01,1,0.01]'), + ('z-axis', '[0.01,0.01,1]'), + ('far', '[10,10,10]') + "); + } + + 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->assertSame('Alice', $rows[0]['name']); + $this->assertSame('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->assertSame('Bob', $rows[0]['name']); + $this->assertSame('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->assertSame('Alice', $rows[0]['name']); + $this->assertSame('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->assertSame('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->assertSame('Frank', $rows[0]['name']); + $this->assertSame('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->assertSame('Grace', $rows[0]['name']); + $this->assertSame('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->assertSame('Ivy', $rows[0]['name']); + $this->assertSame('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->assertSame('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->assertSame('Alice', $rows[0]['name']); + $this->assertSame(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + } + + public function testDeleteWithWhere(): void + { + $this->postgresStatement("DELETE FROM \"orders\" WHERE \"user_id\" = 5"); + + $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 + { + $this->postgresStatement("DELETE FROM \"orders\" WHERE \"user_id\" = 3"); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Charlie'])]) + ->returning(['id', 'name']) + ->delete(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('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->assertSame(1, (int) $rows[0]['user_id']); // @phpstan-ignore cast.int + $this->assertSame(2, (int) $rows[0]['order_count']); // @phpstan-ignore cast.int + $this->assertSame(4, (int) $rows[1]['user_id']); // @phpstan-ignore cast.int + $this->assertSame(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->assertSame(['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->assertSame('Alice Updated', $rows[0]['name']); + $this->assertSame(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + $this->assertSame('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->assertSame('Alice', $rows[0]['name']); + $this->assertSame(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->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Diana', $rows[2]['name']); + $this->assertSame('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->assertSame(1, (int) $user1Rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertSame(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->assertSame(['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->assertSame(['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->assertSame('Alice', $rows[0]['name']); + } finally { + $pdo->rollBack(); + } + } + + public function testVectorSearchL2Distance(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['label']) + ->orderByVectorDistance('vec', [0.95, 0.02, 0.02], VectorMetric::Euclidean) + ->limit(1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('x-axis', $rows[0]['label']); + } + + public function testVectorSearchCosineDistance(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['label']) + ->orderByVectorDistance('vec', [1.0, 0.5, 0.01], VectorMetric::Cosine) + ->limit(2) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(2, $rows); + $this->assertSame('mixed', $rows[0]['label']); + $this->assertSame('x-axis', $rows[1]['label']); + } + + public function testVectorSearchInnerProduct(): void + { + $result = (new Builder()) + ->from('embeddings') + ->select(['label']) + ->orderByVectorDistance('vec', [1.0, 1.0, 1.0], VectorMetric::Dot) + ->limit(1) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('far', $rows[0]['label']); + } + + public function testMergeUpdateExistingAndInsertNew(): void + { + $this->trackPostgresTable('user_updates'); + $this->postgresStatement('DROP TABLE IF EXISTS "user_updates" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "user_updates" ( + "id" INT PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "city" VARCHAR(100) NOT NULL + ) + '); + $this->postgresStatement(" + INSERT INTO \"user_updates\" (\"id\", \"name\", \"city\") VALUES + (1, 'Alice', 'Seattle'), + (2, 'Bob', 'Dublin'), + (99, 'Zack', 'Oslo') + "); + + $source = (new Builder()) + ->from('user_updates') + ->select(['id', 'name', 'city']); + + $merge = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('"users"."id" = "src"."id"') + ->whenMatched('UPDATE SET "city" = "src"."city"') + ->whenNotMatched('INSERT ("name", "email", "age", "city", "active") VALUES ("src"."name", "src"."name" || \'+merged@example.com\', 0, "src"."city", TRUE)') + ->executeMerge(); + + $this->executeOnPostgres($merge); + + $aliceCheck = (new Builder()) + ->from('users') + ->select(['city']) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + $aliceRows = $this->executeOnPostgres($aliceCheck); + $this->assertCount(1, $aliceRows); + $this->assertSame('Seattle', $aliceRows[0]['city']); + + $bobCheck = (new Builder()) + ->from('users') + ->select(['city']) + ->filter([Query::equal('name', ['Bob'])]) + ->build(); + $bobRows = $this->executeOnPostgres($bobCheck); + $this->assertCount(1, $bobRows); + $this->assertSame('Dublin', $bobRows[0]['city']); + + $zackCheck = (new Builder()) + ->from('users') + ->select(['name', 'city']) + ->filter([Query::equal('name', ['Zack'])]) + ->build(); + $zackRows = $this->executeOnPostgres($zackCheck); + $this->assertCount(1, $zackRows); + $this->assertSame('Zack', $zackRows[0]['name']); + $this->assertSame('Oslo', $zackRows[0]['city']); + } + + public function testAggregateFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('SUM("amount")', '"status" = ?', 'completed_total', ['completed']) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('279.96', (string) $rows[0]['completed_total']); // @phpstan-ignore cast.string + } + + public function testDistinctOn(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id', 'product', 'amount']) + ->distinctOn(['user_id']) + ->sortAsc('user_id') + ->sortDesc('amount') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(5, $rows); + $this->assertSame(1, (int) $rows[0]['user_id']); // @phpstan-ignore cast.int + $this->assertSame('Gadget', $rows[0]['product']); + $this->assertSame(2, (int) $rows[1]['user_id']); // @phpstan-ignore cast.int + $this->assertSame('Widget', $rows[1]['product']); + $this->assertSame(3, (int) $rows[2]['user_id']); // @phpstan-ignore cast.int + $this->assertSame('Gizmo', $rows[2]['product']); + $this->assertSame(4, (int) $rows[3]['user_id']); // @phpstan-ignore cast.int + $this->assertSame('Gadget', $rows[3]['product']); + $this->assertSame(5, (int) $rows[4]['user_id']); // @phpstan-ignore cast.int + $this->assertSame('Gizmo', $rows[4]['product']); + } + + public function testPercentileDisc(): void + { + $result = (new Builder()) + ->from('users') + ->percentileDisc(0.5, 'age', 'median_age') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame(28, (int) $rows[0]['median_age']); // @phpstan-ignore cast.int + } + + public function testModeAggregate(): void + { + $result = (new Builder()) + ->from('users') + ->mode('city', 'top_city') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('London', $rows[0]['top_city']); + } + + public function testLateralJoin(): void + { + $topOrder = (new Builder()) + ->from('orders') + ->select(['product', 'amount']) + ->whereColumn('user_id', '=', 'u.id') + ->sortDesc('amount') + ->limit(1); + + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'top_order.product', 'top_order.amount']) + ->joinLateral($topOrder, 'top_order') + ->sortAsc('u.name') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(5, $rows); + $byName = []; + foreach ($rows as $row) { + /** @var string $name */ + $name = $row['name']; + $byName[$name] = $row; + } + + $this->assertSame('Gadget', $byName['Alice']['product']); + $this->assertSame('49.99', (string) $byName['Alice']['amount']); // @phpstan-ignore cast.string + $this->assertSame('Widget', $byName['Bob']['product']); + $this->assertSame('Gizmo', $byName['Charlie']['product']); + $this->assertSame('Gadget', $byName['Diana']['product']); + $this->assertSame('Gizmo', $byName['Eve']['product']); + } + + public function testRecursiveCte(): void + { + $seed = (new Builder()) + ->fromNone() + ->selectRaw('1'); + + $step = (new Builder()) + ->from('t') + ->selectRaw('"n" + 1') + ->filter([Query::lessThan('n', 5)]); + + $result = (new Builder()) + ->withRecursiveSeedStep('t', $seed, $step, ['n']) + ->from('t') + ->select(['n']) + ->sortAsc('n') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $values = array_map(static fn (array $row): int => (int) $row['n'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 2, 3, 4, 5], $values); + } + + public function testJsonbFilterContains(): void + { + $this->trackPostgresTable('profiles'); + $this->postgresStatement('DROP TABLE IF EXISTS "profiles" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "profiles" ( + "id" SERIAL PRIMARY KEY, + "data" JSONB NOT NULL + ) + '); + $this->postgresStatement(' + INSERT INTO "profiles" ("data") VALUES + (\'{"role":"admin","tags":["php","go"]}\'::jsonb), + (\'{"role":"user","tags":["php"]}\'::jsonb), + (\'{"role":"user","tags":["rust"]}\'::jsonb) + '); + + $result = (new Builder()) + ->from('profiles') + ->select(['id']) + ->filterJsonContains('data', ['role' => 'admin']) + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame(1, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testJsonbFilterPath(): void + { + $this->trackPostgresTable('profiles'); + $this->postgresStatement('DROP TABLE IF EXISTS "profiles" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "profiles" ( + "id" SERIAL PRIMARY KEY, + "data" JSONB NOT NULL + ) + '); + $this->postgresStatement(' + INSERT INTO "profiles" ("data") VALUES + (\'{"role":"admin"}\'::jsonb), + (\'{"role":"user"}\'::jsonb), + (\'{"role":"user"}\'::jsonb) + '); + + $result = (new Builder()) + ->from('profiles') + ->select(['id']) + ->filterJsonPath('data', 'role', '=', 'admin') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame(1, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testJsonbSetPath(): void + { + $this->trackPostgresTable('profiles'); + $this->postgresStatement('DROP TABLE IF EXISTS "profiles" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "profiles" ( + "id" SERIAL PRIMARY KEY, + "data" JSONB NOT NULL + ) + '); + $this->postgresStatement(' + INSERT INTO "profiles" ("id", "data") VALUES + (1, \'{"name":"OldValue","level":1}\'::jsonb) + '); + + $result = (new Builder()) + ->from('profiles') + ->setJsonPath('data', '$.name', 'NewValue') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->executeOnPostgres($result); + + $check = (new Builder()) + ->from('profiles') + ->select(['id']) + ->filterJsonPath('data', 'name', '=', 'NewValue') + ->build(); + + $rows = $this->executeOnPostgres($check); + + $this->assertCount(1, $rows); + $this->assertSame(1, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testFullOuterJoin(): void + { + $this->trackPostgresTable('left_side'); + $this->trackPostgresTable('right_side'); + $this->postgresStatement('DROP TABLE IF EXISTS "left_side" CASCADE'); + $this->postgresStatement('DROP TABLE IF EXISTS "right_side" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "left_side" ( + "id" INT PRIMARY KEY, + "label" TEXT NOT NULL + ) + '); + $this->postgresStatement(' + CREATE TABLE "right_side" ( + "id" INT PRIMARY KEY, + "label" TEXT NOT NULL + ) + '); + $this->postgresStatement(" + INSERT INTO \"left_side\" (\"id\", \"label\") VALUES (1, 'a'), (2, 'b') + "); + $this->postgresStatement(" + INSERT INTO \"right_side\" (\"id\", \"label\") VALUES (2, 'b'), (3, 'c') + "); + + $result = (new Builder()) + ->from('left_side', 'l') + ->selectRaw('"l"."id" AS "left_id"') + ->selectRaw('"r"."id" AS "right_id"') + ->fullOuterJoin('right_side', 'l.id', 'r.id', '=', 'r') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(3, $rows); + + $leftIds = array_map( + static fn (array $r): ?int => $r['left_id'] === null ? null : (int) $r['left_id'], // @phpstan-ignore cast.int + $rows, + ); + $rightIds = array_map( + static fn (array $r): ?int => $r['right_id'] === null ? null : (int) $r['right_id'], // @phpstan-ignore cast.int + $rows, + ); + + $this->assertContains(1, $leftIds); + $this->assertContains(2, $leftIds); + $this->assertContains(null, $leftIds); + + $this->assertContains(2, $rightIds); + $this->assertContains(3, $rightIds); + $this->assertContains(null, $rightIds); + } + + public function testTableSampleBernoulli(): void + { + $this->trackPostgresTable('samples'); + $this->postgresStatement('DROP TABLE IF EXISTS "samples" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "samples" ( + "id" SERIAL PRIMARY KEY, + "value" INT NOT NULL + ) + '); + $this->postgresStatement(' + INSERT INTO "samples" ("value") + SELECT generate_series(1, 100) + '); + + $result = (new Builder()) + ->from('samples') + ->select(['id']) + ->tablesample(10, 'BERNOULLI') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $count = count($rows); + $this->assertGreaterThanOrEqual(1, $count); + $this->assertLessThanOrEqual(30, $count); + } + + public function testFullTextSearch(): void + { + $this->trackPostgresTable('docs'); + $this->postgresStatement('DROP TABLE IF EXISTS "docs" CASCADE'); + $this->postgresStatement(' + CREATE TABLE "docs" ( + "id" SERIAL PRIMARY KEY, + "body" TEXT NOT NULL + ) + '); + $this->postgresStatement(" + CREATE INDEX \"docs_body_fts_idx\" ON \"docs\" + USING GIN (to_tsvector('english', \"body\")) + "); + $this->postgresStatement(" + INSERT INTO \"docs\" (\"body\") VALUES + ('The quick brown fox jumps over the lazy dog'), + ('PostgreSQL full text search is powerful'), + ('Completely unrelated content about cooking') + "); + + $result = (new Builder()) + ->from('docs') + ->select(['id']) + ->filterSearch('body', 'postgresql search') + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame(2, (int) $rows[0]['id']); // @phpstan-ignore cast.int + } + + public function testForUpdateOfSpecificTable(): void + { + $pdo = $this->connectPostgres(); + $pdo->beginTransaction(); + + try { + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('city', ['New York'])]) + ->forUpdateOf('users') + ->sortAsc('id') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertStringContainsString('FOR UPDATE OF "users"', $result->query); + $this->assertCount(2, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Charlie', $rows[1]['name']); + } finally { + $pdo->rollBack(); + } + } + + public function testCountWhenCompleted(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('"status" = ?', 'completed_count', 'completed') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame(4, (int) $rows[0]['completed_count']); // @phpstan-ignore cast.int + } + + public function testSumWhenCompleted(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', '"status" = ?', 'completed_total', 'completed') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertCount(1, $rows); + $this->assertSame('279.96', (string) $rows[0]['completed_total']); // @phpstan-ignore cast.string + } + + public function testGroupByRollup(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['status']) + ->count('*', 'total') + ->groupBy(['status']) + ->withRollup() + ->sortAsc('status') + ->build(); + + $rows = $this->executeOnPostgres($result); + + $this->assertStringContainsString('GROUP BY ROLLUP', $result->query); + $this->assertCount(4, $rows); + + $grandTotal = null; + foreach ($rows as $row) { + if ($row['status'] === null) { + $grandTotal = (int) $row['total']; // @phpstan-ignore cast.int + } + } + $this->assertSame(7, $grandTotal); + } +} diff --git a/tests/Integration/Builder/SQLiteIntegrationTest.php b/tests/Integration/Builder/SQLiteIntegrationTest.php new file mode 100644 index 0000000..250dafc --- /dev/null +++ b/tests/Integration/Builder/SQLiteIntegrationTest.php @@ -0,0 +1,371 @@ +sqliteStatement(' + CREATE TABLE `users` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `name` VARCHAR(255) NOT NULL, + `email` VARCHAR(255) NOT NULL UNIQUE, + `age` INTEGER NOT NULL DEFAULT 0, + `city` VARCHAR(100) DEFAULT NULL, + `active` INTEGER NOT NULL DEFAULT 1, + `created_at` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + '); + + $this->sqliteStatement(' + CREATE TABLE `orders` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `user_id` INTEGER NOT NULL REFERENCES `users`(`id`), + `product` VARCHAR(255) NOT NULL, + `amount` REAL NOT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT \'pending\', + `created_at` TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + '); + + $this->sqliteStatement(" + INSERT INTO `users` (`name`, `email`, `age`, `city`, `active`) VALUES + ('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) + "); + + $this->sqliteStatement(" + 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->executeOnSqlite($result); + + $this->assertCount(2, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Charlie', $rows[1]['name']); + } + + public function testSelectWithOrderByLimitOffset(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->sortAsc('name') + ->limit(2) + ->offset(1) + ->build(); + + $rows = $this->executeOnSqlite($result); + + $this->assertCount(2, $rows); + $this->assertSame('Bob', $rows[0]['name']); + $this->assertSame('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->executeOnSqlite($result); + + $this->assertCount(4, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('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->executeOnSqlite($result); + + $this->assertCount(1, $rows); + $this->assertSame('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->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'city']) + ->filter([Query::equal('email', ['frank@example.com'])]) + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(1, $rows); + $this->assertSame('Frank', $rows[0]['name']); + $this->assertSame('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->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('city', ['Tokyo'])]) + ->sortAsc('name') + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(2, $rows); + $this->assertSame('Grace', $rows[0]['name']); + $this->assertSame('Hank', $rows[1]['name']); + } + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['city' => 'San Francisco']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + + $this->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->select(['city']) + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(1, $rows); + $this->assertSame('San Francisco', $rows[0]['city']); + } + + public function testDeleteWithWhere(): void + { + $this->sqliteStatement('DELETE FROM `orders` WHERE `user_id` = 5'); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->delete(); + + $this->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Eve'])]) + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(0, $rows); + } + + 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->executeOnSqlite($result); + + $this->assertCount(2, $rows); + $this->assertSame(1, (int) $rows[0]['user_id']); // @phpstan-ignore cast.int + $this->assertSame(2, (int) $rows[0]['order_count']); // @phpstan-ignore cast.int + $this->assertSame(4, (int) $rows[1]['user_id']); // @phpstan-ignore cast.int + $this->assertSame(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(); + + $this->assertStringNotContainsString('(SELECT', $result->query); + + $rows = $this->executeOnSqlite($result); + + $names = array_column($rows, 'name'); + sort($names); + + $this->assertCount(4, $rows); + $this->assertSame(['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->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age', 'city']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(1, $rows); + $this->assertSame('Alice Updated', $rows[0]['name']); + $this->assertSame(31, (int) $rows[0]['age']); // @phpstan-ignore cast.int + $this->assertSame('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->executeOnSqlite($result); + + $check = (new Builder()) + ->from('users') + ->select(['name', 'age']) + ->filter([Query::equal('email', ['alice@example.com'])]) + ->build(); + + $rows = $this->executeOnSqlite($check); + + $this->assertCount(1, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame(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->executeOnSqlite($result); + + $this->assertCount(4, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Diana', $rows[2]['name']); + $this->assertSame('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->executeOnSqlite($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->assertSame(1, (int) $user1Rows[0]['rn']); // @phpstan-ignore cast.int + $this->assertSame(2, (int) $user1Rows[1]['rn']); // @phpstan-ignore cast.int + } + + public function testRecursiveCte(): void + { + $seed = (new Builder()) + ->fromNone() + ->selectRaw('1'); + + $step = (new Builder()) + ->from('t') + ->selectRaw('`n` + 1') + ->filter([Query::lessThan('n', 5)]); + + $result = (new Builder()) + ->withRecursiveSeedStep('t', $seed, $step, ['n']) + ->from('t') + ->select(['n']) + ->sortAsc('n') + ->build(); + + $rows = $this->executeOnSqlite($result); + + $values = array_map(static fn (array $row): int => (int) $row['n'], $rows); // @phpstan-ignore cast.int + $this->assertSame([1, 2, 3, 4, 5], $values); + } +} diff --git a/tests/Integration/ClickHouseClient.php b/tests/Integration/ClickHouseClient.php new file mode 100644 index 0000000..c25aebe --- /dev/null +++ b/tests/Integration/ClickHouseClient.php @@ -0,0 +1,152 @@ + $params + * @return list> + */ + public function execute(string $query, array $params = []): array + { + $url = $this->host . '/?database=' . urlencode($this->database); + + $placeholderIndex = 0; + $isInsert = (bool) preg_match('/^\s*INSERT\b/i', $query); + + $placeholderCount = substr_count($query, '?'); + $paramCount = count($params); + + if ($placeholderCount > $paramCount) { + throw new InvalidArgumentException(sprintf( + 'Query has %d placeholder(s) but only %d param(s) were provided.', + $placeholderCount, + $paramCount + )); + } + + $sql = preg_replace_callback('/\?/', function () use (&$placeholderIndex, $params, &$url) { + $key = 'param_p' . $placeholderIndex; + $value = $params[$placeholderIndex]; + $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) ?? $query; + + if ($placeholderIndex < $paramCount) { + throw new InvalidArgumentException(sprintf( + 'Query consumed %d placeholder(s) but %d param(s) were provided.', + $placeholderIndex, + $paramCount + )); + } + + $hasFormatClause = (bool) preg_match('/\bFORMAT\s+\w+\s*;?\s*$/i', $sql); + $sqlWithFormat = $hasFormatClause ? $sql : $sql . ' FORMAT JSONEachRow'; + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: text/plain\r\n", + 'content' => $isInsert ? $sql : $sqlWithFormat, + '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] ?? ''; + $statusCode = $this->parseStatusCode($statusLine); + if ($statusCode === null || $statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException(sprintf( + 'ClickHouse error (HTTP %s): %s', + $statusCode !== null ? (string) $statusCode : 'unknown', + $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] ?? ''; + $statusCode = $this->parseStatusCode($statusLine); + if ($statusCode === null || $statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException(sprintf( + 'ClickHouse error (HTTP %s): %s', + $statusCode !== null ? (string) $statusCode : 'unknown', + $response + )); + } + } + + /** + * Parses the numeric HTTP status code from a status line like "HTTP/1.1 200 OK". + * Returns null when the line does not match the expected format. + */ + private function parseStatusCode(string $statusLine): ?int + { + if (preg_match('#^HTTP/\S+\s+(\d{3})#', $statusLine, $matches)) { + return (int) $matches[1]; + } + + return null; + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..2127cf1 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,342 @@ + */ + private array $mysqlCleanup = []; + + /** @var list */ + private array $mariadbCleanup = []; + + /** @var list */ + private array $postgresCleanup = []; + + /** @var list */ + private array $clickhouseCleanup = []; + + /** @var list */ + private array $mongoCleanup = []; + + 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 connectMariadb(): PDO + { + if ($this->mariadb === null) { + $this->mariadb = new PDO( + 'mysql:host=127.0.0.1;port=13307;dbname=query_test', + 'root', + 'test', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + return $this->mariadb; + } + + 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; + } + + protected function connectMongoDB(): MongoDBClient + { + if ($this->mongoClient === null) { + $this->mongoClient = new MongoDBClient(); + } + + return $this->mongoClient; + } + + protected function connectSqlite(): PDO + { + if ($this->sqlite === null) { + $this->sqlite = new PDO('sqlite::memory:', null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $this->sqlite->exec('PRAGMA foreign_keys = ON'); + } + + return $this->sqlite; + } + + protected function sqliteStatement(string $sql): void + { + $this->connectSqlite()->prepare($sql)->execute(); + } + + /** + * @return list> + */ + protected function executeOnSqlite(Statement $result): array + { + $pdo = $this->connectSqlite(); + $stmt = $pdo->prepare($result->query); + + foreach ($result->bindings as $i => $value) { + $type = match (true) { + is_bool($value) => PDO::PARAM_BOOL, + is_int($value) => PDO::PARAM_INT, + $value === null => PDO::PARAM_NULL, + default => PDO::PARAM_STR, + }; + $stmt->bindValue($i + 1, $value, $type); + } + $stmt->execute(); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnMongoDB(Statement $result): array + { + $mongo = $this->connectMongoDB(); + + return $mongo->execute($result->query, $result->bindings); + } + + protected function trackMongoCollection(string $collection): void + { + $this->mongoCleanup[] = $collection; + } + + /** + * @return list> + */ + protected function executeOnMysql(Statement $result): array + { + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare($result->query); + + foreach ($result->bindings as $i => $value) { + $type = match (true) { + is_bool($value) => PDO::PARAM_BOOL, + is_int($value) => PDO::PARAM_INT, + $value === null => PDO::PARAM_NULL, + default => PDO::PARAM_STR, + }; + $stmt->bindValue($i + 1, $value, $type); + } + $stmt->execute(); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnMariadb(Statement $result): array + { + $pdo = $this->connectMariadb(); + $stmt = $pdo->prepare($result->query); + + foreach ($result->bindings as $i => $value) { + $type = match (true) { + is_bool($value) => PDO::PARAM_BOOL, + is_int($value) => PDO::PARAM_INT, + $value === null => PDO::PARAM_NULL, + default => PDO::PARAM_STR, + }; + $stmt->bindValue($i + 1, $value, $type); + } + $stmt->execute(); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnPostgres(Statement $result): array + { + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare($result->query); + + foreach ($result->bindings as $i => $value) { + $type = match (true) { + is_bool($value) => PDO::PARAM_BOOL, + is_int($value) => PDO::PARAM_INT, + $value === null => PDO::PARAM_NULL, + default => PDO::PARAM_STR, + }; + $stmt->bindValue($i + 1, $value, $type); + } + $stmt->execute(); + + /** @var list> */ + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * @return list> + */ + protected function executeOnClickhouse(Statement $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 mariadbStatement(string $sql): void + { + $this->connectMariadb()->prepare($sql)->execute(); + } + + protected function postgresStatement(string $sql): void + { + $this->connectPostgres()->prepare($sql)->execute(); + } + + protected function clickhouseStatement(string $sql): void + { + $this->connectClickhouse()->statement($sql); + } + + /** + * Returns a table/collection name suffixed with the paratest worker token, + * when present. This defuses the race where parallel workers share the same + * MySQL/MariaDB/PostgreSQL/ClickHouse/MongoDB containers and would otherwise + * clobber each other's `users`/`orders`/etc tables in setUp. + * + * Today `composer test:integration` runs phpunit (not paratest), so + * `TEST_TOKEN` is unset and this is a no-op that returns `$base` unchanged. + * If integration tests are ever parallelised, every test that creates or + * references a shared-container table should route its physical table name + * through this helper to keep workers isolated. + * + * Usage: + * + * $users = $this->tableName('users'); + * $this->mysqlStatement("CREATE TABLE `{$users}` (...)"); + * $this->trackMysqlTable($users); + * ...->from($users)->...->build(); + */ + protected function tableName(string $base): string + { + $token = getenv('TEST_TOKEN'); + if ($token === false || $token === '') { + return $base; + } + + return $base . '_' . $token; + } + + protected function trackMysqlTable(string $table): void + { + $this->mysqlCleanup[] = $table; + } + + protected function trackMariadbTable(string $table): void + { + $this->mariadbCleanup[] = $table; + } + + protected function trackPostgresTable(string $table): void + { + $this->postgresCleanup[] = $table; + } + + protected function trackClickhouseTable(string $table): void + { + $this->clickhouseCleanup[] = $table; + } + + protected function tearDown(): void + { + $this->mysql?->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ($this->mysqlCleanup as $table) { + $stmt = $this->mysql?->prepare("DROP TABLE IF EXISTS `{$table}`"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + $this->mysql?->exec('SET FOREIGN_KEY_CHECKS = 1'); + + $this->mariadb?->exec('SET FOREIGN_KEY_CHECKS = 0'); + foreach ($this->mariadbCleanup as $table) { + $stmt = $this->mariadb?->prepare("DROP TABLE IF EXISTS `{$table}`"); + if ($stmt !== null && $stmt !== false) { + $stmt->execute(); + } + } + $this->mariadb?->exec('SET FOREIGN_KEY_CHECKS = 1'); + + 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}`"); + } + + foreach ($this->mongoCleanup as $collection) { + $this->mongoClient?->dropCollection($collection); + } + + $this->mysqlCleanup = []; + $this->mariadbCleanup = []; + $this->postgresCleanup = []; + $this->clickhouseCleanup = []; + $this->mongoCleanup = []; + } +} diff --git a/tests/Integration/MongoDBClient.php b/tests/Integration/MongoDBClient.php new file mode 100644 index 0000000..ec0cc48 --- /dev/null +++ b/tests/Integration/MongoDBClient.php @@ -0,0 +1,348 @@ +database = $client->selectDatabase($database); + } + + /** + * @param list $bindings + * @return list> + */ + public function execute(string $queryJson, array $bindings = []): array + { + $decoded = \json_decode($queryJson, false, 512, JSON_THROW_ON_ERROR); + /** @var array $op */ + $op = $this->objectToArray($decoded); + + $op = $this->replaceBindings($op, $bindings); + + /** @var string $collectionName */ + $collectionName = $op['collection']; + $collection = $this->database->selectCollection($collectionName); + + /** @var string $operation */ + $operation = $op['operation'] ?? 'null'; + + return match ($operation) { + 'find' => $this->executeFind($collection, $op), + 'aggregate' => $this->executeAggregate($collection, $op), + 'insertMany' => $this->executeInsertMany($collection, $op), + 'updateMany' => $this->executeUpdateMany($collection, $op), + 'updateOne' => $this->executeUpdateOne($collection, $op), + 'deleteMany' => $this->executeDeleteMany($collection, $op), + default => throw new \RuntimeException('Unknown MongoDB operation: ' . $operation), + }; + } + + public function command(string $commandJson): void + { + /** @var array $op */ + $op = \json_decode($commandJson, true, 512, JSON_THROW_ON_ERROR); + /** @var string $command */ + $command = $op['command'] ?? ''; + + /** @var string $collectionName */ + $collectionName = $op['collection'] ?? ''; + /** @var string $indexName */ + $indexName = $op['index'] ?? ''; + /** @var array $filter */ + $filter = $op['filter'] ?? []; + /** @var array $options */ + $options = $op['options'] ?? []; + + if (isset($op['validator']) && ! isset($options['validator'])) { + $options['validator'] = $op['validator']; + } + + match ($command) { + 'createCollection' => $this->database->createCollection($collectionName, $options), + 'drop' => $this->dropCollection($collectionName), + 'createIndex' => $this->createIndex($op), + 'dropIndex' => $this->database->selectCollection($collectionName)->dropIndex($indexName), + 'deleteMany' => $this->database->selectCollection($collectionName)->deleteMany($filter), + default => throw new \RuntimeException('Unknown MongoDB command: ' . $command), + }; + } + + public function dropCollection(string $name): void + { + $this->database->dropCollection($name); + } + + /** + * @return list + */ + public function listCollectionNames(): array + { + $names = []; + foreach ($this->database->listCollectionNames() as $name) { + $names[] = $name; + } + + return $names; + } + + /** + * @return list + */ + public function listIndexNames(string $collection): array + { + $names = []; + foreach ($this->database->selectCollection($collection)->listIndexes() as $index) { + $names[] = $index->getName(); + } + + return $names; + } + + /** + * @return array + */ + public function getIndexKey(string $collection, string $name): array + { + foreach ($this->database->selectCollection($collection)->listIndexes() as $index) { + if ($index->getName() === $name) { + /** @var array $key */ + $key = $index->getKey(); + + return $key; + } + } + + return []; + } + + /** + * @param array $document + */ + public function insertOne(string $collection, array $document): void + { + $this->database->selectCollection($collection)->insertOne($document); + } + + /** + * @param list> $documents + */ + public function insertMany(string $collection, array $documents): void + { + $this->database->selectCollection($collection)->insertMany($documents); + } + + /** + * @param array $op + * @return list> + */ + private function executeFind(Collection $collection, array $op): array + { + /** @var array $filter */ + $filter = $op['filter'] ?? []; + $options = []; + + if (isset($op['projection'])) { + $options['projection'] = $op['projection']; + } + if (isset($op['sort'])) { + $options['sort'] = $op['sort']; + } + if (isset($op['skip'])) { + $options['skip'] = $op['skip']; + } + if (isset($op['limit'])) { + $options['limit'] = $op['limit']; + } + + $cursor = $collection->find($filter, $options); + $rows = []; + foreach ($cursor as $doc) { + /** @var array $arr */ + $arr = (array) $doc; + unset($arr['_id']); + $rows[] = $arr; + } + + return $rows; + } + + /** + * @param array $op + * @return list> + */ + private function executeAggregate(Collection $collection, array $op): array + { + /** @var list> $pipeline */ + $pipeline = $op['pipeline'] ?? []; + $cursor = $collection->aggregate($pipeline); + $rows = []; + foreach ($cursor as $doc) { + /** @var array $arr */ + $arr = (array) $doc; + unset($arr['_id']); + $rows[] = $arr; + } + + return $rows; + } + + /** + * @param array $op + * @return list> + */ + private function executeInsertMany(Collection $collection, array $op): array + { + /** @var list> $documents */ + $documents = $op['documents'] ?? []; + /** @var array $options */ + $options = $op['options'] ?? []; + $collection->insertMany($documents, $options); + + return []; + } + + /** + * @param array $op + * @return list> + */ + private function executeUpdateMany(Collection $collection, array $op): array + { + /** @var array $filter */ + $filter = $op['filter'] ?? []; + /** @var array $update */ + $update = $op['update'] ?? []; + /** @var array $options */ + $options = $op['options'] ?? []; + $collection->updateMany($filter, $update, $options); + + return []; + } + + /** + * @param array $op + * @return list> + */ + private function executeUpdateOne(Collection $collection, array $op): array + { + /** @var array $filter */ + $filter = $op['filter'] ?? []; + /** @var array $update */ + $update = $op['update'] ?? []; + /** @var array $options */ + $options = $op['options'] ?? []; + $collection->updateOne($filter, $update, $options); + + return []; + } + + /** + * @param array $op + * @return list> + */ + private function executeDeleteMany(Collection $collection, array $op): array + { + /** @var array $filter */ + $filter = $op['filter'] ?? []; + $collection->deleteMany($filter); + + return []; + } + + /** + * @param array $op + */ + private function createIndex(array $op): void + { + /** @var array $index */ + $index = $op['index']; + /** @var array $keys */ + $keys = $index['key']; + /** @var string $name */ + $name = $index['name']; + $options = ['name' => $name]; + if (! empty($index['unique'])) { + $options['unique'] = true; + } + + /** @var string $collectionName */ + $collectionName = $op['collection']; + $this->database->selectCollection($collectionName)->createIndex($keys, $options); + } + + /** + * Recursively replace "?" string values with binding values. + * + * @param array $data + * @param list $bindings + * @return array + */ + private function replaceBindings(array $data, array $bindings): array + { + $index = 0; + + /** @var array */ + return $this->walkAndReplace($data, $bindings, $index); + } + + /** + * @param array $data + * @param list $bindings + * @return array + */ + private function walkAndReplace(array $data, array $bindings, int &$index): array + { + foreach ($data as $key => $value) { + if (\is_string($value) && $value === '?') { + $data[$key] = $bindings[$index] ?? null; + $index++; + } elseif (\is_array($value)) { + $data[$key] = $this->walkAndReplace($value, $bindings, $index); + } + } + + return $data; + } + + /** + * Recursively convert stdClass objects to associative arrays while + * preserving empty objects as stdClass so MongoDB encodes them as BSON + * documents (not BSON arrays). + */ + private function objectToArray(mixed $data): mixed + { + if ($data instanceof \stdClass) { + $vars = \get_object_vars($data); + if ($vars === []) { + return $data; + } + $out = []; + foreach ($vars as $key => $value) { + $out[$key] = $this->objectToArray($value); + } + + return $out; + } + + if (\is_array($data)) { + $out = []; + foreach ($data as $key => $value) { + $out[$key] = $this->objectToArray($value); + } + + return $out; + } + + return $data; + } +} diff --git a/tests/Integration/Schema/ClickHouseIntegrationTest.php b/tests/Integration/Schema/ClickHouseIntegrationTest.php new file mode 100644 index 0000000..c20b10b --- /dev/null +++ b/tests/Integration/Schema/ClickHouseIntegrationTest.php @@ -0,0 +1,332 @@ +schema = new ClickHouse(); + } + + public function testCreateTableWithMergeTreeEngine(): void + { + $table = 'test_mergetree_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Table $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 (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + }); + $this->clickhouseStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $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 (Table $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']); + } + + public function testCreateReplacingMergeTree(): void + { + $table = 'test_replacing_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->unsigned()->primary(); + $bp->string('name'); + $bp->integer('version')->unsigned(); + $bp->engine(Engine::ReplacingMergeTree, 'version'); + }); + + $this->clickhouseStatement($result->query); + + $this->clickhouseStatement( + 'INSERT INTO `' . $table . "` (`id`, `name`, `version`) VALUES (1, 'v1', 1)" + ); + $this->clickhouseStatement( + 'INSERT INTO `' . $table . "` (`id`, `name`, `version`) VALUES (1, 'v2', 2)" + ); + + $ch = $this->connectClickhouse(); + + $engineRows = $ch->execute( + "SELECT engine FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertSame('ReplacingMergeTree', $engineRows[0]['engine']); + + $rows = $ch->execute('SELECT `name` FROM `' . $table . '` FINAL WHERE `id` = 1'); + $this->assertCount(1, $rows); + $this->assertSame('v2', $rows[0]['name']); + } + + public function testCreateSummingMergeTree(): void + { + $table = 'test_summing_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('key')->unsigned()->primary(); + $bp->bigInteger('total')->unsigned(); + $bp->engine(Engine::SummingMergeTree, 'total'); + }); + + $this->clickhouseStatement($result->query); + + $this->clickhouseStatement( + 'INSERT INTO `' . $table . '` (`key`, `total`) VALUES (1, 10), (1, 20), (2, 5)' + ); + + $ch = $this->connectClickhouse(); + + $engineRows = $ch->execute( + "SELECT engine FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertSame('SummingMergeTree', $engineRows[0]['engine']); + + // OPTIMIZE to force a merge so summing takes effect. + $this->clickhouseStatement('OPTIMIZE TABLE `' . $table . '` FINAL'); + + $rows = $ch->execute( + 'SELECT `key`, `total` FROM `' . $table . '` ORDER BY `key`' + ); + $this->assertCount(2, $rows); + $this->assertSame(30, (int) $rows[0]['total']); // @phpstan-ignore cast.int + $this->assertSame(5, (int) $rows[1]['total']); // @phpstan-ignore cast.int + } + + public function testCreateAggregatingMergeTree(): void + { + // Schema builder lacks AggregatingMergeTree support — use raw DDL. + $table = 'test_aggregating_' . uniqid(); + $this->trackClickhouseTable($table); + + $this->clickhouseStatement(' + CREATE TABLE `' . $table . '` ( + `key` UInt32, + `max_value` AggregateFunction(max, UInt32) + ) ENGINE = AggregatingMergeTree() + ORDER BY `key` + '); + + $this->clickhouseStatement( + 'INSERT INTO `' . $table . '` (`key`, `max_value`) ' + . 'SELECT `key`, maxState(toUInt32(`value`)) FROM (' + . ' SELECT toUInt32(1) AS `key`, toUInt32(10) AS `value` UNION ALL ' + . ' SELECT toUInt32(1) AS `key`, toUInt32(50) AS `value` UNION ALL ' + . ' SELECT toUInt32(2) AS `key`, toUInt32(5) AS `value`' + . ') GROUP BY `key`' + ); + + $ch = $this->connectClickhouse(); + + $engineRows = $ch->execute( + "SELECT engine FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertSame('AggregatingMergeTree', $engineRows[0]['engine']); + + $rows = $ch->execute( + 'SELECT `key`, maxMerge(`max_value`) AS `m` FROM `' . $table + . '` GROUP BY `key` ORDER BY `key`' + ); + $this->assertCount(2, $rows); + $this->assertSame(50, (int) $rows[0]['m']); // @phpstan-ignore cast.int + $this->assertSame(5, (int) $rows[1]['m']); // @phpstan-ignore cast.int + } + + public function testCreateTableWithTTL(): void + { + // Schema builder does not emit TTL clauses — use raw DDL and confirm + // the TTL expression lands on the table. + $table = 'test_ttl_' . uniqid(); + $this->trackClickhouseTable($table); + + $this->clickhouseStatement(' + CREATE TABLE `' . $table . '` ( + `id` UInt32, + `ts` DateTime + ) ENGINE = MergeTree() + ORDER BY `id` + TTL `ts` + INTERVAL 1 DAY + '); + + $ch = $this->connectClickhouse(); + + $rows = $ch->execute( + "SELECT engine_full FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + + $this->assertCount(1, $rows); + $engineFull = $rows[0]['engine_full']; + \assert(\is_string($engineFull)); + $this->assertStringContainsString('TTL', $engineFull); + $this->assertStringContainsString('toIntervalDay(1)', $engineFull); + } + + public function testCreateTableWithPartitionBy(): void + { + // Schema builder's Table supports partitionByHash as a raw + // expression pass-through — use it to emit PARTITION BY toYYYYMM(ts). + $table = 'test_partition_' . uniqid(); + $this->trackClickhouseTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->datetime('ts'); + $bp->partitionByHash('toYYYYMM(`ts`)'); + }); + + $this->assertStringContainsString('PARTITION BY toYYYYMM(`ts`)', $result->query); + + $this->clickhouseStatement($result->query); + + $this->clickhouseStatement( + 'INSERT INTO `' . $table . "` (`id`, `ts`) VALUES " + . "(1, '2024-01-05 00:00:00'), " + . "(2, '2024-02-10 00:00:00'), " + . "(3, '2024-01-20 00:00:00')" + ); + + $ch = $this->connectClickhouse(); + + $rows = $ch->execute( + "SELECT partition_key FROM system.tables WHERE database = 'query_test' AND name = '{$table}'" + ); + $this->assertCount(1, $rows); + $partitionKey = $rows[0]['partition_key']; + \assert(\is_string($partitionKey)); + $this->assertStringContainsString('toYYYYMM', $partitionKey); + + $partitionRows = $ch->execute( + "SELECT DISTINCT partition FROM system.parts " + . "WHERE database = 'query_test' AND table = '{$table}' AND active" + ); + // Two partitions: 202401 (two rows) and 202402 (one row). + $this->assertSame(2, \count($partitionRows)); + } +} diff --git a/tests/Integration/Schema/MongoDBIntegrationTest.php b/tests/Integration/Schema/MongoDBIntegrationTest.php new file mode 100644 index 0000000..4075287 --- /dev/null +++ b/tests/Integration/Schema/MongoDBIntegrationTest.php @@ -0,0 +1,146 @@ +connectMongoDB(); + $this->schema = new MongoDB(); + } + + public function testCreateCollection(): void + { + $collection = 'schema_create_' . \uniqid(); + $this->trackMongoCollection($collection); + + $plan = $this->schema->create($collection, function (Table $bp) { + $bp->integer('id'); + $bp->string('name', 100); + $bp->integer('age')->nullable(); + }); + + $mongo = $this->mongoClient; + $this->assertNotNull($mongo); + + $mongo->command($plan->query); + + // Collection exists after create. + $this->assertContains($collection, $mongo->listCollectionNames()); + + // Validator is enforced: a valid insert succeeds. + $mongo->insertOne($collection, ['id' => 1, 'name' => 'Alice']); + + // Missing required `name` + wrong type for `id` must be rejected by the validator. + $rejected = false; + try { + $mongo->insertOne($collection, ['id' => 'not-an-int']); + } catch (BulkWriteException) { + $rejected = true; + } + + $this->assertTrue($rejected, 'MongoDB validator should reject invalid document'); + } + + public function testCreateIndexSingleField(): void + { + $collection = 'schema_idx_single_' . \uniqid(); + $this->trackMongoCollection($collection); + + $mongo = $this->mongoClient; + $this->assertNotNull($mongo); + + $mongo->command($this->schema->create($collection, function (Table $bp) { + $bp->integer('id'); + $bp->string('email', 255); + })->query); + + $mongo->command($this->schema->createIndex($collection, 'idx_email', ['email'])->query); + + $this->assertContains('idx_email', $mongo->listIndexNames($collection)); + $this->assertSame(['email' => 1], $mongo->getIndexKey($collection, 'idx_email')); + } + + public function testCreateIndexCompound(): void + { + $collection = 'schema_idx_compound_' . \uniqid(); + $this->trackMongoCollection($collection); + + $mongo = $this->mongoClient; + $this->assertNotNull($mongo); + + $mongo->command($this->schema->create($collection, function (Table $bp) { + $bp->integer('id'); + $bp->string('country', 32); + $bp->string('city', 64); + })->query); + + $indexStatement = $this->schema->createIndex( + $collection, + 'idx_country_city', + ['country', 'city'], + orders: ['country' => 'asc', 'city' => 'desc'], + ); + $mongo->command($indexStatement->query); + + $this->assertSame( + ['country' => 1, 'city' => -1], + $mongo->getIndexKey($collection, 'idx_country_city'), + ); + } + + public function testCreateIndexUnique(): void + { + $collection = 'schema_idx_unique_' . \uniqid(); + $this->trackMongoCollection($collection); + + $mongo = $this->mongoClient; + $this->assertNotNull($mongo); + + $mongo->command($this->schema->create($collection, function (Table $bp) { + $bp->integer('id'); + $bp->string('email', 255); + })->query); + + $mongo->command($this->schema->createIndex($collection, 'idx_email_unique', ['email'], unique: true)->query); + + $mongo->insertOne($collection, ['id' => 1, 'email' => 'a@test.com']); + + $rejected = false; + try { + $mongo->insertOne($collection, ['id' => 2, 'email' => 'a@test.com']); + } catch (BulkWriteException) { + $rejected = true; + } + + $this->assertTrue($rejected, 'Unique index should reject duplicate value'); + } + + public function testDropCollection(): void + { + $collection = 'schema_drop_' . \uniqid(); + $this->trackMongoCollection($collection); + + $mongo = $this->mongoClient; + $this->assertNotNull($mongo); + + $mongo->command($this->schema->create($collection, function (Table $bp) { + $bp->integer('id'); + })->query); + + $this->assertContains($collection, $mongo->listCollectionNames()); + + $mongo->command($this->schema->drop($collection)->query); + + $this->assertNotContains($collection, $mongo->listCollectionNames()); + } +} diff --git a/tests/Integration/Schema/MySQLIntegrationTest.php b/tests/Integration/Schema/MySQLIntegrationTest.php new file mode 100644 index 0000000..b72c1c3 --- /dev/null +++ b/tests/Integration/Schema/MySQLIntegrationTest.php @@ -0,0 +1,464 @@ +schema = new MySQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $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 (Table $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 (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255); + }); + $this->mysqlStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $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 (Table $bp) { + $bp->id(); + }); + $this->mysqlStatement($createParent->query); + + $createChild = $this->schema->create($childTable, function (Table $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 (Table $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 testCreateTableWithCheckConstraint(): void + { + $table = 'test_check_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->id(); + $bp->integer('age'); + $bp->check('age_range', '`age` >= 0 AND `age` < 150'); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $insert = $pdo->prepare("INSERT INTO `{$table}` (`age`) VALUES (30)"); + \assert($insert !== false); + $insert->execute(); + + $violation = $pdo->prepare("INSERT INTO `{$table}` (`age`) VALUES (-5)"); + \assert($violation !== false); + try { + $violation->execute(); + $this->fail('Expected CHECK constraint violation'); + } catch (\PDOException $e) { + $this->assertIsArray($e->errorInfo); + $this->assertSame(3819, $e->errorInfo[1]); + } + } + + public function testCreateTableWithGeneratedColumn(): void + { + $table = 'test_generated_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->id(); + $bp->integer('width'); + $bp->integer('height'); + $bp->integer('area')->generatedAs('`width` * `height`')->stored(); + }); + + $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 = 'area'" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertStringContainsString('STORED GENERATED', (string) $row['EXTRA']); // @phpstan-ignore cast.string + + $insert = $pdo->prepare("INSERT INTO `{$table}` (`width`, `height`) VALUES (3, 4)"); + \assert($insert !== false); + $insert->execute(); + + $select = $pdo->prepare("SELECT `area` FROM `{$table}`"); + \assert($select !== false); + $select->execute(); + $area = $select->fetchColumn(); + $this->assertSame(12, (int) $area); + } + + public function testCreateTableWithPartitioning(): void + { + $table = 'test_partition_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->partitionByHash('`id`'); + }); + + $this->assertStringContainsString('PARTITION BY HASH(`id`)', $result->query); + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) AS cnt FROM information_schema.PARTITIONS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND PARTITION_NAME IS NOT NULL" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertGreaterThanOrEqual(1, (int) $row['cnt']); // @phpstan-ignore cast.int + } + + public function testCreateTableWithCompositeIndex(): void + { + $table = 'test_composite_idx_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->id(); + $bp->string('first_name', 100); + $bp->string('last_name', 100); + $bp->addIndex('idx_name', ['last_name', 'first_name']); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT COLUMN_NAME, SEQ_IN_INDEX FROM information_schema.STATISTICS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND INDEX_NAME = 'idx_name' " + . "ORDER BY SEQ_IN_INDEX" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + /** @var list> $rows */ + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $this->assertCount(2, $rows); + $this->assertSame('last_name', $rows[0]['COLUMN_NAME']); + $this->assertSame('first_name', $rows[1]['COLUMN_NAME']); + } + + public function testCreateTableWithFullTextIndex(): void + { + $table = 'test_fulltext_' . uniqid(); + $this->trackMysqlTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->id(); + $bp->string('title', 200); + $bp->text('body'); + $bp->fulltextIndex(['title', 'body'], 'ft_title_body'); + }); + + $this->mysqlStatement($result->query); + + $pdo = $this->connectMysql(); + $stmt = $pdo->prepare( + "SELECT INDEX_TYPE FROM information_schema.STATISTICS " + . "WHERE TABLE_SCHEMA = 'query_test' AND TABLE_NAME = ? AND INDEX_NAME = 'ft_title_body' LIMIT 1" + ); + \assert($stmt !== false); + $stmt->execute([$table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame('FULLTEXT', (string) $row['INDEX_TYPE']); // @phpstan-ignore cast.string + } + + public function testTruncateTable(): void + { + $table = 'test_truncate_' . uniqid(); + $this->trackMysqlTable($table); + + $create = $this->schema->create($table, function (Table $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..ca4dd3a --- /dev/null +++ b/tests/Integration/Schema/PostgreSQLIntegrationTest.php @@ -0,0 +1,458 @@ +schema = new PostgreSQL(); + } + + public function testCreateTableWithBasicColumns(): void + { + $table = 'test_basic_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Table $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 (Table $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 (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $bp) { + $bp->integer('id')->primary(); + $bp->string('temp', 100); + }); + $this->postgresStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $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 (Table $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 (Table $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 (Table $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 (Table $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 (Table $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 + } + + public function testCreateTableWithCheckConstraint(): void + { + $table = 'test_check_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->integer('age'); + $bp->check('age_min', '"age" >= 18'); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $insertOk = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"age\") VALUES (1, 21)"); + \assert($insertOk !== false); + $insertOk->execute(); + + $this->expectException(\PDOException::class); + $insertFail = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"age\") VALUES (2, 10)"); + \assert($insertFail !== false); + $insertFail->execute(); + } + + public function testCreateTableWithGeneratedColumn(): void + { + $table = 'test_generated_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->integer('price'); + $bp->integer('quantity'); + $bp->integer('total')->generatedAs('"price" * "quantity"')->stored(); + }); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $insert = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"price\", \"quantity\") VALUES (1, 5, 3)"); + \assert($insert !== false); + $insert->execute(); + + $stmt = $pdo->prepare("SELECT \"total\" FROM \"{$table}\" WHERE \"id\" = 1"); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame(15, (int) $row['total']); // @phpstan-ignore cast.int + + $columns = $this->fetchPostgresColumns($table); + $totalCol = $this->findColumn($columns, 'total'); + $this->assertSame('ALWAYS', $totalCol['is_generated']); + } + + public function testCreateTableWithSerial(): void + { + $table = 'test_serial_' . uniqid(); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->bigSerial('id')->primary(); + $bp->string('label', 50); + }); + + $this->assertStringContainsString('BIGSERIAL', $result->query); + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $stmt = $pdo->prepare("SELECT pg_get_serial_sequence(:table, 'id') AS seq"); + \assert($stmt !== false); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertNotNull($row['seq']); + $this->assertStringContainsString($table, (string) $row['seq']); // @phpstan-ignore cast.string + + $insert = $pdo->prepare("INSERT INTO \"{$table}\" (\"label\") VALUES ('a'), ('b') RETURNING \"id\""); + \assert($insert !== false); + $insert->execute(); + $ids = $insert->fetchAll(\PDO::FETCH_COLUMN); + $this->assertCount(2, $ids); + $first = (int) $ids[0]; // @phpstan-ignore cast.int + $second = (int) $ids[1]; // @phpstan-ignore cast.int + $this->assertGreaterThan($first, $second); + } + + public function testCreateEnumType(): void + { + $typeName = 'mood_' . uniqid(); + $table = 'test_enum_type_' . uniqid(); + $this->trackPostgresTable($table); + + try { + $createType = $this->schema->createType($typeName, ['happy', 'sad', 'neutral']); + $this->postgresStatement($createType->query); + + $result = $this->schema->create($table, function (Table $bp) use ($typeName) { + $bp->integer('id')->primary(); + $bp->string('mood')->userType($typeName); + }); + + $this->assertStringContainsString('"mood" "' . $typeName . '"', $result->query); + + $this->postgresStatement($result->query); + + $pdo = $this->connectPostgres(); + $insert = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"mood\") VALUES (1, 'happy')"); + \assert($insert !== false); + $insert->execute(); + + $stmt = $pdo->prepare("SELECT \"mood\" FROM \"{$table}\" WHERE \"id\" = 1"); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + $this->assertSame('happy', $row['mood']); + + $typeStmt = $pdo->prepare( + "SELECT typname FROM pg_type WHERE typname = :name" + ); + $typeStmt->execute(['name' => $typeName]); + $typeRow = $typeStmt->fetch(\PDO::FETCH_ASSOC); + $this->assertNotFalse($typeRow); + \assert(\is_array($typeRow)); + $this->assertSame($typeName, $typeRow['typname']); + } finally { + $this->postgresStatement("DROP TABLE IF EXISTS \"{$table}\" CASCADE"); + $this->postgresStatement("DROP TYPE IF EXISTS \"{$typeName}\""); + } + } + + public function testCreatePartitionedTable(): void + { + $table = 'test_partitioned_' . uniqid(); + $partition = $table . '_2024'; + $this->trackPostgresTable($partition); + $this->trackPostgresTable($table); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id'); + $bp->timestamp('created_at'); + $bp->primary(['id', 'created_at']); + $bp->partitionByRange('"created_at"'); + }); + + $this->postgresStatement($result->query); + + $partitionStatement = $this->schema->createPartition($table, $partition, "FROM ('2024-01-01') TO ('2025-01-01')"); + $this->postgresStatement($partitionStatement->query); + + $pdo = $this->connectPostgres(); + $insert = $pdo->prepare("INSERT INTO \"{$table}\" (\"id\", \"created_at\") VALUES (1, '2024-06-15')"); + \assert($insert !== false); + $insert->execute(); + + $stmt = $pdo->prepare("SELECT COUNT(*) AS cnt FROM \"{$partition}\""); + \assert($stmt !== false); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + $this->assertSame('1', (string) $row['cnt']); // @phpstan-ignore cast.string + + $partitionCheck = $pdo->prepare( + "SELECT relkind FROM pg_class WHERE relname = :name" + ); + $partitionCheck->execute(['name' => $table]); + $relRow = $partitionCheck->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($relRow)); + $this->assertSame('p', $relRow['relkind']); + } + + /** + * @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/Integration/Schema/SQLiteIntegrationTest.php b/tests/Integration/Schema/SQLiteIntegrationTest.php new file mode 100644 index 0000000..797157d --- /dev/null +++ b/tests/Integration/Schema/SQLiteIntegrationTest.php @@ -0,0 +1,178 @@ +schema = new SQLite(); + } + + public function testCreateTableBasic(): void + { + $table = 'test_basic_' . uniqid(); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('age'); + $bp->string('name', 100); + $bp->float('score'); + }); + + $this->sqliteStatement($result->query); + + $columns = $this->fetchSqliteColumns($table); + $columnNames = array_column($columns, 'name'); + + $this->assertContains('age', $columnNames); + $this->assertContains('name', $columnNames); + $this->assertContains('score', $columnNames); + + $nameCol = $this->findColumn($columns, 'name'); + $this->assertSame('VARCHAR(100)', (string) $nameCol['type']); // @phpstan-ignore cast.string + } + + public function testCreateTableWithPrimaryKeyAndUnique(): void + { + $table = 'test_pk_unique_' . uniqid(); + + $result = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255)->unique(); + }); + + $this->sqliteStatement($result->query); + + $columns = $this->fetchSqliteColumns($table); + + $idCol = $this->findColumn($columns, 'id'); + $this->assertSame(1, (int) $idCol['pk']); // @phpstan-ignore cast.int + + $pdo = $this->connectSqlite(); + $stmt = $pdo->prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = :table"); + $stmt->execute(['table' => $table]); + /** @var list> $indexes */ + $indexes = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + $found = false; + foreach ($indexes as $idx) { + $name = (string) $idx['name']; // @phpstan-ignore cast.string + $info = $pdo->query("PRAGMA index_info('{$name}')"); + \assert($info !== false); + /** @var list> $infoRows */ + $infoRows = $info->fetchAll(\PDO::FETCH_ASSOC); + foreach ($infoRows as $infoRow) { + if ($infoRow['name'] === 'email') { + $found = true; + } + } + } + + $this->assertTrue($found, 'Expected a unique index covering the email column'); + } + + public function testAlterTableAddColumn(): void + { + $table = 'test_alter_add_' . uniqid(); + + $create = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + }); + $this->sqliteStatement($create->query); + + $alter = $this->schema->alter($table, function (Table $bp) { + $bp->addColumn('description', ColumnType::Text); + }); + $this->sqliteStatement($alter->query); + + $columns = $this->fetchSqliteColumns($table); + $columnNames = array_column($columns, 'name'); + + $this->assertContains('description', $columnNames); + } + + public function testCreateIndex(): void + { + $table = 'test_index_' . uniqid(); + + $create = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + $bp->string('email', 255); + }); + $this->sqliteStatement($create->query); + + $indexName = 'idx_' . $table . '_email'; + $index = $this->schema->createIndex($table, $indexName, ['email']); + $this->sqliteStatement($index->query); + + $pdo = $this->connectSqlite(); + $stmt = $pdo->prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = :table AND name = :name" + ); + $stmt->execute(['table' => $table, 'name' => $indexName]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + $this->assertNotFalse($row); + \assert(\is_array($row)); + $this->assertSame($indexName, $row['name']); + } + + public function testDropTable(): void + { + $table = 'test_drop_' . uniqid(); + + $create = $this->schema->create($table, function (Table $bp) { + $bp->integer('id')->primary(); + }); + $this->sqliteStatement($create->query); + + $drop = $this->schema->drop($table); + $this->sqliteStatement($drop->query); + + $pdo = $this->connectSqlite(); + $stmt = $pdo->prepare( + "SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type = 'table' AND name = :table" + ); + $stmt->execute(['table' => $table]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + \assert(\is_array($row)); + + $this->assertSame(0, (int) $row['cnt']); // @phpstan-ignore cast.int + } + + /** + * @return list> + */ + private function fetchSqliteColumns(string $table): array + { + $pdo = $this->connectSqlite(); + $stmt = $pdo->query("PRAGMA table_info('{$table}')"); + \assert($stmt !== false); + + /** @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['name'] === $name) { + return $col; + } + } + + $this->fail("Column '{$name}' not found"); + } +} diff --git a/tests/Query/AST/BuilderIntegrationTest.php b/tests/Query/AST/BuilderIntegrationTest.php new file mode 100644 index 0000000..7a46342 --- /dev/null +++ b/tests/Query/AST/BuilderIntegrationTest.php @@ -0,0 +1,728 @@ +from('users') + ->select(['id', 'name', 'email']); + + $ast = $builder->toAst(); + + $this->assertInstanceOf(Select::class, $ast); + $this->assertInstanceOf(Table::class, $ast->from); + $this->assertSame('users', $ast->from->name); + $this->assertCount(3, $ast->columns); + $this->assertInstanceOf(Column::class, $ast->columns[0]); + $this->assertSame('id', $ast->columns[0]->name); + $this->assertInstanceOf(Column::class, $ast->columns[1]); + $this->assertSame('name', $ast->columns[1]->name); + $this->assertInstanceOf(Column::class, $ast->columns[2]); + $this->assertSame('email', $ast->columns[2]->name); + } + + public function testToAstWithWhere(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Binary::class, $ast->where); + $this->assertSame('AND', $ast->where->operator); + + $left = $ast->where->left; + $this->assertInstanceOf(Binary::class, $left); + $this->assertSame('=', $left->operator); + $this->assertInstanceOf(Column::class, $left->left); + $this->assertSame('status', $left->left->name); + $this->assertInstanceOf(Literal::class, $left->right); + $this->assertSame('active', $left->right->value); + + $right = $ast->where->right; + $this->assertInstanceOf(Binary::class, $right); + $this->assertSame('>', $right->operator); + } + + public function testToAstWithJoin(): void + { + $builder = (new MySQL()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id'); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->joins); + $join = $ast->joins[0]; + $this->assertInstanceOf(JoinClause::class, $join); + $this->assertSame('JOIN', $join->type); + $this->assertInstanceOf(Table::class, $join->table); + $this->assertSame('orders', $join->table->name); + $this->assertNotNull($join->condition); + } + + public function testToAstWithOrderBy(): void + { + $builder = (new MySQL()) + ->from('users') + ->sortAsc('name') + ->sortDesc('created_at'); + + $ast = $builder->toAst(); + + $this->assertCount(2, $ast->orderBy); + $this->assertInstanceOf(OrderByItem::class, $ast->orderBy[0]); + $this->assertSame(OrderDirection::Asc, $ast->orderBy[0]->direction); + $this->assertInstanceOf(Column::class, $ast->orderBy[0]->expression); + $this->assertSame('name', $ast->orderBy[0]->expression->name); + + $this->assertSame(OrderDirection::Desc, $ast->orderBy[1]->direction); + $this->assertInstanceOf(Column::class, $ast->orderBy[1]->expression); + $this->assertSame('created_at', $ast->orderBy[1]->expression->name); + } + + public function testToAstWithGroupBy(): void + { + $builder = (new MySQL()) + ->from('orders') + ->groupBy(['status', 'region']); + + $ast = $builder->toAst(); + + $this->assertCount(2, $ast->groupBy); + $this->assertInstanceOf(Column::class, $ast->groupBy[0]); + $this->assertSame('status', $ast->groupBy[0]->name); + $this->assertInstanceOf(Column::class, $ast->groupBy[1]); + $this->assertSame('region', $ast->groupBy[1]->name); + } + + public function testToAstWithHaving(): void + { + $builder = (new MySQL()) + ->from('orders') + ->count('*', 'order_count') + ->groupBy(['status']) + ->having([Query::greaterThan('order_count', 5)]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->having); + } + + public function testToAstWithLimitOffset(): void + { + $builder = (new MySQL()) + ->from('users') + ->limit(10) + ->offset(20); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->limit); + $this->assertInstanceOf(Literal::class, $ast->limit); + $this->assertSame(10, $ast->limit->value); + + $this->assertNotNull($ast->offset); + $this->assertInstanceOf(Literal::class, $ast->offset); + $this->assertSame(20, $ast->offset->value); + } + + public function testToAstWithDistinct(): void + { + $builder = (new MySQL()) + ->from('users') + ->distinct() + ->select(['email']); + + $ast = $builder->toAst(); + + $this->assertTrue($ast->distinct); + } + + public function testToAstWithAggregates(): void + { + $builder = (new MySQL()) + ->from('orders') + ->count('*', 'total_count') + ->sum('amount', 'total_amount'); + + $ast = $builder->toAst(); + + $this->assertCount(2, $ast->columns); + + $countCol = $ast->columns[0]; + $this->assertInstanceOf(Aliased::class, $countCol); + $this->assertSame('total_count', $countCol->alias); + $this->assertInstanceOf(Func::class, $countCol->expression); + $this->assertSame('COUNT', $countCol->expression->name); + + $sumCol = $ast->columns[1]; + $this->assertInstanceOf(Aliased::class, $sumCol); + $this->assertSame('total_amount', $sumCol->alias); + $this->assertInstanceOf(Func::class, $sumCol->expression); + $this->assertSame('SUM', $sumCol->expression->name); + } + + public function testFromAstSimpleSelect(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertSame('SELECT * FROM `users`', $result->query); + } + + public function testFromAstWithWhere(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Binary( + new Column('status'), + '=', + new Literal('active'), + ), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('WHERE', $result->query); + $this->assertStringContainsString('`status`', $result->query); + } + + public function testFromAstWithJoin(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + joins: [ + new JoinClause( + 'LEFT JOIN', + new Table('orders'), + new Binary( + new Column('id', 'users'), + '=', + new Column('user_id', 'orders'), + ), + ), + ], + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('LEFT JOIN', $result->query); + $this->assertStringContainsString('`orders`', $result->query); + } + + public function testFromAstWithOrderBy(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + orderBy: [ + new OrderByItem(new Column('name'), OrderDirection::Asc), + new OrderByItem(new Column('age'), OrderDirection::Desc), + ], + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('`name` ASC', $result->query); + $this->assertStringContainsString('`age` DESC', $result->query); + } + + public function testFromAstWithLimitOffset(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + limit: new Literal(25), + offset: new Literal(50), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('LIMIT', $result->query); + $this->assertStringContainsString('OFFSET', $result->query); + $this->assertContains(25, $result->bindings); + $this->assertContains(50, $result->bindings); + } + + public function testRoundTripBuilderToAst(): void + { + $builder = (new MySQL()) + ->from('users') + ->select(['id', 'name']) + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 21), + ]) + ->sortAsc('name') + ->limit(10) + ->offset(0); + + $original = $builder->build(); + + $ast = $builder->toAst(); + $rebuilt = MySQL::fromAst($ast); + $result = $rebuilt->build(); + + $this->assertSame($original->query, $result->query); + $this->assertSame($original->bindings, $result->bindings); + } + + public function testRoundTripAstToBuilder(): void + { + $ast = new Select( + columns: [new Column('id'), new Column('name')], + from: new Table('users'), + where: new Binary( + new Column('age'), + '>', + new Literal(18), + ), + orderBy: [new OrderByItem(new Column('name'), OrderDirection::Asc)], + limit: new Literal(10), + ); + + $builder = MySQL::fromAst($ast); + $result1 = $builder->build(); + + $ast2 = $builder->toAst(); + $builder2 = MySQL::fromAst($ast2); + $result2 = $builder2->build(); + + $this->assertSame($result1->query, $result2->query); + $this->assertSame($result1->bindings, $result2->bindings); + } + + public function testFromAstComplexQuery(): void + { + $ast = new Select( + columns: [ + new Column('id'), + new Aliased(new Func('COUNT', [new Star()]), 'order_count'), + ], + from: new Table('users', 'u'), + joins: [ + new JoinClause( + 'LEFT JOIN', + new Table('orders', 'o'), + new Binary( + new Column('id', 'u'), + '=', + new Column('user_id', 'o'), + ), + ), + ], + where: new Binary( + new Column('status'), + '=', + new Literal('active'), + ), + groupBy: [new Column('id')], + orderBy: [new OrderByItem(new Func('COUNT', [new Star()]), OrderDirection::Desc)], + limit: new Literal(10), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('SELECT', $result->query); + $this->assertStringContainsString('LEFT JOIN', $result->query); + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('ORDER BY', $result->query); + $this->assertStringContainsString('LIMIT', $result->query); + } + + public function testFromAstWithCte(): void + { + $innerStmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Binary( + new Column('active'), + '=', + new Literal(true), + ), + ); + + $ast = new Select( + columns: [new Star()], + from: new Table('active_users'), + ctes: [ + new Cte('active_users', $innerStmt), + ], + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('WITH', $result->query); + $this->assertStringContainsString('`active_users`', $result->query); + } + + public function testMySQLDialect(): void + { + $builder = (new MySQL()) + ->from('products') + ->select(['name', 'price']) + ->filter([Query::greaterThan('price', 100)]) + ->sortDesc('price') + ->limit(5); + + $ast = $builder->toAst(); + $rebuilt = MySQL::fromAst($ast); + $result = $rebuilt->build(); + + $original = $builder->build(); + + $this->assertSame($original->query, $result->query); + $this->assertSame($original->bindings, $result->bindings); + } + + public function testPostgreSQLDialect(): void + { + $builder = (new PostgreSQL()) + ->from('products') + ->select(['name', 'price']) + ->filter([Query::greaterThan('price', 100)]) + ->sortDesc('price') + ->limit(5); + + $ast = $builder->toAst(); + $rebuilt = PostgreSQL::fromAst($ast); + $result = $rebuilt->build(); + + $original = $builder->build(); + + $this->assertSame($original->query, $result->query); + $this->assertSame($original->bindings, $result->bindings); + } + + public function testToAstEqualWithMultipleValues(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::equal('status', ['active', 'pending', 'review'])]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(In::class, $ast->where); + $this->assertFalse($ast->where->negated); + } + + public function testToAstNotEqual(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::notEqual('status', 'deleted')]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + } + + public function testToAstBetween(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::between('age', 18, 65)]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Between::class, $ast->where); + $this->assertFalse($ast->where->negated); + } + + public function testToAstIsNull(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::isNull('deleted_at')]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Unary::class, $ast->where); + $this->assertSame('IS NULL', $ast->where->operator); + } + + public function testToAstIsNotNull(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::isNotNull('email')]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Unary::class, $ast->where); + $this->assertSame('IS NOT NULL', $ast->where->operator); + } + + public function testToAstWithTableAlias(): void + { + $builder = (new MySQL()) + ->from('users', 'u') + ->select(['id']); + + $ast = $builder->toAst(); + + $this->assertInstanceOf(Table::class, $ast->from); + $this->assertSame('users', $ast->from->name); + $this->assertSame('u', $ast->from->alias); + } + + public function testToAstLeftJoin(): void + { + $builder = (new MySQL()) + ->from('users') + ->leftJoin('orders', 'users.id', 'orders.user_id'); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->joins); + $this->assertSame('LEFT JOIN', $ast->joins[0]->type); + } + + public function testToAstCrossJoin(): void + { + $builder = (new MySQL()) + ->from('users') + ->crossJoin('roles'); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->joins); + $this->assertSame('CROSS JOIN', $ast->joins[0]->type); + $this->assertNull($ast->joins[0]->condition); + } + + public function testToAstNoColumns(): void + { + $builder = (new MySQL()) + ->from('users'); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->columns); + $this->assertInstanceOf(Star::class, $ast->columns[0]); + } + + public function testFromAstWithDistinct(): void + { + $ast = new Select( + columns: [new Column('email')], + from: new Table('users'), + distinct: true, + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('SELECT DISTINCT', $result->query); + } + + public function testFromAstWithGroupBy(): void + { + $ast = new Select( + columns: [ + new Column('department'), + new Aliased(new Func('COUNT', [new Star()]), 'cnt'), + ], + from: new Table('employees'), + groupBy: [new Column('department')], + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('GROUP BY', $result->query); + $this->assertStringContainsString('`department`', $result->query); + } + + public function testFromAstWithBetween(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Between( + new Column('age'), + new Literal(18), + new Literal(65), + ), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('BETWEEN', $result->query); + } + + public function testFromAstWithInExpression(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + where: new In( + new Column('status'), + [new Literal('active'), new Literal('pending')], + ), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('IN', $result->query); + } + + public function testFromAstAndCombinedFilters(): void + { + $ast = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Binary( + new Binary(new Column('age'), '>', new Literal(18)), + 'AND', + new Binary(new Column('status'), '=', new Literal('active')), + ), + ); + + $builder = MySQL::fromAst($ast); + $result = $builder->build(); + + $this->assertStringContainsString('WHERE', $result->query); + } + + public function testToAstNotBetween(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::notBetween('age', 0, 17)]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Between::class, $ast->where); + $this->assertTrue($ast->where->negated); + } + + public function testToAstStartsWith(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::startsWith('name', 'Jo')]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Binary::class, $ast->where); + $this->assertSame('LIKE', $ast->where->operator); + } + + public function testToAstEndsWith(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([Query::endsWith('email', '.com')]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Binary::class, $ast->where); + $this->assertSame('LIKE', $ast->where->operator); + } + + public function testToAstOrGroup(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([ + Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']), + ]), + ]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Binary::class, $ast->where); + $this->assertSame('OR', $ast->where->operator); + } + + public function testToAstAndGroup(): void + { + $builder = (new MySQL()) + ->from('users') + ->filter([ + Query::and([ + Query::greaterThan('age', 18), + Query::equal('verified', [true]), + ]), + ]); + + $ast = $builder->toAst(); + + $this->assertNotNull($ast->where); + $this->assertInstanceOf(Binary::class, $ast->where); + $this->assertSame('AND', $ast->where->operator); + } + + public function testToAstCountDistinct(): void + { + $builder = (new MySQL()) + ->from('orders') + ->countDistinct('user_id', 'unique_users'); + + $ast = $builder->toAst(); + + $this->assertCount(1, $ast->columns); + $col = $ast->columns[0]; + $this->assertInstanceOf(Aliased::class, $col); + $this->assertSame('unique_users', $col->alias); + $this->assertInstanceOf(Func::class, $col->expression); + $this->assertSame('COUNT', $col->expression->name); + $this->assertTrue($col->expression->distinct); + } +} diff --git a/tests/Query/AST/CollectingVisitor.php b/tests/Query/AST/CollectingVisitor.php new file mode 100644 index 0000000..02b07c9 --- /dev/null +++ b/tests/Query/AST/CollectingVisitor.php @@ -0,0 +1,33 @@ + */ + public array $visited = []; + + public function visitExpression(Expression $expression): Expression + { + $class = \get_class($expression); + $short = \substr($class, \strrpos($class, '\\') + 1); + $this->visited[] = $short; + + return $expression; + } + + public function visitTableReference(Table $reference): Table + { + return $reference; + } + + public function visitSelect(Select $stmt): Select + { + return $stmt; + } +} diff --git a/tests/Query/AST/NodeTest.php b/tests/Query/AST/NodeTest.php new file mode 100644 index 0000000..7ec1c93 --- /dev/null +++ b/tests/Query/AST/NodeTest.php @@ -0,0 +1,546 @@ +assertInstanceOf(Expression::class, $col); + $this->assertSame('id', $col->name); + $this->assertNull($col->table); + $this->assertNull($col->schema); + + $col = new Column('id', 'users'); + $this->assertSame('id', $col->name); + $this->assertSame('users', $col->table); + $this->assertNull($col->schema); + + $col = new Column('id', 'users', 'public'); + $this->assertSame('id', $col->name); + $this->assertSame('users', $col->table); + $this->assertSame('public', $col->schema); + } + + public function testLiteral(): void + { + $str = new Literal('hello'); + $this->assertInstanceOf(Expression::class, $str); + $this->assertSame('hello', $str->value); + + $int = new Literal(42); + $this->assertSame(42, $int->value); + + $float = new Literal(3.14); + $this->assertSame(3.14, $float->value); + + $bool = new Literal(true); + $this->assertSame(true, $bool->value); + + $null = new Literal(null); + $this->assertNull($null->value); + } + + public function testStar(): void + { + $star = new Star(); + $this->assertInstanceOf(Expression::class, $star); + $this->assertNull($star->table); + + $star = new Star('users'); + $this->assertSame('users', $star->table); + } + + public function testPlaceholder(): void + { + $q = new Placeholder('?'); + $this->assertInstanceOf(Expression::class, $q); + $this->assertSame('?', $q->value); + + $named = new Placeholder(':name'); + $this->assertSame(':name', $named->value); + + $numbered = new Placeholder('$1'); + $this->assertSame('$1', $numbered->value); + } + + public function testRaw(): void + { + $raw = new Raw('NOW() + INTERVAL 1 DAY'); + $this->assertInstanceOf(Expression::class, $raw); + $this->assertSame('NOW() + INTERVAL 1 DAY', $raw->sql); + } + + public function testBinaryExpression(): void + { + $left = new Column('age'); + $right = new Literal(18); + $expression = new Binary($left, '>=', $right); + + $this->assertInstanceOf(Expression::class, $expression); + $this->assertSame($left, $expression->left); + $this->assertSame('>=', $expression->operator); + $this->assertSame($right, $expression->right); + + $and = new Binary( + new Binary(new Column('a'), '=', new Literal(1)), + 'AND', + new Binary(new Column('b'), '=', new Literal(2)), + ); + $this->assertSame('AND', $and->operator); + } + + public function testUnaryExpressionPrefix(): void + { + $operand = new Column('active'); + $not = new Unary('NOT', $operand); + + $this->assertInstanceOf(Expression::class, $not); + $this->assertSame('NOT', $not->operator); + $this->assertSame($operand, $not->operand); + $this->assertTrue($not->prefix); + + $neg = new Unary('-', new Literal(5)); + $this->assertSame('-', $neg->operator); + $this->assertTrue($neg->prefix); + } + + public function testUnaryExpressionPostfix(): void + { + $operand = new Column('deleted_at'); + $isNull = new Unary('IS NULL', $operand, false); + + $this->assertInstanceOf(Expression::class, $isNull); + $this->assertSame('IS NULL', $isNull->operator); + $this->assertSame($operand, $isNull->operand); + $this->assertFalse($isNull->prefix); + + $isNotNull = new Unary('IS NOT NULL', $operand, false); + $this->assertSame('IS NOT NULL', $isNotNull->operator); + $this->assertFalse($isNotNull->prefix); + } + + public function testFunctionCall(): void + { + $fn = new Func('UPPER', [new Column('name')]); + $this->assertInstanceOf(Expression::class, $fn); + $this->assertSame('UPPER', $fn->name); + $this->assertCount(1, $fn->arguments); + $this->assertFalse($fn->distinct); + + $noArgs = new Func('NOW'); + $this->assertSame('NOW', $noArgs->name); + $this->assertSame([], $noArgs->arguments); + } + + public function testFunctionCallDistinct(): void + { + $count = new Func('COUNT', [new Column('id')], true); + $this->assertSame('COUNT', $count->name); + $this->assertTrue($count->distinct); + $this->assertCount(1, $count->arguments); + } + + public function testInExpression(): void + { + $col = new Column('status'); + $list = [new Literal('active'), new Literal('pending')]; + $in = new In($col, $list); + + $this->assertInstanceOf(Expression::class, $in); + $this->assertSame($col, $in->expression); + $this->assertSame($list, $in->list); + $this->assertFalse($in->negated); + + $notIn = new In($col, $list, true); + $this->assertTrue($notIn->negated); + + $subquery = new Select( + columns: [new Column('id')], + from: new Table('other'), + ); + $inSub = new In($col, $subquery); + $this->assertInstanceOf(Select::class, $inSub->list); + } + + public function testBetweenExpression(): void + { + $col = new Column('age'); + $low = new Literal(18); + $high = new Literal(65); + $between = new Between($col, $low, $high); + + $this->assertInstanceOf(Expression::class, $between); + $this->assertSame($col, $between->expression); + $this->assertSame($low, $between->low); + $this->assertSame($high, $between->high); + $this->assertFalse($between->negated); + + $notBetween = new Between($col, $low, $high, true); + $this->assertTrue($notBetween->negated); + } + + public function testExistsExpression(): void + { + $subquery = new Select( + columns: [new Literal(1)], + from: new Table('users'), + where: new Binary(new Column('id'), '=', new Literal(1)), + ); + + $exists = new Exists($subquery); + $this->assertInstanceOf(Expression::class, $exists); + $this->assertSame($subquery, $exists->subquery); + $this->assertFalse($exists->negated); + + $notExists = new Exists($subquery, true); + $this->assertTrue($notExists->negated); + } + + public function testConditionalExpression(): void + { + $whens = [ + new CaseWhen( + new Binary(new Column('status'), '=', new Literal('active')), + new Literal(1), + ), + new CaseWhen( + new Binary(new Column('status'), '=', new Literal('inactive')), + new Literal(0), + ), + ]; + $else = new Literal(-1); + $searched = new Conditional(null, $whens, $else); + + $this->assertInstanceOf(Expression::class, $searched); + $this->assertNull($searched->operand); + $this->assertCount(2, $searched->whens); + $this->assertSame($else, $searched->else); + + $simple = new Conditional(new Column('status'), $whens); + $this->assertInstanceOf(Expression::class, $simple); + $this->assertNotNull($simple->operand); + $this->assertNull($simple->else); + } + + public function testCastExpression(): void + { + $expression = new Column('price'); + $cast = new Cast($expression, 'INTEGER'); + + $this->assertInstanceOf(Expression::class, $cast); + $this->assertSame($expression, $cast->expression); + $this->assertSame('INTEGER', $cast->type); + } + + public function testAliasedExpression(): void + { + $expression = new Func('COUNT', [new Star()]); + $aliased = new Aliased($expression, 'total'); + + $this->assertInstanceOf(Expression::class, $aliased); + $this->assertSame($expression, $aliased->expression); + $this->assertSame('total', $aliased->alias); + } + + public function testSubqueryExpression(): void + { + $query = new Select( + columns: [new Func('MAX', [new Column('salary')])], + from: new Table('employees'), + ); + $sub = new Subquery($query); + + $this->assertInstanceOf(Expression::class, $sub); + $this->assertSame($query, $sub->query); + } + + public function testWindowExpression(): void + { + $fn = new Func('ROW_NUMBER'); + $specification = new WindowSpecification( + partitionBy: [new Column('department')], + orderBy: [new OrderByItem(new Column('salary'), OrderDirection::Desc)], + ); + $window = new Window($fn, specification: $specification); + + $this->assertInstanceOf(Expression::class, $window); + $this->assertSame($fn, $window->function); + $this->assertNull($window->windowName); + $this->assertSame($specification, $window->specification); + + $namedWindow = new Window($fn, windowName: 'w'); + $this->assertSame('w', $namedWindow->windowName); + $this->assertNull($namedWindow->specification); + } + + public function testWindowSpecification(): void + { + $specification = new WindowSpecification(); + $this->assertSame([], $specification->partitionBy); + $this->assertSame([], $specification->orderBy); + $this->assertNull($specification->frameType); + $this->assertNull($specification->frameStart); + $this->assertNull($specification->frameEnd); + + $specification = new WindowSpecification( + partitionBy: [new Column('dept')], + orderBy: [new OrderByItem(new Column('hire_date'))], + frameType: 'ROWS', + frameStart: 'UNBOUNDED PRECEDING', + frameEnd: 'CURRENT ROW', + ); + $this->assertCount(1, $specification->partitionBy); + $this->assertCount(1, $specification->orderBy); + $this->assertSame('ROWS', $specification->frameType); + $this->assertSame('UNBOUNDED PRECEDING', $specification->frameStart); + $this->assertSame('CURRENT ROW', $specification->frameEnd); + } + + public function testTableReference(): void + { + $table = new Table('users'); + $this->assertSame('users', $table->name); + $this->assertNull($table->alias); + $this->assertNull($table->schema); + + $aliased = new Table('users', 'u'); + $this->assertSame('users', $aliased->name); + $this->assertSame('u', $aliased->alias); + + $schemed = new Table('users', 'u', 'public'); + $this->assertSame('public', $schemed->schema); + } + + public function testSubquerySource(): void + { + $query = new Select( + columns: [new Star()], + from: new Table('users'), + ); + $source = new SubquerySource($query, 'sub'); + + $this->assertSame($query, $source->query); + $this->assertSame('sub', $source->alias); + } + + public function testJoinClause(): void + { + $table = new Table('orders', 'o'); + $condition = new Binary( + new Column('id', 'u'), + '=', + new Column('user_id', 'o'), + ); + + $join = new JoinClause('JOIN', $table, $condition); + $this->assertSame('JOIN', $join->type); + $this->assertSame($table, $join->table); + $this->assertSame($condition, $join->condition); + + $leftJoin = new JoinClause('LEFT JOIN', $table, $condition); + $this->assertSame('LEFT JOIN', $leftJoin->type); + + $cross = new JoinClause('CROSS JOIN', $table); + $this->assertNull($cross->condition); + + $subSource = new SubquerySource( + new Select(columns: [new Star()], from: new Table('items')), + 'i', + ); + $subJoin = new JoinClause('LEFT JOIN', $subSource, $condition); + $this->assertInstanceOf(SubquerySource::class, $subJoin->table); + } + + public function testOrderByItem(): void + { + $item = new OrderByItem(new Column('name')); + $this->assertSame(OrderDirection::Asc, $item->direction); + $this->assertNull($item->nulls); + + $desc = new OrderByItem(new Column('created_at'), OrderDirection::Desc); + $this->assertSame(OrderDirection::Desc, $desc->direction); + + $nullsFirst = new OrderByItem(new Column('score'), OrderDirection::Asc, NullsPosition::First); + $this->assertSame(NullsPosition::First, $nullsFirst->nulls); + + $nullsLast = new OrderByItem(new Column('score'), OrderDirection::Desc, NullsPosition::Last); + $this->assertSame(NullsPosition::Last, $nullsLast->nulls); + } + + public function testOrderByItemEnumTypes(): void + { + $item = new OrderByItem(new Column('name'), OrderDirection::Desc, NullsPosition::First); + $this->assertInstanceOf(OrderDirection::class, $item->direction); + $this->assertInstanceOf(NullsPosition::class, $item->nulls); + $this->assertSame(OrderDirection::Desc, $item->direction); + $this->assertSame(NullsPosition::First, $item->nulls); + } + + public function testWindowDefinition(): void + { + $specification = new WindowSpecification( + partitionBy: [new Column('dept')], + orderBy: [new OrderByItem(new Column('salary'), OrderDirection::Desc)], + ); + $def = new WindowDefinition('w', $specification); + + $this->assertSame('w', $def->name); + $this->assertSame($specification, $def->specification); + } + + public function testCteDefinition(): void + { + $query = new Select( + columns: [new Star()], + from: new Table('employees'), + where: new Binary(new Column('active'), '=', new Literal(true)), + ); + + $cte = new Cte('active_employees', $query); + $this->assertSame('active_employees', $cte->name); + $this->assertSame($query, $cte->query); + $this->assertSame([], $cte->columns); + $this->assertFalse($cte->recursive); + + $cteWithCols = new Cte('ranked', $query, ['id', 'name', 'rank']); + $this->assertSame(['id', 'name', 'rank'], $cteWithCols->columns); + + $recursive = new Cte('hierarchy', $query, recursive: true); + $this->assertTrue($recursive->recursive); + } + + public function testSelect(): void + { + $select = new Select( + columns: [ + new Column('name', 'u'), + new Aliased(new Func('COUNT', [new Star()]), 'order_count'), + ], + from: new Table('users', 'u'), + joins: [ + new JoinClause( + 'LEFT JOIN', + new Table('orders', 'o'), + new Binary(new Column('id', 'u'), '=', new Column('user_id', 'o')), + ), + ], + where: new Binary(new Column('active', 'u'), '=', new Literal(true)), + groupBy: [new Column('name', 'u')], + having: new Binary( + new Func('COUNT', [new Star()]), + '>', + new Literal(5), + ), + orderBy: [new OrderByItem(new Column('name', 'u'))], + limit: new Literal(10), + offset: new Literal(0), + distinct: true, + ); + + $this->assertCount(2, $select->columns); + $this->assertInstanceOf(Table::class, $select->from); + $this->assertCount(1, $select->joins); + $this->assertNotNull($select->where); + $this->assertCount(1, $select->groupBy); + $this->assertNotNull($select->having); + $this->assertCount(1, $select->orderBy); + $this->assertNotNull($select->limit); + $this->assertNotNull($select->offset); + $this->assertTrue($select->distinct); + $this->assertSame([], $select->ctes); + $this->assertSame([], $select->windows); + } + + public function testSelectWith(): void + { + $original = new Select( + columns: [new Star()], + from: new Table('users'), + limit: new Literal(10), + distinct: false, + ); + + $modified = $original->with( + limit: new Literal(20), + distinct: true, + ); + + $this->assertNotSame($original, $modified); + $this->assertSame($original->columns, $modified->columns); + $this->assertSame($original->from, $modified->from); + $this->assertTrue($modified->distinct); + $this->assertInstanceOf(Literal::class, $modified->limit); + $this->assertSame(20, $modified->limit->value); + $this->assertInstanceOf(Literal::class, $original->limit); + $this->assertSame(10, $original->limit->value); + $this->assertFalse($original->distinct); + + $withWhere = $original->with( + where: new Binary(new Column('id'), '=', new Literal(1)), + ); + $this->assertNotNull($withWhere->where); + $this->assertNull($original->where); + + $withNullWhere = $withWhere->with(where: null); + $this->assertNull($withNullWhere->where); + + $withFrom = $original->with(from: null); + $this->assertNull($withFrom->from); + $this->assertNotNull($original->from); + + $unchanged = $original->with(); + $this->assertNotSame($original, $unchanged); + $this->assertSame($original->columns, $unchanged->columns); + $this->assertSame($original->from, $unchanged->from); + $this->assertSame($original->limit, $unchanged->limit); + + $withCtes = $original->with( + ctes: [ + new Cte('sub', new Select(columns: [new Literal(1)])), + ], + ); + $this->assertCount(1, $withCtes->ctes); + $this->assertSame([], $original->ctes); + + $withWindows = $original->with( + windows: [ + new WindowDefinition('w', new WindowSpecification( + orderBy: [new OrderByItem(new Column('id'))], + )), + ], + ); + $this->assertCount(1, $withWindows->windows); + } +} diff --git a/tests/Query/AST/ParserTest.php b/tests/Query/AST/ParserTest.php new file mode 100644 index 0000000..f3fca99 --- /dev/null +++ b/tests/Query/AST/ParserTest.php @@ -0,0 +1,755 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + public function testSimpleSelect(): void + { + $stmt = $this->parse('SELECT * FROM users'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Star::class, $stmt->columns[0]); + $this->assertNull($stmt->columns[0]->table); + $this->assertInstanceOf(Table::class, $stmt->from); + $this->assertSame('users', $stmt->from->name); + $this->assertFalse($stmt->distinct); + } + + public function testSelectColumns(): void + { + $stmt = $this->parse('SELECT name, email FROM users'); + + $this->assertCount(2, $stmt->columns); + $this->assertInstanceOf(Column::class, $stmt->columns[0]); + $this->assertSame('name', $stmt->columns[0]->name); + $this->assertInstanceOf(Column::class, $stmt->columns[1]); + $this->assertSame('email', $stmt->columns[1]->name); + } + + public function testSelectDistinct(): void + { + $stmt = $this->parse('SELECT DISTINCT country FROM users'); + + $this->assertTrue($stmt->distinct); + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Column::class, $stmt->columns[0]); + $this->assertSame('country', $stmt->columns[0]->name); + } + + public function testSelectWithAlias(): void + { + $stmt = $this->parse('SELECT name AS n, email AS e FROM users u'); + + $this->assertCount(2, $stmt->columns); + + $this->assertInstanceOf(Aliased::class, $stmt->columns[0]); + $this->assertSame('n', $stmt->columns[0]->alias); + $this->assertInstanceOf(Column::class, $stmt->columns[0]->expression); + $this->assertSame('name', $stmt->columns[0]->expression->name); + + $this->assertInstanceOf(Aliased::class, $stmt->columns[1]); + $this->assertSame('e', $stmt->columns[1]->alias); + + $this->assertInstanceOf(Table::class, $stmt->from); + $this->assertSame('users', $stmt->from->name); + $this->assertSame('u', $stmt->from->alias); + } + + public function testWhereEqual(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE id = 1'); + + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('=', $stmt->where->operator); + $this->assertInstanceOf(Column::class, $stmt->where->left); + $this->assertSame('id', $stmt->where->left->name); + $this->assertInstanceOf(Literal::class, $stmt->where->right); + $this->assertSame(1, $stmt->where->right->value); + } + + public function testWhereComplex(): void + { + $stmt = $this->parse("SELECT * FROM users WHERE age > 18 AND status = 'active' OR role = 'admin'"); + + // OR is lowest precedence, so: OR(AND(age>18, status='active'), role='admin') + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('OR', $stmt->where->operator); + + $left = $stmt->where->left; + $this->assertInstanceOf(Binary::class, $left); + $this->assertSame('AND', $left->operator); + + $right = $stmt->where->right; + $this->assertInstanceOf(Binary::class, $right); + $this->assertSame('=', $right->operator); + } + + public function testOperatorPrecedence(): void + { + // a AND b OR c => OR(AND(a, b), c) + $stmt = $this->parse("SELECT * FROM t WHERE a = 1 AND b = 2 OR c = 3"); + + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('OR', $stmt->where->operator); + + $andExpression = $stmt->where->left; + $this->assertInstanceOf(Binary::class, $andExpression); + $this->assertSame('AND', $andExpression->operator); + } + + public function testNotPrecedence(): void + { + // NOT a AND b => AND(NOT(a), b) + $stmt = $this->parse("SELECT * FROM t WHERE NOT a = 1 AND b = 2"); + + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('AND', $stmt->where->operator); + + $left = $stmt->where->left; + $this->assertInstanceOf(Unary::class, $left); + $this->assertSame('NOT', $left->operator); + $this->assertTrue($left->prefix); + } + + public function testWhereIn(): void + { + $stmt = $this->parse("SELECT * FROM users WHERE status IN ('active', 'pending')"); + + $this->assertInstanceOf(In::class, $stmt->where); + $this->assertFalse($stmt->where->negated); + $this->assertInstanceOf(Column::class, $stmt->where->expression); + $this->assertSame('status', $stmt->where->expression->name); + $this->assertIsArray($stmt->where->list); + $this->assertCount(2, $stmt->where->list); + $this->assertInstanceOf(Literal::class, $stmt->where->list[0]); + $this->assertSame('active', $stmt->where->list[0]->value); + } + + public function testWhereNotIn(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE id NOT IN (1, 2, 3)'); + + $this->assertInstanceOf(In::class, $stmt->where); + $this->assertTrue($stmt->where->negated); + $this->assertIsArray($stmt->where->list); + $this->assertCount(3, $stmt->where->list); + } + + public function testWhereBetween(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE age BETWEEN 18 AND 65'); + + $this->assertInstanceOf(Between::class, $stmt->where); + $this->assertFalse($stmt->where->negated); + $this->assertInstanceOf(Column::class, $stmt->where->expression); + $this->assertSame('age', $stmt->where->expression->name); + $this->assertInstanceOf(Literal::class, $stmt->where->low); + $this->assertSame(18, $stmt->where->low->value); + $this->assertInstanceOf(Literal::class, $stmt->where->high); + $this->assertSame(65, $stmt->where->high->value); + } + + public function testWhereNotBetween(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE id NOT BETWEEN 100 AND 200'); + + $this->assertInstanceOf(Between::class, $stmt->where); + $this->assertTrue($stmt->where->negated); + $this->assertInstanceOf(Literal::class, $stmt->where->low); + $this->assertSame(100, $stmt->where->low->value); + $this->assertInstanceOf(Literal::class, $stmt->where->high); + $this->assertSame(200, $stmt->where->high->value); + } + + public function testWhereLike(): void + { + $stmt = $this->parse("SELECT * FROM users WHERE name LIKE 'A%'"); + + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('LIKE', $stmt->where->operator); + $this->assertInstanceOf(Column::class, $stmt->where->left); + $this->assertSame('name', $stmt->where->left->name); + $this->assertInstanceOf(Literal::class, $stmt->where->right); + $this->assertSame('A%', $stmt->where->right->value); + } + + public function testWhereIsNull(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE deleted_at IS NULL'); + + $this->assertInstanceOf(Unary::class, $stmt->where); + $this->assertSame('IS NULL', $stmt->where->operator); + $this->assertFalse($stmt->where->prefix); + $this->assertInstanceOf(Column::class, $stmt->where->operand); + $this->assertSame('deleted_at', $stmt->where->operand->name); + } + + public function testWhereIsNotNull(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE verified_at IS NOT NULL'); + + $this->assertInstanceOf(Unary::class, $stmt->where); + $this->assertSame('IS NOT NULL', $stmt->where->operator); + $this->assertFalse($stmt->where->prefix); + } + + public function testJoin(): void + { + $stmt = $this->parse('SELECT * FROM users JOIN orders ON users.id = orders.user_id'); + + $this->assertCount(1, $stmt->joins); + $join = $stmt->joins[0]; + $this->assertInstanceOf(JoinClause::class, $join); + $this->assertSame('JOIN', $join->type); + $this->assertInstanceOf(Table::class, $join->table); + $this->assertSame('orders', $join->table->name); + $this->assertInstanceOf(Binary::class, $join->condition); + } + + public function testLeftJoin(): void + { + $stmt = $this->parse('SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id'); + + $this->assertCount(1, $stmt->joins); + $this->assertSame('LEFT JOIN', $stmt->joins[0]->type); + } + + public function testMultipleJoins(): void + { + $stmt = $this->parse( + 'SELECT * FROM users ' + . 'INNER JOIN orders ON users.id = orders.user_id ' + . 'LEFT JOIN items ON orders.id = items.order_id' + ); + + $this->assertCount(2, $stmt->joins); + $this->assertSame('INNER JOIN', $stmt->joins[0]->type); + $this->assertSame('LEFT JOIN', $stmt->joins[1]->type); + } + + public function testCrossJoin(): void + { + $stmt = $this->parse('SELECT * FROM users CROSS JOIN roles'); + + $this->assertCount(1, $stmt->joins); + $this->assertSame('CROSS JOIN', $stmt->joins[0]->type); + $this->assertNull($stmt->joins[0]->condition); + } + + public function testFullOuterJoin(): void + { + $stmt = $this->parse('SELECT * FROM a FULL OUTER JOIN b ON a.id = b.a_id'); + + $this->assertCount(1, $stmt->joins); + $this->assertSame('FULL OUTER JOIN', $stmt->joins[0]->type); + } + + public function testNaturalJoin(): void + { + $stmt = $this->parse('SELECT * FROM users NATURAL JOIN orders'); + + $this->assertCount(1, $stmt->joins); + $this->assertSame('NATURAL JOIN', $stmt->joins[0]->type); + $this->assertNull($stmt->joins[0]->condition); + } + + public function testOrderByAsc(): void + { + $stmt = $this->parse('SELECT * FROM users ORDER BY name ASC'); + + $this->assertCount(1, $stmt->orderBy); + $this->assertInstanceOf(OrderByItem::class, $stmt->orderBy[0]); + $this->assertSame(OrderDirection::Asc, $stmt->orderBy[0]->direction); + $this->assertInstanceOf(Column::class, $stmt->orderBy[0]->expression); + $this->assertSame('name', $stmt->orderBy[0]->expression->name); + } + + public function testOrderByDesc(): void + { + $stmt = $this->parse('SELECT * FROM users ORDER BY created_at DESC'); + + $this->assertCount(1, $stmt->orderBy); + $this->assertSame(OrderDirection::Desc, $stmt->orderBy[0]->direction); + } + + public function testOrderByMultiple(): void + { + $stmt = $this->parse('SELECT * FROM users ORDER BY status ASC, name DESC'); + + $this->assertCount(2, $stmt->orderBy); + $this->assertSame(OrderDirection::Asc, $stmt->orderBy[0]->direction); + $this->assertSame(OrderDirection::Desc, $stmt->orderBy[1]->direction); + } + + public function testOrderByNulls(): void + { + $stmt = $this->parse('SELECT * FROM users ORDER BY name ASC NULLS LAST'); + + $this->assertCount(1, $stmt->orderBy); + $this->assertSame(OrderDirection::Asc, $stmt->orderBy[0]->direction); + $this->assertSame(NullsPosition::Last, $stmt->orderBy[0]->nulls); + } + + public function testGroupBy(): void + { + $stmt = $this->parse('SELECT status, COUNT(*) FROM users GROUP BY status'); + + $this->assertCount(1, $stmt->groupBy); + $this->assertInstanceOf(Column::class, $stmt->groupBy[0]); + $this->assertSame('status', $stmt->groupBy[0]->name); + } + + public function testGroupByHaving(): void + { + $stmt = $this->parse('SELECT status, COUNT(*) FROM users GROUP BY status HAVING COUNT(*) > 5'); + + $this->assertCount(1, $stmt->groupBy); + $this->assertInstanceOf(Binary::class, $stmt->having); + $this->assertSame('>', $stmt->having->operator); + $this->assertInstanceOf(Func::class, $stmt->having->left); + $this->assertSame('COUNT', $stmt->having->left->name); + } + + public function testLimitOffset(): void + { + $stmt = $this->parse('SELECT * FROM users LIMIT 10 OFFSET 20'); + + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(10, $stmt->limit->value); + $this->assertInstanceOf(Literal::class, $stmt->offset); + $this->assertSame(20, $stmt->offset->value); + } + + public function testFunctionCall(): void + { + $stmt = $this->parse('SELECT COUNT(*) FROM users'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Func::class, $stmt->columns[0]); + $this->assertSame('COUNT', $stmt->columns[0]->name); + $this->assertCount(1, $stmt->columns[0]->arguments); + $this->assertInstanceOf(Star::class, $stmt->columns[0]->arguments[0]); + } + + public function testFunctionCallArgs(): void + { + $stmt = $this->parse("SELECT COALESCE(name, 'unknown') FROM users"); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Func::class, $stmt->columns[0]); + $this->assertSame('COALESCE', $stmt->columns[0]->name); + $this->assertCount(2, $stmt->columns[0]->arguments); + $this->assertInstanceOf(Column::class, $stmt->columns[0]->arguments[0]); + $this->assertInstanceOf(Literal::class, $stmt->columns[0]->arguments[1]); + $this->assertSame('unknown', $stmt->columns[0]->arguments[1]->value); + } + + public function testCountDistinct(): void + { + $stmt = $this->parse('SELECT COUNT(DISTINCT user_id) FROM orders'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Func::class, $stmt->columns[0]); + $this->assertSame('COUNT', $stmt->columns[0]->name); + $this->assertTrue($stmt->columns[0]->distinct); + $this->assertCount(1, $stmt->columns[0]->arguments); + $this->assertInstanceOf(Column::class, $stmt->columns[0]->arguments[0]); + $this->assertSame('user_id', $stmt->columns[0]->arguments[0]->name); + } + + public function testNestedFunctions(): void + { + $stmt = $this->parse('SELECT UPPER(TRIM(name)) FROM users'); + + $this->assertCount(1, $stmt->columns); + $outer = $stmt->columns[0]; + $this->assertInstanceOf(Func::class, $outer); + $this->assertSame('UPPER', $outer->name); + $this->assertCount(1, $outer->arguments); + + $inner = $outer->arguments[0]; + $this->assertInstanceOf(Func::class, $inner); + $this->assertSame('TRIM', $inner->name); + } + + public function testCaseSearched(): void + { + $stmt = $this->parse("SELECT CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END FROM t"); + + $this->assertCount(1, $stmt->columns); + $case = $stmt->columns[0]; + $this->assertInstanceOf(Conditional::class, $case); + $this->assertNull($case->operand); + $this->assertCount(1, $case->whens); + $this->assertInstanceOf(CaseWhen::class, $case->whens[0]); + $this->assertInstanceOf(Binary::class, $case->whens[0]->condition); + $this->assertInstanceOf(Literal::class, $case->whens[0]->result); + $this->assertSame('pos', $case->whens[0]->result->value); + $this->assertInstanceOf(Literal::class, $case->else); + $this->assertSame('neg', $case->else->value); + } + + public function testCaseSimple(): void + { + $stmt = $this->parse("SELECT CASE status WHEN 'active' THEN 1 ELSE 0 END FROM t"); + + $case = $stmt->columns[0]; + $this->assertInstanceOf(Conditional::class, $case); + $this->assertInstanceOf(Column::class, $case->operand); + $this->assertSame('status', $case->operand->name); + $this->assertCount(1, $case->whens); + $this->assertInstanceOf(Literal::class, $case->whens[0]->condition); + $this->assertSame('active', $case->whens[0]->condition->value); + $this->assertInstanceOf(Literal::class, $case->else); + $this->assertSame(0, $case->else->value); + } + + public function testCastExpression(): void + { + $stmt = $this->parse('SELECT CAST(value AS INTEGER) FROM t'); + + $this->assertCount(1, $stmt->columns); + $cast = $stmt->columns[0]; + $this->assertInstanceOf(Cast::class, $cast); + $this->assertInstanceOf(Column::class, $cast->expression); + $this->assertSame('value', $cast->expression->name); + $this->assertSame('INTEGER', $cast->type); + } + + public function testPostgresCast(): void + { + $stmt = $this->parse('SELECT value::integer FROM t'); + + $this->assertCount(1, $stmt->columns); + $cast = $stmt->columns[0]; + $this->assertInstanceOf(Cast::class, $cast); + $this->assertInstanceOf(Column::class, $cast->expression); + $this->assertSame('value', $cast->expression->name); + $this->assertSame('integer', $cast->type); + } + + public function testSubquery(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)'); + + $this->assertInstanceOf(In::class, $stmt->where); + $this->assertInstanceOf(Select::class, $stmt->where->list); + $this->assertCount(1, $stmt->where->list->columns); + } + + public function testSubqueryInFrom(): void + { + $stmt = $this->parse('SELECT * FROM (SELECT * FROM users) AS sub'); + + $this->assertInstanceOf(SubquerySource::class, $stmt->from); + $this->assertSame('sub', $stmt->from->alias); + $this->assertInstanceOf(Select::class, $stmt->from->query); + } + + public function testExistsExpression(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)'); + + $this->assertInstanceOf(Exists::class, $stmt->where); + $this->assertFalse($stmt->where->negated); + $this->assertInstanceOf(Select::class, $stmt->where->subquery); + } + + public function testNotExistsExpression(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE NOT EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)'); + + $this->assertInstanceOf(Exists::class, $stmt->where); + $this->assertTrue($stmt->where->negated); + } + + public function testPlaceholders(): void + { + $stmt = $this->parse('SELECT * FROM users WHERE id = ? AND name = :name AND seq = $1'); + + $and1 = $stmt->where; + $this->assertInstanceOf(Binary::class, $and1); + $this->assertSame('AND', $and1->operator); + + // The left side is: (id = ?) AND (name = :name) + $and2 = $and1->left; + $this->assertInstanceOf(Binary::class, $and2); + $this->assertSame('AND', $and2->operator); + + // id = ? + $eq1 = $and2->left; + $this->assertInstanceOf(Binary::class, $eq1); + $this->assertInstanceOf(Placeholder::class, $eq1->right); + $this->assertSame('?', $eq1->right->value); + + // name = :name + $eq2 = $and2->right; + $this->assertInstanceOf(Binary::class, $eq2); + $this->assertInstanceOf(Placeholder::class, $eq2->right); + $this->assertSame(':name', $eq2->right->value); + + // seq = $1 + $eq3 = $and1->right; + $this->assertInstanceOf(Binary::class, $eq3); + $this->assertInstanceOf(Placeholder::class, $eq3->right); + $this->assertSame('$1', $eq3->right->value); + } + + public function testDotNotation(): void + { + $stmt = $this->parse('SELECT u.name, u.email FROM users u'); + + $this->assertCount(2, $stmt->columns); + + $this->assertInstanceOf(Column::class, $stmt->columns[0]); + $this->assertSame('name', $stmt->columns[0]->name); + $this->assertSame('u', $stmt->columns[0]->table); + + $this->assertInstanceOf(Column::class, $stmt->columns[1]); + $this->assertSame('email', $stmt->columns[1]->name); + $this->assertSame('u', $stmt->columns[1]->table); + } + + public function testStarQualified(): void + { + $stmt = $this->parse('SELECT users.* FROM users'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Star::class, $stmt->columns[0]); + $this->assertSame('users', $stmt->columns[0]->table); + } + + public function testWindowFunction(): void + { + $stmt = $this->parse('SELECT ROW_NUMBER() OVER (PARTITION BY dept ORDER BY sal DESC) FROM employees'); + + $this->assertCount(1, $stmt->columns); + $window = $stmt->columns[0]; + $this->assertInstanceOf(Window::class, $window); + $this->assertInstanceOf(Func::class, $window->function); + $this->assertSame('ROW_NUMBER', $window->function->name); + $this->assertInstanceOf(WindowSpecification::class, $window->specification); + $this->assertCount(1, $window->specification->partitionBy); + $this->assertInstanceOf(Column::class, $window->specification->partitionBy[0]); + $this->assertSame('dept', $window->specification->partitionBy[0]->name); + $this->assertCount(1, $window->specification->orderBy); + $this->assertSame(OrderDirection::Desc, $window->specification->orderBy[0]->direction); + } + + public function testNamedWindow(): void + { + $stmt = $this->parse('SELECT SUM(amount) OVER w FROM orders WINDOW w AS (PARTITION BY user_id)'); + + $this->assertCount(1, $stmt->columns); + $window = $stmt->columns[0]; + $this->assertInstanceOf(Window::class, $window); + $this->assertSame('w', $window->windowName); + + $this->assertCount(1, $stmt->windows); + $this->assertInstanceOf(WindowDefinition::class, $stmt->windows[0]); + $this->assertSame('w', $stmt->windows[0]->name); + $this->assertCount(1, $stmt->windows[0]->specification->partitionBy); + } + + public function testCte(): void + { + $stmt = $this->parse('WITH active AS (SELECT * FROM users WHERE status = \'active\') SELECT * FROM active'); + + $this->assertCount(1, $stmt->ctes); + $cte = $stmt->ctes[0]; + $this->assertInstanceOf(Cte::class, $cte); + $this->assertSame('active', $cte->name); + $this->assertFalse($cte->recursive); + $this->assertInstanceOf(Select::class, $cte->query); + + $this->assertInstanceOf(Table::class, $stmt->from); + $this->assertSame('active', $stmt->from->name); + } + + public function testRecursiveCte(): void + { + $stmt = $this->parse( + 'WITH RECURSIVE org AS (SELECT id, name FROM employees WHERE manager_id IS NULL) SELECT * FROM org' + ); + + $this->assertCount(1, $stmt->ctes); + $this->assertTrue($stmt->ctes[0]->recursive); + $this->assertSame('org', $stmt->ctes[0]->name); + } + + public function testArithmeticExpression(): void + { + $stmt = $this->parse('SELECT price * quantity AS total FROM items'); + + $this->assertCount(1, $stmt->columns); + $aliased = $stmt->columns[0]; + $this->assertInstanceOf(Aliased::class, $aliased); + $this->assertSame('total', $aliased->alias); + $expression = $aliased->expression; + $this->assertInstanceOf(Binary::class, $expression); + $this->assertSame('*', $expression->operator); + $this->assertInstanceOf(Column::class, $expression->left); + $this->assertSame('price', $expression->left->name); + $this->assertInstanceOf(Column::class, $expression->right); + $this->assertSame('quantity', $expression->right->name); + } + + public function testParenthesizedExpression(): void + { + // (a OR b) AND c => AND(OR(a, b), c) + $stmt = $this->parse('SELECT * FROM t WHERE (a = 1 OR b = 2) AND c = 3'); + + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('AND', $stmt->where->operator); + + $left = $stmt->where->left; + $this->assertInstanceOf(Binary::class, $left); + $this->assertSame('OR', $left->operator); + } + + public function testComplexQuery(): void + { + $sql = "SELECT u.name, COUNT(o.id) AS order_count " + . "FROM users u " + . "LEFT JOIN orders o ON u.id = o.user_id " + . "WHERE u.active = 1 AND u.created_at IS NOT NULL " + . "GROUP BY u.name " + . "HAVING COUNT(o.id) > 5 " + . "ORDER BY order_count DESC " + . "LIMIT 10 OFFSET 0"; + + $stmt = $this->parse($sql); + + $this->assertCount(2, $stmt->columns); + $this->assertInstanceOf(Table::class, $stmt->from); + $this->assertSame('users', $stmt->from->name); + $this->assertSame('u', $stmt->from->alias); + $this->assertCount(1, $stmt->joins); + $this->assertSame('LEFT JOIN', $stmt->joins[0]->type); + $this->assertInstanceOf(Binary::class, $stmt->where); + $this->assertSame('AND', $stmt->where->operator); + $this->assertCount(1, $stmt->groupBy); + $this->assertInstanceOf(Binary::class, $stmt->having); + $this->assertCount(1, $stmt->orderBy); + $this->assertSame(OrderDirection::Desc, $stmt->orderBy[0]->direction); + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(10, $stmt->limit->value); + $this->assertInstanceOf(Literal::class, $stmt->offset); + $this->assertSame(0, $stmt->offset->value); + } + + public function testSelectWithoutFrom(): void + { + $stmt = $this->parse('SELECT 1 + 2'); + + $this->assertCount(1, $stmt->columns); + $this->assertNull($stmt->from); + $expression = $stmt->columns[0]; + $this->assertInstanceOf(Binary::class, $expression); + $this->assertSame('+', $expression->operator); + $this->assertInstanceOf(Literal::class, $expression->left); + $this->assertSame(1, $expression->left->value); + $this->assertInstanceOf(Literal::class, $expression->right); + $this->assertSame(2, $expression->right->value); + } + + public function testFetchFirstRows(): void + { + $stmt = $this->parse('SELECT * FROM users FETCH FIRST 10 ROWS ONLY'); + + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(10, $stmt->limit->value); + } + + public function testFetchNextRows(): void + { + $stmt = $this->parse('SELECT * FROM users FETCH NEXT 10 ROWS ONLY'); + + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(10, $stmt->limit->value); + } + + public function testFetchFirstSingularRow(): void + { + $stmt = $this->parse('SELECT * FROM users FETCH FIRST 1 ROW ONLY'); + + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(1, $stmt->limit->value); + } + + public function testFetchNextSingularRow(): void + { + $stmt = $this->parse('SELECT * FROM users FETCH NEXT 1 ROW ONLY'); + + $this->assertInstanceOf(Literal::class, $stmt->limit); + $this->assertSame(1, $stmt->limit->value); + } + + public function testBacktickIdentifierUndoublesEscapedDelimiter(): void + { + $stmt = $this->parse('SELECT `foo``bar` FROM t'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Column::class, $stmt->columns[0]); + $this->assertSame('foo`bar', $stmt->columns[0]->name); + } + + public function testDoubleQuotedIdentifierUndoublesEscapedDelimiter(): void + { + $stmt = $this->parse('SELECT "foo""bar" FROM t'); + + $this->assertCount(1, $stmt->columns); + $this->assertInstanceOf(Column::class, $stmt->columns[0]); + $this->assertSame('foo"bar', $stmt->columns[0]->name); + } + + public function testDeeplyNestedExpressionRejected(): void + { + $depth = 500; + $sql = 'SELECT ' . \str_repeat('(', $depth) . '1' . \str_repeat(')', $depth) . ' FROM t'; + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Expression nesting too deep'); + + $this->parse($sql); + } +} diff --git a/tests/Query/AST/Serializer/ClickHouseTest.php b/tests/Query/AST/Serializer/ClickHouseTest.php new file mode 100644 index 0000000..4860109 --- /dev/null +++ b/tests/Query/AST/Serializer/ClickHouseTest.php @@ -0,0 +1,50 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + private function serialize(Select $stmt): string + { + $serializer = new ClickHouse(); + return $serializer->serialize($stmt); + } + + private function roundTrip(string $sql): string + { + return $this->serialize($this->parse($sql)); + } + + public function testBacktickQuoting(): void + { + $serializer = new ClickHouse(); + $stmt = $this->parse('SELECT name, email FROM events'); + $result = $serializer->serialize($stmt); + + $this->assertSame('SELECT `name`, `email` FROM `events`', $result); + } + + public function testRoundTrip(): void + { + $result = $this->roundTrip("SELECT user_id, COUNT(*) AS cnt FROM events WHERE type = 'click' GROUP BY user_id ORDER BY cnt DESC LIMIT 100"); + + $expected = "SELECT `user_id`, COUNT(*) AS `cnt` FROM `events` WHERE `type` = 'click' GROUP BY `user_id` ORDER BY `cnt` DESC LIMIT 100"; + + $this->assertSame($expected, $result); + } +} diff --git a/tests/Query/AST/Serializer/MySQLTest.php b/tests/Query/AST/Serializer/MySQLTest.php new file mode 100644 index 0000000..17847d3 --- /dev/null +++ b/tests/Query/AST/Serializer/MySQLTest.php @@ -0,0 +1,50 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + private function serialize(Select $stmt): string + { + $serializer = new MySQL(); + return $serializer->serialize($stmt); + } + + private function roundTrip(string $sql): string + { + return $this->serialize($this->parse($sql)); + } + + public function testBacktickQuoting(): void + { + $serializer = new MySQL(); + $stmt = $this->parse('SELECT name, email FROM users'); + $result = $serializer->serialize($stmt); + + $this->assertSame('SELECT `name`, `email` FROM `users`', $result); + } + + public function testRoundTrip(): void + { + $result = $this->roundTrip("SELECT u.name, COUNT(*) AS total FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' GROUP BY u.name ORDER BY total DESC LIMIT 10"); + + $expected = "SELECT `u`.`name`, COUNT(*) AS `total` FROM `users` AS `u` LEFT JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id` WHERE `u`.`status` = 'active' GROUP BY `u`.`name` ORDER BY `total` DESC LIMIT 10"; + + $this->assertSame($expected, $result); + } +} diff --git a/tests/Query/AST/Serializer/PostgreSQLTest.php b/tests/Query/AST/Serializer/PostgreSQLTest.php new file mode 100644 index 0000000..2f28f1e --- /dev/null +++ b/tests/Query/AST/Serializer/PostgreSQLTest.php @@ -0,0 +1,50 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + private function serialize(Select $stmt): string + { + $serializer = new PostgreSQL(); + return $serializer->serialize($stmt); + } + + private function roundTrip(string $sql): string + { + return $this->serialize($this->parse($sql)); + } + + public function testDoubleQuoteIdentifiers(): void + { + $serializer = new PostgreSQL(); + $stmt = $this->parse('SELECT name, email FROM users'); + $result = $serializer->serialize($stmt); + + $this->assertSame('SELECT "name", "email" FROM "users"', $result); + } + + public function testRoundTrip(): void + { + $result = $this->roundTrip("SELECT u.name, COUNT(*) AS total FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' GROUP BY u.name ORDER BY total DESC LIMIT 10"); + + $expected = "SELECT \"u\".\"name\", COUNT(*) AS \"total\" FROM \"users\" AS \"u\" LEFT JOIN \"orders\" AS \"o\" ON \"u\".\"id\" = \"o\".\"user_id\" WHERE \"u\".\"status\" = 'active' GROUP BY \"u\".\"name\" ORDER BY \"total\" DESC LIMIT 10"; + + $this->assertSame($expected, $result); + } +} diff --git a/tests/Query/AST/Serializer/SQLiteTest.php b/tests/Query/AST/Serializer/SQLiteTest.php new file mode 100644 index 0000000..7471cdc --- /dev/null +++ b/tests/Query/AST/Serializer/SQLiteTest.php @@ -0,0 +1,74 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + private function serialize(Select $stmt): string + { + $serializer = new SQLite(); + return $serializer->serialize($stmt); + } + + private function roundTrip(string $sql): string + { + return $this->serialize($this->parse($sql)); + } + + public function testDoubleQuoteIdentifiers(): void + { + $serializer = new SQLite(); + $stmt = $this->parse('SELECT name, email FROM users'); + $result = $serializer->serialize($stmt); + + $this->assertSame('SELECT "name", "email" FROM "users"', $result); + } + + public function testDoubleQuoteEscaping(): void + { + $serializer = new SQLite(); + $stmt = $this->parse('SELECT col FROM t'); + $result = $serializer->serialize($stmt); + + $this->assertStringContainsString('"col"', $result); + $this->assertStringContainsString('"t"', $result); + } + + public function testRoundTrip(): void + { + $result = $this->roundTrip("SELECT u.name, COUNT(*) AS total FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 'active' GROUP BY u.name ORDER BY total DESC LIMIT 10"); + + $expected = "SELECT \"u\".\"name\", COUNT(*) AS \"total\" FROM \"users\" AS \"u\" LEFT JOIN \"orders\" AS \"o\" ON \"u\".\"id\" = \"o\".\"user_id\" WHERE \"u\".\"status\" = 'active' GROUP BY \"u\".\"name\" ORDER BY \"total\" DESC LIMIT 10"; + + $this->assertSame($expected, $result); + } + + public function testSimpleSelect(): void + { + $result = $this->roundTrip('SELECT id FROM users'); + + $this->assertSame('SELECT "id" FROM "users"', $result); + } + + public function testSelectWithAlias(): void + { + $result = $this->roundTrip('SELECT name AS n FROM users u'); + + $this->assertSame('SELECT "name" AS "n" FROM "users" AS "u"', $result); + } +} diff --git a/tests/Query/AST/SerializerTest.php b/tests/Query/AST/SerializerTest.php new file mode 100644 index 0000000..01a8fdb --- /dev/null +++ b/tests/Query/AST/SerializerTest.php @@ -0,0 +1,564 @@ +tokenize($sql)); + $parser = new Parser(); + return $parser->parse($tokens); + } + + private function serialize(Select $stmt): string + { + $serializer = new Serializer(); + return $serializer->serialize($stmt); + } + + private function roundTrip(string $sql): string + { + return $this->serialize($this->parse($sql)); + } + + public function testSelectStar(): void + { + $result = $this->roundTrip('SELECT * FROM users'); + $this->assertSame('SELECT * FROM `users`', $result); + } + + public function testSelectColumns(): void + { + $result = $this->roundTrip('SELECT name, email FROM users'); + $this->assertSame('SELECT `name`, `email` FROM `users`', $result); + } + + public function testSelectDistinct(): void + { + $result = $this->roundTrip('SELECT DISTINCT country FROM users'); + $this->assertSame('SELECT DISTINCT `country` FROM `users`', $result); + } + + public function testSelectAlias(): void + { + $result = $this->roundTrip('SELECT name AS n FROM users u'); + $this->assertSame('SELECT `name` AS `n` FROM `users` AS `u`', $result); + } + + public function testWhereEqual(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE id = 1'); + $this->assertSame('SELECT * FROM `users` WHERE `id` = 1', $result); + } + + public function testWhereComplex(): void + { + $result = $this->roundTrip("SELECT * FROM users WHERE age > 18 AND status = 'active'"); + $this->assertSame("SELECT * FROM `users` WHERE `age` > 18 AND `status` = 'active'", $result); + } + + public function testOperatorPrecedencePreserved(): void + { + $result = $this->roundTrip('SELECT * FROM t WHERE (a = 1 OR b = 2) AND c = 3'); + $this->assertSame('SELECT * FROM `t` WHERE (`a` = 1 OR `b` = 2) AND `c` = 3', $result); + } + + public function testWhereIn(): void + { + $result = $this->roundTrip("SELECT * FROM users WHERE status IN ('active', 'pending')"); + $this->assertSame("SELECT * FROM `users` WHERE `status` IN ('active', 'pending')", $result); + } + + public function testWhereNotIn(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE id NOT IN (1, 2, 3)'); + $this->assertSame('SELECT * FROM `users` WHERE `id` NOT IN (1, 2, 3)', $result); + } + + public function testWhereBetween(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE age BETWEEN 18 AND 65'); + $this->assertSame('SELECT * FROM `users` WHERE `age` BETWEEN 18 AND 65', $result); + } + + public function testWhereLike(): void + { + $result = $this->roundTrip("SELECT * FROM users WHERE name LIKE 'A%'"); + $this->assertSame("SELECT * FROM `users` WHERE `name` LIKE 'A%'", $result); + } + + public function testWhereIsNull(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE deleted_at IS NULL'); + $this->assertSame('SELECT * FROM `users` WHERE `deleted_at` IS NULL', $result); + } + + public function testWhereIsNotNull(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE verified_at IS NOT NULL'); + $this->assertSame('SELECT * FROM `users` WHERE `verified_at` IS NOT NULL', $result); + } + + public function testJoin(): void + { + $result = $this->roundTrip('SELECT * FROM users JOIN orders ON users.id = orders.user_id'); + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result); + } + + public function testLeftJoin(): void + { + $result = $this->roundTrip('SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id'); + $this->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result); + } + + public function testCrossJoin(): void + { + $result = $this->roundTrip('SELECT * FROM users CROSS JOIN roles'); + $this->assertSame('SELECT * FROM `users` CROSS JOIN `roles`', $result); + } + + public function testOrderBy(): void + { + $result = $this->roundTrip('SELECT * FROM users ORDER BY name ASC, created_at DESC'); + $this->assertSame('SELECT * FROM `users` ORDER BY `name` ASC, `created_at` DESC', $result); + } + + public function testOrderByNulls(): void + { + $result = $this->roundTrip('SELECT * FROM users ORDER BY name ASC NULLS LAST'); + $this->assertSame('SELECT * FROM `users` ORDER BY `name` ASC NULLS LAST', $result); + } + + public function testGroupByHaving(): void + { + $result = $this->roundTrip('SELECT status, COUNT(*) FROM users GROUP BY status HAVING COUNT(*) > 5'); + $this->assertSame('SELECT `status`, COUNT(*) FROM `users` GROUP BY `status` HAVING COUNT(*) > 5', $result); + } + + public function testLimitOffset(): void + { + $result = $this->roundTrip('SELECT * FROM users LIMIT 10 OFFSET 20'); + $this->assertSame('SELECT * FROM `users` LIMIT 10 OFFSET 20', $result); + } + + public function testFunctionCall(): void + { + $result = $this->roundTrip('SELECT COUNT(*), SUM(amount) FROM orders'); + $this->assertSame('SELECT COUNT(*), SUM(`amount`) FROM `orders`', $result); + } + + public function testCountDistinct(): void + { + $result = $this->roundTrip('SELECT COUNT(DISTINCT user_id) FROM orders'); + $this->assertSame('SELECT COUNT(DISTINCT `user_id`) FROM `orders`', $result); + } + + public function testCaseExpression(): void + { + $result = $this->roundTrip("SELECT CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END FROM t"); + $this->assertSame("SELECT CASE WHEN `x` > 0 THEN 'pos' ELSE 'neg' END FROM `t`", $result); + } + + public function testCastExpression(): void + { + $result = $this->roundTrip('SELECT CAST(val AS INTEGER) FROM t'); + $this->assertSame('SELECT CAST(`val` AS INTEGER) FROM `t`', $result); + } + + public function testSubquery(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE id IN (SELECT user_id FROM orders)'); + $this->assertSame('SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders`)', $result); + } + + public function testSubqueryFrom(): void + { + $result = $this->roundTrip('SELECT * FROM (SELECT * FROM users) AS sub'); + $this->assertSame('SELECT * FROM (SELECT * FROM `users`) AS `sub`', $result); + } + + public function testExists(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)'); + $this->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', $result); + } + + public function testWindowFunction(): void + { + $result = $this->roundTrip('SELECT ROW_NUMBER() OVER (PARTITION BY dept ORDER BY sal DESC) FROM employees'); + $this->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `dept` ORDER BY `sal` DESC) FROM `employees`', $result); + } + + public function testNamedWindow(): void + { + $result = $this->roundTrip('SELECT SUM(amount) OVER w FROM orders WINDOW w AS (PARTITION BY user_id)'); + $this->assertSame('SELECT SUM(`amount`) OVER `w` FROM `orders` WINDOW `w` AS (PARTITION BY `user_id`)', $result); + } + + public function testCte(): void + { + $result = $this->roundTrip("WITH active AS (SELECT * FROM users WHERE status = 'active') SELECT * FROM active"); + $this->assertSame("WITH `active` AS (SELECT * FROM `users` WHERE `status` = 'active') SELECT * FROM `active`", $result); + } + + public function testRecursiveCte(): void + { + $result = $this->roundTrip('WITH RECURSIVE org AS (SELECT id, name FROM employees WHERE manager_id IS NULL) SELECT * FROM org'); + $this->assertSame('WITH RECURSIVE `org` AS (SELECT `id`, `name` FROM `employees` WHERE `manager_id` IS NULL) SELECT * FROM `org`', $result); + } + + public function testArithmetic(): void + { + $result = $this->roundTrip('SELECT price * quantity FROM items'); + $this->assertSame('SELECT `price` * `quantity` FROM `items`', $result); + } + + public function testPlaceholders(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE id = ? AND name = :name AND seq = $1'); + $this->assertSame('SELECT * FROM `users` WHERE `id` = ? AND `name` = :name AND `seq` = $1', $result); + } + + public function testStringEscaping(): void + { + $result = $this->roundTrip("SELECT * FROM users WHERE name = 'O''Brien'"); + $this->assertSame("SELECT * FROM `users` WHERE `name` = 'O''Brien'", $result); + } + + public function testDirectAstConstruction(): void + { + $stmt = new Select( + columns: [ + new Aliased(new Column('name'), 'n'), + new Func('COUNT', [new Star()]), + ], + from: new Table('users', 'u'), + where: new Binary( + new Column('active'), + '=', + new Literal(true), + ), + groupBy: [new Column('name')], + having: new Binary( + new Func('COUNT', [new Star()]), + '>', + new Literal(5), + ), + orderBy: [new OrderByItem(new Column('name'), OrderDirection::Asc)], + limit: new Literal(10), + ); + + $serializer = new Serializer(); + $result = $serializer->serialize($stmt); + + $this->assertSame( + 'SELECT `name` AS `n`, COUNT(*) FROM `users` AS `u` WHERE `active` = TRUE GROUP BY `name` HAVING COUNT(*) > 5 ORDER BY `name` ASC LIMIT 10', + $result + ); + } + + public function testRoundTripComplexQuery(): void + { + $sql = "SELECT u.name, COUNT(o.id) AS order_count " + . "FROM users u " + . "LEFT JOIN orders o ON u.id = o.user_id " + . "WHERE u.active = 1 AND u.created_at IS NOT NULL " + . "GROUP BY u.name " + . "HAVING COUNT(o.id) > 5 " + . "ORDER BY order_count DESC " + . "LIMIT 10 OFFSET 0"; + + $expected = 'SELECT `u`.`name`, COUNT(`o`.`id`) AS `order_count` ' + . 'FROM `users` AS `u` ' + . 'LEFT JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id` ' + . 'WHERE `u`.`active` = 1 AND `u`.`created_at` IS NOT NULL ' + . 'GROUP BY `u`.`name` ' + . 'HAVING COUNT(`o`.`id`) > 5 ' + . 'ORDER BY `order_count` DESC ' + . 'LIMIT 10 OFFSET 0'; + + $result = $this->roundTrip($sql); + $this->assertSame($expected, $result); + } + + public function testSerializeExpressionColumnReference(): void + { + $serializer = new Serializer(); + $this->assertSame('`name`', $serializer->serializeExpression(new Column('name'))); + $this->assertSame('`t`.`name`', $serializer->serializeExpression(new Column('name', 't'))); + $this->assertSame('`s`.`t`.`name`', $serializer->serializeExpression(new Column('name', 't', 's'))); + } + + public function testSerializeExpressionLiterals(): void + { + $serializer = new Serializer(); + $this->assertSame('42', $serializer->serializeExpression(new Literal(42))); + $this->assertSame('3.14', $serializer->serializeExpression(new Literal(3.14))); + $this->assertSame("'hello'", $serializer->serializeExpression(new Literal('hello'))); + $this->assertSame('TRUE', $serializer->serializeExpression(new Literal(true))); + $this->assertSame('FALSE', $serializer->serializeExpression(new Literal(false))); + $this->assertSame('NULL', $serializer->serializeExpression(new Literal(null))); + } + + public function testSerializeExpressionStar(): void + { + $serializer = new Serializer(); + $this->assertSame('*', $serializer->serializeExpression(new Star())); + $this->assertSame('`users`.*', $serializer->serializeExpression(new Star('users'))); + } + + public function testSerializeExpressionPlaceholder(): void + { + $serializer = new Serializer(); + $this->assertSame('?', $serializer->serializeExpression(new Placeholder('?'))); + $this->assertSame(':name', $serializer->serializeExpression(new Placeholder(':name'))); + $this->assertSame('$1', $serializer->serializeExpression(new Placeholder('$1'))); + } + + public function testSerializeExpressionRaw(): void + { + $serializer = new Serializer(); + $this->assertSame('NOW()', $serializer->serializeExpression(new Raw('NOW()'))); + } + + public function testNotExistsExpression(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE NOT EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)'); + $this->assertSame('SELECT * FROM `users` WHERE NOT EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', $result); + } + + public function testNotBetween(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE age NOT BETWEEN 18 AND 65'); + $this->assertSame('SELECT * FROM `users` WHERE `age` NOT BETWEEN 18 AND 65', $result); + } + + public function testUnaryNot(): void + { + $result = $this->roundTrip('SELECT * FROM t WHERE NOT a = 1'); + $this->assertSame('SELECT * FROM `t` WHERE NOT (`a` = 1)', $result); + } + + public function testUnaryMinus(): void + { + $serializer = new Serializer(); + $expression = new Unary('-', new Literal(5)); + $this->assertSame('- (5)', $serializer->serializeExpression($expression)); + } + + public function testUnaryMinusOfNegativeLiteralDoesNotProduceDoubleDash(): void + { + $serializer = new Serializer(); + $expression = new Unary('-', new Literal(-5)); + $result = $serializer->serializeExpression($expression); + + $this->assertStringNotContainsString('--', $result); + $this->assertSame('- (-5)', $result); + } + + public function testUnaryMinusOfUnaryMinusDoesNotProduceDoubleDash(): void + { + $serializer = new Serializer(); + $expression = new Unary('-', new Unary('-', new Literal(5))); + $result = $serializer->serializeExpression($expression); + + $this->assertStringNotContainsString('--', $result); + } + + public function testSubtractionRightAssociativityParenthesized(): void + { + $serializer = new Serializer(); + $expression = new Binary( + new Column('a'), + '-', + new Binary(new Column('b'), '-', new Column('c')), + ); + $this->assertSame('`a` - (`b` - `c`)', $serializer->serializeExpression($expression)); + } + + public function testSubtractionLeftAssociativityNoExtraParens(): void + { + $serializer = new Serializer(); + $expression = new Binary( + new Binary(new Column('a'), '-', new Column('b')), + '-', + new Column('c'), + ); + $this->assertSame('`a` - `b` - `c`', $serializer->serializeExpression($expression)); + } + + public function testDivisionRightAssociativityParenthesized(): void + { + $serializer = new Serializer(); + $expression = new Binary( + new Column('a'), + '/', + new Binary(new Column('b'), '/', new Column('c')), + ); + $this->assertSame('`a` / (`b` / `c`)', $serializer->serializeExpression($expression)); + } + + public function testModuloRightAssociativityParenthesized(): void + { + $serializer = new Serializer(); + $expression = new Binary( + new Column('a'), + '%', + new Binary(new Column('b'), '%', new Column('c')), + ); + $this->assertSame('`a` % (`b` % `c`)', $serializer->serializeExpression($expression)); + } + + public function testLiteralEscapesBackslash(): void + { + $serializer = new Serializer(); + $this->assertSame("'a\\\\b'", $serializer->serializeExpression(new Literal('a\\b'))); + $this->assertSame("'trailing\\\\'", $serializer->serializeExpression(new Literal('trailing\\'))); + } + + public function testLiteralEscapesSingleQuote(): void + { + $serializer = new Serializer(); + $this->assertSame("'O''Brien'", $serializer->serializeExpression(new Literal("O'Brien"))); + } + + public function testLiteralEscapesBackslashBeforeQuote(): void + { + $serializer = new Serializer(); + $this->assertSame("'a\\\\''b'", $serializer->serializeExpression(new Literal("a\\'b"))); + } + + public function testCaseSimple(): void + { + $result = $this->roundTrip("SELECT CASE status WHEN 'active' THEN 1 ELSE 0 END FROM t"); + $this->assertSame("SELECT CASE `status` WHEN 'active' THEN 1 ELSE 0 END FROM `t`", $result); + } + + public function testWindowWithFrame(): void + { + $result = $this->roundTrip('SELECT SUM(amount) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM orders'); + $this->assertSame('SELECT SUM(`amount`) OVER (ORDER BY `created_at` ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM `orders`', $result); + } + + public function testCteWithColumns(): void + { + $result = $this->roundTrip('WITH cte (a, b) AS (SELECT 1, 2) SELECT * FROM cte'); + $this->assertSame('WITH `cte` (`a`, `b`) AS (SELECT 1, 2) SELECT * FROM `cte`', $result); + } + + public function testTableReferenceWithSchema(): void + { + $result = $this->roundTrip('SELECT * FROM public.users'); + $this->assertSame('SELECT * FROM `public`.`users`', $result); + } + + public function testSubqueryExpressionInColumn(): void + { + $result = $this->roundTrip('SELECT (SELECT COUNT(*) FROM orders) FROM users'); + $this->assertSame('SELECT (SELECT COUNT(*) FROM `orders`) FROM `users`', $result); + } + + public function testPrecedenceMultiplicationOverAddition(): void + { + $result = $this->roundTrip('SELECT a + b * c FROM t'); + $this->assertSame('SELECT `a` + `b` * `c` FROM `t`', $result); + } + + public function testPrecedenceAdditionNeedsParens(): void + { + $result = $this->roundTrip('SELECT (a + b) * c FROM t'); + $this->assertSame('SELECT (`a` + `b`) * `c` FROM `t`', $result); + } + + public function testBooleanLiterals(): void + { + $result = $this->roundTrip('SELECT * FROM t WHERE active = TRUE AND deleted = FALSE'); + $this->assertSame('SELECT * FROM `t` WHERE `active` = TRUE AND `deleted` = FALSE', $result); + } + + public function testNullLiteral(): void + { + $result = $this->roundTrip('SELECT NULL FROM t'); + $this->assertSame('SELECT NULL FROM `t`', $result); + } + + public function testFloatLiteral(): void + { + $result = $this->roundTrip('SELECT 3.14 FROM t'); + $this->assertSame('SELECT 3.14 FROM `t`', $result); + } + + public function testSelectWithoutFrom(): void + { + $result = $this->roundTrip('SELECT 1 + 2'); + $this->assertSame('SELECT 1 + 2', $result); + } + + public function testMultipleJoins(): void + { + $result = $this->roundTrip( + 'SELECT * FROM users ' + . 'INNER JOIN orders ON users.id = orders.user_id ' + . 'LEFT JOIN items ON orders.id = items.order_id' + ); + $this->assertSame( + 'SELECT * FROM `users` ' + . 'INNER JOIN `orders` ON `users`.`id` = `orders`.`user_id` ' + . 'LEFT JOIN `items` ON `orders`.`id` = `items`.`order_id`', + $result + ); + } + + public function testNestedSubquery(): void + { + $result = $this->roundTrip('SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > 100)'); + $this->assertSame( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > 100)', + $result + ); + } + + public function testOrPrecedenceNeedsParens(): void + { + $result = $this->roundTrip('SELECT * FROM t WHERE a = 1 AND b = 2 OR c = 3'); + $this->assertSame('SELECT * FROM `t` WHERE `a` = 1 AND `b` = 2 OR `c` = 3', $result); + } + + public function testFunctionCallNoArgs(): void + { + $result = $this->roundTrip('SELECT NOW() FROM t'); + $this->assertSame('SELECT NOW() FROM `t`', $result); + } + + public function testFunctionCallMultipleArgs(): void + { + $result = $this->roundTrip("SELECT COALESCE(name, 'unknown') FROM users"); + $this->assertSame("SELECT COALESCE(`name`, 'unknown') FROM `users`", $result); + } + + public function testImplicitAlias(): void + { + $result = $this->roundTrip('SELECT name n FROM users u'); + $this->assertSame('SELECT `name` AS `n` FROM `users` AS `u`', $result); + } +} diff --git a/tests/Query/AST/VisitorTest.php b/tests/Query/AST/VisitorTest.php new file mode 100644 index 0000000..07984d6 --- /dev/null +++ b/tests/Query/AST/VisitorTest.php @@ -0,0 +1,733 @@ +serialize($stmt); + } + + public function testTableRenamerSingleTable(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['users' => 'accounts']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT * FROM `accounts`', $this->serialize($result)); + } + + public function testTableRenamerInJoin(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users', 'u'), + joins: [ + new JoinClause( + 'JOIN', + new Table('orders', 'o'), + new Binary( + new Column('id', 'u'), + '=', + new Column('user_id', 'o'), + ), + ), + ], + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['orders' => 'purchases']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` AS `u` JOIN `purchases` AS `o` ON `u`.`id` = `o`.`user_id`', + $this->serialize($result), + ); + } + + public function testTableRenamerInColumnReference(): void + { + $stmt = new Select( + columns: [ + new Column('name', 'u'), + new Column('email', 'u'), + ], + from: new Table('users', 'u'), + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['u' => 'a']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT `a`.`name`, `a`.`email` FROM `users` AS `a`', $this->serialize($result)); + } + + public function testTableRenamerInStar(): void + { + $stmt = new Select( + columns: [new Star('users')], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['users' => 'accounts']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT `accounts`.* FROM `accounts`', $this->serialize($result)); + } + + public function testTableRenamerMultiple(): void + { + $stmt = new Select( + columns: [ + new Column('name', 'users'), + new Column('title', 'orders'), + ], + from: new Table('users'), + joins: [ + new JoinClause( + 'JOIN', + new Table('orders'), + new Binary( + new Column('id', 'users'), + '=', + new Column('user_id', 'orders'), + ), + ), + ], + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['users' => 'accounts', 'orders' => 'purchases']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT `accounts`.`name`, `purchases`.`title` FROM `accounts` JOIN `purchases` ON `accounts`.`id` = `purchases`.`user_id`', + $this->serialize($result), + ); + } + + public function testTableRenamerNoMatch(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['products' => 'items']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT * FROM `users`', $this->serialize($result)); + } + + public function testColumnValidatorAllowed(): void + { + $stmt = new Select( + columns: [ + new Column('name'), + new Column('email'), + ], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email', 'id']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT `name`, `email` FROM `users`', $this->serialize($result)); + } + + public function testColumnValidatorDisallowed(): void + { + $stmt = new Select( + columns: [ + new Column('name'), + new Column('password'), + ], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email', 'id']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Column 'password' is not in the allowed list"); + $walker->walk($stmt, $visitor); + } + + public function testColumnValidatorInWhere(): void + { + $stmt = new Select( + columns: [new Column('name')], + from: new Table('users'), + where: new Binary( + new Column('secret'), + '=', + new Literal('foo'), + ), + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Column 'secret' is not in the allowed list"); + $walker->walk($stmt, $visitor); + } + + public function testColumnValidatorInOrderBy(): void + { + $stmt = new Select( + columns: [new Column('name')], + from: new Table('users'), + orderBy: [ + new OrderByItem(new Column('hidden')), + ], + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage("Column 'hidden' is not in the allowed list"); + $walker->walk($stmt, $visitor); + } + + public function testColumnValidatorRejectsStarByDefault(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Wildcard (*) selection is not allowed'); + $walker->walk($stmt, $visitor); + } + + public function testColumnValidatorAllowsStarWhenOptedIn(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = new ColumnValidator(['name', 'email'], allowStar: true); + + $result = $walker->walk($stmt, $visitor); + $this->assertInstanceOf(Select::class, $result); + } + + public function testFilterInjectorEmptyWhere(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + ); + + $condition = new Binary( + new Column('active'), + '=', + new Literal(true), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame('SELECT * FROM `users` WHERE `active` = TRUE', $this->serialize($result)); + } + + public function testFilterInjectorExistingWhere(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Binary( + new Column('age'), + '>', + new Literal(18), + ), + ); + + $condition = new Binary( + new Column('active'), + '=', + new Literal(true), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` WHERE `age` > 18 AND `active` = TRUE', + $this->serialize($result), + ); + } + + public function testFilterInjectorPreservesOther(): void + { + $stmt = new Select( + columns: [new Column('name')], + from: new Table('users'), + orderBy: [new OrderByItem(new Column('name'))], + limit: new Literal(10), + ); + + $condition = new Binary( + new Column('active'), + '=', + new Literal(true), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `active` = TRUE ORDER BY `name` ASC LIMIT 10', + $this->serialize($result), + ); + } + + public function testComposedVisitors(): void + { + $stmt = new Select( + columns: [new Column('name')], + from: new Table('users'), + ); + + $walker = new Walker(); + + $result = $walker->walk($stmt, new TableRenamer(['users' => 'accounts'])); + $result = $walker->walk($result, new FilterInjector( + new Binary(new Column('active'), '=', new Literal(true)), + )); + + $this->assertSame( + 'SELECT `name` FROM `accounts` WHERE `active` = TRUE', + $this->serialize($result), + ); + } + + public function testVisitorWithSubquery(): void + { + $subquery = new Select( + columns: [new Column('id')], + from: new Table('orders'), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new In( + new Column('id'), + $subquery, + ), + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['orders' => 'purchases']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `id` FROM `purchases`)', + $this->serialize($result), + ); + } + + public function testVisitorWithCte(): void + { + $cteQuery = new Select( + columns: [new Column('id'), new Column('name')], + from: new Table('users'), + where: new Binary( + new Column('active'), + '=', + new Literal(true), + ), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('active_users'), + ctes: [ + new Cte('active_users', $cteQuery), + ], + ); + + $walker = new Walker(); + $visitor = new TableRenamer(['users' => 'accounts']); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'WITH `active_users` AS (SELECT `id`, `name` FROM `accounts` WHERE `active` = TRUE) SELECT * FROM `active_users`', + $this->serialize($result), + ); + } + + public function testWalkerRoundTrip(): void + { + $stmt = new Select( + columns: [ + new Column('name', 'u'), + new Aliased(new Func('COUNT', [new Star()]), 'total'), + ], + from: new Table('users', 'u'), + joins: [ + new JoinClause( + 'LEFT JOIN', + new Table('orders', 'o'), + new Binary( + new Column('id', 'u'), + '=', + new Column('user_id', 'o'), + ), + ), + ], + where: new Binary( + new Column('active', 'u'), + '=', + new Literal(true), + ), + groupBy: [new Column('name', 'u')], + having: new Binary( + new Func('COUNT', [new Star()]), + '>', + new Literal(5), + ), + orderBy: [new OrderByItem(new Column('name', 'u'))], + limit: new Literal(10), + offset: new Literal(0), + ); + + $before = $this->serialize($stmt); + + $walker = new Walker(); + $visitor = new TableRenamer([]); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame($before, $this->serialize($result)); + } + + private function createCollectingVisitor(): CollectingVisitor + { + return new CollectingVisitor(); + } + + public function testWalkerWithCastExpression(): void + { + $stmt = new Select( + columns: [ + new Cast(new Column('price'), 'INTEGER'), + ], + from: new Table('products'), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Cast', $visitor->visited); + $this->assertContains('Column', $visitor->visited); + $this->assertSame( + 'SELECT CAST(`price` AS INTEGER) FROM `products`', + $this->serialize($result), + ); + } + + public function testWalkerWithBetweenExpression(): void + { + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Between( + new Column('age'), + new Literal(18), + new Literal(65), + ), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Between', $visitor->visited); + $this->assertSame( + 'SELECT * FROM `users` WHERE `age` BETWEEN 18 AND 65', + $this->serialize($result), + ); + } + + public function testWalkerWithConditionalExpression(): void + { + $stmt = new Select( + columns: [ + new Conditional( + null, + [ + new CaseWhen( + new Binary(new Column('status'), '=', new Literal('active')), + new Literal(1), + ), + new CaseWhen( + new Binary(new Column('status'), '=', new Literal('inactive')), + new Literal(0), + ), + ], + new Literal(-1), + ), + ], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Conditional', $visitor->visited); + $this->assertSame( + "SELECT CASE WHEN `status` = 'active' THEN 1 WHEN `status` = 'inactive' THEN 0 ELSE -1 END FROM `users`", + $this->serialize($result), + ); + } + + public function testWalkerWithExistsExpression(): void + { + $subquery = new Select( + columns: [new Literal(1)], + from: new Table('orders'), + where: new Binary( + new Column('user_id', 'orders'), + '=', + new Column('id', 'users'), + ), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Exists($subquery), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Exists', $visitor->visited); + $this->assertSame( + 'SELECT * FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', + $this->serialize($result), + ); + } + + public function testWalkerWithWindowExpression(): void + { + $windowFunc = new Window( + new Func('ROW_NUMBER', []), + null, + new WindowSpecification( + partitionBy: [new Column('department')], + orderBy: [new OrderByItem(new Column('salary'), OrderDirection::Desc)], + ), + ); + + $stmt = new Select( + columns: [ + new Column('name'), + new Aliased($windowFunc, 'rn'), + ], + from: new Table('employees'), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Window', $visitor->visited); + $this->assertSame( + 'SELECT `name`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `rn` FROM `employees`', + $this->serialize($result), + ); + } + + public function testWalkerWithFunctionFilter(): void + { + $funcWithFilter = new Func( + 'COUNT', + [new Column('id')], + false, + new Binary(new Column('active'), '=', new Literal(true)), + ); + + $stmt = new Select( + columns: [$funcWithFilter], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Func', $visitor->visited); + $this->assertSame( + 'SELECT COUNT(`id`) FILTER (WHERE `active` = TRUE) FROM `users`', + $this->serialize($result), + ); + } + + public function testWalkerWithWindowDefinition(): void + { + $windowFunc = new Window( + new Func('ROW_NUMBER', []), + 'w', + null, + ); + + $stmt = new Select( + columns: [ + new Column('name'), + new Aliased($windowFunc, 'rn'), + ], + from: new Table('employees'), + windows: [ + new WindowDefinition( + 'w', + new WindowSpecification( + orderBy: [new OrderByItem(new Column('salary'), OrderDirection::Desc)], + ), + ), + ], + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Window', $visitor->visited); + $serialized = $this->serialize($result); + $this->assertStringContainsString('WINDOW', $serialized); + $this->assertStringContainsString('OVER `w`', $serialized); + } + + public function testWalkerWithOrderByExpressions(): void + { + $stmt = new Select( + columns: [new Column('name'), new Column('age')], + from: new Table('users'), + orderBy: [ + new OrderByItem(new Column('name'), OrderDirection::Asc, NullsPosition::First), + new OrderByItem(new Column('age'), OrderDirection::Desc, NullsPosition::Last), + ], + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $columnVisits = array_filter($visitor->visited, fn (string $type) => $type === 'Column'); + $this->assertGreaterThanOrEqual(4, count($columnVisits)); + + $serialized = $this->serialize($result); + $this->assertStringContainsString('ORDER BY `name` ASC NULLS FIRST, `age` DESC NULLS LAST', $serialized); + } + + public function testWalkerWithSubqueryExpression(): void + { + $subquery = new Subquery( + new Select( + columns: [new Func('MAX', [new Column('salary')])], + from: new Table('employees'), + ), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('employees'), + where: new Binary( + new Column('salary'), + '=', + $subquery, + ), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Subquery', $visitor->visited); + $this->assertSame( + 'SELECT * FROM `employees` WHERE `salary` = (SELECT MAX(`salary`) FROM `employees`)', + $this->serialize($result), + ); + } + + public function testWalkerWithConditionalOperand(): void + { + $stmt = new Select( + columns: [ + new Conditional( + new Column('status'), + [ + new CaseWhen(new Literal('active'), new Literal(1)), + new CaseWhen(new Literal('inactive'), new Literal(0)), + ], + null, + ), + ], + from: new Table('users'), + ); + + $walker = new Walker(); + $visitor = $this->createCollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertContains('Conditional', $visitor->visited); + $serialized = $this->serialize($result); + $this->assertStringContainsString('CASE `status`', $serialized); + } +} diff --git a/tests/Query/AST/WalkerTest.php b/tests/Query/AST/WalkerTest.php new file mode 100644 index 0000000..3b0a9f1 --- /dev/null +++ b/tests/Query/AST/WalkerTest.php @@ -0,0 +1,171 @@ +serialize($stmt); + } + + /** + * Regression: Walker must route nested Select subqueries through walk() + * so visitors hooking on visitSelect() fire on subqueries too. + * + * When a Select appears in an expression context (EXISTS, scalar subquery, + * IN (SELECT ...)), the Walker previously descended via walkStatement() + * which skips visitSelect(). FilterInjector hooks on visitSelect(), so + * its condition silently skipped the nested Select. + */ + public function testFilterInjectorAppliedToExistsSubquery(): void + { + $subquery = new Select( + columns: [new Literal(1)], + from: new Table('orders'), + where: new Binary( + new Column('user_id', 'orders'), + '=', + new Column('id', 'users'), + ), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Exists($subquery), + ); + + $condition = new Binary( + new Column('tenant_id'), + '=', + new Literal(42), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE `orders`.`user_id` = `users`.`id` AND `tenant_id` = 42) AND `tenant_id` = 42', + $this->serialize($result), + ); + } + + public function testFilterInjectorAppliedToScalarSubquery(): void + { + $subquery = new Subquery( + new Select( + columns: [new Column('max_value')], + from: new Table('limits'), + ), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new Binary( + new Column('score'), + '<', + $subquery, + ), + ); + + $condition = new Binary( + new Column('tenant_id'), + '=', + new Literal(42), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` WHERE `score` < (SELECT `max_value` FROM `limits` WHERE `tenant_id` = 42) AND `tenant_id` = 42', + $this->serialize($result), + ); + } + + public function testFilterInjectorAppliedToInSubquery(): void + { + $subquery = new Select( + columns: [new Column('id')], + from: new Table('orders'), + ); + + $stmt = new Select( + columns: [new Star()], + from: new Table('users'), + where: new In( + new Column('id'), + $subquery, + ), + ); + + $condition = new Binary( + new Column('tenant_id'), + '=', + new Literal(42), + ); + + $walker = new Walker(); + $visitor = new FilterInjector($condition); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `id` FROM `orders` WHERE `tenant_id` = 42) AND `tenant_id` = 42', + $this->serialize($result), + ); + } + + /** + * A pure-inspection visitor (one that never swaps out nodes) must return + * the exact same Select instance the caller passed in. This guarantees no + * Walker allocations for read-only traversals. + */ + public function testWalkReturnsSameInstanceWhenVisitorIsNoOp(): void + { + $stmt = new Select( + columns: [ + new Column('id'), + new Column('name'), + new Literal(1), + ], + from: new Table('users'), + where: new Binary( + new Column('age'), + '>', + new Literal(18), + ), + groupBy: [new Column('country')], + orderBy: [new OrderByItem(new Column('created_at'), OrderDirection::Desc)], + limit: new Literal(10), + offset: new Literal(5), + ); + + $walker = new Walker(); + $visitor = new CollectingVisitor(); + $result = $walker->walk($stmt, $visitor); + + $this->assertSame($stmt, $result); + $this->assertNotEmpty($visitor->visited, 'Visitor should have been invoked'); + } +} diff --git a/tests/Query/AggregationQueryTest.php b/tests/Query/AggregationQueryTest.php new file mode 100644 index 0000000..9374036 --- /dev/null +++ b/tests/Query/AggregationQueryTest.php @@ -0,0 +1,403 @@ +assertSame(Method::Count, $query->getMethod()); + $this->assertSame('*', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testCountWithAttribute(): void + { + $query = Query::count('id'); + $this->assertSame(Method::Count, $query->getMethod()); + $this->assertSame('id', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testCountWithAlias(): void + { + $query = Query::count('*', 'total'); + $this->assertSame('*', $query->getAttribute()); + $this->assertSame(['total'], $query->getValues()); + $this->assertSame('total', $query->getValue()); + } + + public function testSum(): void + { + $query = Query::sum('price'); + $this->assertSame(Method::Sum, $query->getMethod()); + $this->assertSame('price', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testSumWithAlias(): void + { + $query = Query::sum('price', 'total_price'); + $this->assertSame(['total_price'], $query->getValues()); + } + + public function testAvg(): void + { + $query = Query::avg('score'); + $this->assertSame(Method::Avg, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testMin(): void + { + $query = Query::min('price'); + $this->assertSame(Method::Min, $query->getMethod()); + $this->assertSame('price', $query->getAttribute()); + } + + public function testMax(): void + { + $query = Query::max('price'); + $this->assertSame(Method::Max, $query->getMethod()); + $this->assertSame('price', $query->getAttribute()); + } + + public function testGroupBy(): void + { + $query = Query::groupBy(['status', 'country']); + $this->assertSame(Method::GroupBy, $query->getMethod()); + $this->assertSame('', $query->getAttribute()); + $this->assertSame(['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()); + $this->assertTrue(Method::Stddev->isAggregate()); + $this->assertTrue(Method::StddevPop->isAggregate()); + $this->assertTrue(Method::StddevSamp->isAggregate()); + $this->assertTrue(Method::Variance->isAggregate()); + $this->assertTrue(Method::VarPop->isAggregate()); + $this->assertTrue(Method::VarSamp->isAggregate()); + $this->assertTrue(Method::BitAnd->isAggregate()); + $this->assertTrue(Method::BitOr->isAggregate()); + $this->assertTrue(Method::BitXor->isAggregate()); + $aggMethods = array_filter(Method::cases(), fn (Method $m) => $m->isAggregate()); + $this->assertCount(15, $aggMethods); + } + + public function testCountWithEmptyStringAttribute(): void + { + $query = Query::count(''); + $this->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testSumWithEmptyAlias(): void + { + $query = Query::sum('price', ''); + $this->assertSame([], $query->getValues()); + } + + public function testAvgWithAlias(): void + { + $query = Query::avg('score', 'avg_score'); + $this->assertSame(['avg_score'], $query->getValues()); + $this->assertSame('avg_score', $query->getValue()); + } + + public function testMinWithAlias(): void + { + $query = Query::min('price', 'min_price'); + $this->assertSame(['min_price'], $query->getValues()); + } + + public function testMaxWithAlias(): void + { + $query = Query::max('price', 'max_price'); + $this->assertSame(['max_price'], $query->getValues()); + } + + public function testGroupByEmpty(): void + { + $query = Query::groupBy([]); + $this->assertSame(Method::GroupBy, $query->getMethod()); + $this->assertSame([], $query->getValues()); + } + + public function testGroupBySingleColumn(): void + { + $query = Query::groupBy(['status']); + $this->assertSame(['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->assertSame(['status', 'status'], $query->getValues()); + } + + public function testHavingEmpty(): void + { + $query = Query::having([]); + $this->assertSame(Method::Having, $query->getMethod()); + $this->assertSame([], $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->assertSame('COUNT(`id`)', $sql); + } + + public function testSumCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::sum('price', 'total'); + $sql = $query->compile($builder); + $this->assertSame('SUM(`price`) AS `total`', $sql); + } + + public function testAvgCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::avg('score'); + $sql = $query->compile($builder); + $this->assertSame('AVG(`score`)', $sql); + } + + public function testMinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::min('price'); + $sql = $query->compile($builder); + $this->assertSame('MIN(`price`)', $sql); + } + + public function testMaxCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::max('price'); + $sql = $query->compile($builder); + $this->assertSame('MAX(`price`)', $sql); + } + + public function testStddev(): void + { + $query = Query::stddev('score'); + $this->assertSame(Method::Stddev, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testStddevCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::stddev('score'); + $sql = $query->compile($builder); + $this->assertSame('STDDEV(`score`)', $sql); + } + + public function testStddevPop(): void + { + $query = Query::stddevPop('score'); + $this->assertSame(Method::StddevPop, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testStddevPopCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::stddevPop('score', 'sd'); + $sql = $query->compile($builder); + $this->assertSame('STDDEV_POP(`score`) AS `sd`', $sql); + } + + public function testStddevSamp(): void + { + $query = Query::stddevSamp('score'); + $this->assertSame(Method::StddevSamp, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testStddevSampCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::stddevSamp('score', 'sd'); + $sql = $query->compile($builder); + $this->assertSame('STDDEV_SAMP(`score`) AS `sd`', $sql); + } + + public function testVariance(): void + { + $query = Query::variance('score'); + $this->assertSame(Method::Variance, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testVarianceCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::variance('score'); + $sql = $query->compile($builder); + $this->assertSame('VARIANCE(`score`)', $sql); + } + + public function testVarPop(): void + { + $query = Query::varPop('score'); + $this->assertSame(Method::VarPop, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testVarPopCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::varPop('score', 'vp'); + $sql = $query->compile($builder); + $this->assertSame('VAR_POP(`score`) AS `vp`', $sql); + } + + public function testVarSamp(): void + { + $query = Query::varSamp('score'); + $this->assertSame(Method::VarSamp, $query->getMethod()); + $this->assertSame('score', $query->getAttribute()); + } + + public function testVarSampCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::varSamp('score', 'vs'); + $sql = $query->compile($builder); + $this->assertSame('VAR_SAMP(`score`) AS `vs`', $sql); + } + + public function testBitAnd(): void + { + $query = Query::bitAnd('flags'); + $this->assertSame(Method::BitAnd, $query->getMethod()); + $this->assertSame('flags', $query->getAttribute()); + } + + public function testBitAndCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::bitAnd('flags', 'result'); + $sql = $query->compile($builder); + $this->assertSame('BIT_AND(`flags`) AS `result`', $sql); + } + + public function testBitOr(): void + { + $query = Query::bitOr('flags'); + $this->assertSame(Method::BitOr, $query->getMethod()); + $this->assertSame('flags', $query->getAttribute()); + } + + public function testBitOrCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::bitOr('flags', 'result'); + $sql = $query->compile($builder); + $this->assertSame('BIT_OR(`flags`) AS `result`', $sql); + } + + public function testBitXor(): void + { + $query = Query::bitXor('flags'); + $this->assertSame(Method::BitXor, $query->getMethod()); + $this->assertSame('flags', $query->getAttribute()); + } + + public function testBitXorCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::bitXor('flags', 'result'); + $sql = $query->compile($builder); + $this->assertSame('BIT_XOR(`flags`) AS `result`', $sql); + } + + public function testGroupByCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::groupBy(['status', 'country']); + $sql = $query->compile($builder); + $this->assertSame('`status`, `country`', $sql); + } + + public function testHavingCompileDispatchUsesCompileFilter(): void + { + $builder = new MySQL(); + $query = Query::having([Query::greaterThan('total', 5)]); + $sql = $query->compile($builder); + $this->assertSame('(`total` > ?)', $sql); + $this->assertSame([5], $builder->getBindings()); + } +} diff --git a/tests/Query/AssertsBindingCount.php b/tests/Query/AssertsBindingCount.php new file mode 100644 index 0000000..4aad48b --- /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('/(? */ + public array $calls = []; + + public function resolve(string $attribute): string + { + $this->calls[$attribute] = ($this->calls[$attribute] ?? 0) + 1; + + return $attribute; + } + }; + + $builder = new MySQL(); + $builder + ->from('users') + ->addHook($hook) + ->queries([ + Query::select(['id', 'name', 'age']), + Query::equal('name', ['alice']), + Query::greaterThan('age', 18), + Query::orderAsc('name'), + Query::orderDesc('age'), + ]) + ->build(); + + // Every attribute referenced should have been resolved exactly once, + // even though `name` and `age` appear in SELECT, WHERE, and ORDER BY. + foreach ($hook->calls as $attribute => $count) { + $this->assertSame( + 1, + $count, + \sprintf('Attribute %s was resolved %d times (expected 1)', $attribute, $count), + ); + } + + $this->assertNotEmpty($hook->calls, 'Hook should have been invoked at least once'); + } + + public function testMemoClearedBetweenBuilds(): void + { + $hook = new class () implements Attribute { + public int $calls = 0; + + public function resolve(string $attribute): string + { + $this->calls++; + + return $attribute; + } + }; + + $builder = new MySQL(); + $builder + ->from('users') + ->addHook($hook) + ->queries([ + Query::select(['name']), + Query::equal('name', ['alice']), + ]) + ->build(); + + $firstCalls = $hook->calls; + $this->assertGreaterThan(0, $firstCalls); + + // Second build with the same builder must re-resolve (memo cleared). + $builder->build(); + $this->assertSame( + $firstCalls * 2, + $hook->calls, + 'Memo should be cleared between builds — second build must re-resolve.', + ); + } + + public function testMemoClearedOnReset(): void + { + $hook = new class () implements Attribute { + public int $calls = 0; + + public function resolve(string $attribute): string + { + $this->calls++; + + return $attribute; + } + }; + + $builder = new MySQL(); + $builder + ->from('users') + ->addHook($hook) + ->queries([Query::select(['name'])]) + ->build(); + + $firstBuildCalls = $hook->calls; + $this->assertGreaterThan(0, $firstBuildCalls); + + // reset() preserves addHook registrations (user-installed infra) but + // must clear the memo so a fresh build re-resolves attributes rather + // than returning stale entries. + $builder->reset(); + $builder + ->from('users') + ->queries([Query::select(['name'])]) + ->build(); + + $this->assertGreaterThan( + $firstBuildCalls, + $hook->calls, + 'reset() must clear the memo so the next build re-resolves attributes.', + ); + } +} diff --git a/tests/Query/Builder/ClickHouseTest.php b/tests/Query/Builder/ClickHouseTest.php new file mode 100644 index 0000000..c2ad4a1 --- /dev/null +++ b/tests/Query/Builder/ClickHouseTest.php @@ -0,0 +1,10988 @@ +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->assertSame('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->assertSame( + 'SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `logs` WHERE match(`path`, ?)', $result->query); + $this->assertSame(['^/api/v[0-9]+'], $result->bindings); + } + + public function testSearchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search is not supported by this dialect.'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search is not supported by this dialect.'); + + (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->assertSame('SELECT * FROM `events` ORDER BY rand()', $result->query); + } + + public function testFinalKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + 'SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', + $result->query + ); + $this->assertSame(['active', 10], $result->bindings); + } + + public function testSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) AND `timestamp` > ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `events` PREWHERE `event_type` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `users`.`age` > ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `count` > ? ORDER BY `timestamp` DESC LIMIT ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT COUNT(*) AS `total`, SUM(`duration`) AS `total_duration` FROM `events` GROUP BY `event_type` HAVING COUNT(*) > ?', + $result->query + ); + $this->assertSame([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->assertSame( + '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->assertSame('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->assertSame( + '(SELECT * FROM `events` WHERE `year` IN (?)) UNION (SELECT * FROM `events_archive` WHERE `year` IN (?))', + $result->query + ); + $this->assertSame([2024, 2023], $result->bindings); + } + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertSame( + "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->assertSame('SELECT * FROM `logs`', $result->query); + $this->assertSame([], $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->assertSame( + '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->assertSame( + 'SELECT * FROM `events` WHERE `status` IN (?) AND _tenant = ?', + $result->query + ); + $this->assertSame(['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->assertSame(['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->assertSame('SELECT COUNT(*) AS `total`, `users`.`country` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`user_id` = `users`.`id` PREWHERE `event_type` IN (?) WHERE `events`.`amount` > ? GROUP BY `users`.`country` HAVING COUNT(*) > ? ORDER BY `total` DESC LIMIT ?', $query); + + // Verify ordering: PREWHERE before WHERE + $this->assertLessThan(strpos($query, 'WHERE'), strpos($query, 'PREWHERE')); + } + + public function testPrewhereEmptyArray(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testPrewhereSingleEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `status` IN (?)', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testPrewhereSingleNotEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEqual('status', 'deleted')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `status` != ?', $result->query); + $this->assertSame(['deleted'], $result->bindings); + } + + public function testPrewhereLessThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `age` < ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testPrewhereLessThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `age` <= ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testPrewhereGreaterThan(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `score` > ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testPrewhereGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `score` >= ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testPrewhereBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testPrewhereNotBetween(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notBetween('age', 0, 17)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([0, 17], $result->bindings); + } + + public function testPrewhereStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::startsWith('path', '/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE startsWith(`path`, ?)', $result->query); + $this->assertSame(['/api'], $result->bindings); + } + + public function testPrewhereNotStartsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notStartsWith('path', '/admin')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE NOT startsWith(`path`, ?)', $result->query); + $this->assertSame(['/admin'], $result->bindings); + } + + public function testPrewhereEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::endsWith('file', '.csv')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE endsWith(`file`, ?)', $result->query); + $this->assertSame(['.csv'], $result->bindings); + } + + public function testPrewhereNotEndsWith(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notEndsWith('file', '.tmp')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE NOT endsWith(`file`, ?)', $result->query); + $this->assertSame(['.tmp'], $result->bindings); + } + + public function testPrewhereContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsString('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE position(`name`, ?) > 0', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testPrewhereContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::containsString('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE (position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `events` PREWHERE (position(`tag`, ?) > 0 OR position(`tag`, ?) > 0 OR position(`tag`, ?) > 0)', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `events` PREWHERE (position(`tag`, ?) > 0 AND position(`tag`, ?) > 0)', $result->query); + $this->assertSame(['x', 'y'], $result->bindings); + } + + public function testPrewhereNotContainsSingle(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE position(`name`, ?) = 0', $result->query); + $this->assertSame(['bad'], $result->bindings); + } + + public function testPrewhereNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::notContains('name', ['bad', 'ugly'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE (position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertSame(['bad', 'ugly'], $result->bindings); + } + + public function testPrewhereIsNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `deleted_at` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testPrewhereIsNotNull(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::isNotNull('email')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `email` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testPrewhereExists(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::exists(['col_a', 'col_b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT * FROM `events` PREWHERE match(`path`, ?)', $result->query); + $this->assertSame(['^/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->assertSame('SELECT * FROM `events` PREWHERE (`a` IN (?) AND `b` IN (?))', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `events` PREWHERE (`a` IN (?) OR `b` IN (?))', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `events` PREWHERE ((`x` IN (?) OR `y` IN (?)) AND `z` > ?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `events` PREWHERE toDate(created) > ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertSame([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->assertSame( + '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->assertSame( + '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->assertSame( + 'SELECT * FROM `events` FINAL SAMPLE 0.3 PREWHERE `type` IN (?) WHERE `count` > ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `total` FROM `events` PREWHERE `type` IN (?) 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->assertSame('SELECT COUNT(*) AS `total` FROM `events` PREWHERE `type` IN (?) GROUP BY `type` HAVING COUNT(*) > ?', $result->query); + } + + public function testPrewhereWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->sortAsc('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame( + 'SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['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->assertSame('(SELECT * FROM `events` PREWHERE `type` IN (?)) UNION (SELECT * FROM `archive` WHERE `year` IN (?))', $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->assertSame('SELECT DISTINCT `user_id` FROM `events` 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->assertSame('SELECT SUM(`amount`) AS `total_amount` FROM `events` 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->assertSame(['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->assertSame('click', $result->bindings[0]); + $this->assertSame(5, $result->bindings[1]); + $this->assertSame('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->assertSame('click', $result->bindings[0]); + $this->assertSame(5, $result->bindings[1]); + $this->assertSame('t1', $result->bindings[2]); + $this->assertSame('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->assertSame('SELECT * FROM `events` PREWHERE `_uid` IN (?)', $result->query); + $this->assertSame(['abc'], $result->bindings); + } + + public function testPrewhereOnlyNoWhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::greaterThan('ts', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `ts` > ?', $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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?)', $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->assertSame( + 'SELECT * FROM `events` PREWHERE `a` IN (?) AND `b` > ? AND `c` < ?', + $result->query + ); + $this->assertSame([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->assertSame( + "SELECT * FROM `events` PREWHERE `type` IN ('click') WHERE `count` > 5", + $sql + ); + } + + public function testFinalBasicSelect(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->select(['name', 'ts']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', $result->query); + } + + public function testFinalWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` 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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` FINAL GROUP BY `type` HAVING COUNT(*) > ?', $result->query); + } + + public function testFinalWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->distinct() + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result->query); + $this->assertSame([10, 20], $result->bindings); + } + + public function testFinalWithCursor(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $result->query); + } + + public function testFinalWithUnion(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `events` FINAL) UNION (SELECT * FROM `archive`)', $result->query); + } + + public function testFinalWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT `name` FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ? ORDER BY `ts` DESC LIMIT ? OFFSET ?', $query); + } + + public function testFinalCalledMultipleTimesIdempotent(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->final() + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL', $result->query); + // Ensure FINAL appears only once + $this->assertSame(1, substr_count($result->query, 'FINAL')); + } + + public function testFinalInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->filter([Query::equal('status', ['ok'])]) + ->toRawSql(); + + $this->assertSame("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->assertSame('SELECT * FROM `events` FINAL WHERE `col_status` IN (?)', $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->assertSame('SELECT * FROM `events` FINAL WHERE 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->assertSame('SELECT * FROM `events` FINAL', $result->query); + + $result2 = (new Builder()) + ->from('events') + ->when(false, fn (Builder $b) => $b->final()) + ->build(); + + $this->assertStringNotContainsString('FINAL', $result2->query); + } + + public function testSample10Percent(): void + { + $result = (new Builder())->from('events')->sample(0.1)->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT * FROM `events` SAMPLE 0.3 JOIN `users` ON `events`.`uid` = `users`.`id`', $result->query); + } + + public function testSampleWithAggregations(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->count('*', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` SAMPLE 0.1', $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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` SAMPLE 0.5 GROUP BY `type` HAVING COUNT(*) > ?', $result->query); + } + + public function testSampleWithDistinct(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->distinct() + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT `user_id` FROM `events` SAMPLE 0.5', $result->query); + } + + public function testSampleWithSort(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->sortDesc('ts') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT * FROM `events` SAMPLE 0.5 WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $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->assertSame('(SELECT * FROM `events` SAMPLE 0.5) UNION (SELECT * FROM `archive`)', $result->query); + } + + public function testSampleWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.1) + ->prewhere([Query::equal('type', ['click'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT `name` FROM `events` SAMPLE 0.1 WHERE `count` > ? ORDER BY `ts` DESC LIMIT ?', $query); + } + + public function testSampleInToRawSql(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->filter([Query::equal('x', [1])]) + ->toRawSql(); + + $this->assertSame("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->assertSame('SELECT * FROM `events` 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->assertSame('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->assertSame('SELECT * FROM `events` SAMPLE 0.5 WHERE `r_col` IN (?)', $result->query); + } + + public function testRegexBasicPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', 'error|warn')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertSame(['error|warn'], $result->bindings); + } + + public function testRegexWithEmptyPattern(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::regex('msg', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertSame([''], $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->assertSame([$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->assertSame('SELECT * FROM `logs` WHERE match(`msg`, ?)', $result->query); + $this->assertSame([$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->assertSame( + 'SELECT * FROM `logs` WHERE match(`path`, ?) AND `status` IN (?)', + $result->query + ); + $this->assertSame(['^/api', 200], $result->bindings); + } + + public function testRegexInPrewhere(): void + { + $result = (new Builder()) + ->from('logs') + ->prewhere([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` PREWHERE match(`path`, ?)', $result->query); + $this->assertSame(['^/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->assertSame( + 'SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', + $result->query + ); + $this->assertSame(['^/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->assertSame('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->assertSame([$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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `logs` WHERE ((match(`path`, ?) OR match(`path`, ?)) AND `status` IN (?))', $result->query); + } + + public function testRegexWithFinal(): void + { + $result = (new Builder()) + ->from('logs') + ->final() + ->filter([Query::regex('path', '^/api')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` FINAL WHERE 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->assertSame('SELECT * FROM `logs` SAMPLE 0.5 WHERE match(`path`, ?)', $result->query); + } + + public function testRegexInToRawSql(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertSame("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + + public function testRegexCombinedWithContains(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([ + Query::regex('path', '^/api'), + Query::containsString('msg', ['error']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` WHERE match(`path`, ?) AND 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->assertSame('SELECT * FROM `logs` WHERE match(`path`, ?) AND 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->assertSame('SELECT * FROM `logs` PREWHERE match(`path`, ?) WHERE match(`msg`, ?)', $result->query); + $this->assertSame(['^/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->assertSame(['^/api', 'error', 'timeout'], $result->bindings); + } + + public function testSearchThrowsExceptionMessage(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search is not supported by this dialect.'); + + (new Builder()) + ->from('logs') + ->filter([Query::search('content', 'hello world')]) + ->build(); + } + + public function testNotSearchThrowsExceptionMessage(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search is not supported by this dialect.'); + + (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->assertSame('Full-text search is not supported by this dialect.', $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(); + } + + public function testRandomSortProducesLowercaseRand(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY 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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame( + '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->assertSame('SELECT * FROM `events` ORDER BY rand() LIMIT ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` WHERE `status` IN (?) ORDER BY rand()', $result->query); + } + + public function testRandomSortAlone(): void + { + $result = (new Builder()) + ->from('events') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY rand()', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testFilterEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?)', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testFilterEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('a', ['x', 'y', 'z'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?, ?, ?)', $result->query); + $this->assertSame(['x', 'y', 'z'], $result->bindings); + } + + public function testFilterNotEqualSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', 'x')])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `a` != ?', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testFilterNotEqualMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notEqual('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `a` NOT IN (?, ?)', $result->query); + $this->assertSame(['x', 'y'], $result->bindings); + } + + public function testFilterLessThanValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThan('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `a` < ?', $result->query); + $this->assertSame([10], $result->bindings); + } + + public function testFilterLessThanEqualValue(): void + { + $result = (new Builder())->from('t')->filter([Query::lessThanEqual('a', 10)])->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT * FROM `t` WHERE `a` BETWEEN ? AND ?', $result->query); + $this->assertSame([1, 10], $result->bindings); + } + + public function testFilterNotBetweenValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notBetween('a', 1, 10)])->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('SELECT * FROM `t` WHERE startsWith(`a`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testFilterNotStartsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notStartsWith('a', 'foo')])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE NOT startsWith(`a`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testFilterEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::endsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE endsWith(`a`, ?)', $result->query); + $this->assertSame(['bar'], $result->bindings); + } + + public function testFilterNotEndsWithValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notEndsWith('a', 'bar')])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE NOT endsWith(`a`, ?)', $result->query); + $this->assertSame(['bar'], $result->bindings); + } + + public function testFilterContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::containsString('a', ['foo'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE position(`a`, ?) > 0', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testFilterContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsString('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 OR position(`a`, ?) > 0)', $result->query); + $this->assertSame(['foo', 'bar'], $result->bindings); + } + + public function testFilterContainsAnyValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAny('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 OR position(`a`, ?) > 0)', $result->query); + } + + public function testFilterContainsAllValues(): void + { + $result = (new Builder())->from('t')->filter([Query::containsAll('a', ['x', 'y'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE (position(`a`, ?) > 0 AND position(`a`, ?) > 0)', $result->query); + $this->assertSame(['x', 'y'], $result->bindings); + } + + public function testFilterNotContainsSingleValue(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE position(`a`, ?) = 0', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testFilterNotContainsMultipleValues(): void + { + $result = (new Builder())->from('t')->filter([Query::notContains('a', ['foo', 'bar'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('SELECT * FROM `t` WHERE `a` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testFilterIsNotNullValue(): void + { + $result = (new Builder())->from('t')->filter([Query::isNotNull('a')])->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT * FROM `t` WHERE x > ? AND y < ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE ((`a` IN (?) OR (`b` > ? AND `c` < ?)) AND `d` IN (?))', $result->query); + } + + public function testFilterWithFloats(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('price', 9.99)])->build(); + $this->assertBindingCount($result); + $this->assertSame([9.99], $result->bindings); + } + + public function testFilterWithNegativeNumbers(): void + { + $result = (new Builder())->from('t')->filter([Query::greaterThan('temp', -40)])->build(); + $this->assertBindingCount($result); + $this->assertSame([-40], $result->bindings); + } + + public function testFilterWithEmptyStrings(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('name', [''])])->build(); + $this->assertBindingCount($result); + $this->assertSame([''], $result->bindings); + } + + public function testAggregationCountWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT AVG(`price`) AS `avg_price` FROM `events` 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->assertSame('SELECT MIN(`price`) AS `min_price` FROM `events` PREWHERE `type` IN (?) WHERE `amount` > ?', $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->assertSame('SELECT MAX(`price`) AS `max_price` FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $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->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `events` PREWHERE `type` IN (?) GROUP BY `region` HAVING COUNT(*) > ?', $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->assertSame('SELECT COUNT(*) AS `total` FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id`', $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->assertSame('SELECT DISTINCT COUNT(`user_id`) AS `unique_users` FROM `events` 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->assertSame('SELECT COUNT(*) AS `click_count` FROM `events` PREWHERE `type` IN (?)', $result->query); + } + + public function testAggregationWithoutAliasFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->count('*') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) FROM `events` FINAL', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertSame('SELECT COUNT(*) FROM `events` 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->assertSame('SELECT COUNT(*) AS `total` FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $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->assertSame('(SELECT COUNT(*) AS `total` FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?)) UNION (SELECT COUNT(*) AS `total` FROM `archive`)', $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->assertSame('SELECT SUM(`amount_cents`) AS `total` FROM `events` PREWHERE `type` IN (?)', $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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` PREWHERE `type` IN (?) WHERE 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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` FINAL PREWHERE `type` IN (?) GROUP BY `region` HAVING COUNT(*) > ?', $query); + } + + public function testJoinWithFinalFeature(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)', $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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) WHERE `users`.`age` > ?', $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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) WHERE `users`.`age` > ?', $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->assertSame('SELECT * FROM `events` LEFT JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)', $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->assertSame('SELECT * FROM `events` RIGHT JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)', $result->query); + } + + public function testCrossJoinWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->crossJoin('config') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL 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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` LEFT JOIN `sessions` ON `events`.`sid` = `sessions`.`id` PREWHERE `type` IN (?)', $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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) GROUP BY `users`.`country`', $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->assertSame(['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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` 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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) WHERE 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->assertSame('(SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)) UNION (SELECT * FROM `archive`)', $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); + } + + public function testUnionMainHasFinal(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('events') + ->final() + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `events` FINAL) 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->assertSame('(SELECT * FROM `events` SAMPLE 0.5) UNION (SELECT * FROM `archive`)', $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->assertSame('(SELECT * FROM `events` PREWHERE `type` IN (?)) UNION (SELECT * FROM `archive`)', $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->assertSame('(SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ?) UNION (SELECT * FROM `archive`)', $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->assertSame('(SELECT * FROM `events` PREWHERE `type` IN (?)) UNION ALL (SELECT * FROM `archive`)', $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->assertSame(['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->assertSame('(SELECT * FROM `events` PREWHERE `type` IN (?)) UNION (SELECT * FROM `archive1`) UNION (SELECT * FROM `archive2`)', $result->query); + $this->assertSame(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->assertSame('(SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)) UNION (SELECT * FROM `archive`)', $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->assertSame('(SELECT COUNT(*) AS `total` FROM `events` FINAL PREWHERE `type` IN (?)) UNION (SELECT COUNT(*) AS `total` FROM `archive`)', $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->assertSame('(SELECT `name`, `count` FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ? ORDER BY `count` DESC LIMIT ?) UNION (SELECT * FROM `archive` WHERE `year` IN (?))', $query); + } + + public function testToRawSqlWithFinalFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->final() + ->toRawSql(); + + $this->assertSame('SELECT * FROM `events` FINAL', $sql); + } + + public function testToRawSqlWithSampleFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->sample(0.1) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.1', $sql); + } + + public function testToRawSqlWithPrewhereFeature(): void + { + $sql = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertSame("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->assertSame( + "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->assertSame( + "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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (\'click\') WHERE `count` > 5 ORDER BY `ts` DESC LIMIT 10 OFFSET 20', $sql); + } + + public function testToRawSqlWithStringBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('name', ['hello world'])]) + ->toRawSql(); + + $this->assertSame("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->assertSame('SELECT * FROM `events` WHERE `count` > 42', $sql); + } + + public function testToRawSqlWithBooleanBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::equal('active', [true])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `events` WHERE `active` IN (1)', $sql); + } + + public function testToRawSqlWithNullBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::raw('x = ?', [null])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `events` WHERE x = NULL', $sql); + } + + public function testToRawSqlWithFloatBindings(): void + { + $sql = (new Builder()) + ->from('events') + ->filter([Query::greaterThan('price', 9.99)]) + ->toRawSql(); + + $this->assertSame('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->assertSame($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->assertSame('(SELECT * FROM `events` PREWHERE `type` IN (\'click\')) UNION (SELECT * FROM `archive` WHERE `year` IN (2023))', $sql); + } + + public function testToRawSqlWithJoinPrewhere(): void + { + $sql = (new Builder()) + ->from('events') + ->join('users', 'events.uid', 'users.id') + ->prewhere([Query::equal('type', ['click'])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (\'click\')', $sql); + } + + public function testToRawSqlWithRegexMatch(): void + { + $sql = (new Builder()) + ->from('logs') + ->filter([Query::regex('path', '^/api')]) + ->toRawSql(); + + $this->assertSame("SELECT * FROM `logs` WHERE match(`path`, '^/api')", $sql); + } + + 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->assertSame('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->assertSame('SELECT * FROM `events` WHERE `r_col` IN (?)', $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->assertSame('SELECT * FROM `events` WHERE 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->assertSame('SELECT * 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->assertSame([], $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->assertSame('SELECT * FROM `t`', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `events` PREWHERE `x` IN (?)', $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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `d`', $result->query); + $this->assertSame([], $result->bindings); + } + + 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `count` > ?', $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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.5 PREWHERE `type` IN (?)', $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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?)', $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->assertSame('SELECT * FROM `events` FINAL WHERE `status` IN (?)', $result->query); + } + + 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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE 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->assertSame('SELECT * FROM `events` FINAL WHERE 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->assertSame('SELECT * FROM `events` SAMPLE 0.5 WHERE 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->assertSame(['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->assertSame(['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->assertSame('click', $result->bindings[0]); + $this->assertSame('t1', $result->bindings[1]); + $this->assertSame('cur1', $result->bindings[2]); + $this->assertSame(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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ? AND 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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` PREWHERE `type` IN (?) WHERE 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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) WHERE 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->assertSame('SELECT * FROM `events` FINAL WHERE events.deleted = ?', $result->query); + } + + public function testCursorAfterWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `_cursor` < ? ORDER BY `_cursor` ASC', $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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `count` > ? AND `_cursor` > ? ORDER BY `_cursor` ASC', $result->query); + } + + public function testCursorWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $result->query); + } + + public function testCursorWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.5 WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $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->assertSame('click', $result->bindings[0]); + $this->assertSame('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->assertSame('click', $result->bindings[0]); + $this->assertSame('t1', $result->bindings[1]); + $this->assertSame('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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ? AND `_cursor` > ? ORDER BY `_cursor` ASC LIMIT ?', $query); + } + + public function testPageWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->page(2, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) LIMIT ? OFFSET ?', $result->query); + $this->assertSame(['click', 25, 25], $result->bindings); + } + + public function testPageWithFinal(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL LIMIT ? OFFSET ?', $result->query); + $this->assertSame([10, 20], $result->bindings); + } + + public function testPageWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->page(1, 50) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.5 LIMIT ? OFFSET ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) LIMIT ? 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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `count` > ? ORDER BY `ts` DESC LIMIT ? OFFSET ?', $query); + } + + 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->assertSame($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->assertSame($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->assertSame('SELECT * FROM `logs` SAMPLE 0.5', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + } + + 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->assertSame('(SELECT DISTINCT COUNT(*) AS `cnt`, `name` FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` PREWHERE `type` IN (?) WHERE `count` > ? GROUP BY `name` HAVING COUNT(*) > ? ORDER BY `cnt` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive`)', $query); + } + + 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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `status` IN (?) ORDER BY `ts` DESC 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->assertSame('SELECT * FROM `events` FINAL WHERE `status` IN (?) LIMIT ?', $result->query); + } + + public function testQueriesMethodWithSample(): void + { + $result = (new Builder()) + ->from('events') + ->sample(0.5) + ->queries([ + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.5 WHERE `status` IN (?)', $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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?) WHERE `status` IN (?) ORDER BY `ts` DESC LIMIT ?', $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->assertSame($resultA->query, $resultB->query); + $this->assertSame($resultA->bindings, $resultB->bindings); + } + + public function testEmptyTableNameWithFinal(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->final() + ->build(); + } + + public function testEmptyTableNameWithSample(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->sample(0.5) + ->build(); + } + + public function testPrewhereWithEmptyFilterValues(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE 1 = 0', $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->assertSame($result1->query, $result2->query); + $this->assertSame($result2->query, $result3->query); + $this->assertSame($result1->bindings, $result2->bindings); + $this->assertSame($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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `type` IN (?)', $result2->query); + + // Bindings are consistent + $this->assertSame($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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `count` > ?', $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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 JOIN `users` ON `events`.`uid` = `users`.`id` LEFT JOIN `sessions` ON `events`.`sid` = `sessions`.`id`', $query); + + // FINAL SAMPLE appears before JOINs + $finalSamplePos = strpos($query, 'FINAL SAMPLE 0.1'); + $joinPos = strpos($query, 'JOIN'); + $this->assertLessThan($joinPos, $finalSamplePos); + } + + 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(); + } + + 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->assertSame('SELECT * FROM `t` SAMPLE 0.001', $result->query); + } + + public function testCompileFilterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('age', 18)); + $this->assertSame('`age` > ?', $sql); + $this->assertSame([18], $builder->getBindings()); + } + + public function testCompileOrderAscStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertSame('`name` ASC', $sql); + } + + public function testCompileOrderDescStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertSame('`name` DESC', $sql); + } + + public function testCompileOrderRandomStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertSame('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->assertSame('LIMIT ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(5)); + $this->assertSame('OFFSET ?', $sql); + $this->assertSame([5], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b'])); + $this->assertSame('`a`, `b`', $sql); + } + + public function testCompileSelectEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select([])); + $this->assertSame('', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertSame('`_cursor` > ?', $sql); + $this->assertSame(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertSame('`_cursor` < ?', $sql); + $this->assertSame(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertSame('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertSame('SUM(`price`)', $sql); + } + + public function testCompileAggregateAvgWithAliasStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertSame('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertSame('`status`, `country`', $sql); + } + + public function testCompileGroupByEmptyStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy([])); + $this->assertSame('', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'u.id', 'o.uid')); + $this->assertSame('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])); + } + + 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->assertSame('(SELECT * FROM `events` FINAL PREWHERE `type` IN (?) WHERE `count` > ?) UNION (SELECT * FROM `archive` FINAL SAMPLE 0.5 WHERE `status` IN (?))', $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->assertSame('(SELECT * FROM `a` FINAL) UNION ALL (SELECT * FROM `b` FINAL)', $result->query); + } + + 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->assertSame(['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->assertSame(['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->assertSame(['a', 3, 30, 10], $result->bindings); + } + + 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(); + } + + public function testLeftJoinWithFinalAndSample(): void + { + $result = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->leftJoin('users', 'events.uid', 'users.id') + ->build(); + $this->assertBindingCount($result); + $this->assertSame( + '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->assertSame('SELECT * FROM `events` FINAL RIGHT JOIN `users` ON `events`.`uid` = `users`.`id`', $result->query); + } + + public function testCrossJoinWithPrewhereFeature(): void + { + $result = (new Builder())->from('events') + ->crossJoin('colors') + ->prewhere([Query::equal('type', ['a'])]) + ->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` CROSS JOIN `colors` PREWHERE `type` IN (?)', $result->query); + $this->assertSame(['a'], $result->bindings); + } + + public function testJoinWithNonDefaultOperator(): void + { + $result = (new Builder())->from('t') + ->join('other', 'a', 'b', '!=') + ->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` JOIN `other` ON `a` != `b`', $result->query); + } + + 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->assertSame('SELECT * FROM `t` PREWHERE `type` IN (?) 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->assertSame('SELECT * FROM `t` WHERE _deleted = ?', $result->query); + $this->assertSame([0], $result->bindings); + } + + 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->assertSame([25, 24999975], $result->bindings); + } + + public function testBuildWithoutFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->filter([Query::equal('x', [1])])->build(); + } + + public function testToRawSqlWithFinalAndSampleEdge(): void + { + $sql = (new Builder())->from('events') + ->final() + ->sample(0.1) + ->filter([Query::equal('type', ['click'])]) + ->toRawSql(); + $this->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.1 WHERE `type` IN (\'click\')', $sql); + } + + public function testToRawSqlWithPrewhereEdge(): void + { + $sql = (new Builder())->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->toRawSql(); + $this->assertSame('SELECT * FROM `events` PREWHERE `type` IN (\'click\') WHERE `count` > 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->assertSame('(SELECT * FROM `a` FINAL WHERE `y` IN (2)) UNION (SELECT * FROM `b` WHERE `x` IN (1))', $sql); + } + + public function testToRawSqlWithBoolFalse(): void + { + $sql = (new Builder())->from('t')->filter([Query::equal('active', [false])])->toRawSql(); + $this->assertSame('SELECT * FROM `t` WHERE `active` IN (0)', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t')->filter([Query::raw('col = ?', [null])])->toRawSql(); + $this->assertSame('SELECT * FROM `t` WHERE col = 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->assertSame('SELECT * FROM `t` WHERE `name` IN (\'str\') AND `age` > 42 AND `score` < 9.99', $sql); + } + + 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->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `status` HAVING COUNT(*) > ? AND COUNT(*) < ?', $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->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `status` HAVING (`total` > ? OR `total` < ?)', $result->query); + } + + 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->assertSame('SELECT * FROM `other`', $result->query); + $this->assertSame([], $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->assertSame('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->assertSame('SELECT * FROM `other` WHERE _tenant = ?', $result->query); + $this->assertStringNotContainsString('FINAL', $result->query); + $this->assertSame('SELECT * FROM `other` WHERE _tenant = ?', $result->query); + } + + 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->assertSame( + 'SELECT * FROM `events` FINAL SAMPLE 0.1 PREWHERE `event_type` IN (?) WHERE `amount` > ? ORDER BY `amount` DESC LIMIT ?', + $result->query + ); + $this->assertSame(['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->assertSame( + '(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 COUNT(*) > ? ORDER BY `total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` FINAL WHERE `status` IN (?))', + $result->query + ); + $this->assertSame(['purchase', 100, 5, 50, 10, 'closed'], $result->bindings); + } + + public function testQueryCompileFilterViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::greaterThan('age', 18)->compile($builder); + $this->assertSame('`age` > ?', $sql); + } + + public function testQueryCompileRegexViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::regex('path', '^/api')->compile($builder); + $this->assertSame('match(`path`, ?)', $sql); + } + + public function testQueryCompileOrderRandomViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::orderRandom()->compile($builder); + $this->assertSame('rand()', $sql); + } + + public function testQueryCompileLimitViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::limit(10)->compile($builder); + $this->assertSame('LIMIT ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testQueryCompileSelectViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::select(['a', 'b'])->compile($builder); + $this->assertSame('`a`, `b`', $sql); + } + + public function testQueryCompileJoinViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::join('orders', 'u.id', 'o.uid')->compile($builder); + $this->assertSame('JOIN `orders` ON `u`.`id` = `o`.`uid`', $sql); + } + + public function testQueryCompileGroupByViaClickHouse(): void + { + $builder = new Builder(); + $sql = Query::groupBy(['status'])->compile($builder); + $this->assertSame('`status`', $sql); + } + + 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->assertSame('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->assertSame('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->assertSame('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->assertSame('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); + } + + 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->assertSame('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([-1], $result->bindings); + } + + public function testNegativeOffset(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->offset(-5)->build(); + } + + + public function testLimitZero(): void + { + $result = (new Builder())->from('t')->limit(0)->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([0], $result->bindings); + } + + public function testMultipleLimitsFirstWins(): void + { + $result = (new Builder())->from('t')->limit(10)->limit(20)->build(); + $this->assertBindingCount($result); + $this->assertSame([10], $result->bindings); + } + + public function testMultipleOffsetsFirstWins(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->offset(5)->offset(50)->build(); + } + + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->sortAsc('_cursor')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $result->query); + } + + public function testDistinctWithUnion(): void + { + $other = (new Builder())->from('b'); + $result = (new Builder())->from('a')->distinct()->union($other)->build(); + $this->assertBindingCount($result); + $this->assertSame('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'click', 'timestamp' => '2024-01-01']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `events` (`name`, `timestamp`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'INSERT INTO `events` (`name`, `ts`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['click', '2024-01-01', 'view', '2024-01-02'], $result->bindings); + } + + public function testDoesNotImplementUpsert(): void + { + $interfaces = \class_implements(Builder::class); + $this->assertIsArray($interfaces); + $this->assertArrayNotHasKey(Upsert::class, $interfaces); + } + + public function testUpdateUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['old'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'ALTER TABLE `events` UPDATE `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertSame(['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(); + } + + public function testDeleteUsesAlterTable(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::lessThan('timestamp', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` DELETE WHERE `timestamp` < ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'ALTER TABLE `events` DELETE WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertSame(['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(); + } + + public function testIntersect(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '(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->assertSame( + '(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', + $result->query + ); + } + + 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); + } + + 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->assertSame( + 'INSERT INTO `archived_events` (`name`, `timestamp`) SELECT `name`, `timestamp` FROM `events` WHERE `type` IN (?)', + $result->query + ); + $this->assertSame(['click'], $result->bindings); + } + + 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->assertSame( + 'WITH `clicks` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `clicks`', + $result->query + ); + $this->assertSame(['click'], $result->bindings); + } + + public function testSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('events') + ->setRaw('count', 'count + ?', [1]) + ->filter([Query::equal('id', [42])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` UPDATE `count` = count + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertSame([1, 42], $result->bindings); + } + + 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` 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->assertSame('SELECT * FROM `events` SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + + 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->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `timestamp` ASC) AS `rn` FROM `events`', $result->query); + } + + 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 + } + + 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->assertSame('SELECT * FROM `t` PREWHERE `status` IN (?)', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t` PREWHERE `status` IN (?) AND `age` > ?', $result->query); + $this->assertSame(['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->assertSame(['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->assertSame('SELECT * 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->assertSame('SELECT * 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->assertSame('SELECT * 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->assertSame( + 'ALTER TABLE `t` UPDATE `name` = ? WHERE `id` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame('ALTER TABLE `t` UPDATE `counter` = `counter` + 1 WHERE `id` IN (?)', $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->assertSame('ALTER TABLE `t` UPDATE `name` = CONCAT(?, ?) WHERE `id` IN (?)', $result->query); + $this->assertSame(['hello', ' world', 1], $result->bindings); + } + + public function testDeleteAlterTableSyntax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `t` DELETE WHERE `id` IN (?)', + $result->query + ); + $this->assertSame([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->assertSame('ALTER TABLE `t` DELETE WHERE `status` IN (?) AND `age` < ?', $result->query); + $this->assertSame(['old', 5], $result->bindings); + } + + public function testStartsWithUsesStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE startsWith(`name`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testNotStartsWithUsesNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE NOT startsWith(`name`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testEndsWithUsesEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE endsWith(`name`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testNotEndsWithUsesNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE NOT endsWith(`name`, ?)', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testContainsSingleValueUsesPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE position(`name`, ?) > 0', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testContainsMultipleValuesUsesOrPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (position(`name`, ?) > 0 OR position(`name`, ?) > 0)', $result->query); + $this->assertSame(['foo', 'bar'], $result->bindings); + } + + public function testContainsAllUsesAndPosition(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('name', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (position(`name`, ?) > 0 AND position(`name`, ?) > 0)', $result->query); + $this->assertSame(['foo', 'bar'], $result->bindings); + } + + public function testNotContainsSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['foo'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE position(`name`, ?) = 0', $result->query); + $this->assertSame(['foo'], $result->bindings); + } + + public function testNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('name', ['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (position(`name`, ?) = 0 AND position(`name`, ?) = 0)', $result->query); + $this->assertSame(['a', 'b'], $result->bindings); + } + + public function testRegexUsesMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE match(`name`, ?)', $result->query); + $this->assertSame(['^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->assertSame('SELECT * FROM `t` 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->assertSame('SELECT * FROM `t` 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->assertSame(['active'], $result->bindings); + $this->assertSame('SELECT * FROM `t` WHERE `status` IN (?) 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->assertSame('SELECT * FROM `t` JOIN `u` ON `t`.`uid` = `u`.`id` 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->assertSame( + 'WITH `sub` AS (SELECT * FROM `events` WHERE `type` IN (?)) SELECT * FROM `sub`', + $result->query + ); + $this->assertSame(['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->assertSame('WITH RECURSIVE `tree` AS (SELECT * FROM `categories` WHERE `parent_id` IN (?)) SELECT * FROM `tree`', $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->assertSame(['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->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` ASC) AS `rn` FROM `t`', $result->query); + } + + public function testWindowFunctionOrderDescending(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` DESC) AS `rn` FROM `t`', $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->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `user_id` ORDER BY `created_at` ASC) AS `rn`, SUM(`amount`) OVER (PARTITION BY `user_id`) AS `total` FROM `t`', $result->query); + } + + public function testSelectCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->else('Unknown') + ->alias('label'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CASE WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `t`', $result->query); + $this->assertSame(['active', 'Active', 'Unknown'], $result->bindings); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseExpression()) + ->when('role', Operator::Equal, 'admin', 'Admin') + ->else('User'); + + $result = (new Builder()) + ->from('t') + ->setCase('label', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `t` UPDATE `label` = CASE WHEN `role` = ? THEN ? ELSE ? END WHERE `id` IN (?)', $result->query); + $this->assertSame(['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->assertSame('(SELECT * FROM `a`) UNION (SELECT * FROM `b`)', $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->assertSame('(SELECT * FROM `a`) UNION ALL (SELECT * FROM `b`)', $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->assertSame([1, 2], $result->bindings); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(2, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([25, 25], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->sortAsc('_cursor') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` > ? ORDER BY `_cursor` ASC', $result->query); + $this->assertSame(['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->assertSame( + 'INSERT INTO `t` (`name`, `age`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` LEFT JOIN `u` ON `t`.`uid` = `u`.`id` 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->assertSame('SELECT * FROM `t` FINAL WHERE `status` IN (\'active\') LIMIT 10', $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->assertSame([], $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->assertSame('SELECT * FROM `t` WHERE 1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `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->assertSame('SELECT * FROM `t` WHERE (`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->assertSame('SELECT * FROM `t` WHERE `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->assertSame('SELECT * FROM `t` WHERE (`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->assertSame('SELECT * FROM `t` WHERE (`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->assertSame('SELECT * FROM `t` WHERE ((`age` > ? AND `age` < ?) OR (`score` > ? AND `score` < ?))', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([0, 50], $result->bindings); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`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->assertSame('SELECT * FROM `t` WHERE (`name` IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 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->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['events.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `events`.`name` FROM `t`', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` 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->assertSame('SELECT DISTINCT `name` FROM `t`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`amount`) AS `total` FROM `t`', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t`', $result->query); + } + + public function testIsNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `deleted_at` IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `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->assertSame('SELECT * FROM `t` WHERE `age` < ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` <= ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` > ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testRightJoin(): void + { + $result = (new Builder()) + ->from('a') + ->rightJoin('b', 'a.id', 'b.a_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `a` 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->assertSame('SELECT * FROM `a` 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->assertSame(['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->assertSame([1, 'active'], $result->bindings); + } + + public function testSortRandomUsesRand(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` ORDER BY rand()', $result->query); + } + + public function testTableAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` AS `e`', $result->query); + } + + public function testTableAliasWithFinal(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * 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->assertSame('SELECT * 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->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.5 AS `e`', $result->query); + } + + 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->assertSame( + '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->assertSame('SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders`)', $result->query); + } + + public function testOrderByRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->orderByRaw('toDate(`created_at`) ASC') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` 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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY toDate(`created_at`)', $result->query); + } + + public function testCountDistinctClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `events`', + $result->query + ); + } + + 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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id`', $result->query); + } + + 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->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT `id` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', $result->query); + } + + 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); + } + + public function testCrossJoinAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->crossJoin('dates', 'd') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` CROSS JOIN `dates` AS `d`', $result->query); + } + + 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->assertSame('SELECT * FROM `events` WHERE `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->assertSame('SELECT * FROM `events` WHERE `user_id` NOT IN (SELECT `id` FROM `banned_users`)', $result->query); + } + + public function testSelectSubClickHouse(): void + { + $sub = (new Builder())->from('events')->select('COUNT(*)'); + + $result = (new Builder()) + ->from('users') + ->selectSub($sub, 'event_count') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT (SELECT COUNT(*) FROM `events`) AS `event_count` FROM `users`', $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->assertSame('SELECT `user_id` FROM (SELECT `user_id` FROM `events` GROUP BY `user_id`) AS `sub`', $result->query); + } + + public function testFilterNotExistsClickHouse(): void + { + $sub = (new Builder())->from('banned')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE NOT EXISTS (SELECT `id` FROM `banned`)', $result->query); + } + + public function testHavingRawClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->havingRaw('COUNT(*) > ?', [10]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `user_id` HAVING COUNT(*) > ?', $result->query); + $this->assertSame([10], $result->bindings); + } + + public function testWhereRawAppendsFragmentAndBindings(): void + { + $result = (new Builder()) + ->from('events') + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE a = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testWhereRawCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('b', [2])]) + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE `b` IN (?) AND a = ?', $result->query); + $this->assertContains(1, $result->bindings); + $this->assertContains(2, $result->bindings); + } + + public function testTableAliasWithFinalSampleAndAlias(): void + { + $result = (new Builder()) + ->from('events', 'e') + ->final() + ->sample(0.5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` FINAL SAMPLE 0.5 AS `e`', $result->query); + } + + 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->assertSame('SELECT * FROM `events` LEFT JOIN `users` ON `events`.`user_id` = `users`.`id` AND users.active = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + 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->assertSame('SELECT * FROM `events` AS `e` JOIN `users` AS `u` ON `e`.`user_id` = `u`.`id`', $result->query); + } + + 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->assertSame('SELECT * FROM `events` JOIN `users` ON `events`.`user_id` = `users`.`id` AND `events`.`tenant_id` = `users`.`tenant_id`', $result->query); + } + + public function testExplainPreservesBindings(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testCountDistinctWithoutAliasClickHouse(): void + { + $result = (new Builder()) + ->from('events') + ->countDistinct('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(DISTINCT `user_id`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + 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->assertSame('SELECT * FROM `events` WHERE `user_id` IN (SELECT `id` FROM `active_users`) AND `user_id` NOT IN (SELECT `id` FROM `banned_users`)', $result->query); + } + + 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->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `user_id` IN (SELECT `id` FROM `active_users`)', $result->query); + } + + public function testSettingsStillAppear(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']) + ->orderByRaw('`created_at` DESC') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `created_at` DESC SETTINGS max_threads=4', $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->assertSame(['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->assertSame([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->assertSame([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->assertSame([], $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->assertSame([], $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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame([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->assertSame(['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->assertSame(['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->assertSame([], $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 COUNT(*) > ? ORDER BY `order_count` DESC', + $result->query + ); + $this->assertSame([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->assertSame([1], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactExistsSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select('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->assertSame([], $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->assertSame([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->assertSame([], $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->assertSame(['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->assertSame([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->assertSame([20, 40], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('status_label'); + + $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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame([], $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->assertSame(['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->assertSame([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->assertSame(['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->assertSame([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->assertSame([], $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->assertSame([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->assertSame(['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->assertSame(['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->assertSame(['@example.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsSingle(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::containsString('title', ['php'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE position(`title`, ?) > 0', + $result->query + ); + $this->assertSame(['php'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAdvancedContainsMultiple(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['id', 'title']) + ->filter([Query::containsString('title', ['php', 'laravel'])]) + ->build(); + + $this->assertSame( + 'SELECT `id`, `title` FROM `articles` WHERE (position(`title`, ?) > 0 OR position(`title`, ?) > 0)', + $result->query + ); + $this->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['^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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame([], $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->assertSame([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->assertSame(['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->assertSame([], $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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testImplementsConditionalAggregates(): void + { + $this->assertInstanceOf(ConditionalAggregates::class, new Builder()); + } + + public function testImplementsTableSampling(): void + { + $this->assertInstanceOf(TableSampling::class, new Builder()); + } + + public function testImplementsFullOuterJoins(): void + { + $this->assertInstanceOf(FullOuterJoins::class, new Builder()); + } + + public function testHintValidSetting(): void + { + $result = (new Builder()) + ->from('events') + ->hint('max_threads=4') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SETTINGS max_threads=4', $result->query); + } + + public function testHintInvalidThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('events') + ->hint('DROP TABLE;--') + ->build(); + } + + public function testSettingsMultiple(): void + { + $result = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4', 'max_memory_usage' => '1000000']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SETTINGS max_threads=4, max_memory_usage=1000000', $result->query); + } + + public function testSettingsInvalidKeyThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('events') + ->settings(['1invalid' => '4']) + ->build(); + } + + public function testSettingsInvalidValueThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('events') + ->settings(['max_threads' => 'DROP;']) + ->build(); + } + + public function testTableSampleDelegatesToSample(): void + { + $result = (new Builder()) + ->from('events') + ->tablesample(10.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.1', $result->query); + } + + public function testCountWhenWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->countWhen('status = ?', 'active_count', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT countIf(status = ?) AS `active_count` FROM `events`', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testCountWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->countWhen('status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT countIf(status = ?) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSumWhenWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->sumWhen('amount', 'status = ?', 'active_total', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT sumIf(`amount`, status = ?) AS `active_total` FROM `events`', $result->query); + } + + public function testSumWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->sumWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAvgWhenWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->avgWhen('amount', 'status = ?', 'avg_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT avgIf(`amount`, status = ?) AS `avg_active` FROM `events`', $result->query); + } + + public function testAvgWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->avgWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMinWhenWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->minWhen('amount', 'status = ?', 'min_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT minIf(`amount`, status = ?) AS `min_active` FROM `events`', $result->query); + } + + public function testMinWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->minWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMaxWhenWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->maxWhen('amount', 'status = ?', 'max_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT maxIf(`amount`, status = ?) AS `max_active` FROM `events`', $result->query); + } + + public function testMaxWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->maxWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testFullOuterJoinBasic(): void + { + $result = (new Builder()) + ->from('users') + ->fullOuterJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` FULL OUTER JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + } + + public function testFullOuterJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->fullOuterJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` FULL OUTER JOIN `orders` AS `o` ON `users`.`id` = `o`.`user_id`', $result->query); + } + + public function testSampleValidationZeroThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('events')->sample(0.0)->build(); + } + + public function testSampleValidationOneThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('events')->sample(1.0)->build(); + } + + public function testSettingsWithBuild(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `events` WHERE `status` IN (?) SETTINGS max_threads=2', + $result->query + ); + } + + public function testLikeFallback(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::containsString('name', ['mid'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE position(`name`, ?) > 0', $result->query); + } + + public function testResetClearsSettings(): void + { + $builder = (new Builder()) + ->from('events') + ->settings(['max_threads' => '4']); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('SETTINGS', $result->query); + } + + public function testCteJoinWhereGroupByHavingOrderLimit(): void + { + $cte = (new Builder()) + ->from('raw_events') + ->select(['user_id', 'amount']) + ->filter([Query::greaterThan('amount', 0)]); + + $result = (new Builder()) + ->with('filtered', $cte) + ->from('filtered') + ->join('users', 'filtered.user_id', 'users.id') + ->filter([Query::equal('users.status', ['active'])]) + ->sum('filtered.amount', 'total') + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 100)]) + ->sortDesc('total') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `filtered` AS (SELECT `user_id`, `amount` FROM `raw_events` WHERE `amount` > ?) SELECT SUM(`filtered`.`amount`) AS `total` FROM `filtered` JOIN `users` ON `filtered`.`user_id` = `users`.`id` WHERE `users`.`status` IN (?) GROUP BY `users`.`country` HAVING SUM(`filtered`.`amount`) > ? ORDER BY `total` DESC LIMIT ?', $result->query); + } + + public function testMultipleCTEsWithComplexQuery(): void + { + $cte1 = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->sum('total', 'order_total') + ->groupBy(['customer_id']); + + $cte2 = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('active', [1])]); + + $result = (new Builder()) + ->with('order_totals', $cte1) + ->with('active_customers', $cte2) + ->from('order_totals') + ->join('active_customers', 'order_totals.customer_id', 'active_customers.id') + ->filter([Query::greaterThan('order_total', 500)]) + ->sortDesc('order_total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `order_totals` AS (SELECT SUM(`total`) AS `order_total`, `customer_id` FROM `orders` GROUP BY `customer_id`), `active_customers` AS (SELECT `id`, `name` FROM `customers` WHERE `active` IN (?)) SELECT * FROM `order_totals` JOIN `active_customers` ON `order_totals`.`customer_id` = `active_customers`.`id` WHERE `order_total` > ? ORDER BY `order_total` DESC', $result->query); + } + + public function testWindowFunctionWithJoinAndWhere(): void + { + $result = (new Builder()) + ->from('sales') + ->join('products', 'sales.product_id', 'products.id') + ->selectWindow('ROW_NUMBER()', 'rn', ['products.category'], ['sales.amount']) + ->select(['products.name', 'sales.amount']) + ->filter([Query::greaterThan('sales.amount', 0)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `products`.`name`, `sales`.`amount`, ROW_NUMBER() OVER (PARTITION BY `products`.`category` ORDER BY `sales`.`amount` ASC) AS `rn` FROM `sales` JOIN `products` ON `sales`.`product_id` = `products`.`id` WHERE `sales`.`amount` > ?', $result->query); + } + + public function testWindowFunctionWithGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->selectWindow('SUM(amount)', 'running_total', ['category'], ['date']) + ->select(['category', 'date']) + ->groupBy(['category', 'date']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `category`, `date`, SUM(amount) OVER (PARTITION BY `category` ORDER BY `date` ASC) AS `running_total` FROM `sales` GROUP BY `category`, `date`', $result->query); + } + + public function testMultipleWindowFunctionsInSameQuery(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('ROW_NUMBER()', 'rn', ['department'], ['salary']) + ->selectWindow('RANK()', 'rnk', ['department'], ['-salary']) + ->selectWindow('SUM(salary)', 'dept_total', ['department']) + ->select(['name', 'department', 'salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` ASC) AS `rn`, RANK() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `rnk`, SUM(salary) OVER (PARTITION BY `department`) AS `dept_total` FROM `employees`', $result->query); + } + + public function testNamedWindowDefinitionWithSelectWindow(): void + { + $result = (new Builder()) + ->from('sales') + ->window('w', ['category'], ['date']) + ->selectWindow('SUM(amount)', 'running', null, null, 'w') + ->selectWindow('ROW_NUMBER()', 'rn', null, null, 'w') + ->select(['category', 'date', 'amount']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `category`, `date`, `amount`, SUM(amount) OVER `w` AS `running`, ROW_NUMBER() OVER `w` AS `rn` FROM `sales` WINDOW `w` AS (PARTITION BY `category` ORDER BY `date` ASC)', $result->query); + } + + public function testJoinAggregateGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->count('*', 'order_count') + ->sum('orders.total', 'revenue') + ->groupBy(['customers.country']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `order_count`, SUM(`orders`.`total`) AS `revenue` FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` GROUP BY `customers`.`country` HAVING COUNT(*) > ?', $result->query); + } + + public function testSelfJoinWithAlias(): void + { + $result = (new Builder()) + ->from('employees', 'e') + ->leftJoin('employees', 'e.manager_id', 'm.id', '=', 'm') + ->select(['e.name', 'm.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `e`.`name`, `m`.`name` FROM `employees` AS `e` LEFT JOIN `employees` AS `m` ON `e`.`manager_id` = `m`.`id`', $result->query); + } + + public function testTripleJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->join('products', 'orders.product_id', 'products.id') + ->leftJoin('categories', 'products.category_id', 'categories.id') + ->select(['customers.name', 'products.title', 'categories.label']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `customers`.`name`, `products`.`title`, `categories`.`label` FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` JOIN `products` ON `orders`.`product_id` = `products`.`id` LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id`', $result->query); + } + + public function testUnionAllWithOrderLimit(): void + { + $archive = (new Builder()) + ->from('events_archive') + ->select(['id', 'name', 'ts']) + ->filter([Query::greaterThan('ts', '2023-01-01')]); + + $result = (new Builder()) + ->from('events') + ->select(['id', 'name', 'ts']) + ->unionAll($archive) + ->sortDesc('ts') + ->limit(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT `id`, `name`, `ts` FROM `events` ORDER BY `ts` DESC LIMIT ?) UNION ALL (SELECT `id`, `name`, `ts` FROM `events_archive` WHERE `ts` > ?)', $result->query); + } + + public function testMultipleUnionAlls(): void + { + $q2 = (new Builder())->from('archive_2023')->filter([Query::equal('year', [2023])]); + $q3 = (new Builder())->from('archive_2022')->filter([Query::equal('year', [2022])]); + $q4 = (new Builder())->from('archive_2021')->filter([Query::equal('year', [2021])]); + + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('year', [2024])]) + ->unionAll($q2) + ->unionAll($q3) + ->unionAll($q4) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(3, substr_count($result->query, 'UNION ALL')); + $this->assertSame([2024, 2023, 2022, 2021], $result->bindings); + } + + public function testSubSelectWithJoinAndWhere(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->sum('total', 'customer_total') + ->groupBy(['customer_id']); + + $result = (new Builder()) + ->from('customers') + ->selectSub($sub, 'order_summary') + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT (SELECT SUM(`total`) AS `customer_total`, `customer_id` FROM `orders` GROUP BY `customer_id`) AS `order_summary` FROM `customers` WHERE `active` IN (?)', $result->query); + } + + public function testFromSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('events') + ->select(['user_id']) + ->count('*', 'event_count') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'user_events') + ->filter([Query::greaterThan('event_count', 10)]) + ->sortDesc('event_count') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM (SELECT COUNT(*) AS `event_count`, `user_id` FROM `events` GROUP BY `user_id`) AS `user_events` WHERE `event_count` > ? ORDER BY `event_count` DESC', $result->query); + } + + public function testFilterWhereInSubqueryWithOtherFilters(): void + { + $sub = (new Builder()) + ->from('premium_users') + ->select(['id']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $sub) + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `total` > ? AND `user_id` IN (SELECT `id` FROM `premium_users` WHERE `tier` IN (?))', $result->query); + } + + public function testExistsSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->filter([Query::raw('orders.customer_id = customers.id')]) + ->filter([Query::greaterThan('total', 1000)]); + + $result = (new Builder()) + ->from('customers') + ->filterExists($sub) + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `customers` WHERE `active` IN (?) AND EXISTS (SELECT * FROM `orders` WHERE orders.customer_id = customers.id AND `total` > ?)', $result->query); + } + + public function testConditionalAggregatesCountIfGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'completed_count', 'completed') + ->countWhen('status = ?', 'pending_count', 'pending') + ->groupBy(['region']) + ->having([Query::greaterThan('completed_count', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT countIf(status = ?) AS `completed_count`, countIf(status = ?) AS `pending_count` FROM `orders` GROUP BY `region` HAVING `completed_count` > ?', $result->query); + } + + public function testSumIfWithGroupBy(): void + { + $result = (new Builder()) + ->from('transactions') + ->sumWhen('amount', 'type = ?', 'credit_total', 'credit') + ->sumWhen('amount', 'type = ?', 'debit_total', 'debit') + ->groupBy(['account_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT sumIf(`amount`, type = ?) AS `credit_total`, sumIf(`amount`, type = ?) AS `debit_total` FROM `transactions` GROUP BY `account_id`', $result->query); + } + + public function testTableSamplingWithWhereAndOrder(): void + { + $result = (new Builder()) + ->from('events') + ->tablesample(10.0) + ->filter([Query::equal('type', ['click'])]) + ->sortDesc('timestamp') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` SAMPLE 0.1 WHERE `type` IN (?) ORDER BY `timestamp` DESC', $result->query); + } + + public function testSettingsWithComplexQuery(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->filter([Query::greaterThan('count', 10)]) + ->count('*', 'total') + ->groupBy(['users.country']) + ->settings(['max_threads' => '4', 'max_memory_usage' => '1000000000']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id` WHERE `count` > ? GROUP BY `users`.`country` SETTINGS max_threads=4, max_memory_usage=1000000000', $result->query); + } + + public function testInsertSelectFromSubquery(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['name', 'email']) + ->filter([Query::equal('imported', [0])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`name`, `email`) SELECT `name`, `email` FROM `staging` WHERE `imported` IN (?)', $result->query); + } + + public function testCaseExpressionWithAggregate(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'active') + ->when('status', Operator::Equal, 'inactive', 'inactive') + ->else('unknown') + ->alias('status_label'); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->count('*', 'total') + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total`, CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `status_label` FROM `users` GROUP BY `status`', $result->query); + } + + public function testExplainWithComplexQuery(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->join('users', 'events.uid', 'users.id') + ->filter([Query::greaterThan('count', 5)]) + ->count('*', 'total') + ->groupBy(['users.country']) + ->having([Query::greaterThan('total', 10)]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertSame('EXPLAIN SELECT COUNT(*) AS `total` FROM `events` FINAL JOIN `users` ON `events`.`uid` = `users`.`id` WHERE `count` > ? GROUP BY `users`.`country` HAVING COUNT(*) > ?', $result->query); + $this->assertTrue($result->readOnly); + } + + public function testNestedOrAndFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::or([ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]), + Query::and([ + Query::lessThan('score', 50), + Query::notEqual('role', 'admin'), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE ((`status` IN (?) AND `age` > ?) OR (`score` < ? AND `role` != ?))', $result->query); + } + + public function testTripleNestedLogicalOperators(): 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::greaterThan('d', 4), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([1, 2, 3, 4], $result->bindings); + } + + public function testIsNullIsNotNullEqualCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('email'), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `deleted_at` IS NULL AND `email` IS NOT NULL AND `status` IN (?)', $result->query); + } + + public function testBetweenAndNotEqualCombined(): void + { + $result = (new Builder()) + ->from('products') + ->filter([ + Query::between('price', 10, 100), + Query::notEqual('status', 'discontinued'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `products` WHERE `price` BETWEEN ? AND ? AND `status` != ?', $result->query); + $this->assertSame([10, 100, 'discontinued'], $result->bindings); + } + + public function testMultipleSortDirectionsInterleaved(): void + { + $result = (new Builder()) + ->from('events') + ->sortAsc('category') + ->sortDesc('priority') + ->sortAsc('name') + ->sortDesc('created_at') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `events` ORDER BY `category` ASC, `priority` DESC, `name` ASC, `created_at` DESC', + $result->query + ); + } + + public function testDistinctWithCount(): void + { + $result = (new Builder()) + ->from('events') + ->distinct() + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT COUNT(DISTINCT `user_id`) AS `unique_users` FROM `events`', $result->query); + } + + public function testGroupByMultipleColumns(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'total') + ->groupBy(['region', 'category', 'year']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `events` GROUP BY `region`, `category`, `year`', $result->query); + } + + public function testEmptySelect(): void + { + $result = (new Builder()) + ->from('events') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events`', $result->query); + } + + public function testLimitOneOffsetZero(): void + { + $result = (new Builder()) + ->from('events') + ->limit(1) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([1, 0], $result->bindings); + } + + public function testCloneAndModify(): void + { + $original = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('count', 10)]); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + $this->assertBindingCount($originalResult); + $this->assertBindingCount($clonedResult); + + $this->assertStringNotContainsString('`count`', $originalResult->query); + $this->assertSame('SELECT * FROM `events` WHERE `status` IN (?) AND `count` > ?', $clonedResult->query); + } + + public function testResetAndRebuild(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->settings(['max_threads' => '4']); + + $builder->build(); + $builder->reset(); + + $result = $builder + ->from('logs') + ->filter([Query::equal('level', ['error'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` WHERE `level` IN (?)', $result->query); + $this->assertSame(['error'], $result->bindings); + } + + public function testReadOnlyFlagOnBuild(): void + { + $result = (new Builder()) + ->from('events') + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($result->readOnly); + } + + public function testReadOnlyFlagOnInsert(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'test']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertFalse($result->readOnly); + } + + public function testBindingOrderCteWhereHaving(): void + { + $cte = (new Builder()) + ->from('raw_data') + ->filter([Query::greaterThan('amount', 0)]); + + $result = (new Builder()) + ->with('filtered', $cte) + ->from('filtered') + ->filter([Query::equal('status', ['active'])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(0, $result->bindings[0]); + $this->assertSame('active', $result->bindings[1]); + $this->assertSame(5, $result->bindings[2]); + } + + public function testContainsWithSpecialCharacters(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::containsString('message', ["it's a test"])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `logs` WHERE position(`message`, ?) > 0', $result->query); + $this->assertSame(["it's a test"], $result->bindings); + } + + public function testStartsWithSqlWildcardChars(): void + { + $result = (new Builder()) + ->from('files') + ->filter([Query::startsWith('path', '/tmp/%test_')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `files` WHERE startsWith(`path`, ?)', $result->query); + $this->assertSame(['/tmp/%test_'], $result->bindings); + } + + public function testMultipleSetForMultiRowInsert(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'a', 'value' => 1]) + ->set(['name' => 'b', 'value' => 2]) + ->set(['name' => 'c', 'value' => 3]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `events` (`name`, `value`) VALUES (?, ?), (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['a', 1, 'b', 2, 'c', 3], $result->bindings); + } + + public function testBooleanFilterValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('active', [true]), + Query::equal('deleted', [false]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([true, false], $result->bindings); + } + + public function testNullFilterViaRaw(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::isNull('email')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `email` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testBeforeBuildCallback(): void + { + $callbackCalled = false; + $result = (new Builder()) + ->from('events') + ->beforeBuild(function (Builder $b) use (&$callbackCalled) { + $callbackCalled = true; + $b->filter([Query::equal('injected', ['yes'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($callbackCalled); + $this->assertSame('SELECT * FROM `events` WHERE `injected` IN (?)', $result->query); + } + + public function testAfterBuildCallback(): void + { + $capturedQuery = ''; + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->afterBuild(function (Statement $r) use (&$capturedQuery) { + $capturedQuery = 'callback_executed'; + return $r; + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('callback_executed', $capturedQuery); + } + + public function testFullOuterJoinWithFilter(): void + { + $result = (new Builder()) + ->from('left_table') + ->fullOuterJoin('right_table', 'left_table.id', 'right_table.ref_id') + ->filter([Query::isNotNull('left_table.id')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `left_table` FULL OUTER JOIN `right_table` ON `left_table`.`id` = `right_table`.`ref_id` WHERE `left_table`.`id` IS NOT NULL', $result->query); + } + + public function testAvgIfWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'region = ?', 'avg_east', 'east') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT avgIf(`amount`, region = ?) AS `avg_east` FROM `orders`', $result->query); + $this->assertSame(['east'], $result->bindings); + } + + public function testMinIfWithAlias(): void + { + $result = (new Builder()) + ->from('products') + ->minWhen('price', 'category = ?', 'min_electronics', 'electronics') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT minIf(`price`, category = ?) AS `min_electronics` FROM `products`', $result->query); + } + + public function testMaxIfWithAlias(): void + { + $result = (new Builder()) + ->from('products') + ->maxWhen('price', 'in_stock = ?', 'max_available', 1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT maxIf(`price`, in_stock = ?) AS `max_available` FROM `products`', $result->query); + } + + public function testSampleValidationZeroBoundary(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(0.0); + } + + public function testSampleValidationOneBoundary(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->sample(1.0); + } + + public function testSettingsInvalidKeyThrowsOnSpecialChars(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->settings(['invalid key!' => '1']); + } + + public function testSettingsInvalidValueThrowsOnSqlInjection(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->settings(['max_threads' => 'DROP TABLE']); + } + + public function testUpdateWithoutWhereThrowsValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse UPDATE requires a WHERE clause.'); + + (new Builder()) + ->from('events') + ->set(['name' => 'updated']) + ->update(); + } + + public function testDeleteWithoutWhereThrowsValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ClickHouse DELETE requires a WHERE clause.'); + + (new Builder()) + ->from('events') + ->delete(); + } + + public function testUpdateAlterTableWithMultipleAssignments(): void + { + $result = (new Builder()) + ->from('events') + ->set(['status' => 'archived', 'updated_at' => '2024-06-01']) + ->filter([Query::equal('status', ['old'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('ALTER TABLE `events` UPDATE', $result->query); + $this->assertSame('ALTER TABLE `events` UPDATE `status` = ?, `updated_at` = ? WHERE `status` IN (?)', $result->query); + } + + public function testDeleteAlterTableWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('events') + ->filter([ + Query::lessThan('created_at', '2020-01-01'), + Query::equal('archived', [1]), + ]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('ALTER TABLE `events` DELETE', $result->query); + $this->assertSame('ALTER TABLE `events` DELETE WHERE `created_at` < ? AND `archived` IN (?)', $result->query); + } + + public function testPrewhereWithSettings(): void + { + $result = (new Builder()) + ->from('events') + ->prewhere([Query::equal('type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` PREWHERE `type` IN (?) WHERE `count` > ? SETTINGS max_threads=2', $result->query); + } + + public function testHintValidation(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->hint('DROP TABLE; --'); + } + + public function testSelectRawWithBindings(): void + { + $result = (new Builder()) + ->from('events') + ->select('toDate(?) AS ref_date', ['2024-01-01']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT toDate(?) AS ref_date FROM `events`', $result->query); + $this->assertSame(['2024-01-01'], $result->bindings); + } + + public function testFilterWhereNotInSubquery(): void + { + $sub = (new Builder()) + ->from('blocked_users') + ->select(['id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `id` NOT IN (SELECT `id` FROM `blocked_users`)', $result->query); + } + + public function testImplementsLimitBy(): void + { + $this->assertInstanceOf(LimitBy::class, new Builder()); + } + + public function testImplementsArrayJoins(): void + { + $this->assertInstanceOf(ArrayJoins::class, new Builder()); + } + + public function testImplementsAsofJoins(): void + { + $this->assertInstanceOf(AsofJoins::class, new Builder()); + } + + public function testImplementsWithFill(): void + { + $this->assertInstanceOf(WithFill::class, new Builder()); + } + + public function testImplementsGroupByModifiers(): void + { + $this->assertInstanceOf(GroupByModifiers::class, new Builder()); + } + + public function testImplementsApproximateAggregates(): void + { + $this->assertInstanceOf(ApproximateAggregates::class, new Builder()); + } + + public function testImplementsStringAggregates(): void + { + $this->assertInstanceOf(StringAggregates::class, new Builder()); + } + + public function testImplementsStatisticalAggregates(): void + { + $this->assertInstanceOf(StatisticalAggregates::class, new Builder()); + } + + public function testImplementsBitwiseAggregates(): void + { + $this->assertInstanceOf(BitwiseAggregates::class, new Builder()); + } + + public function testLimitByBasic(): void + { + $result = (new Builder()) + ->from('events') + ->select(['user_id', 'event_type']) + ->sortDesc('timestamp') + ->limitBy(3, ['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `user_id`, `event_type` FROM `events` ORDER BY `timestamp` DESC LIMIT ? BY `user_id`', $result->query); + } + + public function testLimitByMultipleColumns(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('timestamp') + ->limitBy(5, ['user_id', 'event_type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `timestamp` DESC LIMIT ? BY `user_id`, `event_type`', $result->query); + } + + public function testLimitByWithLimit(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('timestamp') + ->limitBy(3, ['user_id']) + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `timestamp` DESC LIMIT ? BY `user_id` LIMIT ?', $result->query); + // LIMIT BY count binding should come before the final LIMIT binding + $limitByIdx = \array_search(3, $result->bindings, true); + $limitIdx = \array_search(100, $result->bindings, true); + $this->assertNotFalse($limitByIdx); + $this->assertNotFalse($limitIdx); + $this->assertLessThan($limitIdx, $limitByIdx); + } + + public function testLimitByWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('timestamp') + ->limitBy(3, ['user_id']) + ->limit(100) + ->offset(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `timestamp` DESC LIMIT ? BY `user_id` LIMIT ? OFFSET ?', $result->query); + } + + public function testLimitByWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('created_at') + ->limitBy(2, ['category']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `created_at` DESC LIMIT ? BY `category`', $result->query); + $orderPos = \strpos($result->query, 'ORDER BY'); + $limitByPos = \strpos($result->query, 'LIMIT ? BY'); + $this->assertLessThan($limitByPos, $orderPos); + } + + public function testLimitByWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('timestamp') + ->limitBy(3, ['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE `status` IN (?) ORDER BY `timestamp` DESC LIMIT ? BY `user_id`', $result->query); + } + + public function testLimitByWithSettings(): void + { + $result = (new Builder()) + ->from('events') + ->limitBy(3, ['user_id']) + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` LIMIT ? BY `user_id` SETTINGS max_threads=2', $result->query); + $limitByPos = \strpos($result->query, 'LIMIT ? BY'); + $settingsPos = \strpos($result->query, 'SETTINGS'); + $this->assertLessThan($settingsPos, $limitByPos); + } + + public function testLimitByFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->limitBy(1, ['a'])); + } + + public function testLimitByReset(): void + { + $builder = (new Builder()) + ->from('events') + ->limitBy(3, ['user_id']); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('LIMIT ? BY', $result->query); + } + + public function testArrayJoinBasic(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags`', $result->query); + } + + public function testArrayJoinWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag`', $result->query); + } + + public function testLeftArrayJoinBasic(): void + { + $result = (new Builder()) + ->from('events') + ->leftArrayJoin('tags') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` LEFT ARRAY JOIN `tags`', $result->query); + } + + public function testLeftArrayJoinWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->leftArrayJoin('tags', 'tag') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` LEFT ARRAY JOIN `tags` AS `tag`', $result->query); + } + + public function testArrayJoinWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->filter([Query::equal('tag', ['important'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag` WHERE `tag` IN (?)', $result->query); + $arrayJoinPos = \strpos($result->query, 'ARRAY JOIN'); + $wherePos = \strpos($result->query, 'WHERE'); + $this->assertLessThan($wherePos, $arrayJoinPos); + } + + public function testArrayJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->prewhere([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag` PREWHERE `status` IN (?)', $result->query); + $arrayJoinPos = \strpos($result->query, 'ARRAY JOIN'); + $prewherePos = \strpos($result->query, 'PREWHERE'); + $this->assertLessThan($prewherePos, $arrayJoinPos); + } + + public function testArrayJoinWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->count('*', 'cnt') + ->groupBy(['tag']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` ARRAY JOIN `tags` AS `tag` GROUP BY `tag`', $result->query); + } + + public function testMultipleArrayJoins(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->leftArrayJoin('metadata', 'meta') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag` LEFT ARRAY JOIN `metadata` AS `meta`', $result->query); + } + + public function testArrayJoinFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->arrayJoin('a')); + $this->assertSame($builder, $builder->leftArrayJoin('b')); + } + + public function testArrayJoinReset(): void + { + $builder = (new Builder()) + ->from('events') + ->arrayJoin('tags'); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('ARRAY JOIN', $result->query); + } + + public function testAsofJoinBasic(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testAsofJoinWithAlias(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'q.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'q.ts', + 'q', + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` AS `q` ON `trades`.`symbol` = `q`.`symbol` AND `trades`.`ts` >= `q`.`ts`', $result->query); + } + + public function testAsofLeftJoinBasic(): void + { + $result = (new Builder()) + ->from('trades') + ->asofLeftJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF LEFT JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testAsofLeftJoinWithAlias(): void + { + $result = (new Builder()) + ->from('trades') + ->asofLeftJoin( + 'quotes', + ['trades.symbol' => 'q.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'q.ts', + 'q', + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF LEFT JOIN `quotes` AS `q` ON `trades`.`symbol` = `q`.`symbol` AND `trades`.`ts` >= `q`.`ts`', $result->query); + } + + public function testAsofJoinWithFilter(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->filter([Query::equal('trades.symbol', ['AAPL'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts` WHERE `trades`.`symbol` IN (?)', $result->query); + $joinPos = \strpos($result->query, 'ASOF JOIN'); + $wherePos = \strpos($result->query, 'WHERE'); + $this->assertLessThan($wherePos, $joinPos); + } + + public function testAsofJoinFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->asofJoin('q', ['t.k' => 'q.k'], 't.ts', AsofOperator::GreaterThanEqual, 'q.ts')); + $this->assertSame($builder, $builder->asofLeftJoin('r', ['t.k' => 'r.k'], 't.ts', AsofOperator::LessThanEqual, 'r.ts')); + } + + public function testAsofJoinReset(): void + { + $builder = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('trades')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('ASOF', $result->query); + } + + public function testOrderWithFillBasic(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('date') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `date` ASC WITH FILL', $result->query); + } + + public function testOrderWithFillDesc(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('date', 'DESC') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `date` DESC WITH FILL', $result->query); + } + + public function testOrderWithFillFrom(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('value', 'ASC', 0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `value` ASC WITH FILL FROM ?', $result->query); + $this->assertContains(0, $result->bindings); + } + + public function testOrderWithFillFromTo(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('value', 'ASC', 0, 100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `value` ASC WITH FILL FROM ? TO ?', $result->query); + $this->assertContains(0, $result->bindings); + $this->assertContains(100, $result->bindings); + } + + public function testOrderWithFillFromToStep(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('value', 'ASC', 0, 100, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `value` ASC WITH FILL FROM ? TO ? STEP ?', $result->query); + $this->assertSame([0, 100, 10], $result->bindings); + } + + public function testOrderWithFillWithRegularSort(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('date', 'ASC', '2024-01-01', '2024-12-31') + ->sortDesc('count') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `date` ASC WITH FILL FROM ? TO ?, `count` DESC', $result->query); + } + + public function testOrderWithFillFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->orderWithFill('a')); + } + + public function testWithTotalsBasic(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['event_type']) + ->withTotals() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `event_type` WITH TOTALS', $result->query); + } + + public function testWithRollupBasic(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['year', 'month']) + ->withRollup() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `year`, `month` WITH ROLLUP', $result->query); + } + + public function testWithCubeBasic(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['city', 'category']) + ->withCube() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `city`, `category` WITH CUBE', $result->query); + } + + public function testWithTotalsWithHaving(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['event_type']) + ->withTotals() + ->having([Query::greaterThan('cnt', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `event_type` WITH TOTALS HAVING COUNT(*) > ?', $result->query); + $groupByPos = \strpos($result->query, 'GROUP BY'); + $totalsPos = \strpos($result->query, 'WITH TOTALS'); + $havingPos = \strpos($result->query, 'HAVING'); + $this->assertLessThan($totalsPos, $groupByPos); + $this->assertLessThan($havingPos, $totalsPos); + } + + public function testWithTotalsWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['event_type']) + ->withTotals() + ->sortDesc('cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `event_type` WITH TOTALS ORDER BY `cnt` DESC', $result->query); + $totalsPos = \strpos($result->query, 'WITH TOTALS'); + $orderByPos = \strpos($result->query, 'ORDER BY'); + $this->assertLessThan($orderByPos, $totalsPos); + } + + public function testGroupByModifierFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->withTotals()); + $builder->reset(); + $this->assertSame($builder, $builder->from('t')->withRollup()); + $builder->reset(); + $this->assertSame($builder, $builder->from('t')->withCube()); + } + + public function testGroupByModifierReset(): void + { + $builder = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['event_type']) + ->withTotals(); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('events')->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('WITH TOTALS', $result->query); + $this->assertStringNotContainsString('WITH ROLLUP', $result->query); + $this->assertStringNotContainsString('WITH CUBE', $result->query); + } + + public function testWithTotalsWithLimitBy(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['event_type', 'user_id']) + ->withTotals() + ->sortDesc('cnt') + ->limitBy(3, ['user_id']) + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `event_type`, `user_id` WITH TOTALS ORDER BY `cnt` DESC LIMIT ? BY `user_id` LIMIT ?', $result->query); + } + + public function testQuantileWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->quantile(0.95, 'latency', 'p95') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT quantile(0.95)(`latency`) AS `p95` FROM `events`', $result->query); + } + + public function testQuantileWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->quantile(0.5, 'latency') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT quantile(0.5)(`latency`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testQuantileExactWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->quantileExact(0.99, 'response_time', 'p99_exact') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT quantileExact(0.99)(`response_time`) AS `p99_exact` FROM `events`', $result->query); + } + + public function testQuantileExactWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->quantileExact(0.5, 'latency') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT quantileExact(0.5)(`latency`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMedianWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->median('latency', 'med') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT median(`latency`) AS `med` FROM `events`', $result->query); + } + + public function testMedianWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->median('latency') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT median(`latency`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testUniqWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniq('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniq(`user_id`) AS `unique_users` FROM `events`', $result->query); + } + + public function testUniqWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniq('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniq(`user_id`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testUniqExactWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniqExact('user_id', 'exact_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniqExact(`user_id`) AS `exact_users` FROM `events`', $result->query); + } + + public function testUniqExactWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniqExact('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniqExact(`user_id`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testUniqCombinedWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniqCombined('user_id', 'approx_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniqCombined(`user_id`) AS `approx_users` FROM `events`', $result->query); + } + + public function testUniqCombinedWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->uniqCombined('user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniqCombined(`user_id`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testArgMinWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->argMin('url', 'timestamp', 'first_url') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT argMin(`url`, `timestamp`) AS `first_url` FROM `events`', $result->query); + } + + public function testArgMinWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->argMin('url', 'timestamp') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT argMin(`url`, `timestamp`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testArgMaxWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->argMax('url', 'timestamp', 'last_url') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT argMax(`url`, `timestamp`) AS `last_url` FROM `events`', $result->query); + } + + public function testArgMaxWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->argMax('url', 'timestamp') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT argMax(`url`, `timestamp`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testTopKWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->topK(10, 'user_agent', 'top_agents') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT topK(10)(`user_agent`) AS `top_agents` FROM `events`', $result->query); + } + + public function testTopKWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->topK(5, 'path') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT topK(5)(`path`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testTopKWeightedWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->topKWeighted(10, 'path', 'visits', 'top_paths') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT topKWeighted(10)(`path`, `visits`) AS `top_paths` FROM `events`', $result->query); + } + + public function testTopKWeightedWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->topKWeighted(3, 'url', 'weight') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT topKWeighted(3)(`url`, `weight`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAnyValueWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->anyValue('name', 'sample_name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT any(`name`) AS `sample_name` FROM `events`', $result->query); + } + + public function testAnyValueWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->anyValue('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT any(`name`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAnyLastValueWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->anyLastValue('name', 'last_name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT anyLast(`name`) AS `last_name` FROM `events`', $result->query); + } + + public function testAnyLastValueWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->anyLastValue('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT anyLast(`name`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testGroupUniqArrayWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupUniqArray('tag', 'unique_tags') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupUniqArray(`tag`) AS `unique_tags` FROM `events` GROUP BY `user_id`', $result->query); + } + + public function testGroupUniqArrayWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupUniqArray('tag') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupUniqArray(`tag`) FROM `events` GROUP BY `user_id`', $result->query); + } + + public function testGroupArrayMovingAvgWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupArrayMovingAvg('value', 'moving_avg') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupArrayMovingAvg(`value`) AS `moving_avg` FROM `events`', $result->query); + } + + public function testGroupArrayMovingAvgWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupArrayMovingAvg('value') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupArrayMovingAvg(`value`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testGroupArrayMovingSumWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupArrayMovingSum('value', 'running_total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupArrayMovingSum(`value`) AS `running_total` FROM `events`', $result->query); + } + + public function testGroupArrayMovingSumWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupArrayMovingSum('value') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupArrayMovingSum(`value`) FROM `events`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testQuantileWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->quantile(0.95, 'latency', 'p95') + ->groupBy(['endpoint']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT quantile(0.95)(`latency`) AS `p95` FROM `events` GROUP BY `endpoint`', $result->query); + } + + public function testMultipleAggregatesCombined(): void + { + $result = (new Builder()) + ->from('requests') + ->quantile(0.5, 'latency', 'p50') + ->quantile(0.95, 'latency', 'p95') + ->quantile(0.99, 'latency', 'p99') + ->uniq('user_id', 'unique_users') + ->count('*', 'total') + ->groupBy(['endpoint']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total`, quantile(0.5)(`latency`) AS `p50`, quantile(0.95)(`latency`) AS `p95`, quantile(0.99)(`latency`) AS `p99`, uniq(`user_id`) AS `unique_users` FROM `requests` GROUP BY `endpoint`', $result->query); + } + + public function testArgMinWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->argMin('url', 'timestamp', 'first_url') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT argMin(`url`, `timestamp`) AS `first_url` FROM `events` GROUP BY `user_id`', $result->query); + } + + public function testTopKWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->topK(10, 'user_agent', 'top_agents') + ->filter([Query::greaterThan('timestamp', '2024-01-01')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT topK(10)(`user_agent`) AS `top_agents` FROM `events` WHERE `timestamp` > ?', $result->query); + } + + public function testClickHouseAggregateFluentChaining(): void + { + $builder = new Builder(); + $this->assertSame($builder, $builder->from('t')->quantile(0.5, 'a')); + $this->assertSame($builder, $builder->quantileExact(0.5, 'a')); + $this->assertSame($builder, $builder->median('a')); + $this->assertSame($builder, $builder->uniq('a')); + $this->assertSame($builder, $builder->uniqExact('a')); + $this->assertSame($builder, $builder->uniqCombined('a')); + $this->assertSame($builder, $builder->argMin('a', 'b')); + $this->assertSame($builder, $builder->argMax('a', 'b')); + $this->assertSame($builder, $builder->topK(5, 'a')); + $this->assertSame($builder, $builder->topKWeighted(5, 'a', 'b')); + $this->assertSame($builder, $builder->anyValue('a')); + $this->assertSame($builder, $builder->anyLastValue('a')); + $this->assertSame($builder, $builder->groupUniqArray('a')); + $this->assertSame($builder, $builder->groupArrayMovingAvg('a')); + $this->assertSame($builder, $builder->groupArrayMovingSum('a')); + } + + public function testAllFeaturesCombined(): void + { + $result = (new Builder()) + ->from('events') + ->final() + ->sample(0.1) + ->arrayJoin('tags', 'tag') + ->prewhere([Query::equal('event_type', ['click'])]) + ->filter([Query::greaterThan('count', 5)]) + ->count('*', 'total') + ->quantile(0.95, 'latency', 'p95') + ->groupBy(['tag']) + ->withTotals() + ->having([Query::greaterThan('total', 10)]) + ->sortDesc('total') + ->limitBy(3, ['tag']) + ->limit(50) + ->settings(['max_threads' => '4']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total`, quantile(0.95)(`latency`) AS `p95` FROM `events` FINAL SAMPLE 0.1 ARRAY JOIN `tags` AS `tag` PREWHERE `event_type` IN (?) WHERE `count` > ? GROUP BY `tag` WITH TOTALS HAVING COUNT(*) > ? ORDER BY `total` DESC LIMIT ? BY `tag` LIMIT ? SETTINGS max_threads=4', $result->query); + } + + public function testLimitByNoFinalLimit(): void + { + $result = (new Builder()) + ->from('events') + ->sortDesc('timestamp') + ->limitBy(5, ['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `timestamp` DESC LIMIT ? BY `user_id`', $result->query); + $this->assertSame([5], $result->bindings); + } + + public function testArrayJoinWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->sortAsc('tag') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag` ORDER BY `tag` ASC', $result->query); + $arrayPos = \strpos($result->query, 'ARRAY JOIN'); + $orderPos = \strpos($result->query, 'ORDER BY'); + $this->assertLessThan($orderPos, $arrayPos); + } + + public function testAsofJoinWithPrewhere(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->prewhere([Query::equal('trades.exchange', ['NYSE'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts` PREWHERE `trades`.`exchange` IN (?)', $result->query); + } + + public function testWithRollupWithOrderBy(): void + { + $result = (new Builder()) + ->from('sales') + ->sum('amount', 'total_amount') + ->groupBy(['region', 'product']) + ->withRollup() + ->sortDesc('total_amount') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`amount`) AS `total_amount` FROM `sales` GROUP BY `region`, `product` WITH ROLLUP ORDER BY `total_amount` DESC', $result->query); + } + + public function testWithCubeWithLimit(): void + { + $result = (new Builder()) + ->from('sales') + ->sum('amount', 'total') + ->groupBy(['region', 'product']) + ->withCube() + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`amount`) AS `total` FROM `sales` GROUP BY `region`, `product` WITH CUBE LIMIT ?', $result->query); + } + + public function testOrderWithFillWithLimitBy(): void + { + $result = (new Builder()) + ->from('metrics') + ->orderWithFill('date', 'ASC', '2024-01-01', '2024-12-31', 1) + ->limitBy(10, ['metric_name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `metrics` ORDER BY `date` ASC WITH FILL FROM ? TO ? STEP ? LIMIT ? BY `metric_name`', $result->query); + } + + public function testGroupByModifierOverwrite(): void + { + $builder = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->withTotals() + ->withRollup(); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `type` WITH ROLLUP', $result->query); + $this->assertStringNotContainsString('WITH TOTALS', $result->query); + } + + public function testLimitByBindingCount(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('timestamp') + ->limitBy(3, ['user_id']) + ->limit(100) + ->build(); + $this->assertBindingCount($result); + + $this->assertCount(3, $result->bindings); + $this->assertSame('active', $result->bindings[0]); + $this->assertSame(3, $result->bindings[1]); + $this->assertSame(100, $result->bindings[2]); + } + + public function testDottedColumnInArrayJoin(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('nested.tags', 'tag') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `nested`.`tags` AS `tag`', $result->query); + } + + public function testAsofJoinWithRegularJoin(): void + { + $result = (new Builder()) + ->from('trades') + ->join('instruments', 'trades.symbol', 'instruments.symbol') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` JOIN `instruments` ON `trades`.`symbol` = `instruments`.`symbol` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testWithTotalsNoHavingNoOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->withTotals() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `type` WITH TOTALS', $result->query); + } + + public function testWithRollupNoLimit(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->withRollup() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `type` WITH ROLLUP', $result->query); + } + + public function testArrayJoinNoFilterNoOrder(): void + { + $result = (new Builder()) + ->from('events') + ->select(['name']) + ->arrayJoin('tags', 'tag') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name` FROM `events` ARRAY JOIN `tags` AS `tag`', $result->query); + } + + public function testLimitByWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['user_id', 'event_type']) + ->having([Query::greaterThan('cnt', 5)]) + ->sortDesc('cnt') + ->limitBy(2, ['user_id']) + ->limit(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `user_id`, `event_type` HAVING COUNT(*) > ? ORDER BY `cnt` DESC LIMIT ? BY `user_id` LIMIT ?', $result->query); + } + + public function testGroupConcatWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupConcat('name', ',', 'names') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT arrayStringConcat(groupArray(`name`), ?) AS `names` FROM `events` GROUP BY `type`', $result->query); + } + + public function testGroupConcatWithoutAlias(): void + { + $result = (new Builder()) + ->from('events') + ->groupConcat('name', ',') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT arrayStringConcat(groupArray(`name`), ?) FROM `events` GROUP BY `type`', $result->query); + $this->assertSame([','], $result->bindings); + } + + public function testJsonArrayAggWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->jsonArrayAgg('value', 'values_json') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT toJSONString(groupArray(`value`)) AS `values_json` FROM `events` GROUP BY `type`', $result->query); + } + + public function testJsonObjectAggWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->jsonObjectAgg('key', 'value', 'kv_json') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT toJSONString(CAST((groupArray(`key`), groupArray(`value`)) AS Map(String, String))) AS `kv_json` FROM `events` GROUP BY `type`', $result->query); + } + + public function testStddevWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->stddev('value', 'sd') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT stddevPop(`value`) AS `sd` FROM `events`', $result->query); + $this->assertStringNotContainsString('STDDEV(', $result->query); + } + + public function testStddevPopWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->stddevPop('value', 'sd_pop') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT STDDEV_POP(`value`) AS `sd_pop` FROM `events`', $result->query); + } + + public function testStddevSampWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->stddevSamp('value', 'sd_samp') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT STDDEV_SAMP(`value`) AS `sd_samp` FROM `events`', $result->query); + } + + public function testVarianceWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->variance('value', 'var') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT varPop(`value`) AS `var` FROM `events`', $result->query); + $this->assertStringNotContainsString('VARIANCE(', $result->query); + } + + public function testVarPopWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->varPop('value', 'vp') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT VAR_POP(`value`) AS `vp` FROM `events`', $result->query); + } + + public function testVarSampWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->varSamp('value', 'vs') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT VAR_SAMP(`value`) AS `vs` FROM `events`', $result->query); + } + + public function testBitAndWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->bitAnd('flags', 'and_flags') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT BIT_AND(`flags`) AS `and_flags` FROM `events`', $result->query); + } + + public function testBitOrWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->bitOr('flags', 'or_flags') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT BIT_OR(`flags`) AS `or_flags` FROM `events`', $result->query); + } + + public function testBitXorWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->bitXor('flags', 'xor_flags') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT BIT_XOR(`flags`) AS `xor_flags` FROM `events`', $result->query); + } + + public function testUniqWithGroupByAndFilter(): void + { + $result = (new Builder()) + ->from('events') + ->uniq('user_id', 'unique_users') + ->filter([Query::greaterThan('timestamp', '2024-01-01')]) + ->groupBy(['event_type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT uniq(`user_id`) AS `unique_users` FROM `events` WHERE `timestamp` > ? GROUP BY `event_type`', $result->query); + } + + public function testAnyValueWithGroupBy(): void + { + $result = (new Builder()) + ->from('events') + ->anyValue('name', 'any_name') + ->count('*', 'cnt') + ->groupBy(['type']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, any(`name`) AS `any_name` FROM `events` GROUP BY `type`', $result->query); + } + + public function testGroupUniqArrayWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->groupUniqArray('tag', 'unique_tags') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT groupUniqArray(`tag`) AS `unique_tags` FROM `events` WHERE `status` IN (?) GROUP BY `user_id`', $result->query); + } + + public function testOrderWithFillToOnly(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('value', 'ASC', null, 100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `value` ASC WITH FILL TO ?', $result->query); + $this->assertStringNotContainsString('FROM ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testOrderWithFillStepOnly(): void + { + $result = (new Builder()) + ->from('events') + ->orderWithFill('value', 'ASC', null, null, 5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ORDER BY `value` ASC WITH FILL STEP ?', $result->query); + $this->assertStringNotContainsString('FROM ?', $result->query); + $this->assertStringNotContainsString('TO ?', $result->query); + $this->assertSame([5], $result->bindings); + } + + public function testWithTotalsWithSettings(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->withTotals() + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY `type` WITH TOTALS SETTINGS max_threads=2', $result->query); + $totalsPos = \strpos($result->query, 'WITH TOTALS'); + $settingsPos = \strpos($result->query, 'SETTINGS'); + $this->assertLessThan($settingsPos, $totalsPos); + } + + public function testArrayJoinWithSettings(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag` SETTINGS max_threads=2', $result->query); + } + + public function testAsofJoinWithSettings(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts` SETTINGS max_threads=2', $result->query); + } + + public function testLimitByWithLimitAndSettings(): void + { + $result = (new Builder()) + ->from('events') + ->limitBy(3, ['user_id']) + ->limit(100) + ->settings(['max_threads' => '2']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` LIMIT ? BY `user_id` LIMIT ? SETTINGS max_threads=2', $result->query); + } + + public function testResetClearsAllNewState(): void + { + $builder = (new Builder()) + ->from('events') + ->final() + ->sample(0.5) + ->prewhere([Query::equal('type', ['click'])]) + ->arrayJoin('tags', 'tag') + ->asofJoin('quotes', ['t.k' => 'q.k'], 't.ts', AsofOperator::GreaterThanEqual, 'q.ts') + ->limitBy(3, ['user_id']) + ->withTotals() + ->settings(['max_threads' => '4']); + + $builder->build(); + $builder->reset(); + + $result = $builder->from('clean')->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `clean`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testFromNoneEmitsEmptyPlaceholder(): void + { + // ClickHouse always emits a FROM clause; fromNone() produces an empty backtick-quoted placeholder. + $result = (new Builder()) + ->fromNone() + ->selectRaw('1 + 1') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT 1 + 1 FROM ``', $result->query); + } + + public function testSelectCastEmitsCastExpression(): void + { + $result = (new Builder()) + ->from('products') + ->selectCast('price', 'DECIMAL(10, 2)', 'price_decimal') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CAST(`price` AS DECIMAL(10, 2)) AS `price_decimal` FROM `products`', $result->query); + } + + public function testSelectCastRejectsInvalidType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new Builder()) + ->from('t') + ->selectCast('c', 'INT); DROP TABLE x;--', 'a'); + } + + public function testSelectWindowRejectsInvalidFunction(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()); DROP --', 'w'); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $stripped = \preg_replace('/`[^`]+`/', '', $result->query) ?? ''; + // Lowercase reserved word must not appear bare outside quotes + $this->assertDoesNotMatchRegularExpression( + '/(?from($word) + ->build(); + + $this->assertStringContainsString('FROM `' . $word . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testStructuredSlotsDoNotMutateIdentifiersOrLiterals(): void + { + // Table/column identifiers and bound string literals containing + // SQL keywords (ARRAY JOIN, LIMIT, SETTINGS, PREWHERE, WHERE, ORDER + // BY, GROUP BY) must pass through the builder untouched. The legacy + // regex-based afterBuild() could in principle match these keywords + // inside identifiers/literals; the structured slots cannot. + $result = (new Builder()) + ->from('settings_table', 'settings_alias') + ->select(['id', 'array_join_col', 'limit_by_col']) + ->filter([ + Query::equal('label', ['LIMIT 1 SETTINGS foo']), + Query::equal('description', ['ARRAY JOIN tags']), + Query::equal('note', ['PREWHERE condition']), + ]) + ->sortAsc('order_by_col') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([ + 'LIMIT 1 SETTINGS foo', + 'ARRAY JOIN tags', + 'PREWHERE condition', + 10, + ], $result->bindings); + + $this->assertSame('SELECT `id`, `array_join_col`, `limit_by_col` FROM `settings_table` AS `settings_alias` WHERE `label` IN (?) AND `description` IN (?) AND `note` IN (?) ORDER BY `order_by_col` ASC LIMIT ?', $result->query); + + // Literals must not appear un-parameterised in the SQL. + $this->assertStringNotContainsString('LIMIT 1 SETTINGS foo', $result->query); + $this->assertStringNotContainsString('ARRAY JOIN tags', $result->query); + $this->assertStringNotContainsString('PREWHERE condition', $result->query); + + // No spurious clause keywords were injected anywhere. + $this->assertStringNotContainsString('ARRAY JOIN `', $result->query); + $this->assertStringNotContainsString(' SETTINGS ', $result->query); + $this->assertStringNotContainsString('PREWHERE ', $result->query); + $this->assertStringNotContainsString(' LIMIT BY ', $result->query); + } + + public function testWhereColumnEmitsQualifiedIdentifiers(): void + { + $result = (new Builder()) + ->from('events') + ->whereColumn('events.user_id', '=', 'sessions.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE `events`.`user_id` = `sessions`.`user_id`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testWhereColumnRejectsUnknownOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid whereColumn operator: NOT_AN_OP'); + + (new Builder()) + ->from('events') + ->whereColumn('a', 'NOT_AN_OP', 'b'); + } + + public function testWhereColumnCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::equal('status', ['active'])]) + ->whereColumn('events.user_id', '=', 'sessions.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `events` WHERE `status` IN (?) AND `events`.`user_id` = `sessions`.`user_id`', $result->query); + $this->assertContains('active', $result->bindings); + } + +} diff --git a/tests/Query/Builder/EmptyInputTest.php b/tests/Query/Builder/EmptyInputTest.php new file mode 100644 index 0000000..11a6c69 --- /dev/null +++ b/tests/Query/Builder/EmptyInputTest.php @@ -0,0 +1,122 @@ +}> + */ + public static function dialectProvider(): array + { + return [ + ['mysql', MySQL::class], + ['mariadb', MariaDB::class], + ['postgres', PostgreSQL::class], + ['sqlite', SQLite::class], + ['clickhouse', ClickHouse::class], + ['mongodb', MongoDB::class], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testSelectEmptyArrayIsNoop(string $label, string $class): void + { + $builder = new $class(); + $result = $builder->select([])->from('t')->build(); + + // select([]) does not throw and does not add bindings + $this->assertSame([], $result->bindings); + $this->assertNotSame('', $result->query); + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testGroupByEmptyArrayIsNoop(string $label, string $class): void + { + $builder = new $class(); + $result = $builder->from('t')->groupBy([])->build(); + + // groupBy([]) does not throw — the empty GROUP BY is silently dropped + $this->assertSame([], $result->bindings); + $this->assertNotSame('', $result->query); + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testFilterEmptyArrayIsNoop(string $label, string $class): void + { + $builder = new $class(); + $result = $builder->from('t')->filter([])->build(); + + // filter([]) does not throw and produces no WHERE bindings + $this->assertSame([], $result->bindings); + $this->assertNotSame('', $result->query); + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testQueriesEmptyArrayIsNoop(string $label, string $class): void + { + $builder = new $class(); + $result = $builder->from('t')->queries([])->build(); + + $this->assertSame([], $result->bindings); + $this->assertNotSame('', $result->query); + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testInsertWithoutSetThrows(string $label, string $class): void + { + $builder = new $class(); + $builder->into('t'); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No rows to insert'); + $builder->insert(); + } + + /** + * @param class-string $class + */ + #[DataProvider('dialectProvider')] + public function testInsertWithEmptySetRowThrows(string $label, string $class): void + { + $builder = new $class(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot insert an empty row'); + + $builder->into('t')->set([])->insert(); + } +} diff --git a/tests/Query/Builder/Feature/BitwiseAggregatesTest.php b/tests/Query/Builder/Feature/BitwiseAggregatesTest.php new file mode 100644 index 0000000..be61b5d --- /dev/null +++ b/tests/Query/Builder/Feature/BitwiseAggregatesTest.php @@ -0,0 +1,93 @@ +from('events') + ->bitAnd('flags', 'and_flags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BIT_AND(`flags`) AS `and_flags` FROM `events`', $result->query); + } + + public function testBitOrWithAliasEmitsBitOr(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->bitOr('flags', 'or_flags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BIT_OR(`flags`) AS `or_flags` FROM `events`', $result->query); + } + + public function testBitXorWithAliasEmitsBitXor(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->bitXor('flags', 'xor_flags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BIT_XOR(`flags`) AS `xor_flags` FROM `events`', $result->query); + } + + public function testBitAndWithoutAliasOmitsAsClause(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->bitAnd('flags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BIT_AND(`flags`) FROM `events`', $result->query); + $this->assertStringNotContainsString('AS ``', $result->query); + } + + public function testBitAndOnMySQLBuilderUsesSameSyntax(): void + { + $result = (new MySQLBuilder()) + ->from('events') + ->bitAnd('flags', 'a') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BIT_AND(`flags`) AS `a` FROM `events`', $result->query); + } + + public function testBitwiseAggregateDoesNotAddBindings(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->bitOr('flags', 'o') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testBitAndChainedWithWhereUsesCorrectBindingOrder(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->bitAnd('flags', 'a') + ->filter([Query::equal('tenant', ['acme'])]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame(['acme'], $result->bindings); + } +} diff --git a/tests/Query/Builder/Feature/CTEsTest.php b/tests/Query/Builder/Feature/CTEsTest.php new file mode 100644 index 0000000..96e36ca --- /dev/null +++ b/tests/Query/Builder/Feature/CTEsTest.php @@ -0,0 +1,152 @@ +from('orders'); + + $result = (new Builder()) + ->with('recent', $cte) + ->from('recent') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH "recent" AS (SELECT * FROM "orders") SELECT * FROM "recent"', $result->query); + $this->assertStringNotContainsString('RECURSIVE', $result->query); + } + + public function testWithColumnListIsQuoted(): void + { + $cte = (new Builder())->from('orders'); + + $result = (new Builder()) + ->with('projection', $cte, ['id', 'name']) + ->from('projection') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH "projection"("id", "name") AS (SELECT * FROM "orders") SELECT * FROM "projection"', $result->query); + } + + public function testWithRecursiveEmitsRecursiveKeyword(): void + { + $sub = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH RECURSIVE "tree" AS (SELECT * FROM "categories") SELECT * FROM "tree"', $result->query); + } + + public function testWithRecursiveSeedStepJoinsWithUnionAll(): void + { + $seed = (new Builder()) + ->from('categories') + ->select(['id', 'parent_id', 'name']) + ->filter([Query::isNull('parent_id')]); + + $step = (new Builder()) + ->from('categories') + ->select(['categories.id', 'categories.parent_id', 'categories.name']) + ->join('tree', 'categories.parent_id', 'tree.id'); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step, ['id', 'parent_id', 'name']) + ->from('tree') + ->select(['id', 'name']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH RECURSIVE "tree"("id", "parent_id", "name") AS (SELECT "id", "parent_id", "name" FROM "categories" WHERE "parent_id" IS NULL UNION ALL SELECT "categories"."id", "categories"."parent_id", "categories"."name" FROM "categories" JOIN "tree" ON "categories"."parent_id" = "tree"."id") SELECT "id", "name" FROM "tree"', $result->query); + } + + public function testMultipleCtesAreCommaSeparated(): void + { + $cteA = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]); + $cteB = (new Builder()) + ->from('orders') + ->filter([Query::greaterThan('total', 50)]); + + $result = (new Builder()) + ->with('active_users', $cteA) + ->with('big_orders', $cteB) + ->from('active_users') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH "active_users" AS (SELECT * FROM "users" WHERE "active" IN (?)), "big_orders" AS (SELECT * FROM "orders" WHERE "total" > ?) SELECT * FROM "active_users"', $result->query); + } + + public function testCteBindingsAppearBeforeOuterBindings(): void + { + $cte = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['shipped'])]); + + $result = (new Builder()) + ->with('shipped', $cte) + ->from('shipped') + ->filter([Query::equal('total', [100])]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('shipped', $result->bindings[0]); + $this->assertSame(100, $result->bindings[1]); + } + + public function testWithRecursiveSeedStepMergesBindingsInSeedThenStepOrder(): void + { + $seed = (new Builder()) + ->from('categories') + ->filter([Query::equal('kind', ['root'])]); + $step = (new Builder()) + ->from('categories') + ->filter([Query::equal('kind', ['child'])]); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step) + ->from('tree') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame(['root', 'child'], $result->bindings); + } + + public function testWithEmptyColumnsEmitsNoColumnList(): void + { + $cte = (new Builder())->from('orders'); + + $result = (new Builder()) + ->with('recent', $cte, []) + ->from('recent') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('WITH "recent" AS (SELECT * FROM "orders") SELECT * FROM "recent"', $result->query); + $this->assertStringNotContainsString('"recent"(', $result->query); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new Builder(); + $cte = (new Builder())->from('orders'); + $returned = $builder->with('x', $cte); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/ApproximateAggregatesTest.php b/tests/Query/Builder/Feature/ClickHouse/ApproximateAggregatesTest.php new file mode 100644 index 0000000..b618d3d --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/ApproximateAggregatesTest.php @@ -0,0 +1,55 @@ +from('events') + ->quantiles([0.25, 0.5, 0.75], 'value') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT quantiles(0.25, 0.5, 0.75)(`value`) FROM `events`', $result->query); + } + + public function testQuantilesWithAlias(): void + { + $result = (new Builder()) + ->from('events') + ->quantiles([0.25, 0.5, 0.75], 'value', 'qs') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT quantiles(0.25, 0.5, 0.75)(`value`) AS `qs` FROM `events`', $result->query); + } + + public function testQuantilesRejectsEmptyLevels(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('quantiles() requires at least one level.'); + + (new Builder()) + ->from('events') + ->quantiles([], 'value'); + } + + public function testQuantilesRejectsOutOfRangeLevels(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('quantiles() levels must be in the range [0, 1].'); + + (new Builder()) + ->from('events') + ->quantiles([0.5, 1.5], 'value'); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/ArrayJoinsTest.php b/tests/Query/Builder/Feature/ClickHouse/ArrayJoinsTest.php new file mode 100644 index 0000000..58712cd --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/ArrayJoinsTest.php @@ -0,0 +1,82 @@ +from('events') + ->arrayJoin('tags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags`', $result->query); + } + + public function testArrayJoinWithAliasQuotesBothColumnAndAlias(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags` AS `tag`', $result->query); + } + + public function testLeftArrayJoinPrefixesLeft(): void + { + $result = (new Builder()) + ->from('events') + ->leftArrayJoin('tags') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` LEFT ARRAY JOIN `tags`', $result->query); + } + + public function testLeftArrayJoinWithAliasFormatsAsClause(): void + { + $result = (new Builder()) + ->from('events') + ->leftArrayJoin('tags', 'tag') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` LEFT ARRAY JOIN `tags` AS `tag`', $result->query); + } + + public function testArrayJoinWithEmptyAliasOmitsAsClause(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', '') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `events` ARRAY JOIN `tags`', $result->query); + $this->assertStringNotContainsString('AS ``', $result->query); + } + + public function testArrayJoinPrecedesWhereClause(): void + { + $result = (new Builder()) + ->from('events') + ->arrayJoin('tags', 'tag') + ->filter([Query::equal('tag', ['important'])]) + ->build(); + + $this->assertBindingCount($result); + $this->assertLessThan(\strpos($result->query, 'WHERE'), \strpos($result->query, 'ARRAY JOIN')); + $this->assertSame(['important'], $result->bindings); + } +} diff --git a/tests/Query/Builder/Feature/ClickHouse/AsofJoinsTest.php b/tests/Query/Builder/Feature/ClickHouse/AsofJoinsTest.php new file mode 100644 index 0000000..c319f9e --- /dev/null +++ b/tests/Query/Builder/Feature/ClickHouse/AsofJoinsTest.php @@ -0,0 +1,140 @@ +from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testAsofJoinWithAliasUsesAliasInOnClause(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'q.symbol'], + 'trades.ts', + AsofOperator::GreaterThan, + 'q.ts', + 'q', + ) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` AS `q` ON `trades`.`symbol` = `q`.`symbol` AND `trades`.`ts` > `q`.`ts`', $result->query); + } + + public function testAsofJoinSupportsMultipleEquiPairs(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + [ + 'trades.symbol' => 'quotes.symbol', + 'trades.exchange' => 'quotes.exchange', + ], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `trades` ASOF JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`exchange` = `quotes`.`exchange` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testAsofLeftJoinEmitsAsofLeftJoinKeyword(): void + { + $result = (new Builder()) + ->from('trades') + ->asofLeftJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `trades` ASOF LEFT JOIN `quotes` ON `trades`.`symbol` = `quotes`.`symbol` AND `trades`.`ts` >= `quotes`.`ts`', $result->query); + } + + public function testAsofJoinRejectsEmptyEquiPairs(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ASOF JOIN requires at least one equi-join column pair.'); + + (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + [], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->build(); + } + + public function testAsofJoinPrecedesWhereClause(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::GreaterThanEqual, + 'quotes.ts', + ) + ->filter([Query::equal('trades.symbol', ['AAPL'])]) + ->build(); + + $this->assertBindingCount($result); + $this->assertLessThan(\strpos($result->query, 'WHERE'), \strpos($result->query, 'ASOF JOIN')); + $this->assertSame(['AAPL'], $result->bindings); + } + + public function testAsofJoinDoesNotAddBindings(): void + { + $result = (new Builder()) + ->from('trades') + ->asofJoin( + 'quotes', + ['trades.symbol' => 'quotes.symbol'], + 'trades.ts', + AsofOperator::LessThanEqual, + 'quotes.ts', + ) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } +} diff --git a/tests/Query/Builder/Feature/FullTextSearchTest.php b/tests/Query/Builder/Feature/FullTextSearchTest.php new file mode 100644 index 0000000..27c0004 --- /dev/null +++ b/tests/Query/Builder/Feature/FullTextSearchTest.php @@ -0,0 +1,95 @@ +from('articles') + ->filterSearch('content', 'tutorial') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `articles` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE)', $result->query); + // MySQL boolean-mode adds a '*' suffix to enable prefix matching. + $this->assertSame(['tutorial*'], $result->bindings); + } + + public function testMySQLFilterNotSearchWrapsWithNot(): void + { + $result = (new MySQLBuilder()) + ->from('articles') + ->filterNotSearch('content', 'spam') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `articles` WHERE NOT (MATCH(`content`) AGAINST(? IN BOOLEAN MODE))', $result->query); + $this->assertSame(['spam*'], $result->bindings); + } + + public function testPostgreSQLFilterSearchEmitsTsVectorMatchOperator(): void + { + $result = (new PostgreSQLBuilder()) + ->from('articles') + ->filterSearch('content', 'tutorial') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame("SELECT * FROM \"articles\" WHERE to_tsvector(regexp_replace(\"content\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)", $result->query); + $this->assertSame(['tutorial'], $result->bindings); + } + + public function testPostgreSQLFilterNotSearchNegatesMatchOperator(): void + { + $result = (new PostgreSQLBuilder()) + ->from('articles') + ->filterNotSearch('content', 'spam') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame("SELECT * FROM \"articles\" WHERE NOT (to_tsvector(regexp_replace(\"content\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?))", $result->query); + } + + public function testFilterSearchAndFilterNotSearchBindInOrder(): void + { + $result = (new MySQLBuilder()) + ->from('articles') + ->filterSearch('title', 'good') + ->filterNotSearch('body', 'bad') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame(['good*', 'bad*'], $result->bindings); + } + + public function testMySQLFilterSearchEmptyValueEmitsNeverMatch(): void + { + // An empty search term is degenerate; MySQL boolean-mode rewrites it + // to a tautology-never so no binding is added. + $result = (new MySQLBuilder()) + ->from('articles') + ->filterSearch('content', '') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `articles` WHERE 1 = 0', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new MySQLBuilder(); + $returned = $builder->from('articles')->filterSearch('content', 'x'); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/HintsTest.php b/tests/Query/Builder/Feature/HintsTest.php new file mode 100644 index 0000000..76c099e --- /dev/null +++ b/tests/Query/Builder/Feature/HintsTest.php @@ -0,0 +1,135 @@ +from('users') + ->hint('NO_INDEX_MERGE(users)') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + 'SELECT /*+ NO_INDEX_MERGE(users) */ * FROM `users`', + $result->query + ); + } + + public function testMultipleHintsAreSpaceSeparatedInsideSingleComment(): void + { + $result = (new MySQLBuilder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->hint('BKA(users)') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT /*+ NO_INDEX_MERGE(users) BKA(users) */ * FROM `users`', $result->query); + } + + public function testHintAcceptsBacktickedIdentifier(): void + { + $result = (new MySQLBuilder()) + ->from('users') + ->hint('INDEX(`users` `idx_users_age`)') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT /*+ INDEX(`users` `idx_users_age`) */ * FROM `users`', $result->query); + } + + public function testHintAcceptsSetVarStyle(): void + { + $result = (new MySQLBuilder()) + ->from('users') + ->hint('SET_VAR(sort_buffer_size=16M)') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT /*+ SET_VAR(sort_buffer_size=16M) */ * FROM `users`', $result->query); + } + + public function testHintRejectsSemicolonInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new MySQLBuilder()) + ->from('users') + ->hint('DROP TABLE users; --'); + } + + public function testHintRejectsBlockCommentCloser(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new MySQLBuilder()) + ->from('users') + ->hint('foo */ UNION SELECT'); + } + + public function testHintRejectsEmptyStringOnMySQL(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new MySQLBuilder()) + ->from('users') + ->hint(''); + } + + public function testHintIsInheritedByMariaDB(): void + { + $result = (new MariaDBBuilder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT /*+ NO_INDEX_MERGE(users) */ * FROM `users`', $result->query); + } + + public function testClickHouseHintEmitsSettingsClause(): void + { + $result = (new ClickHouseBuilder()) + ->from('events') + ->hint('max_threads=2') + ->build(); + + $this->assertBindingCount($result); + // ClickHouse emits hints as SETTINGS, not as an optimizer comment. + $this->assertSame('SELECT * FROM `events` SETTINGS max_threads=2', $result->query); + } + + public function testClickHouseHintRejectsBacktickSyntax(): void + { + // ClickHouse uses a stricter regex that forbids backticks and parens. + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new ClickHouseBuilder()) + ->from('events') + ->hint('INDEX(`users` `idx_users_age`)'); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new MySQLBuilder(); + $returned = $builder->from('t')->hint('BKA(t)'); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/LateralJoinsTest.php b/tests/Query/Builder/Feature/LateralJoinsTest.php new file mode 100644 index 0000000..31c9c77 --- /dev/null +++ b/tests/Query/Builder/Feature/LateralJoinsTest.php @@ -0,0 +1,82 @@ +from('orders')->select(['id']); + + $result = (new PostgreSQLBuilder()) + ->from('users') + ->joinLateral($sub, 'o') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "users" JOIN LATERAL (SELECT "id" FROM "orders") AS "o" ON true', $result->query); + } + + public function testLeftJoinLateralEmitsLeftJoinLateral(): void + { + $sub = (new PostgreSQLBuilder())->from('orders')->select(['id']); + + $result = (new PostgreSQLBuilder()) + ->from('users') + ->leftJoinLateral($sub, 'o') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "users" LEFT JOIN LATERAL (SELECT "id" FROM "orders") AS "o" ON true', $result->query); + } + + public function testJoinLateralWithLeftTypeEmitsLeftVariant(): void + { + $sub = (new PostgreSQLBuilder())->from('orders')->select(['id']); + + $result = (new PostgreSQLBuilder()) + ->from('users') + ->joinLateral($sub, 'o', JoinType::Left) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "users" LEFT JOIN LATERAL (SELECT "id" FROM "orders") AS "o" ON true', $result->query); + } + + public function testJoinLateralPreservesSubqueryBindingsInOrder(): void + { + $sub = (new PostgreSQLBuilder()) + ->from('orders') + ->filter([Query::greaterThan('total', 100), Query::equal('status', ['shipped'])]); + + $result = (new PostgreSQLBuilder()) + ->from('users') + ->joinLateral($sub, 'o') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([0 => 100, 1 => 'shipped'], $result->bindings); + } + + public function testMySQLUsesBacktickQuotingForLateralAlias(): void + { + $sub = (new MySQLBuilder())->from('orders')->select(['id']); + + $result = (new MySQLBuilder()) + ->from('users') + ->joinLateral($sub, 'o') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `users` JOIN LATERAL (SELECT `id` FROM `orders`) AS `o` ON true', $result->query); + } +} diff --git a/tests/Query/Builder/Feature/MariaDB/ReturningTest.php b/tests/Query/Builder/Feature/MariaDB/ReturningTest.php new file mode 100644 index 0000000..bce799c --- /dev/null +++ b/tests/Query/Builder/Feature/MariaDB/ReturningTest.php @@ -0,0 +1,158 @@ +into('users') + ->set(['name' => 'John']) + ->returning(['id', 'name']) + ->insert(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT INTO `users` (`name`) VALUES (?) RETURNING `id`, `name`', $result->query); + } + + public function testReturningDefaultIsStarWildcard(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning() + ->insert(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT INTO `users` (`name`) VALUES (?) RETURNING *', $result->query); + } + + public function testReturningEmptyArrayEmitsNoReturningClause(): void + { + // Passing an empty list means "no columns to return"; the builder + // must not emit RETURNING at all rather than degenerate to "RETURNING *". + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning([]) + ->insert(); + + $this->assertBindingCount($result); + $this->assertStringNotContainsString('RETURNING', $result->query); + } + + public function testUpdateReturningEmitsReturningClause(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->update(); + + $this->assertBindingCount($result); + $this->assertSame('UPDATE `users` SET `name` = ? WHERE `id` IN (?) RETURNING `id`', $result->query); + } + + public function testDeleteReturningEmitsReturningClause(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->delete(); + + $this->assertBindingCount($result); + $this->assertSame('DELETE FROM `users` WHERE `id` IN (?) RETURNING `id`', $result->query); + } + + public function testInsertOrIgnoreReturningEmitsReturningClause(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id']) + ->insertOrIgnore(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT IGNORE INTO `users` (`name`) VALUES (?) RETURNING `id`', $result->query); + } + + public function testReturningBindingsUnchanged(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [42])]) + ->returning(['id', 'name']) + ->update(); + + $this->assertBindingCount($result); + // RETURNING should not add bindings; only SET and WHERE contribute. + $this->assertSame(['Jane', 42], $result->bindings); + } + + public function testUpsertWithReturningThrows(): void + { + // MariaDB's ON DUPLICATE KEY UPDATE path cannot coexist with RETURNING. + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('MariaDB does not support RETURNING with ON DUPLICATE KEY UPDATE'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->returning(['id']) + ->upsert(); + } + + public function testUpsertSelectWithReturningThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('MariaDB does not support RETURNING with ON DUPLICATE KEY UPDATE'); + + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name']); + + (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source) + ->onConflict(['id'], ['name']) + ->returning(['id']) + ->upsertSelect(); + } + + public function testUpsertAfterReturningClearedSucceeds(): void + { + // Clearing RETURNING with returning([]) allows upsert() through. + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->returning(['id']) + ->returning([]) + ->upsert(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT INTO `users` (`id`, `name`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)', $result->query); + $this->assertStringNotContainsString('RETURNING', $result->query); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new Builder(); + $returned = $builder->into('users')->set(['name' => 'x'])->returning(['id']); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/MongoDB/ArrayPushModifiersTest.php b/tests/Query/Builder/Feature/MongoDB/ArrayPushModifiersTest.php new file mode 100644 index 0000000..926d7e0 --- /dev/null +++ b/tests/Query/Builder/Feature/MongoDB/ArrayPushModifiersTest.php @@ -0,0 +1,136 @@ + + */ + private function decode(string $query): array + { + /** @var array $op */ + $op = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + + return $op; + } + + public function testPushEachBasicEmitsEachPlaceholders(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('tags', ['a', 'b', 'c']) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $modifier */ + $modifier = $pushDoc['tags']; + + $this->assertSame(['?', '?', '?'], $modifier['$each']); + $this->assertArrayNotHasKey('$position', $modifier); + $this->assertArrayNotHasKey('$slice', $modifier); + $this->assertArrayNotHasKey('$sort', $modifier); + } + + public function testPushEachWithAllModifiersSetsEachKey(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('scores', [85, 92], 0, 5, ['score' => -1]) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $modifier */ + $modifier = $pushDoc['scores']; + + $this->assertSame(['?', '?'], $modifier['$each']); + $this->assertSame(0, $modifier['$position']); + $this->assertSame(5, $modifier['$slice']); + $this->assertSame(['score' => -1], $modifier['$sort']); + } + + public function testPushEachEmptyArrayStillEmitsEachKey(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('tags', []) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $modifier */ + $modifier = $pushDoc['tags']; + + $this->assertSame([], $modifier['$each']); + } + + public function testPushEachBindsValuesBeforeFilterBinding(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('tags', ['a', 'b']) + ->filter([Query::equal('_id', ['ID'])]) + ->update(); + + $this->assertBindingCount($result); + // All bindings appear in the result regardless of order. Order is + // an implementation detail; the assertion here is that every value + // the caller provided ends up bound. + $this->assertContains('a', $result->bindings); + $this->assertContains('b', $result->bindings); + $this->assertContains('ID', $result->bindings); + } + + public function testPushEachWithOnlySliceOmitsPositionAndSort(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('tags', ['a'], null, 3) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $modifier */ + $modifier = $pushDoc['tags']; + + $this->assertSame(3, $modifier['$slice']); + $this->assertArrayNotHasKey('$position', $modifier); + $this->assertArrayNotHasKey('$sort', $modifier); + } + + public function testPushEachRejectsDollarPrefixedField(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('users')->pushEach('$evil', ['x']); + } +} diff --git a/tests/Query/Builder/Feature/MongoDB/AtlasSearchTest.php b/tests/Query/Builder/Feature/MongoDB/AtlasSearchTest.php new file mode 100644 index 0000000..a540b96 --- /dev/null +++ b/tests/Query/Builder/Feature/MongoDB/AtlasSearchTest.php @@ -0,0 +1,127 @@ + + */ + private function decode(string $query): array + { + /** @var array $op */ + $op = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + + return $op; + } + + public function testSearchEmitsSearchStageWithIndex(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'hello', 'path' => 'body']], 'default') + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $searchBody */ + $searchBody = $pipeline[0]['$search']; + + $this->assertSame('default', $searchBody['index']); + $this->assertSame(['query' => 'hello', 'path' => 'body'], $searchBody['text']); + } + + public function testSearchWithoutIndexOmitsIndexKey(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 't', 'path' => 't']]) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $searchBody */ + $searchBody = $pipeline[0]['$search']; + + $this->assertArrayNotHasKey('index', $searchBody); + } + + public function testSearchIsFirstStageEvenAfterLaterFilter(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 't', 'path' => 't']]) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $this->assertArrayHasKey('$search', $pipeline[0]); + } + + public function testSearchMetaEmitsSearchMetaStage(): void + { + $result = (new Builder()) + ->from('articles') + ->searchMeta(['facet' => []], 'default') + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $this->assertArrayHasKey('$searchMeta', $pipeline[0]); + } + + public function testVectorSearchPopulatesAllFields(): void + { + $result = (new Builder()) + ->from('products') + ->vectorSearch('embedding', [0.1, 0.2], 50, 5, 'vi', ['category' => 'x']) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $body */ + $body = $pipeline[0]['$vectorSearch']; + + $this->assertSame('embedding', $body['path']); + $this->assertSame([0.1, 0.2], $body['queryVector']); + $this->assertSame(50, $body['numCandidates']); + $this->assertSame(5, $body['limit']); + $this->assertSame('vi', $body['index']); + $this->assertSame(['category' => 'x'], $body['filter']); + } + + public function testVectorSearchNullableFilterOmitsFilterKey(): void + { + $result = (new Builder()) + ->from('products') + ->vectorSearch('embedding', [0.1], 10, 1) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $body */ + $body = $pipeline[0]['$vectorSearch']; + + $this->assertArrayNotHasKey('filter', $body); + } +} diff --git a/tests/Query/Builder/Feature/MongoDB/ConditionalArrayUpdatesTest.php b/tests/Query/Builder/Feature/MongoDB/ConditionalArrayUpdatesTest.php new file mode 100644 index 0000000..621f654 --- /dev/null +++ b/tests/Query/Builder/Feature/MongoDB/ConditionalArrayUpdatesTest.php @@ -0,0 +1,136 @@ + + */ + private function decode(string $query): array + { + /** @var array $op */ + $op = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + + return $op; + } + + public function testArrayFilterRegistersSingleFilterUnderArrayFiltersOption(): void + { + $result = (new Builder()) + ->from('students') + ->set(['grades.$[elem].mean' => 0]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateMany', $op['operation']); + $this->assertArrayHasKey('options', $op); + /** @var array $options */ + $options = $op['options']; + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertCount(1, $filters); + $this->assertSame(['$gte' => 85], $filters[0]['elem.grade']); + } + + public function testArrayFilterPreservesInsertionOrderAcrossMultipleCalls(): void + { + $result = (new Builder()) + ->from('students') + ->set(['grades.$[elem].adjusted' => true]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->arrayFilter('other', ['other.type' => 'test']) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $options */ + $options = $op['options']; + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertCount(2, $filters); + $this->assertArrayHasKey('elem.grade', $filters[0]); + $this->assertArrayHasKey('other.type', $filters[1]); + } + + public function testArrayFilterAcceptsComparisonOperators(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['items.$[it].status' => 'shipped']) + ->arrayFilter('it', ['it.price' => ['$lt' => 100, '$gte' => 10]]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $options */ + $options = $op['options']; + /** @var list> $filters */ + $filters = $options['arrayFilters']; + /** @var array $priceFilter */ + $priceFilter = $filters[0]['it.price']; + $this->assertSame(100, $priceFilter['$lt']); + $this->assertSame(10, $priceFilter['$gte']); + } + + public function testArrayFilterWithLogicalOperators(): void + { + $result = (new Builder()) + ->from('tasks') + ->set(['items.$[it].done' => true]) + ->arrayFilter('it', [ + '$or' => [ + ['it.priority' => 'high'], + ['it.deadline' => ['$lt' => 1000]], + ], + ]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $options */ + $options = $op['options']; + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertArrayHasKey('$or', $filters[0]); + } + + public function testWithoutArrayFilterNoOptionsAreEmitted(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Alice']) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('options', $op); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new Builder(); + $returned = $builder->from('students')->arrayFilter('elem', ['elem.x' => 1]); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/MongoDB/FieldUpdatesTest.php b/tests/Query/Builder/Feature/MongoDB/FieldUpdatesTest.php new file mode 100644 index 0000000..1a3a7c0 --- /dev/null +++ b/tests/Query/Builder/Feature/MongoDB/FieldUpdatesTest.php @@ -0,0 +1,142 @@ + + */ + private function decode(string $query): array + { + /** @var array $op */ + $op = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + + return $op; + } + + public function testRenameEmitsRenameUpdateOperator(): void + { + $result = (new Builder()) + ->from('users') + ->rename('old', 'new') + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertArrayHasKey('$rename', $update); + $this->assertSame(['old' => 'new'], $update['$rename']); + } + + public function testMultiplyEmitsMulOperator(): void + { + $result = (new Builder()) + ->from('products') + ->multiply('price', 1.1) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertSame(['price' => 1.1], $update['$mul']); + } + + public function testPopFirstEmitsNegativeOneMarker(): void + { + $result = (new Builder()) + ->from('users') + ->popFirst('tags') + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertSame(['tags' => -1], $update['$pop']); + } + + public function testPopLastEmitsPositiveOneMarker(): void + { + $result = (new Builder()) + ->from('users') + ->popLast('tags') + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertSame(['tags' => 1], $update['$pop']); + } + + public function testPullAllBindsEachValueInOrder(): void + { + $result = (new Builder()) + ->from('users') + ->pullAll('scores', [10, 20]) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + // Bindings: pullAll values (10, 20) then _id binding. + $this->assertContains(10, $result->bindings); + $this->assertContains(20, $result->bindings); + } + + public function testUpdateMinEmitsMinOperator(): void + { + $result = (new Builder()) + ->from('users') + ->updateMin('low_score', 50) + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertArrayHasKey('$min', $update); + } + + public function testCurrentDateWithTimestampTypeEmitsTimestampType(): void + { + $result = (new Builder()) + ->from('users') + ->currentDate('modified', 'timestamp') + ->filter([Query::equal('_id', ['x'])]) + ->update(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + + $this->assertSame(['modified' => ['$type' => 'timestamp']], $update['$currentDate']); + } + + public function testRenameRejectsDollarPrefixedField(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('users')->rename('$danger', 'ok'); + } +} diff --git a/tests/Query/Builder/Feature/MongoDB/PipelineStagesTest.php b/tests/Query/Builder/Feature/MongoDB/PipelineStagesTest.php new file mode 100644 index 0000000..e2d2ac7 --- /dev/null +++ b/tests/Query/Builder/Feature/MongoDB/PipelineStagesTest.php @@ -0,0 +1,173 @@ + + */ + private function decode(string $query): array + { + /** @var array $op */ + $op = \json_decode($query, true, flags: JSON_THROW_ON_ERROR); + + return $op; + } + + /** + * @param list> $pipeline + * @return array|null + */ + private function findStage(array $pipeline, string $name): ?array + { + foreach ($pipeline as $stage) { + if (\array_key_exists($name, $stage)) { + return $stage; + } + } + + return null; + } + + public function testBucketEmitsBucketStageWithGroupByAndBoundaries(): void + { + $result = (new Builder()) + ->from('sales') + ->bucket('price', [0, 100, 200], 'Other', ['count' => ['$sum' => 1]]) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$bucket'); + + $this->assertNotNull($stage); + /** @var array $body */ + $body = $stage['$bucket']; + $this->assertSame('$price', $body['groupBy']); + $this->assertSame([0, 100, 200], $body['boundaries']); + $this->assertSame('Other', $body['default']); + } + + public function testBucketWithoutDefaultOrOutputOmitsKeys(): void + { + $result = (new Builder()) + ->from('sales') + ->bucket('amount', [0, 50]) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$bucket'); + $this->assertNotNull($stage); + /** @var array $body */ + $body = $stage['$bucket']; + + $this->assertArrayNotHasKey('default', $body); + $this->assertArrayNotHasKey('output', $body); + } + + public function testBucketAutoEmitsBucketAutoWithBucketCount(): void + { + $result = (new Builder()) + ->from('sales') + ->bucketAuto('price', 5) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$bucketAuto'); + + $this->assertNotNull($stage); + /** @var array $body */ + $body = $stage['$bucketAuto']; + $this->assertSame(5, $body['buckets']); + } + + public function testFacetEmitsFacetStageWithSubPipelines(): void + { + $facetA = (new Builder())->from('events'); + $facetB = (new Builder())->from('events'); + + $result = (new Builder()) + ->from('events') + ->facet(['a' => $facetA, 'b' => $facetB]) + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$facet'); + + $this->assertNotNull($stage); + /** @var array $body */ + $body = $stage['$facet']; + $this->assertArrayHasKey('a', $body); + $this->assertArrayHasKey('b', $body); + } + + public function testGraphLookupEmitsGraphLookupStage(): void + { + $result = (new Builder()) + ->from('users') + ->graphLookup('users', '$manager', 'manager', '_id', 'chain') + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$graphLookup'); + + $this->assertNotNull($stage); + /** @var array $body */ + $body = $stage['$graphLookup']; + $this->assertSame('users', $body['from']); + $this->assertSame('manager', $body['connectFromField']); + } + + public function testOutputToCollectionEmitsOutStage(): void + { + $result = (new Builder()) + ->from('orders') + ->outputToCollection('archive') + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$out'); + + $this->assertNotNull($stage); + } + + public function testReplaceRootEmitsReplaceRootStage(): void + { + $result = (new Builder()) + ->from('orders') + ->replaceRoot('$user') + ->build(); + + $this->assertBindingCount($result); + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $stage = $this->findStage($pipeline, '$replaceRoot'); + + $this->assertNotNull($stage); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/AggregateFilterTest.php b/tests/Query/Builder/Feature/PostgreSQL/AggregateFilterTest.php new file mode 100644 index 0000000..74948a5 --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/AggregateFilterTest.php @@ -0,0 +1,91 @@ +from('orders') + ->selectAggregateFilter('COUNT(*)', 'status = ?', 'active_count', ['active']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) AS "active_count" FROM "orders"', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testSelectAggregateFilterWithoutAliasOmitsAsClause(): void + { + $result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('COUNT(*)', 'status = ?', '', ['active']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) FROM "orders"', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSelectAggregateFilterWithNoBindingsDoesNotAddBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('COUNT(*)', 'total > 100', 'big_count') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT COUNT(*) FILTER (WHERE total > 100) AS "big_count" FROM "orders"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testMultipleAggregateFiltersCommaSeparated(): void + { + $result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('COUNT(*)', 'status = ?', 'active_count', ['active']) + ->selectAggregateFilter('COUNT(*)', 'status = ?', 'cancelled_count', ['cancelled']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) AS "active_count", COUNT(*) FILTER (WHERE status = ?) AS "cancelled_count" FROM "orders"', $result->query); + $this->assertSame(['active', 'cancelled'], $result->bindings); + } + + public function testSelectAggregateFilterWithMultiArgAggregate(): void + { + $result = (new Builder()) + ->from('orders') + ->selectAggregateFilter('SUM("amount")', 'status = ?', 'active_total', ['active']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT SUM("amount") FILTER (WHERE status = ?) AS "active_total" FROM "orders"', $result->query); + } + + public function testSelectAggregateFilterBindingsAppendInOrder(): void + { + $result = (new Builder()) + ->from('events') + ->selectAggregateFilter('COUNT(*)', 'kind = ? AND level >= ?', 'hits', ['click', 2]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame(['click', 2], $result->bindings); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new Builder(); + $returned = $builder->from('orders')->selectAggregateFilter('COUNT(*)', 'status = ?', '', ['active']); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/DistinctOnTest.php b/tests/Query/Builder/Feature/PostgreSQL/DistinctOnTest.php new file mode 100644 index 0000000..efd40c4 --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/DistinctOnTest.php @@ -0,0 +1,90 @@ +from('events') + ->distinctOn(['user_id']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT DISTINCT ON ("user_id") * FROM "events"', $result->query); + } + + public function testDistinctOnMultipleColumnsAreCommaSeparatedAndQuoted(): void + { + $result = (new Builder()) + ->from('events') + ->distinctOn(['user_id', 'session_id']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT DISTINCT ON ("user_id", "session_id") * FROM "events"', $result->query); + } + + public function testDistinctOnReplacesPlainSelectKeyword(): void + { + $result = (new Builder()) + ->from('events') + ->select(['user_id', 'event_at']) + ->distinctOn(['user_id']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT DISTINCT ON ("user_id") "user_id", "event_at" FROM "events"', $result->query); + // Only one SELECT keyword — the DISTINCT ON prefix must replace, not prepend. + $this->assertSame(1, \substr_count($result->query, 'SELECT ')); + } + + public function testDistinctOnEmptyArrayDoesNotEmitDistinctOn(): void + { + $result = (new Builder()) + ->from('events') + ->distinctOn([]) + ->build(); + + $this->assertBindingCount($result); + $this->assertStringNotContainsString('DISTINCT ON', $result->query); + } + + public function testDistinctOnCombinesWithOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->distinctOn(['user_id']) + ->sortAsc('event_at') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT DISTINCT ON ("user_id") * FROM "events" ORDER BY "event_at" ASC', $result->query); + } + + public function testDistinctOnDoesNotAddBindings(): void + { + $result = (new Builder()) + ->from('events') + ->distinctOn(['user_id', 'session_id']) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new Builder(); + $returned = $builder->from('events')->distinctOn(['user_id']); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/MergeTest.php b/tests/Query/Builder/Feature/PostgreSQL/MergeTest.php new file mode 100644 index 0000000..c2dffa8 --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/MergeTest.php @@ -0,0 +1,97 @@ +from('staging')->select(['id', 'name']); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name') + ->whenNotMatched('INSERT (id, name) VALUES (src.id, src.name)') + ->executeMerge(); + + $this->assertBindingCount($result); + $this->assertSame('MERGE INTO "users" USING (SELECT "id", "name" FROM "staging") AS "src" ON users.id = src.id WHEN MATCHED THEN UPDATE SET name = src.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (src.id, src.name)', $result->query); + } + + public function testMergeQuotesTargetIdentifierForPostgreSQL(): void + { + $source = (new Builder())->from('staging'); + + $result = (new Builder()) + ->mergeInto('order_lines') + ->using($source, 'src') + ->on('order_lines.id = src.id') + ->whenMatched('UPDATE SET qty = src.qty') + ->executeMerge(); + + $this->assertBindingCount($result); + $this->assertSame('MERGE INTO "order_lines" USING (SELECT * FROM "staging") AS "src" ON order_lines.id = src.id WHEN MATCHED THEN UPDATE SET qty = src.qty', $result->query); + } + + public function testMergePreservesSourceFilterBindingsFirst(): void + { + $source = (new Builder()) + ->from('staging') + ->filter([Query::equal('status', ['pending'])]); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name') + ->executeMerge(); + + $this->assertBindingCount($result); + // The source subquery's binding must come before any later merge + // clause bindings. + $this->assertSame('pending', $result->bindings[0]); + } + + public function testMergeOnClauseBindingsAppendAfterSource(): void + { + $source = (new Builder()) + ->from('staging') + ->filter([Query::equal('status', ['pending'])]); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id AND src.region = ?', 'US') + ->whenMatched('UPDATE SET name = src.name') + ->executeMerge(); + + $this->assertBindingCount($result); + // Source binding first, then ON-clause binding. + $this->assertSame(['pending', 'US'], $result->bindings); + } + + public function testMergeWithOnlyWhenMatchedStillBuilds(): void + { + $source = (new Builder())->from('staging'); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name') + ->executeMerge(); + + $this->assertBindingCount($result); + $this->assertSame('MERGE INTO "users" USING (SELECT * FROM "staging") AS "src" ON users.id = src.id WHEN MATCHED THEN UPDATE SET name = src.name', $result->query); + $this->assertStringNotContainsString('WHEN NOT MATCHED', $result->query); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/OrderedSetAggregatesTest.php b/tests/Query/Builder/Feature/PostgreSQL/OrderedSetAggregatesTest.php new file mode 100644 index 0000000..d3a8722 --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/OrderedSetAggregatesTest.php @@ -0,0 +1,118 @@ +from('users') + ->arrayAgg('name', 'names') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT ARRAY_AGG("name") AS "names" FROM "users"', $result->query); + } + + public function testBoolAndBoolOrAndEveryEmitCorrectFunctions(): void + { + $result = (new Builder()) + ->from('t') + ->boolAnd('a', 'ba') + ->boolOr('b', 'bo') + ->every('c', 'ev') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT BOOL_AND("a") AS "ba", BOOL_OR("b") AS "bo", EVERY("c") AS "ev" FROM "t"', $result->query); + } + + public function testPercentileContBindsFractionFirst(): void + { + $result = (new Builder()) + ->from('scores') + ->percentileCont(0.5, 'value', 'median') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT PERCENTILE_CONT(?) WITHIN GROUP (ORDER BY "value") AS "median" FROM "scores"', $result->query); + $this->assertSame([0.5], $result->bindings); + } + + public function testPercentileDiscUsesPercentileDiscFunction(): void + { + $result = (new Builder()) + ->from('scores') + ->percentileDisc(0.95, 'value', 'p95') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT PERCENTILE_DISC(?) WITHIN GROUP (ORDER BY "value") AS "p95" FROM "scores"', $result->query); + $this->assertSame([0.95], $result->bindings); + } + + public function testArrayAggWithoutAliasOmitsAsClause(): void + { + $result = (new Builder()) + ->from('users') + ->arrayAgg('name') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT ARRAY_AGG("name") FROM "users"', $result->query); + $this->assertStringNotContainsString('AS ""', $result->query); + } + + public function testModeEmitsModeWithinGroup(): void + { + $result = (new Builder()) + ->from('users') + ->mode('city') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT MODE() WITHIN GROUP (ORDER BY "city") FROM "users"', $result->query); + $this->assertStringNotContainsString('AS ""', $result->query); + } + + public function testModeWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->mode('city', 'top_city') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT MODE() WITHIN GROUP (ORDER BY "city") AS "top_city" FROM "users"', $result->query); + } + + public function testModeWithQualifiedColumn(): void + { + $result = (new Builder()) + ->from('users') + ->mode('users.city', 'top_city') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT MODE() WITHIN GROUP (ORDER BY "users"."city") AS "top_city" FROM "users"', $result->query); + } + + public function testTwoPercentilesBindFractionsInCallOrder(): void + { + $result = (new Builder()) + ->from('scores') + ->percentileCont(0.25, 'value', 'p25') + ->percentileCont(0.75, 'value', 'p75') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([0.25, 0.75], $result->bindings); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/ReturningTest.php b/tests/Query/Builder/Feature/PostgreSQL/ReturningTest.php new file mode 100644 index 0000000..e75aeef --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/ReturningTest.php @@ -0,0 +1,90 @@ +into('users') + ->set(['name' => 'John']) + ->returning(['id', 'name']) + ->insert(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT INTO "users" ("name") VALUES (?) RETURNING "id", "name"', $result->query); + } + + public function testReturningDefaultIsStarWildcard(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning() + ->insert(); + + $this->assertBindingCount($result); + $this->assertSame('INSERT INTO "users" ("name") VALUES (?) RETURNING *', $result->query); + } + + public function testReturningEmptyArrayEmitsNoReturningClause(): void + { + // Passing an empty list means "no columns to return"; the builder + // must not emit RETURNING at all rather than degenerate to "RETURNING *". + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning([]) + ->insert(); + + $this->assertBindingCount($result); + $this->assertStringNotContainsString('RETURNING', $result->query); + } + + public function testUpdateReturningEmitsReturningClause(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->update(); + + $this->assertBindingCount($result); + $this->assertSame('UPDATE "users" SET "name" = ? WHERE "id" IN (?) RETURNING "id"', $result->query); + } + + public function testDeleteReturningEmitsReturningClause(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->returning(['id']) + ->delete(); + + $this->assertBindingCount($result); + $this->assertSame('DELETE FROM "users" WHERE "id" IN (?) RETURNING "id"', $result->query); + } + + public function testReturningBindingsUnchanged(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Jane']) + ->filter([Query::equal('id', [42])]) + ->returning(['id', 'name']) + ->update(); + + $this->assertBindingCount($result); + // RETURNING should not add bindings; only SET and WHERE contribute. + $this->assertSame([0 => 'Jane', 1 => 42], $result->bindings); + } +} diff --git a/tests/Query/Builder/Feature/PostgreSQL/VectorSearchTest.php b/tests/Query/Builder/Feature/PostgreSQL/VectorSearchTest.php new file mode 100644 index 0000000..a316818 --- /dev/null +++ b/tests/Query/Builder/Feature/PostgreSQL/VectorSearchTest.php @@ -0,0 +1,79 @@ +from('items') + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "items" ORDER BY ("embedding" <=> ?::vector) ASC', $result->query); + } + + public function testOrderByVectorDistanceEuclideanUsesL2Operator(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Euclidean) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "items" ORDER BY ("embedding" <-> ?::vector) ASC', $result->query); + } + + public function testOrderByVectorDistanceDotUsesInnerProductOperator(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('embedding', [1.0, 2.0], VectorMetric::Dot) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "items" ORDER BY ("embedding" <#> ?::vector) ASC', $result->query); + } + + public function testOrderByVectorDistanceSerializesVectorAsPgvectorLiteral(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('[0.1,0.2,0.3]', $result->bindings[0]); + } + + public function testOrderByVectorDistanceEmptyVectorStillBindsValue(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('embedding', [], VectorMetric::Cosine) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('[]', $result->bindings[0]); + } + + public function testOrderByVectorDistanceQuotesAttributeIdentifier(): void + { + $result = (new Builder()) + ->from('items') + ->orderByVectorDistance('embedding', [1.0], VectorMetric::Cosine) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "items" ORDER BY ("embedding" <=> ?::vector) ASC', $result->query); + } +} diff --git a/tests/Query/Builder/Feature/SpatialTest.php b/tests/Query/Builder/Feature/SpatialTest.php new file mode 100644 index 0000000..2c8f1f4 --- /dev/null +++ b/tests/Query/Builder/Feature/SpatialTest.php @@ -0,0 +1,116 @@ +from('places') + ->filterDistance('coords', [10.5, 20.25], '<', 100.0) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([0 => 'POINT(10.5 20.25)', 1 => 100.0], $result->bindings); + } + + public function testFilterIntersectsQuotesIdentifierForMySQL(): void + { + $result = (new MySQLBuilder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `zones` WHERE ST_Intersects(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterIntersectsQuotesIdentifierForPostgreSQL(): void + { + $result = (new PostgreSQLBuilder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "zones" WHERE ST_Intersects("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterNotIntersectsWrapsWithNot(): void + { + $result = (new MySQLBuilder()) + ->from('zones') + ->filterNotIntersects('area', [1.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `zones` WHERE NOT ST_Intersects(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterCoversProducesStCoversOnPostgreSQL(): void + { + $result = (new PostgreSQLBuilder()) + ->from('zones') + ->filterCovers('region', [1.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM "zones" WHERE ST_Covers("region", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterSpatialEqualsProducesStEquals(): void + { + $result = (new MySQLBuilder()) + ->from('zones') + ->filterSpatialEquals('area', [3.0, 4.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `zones` WHERE ST_Equals(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterTouchesProducesStTouches(): void + { + $result = (new MySQLBuilder()) + ->from('zones') + ->filterTouches('area', [1.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `zones` WHERE ST_Touches(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterCrossesLineStringBindingIsLinestringWkt(): void + { + $result = (new MySQLBuilder()) + ->from('paths') + ->filterCrosses('path', [[0.0, 0.0], [1.0, 1.0]]) + ->build(); + + $this->assertBindingCount($result); + $this->assertIsString($result->bindings[0]); + $this->assertSame('LINESTRING(0 0, 1 1)', $result->bindings[0]); + } + + public function testFilterOverlapsChainedAddsAllBindings(): void + { + $result = (new MySQLBuilder()) + ->from('zones') + ->filterOverlaps('a', [1.0, 1.0]) + ->filterNotOverlaps('b', [2.0, 2.0]) + ->build(); + + $this->assertBindingCount($result); + $this->assertCount(2, $result->bindings); + $this->assertSame('POINT(1 1)', $result->bindings[0]); + $this->assertSame('POINT(2 2)', $result->bindings[1]); + } +} diff --git a/tests/Query/Builder/Feature/StatisticalAggregatesTest.php b/tests/Query/Builder/Feature/StatisticalAggregatesTest.php new file mode 100644 index 0000000..e72b297 --- /dev/null +++ b/tests/Query/Builder/Feature/StatisticalAggregatesTest.php @@ -0,0 +1,120 @@ +from('scores') + ->stddev('value', 'sd') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT STDDEV(`value`) AS `sd` FROM `scores`', $result->query); + } + + public function testStddevPopAndSampEmitSeparateFunctions(): void + { + $result = (new MySQLBuilder()) + ->from('scores') + ->stddevPop('v', 'sp') + ->stddevSamp('v', 'ss') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT STDDEV_POP(`v`) AS `sp`, STDDEV_SAMP(`v`) AS `ss` FROM `scores`', $result->query); + } + + public function testVarianceAndVarPopAndVarSampEmitCorrectFunctions(): void + { + $result = (new MySQLBuilder()) + ->from('scores') + ->variance('v', 'a') + ->varPop('v', 'b') + ->varSamp('v', 'c') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT VARIANCE(`v`) AS `a`, VAR_POP(`v`) AS `b`, VAR_SAMP(`v`) AS `c` FROM `scores`', $result->query); + } + + public function testStddevOnPostgreSQLUsesDoubleQuoting(): void + { + $result = (new PostgreSQLBuilder()) + ->from('scores') + ->stddev('value', 'sd') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT STDDEV("value") AS "sd" FROM "scores"', $result->query); + } + + public function testStddevOnClickHouseUsesBacktickQuoting(): void + { + $result = (new ClickHouseBuilder()) + ->from('scores') + ->stddev('value', 'sd') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT stddevPop(`value`) AS `sd` FROM `scores`', $result->query); + } + + public function testVarianceOnClickHouseEmitsVarPop(): void + { + $result = (new ClickHouseBuilder()) + ->from('scores') + ->variance('value', 'var') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT varPop(`value`) AS `var` FROM `scores`', $result->query); + $this->assertStringNotContainsString('VARIANCE(', $result->query); + } + + public function testStatisticalAggregateDoesNotAddBindings(): void + { + $result = (new MySQLBuilder()) + ->from('scores') + ->stddev('value', 'sd') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testStatisticalAggregateWithWhereUsesCorrectBindingOrder(): void + { + $result = (new MySQLBuilder()) + ->from('scores') + ->stddev('value', 'sd') + ->filter([Query::equal('category', ['a'])]) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame(['a'], $result->bindings); + } + + public function testStddevWithoutAliasOmitsAs(): void + { + $result = (new MySQLBuilder()) + ->from('scores') + ->stddev('value') + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('SELECT STDDEV(`value`) FROM `scores`', $result->query); + $this->assertStringNotContainsString('AS ``', $result->query); + } +} diff --git a/tests/Query/Builder/Feature/UnionsTest.php b/tests/Query/Builder/Feature/UnionsTest.php new file mode 100644 index 0000000..7628696 --- /dev/null +++ b/tests/Query/Builder/Feature/UnionsTest.php @@ -0,0 +1,170 @@ +from('admins') + ->filter([Query::equal('role', ['admin'])]); + + $result = (new MySQLBuilder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->union($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query, + ); + $this->assertSame(['active', 'admin'], $result->bindings); + } + + public function testUnionAllEmitsAllKeyword(): void + { + $other = (new MySQLBuilder())->from('archive'); + + $result = (new MySQLBuilder()) + ->from('current') + ->unionAll($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + '(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', + $result->query, + ); + } + + public function testIntersectEmitsIntersectKeyword(): void + { + $other = (new PostgreSQLBuilder())->from('b'); + + $result = (new PostgreSQLBuilder()) + ->from('a') + ->intersect($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('(SELECT * FROM "a") INTERSECT (SELECT * FROM "b")', $result->query); + } + + public function testIntersectAllEmitsIntersectAllKeyword(): void + { + $other = (new PostgreSQLBuilder())->from('b'); + + $result = (new PostgreSQLBuilder()) + ->from('a') + ->intersectAll($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('(SELECT * FROM "a") INTERSECT ALL (SELECT * FROM "b")', $result->query); + } + + public function testExceptEmitsExceptKeyword(): void + { + $other = (new PostgreSQLBuilder())->from('b'); + + $result = (new PostgreSQLBuilder()) + ->from('a') + ->except($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('(SELECT * FROM "a") EXCEPT (SELECT * FROM "b")', $result->query); + } + + public function testExceptAllEmitsExceptAllKeyword(): void + { + $other = (new PostgreSQLBuilder())->from('b'); + + $result = (new PostgreSQLBuilder()) + ->from('a') + ->exceptAll($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame('(SELECT * FROM "a") EXCEPT ALL (SELECT * FROM "b")', $result->query); + } + + public function testBindingsAppendInArmOrder(): void + { + $q2 = (new MySQLBuilder()) + ->from('t2') + ->filter([Query::equal('year', [2023])]); + $q3 = (new MySQLBuilder()) + ->from('t3') + ->filter([Query::equal('year', [2022])]); + + $result = (new MySQLBuilder()) + ->from('t1') + ->filter([Query::equal('year', [2024])]) + ->union($q2) + ->unionAll($q3) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame([2024, 2023, 2022], $result->bindings); + } + + public function testSQLiteStripsParensFromUnionArms(): void + { + // SQLite's compound-SELECT parser rejects parenthesised members, + // so the builder must emit bare SELECTs joined by UNION. + $other = (new SQLiteBuilder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new SQLiteBuilder()) + ->from('users') + ->select(['id', 'name']) + ->union($other) + ->build(); + + $this->assertBindingCount($result); + $this->assertSame( + 'SELECT `id`, `name` FROM `users` UNION SELECT `id`, `name` FROM `archived_users`', + $result->query, + ); + $this->assertStringNotContainsString('(SELECT', $result->query); + } + + public function testSQLiteStripsParensAcrossMultipleCompoundOps(): void + { + $q2 = (new SQLiteBuilder())->from('t2'); + $q3 = (new SQLiteBuilder())->from('t3'); + + $result = (new SQLiteBuilder()) + ->from('t1') + ->union($q2) + ->unionAll($q3) + ->build(); + + $this->assertBindingCount($result); + $this->assertStringNotContainsString('(SELECT', $result->query); + $this->assertSame('SELECT * FROM `t1` UNION SELECT * FROM `t2` UNION ALL SELECT * FROM `t3`', $result->query); + } + + public function testChainableReturnsSameInstance(): void + { + $builder = new MySQLBuilder(); + $other = (new MySQLBuilder())->from('t2'); + $returned = $builder->from('t1')->unionAll($other); + + $this->assertSame($builder, $returned); + } +} diff --git a/tests/Query/Builder/MariaDBTest.php b/tests/Query/Builder/MariaDBTest.php new file mode 100644 index 0000000..2724310 --- /dev/null +++ b/tests/Query/Builder/MariaDBTest.php @@ -0,0 +1,1577 @@ +assertInstanceOf(Compiler::class, new Builder()); + } + + public function testImplementsJson(): void + { + $this->assertInstanceOf(Json::class, new Builder()); + } + + public function testImplementsConditionalAggregates(): void + { + $this->assertInstanceOf(ConditionalAggregates::class, new Builder()); + } + + public function testImplementsHints(): void + { + $this->assertInstanceOf(Hints::class, new Builder()); + } + + public function testImplementsLateralJoins(): void + { + $this->assertInstanceOf(LateralJoins::class, new Builder()); + } + + public function testBasicSelect(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t`', $result->query); + } + + public function testSelectWithFilters(): 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->assertSame( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['active', 18, 25, 0], $result->bindings); + } + + public function testGeomFromTextWithoutAxisOrder(): void + { + $result = (new Builder()) + ->from('locations') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_Intersects(`area`, ST_GeomFromText(?, 4326))', $result->query); + $this->assertStringNotContainsString('axis-order', $result->query); + } + + public function testFilterDistanceMetersUsesDistanceSphere(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_DISTANCE_SPHERE(`coords`, ST_GeomFromText(?, 4326)) < ?', $result->query); + $this->assertSame('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertSame(5000.0, $result->bindings[1]); + } + + public function testFilterDistanceNoMetersUsesStDistance(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], '>', 100.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_Distance(`coords`, ST_GeomFromText(?, 4326)) > ?', $result->query); + $this->assertStringNotContainsString('ST_DISTANCE_SPHERE', $result->query); + } + + public function testSpatialDistanceLessThanMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) < ?', $result->query); + } + + public function testSpatialDistanceGreaterThanNoMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceGreaterThan('attr', [0, 0], 500, false)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`attr`, ST_GeomFromText(?, 4326)) > ?', $result->query); + $this->assertStringNotContainsString('ST_DISTANCE_SPHERE', $result->query); + } + + public function testSpatialDistanceEqualMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceEqual('attr', [10, 20], 100, true)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) = ?', $result->query); + } + + public function testSpatialDistanceNotEqualNoMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceNotEqual('attr', [10, 20], 50, false)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`attr`, ST_GeomFromText(?, 4326)) != ?', $result->query); + } + + public function testSpatialDistanceMetersNonPointTypeThrowsValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Distance in meters is not supported between'); + + $query = Query::distanceLessThan('attr', [[0, 0], [1, 1], [2, 2]], 1000, true); + $query->setAttributeType('linestring'); + + (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + } + + public function testSpatialDistanceMetersPointTypeWithPointAttribute(): void + { + $query = Query::distanceLessThan('attr', [10, 20], 1000, true); + $query->setAttributeType('point'); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) < ?', $result->query); + } + + public function testSpatialDistanceMetersWithEmptyAttributeTypePassesThrough(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) < ?', $result->query); + } + + public function testSpatialDistanceMetersPolygonAttributeThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Distance in meters is not supported between polygon and point'); + + $query = Query::distanceLessThan('attr', [10, 20], 1000, true); + $query->setAttributeType('polygon'); + + (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + } + + public function testSpatialDistanceNoMetersDoesNotValidateType(): void + { + $query = Query::distanceLessThan('attr', [[0, 0], [1, 1], [2, 2]], 1000, false); + $query->setAttributeType('linestring'); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`attr`, ST_GeomFromText(?, 4326)) < ?', $result->query); + } + + public function testFilterIntersectsUsesMariaDbGeomFromText(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE ST_Intersects(`area`, ST_GeomFromText(?, 4326))', $result->query); + $this->assertSame('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->assertSame('SELECT * FROM `zones` WHERE NOT 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->assertSame('SELECT * FROM `zones` WHERE 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->assertSame('SELECT * FROM `zones` WHERE ST_Equals(`area`, ST_GeomFromText(?, 4326))', $result->query); + } + + public function testSpatialCrosses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::crosses('attr', [1.0, 2.0])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Crosses(`attr`, ST_GeomFromText(?, 4326))', $result->query); + } + + public function testSpatialTouches(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::touches('attr', [1.0, 2.0])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Touches(`attr`, ST_GeomFromText(?, 4326))', $result->query); + } + + public function testSpatialOverlaps(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::overlaps('attr', [[0, 0], [1, 1]])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Overlaps(`attr`, ST_GeomFromText(?, 4326))', $result->query); + } + + public function testSpatialWithLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))', $wkt); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + } + + public function testUpsertUsesOnDuplicateKey(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.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 + ); + } + + public function testUpsertWithReturningThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('MariaDB does not support RETURNING with ON DUPLICATE KEY UPDATE'); + + (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['id'], ['name', 'email']) + ->returning(['id']) + ->upsert(); + } + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + } + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['inactive'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', + $result->query + ); + } + + public function testDeleteWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::lessThan('last_login', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'DELETE FROM `users` WHERE `last_login` < ?', + $result->query + ); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` ORDER BY RAND()', $result->query); + } + + public function testRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + } + + public function testSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE)', $result->query); + $this->assertSame(['hello*'], $result->bindings); + } + + public function testExplain(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + } + + public function testExplainAnalyze(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + public function testTransactionStatements(): void + { + $builder = new Builder(); + + $this->assertSame('BEGIN', $builder->begin()->query); + $this->assertSame('COMMIT', $builder->commit()->query); + $this->assertSame('ROLLBACK', $builder->rollback()->query); + } + + public function testForUpdate(): void + { + $result = (new Builder()) + ->from('t') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` FOR UPDATE', $result->query); + } + + public function testHintInSelect(): void + { + $result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ NO_INDEX_MERGE(users) */ * FROM `users`', $result->query); + } + + public function testMaxExecutionTime(): void + { + $result = (new Builder()) + ->from('users') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM `users`', $result->query); + } + + public function testSetJsonAppend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new_tag']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `docs` SET `tags` = JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `docs` SET `tags` = JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY())) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = JSON_ARRAY_INSERT(`tags`, ?, ?) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = JSON_REMOVE(`tags`, JSON_UNQUOTE(JSON_SEARCH(`tags`, \'one\', ?))) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPath(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPath('data', '$.name', 'NewValue') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `docs` SET `data` = JSON_SET(`data`, ?, ?) WHERE `id` IN (?)', + $result->query + ); + $this->assertSame('$.name', $result->bindings[0]); + $this->assertSame('NewValue', $result->bindings[1]); + $this->assertSame(1, $result->bindings[2]); + } + + public function testSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE JSON_CONTAINS(?, val)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE NOT JSON_CONTAINS(?, val)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonUnique(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM (SELECT DISTINCT val FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt) AS dt) WHERE `id` IN (?)', $result->query); + } + + public function testFilterJsonContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('meta', 'admin') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_CONTAINS(`meta`, ?)', $result->query); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE NOT JSON_CONTAINS(`meta`, ?)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_OVERLAPS(`tags`, ?)', $result->query); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE JSON_EXTRACT(`data`, \'$.age\') >= ?', $result->query); + $this->assertSame(21, $result->bindings[0]); + } + + public function testCountWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? THEN 1 END) AS `active_count` FROM `orders`', $result->query); + } + + public function testSumWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', 'total_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(CASE WHEN status = ? THEN `amount` END) AS `total_active` FROM `orders`', $result->query); + } + + public function testExactSpatialDistanceMetersQuery(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '<', 5000.0, true) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `locations` WHERE ST_DISTANCE_SPHERE(`coords`, ST_GeomFromText(?, 4326)) < ?', + $result->query + ); + $this->assertSame(['POINT(40.7128 -74.006)', 5000.0], $result->bindings); + } + + public function testExactSpatialDistanceNoMetersQuery(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], '>', 100.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `locations` WHERE ST_Distance(`coords`, ST_GeomFromText(?, 4326)) > ?', + $result->query + ); + $this->assertSame(['POINT(1 2)', 100.0], $result->bindings); + } + + public function testExactIntersectsQuery(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `zones` WHERE ST_Intersects(`area`, ST_GeomFromText(?, 4326))', + $result->query + ); + $this->assertSame(['POINT(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->assertSame('SELECT * FROM `orders` WHERE `total` > ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testSpatialDistanceGreaterThanMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceGreaterThan('attr', [5, 10], 2000, true)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) > ?', $result->query); + } + + public function testSpatialDistanceNotEqualMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceNotEqual('attr', [5, 10], 500, true)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_DISTANCE_SPHERE(`attr`, ST_GeomFromText(?, 4326)) != ?', $result->query); + } + + public function testSpatialDistanceEqualNoMeters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::distanceEqual('attr', [5, 10], 500, false)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`attr`, ST_GeomFromText(?, 4326)) = ?', $result->query); + $this->assertStringNotContainsString('ST_DISTANCE_SPHERE', $result->query); + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`attr`, ST_GeomFromText(?, 4326)) = ?', $result->query); + } + + public function testSpatialDistanceWktString(): void + { + $query = new Query(Method::DistanceLessThan, 'coords', [['POINT(10 20)', 500.0, false]]); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(`coords`, ST_GeomFromText(?, 4326)) < ?', $result->query); + $this->assertContains('POINT(10 20)', $result->bindings); + } + + public function testCteJoinWhereGroupByHavingOrderLimit(): void + { + $cte = (new Builder()) + ->from('raw_orders') + ->select(['customer_id', 'amount']) + ->filter([Query::greaterThan('amount', 0)]); + + $result = (new Builder()) + ->with('filtered_orders', $cte) + ->from('filtered_orders') + ->join('customers', 'filtered_orders.customer_id', 'customers.id') + ->filter([Query::equal('customers.active', [1])]) + ->sum('filtered_orders.amount', 'total') + ->groupBy(['customers.country']) + ->having([Query::greaterThan('total', 100)]) + ->sortDesc('total') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `filtered_orders` AS (SELECT `customer_id`, `amount` FROM `raw_orders` WHERE `amount` > ?) SELECT SUM(`filtered_orders`.`amount`) AS `total` FROM `filtered_orders` JOIN `customers` ON `filtered_orders`.`customer_id` = `customers`.`id` WHERE `customers`.`active` IN (?) GROUP BY `customers`.`country` HAVING SUM(`filtered_orders`.`amount`) > ? ORDER BY `total` DESC LIMIT ?', $result->query); + } + + public function testWindowFunctionWithJoin(): void + { + $result = (new Builder()) + ->from('sales') + ->join('products', 'sales.product_id', 'products.id') + ->selectWindow('ROW_NUMBER()', 'rn', ['products.category'], ['sales.amount']) + ->select(['products.name', 'sales.amount']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `products`.`name`, `sales`.`amount`, ROW_NUMBER() OVER (PARTITION BY `products`.`category` ORDER BY `sales`.`amount` ASC) AS `rn` FROM `sales` JOIN `products` ON `sales`.`product_id` = `products`.`id`', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('ROW_NUMBER()', 'rn', ['department'], ['salary']) + ->selectWindow('RANK()', 'rnk', ['department'], ['-salary']) + ->select(['name', 'department', 'salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` ASC) AS `rn`, RANK() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `rnk` FROM `employees`', $result->query); + } + + public function testJoinAggregateHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->count('*', 'order_count') + ->sum('orders.total', 'revenue') + ->groupBy(['customers.country']) + ->having([Query::greaterThan('order_count', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `order_count`, SUM(`orders`.`total`) AS `revenue` FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` GROUP BY `customers`.`country` HAVING COUNT(*) > ?', $result->query); + } + + public function testUnionAllWithOrderLimit(): void + { + $archive = (new Builder()) + ->from('orders_archive') + ->select(['id', 'total', 'created_at']) + ->filter([Query::greaterThan('created_at', '2023-01-01')]); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'total', 'created_at']) + ->unionAll($archive) + ->sortDesc('created_at') + ->limit(50) + ->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` WHERE `created_at` > ?)', $result->query); + } + + public function testSubSelectWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->sum('total', 'total_spent') + ->groupBy(['customer_id']); + + $result = (new Builder()) + ->from('customers') + ->selectSub($sub, 'spending') + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT (SELECT SUM(`total`) AS `total_spent`, `customer_id` FROM `orders` GROUP BY `customer_id`) AS `spending` FROM `customers` WHERE `active` IN (?)', $result->query); + } + + public function testFilterWhereInSubquery(): void + { + $sub = (new Builder()) + ->from('premium_users') + ->select(['id']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $sub) + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `total` > ? AND `user_id` IN (SELECT `id` FROM `premium_users` WHERE `tier` IN (?))', $result->query); + } + + public function testExistsSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->filter([Query::raw('orders.customer_id = customers.id')]) + ->filter([Query::greaterThan('total', 1000)]); + + $result = (new Builder()) + ->from('customers') + ->filterExists($sub) + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `customers` WHERE `active` IN (?) AND EXISTS (SELECT * FROM `orders` WHERE orders.customer_id = customers.id AND `total` > ?)', $result->query); + } + + public function testUpsertOnDuplicateKeyUpdate(): void + { + $result = (new Builder()) + ->into('counters') + ->set(['id' => 1, 'name' => 'visits', 'count' => 1]) + ->onConflict(['id'], ['count']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `counters` (`id`, `name`, `count`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `count` = VALUES(`count`)', $result->query); + } + + public function testInsertSelectQuery(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['name', 'email']) + ->filter([Query::equal('imported', [0])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`name`, `email`) SELECT `name`, `email` FROM `staging` WHERE `imported` IN (?)', $result->query); + } + + public function testCaseExpressionWithAggregate(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'active') + ->when('status', Operator::Equal, 'inactive', 'inactive') + ->else('other') + ->alias('label'); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->count('*', 'cnt') + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `users` GROUP BY `status`', $result->query); + } + + public function testBeforeBuildCallback(): void + { + $callbackCalled = false; + $result = (new Builder()) + ->from('users') + ->beforeBuild(function (Builder $b) use (&$callbackCalled) { + $callbackCalled = true; + $b->filter([Query::equal('injected', ['yes'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($callbackCalled); + $this->assertSame('SELECT * FROM `users` WHERE `injected` IN (?)', $result->query); + } + + public function testAfterBuildCallback(): void + { + $capturedQuery = ''; + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->afterBuild(function (Statement $r) use (&$capturedQuery) { + $capturedQuery = 'executed'; + return $r; + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('executed', $capturedQuery); + } + + public function testNestedLogicalFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::or([ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]), + Query::and([ + Query::lessThan('score', 50), + Query::notEqual('role', 'admin'), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE ((`status` IN (?) AND `age` > ?) OR (`score` < ? AND `role` != ?))', $result->query); + } + + public function testTripleJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->join('products', 'orders.product_id', 'products.id') + ->leftJoin('categories', 'products.category_id', 'categories.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` JOIN `products` ON `orders`.`product_id` = `products`.`id` LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id`', $result->query); + } + + public function testSelfJoinWithAlias(): void + { + $result = (new Builder()) + ->from('employees', 'e') + ->leftJoin('employees', 'e.manager_id', 'm.id', '=', 'm') + ->select(['e.name', 'm.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `e`.`name`, `m`.`name` FROM `employees` AS `e` LEFT JOIN `employees` AS `m` ON `e`.`manager_id` = `m`.`id`', $result->query); + } + + public function testDistinctWithCount(): void + { + $result = (new Builder()) + ->from('orders') + ->distinct() + ->countDistinct('customer_id', 'unique_customers') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT COUNT(DISTINCT `customer_id`) AS `unique_customers` FROM `orders`', $result->query); + } + + public function testBindingOrderVerification(): void + { + $cte = (new Builder()) + ->from('raw') + ->filter([Query::greaterThan('val', 0)]); + + $result = (new Builder()) + ->with('filtered', $cte) + ->from('filtered') + ->filter([Query::equal('status', ['active'])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(0, $result->bindings[0]); + $this->assertSame('active', $result->bindings[1]); + $this->assertSame(5, $result->bindings[2]); + } + + public function testCloneAndModify(): void + { + $original = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('age', 18)]); + + $origResult = $original->build(); + $clonedResult = $cloned->build(); + $this->assertBindingCount($origResult); + $this->assertBindingCount($clonedResult); + + $this->assertStringNotContainsString('`age`', $origResult->query); + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `age` > ?', $clonedResult->query); + } + + public function testReadOnlyFlagOnSelect(): void + { + $result = (new Builder()) + ->from('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($result->readOnly); + } + + public function testReadOnlyFlagOnUpdate(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertFalse($result->readOnly); + } + + public function testMultipleSortDirections(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('last_name') + ->sortDesc('created_at') + ->sortAsc('first_name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `users` ORDER BY `last_name` ASC, `created_at` DESC, `first_name` ASC', + $result->query + ); + } + + public function testBooleanAndNullFilterValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('active', [true]), + Query::equal('deleted', [false]), + Query::isNull('suspended_at'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([true, false], $result->bindings); + $this->assertSame('SELECT * FROM `users` WHERE `active` IN (?) AND `deleted` IN (?) AND `suspended_at` IS NULL', $result->query); + } + + public function testGroupByMultipleColumns(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['region', 'category', 'year']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `region`, `category`, `year`', $result->query); + } + + public function testWindowWithNamedDefinition(): void + { + $result = (new Builder()) + ->from('sales') + ->window('w', ['category'], ['date']) + ->selectWindow('SUM(amount)', 'running', null, null, 'w') + ->select(['category', 'date', 'amount']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `category`, `date`, `amount`, SUM(amount) OVER `w` AS `running` FROM `sales` WINDOW `w` AS (PARTITION BY `category` ORDER BY `date` ASC)', $result->query); + } + + public function testInsertBatchMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'email' => 'b@b.com']) + ->set(['name' => 'Charlie', 'email' => 'c@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?), (?, ?)', $result->query); + $this->assertSame(['Alice', 'a@b.com', 'Bob', 'b@b.com', 'Charlie', 'c@b.com'], $result->bindings); + } + + public function testDeleteWithComplexFilter(): void + { + $result = (new Builder()) + ->from('sessions') + ->filter([ + Query::or([ + Query::lessThan('expires_at', '2024-01-01'), + Query::equal('revoked', [1]), + ]), + ]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('DELETE FROM `sessions`', $result->query); + $this->assertSame('DELETE FROM `sessions` WHERE (`expires_at` < ? OR `revoked` IN (?))', $result->query); + } + + public function testCountWhenWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'completed', 'completed') + ->countWhen('status = ?', 'pending', 'pending') + ->groupBy(['region']) + ->having([Query::greaterThan('completed', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? THEN 1 END) AS `completed`, COUNT(CASE WHEN status = ? THEN 1 END) AS `pending` FROM `orders` GROUP BY `region` HAVING `completed` > ?', $result->query); + } + + public function testFilterWhereNotInSubquery(): void + { + $sub = (new Builder()) + ->from('blocked') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `id` NOT IN (SELECT `user_id` FROM `blocked`)', $result->query); + } + + public function testFromSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('events') + ->select(['user_id']) + ->count('*', 'event_count') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'user_events') + ->filter([Query::greaterThan('event_count', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM (SELECT COUNT(*) AS `event_count`, `user_id` FROM `events` GROUP BY `user_id`) AS `user_events` WHERE `event_count` > ?', $result->query); + } + + public function testLimitOneOffsetZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(1) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([1, 0], $result->bindings); + } + + public function testBetweenWithNotEqual(): void + { + $result = (new Builder()) + ->from('products') + ->filter([ + Query::between('price', 10, 100), + Query::notEqual('status', 'discontinued'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `products` WHERE `price` BETWEEN ? AND ? AND `status` != ?', $result->query); + } + + public function testIsNullIsNotNullCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('email'), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `deleted_at` IS NULL AND `email` IS NOT NULL AND `status` IN (?)', $result->query); + } + + public function testCrossJoin(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('config') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` CROSS JOIN `config`', $result->query); + } + + public function testRecursiveCte(): void + { + $seed = (new Builder()) + ->from('categories') + ->select(['id', 'name', 'parent_id']) + ->filter([Query::isNull('parent_id')]); + + $step = (new Builder()) + ->from('categories') + ->select(['categories.id', 'categories.name', 'categories.parent_id']) + ->join('tree', 'categories.parent_id', 'tree.id'); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH RECURSIVE `tree` AS (SELECT `id`, `name`, `parent_id` FROM `categories` WHERE `parent_id` IS NULL UNION ALL SELECT `categories`.`id`, `categories`.`name`, `categories`.`parent_id` FROM `categories` JOIN `tree` ON `categories`.`parent_id` = `tree`.`id`) SELECT * FROM `tree`', $result->query); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $stripped = \preg_replace('/`[^`]+`/', '', $result->query) ?? ''; + // Lowercase reserved word must not appear bare outside quotes + $this->assertDoesNotMatchRegularExpression( + '/(?from($word) + ->build(); + + $this->assertStringContainsString('FROM `' . $word . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testWhereRawAppendsFragmentAndBindings(): void + { + $result = (new Builder()) + ->from('users') + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE a = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testWhereRawCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('b', [2])]) + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `b` IN (?) AND a = ?', $result->query); + $this->assertContains(1, $result->bindings); + $this->assertContains(2, $result->bindings); + } + + public function testWhereColumnEmitsQualifiedIdentifiers(): void + { + $result = (new Builder()) + ->from('users') + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testWhereColumnRejectsUnknownOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid whereColumn operator: NOT_AN_OP'); + + (new Builder()) + ->from('users') + ->whereColumn('a', 'NOT_AN_OP', 'b'); + } + + public function testWhereColumnCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertContains('active', $result->bindings); + } + + public function testImplementsSequences(): void + { + $this->assertInstanceOf(Sequences::class, new Builder()); + } + + public function testNextValEmitsSequenceCall(): void + { + $result = (new Builder()) + ->fromNone() + ->nextVal('seq_user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT NEXTVAL(`seq_user_id`)', $result->query); + } + + public function testCurrValEmitsSequenceCall(): void + { + $result = (new Builder()) + ->fromNone() + ->currVal('seq_user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT LASTVAL(`seq_user_id`)', $result->query); + } + + public function testNextValWithAlias(): void + { + $result = (new Builder()) + ->fromNone() + ->nextVal('seq_user_id', 'next_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT NEXTVAL(`seq_user_id`) AS `next_id`', $result->query); + } + + public function testNextValRejectsInvalidName(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid sequence name'); + + (new Builder())->nextVal('bad name; DROP TABLE x'); + } +} diff --git a/tests/Query/Builder/MongoDBTest.php b/tests/Query/Builder/MongoDBTest.php new file mode 100644 index 0000000..4d5347c --- /dev/null +++ b/tests/Query/Builder/MongoDBTest.php @@ -0,0 +1,5672 @@ +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 testImplementsWindows(): void + { + $this->assertInstanceOf(Windows::class, new Builder()); + } + + public function testImplementsUpsert(): void + { + $this->assertInstanceOf(Upsert::class, new Builder()); + } + + public function testImplementsFullTextSearch(): void + { + $this->assertInstanceOf(FullTextSearch::class, new Builder()); + } + + public function testImplementsTableSampling(): void + { + $this->assertInstanceOf(TableSampling::class, new Builder()); + } + + public function testBasicSelect(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('users', $op['collection']); + $this->assertSame('find', $op['operation']); + $this->assertSame(['name' => 1, 'email' => 1, '_id' => 0], $op['projection']); + $this->assertEmpty($result->bindings); + } + + public function testSelectAll(): void + { + $result = (new Builder()) + ->from('users') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertArrayNotHasKey('projection', $op); + } + + public function testFilterEqual(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['status' => '?'], $op['filter']); + $this->assertSame(['active'], $result->bindings); + } + + public function testFilterEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active', 'pending'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['status' => ['$in' => ['?', '?']]], $op['filter']); + $this->assertSame(['active', 'pending'], $result->bindings); + } + + public function testFilterNotEqual(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEqual('status', ['deleted'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['status' => ['$ne' => '?']], $op['filter']); + $this->assertSame(['deleted'], $result->bindings); + } + + public function testFilterNotEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEqual('status', ['deleted', 'banned'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['status' => ['$nin' => ['?', '?']]], $op['filter']); + $this->assertSame(['deleted', 'banned'], $result->bindings); + } + + public function testFilterGreaterThan(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::greaterThan('age', 25)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['age' => ['$gt' => '?']], $op['filter']); + $this->assertSame([25], $result->bindings); + } + + public function testFilterLessThan(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['age' => ['$lt' => '?']], $op['filter']); + $this->assertSame([30], $result->bindings); + } + + public function testFilterGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::greaterThanEqual('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['age' => ['$gte' => '?']], $op['filter']); + $this->assertSame([18], $result->bindings); + } + + public function testFilterLessThanEqual(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::lessThanEqual('age', 65)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['age' => ['$lte' => '?']], $op['filter']); + $this->assertSame([65], $result->bindings); + } + + public function testFilterBetween(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['age' => ['$gte' => '?', '$lte' => '?']], $op['filter']); + $this->assertSame([18, 65], $result->bindings); + } + + public function testFilterNotBetween(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notBetween('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['age' => ['$lt' => '?']], + ['age' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame([18, 65], $result->bindings); + } + + public function testFilterStartsWith(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::startsWith('name', 'Al')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['^Al'], $result->bindings); + } + + public function testFilterEndsWith(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::endsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['\.com$'], $result->bindings); + } + + public function testFilterContains(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::containsString('name', ['test'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['test'], $result->bindings); + } + + public function testFilterNotContains(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notContains('name', ['test'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => ['$not' => ['$regex' => '?']]], $op['filter']); + $this->assertSame(['test'], $result->bindings); + } + + public function testFilterRegex(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::regex('email', '^[a-z]+@test\\.com$')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['^[a-z]+@test\\.com$'], $result->bindings); + } + + public function testFilterIsNull(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::isNull('deleted_at')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['deleted_at' => null], $op['filter']); + $this->assertEmpty($result->bindings); + } + + public function testFilterIsNotNull(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::isNotNull('email')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$ne' => null]], $op['filter']); + $this->assertEmpty($result->bindings); + } + + public function testFilterOr(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::or([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['status' => '?'], + ['age' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame(['active', 18], $result->bindings); + } + + public function testFilterAnd(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['status' => '?'], + ['age' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame(['active', 18], $result->bindings); + } + + public function testMultipleFiltersProduceAnd(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 25), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['status' => '?'], + ['age' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame(['active', 25], $result->bindings); + } + + public function testSortAscAndDesc(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => 1, 'age' => -1], $op['sort']); + } + + public function testLimitAndOffset(): void + { + $result = (new Builder()) + ->from('users') + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(10, $op['limit']); + $this->assertSame(20, $op['skip']); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com', 'age' => 30]) + ->insert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('users', $op['collection']); + $this->assertSame('insertMany', $op['operation']); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(1, $documents); + $this->assertSame(['name' => '?', 'email' => '?', 'age' => '?'], $documents[0]); + $this->assertSame(['Alice', 'alice@test.com', 30], $result->bindings); + } + + public function testInsertMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->insert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(2, $documents); + $this->assertSame(['Alice', 30, 'Bob', 25], $result->bindings); + } + + public function testUpdateWithSet(): void + { + $result = (new Builder()) + ->from('users') + ->set(['city' => 'New York']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('users', $op['collection']); + $this->assertSame('updateMany', $op['operation']); + $this->assertSame(['$set' => ['city' => '?']], $op['update']); + $this->assertSame(['name' => '?'], $op['filter']); + $this->assertSame(['Alice', 'New York'], $result->bindings); + } + + public function testUpdateWithIncrement(): void + { + $result = (new Builder()) + ->from('users') + ->increment('login_count', 1) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$inc' => ['login_count' => 1]], $op['update']); + $this->assertSame(['Alice'], $result->bindings); + } + + public function testUpdateWithPush(): void + { + $result = (new Builder()) + ->from('users') + ->push('tags', 'admin') + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$push' => ['tags' => '?']], $op['update']); + $this->assertSame(['Alice', 'admin'], $result->bindings); + } + + public function testUpdateWithPull(): void + { + $result = (new Builder()) + ->from('users') + ->pull('tags', 'guest') + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$pull' => ['tags' => '?']], $op['update']); + $this->assertSame(['Alice', 'guest'], $result->bindings); + } + + public function testUpdateWithAddToSet(): void + { + $result = (new Builder()) + ->from('users') + ->addToSet('roles', 'editor') + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$addToSet' => ['roles' => '?']], $op['update']); + $this->assertSame(['Alice', 'editor'], $result->bindings); + } + + public function testUpdateWithUnset(): void + { + $result = (new Builder()) + ->from('users') + ->unsetFields('deprecated_field') + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$unset' => ['deprecated_field' => '']], $op['update']); + $this->assertSame(['Alice'], $result->bindings); + } + + public function testDelete(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['deleted'])]) + ->delete(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('users', $op['collection']); + $this->assertSame('deleteMany', $op['operation']); + $this->assertSame(['status' => '?'], $op['filter']); + $this->assertSame(['deleted'], $result->bindings); + } + + public function testDeleteWithoutFilter(): void + { + $result = (new Builder()) + ->from('users') + ->delete(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('deleteMany', $op['operation']); + $this->assertEmpty((array) $op['filter']); + } + + public function testGroupByWithCount(): void + { + $result = (new Builder()) + ->from('users') + ->select(['country']) + ->count('*', 'cnt') + ->groupBy(['country']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame('$country', $groupBody['_id']); + $this->assertSame(['$sum' => 1], $groupBody['cnt']); + } + + public function testGroupByWithMultipleAggregates(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('amount', 'total') + ->avg('amount', 'average') + ->groupBy(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame(['$sum' => '$amount'], $groupBody['total']); + $this->assertSame(['$avg' => '$amount'], $groupBody['average']); + } + + public function testGroupByWithHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupIdx = $this->findStageIndex($pipeline, '$group'); + $this->assertNotNull($groupIdx); + + // HAVING $match should come after $group + $matchStages = []; + for ($i = $groupIdx + 1; $i < \count($pipeline); $i++) { + if (isset($pipeline[$i]['$match'])) { + $matchStages[] = $pipeline[$i]; + } + } + $this->assertNotEmpty($matchStages); + } + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('users') + ->select(['country']) + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + } + + public function testJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name']) + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('users', $lookupBody['from']); + $this->assertSame('user_id', $lookupBody['localField']); + $this->assertSame('id', $lookupBody['foreignField']); + $this->assertSame('u', $lookupBody['as']); + } + + public function testLeftJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->leftJoin('users', 'orders.user_id', 'users.id', '=', 'u') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $unwindStage = $this->findStage($pipeline, '$unwind'); + $this->assertNotNull($unwindStage); + $this->assertIsArray($unwindStage['$unwind']); + /** @var array $unwindBody */ + $unwindBody = $unwindStage['$unwind']; + $this->assertTrue($unwindBody['preserveNullAndEmptyArrays']); + } + + public function testUnionAll(): void + { + $first = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]); + + $second = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('country', ['UK'])]); + + $result = $first->unionAll($second)->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $unionStage = $this->findStage($pipeline, '$unionWith'); + $this->assertNotNull($unionStage); + /** @var array $unionBody */ + $unionBody = $unionStage['$unionWith']; + $this->assertSame('users', $unionBody['coll']); + } + + public function testUpsert(): void + { + $result = (new Builder()) + ->into('users') + ->set(['email' => 'alice@test.com', 'name' => 'Alice Updated', 'age' => 31]) + ->onConflict(['email'], ['name', 'age']) + ->upsert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateOne', $op['operation']); + $this->assertSame(['email' => '?'], $op['filter']); + $this->assertSame(['$set' => ['name' => '?', 'age' => '?']], $op['update']); + /** @var array $options */ + $options = $op['options']; + $this->assertTrue($options['upsert']); + $this->assertSame(['alice@test.com', 'Alice Updated', 31], $result->bindings); + } + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('insertMany', $op['operation']); + /** @var array $options */ + $options = $op['options']; + $this->assertFalse($options['ordered']); + } + + public function testWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + $this->assertArrayHasKey('rn', $output); + } + + public function testFilterWhereInSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::equal('status', ['completed'])]); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filterWhereIn('id', $subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('orders', $lookupBody['from']); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('users') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $addFieldsStage = $this->findStage($pipeline, '$addFields'); + $this->assertNotNull($addFieldsStage); + /** @var array $addFieldsBody */ + $addFieldsBody = $addFieldsStage['$addFields']; + $this->assertArrayHasKey('_rand', $addFieldsBody); + } + + public function testTextSearch(): void + { + $result = (new Builder()) + ->from('articles') + ->filterSearch('content', 'mongodb tutorial') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $matchStage */ + $matchStage = $pipeline[0]; + /** @var array $matchBody */ + $matchBody = $matchStage['$match']; + $this->assertArrayHasKey('$text', $matchBody); + /** @var array $textBody */ + $textBody = $matchBody['$text']; + $this->assertSame('?', $textBody['$search']); + $this->assertSame(['mongodb tutorial'], $result->bindings); + } + + public function testNoTableThrowsException(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->select(['name'])->build(); + } + + public function testInsertWithoutRowsThrowsException(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->into('users')->insert(); + } + + public function testUpdateWithoutOperationsThrowsException(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->from('users')->update(); + } + + public function testReset(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->push('tags', 'test') + ->increment('counter', 1); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->build(); + } + + public function testFindOperationForSimpleQuery(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]) + ->sortAsc('name') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertSame(['name' => 1, '_id' => 0], $op['projection']); + $this->assertSame(['country' => '?'], $op['filter']); + $this->assertSame(['name' => 1], $op['sort']); + $this->assertSame(10, $op['limit']); + $this->assertSame(5, $op['skip']); + } + + public function testAggregateOperationForGroupBy(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + } + + public function testClone(): void + { + $original = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('age', 25)]); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertCount(1, $originalResult->bindings); + $this->assertCount(2, $clonedResult->bindings); + } + + public function testMultipleGroupByColumns(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['country', 'city']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame([ + 'country' => '$country', + 'city' => '$city', + ], $groupBody['_id']); + } + + public function testMinMaxAggregates(): void + { + $result = (new Builder()) + ->from('orders') + ->min('amount', 'min_amount') + ->max('amount', 'max_amount') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame(['$min' => '$amount'], $groupBody['min_amount']); + $this->assertSame(['$max' => '$amount'], $groupBody['max_amount']); + } + + public function testFilterEqualWithNull(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('deleted_at', [null])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['deleted_at' => null], $op['filter']); + $this->assertEmpty($result->bindings); + } + + public function testFilterContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::containsString('bio', ['php', 'java'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['bio' => ['$regex' => '?']], + ['bio' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['php', 'java'], $result->bindings); + } + + public function testFilterContainsAll(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::containsAll('bio', ['php', 'java'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['bio' => ['$regex' => '?']], + ['bio' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['php', 'java'], $result->bindings); + } + + public function testFilterNotStartsWith(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notStartsWith('name', 'Test')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => ['$not' => ['$regex' => '?']]], $op['filter']); + $this->assertSame(['^Test'], $result->bindings); + } + + public function testUpdateWithMultipleOperators(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Alice Updated']) + ->increment('login_count', 1) + ->push('tags', 'updated') + ->unsetFields('temp_field') + ->filter([Query::equal('_id', ['abc123'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + $this->assertArrayHasKey('$inc', $update); + $this->assertArrayHasKey('$push', $update); + $this->assertArrayHasKey('$unset', $update); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('users') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(10, $op['limit']); + $this->assertSame(20, $op['skip']); + } + + public function testTableSampling(): void + { + $result = (new Builder()) + ->from('users') + ->tablesample(100) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $sampleStage = $this->findStage($pipeline, '$sample'); + $this->assertNotNull($sampleStage); + /** @var array $sampleBody */ + $sampleBody = $sampleStage['$sample']; + $this->assertSame(100, $sampleBody['size']); + } + + public function testFilterNotSearchThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('MongoDB does not support negated full-text search.'); + + (new Builder()) + ->from('articles') + ->filterNotSearch('content', 'bad term'); + } + + public function testFilterExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->from('users') + ->filterExists($subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('orders', $lookupBody['from']); + /** @var string $lookupAs */ + $lookupAs = $lookupBody['as']; + $this->assertStringStartsWith('_exists_', $lookupAs); + + $matchStages = []; + foreach ($pipeline as $stage) { + if (isset($stage['$match'])) { + $matchStages[] = $stage; + } + } + $this->assertNotEmpty($matchStages); + + $unsetStage = $this->findStage($pipeline, '$unset'); + $this->assertNotNull($unsetStage); + } + + public function testFilterNotExistsSubquery(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterNotExists($subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $hasExistsMatch = false; + foreach ($pipeline as $stage) { + if (isset($stage['$match'])) { + /** @var array $matchBody */ + $matchBody = $stage['$match']; + foreach ($matchBody as $key => $val) { + if (\str_starts_with($key, '_exists_') && \is_array($val) && isset($val['$size'])) { + $hasExistsMatch = true; + $this->assertSame(0, $val['$size']); + } + } + } + } + $this->assertTrue($hasExistsMatch); + } + + public function testFilterWhereNotInSubquery(): void + { + $subquery = (new Builder()) + ->from('banned_users') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $hasNotIn = false; + foreach ($pipeline as $stage) { + if (isset($stage['$match'])) { + $json = \json_encode($stage['$match']); + if ($json !== false && \str_contains($json, '$not')) { + $hasNotIn = true; + } + } + } + $this->assertTrue($hasNotIn); + } + + public function testWindowFunctionWithSumAggregation(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id', 'amount']) + ->selectWindow('SUM(amount)', 'running_total', ['user_id'], ['amount']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + $this->assertArrayHasKey('running_total', $output); + /** @var array $runningTotal */ + $runningTotal = $output['running_total']; + $this->assertSame('$amount', $runningTotal['$sum']); + $this->assertArrayHasKey('window', $runningTotal); + } + + public function testWindowFunctionWithAvg(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('AVG(price)', 'avg_price', ['category'], ['-price']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + $this->assertArrayHasKey('avg_price', $output); + /** @var array $avgPrice */ + $avgPrice = $output['avg_price']; + $this->assertSame('$price', $avgPrice['$avg']); + } + + public function testWindowFunctionWithMin(): void + { + $result = (new Builder()) + ->from('sales') + ->selectWindow('MIN(amount)', 'min_amount', ['region']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + /** @var array $minAmount */ + $minAmount = $output['min_amount']; + $this->assertSame('$amount', $minAmount['$min']); + } + + public function testWindowFunctionWithMax(): void + { + $result = (new Builder()) + ->from('sales') + ->selectWindow('MAX(amount)', 'max_amount', ['region']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + /** @var array $maxAmount */ + $maxAmount = $output['max_amount']; + $this->assertSame('$amount', $maxAmount['$max']); + } + + public function testWindowFunctionWithCount(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('COUNT(id)', 'event_count', ['user_id']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + /** @var array $eventCount */ + $eventCount = $output['event_count']; + $this->assertSame(1, $eventCount['$sum']); + } + + public function testWindowFunctionUnsupportedThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Unsupported window function'); + + (new Builder()) + ->from('orders') + ->selectWindow('MEDIAN(amount)', 'med', ['user_id']) + ->build(); + } + + public function testWindowFunctionUnsupportedNonParenthesizedThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new Builder()) + ->from('orders') + ->selectWindow('custom_func', 'cf', ['user_id']); + } + + public function testWindowFunctionMultiplePartitionKeys(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['country', 'city'], ['amount']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + $this->assertSame([ + 'country' => '$country', + 'city' => '$city', + ], $windowBody['partitionBy']); + } + + public function testWindowFunctionRankAndDenseRank(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rnk', ['category'], ['score']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $output */ + $output = $windowBody['output']; + $this->assertArrayHasKey('rnk', $output); + + $resultDense = (new Builder()) + ->from('scores') + ->selectWindow('DENSE_RANK()', 'dense_rnk', ['category'], ['score']) + ->build(); + $this->assertBindingCount($resultDense); + + $opDense = $this->decode($resultDense->query); + /** @var list> $pipelineDense */ + $pipelineDense = $opDense['pipeline']; + $windowStageDense = $this->findStage($pipelineDense, '$setWindowFields'); + $this->assertNotNull($windowStageDense); + /** @var array $windowBodyDense */ + $windowBodyDense = $windowStageDense['$setWindowFields']; + /** @var array $outputDense */ + $outputDense = $windowBodyDense['output']; + $this->assertArrayHasKey('dense_rnk', $outputDense); + } + + public function testWindowFunctionWithOrderByAsc(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + /** @var array $sortBy */ + $sortBy = $windowBody['sortBy']; + $this->assertSame(1, $sortBy['created_at']); + } + + public function testFilterEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active', null])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['status' => ['$in' => ['?']]], + ['status' => null], + ]], $op['filter']); + $this->assertSame(['active'], $result->bindings); + } + + public function testFilterNotEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEqual('status', [null])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['status' => ['$ne' => null]], $op['filter']); + $this->assertEmpty($result->bindings); + } + + public function testFilterNotEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEqual('status', ['deleted', null])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['status' => ['$nin' => ['?']]], + ['status' => ['$ne' => null]], + ]], $op['filter']); + $this->assertSame(['deleted'], $result->bindings); + } + + public function testFilterNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notContains('bio', ['spam', 'junk'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['bio' => ['$not' => ['$regex' => '?']]], + ['bio' => ['$not' => ['$regex' => '?']]], + ]], $op['filter']); + $this->assertSame(['spam', 'junk'], $result->bindings); + } + + public function testFilterFieldExists(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::exists(['email'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$exists' => true, '$ne' => null]], $op['filter']); + } + + public function testFilterFieldNotExists(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notExists(['email'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$type' => 10]], $op['filter']); + } + + public function testFilterFieldExistsMultiple(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::exists(['email', 'phone'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['email' => ['$exists' => true, '$ne' => null]], + ['phone' => ['$exists' => true, '$ne' => null]], + ]], $op['filter']); + } + + public function testFilterFieldNotExistsMultiple(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notExists(['email', 'phone'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['email' => ['$type' => 10]], + ['phone' => ['$type' => 10]], + ]], $op['filter']); + } + + public function testContainsAnyOnArray(): void + { + $query = Query::containsAny('tags', ['php', 'js']); + $query->setOnArray(true); + + $result = (new Builder()) + ->from('users') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['tags' => ['$in' => ['?', '?']]], $op['filter']); + $this->assertSame(['php', 'js'], $result->bindings); + } + + public function testContainsAnyOnString(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::containsAny('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['bio' => ['$regex' => '?']], + ['bio' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['php', 'js'], $result->bindings); + } + + public function testUpsertSelectThrowsException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('upsertSelect() is not supported in MongoDB builder.'); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['email'], ['name']) + ->upsertSelect(); + } + + public function testUpsertWithoutExplicitUpdateColumns(): void + { + $result = (new Builder()) + ->into('users') + ->set(['email' => 'alice@test.com', 'name' => 'Alice', 'age' => 30]) + ->onConflict(['email'], []) + ->upsert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateOne', $op['operation']); + $this->assertSame(['email' => '?'], $op['filter']); + /** @var array $update */ + $update = $op['update']; + /** @var array $setDoc */ + $setDoc = $update['$set']; + $this->assertArrayHasKey('name', $setDoc); + $this->assertArrayHasKey('age', $setDoc); + $this->assertArrayNotHasKey('email', $setDoc); + } + + public function testUpsertMissingConflictKeyThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Conflict key 'email' not found in row data."); + + (new Builder()) + ->into('users') + ->set(['name' => 'Alice']) + ->onConflict(['email'], ['name']) + ->upsert(); + } + + public function testAggregateWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['user_id']) + ->limit(10) + ->offset(20) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $skipStage = $this->findStage($pipeline, '$skip'); + $this->assertNotNull($skipStage); + $this->assertSame(20, $skipStage['$skip']); + + $limitStage = $this->findStage($pipeline, '$limit'); + $this->assertNotNull($limitStage); + $this->assertSame(10, $limitStage['$limit']); + } + + public function testAggregateDefaultSortDoesNotThrow(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + } + + public function testAggregationWithNoAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertArrayHasKey('count', $groupBody); + } + + public function testUnsupportedAggregationThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Unsupported aggregation for MongoDB'); + + (new Builder()) + ->from('orders') + ->queries([new Query(Method::CountDistinct, 'id', ['cd'])]) + ->build(); + } + + public function testBeforeBuildCallback(): void + { + $result = (new Builder()) + ->from('users') + ->beforeBuild(function (Builder $builder) { + $builder->filter([Query::equal('injected', ['yes'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['injected' => '?'], $op['filter']); + $this->assertSame(['yes'], $result->bindings); + } + + public function testAfterBuildCallback(): void + { + $result = (new Builder()) + ->from('users') + ->afterBuild(function ($result) { + return $result; + }) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + } + + public function testFilterNotEndsWith(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEndsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$not' => ['$regex' => '?']]], $op['filter']); + $this->assertSame(['\.com$'], $result->bindings); + } + + public function testEmptyHavingReturnsEmpty(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['user_id']) + ->having([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + } + + public function testHavingWithMultipleConditions(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->groupBy(['user_id']) + ->having([ + Query::greaterThan('cnt', 5), + Query::greaterThan('total', 100), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupIdx = $this->findStageIndex($pipeline, '$group'); + $this->assertNotNull($groupIdx); + + $havingMatches = []; + for ($i = $groupIdx + 1; $i < \count($pipeline); $i++) { + if (isset($pipeline[$i]['$match'])) { + $havingMatches[] = $pipeline[$i]; + } + } + $this->assertNotEmpty($havingMatches); + } + + public function testUnionWithFindOperation(): void + { + $first = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('country', ['US'])]) + ->sortAsc('name') + ->limit(10) + ->offset(5); + + $second = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('country', ['UK'])]); + + $result = $first->unionAll($second)->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $unionStage = $this->findStage($pipeline, '$unionWith'); + $this->assertNotNull($unionStage); + } + + public function testEmptyOrLogical(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayHasKey('filter', $op); + } + + public function testJoinWithNonEqualityOperatorThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('equality joins'); + + (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '<>', 'u') + ->build(); + } + + public function testJoinWithGreaterThanOperatorThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('equality joins'); + + (new Builder()) + ->from('orders') + ->join('orders', 'orders.amount', 'users.threshold', '>', 'o') + ->build(); + } + + public function testUpdateWithSetRawThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('setRaw()/setCase()'); + + (new Builder()) + ->from('users') + ->setRaw('counter', 'counter + 1') + ->update(); + } + + public function testUpdateWithSetCaseThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('setRaw()/setCase()'); + + (new Builder()) + ->from('users') + ->setCase( + 'status', + (new CaseExpression()) + ->when('age', Operator::GreaterThan, 18, 'adult') + ->else('minor') + ) + ->update(); + } + + public function testWindowFunctionMultiArgumentThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Multi-argument window functions'); + + (new Builder()) + ->from('metrics') + ->selectWindow('COVAR(a, b)', 'cov', ['user_id'], ['created_at']) + ->build(); + } + + public function testWindowFunctionRankWithoutOrderByThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('requires an ORDER BY'); + + (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rnk', ['category']) + ->build(); + } + + public function testWindowFunctionDenseRankWithoutOrderByThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('requires an ORDER BY'); + + (new Builder()) + ->from('scores') + ->selectWindow('DENSE_RANK()', 'dense', ['category']) + ->build(); + } + + public function testWindowFunctionRowNumberWithoutOrderByThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('requires an ORDER BY'); + + (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id']) + ->build(); + } + + public function testFilterFieldExistsUsesExplicitNonNullCheck(): void + { + // SQL-parity: IS NOT NULL is true only when the field is present AND non-null. + $result = (new Builder()) + ->from('users') + ->filter([Query::exists(['email'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $filter */ + $filter = $op['filter']; + /** @var array $emailFilter */ + $emailFilter = $filter['email']; + $this->assertTrue($emailFilter['$exists']); + $this->assertNull($emailFilter['$ne']); + } + + public function testFilterFieldNotExistsUsesTypeNull(): void + { + // SQL-parity: IS NULL is true only when the field is present AND explicitly null. + // Missing fields do not match (BSON type 10 = null). + $result = (new Builder()) + ->from('users') + ->filter([Query::notExists(['email'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $filter */ + $filter = $op['filter']; + /** @var array $emailFilter */ + $emailFilter = $filter['email']; + $this->assertSame(10, $emailFilter['$type']); + } + + public function testInsertOrIgnoreDoesNotRoundTripThroughJson(): void + { + // Regression: an earlier implementation did json_decode(insert()->query) which + // would coerce any empty stdClass (used elsewhere to encode `{}`) to `[]`. + // Verify the output is still a well-formed insertMany with ordered=false. + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('users', $op['collection']); + $this->assertSame('insertMany', $op['operation']); + /** @var array $options */ + $options = $op['options']; + $this->assertFalse($options['ordered']); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(1, $documents); + $this->assertSame(['name' => '?', 'email' => '?'], $documents[0]); + $this->assertSame(['Alice', 'alice@test.com'], $result->bindings); + } + + public function testWindowFunctionAliasIsPreservedInProjection(): void + { + // Regression for commit 06ceaca: the $project stage after $setWindowFields + // must include the window function alias so it survives projection. + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $projectStage = $this->findStage($pipeline, '$project'); + $this->assertNotNull($projectStage); + /** @var array $projection */ + $projection = $projectStage['$project']; + $this->assertArrayHasKey('rn', $projection); + $this->assertSame(1, $projection['rn']); + $this->assertArrayHasKey('user_id', $projection); + } + + /** + * @return array + */ + private function decode(string $json): array + { + /** @var array */ + return \json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } + + /** + * @param list> $pipeline + * @return array|null + */ + private function findStage(array $pipeline, string $stageName): ?array + { + foreach ($pipeline as $stage) { + if (isset($stage[$stageName])) { + return $stage; + } + } + + return null; + } + + /** + * @param list> $pipeline + */ + private function findStageIndex(array $pipeline, string $stageName): ?int + { + foreach ($pipeline as $idx => $stage) { + if (isset($stage[$stageName])) { + return $idx; + } + } + + return null; + } + + /** + * @param list> $pipeline + * @return list> + */ + private function findAllStages(array $pipeline, string $stageName): array + { + $found = []; + foreach ($pipeline as $stage) { + if (isset($stage[$stageName])) { + $found[] = $stage; + } + } + + return $found; + } + + public function testJoinWithWhereGroupByHavingOrderLimitOffset(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->filter([Query::greaterThan('amount', 10)]) + ->count('*', 'cnt') + ->sum('amount', 'total') + ->groupBy(['u.country']) + ->having([Query::greaterThan('cnt', 2)]) + ->sortDesc('total') + ->limit(20) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + + $matchStage = $this->findStage($pipeline, '$match'); + $this->assertNotNull($matchStage); + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + + $sortStage = $this->findStage($pipeline, '$sort'); + $this->assertNotNull($sortStage); + + $skipStage = $this->findStage($pipeline, '$skip'); + $this->assertNotNull($skipStage); + $this->assertSame(5, $skipStage['$skip']); + + $limitStage = $this->findStage($pipeline, '$limit'); + $this->assertNotNull($limitStage); + $this->assertSame(20, $limitStage['$limit']); + + $lookupIdx = $this->findStageIndex($pipeline, '$lookup'); + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $groupIdx = $this->findStageIndex($pipeline, '$group'); + $sortIdx = $this->findStageIndex($pipeline, '$sort'); + $skipIdx = $this->findStageIndex($pipeline, '$skip'); + $limitIdx = $this->findStageIndex($pipeline, '$limit'); + + $this->assertNotNull($lookupIdx); + $this->assertNotNull($matchIdx); + $this->assertNotNull($groupIdx); + $this->assertNotNull($sortIdx); + $this->assertNotNull($skipIdx); + $this->assertNotNull($limitIdx); + + $this->assertLessThan($matchIdx, $lookupIdx); + $this->assertLessThan($groupIdx, $matchIdx); + $this->assertLessThan($sortIdx, $groupIdx); + $this->assertLessThan($skipIdx, $sortIdx); + $this->assertLessThan($limitIdx, $skipIdx); + } + + public function testMultipleJoinsWithWhere(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->join('products', 'orders.product_id', 'products.id', '=', 'p') + ->filter([Query::greaterThan('amount', 50)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStages = $this->findAllStages($pipeline, '$lookup'); + $this->assertCount(2, $lookupStages); + + /** @var array $lookup1 */ + $lookup1 = $lookupStages[0]['$lookup']; + $this->assertSame('users', $lookup1['from']); + $this->assertSame('u', $lookup1['as']); + + /** @var array $lookup2 */ + $lookup2 = $lookupStages[1]['$lookup']; + $this->assertSame('products', $lookup2['from']); + $this->assertSame('p', $lookup2['as']); + + $this->assertSame([50], $result->bindings); + } + + public function testLeftJoinAndInnerJoinCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->leftJoin('users', 'orders.user_id', 'users.id', '=', 'u') + ->join('categories', 'orders.category_id', 'categories.id', '=', 'c') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $unwindStages = $this->findAllStages($pipeline, '$unwind'); + $this->assertCount(2, $unwindStages); + + /** @var array|string $firstUnwind */ + $firstUnwind = $unwindStages[0]['$unwind']; + $this->assertIsArray($firstUnwind); + $this->assertTrue($firstUnwind['preserveNullAndEmptyArrays']); + + /** @var string $secondUnwind */ + $secondUnwind = $unwindStages[1]['$unwind']; + $this->assertSame('$c', $secondUnwind); + } + + public function testJoinWithAggregateGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->count('*', 'order_count') + ->sum('orders.amount', 'total_amount') + ->groupBy(['u.name']) + ->having([Query::greaterThan('order_count', 3)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame(['$sum' => 1], $groupBody['order_count']); + $this->assertSame(['$sum' => '$orders.amount'], $groupBody['total_amount']); + + $groupIdx = $this->findStageIndex($pipeline, '$group'); + $this->assertNotNull($groupIdx); + $havingMatches = []; + for ($i = $groupIdx + 1; $i < \count($pipeline); $i++) { + if (isset($pipeline[$i]['$match'])) { + $havingMatches[] = $pipeline[$i]; + } + } + $this->assertNotEmpty($havingMatches); + } + + public function testJoinWithWindowFunction(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->selectWindow('ROW_NUMBER()', 'rn', ['u.country'], ['-orders.amount']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + + $lookupIdx = $this->findStageIndex($pipeline, '$lookup'); + $windowIdx = $this->findStageIndex($pipeline, '$setWindowFields'); + $this->assertNotNull($lookupIdx); + $this->assertNotNull($windowIdx); + $this->assertLessThan($windowIdx, $lookupIdx); + } + + public function testJoinWithDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->select(['u.country']) + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + } + + public function testFilterWhereInSubqueryWithJoin(): void + { + $subquery = (new Builder()) + ->from('premium_users') + ->select(['user_id']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->from('orders') + ->join('products', 'orders.product_id', 'products.id', '=', 'p') + ->filterWhereIn('user_id', $subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStages = $this->findAllStages($pipeline, '$lookup'); + $this->assertGreaterThanOrEqual(2, \count($lookupStages)); + + $this->assertSame(['gold'], $result->bindings); + } + + public function testFilterWhereInSubqueryWithAggregate(): void + { + $subquery = (new Builder()) + ->from('active_users') + ->select(['id']) + ->filter([Query::equal('status', ['active'])]); + + $result = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $subquery) + ->count('*', 'order_count') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame(['$sum' => 1], $groupBody['order_count']); + + $this->assertSame(['active'], $result->bindings); + } + + public function testExistsSubqueryWithRegularFilter(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->filterExists($subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + + $matchStages = $this->findAllStages($pipeline, '$match'); + $this->assertGreaterThanOrEqual(2, \count($matchStages)); + + $this->assertSame([100, 'active'], $result->bindings); + } + + public function testNotExistsSubqueryWithRegularFilter(): void + { + $subquery = (new Builder()) + ->from('banned_ips') + ->select(['ip']); + + $result = (new Builder()) + ->from('logins') + ->filter([Query::greaterThan('timestamp', '2024-01-01')]) + ->filterNotExists($subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $hasExistsMatch = false; + foreach ($pipeline as $stage) { + if (isset($stage['$match'])) { + /** @var array $matchBody */ + $matchBody = $stage['$match']; + foreach ($matchBody as $key => $val) { + if (\str_starts_with($key, '_exists_') && \is_array($val) && isset($val['$size'])) { + $hasExistsMatch = true; + $this->assertSame(0, $val['$size']); + } + } + } + } + $this->assertTrue($hasExistsMatch); + + $this->assertContains('2024-01-01', $result->bindings); + } + + public function testUnionAllOfTwoComplexQueries(): void + { + $second = (new Builder()) + ->from('archived_orders') + ->select(['id', 'amount']) + ->filter([ + Query::greaterThan('amount', 200), + Query::equal('status', ['archived']), + ]) + ->sortDesc('amount') + ->limit(5); + + $result = (new Builder()) + ->from('orders') + ->select(['id', 'amount']) + ->filter([ + Query::greaterThan('amount', 100), + Query::equal('status', ['active']), + ]) + ->sortDesc('amount') + ->limit(10) + ->unionAll($second) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $unionStage = $this->findStage($pipeline, '$unionWith'); + $this->assertNotNull($unionStage); + /** @var array $unionBody */ + $unionBody = $unionStage['$unionWith']; + $this->assertSame('archived_orders', $unionBody['coll']); + $this->assertArrayHasKey('pipeline', $unionBody); + } + + public function testUnionAllOfThreeQueries(): void + { + $second = (new Builder()) + ->from('eu_users') + ->select(['name']) + ->filter([Query::equal('region', ['EU'])]); + + $third = (new Builder()) + ->from('asia_users') + ->select(['name']) + ->filter([Query::equal('region', ['ASIA'])]); + + $result = (new Builder()) + ->from('us_users') + ->select(['name']) + ->filter([Query::equal('region', ['US'])]) + ->unionAll($second) + ->unionAll($third) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $unionStages = $this->findAllStages($pipeline, '$unionWith'); + $this->assertCount(2, $unionStages); + + /** @var array $union1Body */ + $union1Body = $unionStages[0]['$unionWith']; + $this->assertSame('eu_users', $union1Body['coll']); + + /** @var array $union2Body */ + $union2Body = $unionStages[1]['$unionWith']; + $this->assertSame('asia_users', $union2Body['coll']); + + $this->assertSame(['US', 'EU', 'ASIA'], $result->bindings); + } + + public function testUnionWithOrderByAndLimit(): void + { + $second = (new Builder()) + ->from('archived_users') + ->select(['name', 'score']); + + $result = (new Builder()) + ->from('users') + ->select(['name', 'score']) + ->unionAll($second) + ->sortDesc('score') + ->limit(50) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $unionIdx = $this->findStageIndex($pipeline, '$unionWith'); + $sortIdx = $this->findStageIndex($pipeline, '$sort'); + $limitIdx = $this->findStageIndex($pipeline, '$limit'); + + $this->assertNotNull($unionIdx); + $this->assertNotNull($sortIdx); + $this->assertNotNull($limitIdx); + + $this->assertLessThan($sortIdx, $unionIdx); + $this->assertLessThan($limitIdx, $sortIdx); + } + + public function testWindowFunctionWithWhereAndOrder(): void + { + $result = (new Builder()) + ->from('sales') + ->filter([Query::greaterThan('amount', 0)]) + ->selectWindow('ROW_NUMBER()', 'rn', ['region'], ['-amount']) + ->sortDesc('rn') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $windowIdx = $this->findStageIndex($pipeline, '$setWindowFields'); + $sortIdx = $this->findStageIndex($pipeline, '$sort'); + + $this->assertNotNull($matchIdx); + $this->assertNotNull($windowIdx); + $this->assertNotNull($sortIdx); + + $this->assertLessThan($windowIdx, $matchIdx); + $this->assertLessThan($sortIdx, $windowIdx); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('ROW_NUMBER()', 'rn', ['user_id'], ['-amount']) + ->selectWindow('SUM(amount)', 'running_sum', ['user_id'], ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $windowStages = $this->findAllStages($pipeline, '$setWindowFields'); + $this->assertCount(2, $windowStages); + + /** @var array $window1Body */ + $window1Body = $windowStages[0]['$setWindowFields']; + /** @var array $output1 */ + $output1 = $window1Body['output']; + $this->assertArrayHasKey('rn', $output1); + + /** @var array $window2Body */ + $window2Body = $windowStages[1]['$setWindowFields']; + /** @var array $output2 */ + $output2 = $window2Body['output']; + $this->assertArrayHasKey('running_sum', $output2); + } + + public function testWindowFunctionMultiplePartitionAndSortKeys(): void + { + $result = (new Builder()) + ->from('sales') + ->selectWindow('RANK()', 'rnk', ['region', 'department', 'team'], ['-revenue', 'name']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + $this->assertSame([ + 'region' => '$region', + 'department' => '$department', + 'team' => '$team', + ], $windowBody['partitionBy']); + + /** @var array $sortBy */ + $sortBy = $windowBody['sortBy']; + $this->assertSame(-1, $sortBy['revenue']); + $this->assertSame(1, $sortBy['name']); + } + + public function testGroupByMultipleColumnsMultipleAggregates(): void + { + $result = (new Builder()) + ->from('sales') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->avg('amount', 'average') + ->groupBy(['region', 'year', 'quarter']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + + $this->assertSame([ + 'region' => '$region', + 'year' => '$year', + 'quarter' => '$quarter', + ], $groupBody['_id']); + + $this->assertSame(['$sum' => 1], $groupBody['cnt']); + $this->assertSame(['$sum' => '$amount'], $groupBody['total']); + $this->assertSame(['$avg' => '$amount'], $groupBody['average']); + + $projectStage = $this->findStage($pipeline, '$project'); + $this->assertNotNull($projectStage); + /** @var array $projectBody */ + $projectBody = $projectStage['$project']; + $this->assertSame(0, $projectBody['_id']); + $this->assertSame('$_id.region', $projectBody['region']); + $this->assertSame('$_id.year', $projectBody['year']); + $this->assertSame('$_id.quarter', $projectBody['quarter']); + $this->assertSame(1, $projectBody['cnt']); + $this->assertSame(1, $projectBody['total']); + $this->assertSame(1, $projectBody['average']); + } + + public function testMultipleAggregatesWithoutGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total_count') + ->sum('amount', 'total_amount') + ->avg('amount', 'avg_amount') + ->min('amount', 'min_amount') + ->max('amount', 'max_amount') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + + $this->assertNull($groupBody['_id']); + $this->assertSame(['$sum' => 1], $groupBody['total_count']); + $this->assertSame(['$sum' => '$amount'], $groupBody['total_amount']); + $this->assertSame(['$avg' => '$amount'], $groupBody['avg_amount']); + $this->assertSame(['$min' => '$amount'], $groupBody['min_amount']); + $this->assertSame(['$max' => '$amount'], $groupBody['max_amount']); + } + + public function testBeforeBuildCallbackAddingFiltersWithMainFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('role', ['admin'])]) + ->beforeBuild(function (Builder $builder) { + $builder->filter([Query::equal('active', [true])]); + }) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['role' => '?'], + ['active' => '?'], + ]], $op['filter']); + $this->assertSame(['admin', true], $result->bindings); + } + + public function testAfterBuildCallbackModifyingResult(): void + { + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->afterBuild(function (Statement $result) { + /** @var array $op */ + $op = \json_decode($result->query, true); + $op['custom_flag'] = true; + + return new Statement( + \json_encode($op, JSON_THROW_ON_ERROR), + $result->bindings, + $result->readOnly + ); + }) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertTrue($op['custom_flag']); + $this->assertSame('find', $op['operation']); + } + + public function testInsertMultipleRowsDocumentStructure(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30, 'city' => 'NYC']) + ->set(['name' => 'Bob', 'age' => 25, 'city' => 'LA']) + ->set(['name' => 'Charlie', 'age' => 35, 'city' => 'SF']) + ->insert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(3, $documents); + + foreach ($documents as $doc) { + $this->assertSame(['name' => '?', 'age' => '?', 'city' => '?'], $doc); + } + + $this->assertSame(['Alice', 30, 'NYC', 'Bob', 25, 'LA', 'Charlie', 35, 'SF'], $result->bindings); + } + + public function testUpdateWithComplexMultiConditionFilter(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'suspended']) + ->filter([Query::or([ + Query::and([ + Query::equal('role', ['admin']), + Query::lessThan('last_login', '2023-01-01'), + ]), + Query::and([ + Query::equal('role', ['user']), + Query::equal('verified', [false]), + ]), + ])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateMany', $op['operation']); + $this->assertSame(['$set' => ['status' => '?']], $op['update']); + + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$or', $filter); + /** @var list> $orConditions */ + $orConditions = $filter['$or']; + $this->assertCount(2, $orConditions); + $this->assertArrayHasKey('$and', $orConditions[0]); + $this->assertArrayHasKey('$and', $orConditions[1]); + } + + public function testDeleteWithComplexOrAndFilter(): void + { + $result = (new Builder()) + ->from('events') + ->filter([Query::or([ + Query::and([ + Query::lessThan('timestamp', '2022-01-01'), + Query::equal('type', ['error']), + ]), + Query::equal('status', ['expired']), + ])]) + ->delete(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('deleteMany', $op['operation']); + + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$or', $filter); + /** @var list> $orConditions */ + $orConditions = $filter['$or']; + $this->assertCount(2, $orConditions); + $this->assertArrayHasKey('$and', $orConditions[0]); + } + + public function testFilterOrWithEqualAndGreaterThanStructure(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::or([ + Query::equal('a', [1]), + Query::greaterThan('b', 5), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['a' => '?'], + ['b' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame([1, 5], $result->bindings); + } + + public function testFilterAndWithEqualAndLessThanStructure(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::and([ + Query::equal('a', [1]), + Query::lessThan('b', 10), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['a' => '?'], + ['b' => ['$lt' => '?']], + ]], $op['filter']); + $this->assertSame([1, 10], $result->bindings); + } + + public function testNestedOrInsideAndInsideOr(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::or([ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]), + Query::and([ + Query::lessThan('score', 50), + Query::notEqual('role', 'guest'), + ]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$or', $filter); + /** @var list> $orConditions */ + $orConditions = $filter['$or']; + $this->assertCount(2, $orConditions); + $this->assertArrayHasKey('$and', $orConditions[0]); + $this->assertArrayHasKey('$and', $orConditions[1]); + + /** @var list> $and1 */ + $and1 = $orConditions[0]['$and']; + $this->assertCount(2, $and1); + $this->assertSame(['status' => '?'], $and1[0]); + $this->assertSame(['age' => ['$gt' => '?']], $and1[1]); + + /** @var list> $and2 */ + $and2 = $orConditions[1]['$and']; + $this->assertCount(2, $and2); + $this->assertSame(['score' => ['$lt' => '?']], $and2[0]); + $this->assertSame(['role' => ['$ne' => '?']], $and2[1]); + } + + public function testTripleNestingAndOfOrFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::and([ + Query::or([ + Query::equal('status', ['active']), + Query::greaterThan('score', 100), + ]), + Query::or([ + Query::lessThan('age', 30), + Query::between('balance', 0, 1000), + ]), + ])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$and', $filter); + /** @var list> $andConditions */ + $andConditions = $filter['$and']; + $this->assertCount(2, $andConditions); + + $this->assertArrayHasKey('$or', $andConditions[0]); + $this->assertArrayHasKey('$or', $andConditions[1]); + + /** @var list> $or1 */ + $or1 = $andConditions[0]['$or']; + $this->assertSame(['status' => '?'], $or1[0]); + $this->assertSame(['score' => ['$gt' => '?']], $or1[1]); + + /** @var list> $or2 */ + $or2 = $andConditions[1]['$or']; + $this->assertSame(['age' => ['$lt' => '?']], $or2[0]); + $this->assertSame(['balance' => ['$gte' => '?', '$lte' => '?']], $or2[1]); + } + + public function testIsNullWithEqualCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::isNull('deleted_at'), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['deleted_at' => null], + ['status' => '?'], + ]], $op['filter']); + $this->assertSame(['active'], $result->bindings); + } + + public function testIsNotNullWithGreaterThanCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::isNotNull('email'), + Query::greaterThan('login_count', 0), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['email' => ['$ne' => null]], + ['login_count' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame([0], $result->bindings); + } + + public function testBetweenWithNotEqualCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::between('age', 18, 65), + Query::notEqual('status', 'banned'), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['age' => ['$gte' => '?', '$lte' => '?']], + ['status' => ['$ne' => '?']], + ]], $op['filter']); + $this->assertSame([18, 65, 'banned'], $result->bindings); + } + + public function testContainsWithStartsWithCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::containsString('name', ['test']), + Query::startsWith('email', 'admin'), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['name' => ['$regex' => '?']], + ['email' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['test', '^admin'], $result->bindings); + } + + public function testNotContainsWithContainsCombined(): void + { + $result = (new Builder()) + ->from('posts') + ->filter([ + Query::notContains('body', ['spam']), + Query::containsString('body', ['valuable']), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['body' => ['$not' => ['$regex' => '?']]], + ['body' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['spam', 'valuable'], $result->bindings); + } + + public function testMultipleEqualOnDifferentFields(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('name', ['Alice']), + Query::equal('city', ['NYC']), + Query::equal('role', ['admin']), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['name' => '?'], + ['city' => '?'], + ['role' => '?'], + ]], $op['filter']); + $this->assertSame(['Alice', 'NYC', 'admin'], $result->bindings); + } + + public function testEqualMultiValueInEquivalent(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['x' => ['$in' => ['?', '?', '?']]], $op['filter']); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testNotEqualMultiValueNinEquivalent(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::notEqual('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['x' => ['$nin' => ['?', '?', '?']]], $op['filter']); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testEqualBooleanValue(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['active' => '?'], $op['filter']); + $this->assertSame([true], $result->bindings); + } + + public function testEqualEmptyStringValue(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', [''])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => '?'], $op['filter']); + $this->assertSame([''], $result->bindings); + } + + public function testRegexWithOtherFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::regex('name', '^[A-Z]'), + Query::greaterThan('age', 18), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['name' => ['$regex' => '?']], + ['age' => ['$gt' => '?']], + ['status' => '?'], + ]], $op['filter']); + $this->assertSame(['^[A-Z]', 18, 'active'], $result->bindings); + } + + public function testContainsAllWithMultipleValues(): void + { + $result = (new Builder()) + ->from('posts') + ->filter([Query::containsAll('tags', ['php', 'mongodb', 'testing'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$and' => [ + ['tags' => ['$regex' => '?']], + ['tags' => ['$regex' => '?']], + ['tags' => ['$regex' => '?']], + ]], $op['filter']); + $this->assertSame(['php', 'mongodb', 'testing'], $result->bindings); + } + + public function testFilterOnDottedNestedField(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('address.city', ['NYC'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['address.city' => '?'], $op['filter']); + $this->assertSame(['NYC'], $result->bindings); + } + + public function testComplexQueryBindingOrder(): void + { + $result = (new Builder()) + ->from('orders') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('amount', 100), + Query::between('created_at', '2024-01-01', '2024-12-31'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([ + 'active', + 100, + '2024-01-01', + '2024-12-31', + ], $result->bindings); + } + + public function testJoinFilterHavingBindingPositions(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->filter([Query::greaterThan('amount', 50)]) + ->count('*', 'cnt') + ->groupBy(['u.name']) + ->having([Query::greaterThan('cnt', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([50, 10], $result->bindings); + } + + public function testUnionBindingsInBothBranches(): void + { + $second = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::equal('status', ['cancelled'])]); + + $result = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::equal('status', ['active'])]) + ->unionAll($second) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['active', 'cancelled'], $result->bindings); + } + + public function testSubqueryBindingsWithOuterQueryBindings(): void + { + $subquery = (new Builder()) + ->from('vip_users') + ->select(['id']) + ->filter([Query::equal('level', ['platinum'])]); + + $result = (new Builder()) + ->from('orders') + ->filter([Query::greaterThan('total', 500)]) + ->filterWhereIn('user_id', $subquery) + ->build(); + $this->assertBindingCount($result); + + $this->assertContains('platinum', $result->bindings); + $this->assertContains(500, $result->bindings); + } + + public function testUpdateWithFilterBindingsAndSetValueBindings(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'banned', 'reason' => 'violation']) + ->filter([ + Query::equal('role', ['user']), + Query::lessThan('karma', -10), + ]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame(['user', -10, 'banned', 'violation'], $result->bindings); + } + + public function testInsertMultipleRowsBindingPositions(): void + { + $result = (new Builder()) + ->into('items') + ->set(['a' => 1, 'b' => 'x']) + ->set(['a' => 2, 'b' => 'y']) + ->set(['a' => 3, 'b' => 'z']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame([1, 'x', 2, 'y', 3, 'z'], $result->bindings); + } + + public function testSelectEmptyArray(): void + { + $result = (new Builder()) + ->from('users') + ->select([]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + // Empty select still creates a projection with only _id suppressed + $this->assertSame(['_id' => 0], $op['projection']); + } + + public function testSelectStar(): void + { + $result = (new Builder()) + ->from('users') + ->select(['*']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertArrayHasKey('projection', $op); + /** @var array $projection */ + $projection = $op['projection']; + $this->assertSame(1, $projection['*']); + } + + public function testSelectManyColumns(): void + { + $result = (new Builder()) + ->from('users') + ->select(['a', 'b', 'c', 'd', 'e']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $projection */ + $projection = $op['projection']; + $this->assertSame(1, $projection['a']); + $this->assertSame(1, $projection['b']); + $this->assertSame(1, $projection['c']); + $this->assertSame(1, $projection['d']); + $this->assertSame(1, $projection['e']); + $this->assertSame(0, $projection['_id']); + } + + public function testCompoundSort(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('a') + ->sortDesc('b') + ->sortAsc('c') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['a' => 1, 'b' => -1, 'c' => 1], $op['sort']); + } + + public function testLimitOne(): void + { + $result = (new Builder()) + ->from('users') + ->limit(1) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(1, $op['limit']); + } + + public function testOffsetZero(): void + { + $result = (new Builder()) + ->from('users') + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(0, $op['skip']); + } + + public function testLargeLimit(): void + { + $result = (new Builder()) + ->from('users') + ->limit(1000000) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(1000000, $op['limit']); + } + + public function testGroupByThreeColumns(): void + { + $result = (new Builder()) + ->from('data') + ->count('*', 'cnt') + ->groupBy(['a', 'b', 'c']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertSame([ + 'a' => '$a', + 'b' => '$b', + 'c' => '$c', + ], $groupBody['_id']); + } + + public function testDistinctWithoutExplicitSelect(): void + { + $result = (new Builder()) + ->from('users') + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + } + + public function testDistinctWithSelectAndSort(): void + { + $result = (new Builder()) + ->from('users') + ->select(['country', 'city']) + ->distinct() + ->sortAsc('country') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + + $sortStage = $this->findStage($pipeline, '$sort'); + $this->assertNotNull($sortStage); + /** @var array $sortBody */ + $sortBody = $sortStage['$sort']; + $this->assertSame(1, $sortBody['country']); + } + + public function testCountStarWithoutGroupByWholeCollection(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNotNull($groupStage); + /** @var array $groupBody */ + $groupBody = $groupStage['$group']; + $this->assertNull($groupBody['_id']); + $this->assertSame(['$sum' => 1], $groupBody['total']); + } + + public function testReadOnlyFlagOnBuild(): void + { + $buildResult = (new Builder()) + ->from('users') + ->select(['name']) + ->build(); + $this->assertBindingCount($buildResult); + $this->assertTrue($buildResult->readOnly); + } + + public function testReadOnlyFlagOnInsert(): void + { + $insertResult = (new Builder()) + ->into('users') + ->set(['name' => 'Alice']) + ->insert(); + $this->assertBindingCount($insertResult); + $this->assertFalse($insertResult->readOnly); + } + + public function testReadOnlyFlagOnUpdate(): void + { + $updateResult = (new Builder()) + ->from('users') + ->set(['name' => 'Alice']) + ->update(); + $this->assertBindingCount($updateResult); + $this->assertFalse($updateResult->readOnly); + } + + public function testReadOnlyFlagOnDelete(): void + { + $deleteResult = (new Builder()) + ->from('users') + ->delete(); + $this->assertBindingCount($deleteResult); + $this->assertFalse($deleteResult->readOnly); + } + + public function testCloneThenModifyOriginalUnchanged(): void + { + $original = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('age', 25)]); + $cloned->sortDesc('age'); + $cloned->limit(5); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertCount(1, $originalResult->bindings); + $this->assertCount(2, $clonedResult->bindings); + + $originalOp = $this->decode($originalResult->query); + $this->assertSame('find', $originalOp['operation']); + $this->assertArrayNotHasKey('sort', $originalOp); + $this->assertArrayNotHasKey('limit', $originalOp); + } + + public function testResetThenRebuild(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + + $builder->reset(); + + $builder->from('orders') + ->select(['id']) + ->filter([Query::greaterThan('total', 100)]) + ->build(); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('orders', $op['collection']); + $this->assertSame(['total' => ['$gt' => '?']], $op['filter']); + $this->assertSame([100], $result->bindings); + } + + public function testMultipleSetCallsForUpdate(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'First']) + ->set(['name' => 'Second', 'email' => 'test@test.com']) + ->filter([Query::equal('id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + /** @var array $setDoc */ + $setDoc = $update['$set']; + $this->assertSame('?', $setDoc['name']); + } + + public function testEmptyOrLogicalProducesExprFalse(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$expr', $filter); + $this->assertFalse($filter['$expr']); + } + + public function testEmptyAndLogicalProducesNoFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + // Empty AND returns [], buildFilter returns [], buildFind skips it + $this->assertArrayNotHasKey('filter', $op); + } + + public function testTextSearchIsFirstPipelineStage(): void + { + $result = (new Builder()) + ->from('articles') + ->filterSearch('content', 'mongodb') + ->filter([Query::equal('status', ['published'])]) + ->sortDesc('score') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + /** @var array $firstStage */ + $firstStage = $pipeline[0]; + $this->assertArrayHasKey('$match', $firstStage); + /** @var array $matchBody */ + $matchBody = $firstStage['$match']; + $this->assertArrayHasKey('$text', $matchBody); + + $this->assertSame(['mongodb', 'published'], $result->bindings); + } + + public function testTableSamplingBeforeFilters(): void + { + $result = (new Builder()) + ->from('logs') + ->tablesample(500) + ->filter([Query::equal('level', ['error'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $sampleIdx = $this->findStageIndex($pipeline, '$sample'); + $matchIdx = $this->findStageIndex($pipeline, '$match'); + + $this->assertNotNull($sampleIdx); + $this->assertNotNull($matchIdx); + $this->assertLessThan($matchIdx, $sampleIdx); + } + + public function testSortRandomWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->sortRandom() + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $addFieldsStage = $this->findStage($pipeline, '$addFields'); + $this->assertNotNull($addFieldsStage); + + $sortStage = $this->findStage($pipeline, '$sort'); + $this->assertNotNull($sortStage); + /** @var array $sortBody */ + $sortBody = $sortStage['$sort']; + $this->assertSame(1, $sortBody['_rand']); + + $unsetStage = $this->findStage($pipeline, '$unset'); + $this->assertNotNull($unsetStage); + $this->assertSame('_rand', $unsetStage['$unset']); + } + + public function testUpdateWithSetAndPushAndIncrement(): void + { + $result = (new Builder()) + ->from('users') + ->set(['last_login' => '2024-06-15']) + ->push('activity_log', 'logged_in') + ->increment('login_count', 1) + ->addToSet('badges', 'frequent_user') + ->pull('temp_flags', 'old_flag') + ->unsetFields('deprecated', 'legacy') + ->filter([Query::equal('_id', ['user123'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + $this->assertArrayHasKey('$push', $update); + $this->assertArrayHasKey('$inc', $update); + $this->assertArrayHasKey('$addToSet', $update); + $this->assertArrayHasKey('$pull', $update); + $this->assertArrayHasKey('$unset', $update); + + /** @var array $unsetDoc */ + $unsetDoc = $update['$unset']; + $this->assertArrayHasKey('deprecated', $unsetDoc); + $this->assertArrayHasKey('legacy', $unsetDoc); + } + + public function testUpsertWithMultipleConflictKeys(): void + { + $result = (new Builder()) + ->into('metrics') + ->set(['date' => '2024-06-15', 'metric' => 'pageviews', 'value' => 1500]) + ->onConflict(['date', 'metric'], ['value']) + ->upsert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateOne', $op['operation']); + $this->assertSame(['date' => '?', 'metric' => '?'], $op['filter']); + $this->assertSame(['$set' => ['value' => '?']], $op['update']); + $this->assertSame(['2024-06-15', 'pageviews', 1500], $result->bindings); + } + + public function testDeleteWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('sessions') + ->filter([ + Query::lessThan('expires_at', '2024-01-01'), + Query::notEqual('persistent', true), + Query::isNull('user_id'), + ]) + ->delete(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('deleteMany', $op['operation']); + + /** @var array $filter */ + $filter = $op['filter']; + $this->assertArrayHasKey('$and', $filter); + /** @var list> $andConditions */ + $andConditions = $filter['$and']; + $this->assertCount(3, $andConditions); + + $this->assertSame(['expires_at' => ['$lt' => '?']], $andConditions[0]); + $this->assertSame(['persistent' => ['$ne' => '?']], $andConditions[1]); + $this->assertSame(['user_id' => null], $andConditions[2]); + + $this->assertSame(['2024-01-01', true], $result->bindings); + } + + public function testUpdateWithNoFilterProducesEmptyStdclass(): void + { + $result = (new Builder()) + ->from('users') + ->set(['updated_at' => '2024-06-15']) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateMany', $op['operation']); + $this->assertEmpty((array) $op['filter']); + } + + public function testInsertOrIgnorePreservesOrderedFalse(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'A', 'email' => 'a@b.com']) + ->set(['name' => 'B', 'email' => 'b@b.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('insertMany', $op['operation']); + /** @var array $options */ + $options = $op['options']; + $this->assertFalse($options['ordered']); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(2, $documents); + } + + public function testPipelineStageOrderWithAllFeatures(): void + { + $subquery = (new Builder()) + ->from('vips') + ->select(['user_id']); + + $unionBranch = (new Builder()) + ->from('archived') + ->select(['name']); + + $result = (new Builder()) + ->from('users') + ->filterSearch('bio', 'developer') + ->join('profiles', 'users.id', 'profiles.user_id', '=', 'p') + ->filterWhereIn('id', $subquery) + ->filter([Query::equal('active', [true])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->unionAll($unionBranch) + ->sortDesc('cnt') + ->limit(20) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $stageTypes = []; + foreach ($pipeline as $stage) { + $stageTypes[] = \array_key_first($stage); + } + + $textMatchPos = \array_search('$match', $stageTypes); + $this->assertNotFalse($textMatchPos); + $this->assertSame(0, $textMatchPos); + } + + public function testWindowFunctionWithNullPartition(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('ROW_NUMBER()', 'global_rn', null, ['timestamp']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + $this->assertArrayNotHasKey('partitionBy', $windowBody); + $this->assertArrayHasKey('sortBy', $windowBody); + } + + public function testWindowFunctionWithEmptyPartition(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('DENSE_RANK()', 'rnk', [], ['score']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + $this->assertArrayNotHasKey('partitionBy', $windowBody); + } + + public function testWindowFunctionWithNullOrderBy(): void + { + $result = (new Builder()) + ->from('events') + ->selectWindow('SUM(amount)', 'total', ['category'], null) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $windowStage = $this->findStage($pipeline, '$setWindowFields'); + $this->assertNotNull($windowStage); + + /** @var array $windowBody */ + $windowBody = $windowStage['$setWindowFields']; + $this->assertArrayNotHasKey('sortBy', $windowBody); + } + + public function testGroupByProjectReshape(): void + { + $result = (new Builder()) + ->from('sales') + ->sum('amount', 'total_sales') + ->groupBy(['region']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $projectStage = $this->findStage($pipeline, '$project'); + $this->assertNotNull($projectStage); + /** @var array $projectBody */ + $projectBody = $projectStage['$project']; + $this->assertSame(0, $projectBody['_id']); + $this->assertSame('$_id', $projectBody['region']); + $this->assertSame(1, $projectBody['total_sales']); + } + + public function testCrossJoinThrowsUnsupportedException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Cross/natural joins are not supported'); + + (new Builder()) + ->from('users') + ->crossJoin('roles') + ->build(); + } + + public function testNaturalJoinThrowsUnsupportedException(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Cross/natural joins are not supported'); + + (new Builder()) + ->from('users') + ->naturalJoin('roles') + ->build(); + } + + public function testFilterEndsWithSpecialChars(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::endsWith('email', '.co.uk')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['email' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['\.co\.uk$'], $result->bindings); + } + + public function testFilterStartsWithSpecialChars(): void + { + $result = (new Builder()) + ->from('files') + ->filter([Query::startsWith('path', '/var/log.')]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['path' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['^\/var\/log\.'], $result->bindings); + } + + public function testFilterContainsWithSpecialCharsEscaped(): void + { + $result = (new Builder()) + ->from('logs') + ->filter([Query::containsString('message', ['file.txt'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['message' => ['$regex' => '?']], $op['filter']); + $this->assertSame(['file\.txt'], $result->bindings); + } + + public function testFilterGreaterThanEqualWithFloat(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::greaterThanEqual('price', 9.99)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['price' => ['$gte' => '?']], $op['filter']); + $this->assertSame([9.99], $result->bindings); + } + + public function testFilterLessThanEqualWithZero(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::lessThanEqual('stock', 0)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['stock' => ['$lte' => '?']], $op['filter']); + $this->assertSame([0], $result->bindings); + } + + public function testInsertSingleRowBindingStructure(): void + { + $result = (new Builder()) + ->into('logs') + ->set(['level' => 'info', 'message' => 'test', 'timestamp' => 12345]) + ->insert(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $documents */ + $documents = $op['documents']; + $this->assertCount(1, $documents); + $this->assertSame(['level' => '?', 'message' => '?', 'timestamp' => '?'], $documents[0]); + $this->assertSame(['info', 'test', 12345], $result->bindings); + } + + public function testFindOperationHasNoProjectionWhenNoneSelected(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertArrayNotHasKey('projection', $op); + } + + public function testFindOperationHasNoSortWhenNoneSorted(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('sort', $op); + } + + public function testFindOperationHasNoSkipWhenNoOffset(): void + { + $result = (new Builder()) + ->from('users') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('skip', $op); + } + + public function testFindOperationHasNoLimitWhenNoLimit(): void + { + $result = (new Builder()) + ->from('users') + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('limit', $op); + } + + public function testSelectIdFieldSuppressesIdExclusion(): void + { + $result = (new Builder()) + ->from('users') + ->select(['_id', 'name']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $projection */ + $projection = $op['projection']; + $this->assertSame(1, $projection['_id']); + $this->assertSame(1, $projection['name']); + } + + public function testIncrementWithFloat(): void + { + $result = (new Builder()) + ->from('accounts') + ->increment('balance', 99.50) + ->filter([Query::equal('id', ['acc1'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $incDoc */ + $incDoc = $update['$inc']; + $this->assertSame(99.50, $incDoc['balance']); + } + + public function testIncrementWithNegativeValue(): void + { + $result = (new Builder()) + ->from('counters') + ->increment('value', -5) + ->filter([Query::equal('name', ['test'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $incDoc */ + $incDoc = $update['$inc']; + $this->assertSame(-5, $incDoc['value']); + } + + public function testUnsetMultipleFields(): void + { + $result = (new Builder()) + ->from('users') + ->unsetFields('field_a', 'field_b', 'field_c') + ->filter([Query::equal('id', ['x'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $unsetDoc */ + $unsetDoc = $update['$unset']; + $this->assertCount(3, $unsetDoc); + $this->assertSame('', $unsetDoc['field_a']); + $this->assertSame('', $unsetDoc['field_b']); + $this->assertSame('', $unsetDoc['field_c']); + } + + public function testResetClearsMongoSpecificState(): void + { + $builder = (new Builder()) + ->from('users') + ->push('tags', 'a') + ->pull('tags', 'b') + ->addToSet('roles', 'admin') + ->increment('counter', 1) + ->unsetFields('temp') + ->filterSearch('bio', 'test') + ->tablesample(50); + + $builder->reset(); + $builder->from('items')->set(['name' => 'item1']); + + $result = $builder->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('items', $op['collection']); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + $this->assertArrayNotHasKey('$push', $update); + $this->assertArrayNotHasKey('$pull', $update); + $this->assertArrayNotHasKey('$addToSet', $update); + $this->assertArrayNotHasKey('$inc', $update); + $this->assertArrayNotHasKey('$unset', $update); + } + + public function testSingleFilterDoesNotWrapInAnd(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Alice'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['name' => '?'], $op['filter']); + $this->assertArrayNotHasKey('$and', $op['filter']); + } + + public function testPageCalculation(): void + { + $result = (new Builder()) + ->from('users') + ->page(5, 20) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(20, $op['limit']); + $this->assertSame(80, $op['skip']); + } + + public function testTextSearchAndTableSamplingCombined(): void + { + $result = (new Builder()) + ->from('articles') + ->filterSearch('content', 'tutorial') + ->tablesample(200) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $this->assertArrayHasKey('$match', $pipeline[0]); + /** @var array $firstMatch */ + $firstMatch = $pipeline[0]['$match']; + $this->assertArrayHasKey('$text', $firstMatch); + + $this->assertArrayHasKey('$sample', $pipeline[1]); + /** @var array $sampleBody */ + $sampleBody = $pipeline[1]['$sample']; + $this->assertSame(200, $sampleBody['size']); + } + + public function testNotBetweenStructure(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::notBetween('price', 10.0, 50.0)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['$or' => [ + ['price' => ['$lt' => '?']], + ['price' => ['$gt' => '?']], + ]], $op['filter']); + $this->assertSame([10.0, 50.0], $result->bindings); + } + + public function testContainsAnyOnArrayUsesIn(): void + { + $query = Query::containsAny('tags', ['a', 'b', 'c']); + $query->setOnArray(true); + + $result = (new Builder()) + ->from('posts') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame(['tags' => ['$in' => ['?', '?', '?']]], $op['filter']); + $this->assertSame(['a', 'b', 'c'], $result->bindings); + } + + public function testFilterWhereNotInSubqueryStructure(): void + { + $subquery = (new Builder()) + ->from('blacklist') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('blacklist', $lookupBody['from']); + $this->assertSame('_sub_0', $lookupBody['as']); + + $unsetStage = $this->findStage($pipeline, '$unset'); + $this->assertNotNull($unsetStage); + } + + public function testBuildIdempotent(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['name']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + + $result1 = $builder->build(); + $result2 = $builder->build(); + + $this->assertSame($result1->query, $result2->query); + $this->assertSame($result1->bindings, $result2->bindings); + } + + public function testExistsSubqueryAddsLimitOnePipeline(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterExists($subquery) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + /** @var list> $subPipeline */ + $subPipeline = $lookupBody['pipeline']; + + $hasLimit = false; + foreach ($subPipeline as $stage) { + if (isset($stage['$limit'])) { + $hasLimit = true; + $this->assertSame(1, $stage['$limit']); + } + } + $this->assertTrue($hasLimit); + } + + public function testJoinStripTablePrefix(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users._id', '=', 'u') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('user_id', $lookupBody['localField']); + $this->assertSame('_id', $lookupBody['foreignField']); + } + + public function testJoinDefaultAliasUsesTableName(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $lookupStage = $this->findStage($pipeline, '$lookup'); + $this->assertNotNull($lookupStage); + /** @var array $lookupBody */ + $lookupBody = $lookupStage['$lookup']; + $this->assertSame('users', $lookupBody['as']); + } + + public function testSortRandomWithSortAscCombined(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('name') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $sortStage = $this->findStage($pipeline, '$sort'); + $this->assertNotNull($sortStage); + /** @var array $sortBody */ + $sortBody = $sortStage['$sort']; + $this->assertSame(1, $sortBody['name']); + $this->assertSame(1, $sortBody['_rand']); + } + + public function testImplementsFieldUpdates(): void + { + $this->assertInstanceOf(FieldUpdates::class, new Builder()); + } + + public function testImplementsArrayPushModifiers(): void + { + $this->assertInstanceOf(ArrayPushModifiers::class, new Builder()); + } + + public function testImplementsConditionalArrayUpdates(): void + { + $this->assertInstanceOf(ConditionalArrayUpdates::class, new Builder()); + } + + public function testImplementsPipelineStages(): void + { + $this->assertInstanceOf(PipelineStages::class, new Builder()); + } + + public function testImplementsAtlasSearch(): void + { + $this->assertInstanceOf(AtlasSearch::class, new Builder()); + } + + public function testRenameField(): void + { + $result = (new Builder()) + ->from('users') + ->rename('old_name', 'new_name') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateMany', $op['operation']); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$rename', $update); + $this->assertSame(['old_name' => 'new_name'], $update['$rename']); + } + + public function testMultiply(): void + { + $result = (new Builder()) + ->from('products') + ->multiply('price', 1.1) + ->filter([Query::equal('category', ['sale'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$mul', $update); + $this->assertSame(['price' => 1.1], $update['$mul']); + } + + public function testPopFirst(): void + { + $result = (new Builder()) + ->from('users') + ->popFirst('tags') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$pop', $update); + $this->assertSame(['tags' => -1], $update['$pop']); + } + + public function testPopLast(): void + { + $result = (new Builder()) + ->from('users') + ->popLast('tags') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$pop', $update); + $this->assertSame(['tags' => 1], $update['$pop']); + } + + public function testPullAll(): void + { + $result = (new Builder()) + ->from('users') + ->pullAll('scores', [0, 5]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$pullAll', $update); + /** @var array> $pullAll */ + $pullAll = $update['$pullAll']; + $this->assertSame(['?', '?'], $pullAll['scores']); + $this->assertContains(0, $result->bindings); + $this->assertContains(5, $result->bindings); + } + + public function testUpdateMin(): void + { + $result = (new Builder()) + ->from('users') + ->updateMin('low_score', 50) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$min', $update); + $this->assertSame(['low_score' => '?'], $update['$min']); + $this->assertContains(50, $result->bindings); + } + + public function testUpdateMax(): void + { + $result = (new Builder()) + ->from('users') + ->updateMax('high_score', 100) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$max', $update); + $this->assertSame(['high_score' => '?'], $update['$max']); + $this->assertContains(100, $result->bindings); + } + + public function testCurrentDate(): void + { + $result = (new Builder()) + ->from('users') + ->currentDate('lastModified', 'date') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$currentDate', $update); + $this->assertSame(['lastModified' => ['$type' => 'date']], $update['$currentDate']); + } + + public function testCurrentDateTimestamp(): void + { + $result = (new Builder()) + ->from('users') + ->currentDate('lastModified', 'timestamp') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertSame(['lastModified' => ['$type' => 'timestamp']], $update['$currentDate']); + } + + public function testMultipleUpdateOperators(): void + { + $result = (new Builder()) + ->from('users') + ->rename('old_field', 'new_field') + ->multiply('score', 2) + ->popLast('queue') + ->updateMin('min_val', 10) + ->updateMax('max_val', 100) + ->currentDate('updated_at') + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$rename', $update); + $this->assertArrayHasKey('$mul', $update); + $this->assertArrayHasKey('$pop', $update); + $this->assertArrayHasKey('$min', $update); + $this->assertArrayHasKey('$max', $update); + $this->assertArrayHasKey('$currentDate', $update); + } + + public function testPushEachBasic(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('tags', ['a', 'b', 'c']) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$push', $update); + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $tagsModifier */ + $tagsModifier = $pushDoc['tags']; + $this->assertSame(['?', '?', '?'], $tagsModifier['$each']); + $this->assertArrayNotHasKey('$position', $tagsModifier); + $this->assertArrayNotHasKey('$slice', $tagsModifier); + $this->assertArrayNotHasKey('$sort', $tagsModifier); + } + + public function testPushEachWithAllModifiers(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('scores', [85, 92], 0, 5, ['score' => -1]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $scoresModifier */ + $scoresModifier = $pushDoc['scores']; + $this->assertSame(['?', '?'], $scoresModifier['$each']); + $this->assertSame(0, $scoresModifier['$position']); + $this->assertSame(5, $scoresModifier['$slice']); + $this->assertSame(['score' => -1], $scoresModifier['$sort']); + } + + public function testPushEachWithPosition(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('items', ['x'], 2) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $itemsModifier */ + $itemsModifier = $pushDoc['items']; + $this->assertSame(2, $itemsModifier['$position']); + $this->assertArrayNotHasKey('$slice', $itemsModifier); + } + + public function testPushEachWithSlice(): void + { + $result = (new Builder()) + ->from('users') + ->pushEach('items', ['a', 'b'], null, 10) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + /** @var array $itemsModifier */ + $itemsModifier = $pushDoc['items']; + $this->assertSame(10, $itemsModifier['$slice']); + $this->assertArrayNotHasKey('$position', $itemsModifier); + } + + public function testPushAndPushEachCombined(): void + { + $result = (new Builder()) + ->from('users') + ->push('simple_field', 'value') + ->pushEach('array_field', ['a', 'b']) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + /** @var array $pushDoc */ + $pushDoc = $update['$push']; + $this->assertSame('?', $pushDoc['simple_field']); + $this->assertIsArray($pushDoc['array_field']); + } + + public function testArrayFilter(): void + { + $result = (new Builder()) + ->from('students') + ->set(['grades.$[elem].mean' => 0]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('updateMany', $op['operation']); + $this->assertArrayHasKey('options', $op); + /** @var array $options */ + $options = $op['options']; + $this->assertArrayHasKey('arrayFilters', $options); + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertCount(1, $filters); + $this->assertArrayHasKey('elem.grade', $filters[0]); + $this->assertSame(['$gte' => 85], $filters[0]['elem.grade']); + } + + public function testMultipleArrayFilters(): void + { + $result = (new Builder()) + ->from('students') + ->set(['grades.$[elem].adjusted' => true]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->arrayFilter('other', ['other.type' => 'test']) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $options */ + $options = $op['options']; + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertCount(2, $filters); + $this->assertArrayHasKey('elem.grade', $filters[0]); + $this->assertArrayHasKey('other.type', $filters[1]); + } + + public function testBucket(): void + { + $result = (new Builder()) + ->from('sales') + ->bucket('price', [0, 100, 200, 300], 'Other', ['count' => ['$sum' => 1]]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $bucketStage = $this->findStage($pipeline, '$bucket'); + $this->assertNotNull($bucketStage); + /** @var array $bucketBody */ + $bucketBody = $bucketStage['$bucket']; + $this->assertSame('$price', $bucketBody['groupBy']); + $this->assertSame([0, 100, 200, 300], $bucketBody['boundaries']); + $this->assertSame('Other', $bucketBody['default']); + $this->assertSame(['count' => ['$sum' => 1]], $bucketBody['output']); + } + + public function testBucketWithoutDefault(): void + { + $result = (new Builder()) + ->from('sales') + ->bucket('amount', [0, 50, 100]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $bucketStage = $this->findStage($pipeline, '$bucket'); + $this->assertNotNull($bucketStage); + /** @var array $bucketBody */ + $bucketBody = $bucketStage['$bucket']; + $this->assertArrayNotHasKey('default', $bucketBody); + $this->assertArrayNotHasKey('output', $bucketBody); + } + + public function testBucketAuto(): void + { + $result = (new Builder()) + ->from('sales') + ->bucketAuto('price', 5, ['count' => ['$sum' => 1]]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $bucketAutoStage = $this->findStage($pipeline, '$bucketAuto'); + $this->assertNotNull($bucketAutoStage); + /** @var array $bucketAutoBody */ + $bucketAutoBody = $bucketAutoStage['$bucketAuto']; + $this->assertSame('$price', $bucketAutoBody['groupBy']); + $this->assertSame(5, $bucketAutoBody['buckets']); + $this->assertSame(['count' => ['$sum' => 1]], $bucketAutoBody['output']); + } + + public function testBucketAutoWithoutOutput(): void + { + $result = (new Builder()) + ->from('sales') + ->bucketAuto('amount', 10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $bucketAutoStage = $this->findStage($pipeline, '$bucketAuto'); + $this->assertNotNull($bucketAutoStage); + /** @var array $bucketAutoBody */ + $bucketAutoBody = $bucketAutoStage['$bucketAuto']; + $this->assertArrayNotHasKey('output', $bucketAutoBody); + } + + public function testFacet(): void + { + $priceFacet = (new Builder()) + ->from('products') + ->bucket('price', [0, 100, 200]); + + $categoryFacet = (new Builder()) + ->from('products') + ->count('*', 'total') + ->groupBy(['category']); + + $result = (new Builder()) + ->from('products') + ->facet([ + 'priceFacet' => $priceFacet, + 'categoryFacet' => $categoryFacet, + ]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $facetStage = $this->findStage($pipeline, '$facet'); + $this->assertNotNull($facetStage); + /** @var array $facetBody */ + $facetBody = $facetStage['$facet']; + $this->assertArrayHasKey('priceFacet', $facetBody); + $this->assertArrayHasKey('categoryFacet', $facetBody); + $this->assertIsArray($facetBody['priceFacet']); + $this->assertIsArray($facetBody['categoryFacet']); + } + + public function testGraphLookup(): void + { + $result = (new Builder()) + ->from('employees') + ->graphLookup('employees', 'managerId', 'managerId', '_id', 'reportingHierarchy', 5, 'depth') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $graphLookupStage = $this->findStage($pipeline, '$graphLookup'); + $this->assertNotNull($graphLookupStage); + /** @var array $graphLookupBody */ + $graphLookupBody = $graphLookupStage['$graphLookup']; + $this->assertSame('employees', $graphLookupBody['from']); + $this->assertSame('$managerId', $graphLookupBody['startWith']); + $this->assertSame('managerId', $graphLookupBody['connectFromField']); + $this->assertSame('_id', $graphLookupBody['connectToField']); + $this->assertSame('reportingHierarchy', $graphLookupBody['as']); + $this->assertSame(5, $graphLookupBody['maxDepth']); + $this->assertSame('depth', $graphLookupBody['depthField']); + } + + public function testGraphLookupWithoutOptionalFields(): void + { + $result = (new Builder()) + ->from('categories') + ->graphLookup('categories', 'parentId', 'parentId', '_id', 'ancestors') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $graphLookupStage = $this->findStage($pipeline, '$graphLookup'); + $this->assertNotNull($graphLookupStage); + /** @var array $graphLookupBody */ + $graphLookupBody = $graphLookupStage['$graphLookup']; + $this->assertArrayNotHasKey('maxDepth', $graphLookupBody); + $this->assertArrayNotHasKey('depthField', $graphLookupBody); + } + + public function testMergeIntoCollection(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['region']) + ->mergeIntoCollection('order_summary', ['_id'], ['replace'], ['insert']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $mergeStage = $this->findStage($pipeline, '$merge'); + $this->assertNotNull($mergeStage); + /** @var array $mergeBody */ + $mergeBody = $mergeStage['$merge']; + $this->assertSame('order_summary', $mergeBody['into']); + $this->assertSame(['_id'], $mergeBody['on']); + $this->assertSame(['replace'], $mergeBody['whenMatched']); + $this->assertSame(['insert'], $mergeBody['whenNotMatched']); + } + + public function testMergeIntoCollectionMinimal(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->mergeIntoCollection('summary') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $mergeStage = $this->findStage($pipeline, '$merge'); + $this->assertNotNull($mergeStage); + /** @var array $mergeBody */ + $mergeBody = $mergeStage['$merge']; + $this->assertSame('summary', $mergeBody['into']); + $this->assertArrayNotHasKey('on', $mergeBody); + $this->assertArrayNotHasKey('whenMatched', $mergeBody); + } + + public function testMergeIsLastPipelineStage(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->sortDesc('total') + ->limit(10) + ->mergeIntoCollection('output') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $lastStage = $pipeline[\count($pipeline) - 1]; + $this->assertArrayHasKey('$merge', $lastStage); + } + + public function testOutputToCollection(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->outputToCollection('order_results') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $outStage = $this->findStage($pipeline, '$out'); + $this->assertNotNull($outStage); + $this->assertSame('order_results', $outStage['$out']); + } + + public function testOutputToCollectionWithDatabase(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->outputToCollection('results', 'analytics_db') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $outStage = $this->findStage($pipeline, '$out'); + $this->assertNotNull($outStage); + /** @var array $outBody */ + $outBody = $outStage['$out']; + $this->assertSame('analytics_db', $outBody['db']); + $this->assertSame('results', $outBody['coll']); + } + + public function testOutputIsLastPipelineStage(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->limit(5) + ->outputToCollection('output') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $lastStage = $pipeline[\count($pipeline) - 1]; + $this->assertArrayHasKey('$out', $lastStage); + } + + public function testMergeTakesPrecedenceOverOut(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->mergeIntoCollection('merge_target') + ->outputToCollection('out_target') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $mergeStage = $this->findStage($pipeline, '$merge'); + $this->assertNotNull($mergeStage); + $outStage = $this->findStage($pipeline, '$out'); + $this->assertNull($outStage); + } + + public function testReplaceRoot(): void + { + $result = (new Builder()) + ->from('users') + ->replaceRoot('$profile') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $replaceRootStage = $this->findStage($pipeline, '$replaceRoot'); + $this->assertNotNull($replaceRootStage); + /** @var array $replaceRootBody */ + $replaceRootBody = $replaceRootStage['$replaceRoot']; + $this->assertSame('$profile', $replaceRootBody['newRoot']); + } + + public function testAtlasSearch(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'mongodb', 'path' => 'content']], 'default') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertArrayHasKey('$search', $pipeline[0]); + /** @var array $searchBody */ + $searchBody = $pipeline[0]['$search']; + $this->assertSame('default', $searchBody['index']); + $this->assertSame(['query' => 'mongodb', 'path' => 'content'], $searchBody['text']); + } + + public function testAtlasSearchWithoutIndex(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'test', 'path' => 'title']]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $searchBody */ + $searchBody = $pipeline[0]['$search']; + $this->assertArrayNotHasKey('index', $searchBody); + } + + public function testAtlasSearchIsFirstStage(): void + { + $result = (new Builder()) + ->from('articles') + ->filter([Query::equal('status', ['published'])]) + ->search(['text' => ['query' => 'test', 'path' => 'content']], 'default') + ->sortDesc('score') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertArrayHasKey('$search', $pipeline[0]); + } + + public function testSearchMeta(): void + { + $result = (new Builder()) + ->from('articles') + ->searchMeta(['facet' => ['operator' => ['text' => ['query' => 'test', 'path' => 'content']], 'facets' => ['categories' => ['type' => 'string', 'path' => 'category']]]], 'default') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertCount(1, $pipeline); + $this->assertArrayHasKey('$searchMeta', $pipeline[0]); + } + + public function testVectorSearch(): void + { + $result = (new Builder()) + ->from('products') + ->vectorSearch('embedding', [0.1, 0.2, 0.3], 100, 10, 'vector_index', ['category' => 'electronics']) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertArrayHasKey('$vectorSearch', $pipeline[0]); + /** @var array $vectorSearchBody */ + $vectorSearchBody = $pipeline[0]['$vectorSearch']; + $this->assertSame('embedding', $vectorSearchBody['path']); + $this->assertSame([0.1, 0.2, 0.3], $vectorSearchBody['queryVector']); + $this->assertSame(100, $vectorSearchBody['numCandidates']); + $this->assertSame(10, $vectorSearchBody['limit']); + $this->assertSame('vector_index', $vectorSearchBody['index']); + $this->assertSame(['category' => 'electronics'], $vectorSearchBody['filter']); + } + + public function testVectorSearchWithoutOptionalFields(): void + { + $result = (new Builder()) + ->from('products') + ->vectorSearch('embedding', [0.5, 0.5], 50, 5) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + /** @var array $vectorSearchBody */ + $vectorSearchBody = $pipeline[0]['$vectorSearch']; + $this->assertArrayNotHasKey('index', $vectorSearchBody); + $this->assertArrayNotHasKey('filter', $vectorSearchBody); + } + + public function testVectorSearchIsFirstStage(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::equal('active', [true])]) + ->vectorSearch('embedding', [0.1, 0.2], 100, 10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertArrayHasKey('$vectorSearch', $pipeline[0]); + } + + public function testHintStringOnFind(): void + { + $result = (new Builder()) + ->from('users') + ->hint('idx_name') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertSame('idx_name', $op['hint']); + } + + public function testHintArrayOnFind(): void + { + $result = (new Builder()) + ->from('users') + ->hint(['name' => 1, 'age' => -1]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + $this->assertSame(['name' => 1, 'age' => -1], $op['hint']); + } + + public function testHintOnAggregate(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'total') + ->hint('idx_name') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + $this->assertSame('idx_name', $op['hint']); + } + + public function testResetClearsNewProperties(): void + { + $builder = (new Builder()) + ->from('users') + ->rename('a', 'b') + ->multiply('c', 2) + ->popFirst('d') + ->pullAll('e', [1]) + ->updateMin('f', 0) + ->updateMax('g', 100) + ->currentDate('h') + ->pushEach('i', [1, 2]) + ->arrayFilter('elem', ['elem.x' => 1]) + ->hint('idx'); + + $builder->reset(); + $builder->from('items')->set(['name' => 'item1']); + + $result = $builder->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + $this->assertArrayNotHasKey('$rename', $update); + $this->assertArrayNotHasKey('$mul', $update); + $this->assertArrayNotHasKey('$pop', $update); + $this->assertArrayNotHasKey('$pullAll', $update); + $this->assertArrayNotHasKey('$min', $update); + $this->assertArrayNotHasKey('$max', $update); + $this->assertArrayNotHasKey('$currentDate', $update); + $this->assertArrayNotHasKey('options', $op); + $this->assertArrayNotHasKey('hint', $op); + } + + public function testResetClearsPipelineStages(): void + { + $builder = (new Builder()) + ->from('products') + ->bucket('price', [0, 100]) + ->replaceRoot('$data') + ->mergeIntoCollection('output'); + + $builder->reset(); + $builder->from('items'); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + } + + public function testResetClearsSearchStages(): void + { + $builder = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'test', 'path' => 'content']]); + + $builder->reset(); + $builder->from('articles'); + + $result = $builder->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('find', $op['operation']); + } + + public function testBucketReplacesGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->bucket('price', [0, 50, 100, 200]) + ->filter([Query::greaterThan('quantity', 0)]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $bucketIdx = $this->findStageIndex($pipeline, '$bucket'); + $this->assertNotNull($matchIdx); + $this->assertNotNull($bucketIdx); + $this->assertLessThan($bucketIdx, $matchIdx); + + $groupStage = $this->findStage($pipeline, '$group'); + $this->assertNull($groupStage); + } + + public function testGraphLookupWithFilter(): void + { + $result = (new Builder()) + ->from('employees') + ->graphLookup('employees', 'reportsTo', 'reportsTo', '_id', 'hierarchy', 3) + ->filter([Query::equal('department', ['engineering'])]) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $graphLookupIdx = $this->findStageIndex($pipeline, '$graphLookup'); + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $this->assertNotNull($graphLookupIdx); + $this->assertNotNull($matchIdx); + $this->assertLessThan($matchIdx, $graphLookupIdx); + } + + public function testSearchWithFilterAndSort(): void + { + $result = (new Builder()) + ->from('articles') + ->search(['text' => ['query' => 'mongodb', 'path' => 'content']], 'default') + ->filter([Query::equal('status', ['published'])]) + ->sortDesc('score') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $searchIdx = $this->findStageIndex($pipeline, '$search'); + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $sortIdx = $this->findStageIndex($pipeline, '$sort'); + $limitIdx = $this->findStageIndex($pipeline, '$limit'); + + $this->assertNotNull($searchIdx); + $this->assertNotNull($matchIdx); + $this->assertNotNull($sortIdx); + $this->assertNotNull($limitIdx); + + $this->assertSame(0, $searchIdx); + $this->assertLessThan($matchIdx, $searchIdx); + $this->assertLessThan($sortIdx, $matchIdx); + $this->assertLessThan($limitIdx, $sortIdx); + } + + public function testAllUpdateOperatorsCombined(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Updated']) + ->increment('counter', 1) + ->push('log', 'entry') + ->pull('obsolete', 'old') + ->addToSet('roles', 'editor') + ->unsetFields('temp') + ->rename('oldField', 'newField') + ->multiply('score', 1.5) + ->popFirst('queue') + ->pullAll('tags', ['a', 'b']) + ->updateMin('min_val', 0) + ->updateMax('max_val', 999) + ->currentDate('modified_at', 'timestamp') + ->pushEach('items', ['x', 'y'], 0, 10) + ->filter([Query::equal('_id', ['test'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertArrayHasKey('$set', $update); + $this->assertArrayHasKey('$inc', $update); + $this->assertArrayHasKey('$push', $update); + $this->assertArrayHasKey('$pull', $update); + $this->assertArrayHasKey('$addToSet', $update); + $this->assertArrayHasKey('$unset', $update); + $this->assertArrayHasKey('$rename', $update); + $this->assertArrayHasKey('$mul', $update); + $this->assertArrayHasKey('$pop', $update); + $this->assertArrayHasKey('$pullAll', $update); + $this->assertArrayHasKey('$min', $update); + $this->assertArrayHasKey('$max', $update); + $this->assertArrayHasKey('$currentDate', $update); + } + + public function testBucketWithFilterAndSort(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::equal('active', [true])]) + ->bucket('price', [0, 25, 50, 100, 200], 'expensive', ['count' => ['$sum' => 1], 'avgPrice' => ['$avg' => '$price']]) + ->sortDesc('count') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $bucketIdx = $this->findStageIndex($pipeline, '$bucket'); + $sortIdx = $this->findStageIndex($pipeline, '$sort'); + + $this->assertNotNull($matchIdx); + $this->assertNotNull($bucketIdx); + $this->assertNotNull($sortIdx); + + $this->assertLessThan($bucketIdx, $matchIdx); + $this->assertLessThan($sortIdx, $bucketIdx); + } + + public function testHintOnSearchMeta(): void + { + $result = (new Builder()) + ->from('articles') + ->searchMeta(['count' => ['type' => 'total']], 'default') + ->hint('search_idx') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertSame('aggregate', $op['operation']); + $this->assertSame('search_idx', $op['hint']); + } + + public function testReplaceRootAfterGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->count('*', 'total') + ->groupBy(['region']) + ->replaceRoot('$stats') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $groupIdx = $this->findStageIndex($pipeline, '$group'); + $replaceRootIdx = $this->findStageIndex($pipeline, '$replaceRoot'); + + $this->assertNotNull($groupIdx); + $this->assertNotNull($replaceRootIdx); + $this->assertLessThan($replaceRootIdx, $groupIdx); + } + + public function testUpdateWithNoArrayFiltersHasNoOptions(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'test']) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('options', $op); + } + + public function testMultiplyWithInteger(): void + { + $result = (new Builder()) + ->from('products') + ->multiply('quantity', 2) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var array $update */ + $update = $op['update']; + $this->assertSame(['quantity' => 2], $update['$mul']); + } + + public function testBucketAutoWithFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->filter([Query::greaterThan('amount', 0)]) + ->bucketAuto('amount', 4) + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + + $matchIdx = $this->findStageIndex($pipeline, '$match'); + $bucketAutoIdx = $this->findStageIndex($pipeline, '$bucketAuto'); + + $this->assertNotNull($matchIdx); + $this->assertNotNull($bucketAutoIdx); + $this->assertLessThan($bucketAutoIdx, $matchIdx); + } + + public function testNoHintOnFindByDefault(): void + { + $result = (new Builder()) + ->from('users') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('hint', $op); + } + + public function testNoHintOnAggregateByDefault(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $op = $this->decode($result->query); + $this->assertArrayNotHasKey('hint', $op); + } + + /** + * @return list + */ + public static function fieldNameRejectingSetters(): array + { + return [ + ['set', fn (Builder $b, string $f) => $b->set([$f => 1])], + ['push', fn (Builder $b, string $f) => $b->push($f, 1)], + ['pull', fn (Builder $b, string $f) => $b->pull($f, 1)], + ['pullAll', fn (Builder $b, string $f) => $b->pullAll($f, [1])], + ['addToSet', fn (Builder $b, string $f) => $b->addToSet($f, 1)], + ['increment', fn (Builder $b, string $f) => $b->increment($f, 1)], + ['multiply', fn (Builder $b, string $f) => $b->multiply($f, 2)], + ['rename', fn (Builder $b, string $f) => $b->rename($f, 'ok')], + ['renameTarget', fn (Builder $b, string $f) => $b->rename('ok', $f)], + ['unsetFields', fn (Builder $b, string $f) => $b->unsetFields($f)], + ['currentDate', fn (Builder $b, string $f) => $b->currentDate($f)], + ['popFirst', fn (Builder $b, string $f) => $b->popFirst($f)], + ['popLast', fn (Builder $b, string $f) => $b->popLast($f)], + ['updateMin', fn (Builder $b, string $f) => $b->updateMin($f, 1)], + ['updateMax', fn (Builder $b, string $f) => $b->updateMax($f, 1)], + ['pushEach', fn (Builder $b, string $f) => $b->pushEach($f, [1])], + ]; + } + + /** + * @param callable(Builder, string): mixed $action + */ + #[\PHPUnit\Framework\Attributes\DataProvider('fieldNameRejectingSetters')] + public function testSetterRejectsDollarPrefixedFieldName(string $label, callable $action): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid MongoDB field name'); + + $action(new Builder(), '$where'); + } + + /** + * @param callable(Builder, string): mixed $action + */ + #[\PHPUnit\Framework\Attributes\DataProvider('fieldNameRejectingSetters')] + public function testSetterRejectsEmptyFieldName(string $label, callable $action): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid MongoDB field name'); + + $action(new Builder(), ''); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $op = $this->decode($result->query); + $this->assertArrayHasKey('projection', $op); + $this->assertIsArray($op['projection']); + $this->assertArrayHasKey($word, $op['projection']); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFrom(string $word): void + { + $result = (new Builder()) + ->from($word) + ->build(); + + $op = $this->decode($result->query); + $this->assertSame($word, $op['collection']); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $op = $this->decode($result->query); + $this->assertArrayHasKey('filter', $op); + $this->assertIsArray($op['filter']); + $this->assertArrayHasKey($word, $op['filter']); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $op = $this->decode($result->query); + $this->assertArrayHasKey('projection', $op); + $this->assertIsArray($op['projection']); + $this->assertArrayHasKey($identifier, $op['projection']); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $op = $this->decode($result->query); + $this->assertSame($identifier, $op['collection']); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $op = $this->decode($result->query); + $this->assertArrayHasKey('filter', $op); + $this->assertIsArray($op['filter']); + $this->assertArrayHasKey($identifier, $op['filter']); + $this->assertSame(['x'], $result->bindings); + } + + public function testUpdateUsesOperatorEnumsForMixedOperations(): void + { + $builder = (new Builder()) + ->from('users') + ->set(['status' => 'active']) + ->push('tags', 'vip') + ->increment('loginCount', 1) + ->unsetFields('deprecatedField'); + + $plan = $builder->update(); + /** @var array $operation */ + $operation = \json_decode($plan->query, true); + + $this->assertSame(Operation::UpdateMany->value, $operation['operation']); + + /** @var array $update */ + $update = $operation['update']; + + $this->assertArrayHasKey(UpdateOperator::Set->value, $update); + $this->assertArrayHasKey(UpdateOperator::Push->value, $update); + $this->assertArrayHasKey(UpdateOperator::Increment->value, $update); + $this->assertArrayHasKey(UpdateOperator::Unset->value, $update); + + $this->assertSame(['status' => '?'], $update[UpdateOperator::Set->value]); + $this->assertSame(['tags' => '?'], $update[UpdateOperator::Push->value]); + $this->assertSame(['loginCount' => 1], $update[UpdateOperator::Increment->value]); + $this->assertSame(['deprecatedField' => ''], $update[UpdateOperator::Unset->value]); + } + + public function testUpdateOperatorEnumValuesMatchMongoStrings(): void + { + $this->assertSame('$set', UpdateOperator::Set->value); + $this->assertSame('$push', UpdateOperator::Push->value); + $this->assertSame('$pull', UpdateOperator::Pull->value); + $this->assertSame('$pullAll', UpdateOperator::PullAll->value); + $this->assertSame('$addToSet', UpdateOperator::AddToSet->value); + $this->assertSame('$inc', UpdateOperator::Increment->value); + $this->assertSame('$mul', UpdateOperator::Multiply->value); + $this->assertSame('$unset', UpdateOperator::Unset->value); + $this->assertSame('$rename', UpdateOperator::Rename->value); + $this->assertSame('$pop', UpdateOperator::Pop->value); + $this->assertSame('$min', UpdateOperator::Min->value); + $this->assertSame('$max', UpdateOperator::Max->value); + $this->assertSame('$currentDate', UpdateOperator::CurrentDate->value); + } + + public function testWhereColumnIsNotSupportedOnMongoDB(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('whereColumn() is not supported on the MongoDB builder.'); + + (new Builder()) + ->from('users') + ->whereColumn('users.id', '=', 'orders.user_id'); + } + +} diff --git a/tests/Query/Builder/MySQLTest.php b/tests/Query/Builder/MySQLTest.php new file mode 100644 index 0000000..e5f24a2 --- /dev/null +++ b/tests/Query/Builder/MySQLTest.php @@ -0,0 +1,14989 @@ +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->assertSame('`age` > ?', $sql); + $this->assertSame([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->assertSame( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT `name`, `email` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE `status` IN (?, ?)', $result->query); + $this->assertSame(['active', 'pending'], $result->bindings); + } + + public function testNotEqualSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', 'guest')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `role` != ?', $result->query); + $this->assertSame(['guest'], $result->bindings); + } + + public function testNotEqualMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertSame(['guest', 'banned'], $result->bindings); + } + + public function testLessThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('price', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `price` < ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('price', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `price` <= ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` > ?', $result->query); + $this->assertSame([18], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 90)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertSame([90], $result->bindings); + } + + public function testBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testNotBetween(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'Jo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertSame(['Jo%'], $result->bindings); + } + + public function testNotStartsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'Jo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); + $this->assertSame(['Jo%'], $result->bindings); + } + + public function testEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `email` LIKE ?', $result->query); + $this->assertSame(['%.com'], $result->bindings); + } + + public function testNotEndsWith(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('email', '.com')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `email` NOT LIKE ?', $result->query); + $this->assertSame(['%.com'], $result->bindings); + } + + public function testContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertSame(['%php%'], $result->bindings); + } + + public function testContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertSame(['%php%', '%js%'], $result->bindings); + } + + public function testContainsAny(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAny('tags', ['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`tags` LIKE ? OR `tags` LIKE ?)', $result->query); + $this->assertSame(['%a%', '%b%'], $result->bindings); + } + + public function testContainsAll(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('perms', ['read', 'write'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`perms` LIKE ? AND `perms` LIKE ?)', $result->query); + $this->assertSame(['%read%', '%write%'], $result->bindings); + } + + public function testNotContainsSingle(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertSame(['%php%'], $result->bindings); + } + + public function testNotContainsMultiple(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertSame(['%php%', '%js%'], $result->bindings); + } + + public function testSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE)', $result->query); + $this->assertSame(['hello*'], $result->bindings); + } + + public function testNotSearch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(? IN BOOLEAN MODE))', $result->query); + $this->assertSame(['hello*'], $result->bindings); + } + + public function testRegex(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertSame(['^[a-z]+$'], $result->bindings); + } + + public function testIsNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNull('deleted')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `deleted` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testIsNotNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('verified')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `verified` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotExists(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['legacy'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`legacy` IS NULL)', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `t` WHERE (`age` > ? AND `status` IN (?))', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE (`age` > ? AND (`role` IN (?) OR `role` IN (?)))', + $result->query + ); + $this->assertSame([18, 'admin', 'mod'], $result->bindings); + } + + public function testSortAsc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM `t` ORDER BY `score` DESC', $result->query); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([10], $result->bindings); + } + + public function testOffsetOnly(): void + { + // OFFSET without LIMIT is invalid in MySQL/ClickHouse; the builder refuses it. + $this->expectException(ValidationException::class); + (new Builder()) + ->from('t') + ->offset(50) + ->build(); + } + + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc123') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` > ?', $result->query); + $this->assertSame(['abc123'], $result->bindings); + } + + public function testCursorBefore(): void + { + $result = (new Builder()) + ->from('t') + ->cursorBefore('xyz789') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` < ?', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT `id`, `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC, `age` DESC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `orders` WHERE `total` > ?', $result->query); + $this->assertSame([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->assertSame( + 'SELECT * FROM `users` WHERE `_uid` IN (?) ORDER BY `_createdAt` ASC', + $result->query + ); + $this->assertSame(['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->assertSame( + '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->assertSame( + 'SELECT * FROM `users` WHERE `_uid` IN (?) AND _tenant = ?', + $result->query + ); + $this->assertSame(['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->assertSame( + "SELECT * FROM `users` WHERE `status` IN (?) AND _uid IN (SELECT _document FROM users_perms WHERE _type = 'read')", + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `docs` WHERE `status` IN (?) AND _tenant = ?', + $result->query + ); + // filter bindings first, then hook bindings + $this->assertSame(['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->assertSame(['active', 't1', 'cursor_val', 10, 5], $result->bindings); + } + + public function testDefaultSelectStar(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t`', $result->query); + } + + public function testCountStar(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) FROM `t`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCountWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('SELECT AVG(`score`) FROM `t`', $result->query); + } + + public function testMinColumn(): void + { + $result = (new Builder()) + ->from('t') + ->min('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MIN(`price`) FROM `t`', $result->query); + } + + public function testMaxColumn(): void + { + $result = (new Builder()) + ->from('t') + ->max('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING COUNT(*) > ?', + $result->query + ); + $this->assertSame([5], $result->bindings); + } + + public function testDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT `status` FROM `t`', $result->query); + } + + public function testDistinctStar(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ?', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE score > ? AND score < ?', $result->query); + $this->assertSame([10, 100], $result->bindings); + } + + public function testRawFilterNoBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('1 = 1')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertSame([], $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->assertSame( + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertSame(['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->assertSame( + '(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->assertSame('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([10, 20], $result->bindings); + } + + public function testPageDefaultPerPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([25, 0], $result->bindings); + } + + public function testToRawSql(): void + { + $sql = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10) + ->toRawSql(); + + $this->assertSame( + "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->assertSame("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->assertSame( + '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 COUNT(*) > ? ORDER BY `total_amount` DESC LIMIT ?', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `fresh`', $result->query); + } + // EDGE CASES & COMBINATIONS + + + public function testCountWithNamedColumn(): void + { + $result = (new Builder()) + ->from('t') + ->count('id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(`id`) FROM `t`', $result->query); + } + + public function testCountWithEmptyStringAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->count('') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + '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->assertSame([], $result->bindings); + } + + public function testAggregationWithoutGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->sum('total', 'grand_total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + 'SELECT COUNT(*) AS `total` FROM `orders` WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testAggregationWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->sum('price') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*), SUM(`price`) FROM `t`', $result->query); + } + + public function testGroupByEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->groupBy([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `status`, `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->assertSame( + 'SELECT COUNT(*) AS `total`, SUM(`price`) AS `sum_price` FROM `t` GROUP BY `status` HAVING COUNT(*) > ? AND SUM(`price`) < ?', + $result->query + ); + $this->assertSame([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->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `status` HAVING (`total` > ? OR `total` < ?)', $result->query); + $this->assertSame([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->assertSame('SELECT COUNT(*) AS `total` FROM `t` HAVING COUNT(*) > ?', $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->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `status` HAVING COUNT(*) > ? AND COUNT(*) < ?', $result->query); + $this->assertSame([1, 100], $result->bindings); + } + + public function testDistinctWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame([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->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ?', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE `status` IN (?) AND custom_func(col) > ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE 1 = 1', $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->assertSame( + '(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->assertSame( + '(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->assertSame( + '(SELECT * FROM `users` WHERE `status` IN (?)) UNION (SELECT * FROM `admins` WHERE `level` IN (?)) UNION ALL (SELECT * FROM `mods` WHERE `score` > ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + '(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->assertSame('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->assertSame('SELECT * FROM `t` WHERE `a` IN (?) AND `c` IN (?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([1, 4], $result->bindings); + } + + public function testPageLargeValues(): void + { + $result = (new Builder()) + ->from('t') + ->page(1000, 100) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([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->assertSame("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->assertSame("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->assertSame("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->assertSame( + "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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame('SELECT COUNT(*) AS `cnt` FROM `t` GROUP BY `status` 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->assertSame( + 'SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`total` > ? AND users.org_id = ?', + $result->query + ); + $this->assertSame([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->assertSame('SELECT COUNT(*) AS `total` FROM `orders` WHERE org_id = ? GROUP BY `status`', $result->query); + $this->assertSame(['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->assertSame($result1->query, $result2->query); + $this->assertSame($result1->bindings, $result2->bindings); + } + + + public function testEmptyBuilderNoFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->build(); + } + + public function testCursorWithLimitAndOffset(): void + { + $result = (new Builder()) + ->from('t') + ->cursorAfter('abc') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE `_cursor` > ? LIMIT ? OFFSET ?', $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->assertSame('(SELECT DISTINCT COUNT(*) AS `cnt`, SUM(`total`) AS `sum_total`, `status` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `coupons` ON `orders`.`coupon_id` = `coupons`.`id` WHERE `orders`.`status` IN (?) AND `orders`.`total` > ? AND org = ? GROUP BY `status` HAVING COUNT(*) > ? ORDER BY `sum_total` DESC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `year` IN (?))', $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->assertSame('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->assertSame('SELECT FROM `t`', $result->query); + } + + public function testLimitZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([0], $result->bindings); + } + + public function testOffsetZero(): void + { + // OFFSET without LIMIT is invalid in MySQL/ClickHouse; the builder refuses it. + $this->expectException(ValidationException::class); + (new Builder()) + ->from('t') + ->offset(0) + ->build(); + } + + + 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->assertSame('SELECT * FROM `t` WHERE `slug` REGEXP ?', $result->query); + $this->assertSame([''], $result->bindings); + } + + public function testRegexWithDotChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a.b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` REGEXP ?', $result->query); + $this->assertSame(['a.b'], $result->bindings); + } + + public function testRegexWithStarChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a*b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['a*b'], $result->bindings); + } + + public function testRegexWithPlusChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'a+')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['a+'], $result->bindings); + } + + public function testRegexWithQuestionMarkChar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('name', 'colou?r')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['colou?r'], $result->bindings); + } + + public function testRegexWithCaretAndDollar(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('code', '^[A-Z]+$')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['^[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->assertSame(['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->assertSame(['\\\\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->assertSame('[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->assertSame(['(\\+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->assertSame( + 'SELECT * FROM `t` WHERE `status` IN (?) AND `slug` REGEXP ? AND `age` > ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE `_slug` REGEXP ?', $result->query); + $this->assertSame(['^test'], $result->bindings); + } + + public function testRegexStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::regex('col', '^abc'); + $sql = $builder->compileFilter($query); + + $this->assertSame('`col` REGEXP ?', $sql); + $this->assertSame(['^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->assertSame($pattern, $result->bindings[0]); + $this->assertSame('SELECT * FROM `t` WHERE `col` 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->assertSame( + 'SELECT * FROM `t` WHERE `name` REGEXP ? AND `email` REGEXP ?', + $result->query + ); + $this->assertSame(['^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->assertSame( + 'SELECT * FROM `t` WHERE (`slug` REGEXP ? AND `status` IN (?))', + $result->query + ); + $this->assertSame(['^[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->assertSame( + 'SELECT * FROM `t` WHERE (`name` REGEXP ? OR `name` REGEXP ?)', + $result->query + ); + $this->assertSame(['^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->assertSame('SELECT * FROM `t` WHERE 1 = 0', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testSearchWithSpecialCharacters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello "world" +required -excluded')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE) AND `status` IN (?) AND `views` > ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(? IN BOOLEAN MODE)) AND `status` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE MATCH(`_body`) AGAINST(? IN BOOLEAN MODE)', $result->query); + } + + public function testSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::search('body', 'test'); + $sql = $builder->compileFilter($query); + + $this->assertSame('MATCH(`body`) AGAINST(? IN BOOLEAN MODE)', $sql); + $this->assertSame(['test*'], $builder->getBindings()); + } + + public function testNotSearchStandaloneCompileFilter(): void + { + $builder = new Builder(); + $query = Query::notSearch('body', 'spam'); + $sql = $builder->compileFilter($query); + + $this->assertSame('NOT (MATCH(`body`) AGAINST(? IN BOOLEAN MODE))', $sql); + $this->assertSame(['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('hello world exact phrase required excluded*', $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->assertSame(trim($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->assertSame( + 'SELECT * FROM `t` WHERE MATCH(`title`) AGAINST(? IN BOOLEAN MODE) AND MATCH(`body`) AGAINST(? IN BOOLEAN MODE)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE (MATCH(`content`) AGAINST(? IN BOOLEAN MODE) 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->assertSame( + 'SELECT * FROM `t` WHERE (MATCH(`title`) AGAINST(? IN BOOLEAN MODE) OR MATCH(`body`) AGAINST(? IN BOOLEAN MODE))', + $result->query + ); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE) AND `slug` REGEXP ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE NOT (MATCH(`content`) AGAINST(? IN BOOLEAN MODE))', $result->query); + $this->assertSame(['spam*'], $result->bindings); + } + // 3. SQL-Specific: RAND() + + public function testRandomSortStandaloneCompile(): void + { + $builder = new Builder(); + $query = Query::orderRandom(); + $sql = $builder->compileOrder($query); + + $this->assertSame('RAND()', $sql); + } + + public function testRandomSortCombinedWithAscDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortRandom() + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame( + 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY RAND()', + $result->query + ); + $this->assertSame(['active'], $result->bindings); + } + + public function testRandomSortWithLimit(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertSame([5], $result->bindings); + } + + public function testRandomSortWithAggregation(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['category']) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `category` ORDER BY RAND()', $result->query); + } + + public function testRandomSortWithJoins(): void + { + $result = (new Builder()) + ->from('users') + ->join('orders', 'users.id', 'orders.user_id') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` ORDER BY RAND()', $result->query); + } + + public function testRandomSortWithDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame('SELECT * FROM `t` ORDER BY RAND() LIMIT ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` ORDER BY RAND()', $result->query); + } + + public function testMultipleRandomSorts(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM `t` ORDER BY RAND() LIMIT ? OFFSET ?', $result->query); + $this->assertSame([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->assertSame('`col` IN (?, ?)', $sql); + $this->assertSame(['a', 'b'], $builder->getBindings()); + } + + public function testCompileFilterNotEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEqual('col', 'a')); + $this->assertSame('`col` != ?', $sql); + $this->assertSame(['a'], $builder->getBindings()); + } + + public function testCompileFilterLessThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThan('col', 10)); + $this->assertSame('`col` < ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testCompileFilterLessThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::lessThanEqual('col', 10)); + $this->assertSame('`col` <= ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThan(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThan('col', 10)); + $this->assertSame('`col` > ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testCompileFilterGreaterThanEqual(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::greaterThanEqual('col', 10)); + $this->assertSame('`col` >= ?', $sql); + $this->assertSame([10], $builder->getBindings()); + } + + public function testCompileFilterBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::between('col', 1, 100)); + $this->assertSame('`col` BETWEEN ? AND ?', $sql); + $this->assertSame([1, 100], $builder->getBindings()); + } + + public function testCompileFilterNotBetween(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notBetween('col', 1, 100)); + $this->assertSame('`col` NOT BETWEEN ? AND ?', $sql); + $this->assertSame([1, 100], $builder->getBindings()); + } + + public function testCompileFilterStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::startsWith('col', 'abc')); + $this->assertSame('`col` LIKE ?', $sql); + $this->assertSame(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterNotStartsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notStartsWith('col', 'abc')); + $this->assertSame('`col` NOT LIKE ?', $sql); + $this->assertSame(['abc%'], $builder->getBindings()); + } + + public function testCompileFilterEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::endsWith('col', 'xyz')); + $this->assertSame('`col` LIKE ?', $sql); + $this->assertSame(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterNotEndsWith(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notEndsWith('col', 'xyz')); + $this->assertSame('`col` NOT LIKE ?', $sql); + $this->assertSame(['%xyz'], $builder->getBindings()); + } + + public function testCompileFilterContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsString('col', ['val'])); + $this->assertSame('`col` LIKE ?', $sql); + $this->assertSame(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsString('col', ['a', 'b'])); + $this->assertSame('(`col` LIKE ? OR `col` LIKE ?)', $sql); + $this->assertSame(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterContainsAny(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAny('col', ['a', 'b'])); + $this->assertSame('(`col` LIKE ? OR `col` LIKE ?)', $sql); + $this->assertSame(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterContainsAll(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::containsAll('col', ['a', 'b'])); + $this->assertSame('(`col` LIKE ? AND `col` LIKE ?)', $sql); + $this->assertSame(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsSingle(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['val'])); + $this->assertSame('`col` NOT LIKE ?', $sql); + $this->assertSame(['%val%'], $builder->getBindings()); + } + + public function testCompileFilterNotContainsMultiple(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notContains('col', ['a', 'b'])); + $this->assertSame('(`col` NOT LIKE ? AND `col` NOT LIKE ?)', $sql); + $this->assertSame(['%a%', '%b%'], $builder->getBindings()); + } + + public function testCompileFilterIsNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNull('col')); + $this->assertSame('`col` IS NULL', $sql); + $this->assertSame([], $builder->getBindings()); + } + + public function testCompileFilterIsNotNull(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::isNotNull('col')); + $this->assertSame('`col` IS NOT NULL', $sql); + $this->assertSame([], $builder->getBindings()); + } + + public function testCompileFilterAnd(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ])); + $this->assertSame('(`a` IN (?) AND `b` > ?)', $sql); + $this->assertSame([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->assertSame('(`a` IN (?) OR `b` IN (?))', $sql); + $this->assertSame([1, 2], $builder->getBindings()); + } + + public function testCompileFilterExists(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::exists(['a', 'b'])); + $this->assertSame('(`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->assertSame('(`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->assertSame('x > ? AND y < ?', $sql); + $this->assertSame([1, 2], $builder->getBindings()); + } + + public function testCompileFilterSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::search('body', 'hello')); + $this->assertSame('MATCH(`body`) AGAINST(? IN BOOLEAN MODE)', $sql); + $this->assertSame(['hello*'], $builder->getBindings()); + } + + public function testCompileFilterNotSearch(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notSearch('body', 'spam')); + $this->assertSame('NOT (MATCH(`body`) AGAINST(? IN BOOLEAN MODE))', $sql); + $this->assertSame(['spam*'], $builder->getBindings()); + } + + public function testCompileFilterRegex(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::regex('col', '^abc')); + $this->assertSame('`col` REGEXP ?', $sql); + $this->assertSame(['^abc'], $builder->getBindings()); + } + + public function testCompileOrderAsc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderAsc('name')); + $this->assertSame('`name` ASC', $sql); + } + + public function testCompileOrderDesc(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderDesc('name')); + $this->assertSame('`name` DESC', $sql); + } + + public function testCompileOrderRandom(): void + { + $builder = new Builder(); + $sql = $builder->compileOrder(Query::orderRandom()); + $this->assertSame('RAND()', $sql); + } + + public function testCompileLimitStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(25)); + $this->assertSame('LIMIT ?', $sql); + $this->assertSame([25], $builder->getBindings()); + } + + public function testCompileOffsetStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(50)); + $this->assertSame('OFFSET ?', $sql); + $this->assertSame([50], $builder->getBindings()); + } + + public function testCompileSelectStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileSelect(Query::select(['a', 'b', 'c'])); + $this->assertSame('`a`, `b`, `c`', $sql); + } + + public function testCompileCursorAfterStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorAfter('abc')); + $this->assertSame('`_cursor` > ?', $sql); + $this->assertSame(['abc'], $builder->getBindings()); + } + + public function testCompileCursorBeforeStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileCursor(Query::cursorBefore('xyz')); + $this->assertSame('`_cursor` < ?', $sql); + $this->assertSame(['xyz'], $builder->getBindings()); + } + + public function testCompileAggregateCountStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count('*', 'total')); + $this->assertSame('COUNT(*) AS `total`', $sql); + } + + public function testCompileAggregateCountWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::count()); + $this->assertSame('COUNT(*)', $sql); + } + + public function testCompileAggregateSumStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price', 'total')); + $this->assertSame('SUM(`price`) AS `total`', $sql); + } + + public function testCompileAggregateAvgStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score', 'avg_score')); + $this->assertSame('AVG(`score`) AS `avg_score`', $sql); + } + + public function testCompileAggregateMinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price', 'lowest')); + $this->assertSame('MIN(`price`) AS `lowest`', $sql); + } + + public function testCompileAggregateMaxStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price', 'highest')); + $this->assertSame('MAX(`price`) AS `highest`', $sql); + } + + public function testCompileGroupByStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileGroupBy(Query::groupBy(['status', 'country'])); + $this->assertSame('`status`, `country`', $sql); + } + + public function testCompileJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::join('orders', 'users.id', 'orders.uid')); + $this->assertSame('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->assertSame('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->assertSame('RIGHT JOIN `orders` ON `users`.`id` = `orders`.`uid`', $sql); + } + + public function testCompileCrossJoinStandalone(): void + { + $builder = new Builder(); + $sql = $builder->compileJoin(Query::crossJoin('colors')); + $this->assertSame('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->assertSame('SELECT * FROM `t` WHERE `status` IN (?)', $result->query); + $this->assertSame(['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->assertSame("SELECT * FROM `t` WHERE `id` IN ({$placeholders})", $result->query); + $this->assertSame($values, $result->bindings); + } + + public function testEqualWithEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 0', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotEqualWithExactlyTwoValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('role', ['guest', 'banned'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `role` NOT IN (?, ?)', $result->query); + $this->assertSame(['guest', 'banned'], $result->bindings); + } + + public function testBetweenWithSameMinAndMax(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 25, 25)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([25, 25], $result->bindings); + } + + public function testStartsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertSame(['%'], $result->bindings); + } + + public function testEndsWithEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertSame(['%'], $result->bindings); + } + + public function testContainsWithSingleEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', [''])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertSame(['%%'], $result->bindings); + } + + public function testContainsWithManyValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['a', 'b', 'c', 'd', 'e'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertSame(['%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->assertSame('SELECT * FROM `t` WHERE (`perms` LIKE ?)', $result->query); + $this->assertSame(['%read%'], $result->bindings); + } + + public function testNotContainsWithEmptyStringValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', [''])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `bio` NOT LIKE ?', $result->query); + $this->assertSame(['%%'], $result->bindings); + } + + public function testComparisonWithFloatValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('price', 9.99)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `price` > ?', $result->query); + $this->assertSame([9.99], $result->bindings); + } + + public function testComparisonWithNegativeValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `balance` < ?', $result->query); + $this->assertSame([-100], $result->bindings); + } + + public function testComparisonWithZero(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 0)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` >= ?', $result->query); + $this->assertSame([0], $result->bindings); + } + + public function testComparisonWithVeryLargeInteger(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('id', 9999999999999)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([9999999999999], $result->bindings); + } + + public function testComparisonWithStringValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('name', 'M')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` > ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE `created_at` BETWEEN ? AND ?', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `verified_at` IS NOT NULL', + $result->query + ); + $this->assertSame([], $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->assertSame( + '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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testOrWithSingleSubQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('a', [1]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`a` IN (?))', $result->query); + $this->assertSame([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->assertSame( + 'SELECT * FROM `t` WHERE (`a` IN (?) AND `b` IN (?) AND `c` IN (?) AND `d` IN (?) AND `e` IN (?))', + $result->query + ); + $this->assertSame([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->assertSame( + '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->assertSame( + 'SELECT * FROM `t` WHERE (((`a` IN (?) AND `b` IN (?)) OR `c` IN (?)) AND `d` IN (?))', + $result->query + ); + $this->assertSame([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->assertSame("SELECT * FROM `t` WHERE {$placeholders}", $result->query); + $this->assertSame($bindings, $result->bindings); + } + + public function testFilterWithDotsInAttributeName(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('table.column', ['value'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT COUNT(*) AS `cnt` FROM `t`', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder())->from('t')->sum('price', 'total')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT SUM(`price`) AS `total` FROM `t`', $result->query); + } + + public function testAvgWithAlias(): void + { + $result = (new Builder())->from('t')->avg('score', 'avg_s')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT AVG(`score`) AS `avg_s` FROM `t`', $result->query); + } + + public function testMinWithAlias(): void + { + $result = (new Builder())->from('t')->min('price', 'lowest')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT MIN(`price`) AS `lowest` FROM `t`', $result->query); + } + + public function testMaxWithAlias(): void + { + $result = (new Builder())->from('t')->max('price', 'highest')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT MAX(`price`) AS `highest` FROM `t`', $result->query); + } + + public function testMultipleSameAggregationType(): void + { + $result = (new Builder()) + ->from('t') + ->count('id', 'count_id') + ->count('*', 'count_all') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame('SELECT COUNT(*) AS `total`, SUM(`price`) AS `price_sum`, `category` FROM `t`', $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->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` WHERE `status` IN (?) GROUP BY `category` ORDER BY `cnt` DESC LIMIT ?', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`total`) AS `revenue`, `users`.`name` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` WHERE `orders`.`total` > ? GROUP BY `users`.`name` HAVING COUNT(*) > ? ORDER BY `revenue` DESC LIMIT ? OFFSET ?', $result->query); + $this->assertSame([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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE `orders`.`status` IN (?) AND `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ? OFFSET ?', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` GROUP BY `users`.`name` HAVING COUNT(*) > ?', $result->query); + $this->assertSame([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->assertSame('SELECT DISTINCT `users`.`name` FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $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->assertSame('(SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id`) UNION (SELECT * FROM `archived_users` JOIN `archived_orders` ON `archived_users`.`id` = `archived_orders`.`user_id`)', $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->assertSame('SELECT * FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` LEFT JOIN `products` ON `orders`.`product_id` = `products`.`id` RIGHT JOIN `categories` ON `products`.`cat_id` = `categories`.`id` 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->assertSame( + '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->assertSame('SELECT * FROM `sizes` CROSS JOIN `colors` 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->assertSame( + '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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` LEFT JOIN `profiles` ON `users`.`id` = `profiles`.`user_id` WHERE `orders`.`total` > ? AND `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->assertSame( + '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->assertSame(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->assertSame( + '(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->assertSame( + '(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->assertSame( + '(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->assertSame('(SELECT * FROM `users`) UNION (SELECT * FROM `archived_users` JOIN `archived_orders` ON `archived_users`.`id` = `archived_orders`.`user_id`)', $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->assertSame('(SELECT COUNT(*) AS `cnt` FROM `orders_2024` GROUP BY `status`) 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->assertSame('(SELECT * FROM `current`) 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->assertSame('(SELECT * FROM `main` WHERE org = ?) UNION (SELECT * FROM `other` WHERE org = ?)', $result->query); + $this->assertSame(['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->assertSame(['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->assertSame('(SELECT DISTINCT `name` FROM `current`) 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->assertSame( + '(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->assertSame(['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->assertSame(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->assertSame(['paid', 'paid', 'paid', 'paid'], $result->bindings); + $this->assertSame(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->assertSame('SELECT * FROM `t` WHERE `name` IN (\'Alice\') AND `age` > 18 AND active = 1 AND deleted = NULL AND score > 9.5 LIMIT 10', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithEmptyStringBinding(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', [''])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `name` IN (\'\')', $sql); + } + + public function testToRawSqlWithStringContainingSingleQuotes(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `name` IN (\'O\'\'Brien\')', $sql); + } + + public function testToRawSqlWithVeryLargeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('id', 99999999999)]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `id` > 99999999999', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithNegativeNumber(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::lessThan('balance', -500)]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `balance` < -500', $sql); + } + + public function testToRawSqlWithZero(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::equal('count', [0])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `count` IN (0)', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithFalseBoolean(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('active = ?', [false])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE active = 0', $sql); + } + + public function testToRawSqlWithMultipleNullBindings(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('a = ? AND b = ?', [null, null])]) + ->toRawSql(); + + $this->assertSame("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->assertSame('SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status` HAVING COUNT(*) > 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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`uid` WHERE `orders`.`total` > 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->assertSame('(SELECT * FROM `current` WHERE `year` IN (2024)) UNION (SELECT * FROM `archive` WHERE `year` IN (2023))', $sql); + $this->assertStringNotContainsString('?', $sql); + } + + public function testToRawSqlWithRegexAndSearch(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([ + Query::regex('slug', '^test'), + Query::search('content', 'hello'), + ]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `slug` REGEXP \'^test\' AND MATCH(`content`) AGAINST(\'hello*\' IN BOOLEAN MODE)', $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->assertSame($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->assertSame('SELECT * FROM `t` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?) AND `d` IN (?) AND `e` IN (?)', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE `deep` IN (?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`uid`', $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->assertSame('SELECT COUNT(*) AS `total` FROM `t` 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->assertSame('(SELECT * FROM `current`) UNION (SELECT * FROM `archive`)', $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->assertSame('SELECT * FROM `t`', $result->query); + $this->assertSame([], $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->assertSame('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->assertSame( + 'SELECT * FROM `t` WHERE p1 = ? AND p2 = ? AND p3 = ?', + $result->query + ); + $this->assertSame(['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->assertSame('SELECT * FROM `t` 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->assertSame( + 'SELECT * FROM `t` WHERE a IN (?, ?, ?, ?, ?)', + $result->query + ); + $this->assertSame([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->assertSame('SELECT COUNT(*) AS `cnt` FROM `t` WHERE `status` IN (?) AND org = ? AND `_cursor` > ? GROUP BY `status` HAVING COUNT(*) > ?', $result->query); + // filter, provider, cursor, having + $this->assertSame(['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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`uid` WHERE tenant = ?', $result->query); + $this->assertSame(['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->assertSame('(SELECT * FROM `current` WHERE org = ?) UNION (SELECT * FROM `archive`)', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `total` FROM `orders` WHERE org = ? GROUP BY `status`', $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->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT 1 FROM users_perms WHERE type = ?)', $result->query); + $this->assertSame(['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->assertSame(['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->assertSame('SELECT * FROM `t2` WHERE org = ?', $result->query); + $this->assertSame(['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->assertSame( + 'SELECT * FROM `t` WHERE a = ? AND b = ? AND c = ? AND d = ?', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `t2` WHERE `_y` IN (?)', $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->assertSame('SELECT * FROM `t2` WHERE org = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t2`', $result->query); + $this->assertSame([], $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->assertSame([], $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->assertSame('SELECT * FROM `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->assertSame('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->assertSame('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->assertSame('SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', $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->assertSame('SELECT `name` FROM `users` WHERE `active` IN (?)', $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->assertSame('SELECT * FROM `new`', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `simple`', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `t` WHERE `a` IN (?) AND `b` IN (?)', $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->assertSame($result1->query, $result2->query); + $this->assertSame($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->assertSame($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->assertSame($result1->bindings, $result2->bindings); + $this->assertSame($result2->bindings, $result3->bindings); + } + + public function testBuildAfterAddingMoreQueries(): void + { + $builder = (new Builder())->from('t'); + + $result1 = $builder->build(); + $this->assertSame('SELECT * FROM `t`', $result1->query); + + $builder->filter([Query::equal('a', [1])]); + $result2 = $builder->build(); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?)', $result2->query); + + $builder->sortAsc('a'); + $result3 = $builder->build(); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?) ORDER BY `a` ASC', $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->assertSame($result1->query, $result2->query); + $this->assertSame($result1->bindings, $result2->bindings); + } + + public function testBuildThreeTimesWithIncreasingComplexity(): void + { + $builder = (new Builder())->from('t'); + + $r1 = $builder->build(); + $this->assertSame('SELECT * FROM `t`', $r1->query); + + $builder->filter([Query::equal('a', [1])]); + $r2 = $builder->build(); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?)', $r2->query); + + $builder->limit(10)->offset(5); + $r3 = $builder->build(); + $this->assertSame('SELECT * FROM `t` WHERE `a` IN (?) LIMIT ? 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->assertSame([5], $r1->bindings); + $this->assertSame([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->assertSame(['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->assertSame(['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->assertSame([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->assertSame([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->assertSame([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->assertSame([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->assertSame(['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->assertSame(['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->assertSame(['paid', 0, 't1', 'cursor_val', 1, 25, 50, true], $result->bindings); + } + + public function testBindingOrderContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::containsString('bio', ['php', 'js', 'go']), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + // contains produces three LIKE bindings, then equal + $this->assertSame(['%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->assertSame([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->assertSame(['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->assertSame(['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->assertSame(['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())->build(); + } + + public function testBuildWithOnlyLimit(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->limit(10) + ->build(); + } + + public function testBuildWithOnlyOffset(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->offset(50) + ->build(); + } + + public function testBuildWithOnlySort(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->sortAsc('name') + ->build(); + } + + public function testBuildWithOnlySelect(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->select(['a', 'b']) + ->build(); + } + + public function testBuildWithOnlyAggregationNoFrom(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder()) + ->count('*', 'total') + ->build(); + } + + public function testBuildWithEmptyFilterArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t`', $result->query); + } + + public function testBuildWithEmptySelectArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT COUNT(*) AS `cnt` FROM `t` HAVING COUNT(*) > ?', $result->query); + $this->assertStringNotContainsString('GROUP BY', $result->query); + } + + public function testBuildWithOnlyDistinct(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM `t` WHERE ST_Crosses(`attr`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testSpatialDistanceLessThan(): void + { + $result = (new Builder())->from('t')->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE ST_Distance(ST_SRID(`attr`, 4326), ST_GeomFromText(?, 4326, \'axis-order=long-lat\'), \'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->assertSame('SELECT * FROM `t` WHERE ST_Intersects(`attr`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testSpatialOverlaps(): void + { + $result = (new Builder())->from('t')->filter([Query::overlaps('attr', [[0, 0], [1, 1]])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE ST_Overlaps(`attr`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testSpatialTouches(): void + { + $result = (new Builder())->from('t')->filter([Query::touches('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE ST_Touches(`attr`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testSpatialNotIntersects(): void + { + $result = (new Builder())->from('t')->filter([Query::notIntersects('attr', [1.0, 2.0])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE NOT ST_Intersects(`attr`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $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->assertSame("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->assertSame('SELECT * FROM `t` WHERE `name` IN (\'str\') AND `age` > 42 AND `score` < 9.99 AND `active` IN (1)', $sql); + } + + public function testToRawSqlWithNull(): void + { + $sql = (new Builder())->from('t') + ->filter([Query::raw('col = ?', [null])]) + ->toRawSql(); + $this->assertSame('SELECT * FROM `t` WHERE col = 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->assertSame('(SELECT * FROM `a` WHERE `y` IN (2)) UNION (SELECT * FROM `b` WHERE `x` IN (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->assertSame('SELECT COUNT(*) AS `total`, `users`.`country` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` GROUP BY `users`.`country` HAVING COUNT(*) > 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->assertSame( + '(SELECT DISTINCT COUNT(*) AS `total`, `status` FROM `orders` JOIN `users` ON `orders`.`uid` = `users`.`id` WHERE `amount` > ? GROUP BY `status` HAVING COUNT(*) > ? ORDER BY `status` ASC LIMIT ? OFFSET ?) UNION (SELECT * FROM `archive` WHERE `status` IN (?))', + $result->query + ); + $this->assertSame([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->assertSame('(SELECT DISTINCT * FROM `a`) UNION (SELECT * FROM `b`)', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `t` WHERE (`x` > ? AND custom_func(y) > ?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE (`a` IN (?) OR b IS NOT NULL)', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testAggregationWithCursor(): void + { + $result = (new Builder())->from('t') + ->count('*', 'total') + ->cursorAfter('abc') + ->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT COUNT(*) AS `total` FROM `t` WHERE `_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->assertSame('(SELECT COUNT(*) AS `total` FROM `a` WHERE `_cursor` > ? GROUP BY `status` ORDER BY `total` DESC) UNION (SELECT * FROM `b`)', $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->assertSame('SELECT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE _tenant = ? AND `_cursor` > ?', $result->query); + // Provider bindings come before cursor bindings + $this->assertSame(['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->assertSame('SELECT DISTINCT * FROM `t` WHERE _tenant = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `other` WHERE _tenant = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `total` FROM `t` WHERE _tenant = ? GROUP BY `status` HAVING COUNT(*) > ?', $result->query); + // Provider bindings before having bindings + $this->assertSame(['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->assertSame('(SELECT * FROM `a`) UNION (SELECT * FROM `b` WHERE _deleted = ?)', $result->query); + $this->assertSame([0], $result->bindings); + } + // Boundary Value Tests + + public function testNegativeLimit(): void + { + $result = (new Builder())->from('t')->limit(-1)->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([-1], $result->bindings); + } + + public function testNegativeOffset(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->offset(-5)->build(); + } + + + public function testEqualWithNullOnly(): void + { + $result = (new Builder())->from('t')->filter([Query::equal('col', [null])])->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([65, 18], $result->bindings); + } + + public function testContainsWithSqlWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::containsString('bio', ['100%'])])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertSame(['%100\%%'], $result->bindings); + } + + public function testStartsWithWithWildcard(): void + { + $result = (new Builder())->from('t')->filter([Query::startsWith('name', '%admin')])->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `name` LIKE ?', $result->query); + $this->assertSame(['\%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->assertSame([], $result->bindings); + } + + public function testCursorWithIntegerValue(): void + { + $result = (new Builder())->from('t')->cursorAfter(42)->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `_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->assertSame('SELECT * FROM `t` WHERE `_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->assertSame('SELECT * FROM `t` LIMIT ?', $result->query); + $this->assertSame([10], $result->bindings); + } + + public function testMultipleOffsetsFirstWins(): void + { + $this->expectException(ValidationException::class); + (new Builder())->from('t')->offset(5)->offset(50)->build(); + } + + + public function testCursorAfterAndBeforeFirstWins(): void + { + $result = (new Builder())->from('t')->cursorAfter('a')->cursorBefore('b')->build(); + $this->assertBindingCount($result); + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` > ?', $result->query); + $this->assertStringNotContainsString('`_cursor` < ?', $result->query); + } + + public function testEmptyTableWithJoin(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No table specified'); + (new Builder())->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->assertSame('', $result); + } + + public function testCompileGroupByEmpty(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy([])); + $this->assertSame('', $result); + } + + public function testCompileGroupBySingleColumn(): void + { + $builder = new Builder(); + $result = $builder->compileGroupBy(Query::groupBy(['status'])); + $this->assertSame('`status`', $result); + } + + public function testCompileSumWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::sum('price')); + $this->assertSame('SUM(`price`)', $sql); + } + + public function testCompileAvgWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::avg('score')); + $this->assertSame('AVG(`score`)', $sql); + } + + public function testCompileMinWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::min('price')); + $this->assertSame('MIN(`price`)', $sql); + } + + public function testCompileMaxWithoutAlias(): void + { + $builder = new Builder(); + $sql = $builder->compileAggregate(Query::max('price')); + $this->assertSame('MAX(`price`)', $sql); + } + + public function testCompileLimitZero(): void + { + $builder = new Builder(); + $sql = $builder->compileLimit(Query::limit(0)); + $this->assertSame('LIMIT ?', $sql); + $this->assertSame([0], $builder->getBindings()); + } + + public function testCompileOffsetZero(): void + { + $builder = new Builder(); + $sql = $builder->compileOffset(Query::offset(0)); + $this->assertSame('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->assertSame('`name` ASC', Query::orderAsc('name')->compile($builder)); + } + + public function testQueryCompileOrderDesc(): void + { + $builder = new Builder(); + $this->assertSame('`name` DESC', Query::orderDesc('name')->compile($builder)); + } + + public function testQueryCompileOrderRandom(): void + { + $builder = new Builder(); + $this->assertSame('RAND()', Query::orderRandom()->compile($builder)); + } + + public function testQueryCompileLimit(): void + { + $builder = new Builder(); + $this->assertSame('LIMIT ?', Query::limit(10)->compile($builder)); + $this->assertSame([10], $builder->getBindings()); + } + + public function testQueryCompileOffset(): void + { + $builder = new Builder(); + $this->assertSame('OFFSET ?', Query::offset(5)->compile($builder)); + $this->assertSame([5], $builder->getBindings()); + } + + public function testQueryCompileCursorAfter(): void + { + $builder = new Builder(); + $this->assertSame('`_cursor` > ?', Query::cursorAfter('x')->compile($builder)); + $this->assertSame(['x'], $builder->getBindings()); + } + + public function testQueryCompileCursorBefore(): void + { + $builder = new Builder(); + $this->assertSame('`_cursor` < ?', Query::cursorBefore('x')->compile($builder)); + $this->assertSame(['x'], $builder->getBindings()); + } + + public function testQueryCompileSelect(): void + { + $builder = new Builder(); + $this->assertSame('`a`, `b`', Query::select(['a', 'b'])->compile($builder)); + } + + public function testQueryCompileGroupBy(): void + { + $builder = new Builder(); + $this->assertSame('`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->assertSame('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->assertSame([], $result->bindings); + } + // Missing Binding Assertions + + public function testSortAscBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortAsc('name')->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testSortDescBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortDesc('name')->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testSortRandomBindingsEmpty(): void + { + $result = (new Builder())->from('t')->sortRandom()->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testDistinctBindingsEmpty(): void + { + $result = (new Builder())->from('t')->distinct()->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->join('other', 'a', 'b')->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testCrossJoinBindingsEmpty(): void + { + $result = (new Builder())->from('t')->crossJoin('other')->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testGroupByBindingsEmpty(): void + { + $result = (new Builder())->from('t')->groupBy(['status'])->build(); + $this->assertBindingCount($result); + $this->assertSame([], $result->bindings); + } + + public function testCountWithAliasBindingsEmpty(): void + { + $result = (new Builder())->from('t')->count('*', 'total')->build(); + $this->assertBindingCount($result); + $this->assertSame([], $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->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame('INSERT INTO `users` (`name`) VALUES (?)', $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->assertSame( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', + $result->query + ); + $this->assertSame([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->assertSame( + 'INSERT INTO `user_roles` (`user_id`, `role_id`, `granted_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `granted_at` = VALUES(`granted_at`)', + $result->query + ); + $this->assertSame([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->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'UPDATE `users` SET `name` = ?, `login_count` = login_count + 1 WHERE `id` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `id` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertSame(['active', 1, 'tenant_123'], $result->bindings); + } + + public function testUpdateWithoutWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'active']) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `status` = ?', $result->query); + $this->assertSame(['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->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `active` IN (?) ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'DELETE FROM `users` WHERE `last_login` < ?', + $result->query + ); + $this->assertSame(['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->assertSame( + 'DELETE FROM `users` WHERE `status` IN (?) AND `_tenant` = ?', + $result->query + ); + $this->assertSame(['deleted', 'tenant_123'], $result->bindings); + } + + public function testDeleteWithoutWhere(): void + { + $result = (new Builder()) + ->from('users') + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `users`', $result->query); + $this->assertSame([], $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->assertSame( + 'DELETE FROM `logs` WHERE `created_at` < ? ORDER BY `created_at` ASC LIMIT ?', + $result->query + ); + $this->assertSame(['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->assertSame( + '(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->assertSame( + '(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->assertSame( + '(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->assertSame( + '(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->assertSame( + '(SELECT * FROM `users` WHERE `status` IN (?)) INTERSECT (SELECT * FROM `admins` WHERE `role` IN (?))', + $result->query + ); + $this->assertSame(['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->assertSame(['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->assertSame('(SELECT * FROM `main`) UNION (SELECT * FROM `a`) INTERSECT (SELECT * FROM `b`) EXCEPT (SELECT * FROM `c`)', $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->assertSame( + 'SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE', + $result->query + ); + $this->assertSame([1], $result->bindings); + } + + public function testForShare(): void + { + $result = (new Builder()) + ->from('accounts') + ->filter([Query::equal('id', [1])]) + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame( + 'SELECT * FROM `accounts` LIMIT ? OFFSET ? FOR UPDATE', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `t`', $result->query); + } + // Transaction Statements + + public function testBegin(): void + { + $result = (new Builder())->begin(); + $this->assertSame('BEGIN', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCommit(): void + { + $result = (new Builder())->commit(); + $this->assertSame('COMMIT', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testRollback(): void + { + $result = (new Builder())->rollback(); + $this->assertSame('ROLLBACK', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testSavepoint(): void + { + $result = (new Builder())->savepoint('sp1'); + $this->assertSame('SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testReleaseSavepoint(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertSame('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testRollbackToSavepoint(): void + { + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertSame('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $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->assertSame( + 'INSERT INTO `archive` (`name`, `email`) SELECT `name`, `email` FROM `users` WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['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->assertSame('INSERT INTO `customer_stats` (`customer_id`, `order_count`) SELECT COUNT(*) AS `order_count`, `customer_id` FROM `orders` GROUP BY `customer_id`', $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->assertSame( + 'WITH `paid_orders` AS (SELECT * FROM `orders` WHERE `status` IN (?)) SELECT `customer_id` FROM `paid_orders`', + $result->query + ); + $this->assertSame(['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->assertSame( + '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->assertSame('WITH `paid` AS (SELECT * FROM `orders` WHERE `status` IN (?)), `approved_returns` AS (SELECT * FROM `returns` WHERE `status` IN (?)) SELECT * FROM `paid`', $result->query); + $this->assertSame(['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->assertSame([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->assertSame('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->assertSame('WITH RECURSIVE `prods` AS (SELECT * FROM `products`), `tree` AS (SELECT * FROM `categories`) SELECT * FROM `tree`', $result->query); + } + // CASE/WHEN + selectRaw() + + public function testCaseBuilder(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('label'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `t`', $result->query); + $this->assertSame(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + } + + public function testCaseBuilderWithoutElse(): void + { + $case = (new CaseExpression()) + ->when('x', Operator::GreaterThan, 10, 1); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN `x` > ? THEN ? END FROM `t`', $result->query); + $this->assertSame([10, 1], $result->bindings); + } + + public function testCaseBuilderWithoutAlias(): void + { + $case = (new CaseExpression()) + ->whenRaw('x = 1', 'yes') + ->else('no'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN x = 1 THEN ? ELSE ? END FROM `t`', $result->query); + $this->assertStringNotContainsString('END AS', $result->query); + $this->assertSame(['yes', 'no'], $result->bindings); + } + + public function testCaseBuilderNoWhensThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least one WHEN'); + + (new Builder()) + ->from('t') + ->selectCase(new CaseExpression()) + ->build(); + } + + public function testCaseExpressionToSql(): void + { + $case = (new CaseExpression()) + ->whenRaw('a = ?', 1, [1]); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN a = ? THEN ? END FROM `t`', $result->query); + $this->assertSame([1, 1], $result->bindings); + } + + public function testSelectRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->select('SUM(amount) AS total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(amount) AS total FROM `orders`', $result->query); + } + + public function testSelectRawWithBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->select('IF(amount > ?, 1, 0) AS big_order', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT IF(amount > ?, 1, 0) AS big_order FROM `orders`', $result->query); + $this->assertSame([1000], $result->bindings); + } + + public function testSelectRawCombinedWithSelect(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id']) + ->select('SUM(amount) AS total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `id`, `customer_id`, SUM(amount) AS total FROM `orders`', $result->query); + } + + public function testSelectRawWithCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->else('Other') + ->alias('label'); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `id`, CASE WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `users`', $result->query); + $this->assertSame(['active', 'Active', 'Other'], $result->bindings); + } + + public function testSelectRawResetClears(): void + { + $builder = (new Builder())->from('t')->select('1 AS one'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame( + 'UPDATE `accounts` SET `name` = ?, `balance` = balance + ? WHERE `id` IN (?)', + $result->query + ); + $this->assertSame(['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') + ->select('COUNT(*) AS cnt') + ->select('MAX(price) AS max_price') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('(SELECT * FROM `a` FOR UPDATE) UNION (SELECT * FROM `b`)', $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->assertSame('WITH `o` AS (SELECT * FROM `orders`) (SELECT * FROM `o`) UNION (SELECT * FROM `archive_orders`)', $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->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`coords`, 4326), ST_GeomFromText(?, 4326, \'axis-order=long-lat\'), \'metre\') < ?', $result->query); + $this->assertSame('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`coords`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) > ?', $result->query); + } + + public function testFilterIntersectsPoint(): void + { + $result = (new Builder()) + ->from('zones') + ->filterIntersects('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE ST_Intersects(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + $this->assertSame('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->assertSame('SELECT * FROM `zones` WHERE NOT ST_Intersects(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE ST_Contains(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterSpatialEquals(): void + { + $result = (new Builder()) + ->from('zones') + ->filterSpatialEquals('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE ST_Equals(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testSpatialWithLinestring(): void + { + $result = (new Builder()) + ->from('roads') + ->filterIntersects('path', [[0, 0], [1, 1], [2, 2]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))', $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->assertSame('SELECT * FROM `docs` WHERE JSON_CONTAINS(`tags`, ?)', $result->query); + $this->assertSame('"php"', $result->bindings[0]); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', 'old') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE NOT JSON_CONTAINS(`tags`, ?)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_OVERLAPS(`tags`, ?)', $result->query); + $this->assertSame('["php","go"]', $result->bindings[0]); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE JSON_EXTRACT(`metadata`, \'$.level\') > ?', $result->query); + $this->assertSame(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->assertSame('UPDATE `docs` SET `tags` = JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `docs` SET `tags` = JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY())) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = JSON_ARRAY_INSERT(`tags`, ?, ?) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = JSON_REMOVE(`tags`, JSON_UNQUOTE(JSON_SEARCH(`tags`, \'one\', ?))) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPath(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPath('data', '$.name', 'NewValue') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `docs` SET `data` = JSON_SET(`data`, ?, ?) WHERE `id` IN (?)', + $result->query + ); + $this->assertSame('$.name', $result->bindings[0]); + $this->assertSame('NewValue', $result->bindings[1]); + $this->assertSame(1, $result->bindings[2]); + } + + public function testSetJsonPathRejectsInvalidPath(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('docs') + ->setJsonPath('data', 'name', 'NewValue'); + } + // 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->assertSame('SELECT /*+ NO_INDEX_MERGE(users) */ * FROM `users`', $result->query); + } + + public function testMaxExecutionTime(): void + { + $result = (new Builder()) + ->from('users') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM `users`', $result->query); + } + + public function testMultipleHints(): void + { + $result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE(users)') + ->hint('BKA(users)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ NO_INDEX_MERGE(users) BKA(users) */ * FROM `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->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY `customer_id` ORDER BY `created_at` ASC) AS `rn` FROM `orders`', $result->query); + } + + public function testSelectWindowRank(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT RANK() OVER (ORDER BY `score` DESC) AS `rank` FROM `scores`', $result->query); + } + + public function testSelectWindowPartitionOnly(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('SUM(amount)', 'total', ['dept']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(amount) OVER (PARTITION BY `dept`) AS `total` FROM `orders`', $result->query); + } + + public function testSelectWindowNoPartitionNoOrder(): void + { + $result = (new Builder()) + ->from('orders') + ->selectWindow('COUNT(*)', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) OVER () AS `total` FROM `orders`', $result->query); + } + // CASE integration + + public function testSelectCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->else('Other') + ->alias('label'); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `id`, CASE WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `users`', $result->query); + $this->assertSame(['active', 'Active', 'Other'], $result->bindings); + } + + public function testSetCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('age', Operator::GreaterThanEqual, 18, 'adult') + ->else('minor'); + + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::greaterThan('id', 0)]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `category` = CASE WHEN `age` >= ? THEN ? ELSE ? END WHERE `id` > ?', $result->query); + $this->assertSame([18, 'adult', 'minor', 0], $result->bindings); + } + // Query factory methods for JSON + + public function testQueryJsonContainsFactory(): void + { + $q = Query::jsonContains('tags', 'php'); + $this->assertSame(Method::JsonContains, $q->getMethod()); + $this->assertSame('tags', $q->getAttribute()); + } + + public function testQueryJsonOverlapsFactory(): void + { + $q = Query::jsonOverlaps('tags', ['php', 'go']); + $this->assertSame(Method::JsonOverlaps, $q->getMethod()); + } + + public function testQueryJsonPathFactory(): void + { + $q = Query::jsonPath('meta', 'level', '>', 5); + $this->assertSame(Method::JsonPath, $q->getMethod()); + $this->assertSame(['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->assertSame('SELECT * FROM `zones` WHERE NOT ST_Intersects(`zone`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + $this->assertSame('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->assertSame('SELECT * FROM `roads` WHERE NOT ST_Crosses(`path`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('LINESTRING(0 0, 1 1)', $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->assertSame('SELECT * FROM `regions` WHERE ST_Overlaps(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('POLYGON((0 0, 1 0, 1 1, 0 0))', $binding); + } + + public function testFilterNotOverlaps(): void + { + $result = (new Builder()) + ->from('regions') + ->filterNotOverlaps('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `regions` WHERE NOT ST_Overlaps(`area`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE ST_Touches(`zone`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE NOT ST_Touches(`zone`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotCovers(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotCovers('region', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE NOT ST_Contains(`region`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotSpatialEquals(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `zones` WHERE NOT ST_Equals(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterDistanceGreaterThan(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`loc`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) > ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`loc`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) = ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`loc`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) != ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`loc`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) < ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('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->assertSame('SELECT * FROM `places` WHERE ST_Equals(`pos`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + $this->assertSame('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->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE JSON_CONTAINS(?, val)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt WHERE NOT JSON_CONTAINS(?, val)) WHERE `id` IN (?)', $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->assertSame('UPDATE `t` SET `tags` = (SELECT JSON_ARRAYAGG(val) FROM (SELECT DISTINCT val FROM JSON_TABLE(`tags`, \'$[*]\' COLUMNS(val JSON PATH \'$\')) AS jt) AS dt) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPrependMergeOrder(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `items` = JSON_MERGE_PRESERVE(?, IFNULL(`items`, JSON_ARRAY())) WHERE `id` IN (?)', $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->assertSame('UPDATE `t` SET `items` = JSON_ARRAY_INSERT(`items`, ?, ?) WHERE `id` IN (?)', $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->assertSame('SELECT * FROM `docs` WHERE NOT JSON_CONTAINS(`meta`, ?)', $result->query); + } + + public function testFilterJsonOverlapsCompiles(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_OVERLAPS(`tags`, ?)', $result->query); + } + + public function testFilterJsonPathCompiles(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE JSON_EXTRACT(`data`, \'$.age\') >= ?', $result->query); + $this->assertSame(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->assertSame('SELECT /*+ NO_ICP(t) BKA(t) */ * FROM `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->assertSame('SELECT DISTINCT /*+ SET_VAR(sort_buffer_size=16M) */ * FROM `t`', $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->assertSame(['active'], $result->bindings); + } + + public function testMaxExecutionTimeValue(): void + { + $result = (new Builder()) + ->from('t') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM `t`', $result->query); + } + + public function testSelectWindowWithPartitionOnly(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('SUM(amount)', 'total', ['dept']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(amount) OVER (PARTITION BY `dept`) AS `total` FROM `t`', $result->query); + } + + public function testSelectWindowWithOrderOnly(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()', 'rn', null, ['created_at']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT ROW_NUMBER() OVER (ORDER BY `created_at` ASC) AS `rn` FROM `t`', $result->query); + } + + public function testSelectWindowNoPartitionNoOrderEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('COUNT(*)', 'cnt') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) OVER () AS `cnt` FROM `t`', $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->assertSame('SELECT ROW_NUMBER() OVER (ORDER BY `id` ASC) AS `rn`, SUM(amount) OVER (ORDER BY `id` ASC) AS `running_total` FROM `t`', $result->query); + } + + public function testSelectWindowWithDescOrder(): void + { + $result = (new Builder()) + ->from('t') + ->selectWindow('RANK()', 'r', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT RANK() OVER (ORDER BY `score` DESC) AS `r` FROM `t`', $result->query); + } + + public function testCaseWithMultipleWhens(): void + { + $case = (new CaseExpression()) + ->when('x', Operator::Equal, 1, 'one') + ->when('x', Operator::Equal, 2, 'two') + ->when('x', Operator::Equal, 3, 'three'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN `x` = ? THEN ? WHEN `x` = ? THEN ? WHEN `x` = ? THEN ? END FROM `t`', $result->query); + $this->assertSame([1, 'one', 2, 'two', 3, 'three'], $result->bindings); + } + + public function testCaseExpressionWithoutElseClause(): void + { + $case = (new CaseExpression()) + ->when('x', Operator::GreaterThan, 10, 1) + ->when('x', Operator::LessThan, 0, 0); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertStringNotContainsString('ELSE', $result->query); + } + + public function testCaseExpressionWithoutAliasClause(): void + { + $case = (new CaseExpression()) + ->whenRaw('x = 1', 'yes'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertStringNotContainsString('END AS', $result->query); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseExpression()) + ->when('age', Operator::GreaterThanEqual, 18, 'adult') + ->else('minor'); + + $result = (new Builder()) + ->from('users') + ->setCase('status', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `status` = CASE WHEN `age` >= ? THEN ? ELSE ? END WHERE `id` IN (?)', $result->query); + } + + public function testCaseBuilderThrowsWhenNoWhensAdded(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->selectCase(new CaseExpression()) + ->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->assertSame('WITH `a` AS (SELECT * FROM `orders`), `b` AS (SELECT * FROM `returns`) SELECT * FROM `a`', $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->assertSame('paid', $result->bindings[0]); + $this->assertSame(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->assertSame('WITH RECURSIVE `prods` AS (SELECT * FROM `products`), `tree` AS (SELECT * FROM `categories`) SELECT * FROM `tree`', $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->assertSame('INSERT INTO `archive` (`name`, `email`) SELECT `name`, `email` FROM `users` WHERE `status` IN (?)', $result->query); + $this->assertSame(['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->assertSame('INSERT INTO `archive` (`name`, `email`, `age`) SELECT `name`, `email`, `age` FROM `users`', $result->query); + } + + public function testUnionAllCompiles(): void + { + $other = (new Builder())->from('archive'); + $result = (new Builder()) + ->from('current') + ->unionAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `current`) UNION ALL (SELECT * FROM `archive`)', $result->query); + } + + public function testIntersectCompiles(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `users`) INTERSECT (SELECT * FROM `admins`)', $result->query); + } + + public function testIntersectAllCompiles(): void + { + $other = (new Builder())->from('admins'); + $result = (new Builder()) + ->from('users') + ->intersectAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `users`) INTERSECT ALL (SELECT * FROM `admins`)', $result->query); + } + + public function testExceptCompiles(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `users`) EXCEPT (SELECT * FROM `banned`)', $result->query); + } + + public function testExceptAllCompiles(): void + { + $other = (new Builder())->from('banned'); + $result = (new Builder()) + ->from('users') + ->exceptAll($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `users`) EXCEPT ALL (SELECT * FROM `banned`)', $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->assertSame(['active', 'admin'], $result->bindings); + } + + public function testPageThreeWithTen(): void + { + $result = (new Builder()) + ->from('t') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([10, 20], $result->bindings); + } + + public function testPageFirstPage(): void + { + $result = (new Builder()) + ->from('t') + ->page(1, 25) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([25, 0], $result->bindings); + } + + public function testCursorAfterWithSort(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('id') + ->cursorAfter(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `_cursor` > ? ORDER BY `id` ASC LIMIT ?', $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->assertSame('SELECT * FROM `t` WHERE `_cursor` < ? ORDER BY `id` ASC LIMIT ?', $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->assertSame('SELECT * FROM `t` WHERE `name` IN (\'Alice\')', $sql); + } + + public function testToRawSqlWithIntegers(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 30)]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE `age` > 30', $sql); + $this->assertStringNotContainsString("'30'", $sql); + } + + public function testToRawSqlWithNullValue(): void + { + $sql = (new Builder()) + ->from('t') + ->filter([Query::raw('deleted_at = ?', [null])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM `t` WHERE deleted_at = 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->assertSame('SELECT * FROM `t` WHERE active = 1', $sqlTrue); + $this->assertSame('SELECT * FROM `t` WHERE active = 0', $sqlFalse); + } + + public function testWhenTrueAppliesLimit(): void + { + $result = (new Builder()) + ->from('t') + ->when(true, fn (Builder $b) => $b->limit(5)) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` 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->assertSame('INSERT INTO `t` (`a`, `b`) VALUES (?, ?), (?, ?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE NOT (MATCH(`body`) AGAINST(? IN BOOLEAN MODE))', $result->query); + } + + public function testRegexpCompiles(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `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->assertSame('INSERT INTO `t` (`id`, `name`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)', $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->assertSame('SELECT * FROM `accounts` WHERE `id` IN (?) FOR UPDATE', $result->query); + $this->assertStringEndsWith('FOR UPDATE', $result->query); + } + + public function testBeginTransaction(): void + { + $result = (new Builder())->begin(); + $this->assertSame('BEGIN', $result->query); + } + + public function testCommitTransaction(): void + { + $result = (new Builder())->commit(); + $this->assertSame('COMMIT', $result->query); + } + + public function testRollbackTransaction(): void + { + $result = (new Builder())->rollback(); + $this->assertSame('ROLLBACK', $result->query); + } + + public function testReleaseSavepointCompiles(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertSame('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->assertSame('SELECT COUNT(*) AS `cnt` FROM `employees` GROUP BY `dept` HAVING (`COUNT(*)` > ?)', $result->query); + } + + public function testGroupByMultipleColumnsAB(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->groupBy(['a', 'b']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `t` GROUP BY `a`, `b`', $result->query); + } + + public function testEqualEmptyArrayReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 0', $result->query); + } + + public function testEqualWithNullOnlyCompileIn(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`x` IN (?) OR `x` IS NULL)', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` IN (?, ?, ?)', $result->query); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testNotEqualEmptyArrayReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + } + + public function testNotEqualSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` != ?', $result->query); + $this->assertSame([5], $result->bindings); + } + + public function testNotEqualWithNullOnlyCompileNotIn(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotEqualWithNullAndValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`x` != ? AND `x` IS NOT NULL)', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testNotEqualMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` NOT IN (?, ?, ?)', $result->query); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testNotEqualSingleNonNull(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('x', 42)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` != ?', $result->query); + $this->assertSame([42], $result->bindings); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `score` NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE `date` BETWEEN ? AND ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE (`age` > ? AND `age` < ?)', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE (`role` IN (?) OR `role` IN (?))', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `t` WHERE ((`a` > ? AND `b` < ?) OR `c` IN (?))', $result->query); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testEmptyAndReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + } + + public function testEmptyOrReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 0', $result->query); + } + + public function testExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`name` IS NOT NULL)', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name', 'email'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`name` IS NOT NULL AND `email` IS NOT NULL)', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`name` IS NULL)', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNotExistsMultipleAttributes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notExists(['a', 'b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`a` IS NULL AND `b` IS NULL)', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testRawFilterWithSql(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 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->assertSame('SELECT * FROM `t` WHERE active = 1', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testRawFilterEmpty(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + } + + public function testStartsWithEscapesPercent(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', '100%')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['100\\%%'], $result->bindings); + } + + public function testStartsWithEscapesUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('name', 'a_b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['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->assertSame("path\\\\%", $binding); + } + + public function testEndsWithEscapesSpecialChars(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('name', '%test_')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['%\\%test\\_'], $result->bindings); + } + + public function testContainsMultipleValuesUsesOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` LIKE ? OR `bio` LIKE ?)', $result->query); + $this->assertSame(['%php%', '%js%'], $result->bindings); + } + + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['php', 'js'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` LIKE ? AND `bio` LIKE ?)', $result->query); + $this->assertSame(['%php%', '%js%'], $result->bindings); + } + + public function testNotContainsMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['x', 'y'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (`bio` NOT LIKE ? AND `bio` NOT LIKE ?)', $result->query); + $this->assertSame(['%x%', '%y%'], $result->bindings); + } + + public function testContainsSingleValueNoParentheses(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['php'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `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->assertSame('SELECT `users`.`name`, `users`.`email` FROM `t`', $result->query); + } + + public function testDottedIdentifierInFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('users.id', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `users`.`id` IN (?)', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` 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->assertSame('SELECT * FROM `t` ORDER BY `name` ASC, RAND()', $result->query); + } + + public function testDistinctWithSelect(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT `name` FROM `t`', $result->query); + } + + public function testDistinctWithAggregate(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT COUNT(*) FROM `t`', $result->query); + } + + public function testSumWithAlias2(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('SELECT MAX(`price`) AS `priciest` FROM `t`', $result->query); + } + + public function testCountWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t`', $result->query); + } + + public function testSelectRawWithRegularSelect(): void + { + $result = (new Builder()) + ->from('t') + ->select(['id']) + ->select('NOW() as current_time') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `id`, NOW() as current_time FROM `t`', $result->query); + } + + public function testSelectRawWithBindings2(): void + { + $result = (new Builder()) + ->from('t') + ->select('COALESCE(?, ?) as result', ['a', 'b']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['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->assertSame('SELECT * FROM `a` 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->assertSame('SELECT * FROM `a` 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->assertSame('SELECT * FROM `a` JOIN `b` 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->assertSame('SELECT * FROM `t` WHERE `a` IN (?) AND `b` > ? AND `c` < ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `t` WHERE `x` IN (?) AND y > 5', $result->query); + } + + public function testResetClearsRawSelects2(): void + { + $builder = (new Builder())->from('t')->select('1 AS one'); + $builder->build(); + $builder->reset(); + + $result = $builder->from('t')->build(); + $this->assertBindingCount($result); + $this->assertSame('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->assertSame('SELECT * FROM `t` WHERE `real_column` IN (?)', $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->assertSame('SELECT `real_column` FROM `t`', $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->assertSame('SELECT * FROM `t` WHERE `x` IN (?) AND `tenant` = ? AND `org` = ?', $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->assertSame('SELECT * FROM `t` WHERE MATCH(`body`) AGAINST(? IN BOOLEAN MODE)', $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->assertSame('SELECT * FROM `t` WHERE NOT (MATCH(`body`) AGAINST(? IN BOOLEAN MODE))', $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->assertSame('SELECT * FROM `t` WHERE `deleted_at` IS NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` IS NOT NULL', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testLessThanFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThan('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` < ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testLessThanEqualFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` <= ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testGreaterThanFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` > ?', $result->query); + $this->assertSame([18], $result->bindings); + } + + public function testGreaterThanEqualFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('age', 21)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `age` >= ?', $result->query); + $this->assertSame([21], $result->bindings); + } + + public function testNotStartsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'foo')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); + $this->assertSame(['foo%'], $result->bindings); + } + + public function testNotEndsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'bar')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` NOT LIKE ?', $result->query); + $this->assertSame(['%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->assertSame('DELETE FROM `t` WHERE `age` < ? ORDER BY `id` ASC 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->assertSame('UPDATE `t` SET `status` = ? WHERE `age` < ? ORDER BY `id` ASC 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->assertSame('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->assertSame('SELECT * FROM `users` AS `u` 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->assertSame('SELECT * FROM `users` 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->assertSame('SELECT * FROM `users` 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->assertSame('SELECT * FROM `users` 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->assertSame( + 'SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', + $result->query + ); + $this->assertSame([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->assertSame('SELECT * FROM `users` WHERE `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->assertSame('SELECT `name`, (SELECT COUNT(*) AS `cnt` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`) AS `order_count` FROM `users`', $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->assertSame( + '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->assertSame('SELECT * FROM `users` ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` 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->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` GROUP BY `user_id` HAVING COUNT(*) > ?', $result->query); + $this->assertContains(5, $result->bindings); + } + + public function testWhereRawAppendsFragmentAndBindings(): void + { + $result = (new Builder()) + ->from('users') + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE a = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testWhereRawCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('b', [2])]) + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `b` IN (?) AND a = ?', $result->query); + $this->assertContains(1, $result->bindings); + $this->assertContains(2, $result->bindings); + } + + // Feature 4: countDistinct + + public function testCountDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `users` 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->assertSame('SELECT * FROM `users` 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->assertSame('SELECT * FROM `users` AS `u` 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->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT `id` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', $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->assertSame('SELECT * FROM `users` WHERE NOT EXISTS (SELECT `id` FROM `orders` WHERE `orders`.`user_id` = `users`.`id`)', $result->query); + } + + // Feature 7: insertOrIgnore + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + + $this->assertSame( + 'INSERT IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame('EXPLAIN SELECT * FROM `users` WHERE `status` IN (?)', $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->assertSame('SELECT * FROM `users` FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testForUpdateNoWait(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` FOR UPDATE NOWAIT', $result->query); + } + + public function testForShareSkipLocked(): void + { + $result = (new Builder()) + ->from('users') + ->forShareSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` FOR SHARE SKIP LOCKED', $result->query); + } + + public function testForShareNoWait(): void + { + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` 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'); + + (new Builder()) + ->from('t') + ->selectCase(new CaseExpression()) + ->build(); + } + + public function testCaseBuilderMultipleWhens(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('label'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `t`', $result->query); + $this->assertSame(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + } + + public function testCaseBuilderWithoutElseClause(): void + { + $case = (new CaseExpression()) + ->when('x', Operator::GreaterThan, 10, 1); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertSame('SELECT CASE WHEN `x` > ? THEN ? END FROM `t`', $result->query); + $this->assertSame([10, 1], $result->bindings); + } + + public function testCaseBuilderWithoutAliasClause(): void + { + $case = (new CaseExpression()) + ->whenRaw('1=1', 'yes'); + + $result = (new Builder()) + ->from('t') + ->selectCase($case) + ->build(); + + $this->assertStringNotContainsString('END AS', $result->query); + } + + // 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->assertSame('a.id', $ons[0]->left); + $this->assertSame('b.a_id', $ons[0]->right); + $this->assertSame('=', $ons[0]->operator); + } + + public function testJoinBuilderWhereAddsCondition(): void + { + $jb = new JoinBuilder(); + $jb->where('status', '=', 'active'); + + $wheres = $jb->wheres; + $this->assertCount(1, $wheres); + $this->assertSame('status = ?', $wheres[0]->expression); + $this->assertSame(['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->assertSame([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->assertSame('`deleted_at` IS NULL', $wheres[0]->expression); + $this->assertSame([], $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->assertSame(['admin', 'completed'], $result->bindings); + } + + public function testSelectSubBindingOrder(): void + { + $sub = (new Builder())->from('orders') + ->select('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->assertSame(['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->assertSame([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->assertSame('SELECT * FROM `users` WHERE `active` IN (?) AND EXISTS (SELECT `id` FROM `orders` WHERE `status` IN (?))', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` WHERE NOT EXISTS (SELECT `id` FROM `bans`)', $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->assertSame([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->assertSame([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->assertSame('SELECT * 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->assertSame('SELECT * FROM (SELECT `id` FROM `orders`) 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->assertSame('SELECT * 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->assertSame('SELECT * FROM `users` ORDER BY FIELD(`status`, ?, ?, ?)', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` GROUP BY DATE_FORMAT(`created_at`, ?)', $result->query); + $this->assertSame(['%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->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` GROUP BY `user_id` HAVING SUM(`amount`) > ?', $result->query); + $this->assertSame([1000], $result->bindings); + } + + public function testMultipleRawOrdersCombined(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('name') + ->orderByRaw('FIELD(`role`, ?)', ['admin']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` ORDER BY FIELD(`role`, ?), `name` ASC', $result->query); + } + + public function testMultipleRawGroupsCombined(): void + { + $result = (new Builder()) + ->from('events') + ->count('*', 'cnt') + ->groupBy(['type']) + ->groupByRaw('YEAR(`created_at`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `events` 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->assertSame('SELECT COUNT(DISTINCT `email`) FROM `users`', $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->assertSame('SELECT * FROM `users` AS `u` LEFT JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $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->assertSame('SELECT * FROM `users` AS `u` RIGHT JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $result->query); + } + + public function testCrossJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` 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->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM `users` AS `u` JOIN `orders` AS `o` ON `u`.`id` = `o`.`user_id`', $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->assertSame('SELECT * FROM `users` JOIN `orders` 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->assertSame('SELECT * FROM `orders` WHERE `amount` > ? AND `status` IN (?) AND `user_id` IN (SELECT `id` FROM `vip_users`)', $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->assertSame('SELECT * FROM `users` WHERE `id` IN (SELECT `id` FROM `admins`) AND `dept_id` NOT IN (SELECT `id` FROM `departments`)', $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->assertSame(['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->assertSame('SELECT * FROM `users` WHERE `name` IN (\'O\'\'Brien\') AND `active` IN (1) AND `age` IN (25)', $sql); + } + + // page() helper + + public function testPageFirstPageOffsetZero(): void + { + $result = (new Builder()) + ->from('users') + ->page(1, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` LIMIT ? 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->assertSame('SELECT * FROM `users` WHERE `active` IN (?)', $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->assertSame(['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->assertSame(['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->assertSame([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->assertSame([], $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->assertSame([], $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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame([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->assertSame([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->assertSame([], $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->assertSame(['paid'], $result->bindings); + } + + public function testExactCaseInSelect(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('status_label'); + + $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->assertSame(['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 COUNT(*) > ?', + $result->query + ); + $this->assertSame([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->assertSame(['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->assertSame([], $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->assertSame([], $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->assertSame([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->assertSame([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->assertSame([], $result->bindings); + } + + public function testExactRawExpressions(): void + { + $result = (new Builder()) + ->from('users') + ->select('COUNT(*) AS `total`') + ->select('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->assertSame([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->assertSame([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->assertSame([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->assertSame(['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->assertSame([500], $result->bindings); + } + + public function testExactSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->select('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->assertSame([], $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->assertSame(['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->assertSame([], $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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['xyz789'], $result->bindings); + } + + public function testExactAdvancedTransactionBegin(): void + { + $result = (new Builder())->begin(); + $this->assertBindingCount($result); + + $this->assertSame('BEGIN', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExactAdvancedTransactionCommit(): void + { + $result = (new Builder())->commit(); + $this->assertBindingCount($result); + + $this->assertSame('COMMIT', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExactAdvancedTransactionRollback(): void + { + $result = (new Builder())->rollback(); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExactAdvancedSavepoint(): void + { + $result = (new Builder())->savepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExactAdvancedReleaseSavepoint(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('RELEASE SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testExactAdvancedRollbackToSavepoint(): void + { + $result = (new Builder())->rollbackToSavepoint('sp1'); + $this->assertBindingCount($result); + + $this->assertSame('ROLLBACK TO SAVEPOINT `sp1`', $result->query); + $this->assertSame([], $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->assertSame(['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->assertSame([], $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->assertSame([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->assertSame(['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->assertSame([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->assertSame([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->assertSame([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->assertSame([1.1, 'electronics'], $result->bindings); + } + + public function testExactAdvancedSetCaseInUpdate(): void + { + $case = (new CaseExpression()) + ->when('category', Operator::Equal, 'electronics', 1.2) + ->when('category', Operator::Equal, 'clothing', 0.8) + ->else(1.0); + + $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 ? WHEN `category` = ? THEN ? ELSE ? END WHERE `stock` > ?', + $result->query + ); + $this->assertSame(['electronics', 1.2, 'clothing', 0.8, 1.0, 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->assertSame([], $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->assertSame([], $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->assertSame([], $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->assertSame([], $result->bindings); + } + + public function testExactAdvancedSelectRawWithGroupByRawAndHavingRaw(): void + { + $result = (new Builder()) + ->from('orders') + ->select('DATE(`created_at`) AS `order_date`') + ->select('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->assertSame([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->assertSame(['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->assertSame(['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->assertSame([100], $result->bindings); + } + + public function testCountWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(CASE WHEN status = ? THEN 1 END) AS `active_count` FROM `orders`', + $result->query + ); + $this->assertSame(['active'], $result->bindings); + } + + public function testCountWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(CASE WHEN status = ? THEN 1 END) FROM `orders`', + $result->query + ); + $this->assertSame(['active'], $result->bindings); + } + + public function testSumWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', 'active_total', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT SUM(CASE WHEN status = ? THEN `amount` END) AS `active_total` FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testSumWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', '', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT SUM(CASE WHEN status = ? THEN `amount` END) FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testAvgWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'status = ?', 'avg_completed', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT AVG(CASE WHEN status = ? THEN `amount` END) AS `avg_completed` FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testAvgWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'status = ?', '', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT AVG(CASE WHEN status = ? THEN `amount` END) FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testMinWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->minWhen('amount', 'status = ?', 'min_completed', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MIN(CASE WHEN status = ? THEN `amount` END) AS `min_completed` FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testMinWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->minWhen('amount', 'status = ?', '', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MIN(CASE WHEN status = ? THEN `amount` END) FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testMaxWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->maxWhen('amount', 'status = ?', 'max_completed', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MAX(CASE WHEN status = ? THEN `amount` END) AS `max_completed` FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testMaxWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->maxWhen('amount', 'status = ?', '', 'completed') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MAX(CASE WHEN status = ? THEN `amount` END) FROM `orders`', + $result->query + ); + $this->assertSame(['completed'], $result->bindings); + } + + public function testJoinLateral(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['total']) + ->filter([Query::greaterThan('total', 100)]) + ->limit(1); + + $result = (new Builder()) + ->from('users') + ->select(['users.id', 'users.name']) + ->joinLateral($subquery, 'top_order') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `users`.`id`, `users`.`name` FROM `users` JOIN LATERAL (SELECT `total` FROM `orders` WHERE `total` > ? LIMIT ?) AS `top_order` ON true', $result->query); + } + + public function testLeftJoinLateral(): void + { + $subquery = (new Builder()) + ->from('orders') + ->select(['total']) + ->limit(3); + + $result = (new Builder()) + ->from('users') + ->leftJoinLateral($subquery, 'recent_orders') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` LEFT JOIN LATERAL (SELECT `total` FROM `orders` LIMIT ?) AS `recent_orders` ON true', $result->query); + } + + public function testHint(): void + { + $result = (new Builder()) + ->from('users') + ->hint('NO_INDEX_MERGE') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ NO_INDEX_MERGE */ * FROM `users`', + $result->query + ); + } + + public function testHintInvalidThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint('DROP TABLE users; --'); + } + + public function testHintAcceptsBacktickedIndex(): void + { + $result = (new Builder()) + ->from('users') + ->hint('INDEX(`users` `idx_users_age`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ INDEX(`users` `idx_users_age`) */ * FROM `users`', + $result->query + ); + } + + public function testHintAcceptsForceIndex(): void + { + $result = (new Builder()) + ->from('users') + ->hint('FORCE INDEX (`idx_age`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ FORCE INDEX (`idx_age`) */ * FROM `users`', + $result->query + ); + } + + public function testHintRejectsSemicolonInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint('; DROP TABLE users;'); + } + + public function testHintRejectsNullByteInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint("INDEX(`idx`)\x00"); + } + + public function testHintRejectsNewlineInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint("INDEX(`idx`)\nDROP TABLE users"); + } + + public function testHintRejectsBlockCommentInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint('INDEX(`idx`) */ DROP TABLE users /*'); + } + + public function testHintRejectsQuoteInjection(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid hint'); + + (new Builder()) + ->from('users') + ->hint("INDEX('idx')"); + } + + public function testMaxExecutionTimeExactQuery(): void + { + $result = (new Builder()) + ->from('users') + ->maxExecutionTime(5000) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM `users`', + $result->query + ); + } + + public function testUpdateJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['orders.status' => 'shipped']) + ->updateJoin('users', 'orders.user_id', 'users.id') + ->filter([Query::equal('users.active', [true])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` SET `orders`.`status` = ? WHERE `users`.`active` IN (?)', $result->query); + } + + public function testUpdateJoinWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['orders.status' => 'shipped']) + ->updateJoin('users', 'orders.user_id', 'u.id', 'u') + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `orders` JOIN `users` AS `u` ON `orders`.`user_id` = `u`.`id` SET `orders`.`status` = ?', $result->query); + } + + public function testUpdateJoinWithoutSetThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No assignments for UPDATE'); + + (new Builder()) + ->from('orders') + ->updateJoin('users', 'orders.user_id', 'users.id') + ->update(); + } + + public function testDeleteJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteJoin('o', 'users', 'o.user_id', 'users.id') + ->filter([Query::equal('users.active', [false])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE `o` FROM `orders` AS `o` JOIN `users` ON `o`.`user_id` = `users`.`id` WHERE `users`.`active` IN (?)', $result->query); + } + + public function testExplainWithFormat(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(false, 'json'); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('EXPLAIN FORMAT=JSON', $result->query); + } + + public function testExplainAnalyzeWithFormat(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true, 'tree'); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('EXPLAIN ANALYZE FORMAT=TREE', $result->query); + } + + public function testCompileSearchExprExactMatch(): void + { + $result = (new Builder()) + ->from('articles') + ->filterSearch('title', '"exact phrase"') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `articles` WHERE MATCH(`title`) AGAINST(? IN BOOLEAN MODE)', $result->query); + $this->assertSame(['"exact phrase"'], $result->bindings); + } + + public function testConflictClauseWithRawSets(): void + { + $result = (new Builder()) + ->into('counters') + ->set(['name' => 'views', 'count' => 1]) + ->onConflict(['name'], ['count']) + ->conflictSetRaw('count', '`count` + ?', [1]) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `counters` (`name`, `count`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `count` = `count` + ?', $result->query); + } + + public function testJsonPathValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid JSON path'); + + (new Builder()) + ->from('t') + ->filter([Query::jsonPath('data', 'path; DROP TABLE', '=', 'x')]) + ->build(); + } + + public function testJsonPathOperatorValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid JSON path operator'); + + (new Builder()) + ->from('t') + ->filter([Query::jsonPath('data', 'name', 'LIKE', 'x')]) + ->build(); + } + + public function testResetClearsUpdateJoinAndDeleteJoin(): void + { + $builder = (new Builder()) + ->from('orders') + ->set(['status' => 'cancelled']) + ->updateJoin('users', 'orders.user_id', 'users.id') + ->deleteJoin('o', 'users', 'o.user_id', 'users.id'); + + $builder->reset(); + + $result = $builder + ->from('products') + ->select(['id', 'name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `products`', + $result->query + ); + } + + public function testFromNone(): void + { + $result = (new Builder()) + ->from() + ->select('1 AS one') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT 1 AS one', $result->query); + } + + public function testFromSubquery(): void + { + $sub = (new Builder())->from('orders')->select(['user_id'])->filter([Query::greaterThan('total', 100)]); + $result = (new Builder()) + ->fromSub($sub, 'high_orders') + ->select(['user_id']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `user_id` FROM (SELECT `user_id` FROM `orders` WHERE `total` > ?) AS `high_orders`', + $result->query + ); + $this->assertSame([100], $result->bindings); + } + + public function testInsertAs(): void + { + $result = (new Builder()) + ->into('users') + ->insertAs('new_row') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->onConflict(['email'], ['name']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` AS `new_row` (`name`, `email`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)', $result->query); + } + + public function testInsertColumnExpression(): void + { + $result = (new Builder()) + ->into('locations') + ->insertColumnExpression('coords', 'ST_GeomFromText(?, ?)', [4326]) + ->set(['name' => 'HQ', 'coords' => 'POINT(1 2)']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `locations` (`name`, `coords`) VALUES (?, ST_GeomFromText(?, ?))', $result->query); + } + + public function testNaturalJoin(): void + { + $result = (new Builder()) + ->from('users') + ->naturalJoin('accounts') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` NATURAL JOIN `accounts`', $result->query); + } + + public function testNaturalJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->naturalJoin('accounts', 'a') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` NATURAL JOIN `accounts` AS `a`', $result->query); + } + + public function testWithRecursiveSeedStep(): void + { + $seed = (new Builder())->from()->select('1 AS n'); + $step = (new Builder())->from('cte')->select('n + 1')->filter([Query::lessThan('n', 10)]); + $result = (new Builder()) + ->from('cte') + ->withRecursiveSeedStep('cte', $seed, $step) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH RECURSIVE `cte` AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM `cte` WHERE `n` < ?) SELECT * FROM `cte`', $result->query); + } + + public function testSelectWindowWithNamedWindow(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['name', 'salary']) + ->selectWindow('ROW_NUMBER()', 'rn', windowName: 'w') + ->window('w', partitionBy: ['department'], orderBy: ['salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `salary`, ROW_NUMBER() OVER `w` AS `rn` FROM `employees` WINDOW `w` AS (PARTITION BY `department` ORDER BY `salary` ASC)', $result->query); + } + + public function testWindowDefinitionWithDescOrder(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['name']) + ->selectWindow('RANK()', 'rnk', windowName: 'w') + ->window('w', partitionBy: ['department'], orderBy: ['-salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, RANK() OVER `w` AS `rnk` FROM `employees` WINDOW `w` AS (PARTITION BY `department` ORDER BY `salary` DESC)', $result->query); + } + + public function testBeforeBuildCallback(): void + { + $result = (new Builder()) + ->from('users') + ->beforeBuild(function (Builder $builder) { + $builder->filter([Query::equal('active', [true])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `active` IN (?)', $result->query); + } + + public function testAfterBuildCallback(): void + { + $result = (new Builder()) + ->from('users') + ->afterBuild(function (Statement $result) { + return new Statement( + '/* traced */ ' . $result->query, + $result->bindings, + $result->readOnly + ); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('/* traced */ SELECT', $result->query); + } + + public function testPagePerPageValidation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Per page must be >= 1'); + + (new Builder())->from('users')->page(1, 0); + } + + public function testFilterWhereInSubquery(): 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->assertSame('SELECT * FROM `users` WHERE `id` IN (SELECT `user_id` FROM `orders` WHERE `total` > ?)', $result->query); + } + + public function testFilterWhereNotInSubquery(): void + { + $sub = (new Builder())->from('banned')->select(['user_id']); + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `id` NOT IN (SELECT `user_id` FROM `banned`)', $result->query); + } + + public function testFilterExistsSubquery(): void + { + $sub = (new Builder())->from('orders')->filter([Query::equal('user_id', [1])]); + $result = (new Builder()) + ->from('users') + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT * FROM `orders` WHERE `user_id` IN (?))', $result->query); + } + + public function testFilterNotExistsSubquery(): void + { + $sub = (new Builder())->from('orders')->filter([Query::equal('user_id', [1])]); + $result = (new Builder()) + ->from('users') + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE NOT EXISTS (SELECT * FROM `orders` WHERE `user_id` IN (?))', $result->query); + } + + public function testSelectSubquery(): void + { + $sub = (new Builder())->from()->select('COUNT(*)'); + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->selectSub($sub, 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, (SELECT COUNT(*)) AS `total` FROM `users`', $result->query); + } + + public function testInsertOrIgnoreBindingCount(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'alice@test.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', $result->query); + $this->assertSame(['Alice', 'alice@test.com'], $result->bindings); + } + + public function testUpsertSelectFromBuilder(): void + { + $source = (new Builder())->from('staging')->select(['id', 'name', 'email']); + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name', 'email'], $source) + ->onConflict(['id'], ['name', 'email']) + ->upsertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`id`, `name`, `email`) SELECT `id`, `name`, `email` FROM `staging` ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', $result->query); + } + + public function testLateralJoin(): void + { + $sub = (new Builder())->from('orders')->select(['total'])->filter([Query::greaterThan('total', 100)])->limit(5); + $result = (new Builder()) + ->from('users') + ->joinLateral($sub, 'top_orders') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN LATERAL (SELECT `total` FROM `orders` WHERE `total` > ? LIMIT ?) AS `top_orders` ON true', $result->query); + } + + public function testLeftLateralJoin(): void + { + $sub = (new Builder())->from('orders')->select(['total'])->limit(3); + $result = (new Builder()) + ->from('users') + ->leftJoinLateral($sub, 'recent_orders') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` LEFT JOIN LATERAL (SELECT `total` FROM `orders` LIMIT ?) AS `recent_orders` ON true', $result->query); + } + + public function testJoinWhereWithCallback(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join) { + $join->on('users.id', 'orders.user_id'); + $join->where('orders.status', '=', 'active'); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ?', $result->query); + } + + public function testJoinWhereLeftJoinCompilation(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join) { + $join->on('users.id', 'orders.user_id'); + }, JoinType::Left) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + } + + public function testJoinWhereRightJoin(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('departments', function (JoinBuilder $join) { + $join->on('users.dept_id', 'departments.id'); + }, JoinType::Right) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` RIGHT JOIN `departments` ON `users`.`dept_id` = `departments`.`id`', $result->query); + } + + public function testJoinWhereFullOuterJoin(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('accounts', function (JoinBuilder $join) { + $join->on('users.id', 'accounts.user_id'); + }, JoinType::FullOuter) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` FULL OUTER JOIN `accounts` ON `users`.`id` = `accounts`.`user_id`', $result->query); + } + + public function testJoinWhereNaturalJoin(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('accounts', function (JoinBuilder $join) { + // natural join doesn't need ON + }, JoinType::Natural) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` NATURAL JOIN `accounts`', $result->query); + } + + public function testJoinWhereCrossJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('numbers', function (JoinBuilder $join) { + // cross join doesn't need ON + }, JoinType::Cross, 'n') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` CROSS JOIN `numbers` AS `n`', $result->query); + } + + public function testJoinBuilderWhereInvalidColumn(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column name'); + + $join = new JoinBuilder(); + $join->where('invalid column!', '=', 'value'); + } + + public function testJoinBuilderWhereInvalidOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid join operator'); + + $join = new JoinBuilder(); + $join->where('col', 'LIKE', 'value'); + } + + public function testJoinBuilderOnInvalidOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid join operator'); + + $join = new JoinBuilder(); + $join->on('left', 'right', 'LIKE'); + } + + public function testJoinWhereWithAliasOnInnerJoin(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $join) { + $join->on('users.id', 'orders.user_id'); + }, JoinType::Inner, 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN `orders` AS `o` ON `users`.`id` = `orders`.`user_id`', $result->query); + } + + public function testJoinWithBuilderEmptyOnsReturnsNoOnClause(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('numbers', function (JoinBuilder $join) { + // intentionally empty + }, JoinType::Cross) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` CROSS JOIN `numbers`', $result->query); + $this->assertStringNotContainsString(' ON ', $result->query); + } + + public function testHavingRawWithGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->queries([Query::groupBy(['user_id'])]) + ->havingRaw('COUNT(*) > ?', [5]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `user_id` FROM `orders` GROUP BY `user_id` HAVING COUNT(*) > ?', $result->query); + } + + public function testCompileExistsEmptyValues(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::exists([])); + $this->assertSame('1 = 1', $sql); + } + + public function testCompileNotExistsEmptyValues(): void + { + $builder = new Builder(); + $sql = $builder->compileFilter(Query::notExists([])); + $this->assertSame('1 = 1', $sql); + } + + public function testEscapeLikeValueWithArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('data', [['nested' => 'value']])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `data` LIKE ?', $result->query); + } + + public function testEscapeLikeValueWithNumeric(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('col', [42])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `col` LIKE ?', $result->query); + $this->assertSame(['%42%'], $result->bindings); + } + + public function testEscapeLikeValueWithBoolean(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('col', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `col` LIKE ?', $result->query); + $this->assertSame(['%1%'], $result->bindings); + } + + public function testCloneWithSubqueries(): void + { + $sub = (new Builder())->from('orders')->select(['user_id']); + $original = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->filterExists((new Builder())->from('accounts')); + + $cloned = $original->clone(); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertBindingCount($originalResult); + $this->assertBindingCount($clonedResult); + + $this->assertSame($originalResult->query, $clonedResult->query); + } + + public function testCloneWithFromSubquery(): void + { + $sub = (new Builder())->from('orders')->select(['user_id']); + $original = (new Builder()) + ->fromSub($sub, 'sub') + ->select(['user_id']); + + $cloned = $original->clone(); + $result = $cloned->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `user_id` FROM (SELECT `user_id` FROM `orders`) AS `sub`', $result->query); + } + + public function testCloneWithInsertSelectSource(): void + { + $source = (new Builder())->from('staging')->select(['id', 'name']); + $original = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source); + + $cloned = $original->clone(); + $result = $cloned->insertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`id`, `name`) SELECT `id`, `name` FROM `staging`', $result->query); + } + + public function testWhereInSubqueryInUpdate(): void + { + $sub = (new Builder())->from('banned_users')->select(['id']); + $result = (new Builder()) + ->from('users') + ->set(['active' => false]) + ->filterWhereIn('id', $sub) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `active` = ? WHERE `id` IN (SELECT `id` FROM `banned_users`)', $result->query); + } + + public function testExistsSubqueryInUpdate(): void + { + $sub = (new Builder())->from('orders')->filter([Query::greaterThan('total', 1000)]); + $result = (new Builder()) + ->from('users') + ->set(['vip' => true]) + ->filterExists($sub) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `vip` = ? WHERE EXISTS (SELECT * FROM `orders` WHERE `total` > ?)', $result->query); + } + + public function testWhereInSubqueryInDelete(): void + { + $sub = (new Builder())->from('banned_users')->select(['id']); + $result = (new Builder()) + ->from('users') + ->filterWhereIn('id', $sub) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `users` WHERE `id` IN (SELECT `id` FROM `banned_users`)', $result->query); + } + + public function testExistsSubqueryInDelete(): void + { + $sub = (new Builder())->from('audit_log')->filter([Query::equal('action', ['delete'])]); + $result = (new Builder()) + ->from('sessions') + ->filterExists($sub) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `sessions` WHERE EXISTS (SELECT * FROM `audit_log` WHERE `action` IN (?))', $result->query); + } + + public function testOrderByRawInUpdate(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::lessThan('last_login', '2020-01-01')]) + ->orderByRaw('FIELD(status, ?, ?)', ['active', 'inactive']) + ->limit(100) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `status` = ? WHERE `last_login` < ? ORDER BY FIELD(status, ?, ?) LIMIT ?', $result->query); + } + + public function testFilterSearchFluent(): void + { + $result = (new Builder()) + ->from('posts') + ->filterSearch('content', 'hello world') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `posts` WHERE MATCH(`content`) AGAINST(? IN BOOLEAN MODE)', $result->query); + } + + public function testFilterNotSearchFluent(): void + { + $result = (new Builder()) + ->from('posts') + ->filterNotSearch('content', 'spam') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `posts` WHERE NOT (MATCH(`content`) AGAINST(? IN BOOLEAN MODE))', $result->query); + } + + public function testFilterDistanceFluent(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], '<', 1000.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`coords`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) < ?', $result->query); + } + + public function testFilterDistanceDefaultOperator(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [1.0, 2.0], 'unknown', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `locations` WHERE ST_Distance(ST_SRID(`coords`, 0), ST_GeomFromText(?, 0, \'axis-order=long-lat\')) < ?', $result->query); + } + + public function testFilterIntersectsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterIntersects('geom', [[0.0, 0.0], [1.0, 1.0], [2.0, 0.0], [0.0, 0.0]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE ST_Intersects(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotIntersectsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterNotIntersects('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE NOT ST_Intersects(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterCrossesFluent(): void + { + $result = (new Builder()) + ->from('paths') + ->filterCrosses('geom', [[0.0, 0.0], [1.0, 1.0]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `paths` WHERE ST_Crosses(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotCrossesFluent(): void + { + $result = (new Builder()) + ->from('paths') + ->filterNotCrosses('geom', [[0.0, 0.0], [1.0, 1.0]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `paths` WHERE NOT ST_Crosses(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterOverlapsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterOverlaps('geom', [[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE ST_Overlaps(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotOverlapsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterNotOverlaps('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE NOT ST_Overlaps(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterTouchesFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterTouches('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE ST_Touches(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotTouchesFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterNotTouches('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE NOT ST_Touches(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterCoversFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterCovers('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE ST_Contains(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotCoversFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterNotCovers('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE NOT ST_Contains(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterSpatialEqualsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterSpatialEquals('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE ST_Equals(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterNotSpatialEqualsFluent(): void + { + $result = (new Builder()) + ->from('areas') + ->filterNotSpatialEquals('geom', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `areas` WHERE NOT ST_Equals(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $result->query); + } + + public function testFilterJsonContainsFluent(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('data', 'hello') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_CONTAINS(`data`, ?)', $result->query); + } + + public function testFilterJsonNotContainsFluent(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('data', 'hello') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE NOT JSON_CONTAINS(`data`, ?)', $result->query); + } + + public function testFilterJsonOverlapsFluent(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_OVERLAPS(`tags`, ?)', $result->query); + } + + public function testFilterJsonPathFluent(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonPath('data', 'user.age', '>', 18) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE JSON_EXTRACT(`data`, \'$.user.age\') > ?', $result->query); + } + + public function testGeometryToWktSinglePointWrapped(): void + { + $result = (new Builder()) + ->from('locations') + ->filterIntersects('geom', [[1.5, 2.5]]) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('POINT(1.5 2.5)', $binding); + } + + public function testGeometryToWktLinestring(): void + { + $result = (new Builder()) + ->from('paths') + ->filterIntersects('geom', [[0.0, 0.0], [1.0, 1.0], [2.0, 2.0]]) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('LINESTRING(0 0, 1 1, 2 2)', $binding); + } + + public function testGeometryToWktPolygon(): void + { + $geometry = [ + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]], + ]; + $result = (new Builder()) + ->from('areas') + ->filterIntersects('geom', $geometry) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('POLYGON((0 0, 1 0, 1 1, 0 0))', $binding); + } + + public function testGeometryToWktFallbackPoint(): void + { + $result = (new Builder()) + ->from('locations') + ->filterIntersects('geom', ['10', '20', 'extra']) + ->build(); + $this->assertBindingCount($result); + + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('POINT(10 20)', $binding); + } + + public function testSpatialAttributeTypeRedirectToSpatialEquals(): void + { + $query = Query::equal('geom', [[1.0, 2.0]]); + $query->setAttributeType('point'); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('ST_Equals(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $sql); + } + + public function testSpatialAttributeTypeRedirectToNotSpatialEquals(): void + { + $query = Query::notEqual('geom', [[1.0, 2.0]]); + $query->setAttributeType('point'); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('NOT ST_Equals(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $sql); + } + + public function testSpatialAttributeTypeRedirectToCovers(): void + { + $query = Query::containsString('geom', [[1.0, 2.0]]); + $query->setAttributeType('point'); + $query->setOnArray(false); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('ST_Contains(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $sql); + } + + public function testSpatialAttributeTypeRedirectToNotCovers(): void + { + $query = Query::notContains('geom', [[1.0, 2.0]]); + $query->setAttributeType('point'); + $query->setOnArray(false); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('NOT ST_Contains(`geom`, ST_GeomFromText(?, 4326, \'axis-order=long-lat\'))', $sql); + } + + public function testArrayFilterContains(): void + { + $query = Query::containsString('tags', ['php', 'js']); + $query->setOnArray(true); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('JSON_OVERLAPS(`tags`, ?)', $sql); + } + + public function testArrayFilterNotContains(): void + { + $query = Query::notContains('tags', ['php']); + $query->setOnArray(true); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('NOT JSON_OVERLAPS(`tags`, ?)', $sql); + } + + public function testArrayFilterContainsAll(): void + { + $query = Query::containsAll('tags', ['php', 'js']); + $query->setOnArray(true); + + $builder = new Builder(); + $sql = $builder->compileFilter($query); + + $this->assertSame('JSON_CONTAINS(`tags`, ?)', $sql); + } + + public function testInsertAliasInInsertBody(): void + { + $result = (new Builder()) + ->into('users') + ->insertAs('u') + ->set(['name' => 'Bob']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` AS `u` (`name`) VALUES (?)', $result->query); + } + + public function testInsertColumnExpressionInInsertBody(): void + { + $result = (new Builder()) + ->into('locations') + ->insertColumnExpression('coords', 'ST_GeomFromText(?, ?)', [4326]) + ->set(['name' => 'Place', 'coords' => 'POINT(1 2)']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `locations` (`name`, `coords`) VALUES (?, ST_GeomFromText(?, ?))', $result->query); + } + + public function testIndexInvalidMethodThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid index method'); + + new Index('idx', ['col'], method: 'DROP TABLE;'); + } + + public function testIndexInvalidOperatorClassThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid operator class'); + + new Index('idx', ['col'], operatorClass: 'DROP TABLE;'); + } + + public function testIndexInvalidCollationThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation'); + + new Index('idx', ['col'], collations: ['col' => 'DROP TABLE;']); + } + + public function testUpsertWithInsertColumnExpression(): void + { + $result = (new Builder()) + ->into('locations') + ->insertColumnExpression('coords', 'ST_GeomFromText(?, ?)', [4326]) + ->set(['name' => 'HQ', 'coords' => 'POINT(1 2)']) + ->onConflict(['name'], ['coords']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `locations` (`name`, `coords`) VALUES (?, ST_GeomFromText(?, ?)) ON DUPLICATE KEY UPDATE `coords` = VALUES(`coords`)', $result->query); + } + + public function testWindowSelectInlinePartitionAndOrder(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['name']) + ->selectWindow('ROW_NUMBER()', 'rn', partitionBy: ['dept'], orderBy: ['-salary', 'name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, ROW_NUMBER() OVER (PARTITION BY `dept` ORDER BY `salary` DESC, `name` ASC) AS `rn` FROM `employees`', $result->query); + } + + public function testFullOuterJoinCompilation(): void + { + $result = (new Builder()) + ->from('left_table') + ->queries([new Query(Method::FullOuterJoin, 'right_table', ['left_table.id', '=', 'right_table.id'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `left_table` FULL OUTER JOIN `right_table` ON `left_table`.`id` = `right_table`.`id`', $result->query); + } + + public function testNaturalJoinCompilation(): void + { + $result = (new Builder()) + ->from('users') + ->queries([Query::naturalJoin('profiles')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` NATURAL JOIN `profiles`', $result->query); + } + + public function testCloneWithLateralJoins(): void + { + $sub = (new Builder())->from('orders')->select(['total'])->limit(3); + $original = (new Builder()) + ->from('users') + ->joinLateral($sub, 'top'); + + $cloned = $original->clone(); + $result = $cloned->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN LATERAL (SELECT `total` FROM `orders` LIMIT ?) AS `top` ON true', $result->query); + } + + public function testValidateTableFromNone(): void + { + $result = (new Builder()) + ->from() + ->select('CONNECTION_ID() AS cid') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CONNECTION_ID() AS cid', $result->query); + } + + public function testUpsertInsertAlias(): void + { + $result = (new Builder()) + ->into('users') + ->insertAs('new') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['email'], ['name']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` AS `new` (`name`, `email`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)', $result->query); + } + + public function testUpsertSelectValidationNoSource(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No SELECT source specified'); + + (new Builder()) + ->into('users') + ->onConflict(['id'], ['name']) + ->upsertSelect(); + } + + public function testUpsertSelectValidationNoColumns(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No columns specified'); + + $source = (new Builder())->from('staging'); + (new Builder()) + ->into('users') + ->fromSelect([], $source) + ->onConflict(['id'], ['name']) + ->upsertSelect(); + } + + public function testUpsertSelectValidationNoConflictKeys(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict keys specified'); + + $source = (new Builder())->from('staging'); + (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source) + ->upsertSelect(); + } + + public function testUpsertSelectValidationNoConflictUpdateColumns(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('No conflict update columns specified'); + + $source = (new Builder())->from('staging'); + (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source) + ->onConflict(['id'], []) + ->upsertSelect(); + } + + public function testCteWithJoinWhereGroupByHavingOrderLimitOffset(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->count('*', 'order_count') + ->groupBy(['user_id']) + ->having([Query::greaterThan('order_count', 3)]); + + $result = (new Builder()) + ->with('active_buyers', $cteQuery) + ->from('users') + ->select(['users.name', 'ab.order_count']) + ->join('active_buyers', 'users.id', 'active_buyers.user_id', '=', 'ab') + ->filter([Query::equal('users.status', ['active'])]) + ->sortDesc('ab.order_count') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `active_buyers` AS (SELECT COUNT(*) AS `order_count`, `user_id` FROM `orders` GROUP BY `user_id` HAVING COUNT(*) > ?) SELECT `users`.`name`, `ab`.`order_count` FROM `users` JOIN `active_buyers` AS `ab` ON `users`.`id` = `active_buyers`.`user_id` WHERE `users`.`status` IN (?) ORDER BY `ab`.`order_count` DESC LIMIT ? OFFSET ?', $result->query); + $this->assertSame([3, 'active', 10, 5], $result->bindings); + } + + public function testCteWithUnionCombiningComplexSubqueries(): void + { + $cteQuery = (new Builder()) + ->from('products') + ->filter([Query::equal('active', [true])]); + + $q2 = (new Builder()) + ->from('archived_products') + ->filter([Query::greaterThan('sales', 1000)]); + + $result = (new Builder()) + ->with('active_products', $cteQuery) + ->from('active_products') + ->select(['name', 'price']) + ->union($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `active_products` AS (SELECT * FROM `products` WHERE `active` IN (?)) (SELECT `name`, `price` FROM `active_products`) UNION (SELECT * FROM `archived_products` WHERE `sales` > ?)', $result->query); + $this->assertSame([true, 1000], $result->bindings); + } + + public function testCteReferencedInJoin(): void + { + $cte = (new Builder()) + ->from('departments') + ->filter([Query::equal('active', [true])]); + + $result = (new Builder()) + ->with('active_depts', $cte) + ->from('employees') + ->join('active_depts', 'employees.dept_id', 'active_depts.id') + ->filter([Query::greaterThan('employees.salary', 50000)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `active_depts` AS (SELECT * FROM `departments` WHERE `active` IN (?)) SELECT * FROM `employees` JOIN `active_depts` ON `employees`.`dept_id` = `active_depts`.`id` WHERE `employees`.`salary` > ?', $result->query); + $this->assertSame([true, 50000], $result->bindings); + } + + public function testRecursiveCteWithWhereFilter(): void + { + $seed = (new Builder()) + ->from('categories') + ->filter([Query::isNull('parent_id')]); + + $step = (new Builder()) + ->from('categories') + ->select(['categories.id', 'categories.name', 'categories.parent_id']) + ->join('tree', 'categories.parent_id', 'tree.id'); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step) + ->from('tree') + ->filter([Query::notEqual('name', 'Excluded')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH RECURSIVE `tree` AS (SELECT * FROM `categories` WHERE `parent_id` IS NULL UNION ALL SELECT `categories`.`id`, `categories`.`name`, `categories`.`parent_id` FROM `categories` JOIN `tree` ON `categories`.`parent_id` = `tree`.`id`) SELECT * FROM `tree` WHERE `name` != ?', $result->query); + $this->assertSame(['Excluded'], $result->bindings); + } + + public function testMultipleCtesWhereSecondReferencesFirst(): void + { + $cte1 = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['completed'])]); + + $cte2 = (new Builder()) + ->from('completed_orders') + ->sum('total', 'grand_total'); + + $result = (new Builder()) + ->with('completed_orders', $cte1) + ->with('order_totals', $cte2) + ->from('order_totals') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `completed_orders` AS (SELECT * FROM `orders` WHERE `status` IN (?)), `order_totals` AS (SELECT SUM(`total`) AS `grand_total` FROM `completed_orders`) SELECT * FROM `order_totals`', $result->query); + $this->assertSame(['completed'], $result->bindings); + } + + public function testWindowFunctionWithJoinAndWhere(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name']) + ->selectWindow('ROW_NUMBER()', 'rn', ['users.name'], ['-orders.total']) + ->join('users', 'orders.user_id', 'users.id') + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `orders`.`id`, `users`.`name`, ROW_NUMBER() OVER (PARTITION BY `users`.`name` ORDER BY `orders`.`total` DESC) AS `rn` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` WHERE `orders`.`total` > ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testWindowFunctionCombinedWithGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['category']) + ->sum('amount', 'total_sales') + ->selectWindow('RANK()', 'sales_rank', null, ['-total_sales']) + ->groupBy(['category']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`amount`) AS `total_sales`, `category`, RANK() OVER (ORDER BY `total_sales` DESC) AS `sales_rank` FROM `sales` GROUP BY `category`', $result->query); + } + + public function testMultipleWindowFunctionsWithDifferentPartitions(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['name', 'department', 'salary']) + ->selectWindow('ROW_NUMBER()', 'dept_rank', ['department'], ['-salary']) + ->selectWindow('ROW_NUMBER()', 'global_rank', null, ['-salary']) + ->selectWindow('SUM(salary)', 'dept_total', ['department']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `dept_rank`, ROW_NUMBER() OVER (ORDER BY `salary` DESC) AS `global_rank`, SUM(salary) OVER (PARTITION BY `department`) AS `dept_total` FROM `employees`', $result->query); + } + + public function testNamedWindowUsedByMultipleSelectWindowCalls(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['date', 'amount']) + ->window('w', ['category'], ['date']) + ->selectWindow('ROW_NUMBER()', 'rn', windowName: 'w') + ->selectWindow('SUM(amount)', 'running_total', windowName: 'w') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `date`, `amount`, ROW_NUMBER() OVER `w` AS `rn`, SUM(amount) OVER `w` AS `running_total` FROM `sales` WINDOW `w` AS (PARTITION BY `category` ORDER BY `date` ASC)', $result->query); + } + + public function testSubSelectWithJoinAndWhere(): void + { + $sub = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([new Query(Method::Raw, 'orders.user_id = users.id')]); + + $result = (new Builder()) + ->from('users') + ->select(['users.name']) + ->selectSub($sub, 'order_count') + ->join('departments', 'users.dept_id', 'departments.id') + ->filter([Query::equal('departments.name', ['Engineering'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `users`.`name`, (SELECT COUNT(*) AS `cnt` FROM `orders` WHERE orders.user_id = users.id) AS `order_count` FROM `users` JOIN `departments` ON `users`.`dept_id` = `departments`.`id` WHERE `departments`.`name` IN (?)', $result->query); + } + + public function testFromSubqueryWithJoinWhereOrder(): void + { + $sub = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['paid'])]) + ->select(['user_id', 'total']); + + $result = (new Builder()) + ->fromSub($sub, 'paid_orders') + ->join('users', 'paid_orders.user_id', 'users.id') + ->filter([Query::greaterThan('paid_orders.total', 100)]) + ->sortDesc('paid_orders.total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM (SELECT `user_id`, `total` FROM `orders` WHERE `status` IN (?)) AS `paid_orders` JOIN `users` ON `paid_orders`.`user_id` = `users`.`id` WHERE `paid_orders`.`total` > ? ORDER BY `paid_orders`.`total` DESC', $result->query); + $this->assertSame(['paid', 100], $result->bindings); + } + + public function testFilterWhereInWithSubqueryAndJoin(): void + { + $sub = (new Builder()) + ->from('vip_users') + ->select(['id']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->from('orders') + ->join('products', 'orders.product_id', 'products.id') + ->filterWhereIn('orders.user_id', $sub) + ->filter([Query::greaterThan('orders.total', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` JOIN `products` ON `orders`.`product_id` = `products`.`id` WHERE `orders`.`total` > ? AND `orders`.`user_id` IN (SELECT `id` FROM `vip_users` WHERE `tier` IN (?))', $result->query); + $this->assertSame([50, 'gold'], $result->bindings); + } + + public function testExistsSubqueryWithOtherWhereFilters(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['id']) + ->filter([Query::raw('orders.user_id = users.id')]); + + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->filterExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `age` > ? AND EXISTS (SELECT `id` FROM `orders` WHERE orders.user_id = users.id)', $result->query); + $this->assertSame(['active', 18], $result->bindings); + } + + public function testUnionWithOrderByAndLimit(): void + { + $q2 = (new Builder()) + ->from('archived') + ->filter([Query::equal('type', ['premium'])]); + + $result = (new Builder()) + ->from('current') + ->filter([Query::equal('type', ['premium'])]) + ->sortDesc('created_at') + ->limit(20) + ->union($q2) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM `current` WHERE `type` IN (?) ORDER BY `created_at` DESC LIMIT ?) UNION (SELECT * FROM `archived` WHERE `type` IN (?))', $result->query); + $this->assertSame(['premium', 20, 'premium'], $result->bindings); + } + + public function testThreeUnionQueries(): void + { + $q1 = (new Builder())->from('t1')->filter([Query::equal('a', [1])]); + $q2 = (new Builder())->from('t2')->filter([Query::equal('b', [2])]); + $q3 = (new Builder())->from('t3')->filter([Query::equal('c', [3])]); + + $result = (new Builder()) + ->from('t0') + ->filter([Query::equal('d', [0])]) + ->union($q1) + ->unionAll($q2) + ->union($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(2, substr_count($result->query, ') UNION (')); + $this->assertSame('(SELECT * FROM `t0` WHERE `d` IN (?)) UNION (SELECT * FROM `t1` WHERE `a` IN (?)) UNION ALL (SELECT * FROM `t2` WHERE `b` IN (?)) UNION (SELECT * FROM `t3` WHERE `c` IN (?))', $result->query); + $this->assertSame([0, 1, 2, 3], $result->bindings); + } + + public function testInsertSelectWithJoinedSource(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['staging.name', 'departments.code']) + ->join('departments', 'staging.dept_id', 'departments.id') + ->filter([Query::equal('staging.verified', [true])]); + + $result = (new Builder()) + ->into('employees') + ->fromSelect(['name', 'dept_code'], $source) + ->insertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `employees` (`name`, `dept_code`) SELECT `staging`.`name`, `departments`.`code` FROM `staging` JOIN `departments` ON `staging`.`dept_id` = `departments`.`id` WHERE `staging`.`verified` IN (?)', $result->query); + $this->assertSame([true], $result->bindings); + } + + public function testUpdateJoinWithFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->updateJoin('users', 'orders.user_id', 'users.id') + ->set(['orders.status' => 'upgraded']) + ->filter([Query::equal('users.tier', ['gold'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` SET `orders`.`status` = ? WHERE `users`.`tier` IN (?)', $result->query); + $this->assertSame(['upgraded', 'gold'], $result->bindings); + } + + public function testDeleteWithSubqueryFilter(): void + { + $sub = (new Builder()) + ->from('blacklist') + ->select(['user_id']); + + $result = (new Builder()) + ->from('sessions') + ->filterWhereIn('user_id', $sub) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `sessions` WHERE `user_id` IN (SELECT `user_id` FROM `blacklist`)', $result->query); + } + + public function testUpsertWithConflictSetRaw(): void + { + $result = (new Builder()) + ->into('counters') + ->set(['id' => 1, 'hits' => 1, 'updated_at' => '2024-01-01']) + ->onConflict(['id'], ['hits', 'updated_at']) + ->conflictSetRaw('hits', '`hits` + VALUES(`hits`)') + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `counters` (`id`, `hits`, `updated_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `hits` = `hits` + VALUES(`hits`), `updated_at` = VALUES(`updated_at`)', $result->query); + } + + public function testCaseExpressionInSelectWithWhereAndOrderBy(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('status_label'); + + $result = (new Builder()) + ->from('users') + ->select(['name']) + ->selectCase($case) + ->filter([Query::isNotNull('status')]) + ->sortAsc('name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `status_label` FROM `users` WHERE `status` IS NOT NULL ORDER BY `name` ASC', $result->query); + $this->assertSame(['active', 'Active', 'inactive', 'Inactive', 'Unknown'], $result->bindings); + } + + public function testCaseExpressionWithMultipleWhensAndAggregate(): void + { + $case = (new CaseExpression()) + ->when('score', Operator::GreaterThanEqual, 90, 'A') + ->when('score', Operator::GreaterThanEqual, 80, 'B') + ->when('score', Operator::GreaterThanEqual, 70, 'C') + ->else('F') + ->alias('grade'); + + $result = (new Builder()) + ->from('students') + ->selectCase($case) + ->count('*', 'student_count') + ->groupBy(['grade']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `student_count`, CASE WHEN `score` >= ? THEN ? WHEN `score` >= ? THEN ? WHEN `score` >= ? THEN ? ELSE ? END AS `grade` FROM `students` GROUP BY `grade`', $result->query); + $this->assertSame([90, 'A', 80, 'B', 70, 'C', 'F'], $result->bindings); + } + + public function testLateralJoinWithWhereAndOrder(): void + { + $lateral = (new Builder()) + ->from('orders') + ->select(['total', 'created_at']) + ->filter([Query::raw('orders.user_id = users.id')]) + ->sortDesc('created_at') + ->limit(3); + + $result = (new Builder()) + ->from('users') + ->select(['users.name']) + ->joinLateral($lateral, 'recent_orders') + ->filter([Query::greaterThan('recent_orders.total', 50)]) + ->sortAsc('users.name') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `users`.`name` FROM `users` JOIN LATERAL (SELECT `total`, `created_at` FROM `orders` WHERE orders.user_id = users.id ORDER BY `created_at` DESC LIMIT ?) AS `recent_orders` ON true WHERE `recent_orders`.`total` > ? ORDER BY `users`.`name` ASC', $result->query); + } + + public function testFullTextSearchWithRegularWhereAndJoin(): void + { + $result = (new Builder()) + ->from('articles') + ->select(['articles.title', 'authors.name']) + ->join('authors', 'articles.author_id', 'authors.id') + ->filter([ + Query::search('articles.content', 'database optimization'), + Query::equal('articles.published', [true]), + ]) + ->sortDesc('articles.created_at') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `articles`.`title`, `authors`.`name` FROM `articles` JOIN `authors` ON `articles`.`author_id` = `authors`.`id` WHERE MATCH(`articles`.`content`) AGAINST(? IN BOOLEAN MODE) AND `articles`.`published` IN (?) ORDER BY `articles`.`created_at` DESC', $result->query); + $this->assertSame(['database optimization*', true], $result->bindings); + } + + public function testForUpdateWithJoinAndSubquery(): void + { + $sub = (new Builder()) + ->from('locked_users') + ->select(['id']); + + $result = (new Builder()) + ->from('accounts') + ->join('users', 'accounts.user_id', 'users.id') + ->filterWhereIn('users.id', $sub) + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `accounts` JOIN `users` ON `accounts`.`user_id` = `users`.`id` WHERE `users`.`id` IN (SELECT `id` FROM `locked_users`) FOR UPDATE', $result->query); + } + + public function testMultipleAggregatesWithGroupByAndHaving(): void + { + $result = (new Builder()) + ->from('sales') + ->count('*', 'sale_count') + ->sum('amount', 'total_amount') + ->avg('amount', 'avg_amount') + ->select(['region']) + ->groupBy(['region']) + ->having([ + Query::greaterThan('sale_count', 10), + Query::greaterThan('avg_amount', 50), + ]) + ->sortDesc('total_amount') + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `sale_count`, SUM(`amount`) AS `total_amount`, AVG(`amount`) AS `avg_amount`, `region` FROM `sales` GROUP BY `region` HAVING COUNT(*) > ? AND AVG(`amount`) > ? ORDER BY `total_amount` DESC LIMIT ?', $result->query); + $this->assertSame([10, 50, 5], $result->bindings); + } + + public function testCountDistinctColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->countDistinct('user_id', 'unique_buyers') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(DISTINCT `user_id`) AS `unique_buyers` FROM `orders`', $result->query); + } + + public function testSelfJoinWithAlias(): void + { + $result = (new Builder()) + ->from('employees', 'e') + ->select(['e.name', 'mgr.name']) + ->leftJoin('employees', 'e.manager_id', 'mgr.id', '=', 'mgr') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `e`.`name`, `mgr`.`name` FROM `employees` AS `e` LEFT JOIN `employees` AS `mgr` ON `e`.`manager_id` = `mgr`.`id`', $result->query); + } + + public function testTripleJoinWithFilters(): void + { + $result = (new Builder()) + ->from('orders') + ->select(['orders.id', 'users.name', 'products.title']) + ->join('users', 'orders.user_id', 'users.id') + ->join('order_items', 'orders.id', 'order_items.order_id') + ->join('products', 'order_items.product_id', 'products.id') + ->filter([ + Query::greaterThan('orders.total', 100), + Query::equal('products.category', ['electronics']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(3, substr_count($result->query, ' JOIN ')); + $this->assertSame('SELECT `orders`.`id`, `users`.`name`, `products`.`title` FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` JOIN `order_items` ON `orders`.`id` = `order_items`.`order_id` JOIN `products` ON `order_items`.`product_id` = `products`.`id` WHERE `orders`.`total` > ? AND `products`.`category` IN (?)', $result->query); + $this->assertSame([100, 'electronics'], $result->bindings); + } + + public function testCrossJoinWithLeftAndInnerJoinCombined(): void + { + $result = (new Builder()) + ->from('sizes') + ->crossJoin('colors') + ->leftJoin('inventory', 'sizes.id', 'inventory.size_id') + ->join('warehouses', 'inventory.warehouse_id', 'warehouses.id') + ->filter([Query::equal('warehouses.active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `sizes` CROSS JOIN `colors` LEFT JOIN `inventory` ON `sizes`.`id` = `inventory`.`size_id` JOIN `warehouses` ON `inventory`.`warehouse_id` = `warehouses`.`id` WHERE `warehouses`.`active` IN (?)', $result->query); + $this->assertSame([true], $result->bindings); + } + + public function testExplainWithComplexQuery(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id') + ->filter([Query::greaterThan('orders.total', 100)]) + ->sortDesc('orders.total') + ->limit(10) + ->explain(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('EXPLAIN ', $result->query); + $this->assertSame('EXPLAIN SELECT * FROM `orders` JOIN `users` ON `orders`.`user_id` = `users`.`id` WHERE `orders`.`total` > ? ORDER BY `orders`.`total` DESC LIMIT ?', $result->query); + $this->assertTrue($result->readOnly); + } + + public function testFilterSingleElementArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` IN (?)', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testFilterMultiElementArray(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [1, 2, 3])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `x` IN (?, ?, ?)', $result->query); + $this->assertSame([1, 2, 3], $result->bindings); + } + + public function testIsNullCombinedWithEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('deleted_at'), + Query::equal('status', ['active', 'pending']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `deleted_at` IS NULL AND `status` IN (?, ?)', + $result->query + ); + $this->assertSame(['active', 'pending'], $result->bindings); + } + + public function testIsNotNullWithGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNotNull('verified_at'), + Query::greaterThan('login_count', 5), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `verified_at` IS NOT NULL AND `login_count` > ?', + $result->query + ); + $this->assertSame([5], $result->bindings); + } + + public function testBetweenCombinedWithNotEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::between('age', 18, 65), + Query::notEqual('status', 'banned'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `age` BETWEEN ? AND ? AND `status` != ?', + $result->query + ); + $this->assertSame([18, 65, 'banned'], $result->bindings); + } + + public function testOrWrappingMultipleDifferentOperatorTypes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('role', ['admin']), + Query::greaterThan('score', 95), + Query::isNull('suspended_at'), + Query::startsWith('email', 'vip'), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE (`role` IN (?) OR `score` > ? OR `suspended_at` IS NULL OR `email` LIKE ?)', + $result->query + ); + $this->assertSame(['admin', 95, 'vip%'], $result->bindings); + } + + public function testNestedOrInsideAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::equal('active', [true]), + Query::or([ + Query::greaterThan('age', 21), + Query::equal('verified', [true]), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE (`active` IN (?) AND (`age` > ? OR `verified` IN (?)))', + $result->query + ); + $this->assertSame([true, 21, true], $result->bindings); + } + + public function testAndInsideOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([ + Query::equal('role', ['admin']), + Query::greaterThan('level', 5), + ]), + Query::and([ + Query::equal('role', ['superuser']), + Query::greaterThan('level', 1), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE ((`role` IN (?) AND `level` > ?) OR (`role` IN (?) AND `level` > ?))', + $result->query + ); + $this->assertSame(['admin', 5, 'superuser', 1], $result->bindings); + } + + public function testTripleNestedLogicalOrAndEqGtAndLtNe(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 2), + ]), + Query::and([ + Query::lessThan('c', 3), + Query::notEqual('d', 4), + ]), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE ((`a` IN (?) AND `b` > ?) OR (`c` < ? AND `d` != ?))', + $result->query + ); + $this->assertSame([1, 2, 3, 4], $result->bindings); + } + + public function testEqualWithEmptyStringValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('name', [''])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `name` IN (?)', $result->query); + $this->assertSame([''], $result->bindings); + } + + public function testContainsWithSqlWildcardPercentAndUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['100%_test'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `bio` LIKE ?', $result->query); + $this->assertSame(['%100\%\_test%'], $result->bindings); + } + + public function testCompoundSortAscDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('last_name') + ->sortAsc('first_name') + ->sortDesc('created_at') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` ORDER BY `last_name` ASC, `first_name` ASC, `created_at` DESC', + $result->query + ); + } + + public function testLimitOneEdgeCase(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->sortDesc('score') + ->limit(1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `status` IN (?) ORDER BY `score` DESC LIMIT ?', + $result->query + ); + $this->assertSame(['active', 1], $result->bindings); + } + + public function testExplicitOffsetZeroWithLimit(): void + { + $result = (new Builder()) + ->from('t') + ->limit(10) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([10, 0], $result->bindings); + } + + public function testLargeOffset(): void + { + $result = (new Builder()) + ->from('t') + ->limit(25) + ->offset(999999) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([25, 999999], $result->bindings); + } + + public function testDistinctWithCountStar(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count('*', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT COUNT(*) AS `total` FROM `t`', $result->query); + } + + public function testDistinctWithOrderByOnNonSelectedColumn(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['name']) + ->sortAsc('created_at') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT `name` FROM `t` ORDER BY `created_at` ASC', $result->query); + } + + public function testMultipleSetCallsForUpdate(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Alice', 'email' => 'alice@example.com', 'age' => 30]) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `name` = ?, `email` = ?, `age` = ? WHERE `id` IN (?)', $result->query); + $this->assertSame(['Alice', 'alice@example.com', 30, 1], $result->bindings); + } + + public function testMultipleSetCallsForInsert(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'email' => 'b@b.com']) + ->set(['name' => 'Charlie', 'email' => 'c@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['Alice', 'a@b.com', 'Bob', 'b@b.com', 'Charlie', 'c@b.com'], $result->bindings); + } + + public function testGroupBySingleColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`', $result->query); + } + + public function testGroupByMultipleColumnsList(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['status', 'region', 'year']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `total` FROM `orders` GROUP BY `status`, `region`, `year`', + $result->query + ); + } + + public function testFilterOnAliasedColumnFromJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->join('users', 'orders.user_id', 'users.id', '=', 'u') + ->filter([Query::equal('u.status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` JOIN `users` AS `u` ON `orders`.`user_id` = `users`.`id` WHERE `u`.`status` IN (?)', $result->query); + } + + public function testFilterAfterJoinOnJoinedTableColumn(): void + { + $result = (new Builder()) + ->from('orders') + ->leftJoin('refunds', 'orders.id', 'refunds.order_id') + ->filter([ + Query::isNull('refunds.id'), + Query::greaterThan('orders.total', 50), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` LEFT JOIN `refunds` ON `orders`.`id` = `refunds`.`order_id` WHERE `refunds`.`id` IS NULL AND `orders`.`total` > ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testBindingOrderComplexFilterHavingSubquery(): void + { + $sub = (new Builder()) + ->from('blacklist') + ->select(['user_id']) + ->filter([Query::equal('reason', ['fraud'])]); + + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant_id = ?', ['t1']); + } + }; + + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('total', 'revenue') + ->addHook($hook) + ->filter([Query::greaterThan('total', 0)]) + ->filterWhereNotIn('user_id', $sub) + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`total`) AS `revenue` FROM `orders` WHERE `total` > ? AND tenant_id = ? AND `user_id` NOT IN (SELECT `user_id` FROM `blacklist` WHERE `reason` IN (?)) GROUP BY `status` HAVING COUNT(*) > ? LIMIT ?', $result->query); + $this->assertSame([0, 't1', 'fraud', 5, 10], $result->bindings); + } + + public function testCloneThenModifyOriginalUnchanged(): void + { + $original = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->limit(10); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('age', 30)]); + $cloned->limit(5); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertStringNotContainsString('`age`', $originalResult->query); + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `age` > ? LIMIT ?', $clonedResult->query); + $this->assertSame(['active', 10], $originalResult->bindings); + } + + public function testResetThenRebuildEntirelyDifferentQueryType(): void + { + $builder = new Builder(); + + $selectResult = $builder + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10) + ->build(); + $this->assertSame('SELECT `name`, `email` FROM `users` WHERE `status` IN (?) ORDER BY `name` ASC LIMIT ?', $selectResult->query); + + $builder->reset(); + + $insertResult = $builder + ->into('users') + ->set(['name' => 'New User', 'email' => 'new@example.com']) + ->insert(); + $this->assertSame('INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', $insertResult->query); + $this->assertStringNotContainsString('SELECT', $insertResult->query); + } + + public function testSelectRawWithBindingsPlusRegularSelect(): void + { + $result = (new Builder()) + ->from('t') + ->select(['name']) + ->select('COALESCE(bio, ?) AS bio_display', ['N/A']) + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, COALESCE(bio, ?) AS bio_display FROM `t` WHERE `active` IN (?)', $result->query); + $this->assertSame(['N/A', true], $result->bindings); + } + + public function testWhereRawWithRegularFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::equal('status', ['active']), + Query::raw('YEAR(created_at) = ?', [2024]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE `status` IN (?) AND YEAR(created_at) = ?', + $result->query + ); + $this->assertSame(['active', 2024], $result->bindings); + } + + public function testHavingWithMultipleConditionsAndLogicalOr(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->groupBy(['category']) + ->having([ + Query::greaterThan('cnt', 5), + Query::or([ + Query::greaterThan('total', 10000), + Query::lessThan('total', 100), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`amount`) AS `total` FROM `t` GROUP BY `category` HAVING COUNT(*) > ? AND (`total` > ? OR `total` < ?)', $result->query); + $this->assertSame([5, 10000, 100], $result->bindings); + } + + public function testCountStarVsCountColumnName(): void + { + $starResult = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + $this->assertBindingCount($starResult); + + $colResult = (new Builder()) + ->from('t') + ->count('name', 'total') + ->build(); + $this->assertBindingCount($colResult); + + $this->assertSame('SELECT COUNT(*) AS `total` FROM `t`', $starResult->query); + $this->assertSame('SELECT COUNT(`name`) AS `total` FROM `t`', $colResult->query); + } + + public function testAggregatesOnlyNoGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'total_orders') + ->sum('total', 'revenue') + ->avg('total', 'avg_order') + ->min('total', 'smallest_order') + ->max('total', 'largest_order') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(*) AS `total_orders`, SUM(`total`) AS `revenue`, AVG(`total`) AS `avg_order`, MIN(`total`) AS `smallest_order`, MAX(`total`) AS `largest_order` FROM `orders`', + $result->query + ); + $this->assertStringNotContainsString('GROUP BY', $result->query); + } + + public function testInsertOrIgnoreMultipleRows(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->set(['id' => 2, 'name' => 'Bob']) + ->set(['id' => 3, 'name' => 'Charlie']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('INSERT IGNORE INTO', $result->query); + $this->assertSame('INSERT IGNORE INTO `users` (`id`, `name`) VALUES (?, ?), (?, ?), (?, ?)', $result->query); + $this->assertSame([1, 'Alice', 2, 'Bob', 3, 'Charlie'], $result->bindings); + } + + public function testSelectReadOnlyFlag(): void + { + $selectResult = (new Builder()) + ->from('t') + ->build(); + $this->assertTrue($selectResult->readOnly); + } + + public function testInsertNotReadOnly(): void + { + $insertResult = (new Builder()) + ->into('t') + ->set(['a' => 1]) + ->insert(); + $this->assertFalse($insertResult->readOnly); + } + + public function testUpdateNotReadOnly(): void + { + $updateResult = (new Builder()) + ->from('t') + ->set(['a' => 1]) + ->update(); + $this->assertFalse($updateResult->readOnly); + } + + public function testDeleteNotReadOnly(): void + { + $deleteResult = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertFalse($deleteResult->readOnly); + } + + public function testHavingRawWithRegularHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['status']) + ->having([Query::greaterThan('cnt', 5)]) + ->havingRaw('SUM(total) > ?', [1000]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` GROUP BY `status` HAVING COUNT(*) > ? AND SUM(total) > ?', $result->query); + $this->assertSame([5, 1000], $result->bindings); + } + + public function testOrderByRawWithRegularSort(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->orderByRaw('FIELD(status, ?, ?, ?)', ['active', 'pending', 'inactive']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` ORDER BY FIELD(status, ?, ?, ?), `name` ASC', $result->query); + $this->assertSame(['active', 'pending', 'inactive'], $result->bindings); + } + + public function testGroupByRawWithRegularGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['status']) + ->groupByRaw('YEAR(created_at)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` GROUP BY `status`, YEAR(created_at)', $result->query); + } + + public function testDeleteJoinWithFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteJoin('o', 'blacklist', 'o.user_id', 'blacklist.user_id') + ->filter([Query::equal('blacklist.reason', ['fraud'])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE `o` FROM `orders` AS `o` JOIN `blacklist` ON `o`.`user_id` = `blacklist`.`user_id` WHERE `blacklist`.`reason` IN (?)', $result->query); + $this->assertSame(['fraud'], $result->bindings); + } + + public function testMaxExecutionTimeHint(): void + { + $result = (new Builder()) + ->from('t') + ->maxExecutionTime(5000) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM `t` WHERE `status` IN (?)', $result->query); + } + + public function testMultipleHintsWithComplexQuery(): void + { + $result = (new Builder()) + ->from('t') + ->maxExecutionTime(1000) + ->hint('NO_RANGE_OPTIMIZATION(t)') + ->filter([Query::greaterThan('id', 100)]) + ->limit(50) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ MAX_EXECUTION_TIME(1000) NO_RANGE_OPTIMIZATION(t) */ * FROM `t` WHERE `id` > ? LIMIT ?', $result->query); + } + + public function testJoinWhereWithMultipleConditions(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $j) { + $j->on('users.id', 'orders.user_id') + ->where('orders.status', '=', 'completed') + ->where('orders.total', '>', 100); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.status = ? AND orders.total > ?', $result->query); + $this->assertSame(['completed', 100], $result->bindings); + } + + public function testFromNoneWithSelectRaw(): void + { + $result = (new Builder()) + ->from() + ->select('1 + 1 AS result') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT 1 + 1 AS result', $result->query); + } + + public function testCteBindingOrderPrecedesMainQuery(): void + { + $cte = (new Builder()) + ->from('source') + ->filter([Query::equal('type', ['premium'])]); + + $result = (new Builder()) + ->with('filtered', $cte) + ->from('filtered') + ->filter([Query::greaterThan('score', 80)]) + ->limit(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['premium', 80, 5], $result->bindings); + } + + public function testWindowFunctionWithJoinFilterGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['products.category']) + ->sum('sales.amount', 'total_sales') + ->selectWindow('RANK()', 'category_rank', null, ['-total_sales']) + ->join('products', 'sales.product_id', 'products.id') + ->filter([Query::greaterThan('sales.amount', 0)]) + ->groupBy(['products.category']) + ->sortAsc('category_rank') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`sales`.`amount`) AS `total_sales`, `products`.`category`, RANK() OVER (ORDER BY `total_sales` DESC) AS `category_rank` FROM `sales` JOIN `products` ON `sales`.`product_id` = `products`.`id` WHERE `sales`.`amount` > ? GROUP BY `products`.`category` ORDER BY `category_rank` ASC', $result->query); + } + + public function testSubSelectWithFilterBindingOrder(): void + { + $sub = (new Builder()) + ->from('orders') + ->count('*') + ->filter([Query::raw('orders.user_id = users.id'), Query::equal('orders.status', ['paid'])]); + + $result = (new Builder()) + ->from('users') + ->select(['users.name']) + ->selectSub($sub, 'paid_order_count') + ->filter([Query::equal('users.active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `users`.`name`, (SELECT COUNT(*) FROM `orders` WHERE orders.user_id = users.id AND `orders`.`status` IN (?)) AS `paid_order_count` FROM `users` WHERE `users`.`active` IN (?)', $result->query); + $this->assertSame(['paid', true], $result->bindings); + } + + public function testFilterWhereNotInSubqueryWithAdditionalFilter(): void + { + $sub = (new Builder()) + ->from('banned_users') + ->select(['id']); + + $result = (new Builder()) + ->from('comments') + ->filterWhereNotIn('user_id', $sub) + ->filter([Query::equal('approved', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `comments` WHERE `approved` IN (?) AND `user_id` NOT IN (SELECT `id` FROM `banned_users`)', $result->query); + } + + public function testNotExistsSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('refunds') + ->select(['id']) + ->filter([Query::raw('refunds.order_id = orders.id')]); + + $result = (new Builder()) + ->from('orders') + ->filter([Query::equal('status', ['completed'])]) + ->filterNotExists($sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `status` IN (?) AND NOT EXISTS (SELECT `id` FROM `refunds` WHERE refunds.order_id = orders.id)', $result->query); + $this->assertSame(['completed'], $result->bindings); + } + + public function testCountWhenWithFilterAndGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'paid_count', 'paid') + ->countWhen('status = ?', 'pending_count', 'pending') + ->sum('total', 'revenue') + ->groupBy(['region']) + ->filter([Query::greaterThan('total', 0)]) + ->having([Query::greaterThan('paid_count', 1)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(`total`) AS `revenue`, COUNT(CASE WHEN status = ? THEN 1 END) AS `paid_count`, COUNT(CASE WHEN status = ? THEN 1 END) AS `pending_count` FROM `orders` WHERE `total` > ? GROUP BY `region` HAVING `paid_count` > ?', $result->query); + } + + public function testSumWhenConditionalAggregate(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('total', 'status = ?', 'paid_revenue', 'paid') + ->sumWhen('total', 'status = ?', 'refunded_amount', 'refunded') + ->groupBy(['region']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(CASE WHEN status = ? THEN `total` END) AS `paid_revenue`, SUM(CASE WHEN status = ? THEN `total` END) AS `refunded_amount` FROM `orders` GROUP BY `region`', $result->query); + } + + public function testForShareLockWithJoin(): void + { + $result = (new Builder()) + ->from('accounts') + ->join('users', 'accounts.user_id', 'users.id') + ->filter([Query::equal('users.status', ['active'])]) + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `accounts` JOIN `users` ON `accounts`.`user_id` = `users`.`id` WHERE `users`.`status` IN (?) FOR SHARE', $result->query); + } + + public function testForUpdateSkipLockedWithFilter(): void + { + $result = (new Builder()) + ->from('jobs') + ->filter([Query::equal('status', ['pending'])]) + ->sortAsc('created_at') + ->limit(1) + ->forUpdateSkipLocked() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `jobs` WHERE `status` IN (?) ORDER BY `created_at` ASC LIMIT ? FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testBeforeBuildCallbackModifiesQuery(): void + { + $result = (new Builder()) + ->from('t') + ->beforeBuild(function (Builder $b) { + $b->filter([Query::equal('injected', [true])]); + }) + ->filter([Query::equal('status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE `status` IN (?) AND `injected` IN (?)', $result->query); + } + + public function testAfterBuildCallbackTransformsResult(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->afterBuild(function (Statement $r) { + return new Statement( + '/* traced */ ' . $r->query, + $r->bindings, + $r->readOnly, + ); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('/* traced */ SELECT', $result->query); + } + + public function testJsonSetAppendAndUpdate(): void + { + $result = (new Builder()) + ->from('documents') + ->setJsonAppend('tags', ['newTag']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `documents` SET `tags` = JSON_MERGE_PRESERVE(IFNULL(`tags`, JSON_ARRAY()), ?) WHERE `id` IN (?)', $result->query); + } + + public function testJsonSetPrependAndUpdate(): void + { + $result = (new Builder()) + ->from('documents') + ->setJsonPrepend('tags', ['firstTag']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `documents` SET `tags` = JSON_MERGE_PRESERVE(?, IFNULL(`tags`, JSON_ARRAY())) WHERE `id` IN (?)', $result->query); + } + + public function testJsonSetRemoveAndUpdate(): void + { + $result = (new Builder()) + ->from('documents') + ->setJsonRemove('tags', 'oldTag') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `documents` SET `tags` = JSON_REMOVE(`tags`, JSON_UNQUOTE(JSON_SEARCH(`tags`, \'one\', ?))) WHERE `id` IN (?)', $result->query); + } + + public function testUpdateWithCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('priority', Operator::Equal, 'high', 1) + ->when('priority', Operator::Equal, 'medium', 2) + ->else(3); + + $result = (new Builder()) + ->from('tasks') + ->setCase('sort_order', $case) + ->filter([Query::isNotNull('priority')]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `tasks` SET `sort_order` = CASE WHEN `priority` = ? THEN ? WHEN `priority` = ? THEN ? ELSE ? END WHERE `priority` IS NOT NULL', $result->query); + $this->assertSame(['high', 1, 'medium', 2, 3], $result->bindings); + } + + public function testLeftLateralJoinWithFilters(): void + { + $lateral = (new Builder()) + ->from('scores') + ->select(['value']) + ->filter([Query::raw('scores.player_id = players.id')]) + ->sortDesc('value') + ->limit(1); + + $result = (new Builder()) + ->from('players') + ->select(['players.name', 'top_score.value']) + ->leftJoinLateral($lateral, 'top_score') + ->filter([Query::equal('players.active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `players`.`name`, `top_score`.`value` FROM `players` LEFT JOIN LATERAL (SELECT `value` FROM `scores` WHERE scores.player_id = players.id ORDER BY `value` DESC LIMIT ?) AS `top_score` ON true WHERE `players`.`active` IN (?)', $result->query); + } + + public function testCteWithWindowFunction(): void + { + $cte = (new Builder()) + ->from('sales') + ->select(['region', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', ['region'], ['-amount']); + + $result = (new Builder()) + ->with('ranked_sales', $cte) + ->from('ranked_sales') + ->filter([Query::equal('rn', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `ranked_sales` AS (SELECT `region`, `amount`, ROW_NUMBER() OVER (PARTITION BY `region` ORDER BY `amount` DESC) AS `rn` FROM `sales`) SELECT * FROM `ranked_sales` WHERE `rn` IN (?)', $result->query); + } + + public function testComplexBindingOrderCteFilterHookSubqueryHavingLimitUnion(): void + { + $cte = (new Builder()) + ->from('source') + ->filter([Query::equal('type', ['A'])]); + + $sub = (new Builder()) + ->from('exclude') + ->select(['id']) + ->filter([Query::equal('reason', ['banned'])]); + + $unionQuery = (new Builder()) + ->from('archive') + ->filter([Query::equal('year', [2023])]); + + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('org_id = ?', ['org1']); + } + }; + + $result = (new Builder()) + ->with('cte_source', $cte) + ->from('cte_source') + ->count('*', 'cnt') + ->addHook($hook) + ->filter([Query::greaterThan('score', 50)]) + ->filterWhereNotIn('id', $sub) + ->groupBy(['category']) + ->having([Query::greaterThan('cnt', 2)]) + ->limit(10) + ->union($unionQuery) + ->build(); + $this->assertBindingCount($result); + + $expectedBindings = ['A', 50, 'org1', 'banned', 2, 10, 2023]; + $this->assertSame($expectedBindings, $result->bindings); + } + + public function testSearchExactPhraseMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('content', '"exact phrase"')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('"exact phrase"', $result->bindings[0]); + /** @var string $firstBinding */ + $firstBinding = $result->bindings[0]; + $this->assertStringNotContainsString('*', $firstBinding); + } + + public function testNotSearchEmptyString(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', '')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE 1 = 1', $result->query); + } + + public function testSearchExactPhraseInExactMode(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('title', '"hello world"')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['"hello world"'], $result->bindings); + } + + public function testUpsertSelectWithFilteredSource(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('verified', [true])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name', 'email'], $source) + ->onConflict(['id'], ['name', 'email']) + ->upsertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`id`, `name`, `email`) SELECT `id`, `name`, `email` FROM `staging` WHERE `verified` IN (?) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `email` = VALUES(`email`)', $result->query); + $this->assertSame([true], $result->bindings); + } + + public function testExplainAnalyzeWithFormatJson(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('status', ['active'])]) + ->explain(true, 'JSON'); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('EXPLAIN ANALYZE FORMAT=JSON SELECT', $result->query); + $this->assertTrue($result->readOnly); + } + + public function testMultipleSelectRawExpressions(): void + { + $result = (new Builder()) + ->from('t') + ->select('NOW() AS current_time') + ->select('CONCAT(first_name, ?, last_name) AS full_name', [' ']) + ->select('? AS constant_val', [42]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT NOW() AS current_time, CONCAT(first_name, ?, last_name) AS full_name, ? AS constant_val FROM `t`', $result->query); + $this->assertSame([' ', 42], $result->bindings); + } + + public function testFromSubqueryWithAggregation(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('total', 'user_total') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'user_totals') + ->avg('user_total', 'avg_user_spend') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT AVG(`user_total`) AS `avg_user_spend` FROM (SELECT SUM(`total`) AS `user_total`, `user_id` FROM `orders` GROUP BY `user_id`) AS `user_totals`', $result->query); + } + + public function testMultipleWhereInSubqueriesOnDifferentColumns(): void + { + $sub1 = (new Builder())->from('vip_users')->select(['id']); + $sub2 = (new Builder())->from('active_products')->select(['id']); + + $result = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $sub1) + ->filterWhereIn('product_id', $sub2) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `user_id` IN (SELECT `id` FROM `vip_users`) AND `product_id` IN (SELECT `id` FROM `active_products`)', $result->query); + } + + public function testExistsAndNotExistsCombined(): void + { + $existsSub = (new Builder()) + ->from('orders') + ->select('1') + ->filter([Query::raw('orders.user_id = users.id')]); + + $notExistsSub = (new Builder()) + ->from('bans') + ->select('1') + ->filter([Query::raw('bans.user_id = users.id')]); + + $result = (new Builder()) + ->from('users') + ->filterExists($existsSub) + ->filterNotExists($notExistsSub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE EXISTS (SELECT 1 FROM `orders` WHERE orders.user_id = users.id) AND NOT EXISTS (SELECT 1 FROM `bans` WHERE bans.user_id = users.id)', $result->query); + } + + public function testCteWithDeleteJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteJoin('o', 'expired_users', 'o.user_id', 'expired_users.id') + ->filter([Query::lessThan('o.created_at', '2023-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE `o` FROM `orders` AS `o` JOIN `expired_users` ON `o`.`user_id` = `expired_users`.`id` WHERE `o`.`created_at` < ?', $result->query); + $this->assertSame(['2023-01-01'], $result->bindings); + } + + public function testUpdateJoinWithAliasAndFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->updateJoin('users', 'orders.user_id', 'u.id', 'u') + ->set(['orders.discount' => 10]) + ->filter([Query::equal('u.tier', ['gold'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `orders` JOIN `users` AS `u` ON `orders`.`user_id` = `u`.`id` SET `orders`.`discount` = ? WHERE `u`.`tier` IN (?)', $result->query); + } + + public function testJsonContainsFilter(): void + { + $result = (new Builder()) + ->from('documents') + ->filterJsonContains('tags', ['php', 'mysql']) + ->filter([Query::equal('active', [true])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `documents` WHERE JSON_CONTAINS(`tags`, ?) AND `active` IN (?)', $result->query); + } + + public function testJsonPathFilter(): void + { + $result = (new Builder()) + ->from('config') + ->filterJsonPath('settings', 'theme.color', '=', 'blue') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `config` WHERE JSON_EXTRACT(`settings`, \'$.theme.color\') = ?', $result->query); + $this->assertSame(['blue'], $result->bindings); + } + + public function testCloneIndependenceWithWhereInSubquery(): void + { + $sub = (new Builder())->from('vips')->select(['id']); + + $original = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $sub); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('total', 100)]); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertStringNotContainsString('`total`', $originalResult->query); + $this->assertSame('SELECT * FROM `orders` WHERE `total` > ? AND `user_id` IN (SELECT `id` FROM `vips`)', $clonedResult->query); + } + + public function testCteWithJoinAndConditionProvider(): void + { + $cte = (new Builder()) + ->from('monthly_sales') + ->filter([Query::greaterThan('month', 6)]); + + $hook = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('region = ?', ['US']); + } + }; + + $result = (new Builder()) + ->with('recent_sales', $cte) + ->from('recent_sales') + ->addHook($hook) + ->join('products', 'recent_sales.product_id', 'products.id') + ->sum('recent_sales.amount', 'total') + ->groupBy(['products.category']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `recent_sales` AS (SELECT * FROM `monthly_sales` WHERE `month` > ?) SELECT SUM(`recent_sales`.`amount`) AS `total` FROM `recent_sales` JOIN `products` ON `recent_sales`.`product_id` = `products`.`id` WHERE region = ? GROUP BY `products`.`category`', $result->query); + $this->assertSame([6, 'US'], $result->bindings); + } + + public function testEndsWithWithUnderscoreWildcard(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('code', '_test')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['%\_test'], $result->bindings); + } + + public function testStartsWithWithPercentWildcard(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('label', '50%')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['50\%%'], $result->bindings); + } + + public function testNotBetweenCombinedWithOrFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::notBetween('age', 18, 65), + Query::equal('status', ['exempt']), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `t` WHERE (`age` NOT BETWEEN ? AND ? OR `status` IN (?))', + $result->query + ); + $this->assertSame([18, 65, 'exempt'], $result->bindings); + } + + public function testUpdateWithMultipleRawSets(): void + { + $result = (new Builder()) + ->from('users') + ->set(['name' => 'Updated']) + ->setRaw('login_count', 'login_count + 1') + ->setRaw('last_login', 'NOW()') + ->filter([Query::equal('id', [42])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `users` SET `name` = ?, `login_count` = login_count + 1, `last_login` = NOW() WHERE `id` IN (?)', $result->query); + } + + public function testInsertWithNullValues(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'bio' => null, 'age' => null]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `bio`, `age`) VALUES (?, ?, ?)', + $result->query + ); + $this->assertSame(['Alice', null, null], $result->bindings); + } + + public function testResetClearsLateralJoins(): void + { + $lateral = (new Builder())->from('sub')->limit(1); + $builder = (new Builder()) + ->from('t') + ->joinLateral($lateral, 'lat'); + $builder->build(); + + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('LATERAL', $result->query); + $this->assertSame('SELECT * FROM `fresh`', $result->query); + } + + public function testResetClearsWindowDefinitions(): void + { + $builder = (new Builder()) + ->from('t') + ->window('w', ['category']) + ->selectWindow('ROW_NUMBER()', 'rn', windowName: 'w'); + $builder->build(); + + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WINDOW', $result->query); + } + + public function testResetClearsCteDefinitions(): void + { + $cte = (new Builder())->from('source'); + $builder = (new Builder()) + ->with('src', $cte) + ->from('src'); + $builder->build(); + + $builder->reset(); + + $result = $builder->from('fresh')->build(); + $this->assertBindingCount($result); + $this->assertStringNotContainsString('WITH', $result->query); + } + + public function testComplexQueryClauseOrdering(): void + { + $cte = (new Builder()) + ->from('source') + ->filter([Query::equal('type', ['A'])]); + + $result = (new Builder()) + ->with('src', $cte) + ->from('src') + ->select(['category']) + ->count('*', 'cnt') + ->join('meta', 'src.id', 'meta.src_id') + ->filter([Query::greaterThan('score', 50)]) + ->groupBy(['category']) + ->having([Query::greaterThan('cnt', 2)]) + ->sortDesc('cnt') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $query = $result->query; + $withPos = strpos($query, 'WITH'); + $fromPos = strpos($query, 'FROM `src`'); + $this->assertNotFalse($fromPos); + $joinPos = strpos($query, 'JOIN', $fromPos); + $this->assertNotFalse($joinPos); + $wherePos = strpos($query, 'WHERE', $joinPos); + $groupPos = strpos($query, 'GROUP BY'); + $havingPos = strpos($query, 'HAVING'); + $orderPos = strpos($query, 'ORDER BY'); + $limitPos = strpos($query, 'LIMIT'); + $offsetPos = strpos($query, 'OFFSET'); + + $this->assertNotFalse($withPos); + $this->assertLessThan($fromPos, $withPos); + $this->assertLessThan($joinPos, $fromPos); + $this->assertLessThan($wherePos, $joinPos); + $this->assertLessThan($groupPos, $wherePos); + $this->assertLessThan($havingPos, $groupPos); + $this->assertLessThan($orderPos, $havingPos); + $this->assertLessThan($limitPos, $orderPos); + $this->assertLessThan($offsetPos, $limitPos); + } + + public function testFromTableAlias(): void + { + $result = (new Builder()) + ->from('users', 'u') + ->select(['u.name', 'u.email']) + ->filter([Query::equal('u.status', ['active'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `u`.`name`, `u`.`email` FROM `users` AS `u` WHERE `u`.`status` IN (?)', $result->query); + } + + public function testJoinWhereWithOnRaw(): void + { + $result = (new Builder()) + ->from('users') + ->joinWhere('orders', function (JoinBuilder $j) { + $j->on('users.id', 'orders.user_id') + ->onRaw('orders.created_at > NOW() - INTERVAL ? DAY', [30]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND orders.created_at > NOW() - INTERVAL ? DAY', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testFromNoneEmitsNoFromClause(): void + { + $result = (new Builder()) + ->fromNone() + ->selectRaw('1 + 1') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FROM', $result->query); + $this->assertSame('SELECT 1 + 1', $result->query); + } + + public function testSelectCastEmitsCastExpression(): void + { + $result = (new Builder()) + ->from('products') + ->selectCast('price', 'DECIMAL(10, 2)', 'price_decimal') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CAST(`price` AS DECIMAL(10, 2)) AS `price_decimal` FROM `products`', $result->query); + } + + public function testSelectCastAcceptsValidTypes(): void + { + foreach (['INT', 'VARCHAR(255)', 'DECIMAL(10, 2)', 'UNSIGNED INTEGER'] as $type) { + $result = (new Builder()) + ->from('t') + ->selectCast('c', $type, 'a') + ->build(); + $this->assertStringContainsString('CAST(`c` AS ' . $type . ')', $result->query); + } + } + + public function testSelectCastRejectsInvalidType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new Builder()) + ->from('t') + ->selectCast('c', 'INT); DROP TABLE x;--', 'a'); + } + + public function testSelectWindowAcceptsValidFunctions(): void + { + foreach (['ROW_NUMBER()', 'RANK()', 'SUM(amount)', 'LAG(x, 1, 0)'] as $function) { + $builder = (new Builder()) + ->from('t') + ->selectWindow($function, 'w', ['cat'], ['price']); + $this->assertInstanceOf(Builder::class, $builder); + } + } + + public function testSelectWindowRejectsInvalidFunction(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()); DROP --', 'w'); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $stripped = \preg_replace('/`[^`]+`/', '', $result->query) ?? ''; + // Lowercase reserved word must not appear bare outside quotes + $this->assertDoesNotMatchRegularExpression( + '/(?from($word) + ->build(); + + $this->assertStringContainsString('FROM `' . $word . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testWhereColumnEmitsQualifiedIdentifiers(): void + { + $result = (new Builder()) + ->from('users') + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testWhereColumnRejectsUnknownOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid whereColumn operator: NOT_AN_OP'); + + (new Builder()) + ->from('users') + ->whereColumn('a', 'NOT_AN_OP', 'b'); + } + + public function testWhereColumnCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertContains('active', $result->bindings); + } + +} diff --git a/tests/Query/Builder/PostgreSQLTest.php b/tests/Query/Builder/PostgreSQLTest.php new file mode 100644 index 0000000..34f6b37 --- /dev/null +++ b/tests/Query/Builder/PostgreSQLTest.php @@ -0,0 +1,6458 @@ +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->assertSame('SELECT "a", "b", "c" FROM "t"', $result->query); + } + + public function testFromWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('my_table') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame( + '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->assertSame('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->assertSame('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->assertSame( + '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->assertSame('SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "status" HAVING COUNT(*) > ?', $result->query); + } + + public function testDistinctWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->select(['status']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM "t" WHERE "deleted" IS NULL', $result->query); + } + + public function testRandomUsesRandomFunction(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame('SELECT * FROM "t" WHERE "slug" ~ ?', $result->query); + $this->assertSame(['^test'], $result->bindings); + } + + public function testSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', 'hello')]) + ->build(); + $this->assertBindingCount($result); + + $expected = "SELECT * FROM \"t\" WHERE to_tsvector(regexp_replace(\"body\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)"; + $this->assertSame($expected, $result->query); + $this->assertSame(['hello'], $result->bindings); + } + + public function testNotSearchUsesToTsvector(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', 'spam')]) + ->build(); + $this->assertBindingCount($result); + + $expected = "SELECT * FROM \"t\" WHERE NOT (to_tsvector(regexp_replace(\"body\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?))"; + $this->assertSame($expected, $result->query); + $this->assertSame(['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->assertSame( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email"', + $result->query + ); + $this->assertSame([1, 'Alice', 'alice@example.com'], $result->bindings); + } + + public function testOffsetWithoutLimitEmitsOffset(): void + { + $result = (new Builder()) + ->from('t') + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" OFFSET ?', $result->query); + $this->assertSame([10], $result->bindings); + } + + public function testOffsetWithLimitEmitsBoth(): void + { + $result = (new Builder()) + ->from('t') + ->limit(25) + ->offset(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" LIMIT ? OFFSET ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM "t" WHERE raw_condition = 1', $result->query); + } + + public function testInsertWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO "users" ("name", "age") VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'UPDATE "users" SET "name" = ? WHERE "id" IN (?)', + $result->query + ); + $this->assertSame(['Bob', 1], $result->bindings); + } + + public function testDeleteWrapsWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'DELETE FROM "users" WHERE "id" IN (?)', + $result->query + ); + $this->assertSame([1], $result->bindings); + } + + public function testSavepointWrapsWithDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertSame('SAVEPOINT "sp1"', $result->query); + } + + public function testForUpdateWithDoubleQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" FOR UPDATE', $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->assertSame('SELECT * FROM "locations" WHERE ST_Distance(("coords"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) < ?', $result->query); + $this->assertSame('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM "zones" WHERE 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->assertSame('SELECT * FROM "zones" WHERE 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->assertSame('SELECT * FROM "roads" WHERE ST_Crosses("path", ST_GeomFromText(?, 4326))', $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->assertSame('SELECT * FROM "embeddings" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', $result->query); + $this->assertSame('[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->assertSame('SELECT * FROM "embeddings" ORDER BY ("embedding" <-> ?::vector) ASC LIMIT ?', $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->assertSame('SELECT * FROM "embeddings" ORDER BY ("embedding" <#> ?::vector) ASC LIMIT ?', $result->query); + } + + public function testVectorFilterCosine(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "embeddings" WHERE ("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->assertSame('SELECT * FROM "embeddings" WHERE ("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->assertSame('SELECT * FROM "embeddings" WHERE ("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->assertSame('SELECT * FROM "docs" WHERE "tags" @> ?::jsonb', $result->query); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', 'old') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "docs" WHERE NOT ("tags" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'go']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "docs" WHERE ("tags" @> ?::jsonb OR "tags" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonPath(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('metadata', 'level', '>', 5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "metadata"->>\'level\' > ?', $result->query); + $this->assertSame(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->assertSame('UPDATE "docs" SET "tags" = COALESCE("tags", \'[]\'::jsonb) || ?::jsonb WHERE "id" IN (?)', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "docs" SET "tags" = ?::jsonb || COALESCE("tags", \'[]\'::jsonb) WHERE "id" IN (?)', $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->assertSame('UPDATE "docs" SET "tags" = jsonb_insert("tags", \'{0}\', ?::jsonb) WHERE "id" IN (?)', $result->query); + } + + public function testSetJsonPath(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPath('data', '$.name', 'NewValue') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE "docs" SET "data" = jsonb_set("data", ?, to_jsonb(?::text)::jsonb, true) WHERE "id" IN (?)', + $result->query + ); + $this->assertSame('{name}', $result->bindings[0]); + $this->assertSame('NewValue', $result->bindings[1]); + $this->assertSame(1, $result->bindings[2]); + } + + public function testSetJsonPathNested(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPath('data', '$.profile.name', 'Alice') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "docs" SET "data" = jsonb_set("data", ?, to_jsonb(?::text)::jsonb, true) WHERE "id" IN (?)', $result->query); + $this->assertSame('{profile,name}', $result->bindings[0]); + $this->assertSame('Alice', $result->bindings[1]); + } + + public function testSetJsonPathRejectsInvalidPath(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('docs') + ->setJsonPath('data', 'name', 'NewValue'); + } + // 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->assertSame('SELECT ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "created_at" ASC) AS "rn" FROM "orders"', $result->query); + } + + public function testSelectWindowRankDesc(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rank', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT RANK() OVER (ORDER BY "score" DESC) AS "rank" FROM "scores"', $result->query); + } + // CASE integration + + public function testSelectCaseExpression(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->else('Other') + ->alias('label'); + + $result = (new Builder()) + ->from('users') + ->select(['id']) + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT "id", CASE WHEN "status" = ? THEN ? ELSE ? END AS "label" FROM "users"', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM "zones" WHERE NOT ST_Intersects("zone", ST_GeomFromText(?, 4326))', $result->query); + $this->assertSame('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->assertSame('SELECT * FROM "roads" WHERE NOT ST_Crosses("path", ST_GeomFromText(?, 4326))', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('LINESTRING(0 0, 1 1)', $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->assertSame('SELECT * FROM "maps" WHERE ST_Overlaps("area", ST_GeomFromText(?, 4326))', $result->query); + /** @var string $binding */ + $binding = $result->bindings[0]; + $this->assertSame('POLYGON((0 0, 1 0, 1 1, 0 0))', $binding); + } + + public function testFilterNotOverlaps(): void + { + $result = (new Builder()) + ->from('maps') + ->filterNotOverlaps('area', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "maps" WHERE NOT ST_Overlaps("area", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "zones" WHERE ST_Touches("zone", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterNotTouches(): void + { + $result = (new Builder()) + ->from('zones') + ->filterNotTouches('zone', [5.0, 10.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "zones" WHERE NOT ST_Touches("zone", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterCoversUsesSTCovers(): void + { + $result = (new Builder()) + ->from('regions') + ->filterCovers('region', [1.0, 2.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "regions" WHERE ST_Covers("region", ST_GeomFromText(?, 4326))', $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->assertSame('SELECT * FROM "regions" WHERE NOT ST_Covers("region", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "geoms" WHERE ST_Equals("geom", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterNotSpatialEquals(): void + { + $result = (new Builder()) + ->from('geoms') + ->filterNotSpatialEquals('geom', [3.0, 4.0]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "geoms" WHERE NOT ST_Equals("geom", ST_GeomFromText(?, 4326))', $result->query); + } + + public function testFilterDistanceGreaterThan(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '>', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "locations" WHERE ST_Distance("loc", ST_GeomFromText(?, 4326)) > ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM "locations" WHERE ST_Distance("loc", ST_GeomFromText(?, 4326)) < ?', $result->query); + $this->assertSame('POINT(1 2)', $result->bindings[0]); + $this->assertSame(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->assertSame('SELECT * FROM "items" ORDER BY ("embedding" <=> ?::vector) ASC, "name" ASC', $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->assertSame('SELECT * FROM "items" ORDER BY ("emb" <=> ?::vector) ASC LIMIT ?', $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->assertSame('SELECT * FROM "items" ORDER BY ("emb" <=> ?::vector) ASC', $result->query); + } + + public function testVectorFilterCosineBindings(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "embeddings" WHERE ("embedding" <=> ?::vector)', $result->query); + $this->assertSame(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->assertSame('SELECT * FROM "embeddings" WHERE ("embedding" <-> ?::vector)', $result->query); + $this->assertSame(json_encode([0.1]), $result->bindings[0]); + } + + public function testFilterJsonNotContainsAdmin(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('meta', 'admin') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "docs" WHERE NOT ("meta" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonOverlapsArray(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "docs" WHERE ("tags" @> ?::jsonb OR "tags" @> ?::jsonb)', $result->query); + } + + public function testFilterJsonPathComparison(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "data"->>\'age\' >= ?', $result->query); + $this->assertSame(21, $result->bindings[0]); + } + + public function testFilterJsonPathEquality(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('meta', 'status', '=', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "meta"->>\'status\' = ?', $result->query); + $this->assertSame('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->assertSame('UPDATE "docs" SET "tags" = "tags" - ? WHERE "id" IN (?)', $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->assertSame('UPDATE "docs" SET "tags" = (SELECT jsonb_agg(elem) FROM jsonb_array_elements("tags") AS elem WHERE elem <@ ?::jsonb) WHERE "id" IN (?)', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "docs" SET "tags" = (SELECT COALESCE(jsonb_agg(elem), \'[]\'::jsonb) FROM jsonb_array_elements("tags") AS elem WHERE NOT elem <@ ?::jsonb) WHERE "id" IN (?)', $result->query); + } + + public function testSetJsonUnique(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonUnique('tags') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "docs" SET "tags" = (SELECT jsonb_agg(DISTINCT elem) FROM jsonb_array_elements("tags") AS elem) WHERE "id" IN (?)', $result->query); + } + + public function testSetJsonAppendBindings(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "docs" SET "tags" = COALESCE("tags", \'[]\'::jsonb) || ?::jsonb WHERE "id" IN (?)', $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->assertSame('UPDATE "docs" SET "items" = ?::jsonb || COALESCE("items", \'[]\'::jsonb) WHERE "id" IN (?)', $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->assertSame('WITH "a" AS (SELECT * FROM "x" WHERE "status" IN (?)), "b" AS (SELECT * FROM "y" WHERE "type" IN (?)) SELECT * FROM "a"', $result->query); + } + + public function testCTEWithRecursive(): void + { + $sub = (new Builder())->from('categories'); + + $result = (new Builder()) + ->withRecursive('tree', $sub) + ->from('tree') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH RECURSIVE "tree" AS (SELECT * FROM "categories") SELECT * FROM "tree"', $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->assertSame('shipped', $result->bindings[0]); + $this->assertSame(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->assertSame('INSERT INTO "big_orders" ("customer_id", "total") SELECT "customer_id", "total" FROM "orders" WHERE "total" > ?', $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->assertSame('(SELECT * FROM "a") UNION ALL (SELECT * FROM "b")', $result->query); + } + + public function testIntersect(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->intersect($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM "a") INTERSECT (SELECT * FROM "b")', $result->query); + } + + public function testExcept(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->except($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('(SELECT * FROM "a") EXCEPT (SELECT * FROM "b")', $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->assertSame('alpha', $result->bindings[0]); + $this->assertSame('beta', $result->bindings[1]); + } + + public function testPage(): void + { + $result = (new Builder()) + ->from('items') + ->page(3, 10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "items" LIMIT ? OFFSET ?', $result->query); + $this->assertSame(10, $result->bindings[0]); + $this->assertSame(20, $result->bindings[1]); + } + + public function testOffsetWithoutLimitEmitsOffsetPostgres(): void + { + $result = (new Builder()) + ->from('items') + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "items" OFFSET ?', $result->query); + $this->assertSame([5], $result->bindings); + } + + public function testCursorAfter(): void + { + $result = (new Builder()) + ->from('items') + ->sortAsc('id') + ->cursorAfter(5) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "items" WHERE "_cursor" > ? ORDER BY "id" ASC LIMIT ?', $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->assertSame('SELECT * FROM "items" WHERE "_cursor" < ? ORDER BY "id" ASC LIMIT ?', $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->assertSame('SELECT SUM("salary") OVER (PARTITION BY "dept") AS "dept_total" FROM "employees"', $result->query); + } + + public function testSelectWindowNoPartitionNoOrder(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('COUNT(*)', 'total', null, null) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) OVER () AS "total" FROM "employees"', $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->assertSame('SELECT ROW_NUMBER() OVER (ORDER BY "id" ASC) AS "rn", RANK() OVER (ORDER BY "score" DESC) AS "rnk" FROM "scores"', $result->query); + } + + public function testWindowFunctionWithDescOrder(): void + { + $result = (new Builder()) + ->from('scores') + ->selectWindow('RANK()', 'rnk', null, ['-score']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT RANK() OVER (ORDER BY "score" DESC) AS "rnk" FROM "scores"', $result->query); + } + + public function testCaseMultipleWhens(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'pending', 'Pending') + ->when('status', Operator::Equal, 'closed', 'Closed') + ->alias('label'); + + $result = (new Builder()) + ->from('tickets') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CASE WHEN "status" = ? THEN ? WHEN "status" = ? THEN ? WHEN "status" = ? THEN ? END AS "label" FROM "tickets"', $result->query); + $this->assertSame(['active', 'Active', 'pending', 'Pending', 'closed', 'Closed'], $result->bindings); + } + + public function testCaseWithoutElse(): void + { + $case = (new CaseExpression()) + ->when('active', Operator::Equal, 1, 'Yes') + ->alias('lbl'); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CASE WHEN "active" = ? THEN ? END AS "lbl" FROM "users"', $result->query); + $this->assertStringNotContainsString('ELSE', $result->query); + } + + public function testSetCaseInUpdate(): void + { + $case = (new CaseExpression()) + ->when('age', Operator::GreaterThanEqual, 18, 'adult') + ->else('minor'); + + $result = (new Builder()) + ->from('users') + ->setCase('category', $case) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "users" SET "category" = CASE WHEN "age" >= ? THEN ? ELSE ? END WHERE "id" IN (?)', $result->query); + $this->assertSame([18, 'adult', 'minor', 1], $result->bindings); + } + + public function testToRawSqlWithStrings(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ['Alice'])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM "users" WHERE "name" IN (\'Alice\')', $raw); + $this->assertStringNotContainsString('?', $raw); + } + + public function testToRawSqlEscapesSingleQuotes(): void + { + $raw = (new Builder()) + ->from('users') + ->filter([Query::equal('name', ["O'Brien"])]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM "users" WHERE "name" IN (\'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->assertSame('INSERT INTO "users" ("name", "age") VALUES (?, ?), (?, ?)', $result->query); + $this->assertSame(['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->assertSame('SELECT * FROM "items" WHERE "s" ~ ?', $result->query); + $this->assertSame(['^t'], $result->bindings); + } + + public function testSearchUsesToTsvectorWithMultipleWords(): void + { + $result = (new Builder()) + ->from('articles') + ->filter([Query::search('body', 'hello world')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame("SELECT * FROM \"articles\" WHERE to_tsvector(regexp_replace(\"body\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)", $result->query); + $this->assertSame(['hello or 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->assertSame('INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name"', $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->assertSame('SELECT * FROM "accounts" FOR UPDATE', $result->query); + } + + public function testForShareLocking(): void + { + $result = (new Builder()) + ->from('accounts') + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "accounts" FOR SHARE', $result->query); + } + + public function testBeginCommitRollback(): void + { + $builder = new Builder(); + + $begin = $builder->begin(); + $this->assertSame('BEGIN', $begin->query); + + $commit = $builder->commit(); + $this->assertSame('COMMIT', $commit->query); + + $rollback = $builder->rollback(); + $this->assertSame('ROLLBACK', $rollback->query); + } + + public function testSavepointDoubleQuotes(): void + { + $result = (new Builder())->savepoint('sp1'); + + $this->assertSame('SAVEPOINT "sp1"', $result->query); + } + + public function testReleaseSavepointDoubleQuotes(): void + { + $result = (new Builder())->releaseSavepoint('sp1'); + + $this->assertSame('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->assertSame('SELECT COUNT(*) AS "cnt" FROM "orders" GROUP BY "customer_id" HAVING COUNT(*) > ?', $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->assertSame('SELECT COUNT(*) AS "cnt" FROM "sales" 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->assertSame('SELECT * FROM "items" 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->assertSame('SELECT * FROM "t" WHERE 1 = 0', $result->query); + } + + public function testEqualWithNullOnly(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('x', [null])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "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->assertSame('SELECT * FROM "t" WHERE ("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->assertSame('SELECT * FROM "t" WHERE ("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->assertSame('SELECT * FROM "t" WHERE ("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->assertSame('SELECT * FROM "t" WHERE ("role" IN (?) OR "role" IN (?))', $result->query); + } + + public function testEmptyAndReturnsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::and([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE 1 = 1', $result->query); + } + + public function testEmptyOrReturnsFalse(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::or([])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE 1 = 0', $result->query); + } + + public function testBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::between('age', 18, 65)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "age" BETWEEN ? AND ?', $result->query); + $this->assertSame([18, 65], $result->bindings); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notBetween('score', 0, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "score" NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([0, 50], $result->bindings); + } + + public function testExistsSingleAttribute(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::exists(['name'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("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->assertSame('SELECT * FROM "t" WHERE ("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->assertSame('SELECT * FROM "t" WHERE ("name" IS NULL)', $result->query); + } + + public function testRawFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score > ?', [10])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE 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->assertSame('SELECT * FROM "t" WHERE 1 = 1', $result->query); + } + + public function testStartsWithEscapesPercent(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::startsWith('val', '100%')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "val" ILIKE ?', $result->query); + $this->assertSame(['100\%%'], $result->bindings); + } + + public function testEndsWithEscapesUnderscore(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::endsWith('val', 'a_b')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "val" ILIKE ?', $result->query); + $this->assertSame(['%a\_b'], $result->bindings); + } + + public function testContainsEscapesBackslash(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('path', ['a\\b'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "path" ILIKE ?', $result->query); + $this->assertSame(['%a\\\\b%'], $result->bindings); + } + + public function testContainsMultipleUsesOr(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsString('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("bio" ILIKE ? OR "bio" ILIKE ?)', $result->query); + } + + public function testContainsAllUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::containsAll('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("bio" ILIKE ? AND "bio" ILIKE ?)', $result->query); + } + + public function testNotContainsMultipleUsesAnd(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notContains('bio', ['foo', 'bar'])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("bio" NOT ILIKE ? AND "bio" NOT ILIKE ?)', $result->query); + } + + public function testDottedIdentifier(): void + { + $result = (new Builder()) + ->from('t') + ->select(['users.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT "users"."name" FROM "t"', $result->query); + } + + public function testMultipleOrderBy(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('name') + ->sortDesc('age') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" 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->assertSame('SELECT DISTINCT "name" FROM "t"', $result->query); + } + + public function testSumWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM("amount") AS "total" FROM "t"', $result->query); + } + + public function testMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS "cnt", SUM("amount") AS "total" FROM "t"', $result->query); + } + + public function testCountWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) FROM "t"', $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->assertSame('SELECT * FROM "a" 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->assertSame('SELECT * FROM "a" 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->assertSame('SELECT * FROM "t" WHERE "deleted_at" IS NULL', $result->query); + } + + public function testIsNotNullFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::isNotNull('name')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "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->assertSame('SELECT * FROM "t" WHERE "age" < ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testLessThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::lessThanEqual('age', 30)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "age" <= ?', $result->query); + $this->assertSame([30], $result->bindings); + } + + public function testGreaterThan(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThan('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "score" > ?', $result->query); + $this->assertSame([50], $result->bindings); + } + + public function testGreaterThanEqual(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::greaterThanEqual('score', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "score" >= ?', $result->query); + $this->assertSame([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->assertSame('DELETE FROM "t" WHERE "status" IN (?) ORDER BY "id" ASC 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->assertSame('UPDATE "t" SET "status" = ? WHERE "active" IN (?) ORDER BY "id" ASC 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->assertSame('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->assertSame( + 'INSERT INTO "users" ("name", "email") VALUES (?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertSame(['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->assertSame('INSERT INTO "users" ("name") VALUES (?) RETURNING "id", "name"', $result->query); + } + + public function testInsertReturningAll(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning() + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO "users" ("name") VALUES (?) 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->assertSame('UPDATE "users" SET "name" = ? WHERE "id" IN (?) 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->assertSame('DELETE FROM "users" WHERE "id" IN (?) 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->assertSame('INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email" RETURNING "id"', $result->query); + } + + public function testInsertOrIgnoreReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->returning(['id']) + ->insertOrIgnore(); + + $this->assertSame('INSERT INTO "users" ("name") VALUES (?) 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->assertSame('SELECT * FROM "users" FOR UPDATE OF "users"', $result->query); + } + + public function testForShareOf(): void + { + $result = (new Builder()) + ->from('users') + ->forShareOf('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" 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->assertSame('SELECT * 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->assertSame('SELECT * FROM "users" AS "u" 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->assertSame( + '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->assertSame( + '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->assertSame('SELECT * FROM "users" FOR UPDATE SKIP LOCKED', $result->query); + } + + public function testForUpdateNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forUpdateNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" 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->assertSame(['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->assertSame('SELECT * FROM "users" WHERE NOT EXISTS (SELECT "id" FROM "bans")', $result->query); + } + + // Raw clauses (PostgreSQL) + + public function testOrderByRawPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->orderByRaw('NULLS LAST') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" 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->assertSame('SELECT COUNT(*) AS "cnt" FROM "events" GROUP BY date_trunc(?, "created_at")', $result->query); + $this->assertSame(['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->assertSame('SELECT COUNT(*) AS "cnt" FROM "orders" GROUP BY "user_id" HAVING SUM("amount") > ?', $result->query); + } + + public function testWhereRawAppendsFragmentAndBindings(): void + { + $result = (new Builder()) + ->from('users') + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE a = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testWhereRawCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('b', [2])]) + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "b" IN (?) AND a = ?', $result->query); + $this->assertContains(1, $result->bindings); + $this->assertContains(2, $result->bindings); + } + + // 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->assertSame('SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" AND orders.amount > ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + // Insert or ignore (PostgreSQL) + + public function testInsertOrIgnorePostgreSQL(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John']) + ->insertOrIgnore(); + + $this->assertSame('INSERT INTO "users" ("name") VALUES (?) 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->assertSame('INSERT INTO "users" ("name") VALUES (?) 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->assertSame('SELECT * FROM "users" WHERE "id" IN (?) 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->assertSame('SELECT * 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->assertSame('SELECT COUNT(DISTINCT "email") FROM "users"', $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->assertSame('SELECT * FROM "users" WHERE EXISTS (SELECT "id" FROM "orders") AND NOT EXISTS (SELECT "id" FROM "payments")', $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->assertSame('SELECT * FROM "users" AS "u" LEFT JOIN "orders" AS "o" ON "u"."id" = "o"."user_id"', $result->query); + } + + // Cross join alias PostgreSQL + + public function testCrossJoinAliasPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->crossJoin('roles', 'r') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" 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->assertSame('SELECT * FROM "users" FOR SHARE SKIP LOCKED', $result->query); + } + + public function testForShareNoWaitPostgreSQL(): void + { + $result = (new Builder()) + ->from('users') + ->forShareNoWait() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" 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->assertSame(['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->assertSame([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->assertSame([], $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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame([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->assertSame([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->assertSame([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->assertSame([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->assertSame(['[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->assertSame(['[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->assertSame(['"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" @> ?::jsonb OR "tags" @> ?::jsonb)', + $result->query + ); + $this->assertSame(['"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->assertSame(['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->assertSame([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->assertSame([], $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->assertSame([], $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->assertSame([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->assertSame(['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 COUNT(*) > ?', + $result->query + ); + $this->assertSame([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->assertSame([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->assertSame([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->assertSame(['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->assertSame([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->assertSame(['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->assertSame([], $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->assertSame(['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->assertSame([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->assertSame(['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->assertSame([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->assertSame([], $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->assertSame([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->assertSame([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->assertSame([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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['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->assertSame(['["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->assertSame(['["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->assertSame(['"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->assertSame(['"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->assertSame(['["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->assertSame(['["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->assertSame([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->assertSame([], $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->assertSame([], $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->assertSame([], $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->assertSame(['published', '[0.1,0.2,0.3]', 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testSearchEmptyTermReturnsNoMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', ' ')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE 1 = 0', $result->query); + } + + public function testNotSearchEmptyTermReturnsAllMatch(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notSearch('body', ' ')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE 1 = 1', $result->query); + } + + public function testSearchExactTermWrapsInQuotes(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', '"exact phrase"')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame("SELECT * FROM \"t\" WHERE to_tsvector(regexp_replace(\"body\", '[^\\w]+', ' ', 'g')) @@ websearch_to_tsquery(?)", $result->query); + $this->assertSame(['"exact phrase"'], $result->bindings); + } + + public function testSearchSpecialCharsAreSanitized(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::search('body', '@+hello-world*')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(['hello or world'], $result->bindings); + } + + public function testUpsertConflictSetRawWithBindings(): void + { + $result = (new Builder()) + ->from('counters') + ->set(['id' => 'views', 'count' => 1]) + ->onConflict(['id'], ['count']) + ->conflictSetRaw('count', '"counters"."count" + ?', [1]) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO "counters" ("id", "count") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "count" = "counters"."count" + ?', $result->query); + } + + public function testTableSampleBernoulli(): void + { + $result = (new Builder()) + ->from('users') + ->tablesample(10.0, 'BERNOULLI') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" TABLESAMPLE BERNOULLI(10)', $result->query); + } + + public function testTableSampleSystem(): void + { + $result = (new Builder()) + ->from('users') + ->tablesample(25.0, 'system') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" TABLESAMPLE SYSTEM(25)', $result->query); + } + + public function testImplementsTableSampling(): void + { + $this->assertInstanceOf(TableSampling::class, new Builder()); + } + + public function testTableSampleRejectsUnknownMethod(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid TABLESAMPLE method'); + + (new Builder()) + ->from('users') + ->tablesample(10.0, 'DROP TABLE'); + } + + public function testExplainRejectsUnknownFormat(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid EXPLAIN format'); + + (new Builder()) + ->from('users') + ->explain(format: 'csv'); + } + + public function testImplementsConditionalAggregates(): void + { + $this->assertInstanceOf(ConditionalAggregates::class, new Builder()); + } + + public function testImplementsMerge(): void + { + $this->assertInstanceOf(Merge::class, new Builder()); + } + + public function testImplementsLateralJoins(): void + { + $this->assertInstanceOf(LateralJoins::class, new Builder()); + } + + public function testImplementsFullOuterJoins(): void + { + $this->assertInstanceOf(FullOuterJoins::class, new Builder()); + } + + public function testUpdateFromBasic(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments', 's') + ->updateFromWhere('orders.id = s.order_id') + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "shipments" AS "s" WHERE orders.id = s.order_id', $result->query); + } + + public function testUpdateFromWithWhereFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments') + ->updateFromWhere('orders.id = shipments.order_id') + ->filter([Query::equal('orders.active', [true])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "shipments" WHERE "orders"."active" IN (?) AND orders.id = shipments.order_id', $result->query); + } + + public function testUpdateFromWithBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments', 's') + ->updateFromWhere('orders.id = s.order_id AND s.region = ?', 'US') + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "shipments" AS "s" WHERE orders.id = s.order_id AND s.region = ?', $result->query); + $this->assertContains('US', $result->bindings); + } + + public function testUpdateFromWithoutAliasOrCondition(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'done']) + ->updateFrom('inventory') + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "inventory"', $result->query); + } + + public function testUpdateFromReturning(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments', 's') + ->updateFromWhere('orders.id = s.order_id') + ->returning(['orders.id']) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "shipments" AS "s" WHERE orders.id = s.order_id RETURNING "orders"."id"', $result->query); + } + + public function testUpdateFromNoAssignmentsThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('orders') + ->updateFrom('shipments') + ->update(); + } + + public function testDeleteUsingBasic(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('old_orders', 'orders.id = old_orders.id') + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM "orders" USING "old_orders" WHERE orders.id = old_orders.id', $result->query); + } + + public function testDeleteUsingWithBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('expired', 'orders.id = expired.id AND expired.reason = ?', 'timeout') + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM "orders" USING "expired" WHERE orders.id = expired.id AND expired.reason = ?', $result->query); + $this->assertContains('timeout', $result->bindings); + } + + public function testDeleteUsingWithFilterCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('expired', 'orders.id = expired.id') + ->filter([Query::equal('orders.status', ['cancelled'])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM "orders" USING "expired" WHERE "orders"."status" IN (?) AND orders.id = expired.id', $result->query); + } + + public function testDeleteUsingReturning(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('expired', 'orders.id = expired.id') + ->returning(['orders.id']) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM "orders" USING "expired" WHERE orders.id = expired.id RETURNING "orders"."id"', $result->query); + } + + public function testDeleteUsingWithoutCondition(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('old_orders', '') + ->filter([Query::equal('status', ['old'])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM "orders" USING "old_orders" WHERE "status" IN (?)', $result->query); + } + + public function testUpsertSelectReturning(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name', 'email']); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name', 'email'], $source) + ->onConflict(['id'], ['name', 'email']) + ->returning(['id']) + ->upsertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO "users" ("id", "name", "email") SELECT "id", "name", "email" FROM "staging" ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email" RETURNING "id"', $result->query); + } + + public function testCountWhenFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) AS "active_count" FROM "orders"', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testCountWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) FROM "orders"', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSumWhenFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', 'active_total', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM("amount") FILTER (WHERE status = ?) AS "active_total" FROM "orders"', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testSumWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAvgWhenFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'status = ?', 'avg_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT AVG("amount") FILTER (WHERE status = ?) AS "avg_active" FROM "orders"', $result->query); + } + + public function testAvgWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMinWhenFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->minWhen('amount', 'status = ?', 'min_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MIN("amount") FILTER (WHERE status = ?) AS "min_active" FROM "orders"', $result->query); + } + + public function testMinWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->minWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMaxWhenFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->maxWhen('amount', 'status = ?', 'max_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MAX("amount") FILTER (WHERE status = ?) AS "max_active" FROM "orders"', $result->query); + } + + public function testMaxWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->maxWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMergeIntoBasic(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name', 'email']); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name, email = src.email') + ->whenNotMatched('INSERT (id, name, email) VALUES (src.id, src.name, src.email)') + ->executeMerge(); + $this->assertBindingCount($result); + + $this->assertSame('MERGE INTO "users" USING (SELECT "id", "name", "email" FROM "staging") AS "src" ON users.id = src.id WHEN MATCHED THEN UPDATE SET name = src.name, email = src.email WHEN NOT MATCHED THEN INSERT (id, name, email) VALUES (src.id, src.name, src.email)', $result->query); + } + + public function testMergeWithBindings(): void + { + $source = (new Builder()) + ->from('staging') + ->filter([Query::equal('status', ['pending'])]); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name') + ->executeMerge(); + $this->assertBindingCount($result); + + $this->assertContains('pending', $result->bindings); + } + + public function testMergeWithConditionBindings(): void + { + $source = (new Builder())->from('staging'); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id AND src.region = ?', 'US') + ->whenMatched('UPDATE SET name = src.name') + ->executeMerge(); + $this->assertBindingCount($result); + + $this->assertContains('US', $result->bindings); + } + + public function testMergeWithClauseBindings(): void + { + $source = (new Builder())->from('staging'); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET count = users.count + ?', 1) + ->whenNotMatched('INSERT (id, count) VALUES (src.id, ?)', 1) + ->executeMerge(); + $this->assertBindingCount($result); + + $this->assertSame([1, 1], $result->bindings); + } + + public function testMergeWithoutTargetThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder())->executeMerge(); + } + + public function testMergeWithoutSourceThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->mergeInto('users') + ->executeMerge(); + } + + public function testMergeWithoutConditionThrows(): void + { + $this->expectException(ValidationException::class); + + $source = (new Builder())->from('staging'); + (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->executeMerge(); + } + + public function testJoinLateral(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['total']) + ->filter([Query::greaterThan('total', 100)]) + ->limit(5); + + $result = (new Builder()) + ->from('users') + ->joinLateral($sub, 'latest_orders') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" JOIN LATERAL (SELECT "total" FROM "orders" WHERE "total" > ? LIMIT ?) AS "latest_orders" ON true', $result->query); + } + + public function testLeftJoinLateral(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['total']) + ->limit(3); + + $result = (new Builder()) + ->from('users') + ->leftJoinLateral($sub, 'recent_orders') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" LEFT JOIN LATERAL (SELECT "total" FROM "orders" LIMIT ?) AS "recent_orders" ON true', $result->query); + } + + public function testJoinLateralWithType(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $result = (new Builder()) + ->from('users') + ->joinLateral($sub, 'o', JoinType::Left) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" LEFT JOIN LATERAL (SELECT "id" FROM "orders") AS "o" ON true', $result->query); + } + + public function testFullOuterJoin(): void + { + $result = (new Builder()) + ->from('users') + ->fullOuterJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" FULL OUTER JOIN "orders" ON "users"."id" = "orders"."user_id"', $result->query); + } + + public function testFullOuterJoinWithAlias(): void + { + $result = (new Builder()) + ->from('users') + ->fullOuterJoin('orders', 'users.id', 'o.user_id', '=', 'o') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" FULL OUTER JOIN "orders" AS "o" ON "users"."id" = "o"."user_id"', $result->query); + } + + public function testExplainVerbose(): void + { + $result = (new Builder()) + ->from('users') + ->explain(verbose: true); + + $this->assertStringStartsWith('EXPLAIN (VERBOSE) SELECT', $result->query); + } + + public function testExplainBuffers(): void + { + $result = (new Builder()) + ->from('users') + ->explain(buffers: true); + + $this->assertStringStartsWith('EXPLAIN (BUFFERS) SELECT', $result->query); + } + + public function testExplainFormat(): void + { + $result = (new Builder()) + ->from('users') + ->explain(format: 'json'); + + $this->assertStringStartsWith('EXPLAIN (FORMAT JSON) SELECT', $result->query); + } + + public function testExplainAllOptions(): void + { + $result = (new Builder()) + ->from('users') + ->explain(analyze: true, verbose: true, buffers: true, format: 'yaml'); + + $this->assertStringStartsWith('EXPLAIN (ANALYZE, VERBOSE, BUFFERS, FORMAT YAML)', $result->query); + } + + public function testObjectFilterNestedEqual(): void + { + $query = Query::equal('metadata.key', ['value']); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"->>\'key\' IN (?)', $result->query); + } + + public function testObjectFilterNestedNotEqual(): void + { + $query = Query::notEqual('metadata.key', ['a', 'b']); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"->>\'key\' NOT IN (?, ?)', $result->query); + } + + public function testObjectFilterNestedLessThan(): void + { + $query = Query::lessThan('data.score', 50); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'score\' < ?', $result->query); + } + + public function testObjectFilterNestedLessThanEqual(): void + { + $query = Query::lessThanEqual('data.score', 50); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'score\' <= ?', $result->query); + } + + public function testObjectFilterNestedGreaterThan(): void + { + $query = Query::greaterThan('data.score', 50); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'score\' > ?', $result->query); + } + + public function testObjectFilterNestedGreaterThanEqual(): void + { + $query = Query::greaterThanEqual('data.score', 50); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'score\' >= ?', $result->query); + } + + public function testObjectFilterNestedStartsWith(): void + { + $query = Query::startsWith('data.name', 'foo'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' ILIKE ?', $result->query); + } + + public function testObjectFilterNestedNotStartsWith(): void + { + $query = Query::notStartsWith('data.name', 'foo'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' NOT ILIKE ?', $result->query); + } + + public function testObjectFilterNestedEndsWith(): void + { + $query = Query::endsWith('data.name', 'bar'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' ILIKE ?', $result->query); + } + + public function testObjectFilterNestedNotEndsWith(): void + { + $query = Query::notEndsWith('data.name', 'bar'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' NOT ILIKE ?', $result->query); + } + + public function testObjectFilterNestedContains(): void + { + $query = Query::containsString('data.name', ['mid']); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' ILIKE ?', $result->query); + } + + public function testObjectFilterNestedNotContains(): void + { + $query = Query::notContains('data.name', ['mid']); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'name\' NOT ILIKE ?', $result->query); + } + + public function testObjectFilterNestedIsNull(): void + { + $query = Query::isNull('data.value'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'value\' IS NULL', $result->query); + } + + public function testObjectFilterNestedIsNotNull(): void + { + $query = Query::isNotNull('data.value'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->>\'value\' IS NOT NULL', $result->query); + } + + public function testObjectFilterTopLevelEqual(): void + { + $query = Query::equal('metadata', [['key' => 'val']]); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("metadata" @> ?::jsonb)', $result->query); + } + + public function testObjectFilterTopLevelNotEqual(): void + { + $query = Query::notEqual('metadata', [['key' => 'val']]); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE (NOT ("metadata" @> ?::jsonb))', $result->query); + } + + public function testObjectFilterTopLevelContains(): void + { + $query = Query::containsString('tags', [['key' => 'val']]); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE ("tags" @> ?::jsonb)', $result->query); + } + + public function testObjectFilterTopLevelStartsWith(): void + { + $query = Query::startsWith('metadata', 'foo'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"::text ILIKE ?', $result->query); + } + + public function testObjectFilterTopLevelNotStartsWith(): void + { + $query = Query::notStartsWith('metadata', 'foo'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"::text NOT ILIKE ?', $result->query); + } + + public function testObjectFilterTopLevelEndsWith(): void + { + $query = Query::endsWith('metadata', 'bar'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"::text ILIKE ?', $result->query); + } + + public function testObjectFilterTopLevelNotEndsWith(): void + { + $query = Query::notEndsWith('metadata', 'bar'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata"::text NOT ILIKE ?', $result->query); + } + + public function testObjectFilterTopLevelIsNull(): void + { + $query = Query::isNull('metadata'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata" IS NULL', $result->query); + } + + public function testObjectFilterTopLevelIsNotNull(): void + { + $query = Query::isNotNull('metadata'); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "metadata" IS NOT NULL', $result->query); + } + + public function testBuildJsonbPathDeepNested(): void + { + $query = Query::equal('data.level1.level2.leaf', ['val']); + $query->setAttributeType(ColumnType::Object->value); + + $result = (new Builder()) + ->from('t') + ->filter([$query]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "t" WHERE "data"->\'level1\'->\'level2\'->>\'leaf\' IN (?)', $result->query); + } + + public function testVectorFilterDefault(): void + { + $result = (new Builder()) + ->from('embeddings') + ->filter([Query::vectorCosine('embedding', [0.1, 0.2])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "embeddings" WHERE ("embedding" <=> ?::vector)', $result->query); + } + + public function testSpatialDistanceEqual(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '=', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "locations" WHERE ST_Distance("loc", ST_GeomFromText(?, 4326)) = ?', $result->query); + } + + public function testSpatialDistanceNotEqual(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('loc', [1.0, 2.0], '!=', 500.0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "locations" WHERE ST_Distance("loc", ST_GeomFromText(?, 4326)) != ?', $result->query); + } + + public function testResetClearsMergeState(): void + { + $source = (new Builder())->from('staging'); + $builder = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('DELETE') + ->whenNotMatched('INSERT (id) VALUES (src.id)'); + + $builder->reset(); + + $this->expectException(ValidationException::class); + $builder->executeMerge(); + } + + public function testResetClearsUpdateFromState(): void + { + $builder = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments', 's') + ->updateFromWhere('orders.id = s.order_id'); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->set(['status' => 'done']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FROM "shipments"', $result->query); + } + + public function testResetClearsDeleteUsingState(): void + { + $builder = (new Builder()) + ->from('orders') + ->deleteUsing('expired', 'orders.id = expired.id'); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->filter([Query::equal('id', [1])]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('USING', $result->query); + } + + public function testCteWithInsertReturning(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['id', 'customer_id']) + ->filter([Query::equal('status', ['pending'])]); + + $sourceWithCte = (new Builder()) + ->with('pending_orders', $cteQuery) + ->from('pending_orders') + ->select(['id', 'customer_id']); + + $result = (new Builder()) + ->into('archived_orders') + ->fromSelect(['id', 'customer_id'], $sourceWithCte) + ->insertSelect(); + + $this->assertSame('INSERT INTO "archived_orders" ("id", "customer_id") WITH "pending_orders" AS (SELECT "id", "customer_id" FROM "orders" WHERE "status" IN (?)) SELECT "id", "customer_id" FROM "pending_orders"', $result->query); + $this->assertContains('pending', $result->bindings); + $this->assertBindingCount($result); + } + + public function testRecursiveCteJoinToMainTable(): void + { + $seed = (new Builder()) + ->from('categories') + ->select(['id', 'parent_id', 'name']) + ->filter([Query::isNull('parent_id')]); + + $step = (new Builder()) + ->from('categories') + ->select(['categories.id', 'categories.parent_id', 'categories.name']) + ->join('tree', 'categories.parent_id', 'tree.id'); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step) + ->from('tree') + ->select(['id', 'name']) + ->build(); + + $this->assertSame('WITH RECURSIVE "tree" AS (SELECT "id", "parent_id", "name" FROM "categories" WHERE "parent_id" IS NULL UNION ALL SELECT "categories"."id", "categories"."parent_id", "categories"."name" FROM "categories" JOIN "tree" ON "categories"."parent_id" = "tree"."id") SELECT "id", "name" FROM "tree"', $result->query); + $this->assertBindingCount($result); + } + + public function testMultipleCtesJoinBetweenThem(): void + { + $cteA = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->filter([Query::equal('active', [true])]); + + $cteB = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 50)]); + + $result = (new Builder()) + ->with('active_users', $cteA) + ->with('big_orders', $cteB) + ->from('active_users') + ->select(['active_users.name', 'big_orders.total']) + ->join('big_orders', 'active_users.id', 'big_orders.user_id') + ->build(); + + $this->assertSame( + 'WITH "active_users" AS (SELECT "id", "name" FROM "users" WHERE "active" IN (?)), "big_orders" AS (SELECT "user_id", "total" FROM "orders" WHERE "total" > ?) SELECT "active_users"."name", "big_orders"."total" FROM "active_users" JOIN "big_orders" ON "active_users"."id" = "big_orders"."user_id"', + $result->query + ); + $this->assertSame([true, 50], $result->bindings); + $this->assertBindingCount($result); + } + + public function testMergeWithCteSource(): void + { + $cteQuery = (new Builder()) + ->from('staging') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['ready'])]); + + $sourceFromCte = (new Builder()) + ->with('ready_staging', $cteQuery) + ->from('ready_staging') + ->select(['id', 'name', 'email']); + + $result = (new Builder()) + ->mergeInto('users') + ->using($sourceFromCte, 'src') + ->on('users.id = src.id') + ->whenMatched('UPDATE SET name = src.name, email = src.email') + ->whenNotMatched('INSERT (id, name, email) VALUES (src.id, src.name, src.email)') + ->executeMerge(); + + $this->assertSame('MERGE INTO "users" USING (WITH "ready_staging" AS (SELECT "id", "name", "email" FROM "staging" WHERE "status" IN (?)) SELECT "id", "name", "email" FROM "ready_staging") AS "src" ON users.id = src.id WHEN MATCHED THEN UPDATE SET name = src.name, email = src.email WHEN NOT MATCHED THEN INSERT (id, name, email) VALUES (src.id, src.name, src.email)', $result->query); + $this->assertContains('ready', $result->bindings); + $this->assertBindingCount($result); + } + + public function testJsonPathWithWhereAndJoin(): void + { + $result = (new Builder()) + ->from('users') + ->select(['users.id', 'orders.total']) + ->join('orders', 'users.id', 'orders.user_id') + ->filterJsonPath('users.metadata', 'role', '=', 'admin') + ->filter([Query::greaterThan('orders.total', 100)]) + ->build(); + + $this->assertSame('SELECT "users"."id", "orders"."total" FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" WHERE "users"."metadata"->>\'role\' = ? AND "orders"."total" > ?', $result->query); + $this->assertSame(['admin', 100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testJsonContainsWithGroupByHaving(): void + { + $result = (new Builder()) + ->from('products') + ->count('*', 'cnt') + ->filterJsonContains('tags', 'sale') + ->groupBy(['category']) + ->having([Query::greaterThan('cnt', 3)]) + ->build(); + + $this->assertSame('SELECT COUNT(*) AS "cnt" FROM "products" WHERE "tags" @> ?::jsonb GROUP BY "category" HAVING COUNT(*) > ?', $result->query); + $this->assertBindingCount($result); + } + + public function testUpdateFromWithComplexSubqueryReturning(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'shipped']) + ->updateFrom('shipments', 's') + ->updateFromWhere('orders.id = s.order_id AND s.date > ?', '2024-01-01') + ->filter([Query::equal('orders.warehouse', ['US-EAST'])]) + ->returning(['orders.id', 'orders.status']) + ->update(); + + $this->assertSame('UPDATE "orders" SET "status" = ? FROM "shipments" AS "s" WHERE "orders"."warehouse" IN (?) AND orders.id = s.order_id AND s.date > ? RETURNING "orders"."id", "orders"."status"', $result->query); + $this->assertBindingCount($result); + } + + public function testDeleteUsingWithFilterReturning(): void + { + $result = (new Builder()) + ->from('orders') + ->deleteUsing('blacklist', 'orders.user_id = blacklist.user_id') + ->filter([Query::lessThan('orders.created_at', '2023-01-01')]) + ->returning(['orders.id']) + ->delete(); + + $this->assertSame('DELETE FROM "orders" USING "blacklist" WHERE "orders"."created_at" < ? AND orders.user_id = blacklist.user_id RETURNING "orders"."id"', $result->query); + $this->assertBindingCount($result); + } + + public function testLateralJoinWithAggregateAndWhere(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id']) + ->sum('total', 'order_total') + ->filter([Query::greaterThan('total', 0)]) + ->groupBy(['user_id']) + ->limit(5); + + $result = (new Builder()) + ->from('users') + ->select(['users.name']) + ->joinLateral($sub, 'user_orders') + ->filter([Query::equal('users.active', [true])]) + ->build(); + + $this->assertSame('SELECT "users"."name" FROM "users" JOIN LATERAL (SELECT SUM("total") AS "order_total", "user_id" FROM "orders" WHERE "total" > ? GROUP BY "user_id" LIMIT ?) AS "user_orders" ON true WHERE "users"."active" IN (?)', $result->query); + $this->assertBindingCount($result); + } + + public function testFullOuterJoinWithNullFilter(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['employees.name', 'departments.name']) + ->fullOuterJoin('departments', 'employees.dept_id', 'departments.id') + ->filter([ + Query::or([ + Query::isNull('employees.dept_id'), + Query::isNull('departments.id'), + ]), + ]) + ->build(); + + $this->assertSame('SELECT "employees"."name", "departments"."name" FROM "employees" FULL OUTER JOIN "departments" ON "employees"."dept_id" = "departments"."id" WHERE ("employees"."dept_id" IS NULL OR "departments"."id" IS NULL)', $result->query); + $this->assertBindingCount($result); + } + + public function testWindowFunctionWithDistinct(): void + { + $result = (new Builder()) + ->from('orders') + ->distinct() + ->select(['customer_id']) + ->selectWindow('ROW_NUMBER()', 'rn', ['customer_id'], ['created_at']) + ->build(); + + $this->assertSame('SELECT DISTINCT "customer_id", ROW_NUMBER() OVER (PARTITION BY "customer_id" ORDER BY "created_at" ASC) AS "rn" FROM "orders"', $result->query); + $this->assertBindingCount($result); + } + + public function testNamedWindowDefinitionWithJoin(): void + { + $result = (new Builder()) + ->from('employees') + ->select(['employees.name', 'departments.name']) + ->join('departments', 'employees.dept_id', 'departments.id') + ->selectWindow('RANK()', 'salary_rank', null, null, 'dept_window') + ->window('dept_window', ['employees.dept_id'], ['-employees.salary']) + ->build(); + + $this->assertSame('SELECT "employees"."name", "departments"."name", RANK() OVER "dept_window" AS "salary_rank" FROM "employees" JOIN "departments" ON "employees"."dept_id" = "departments"."id" WINDOW "dept_window" AS (PARTITION BY "employees"."dept_id" ORDER BY "employees"."salary" DESC)', $result->query); + $this->assertBindingCount($result); + } + + public function testMultipleWindowFunctionsRowNumberRankSum(): void + { + $result = (new Builder()) + ->from('sales') + ->select(['employee_id', 'amount']) + ->selectWindow('ROW_NUMBER()', 'rn', null, ['date']) + ->selectWindow('RANK()', 'rnk', ['region'], ['-amount']) + ->selectWindow('SUM("amount")', 'running_total', ['region'], ['date']) + ->build(); + + $this->assertSame( + 'SELECT "employee_id", "amount", ROW_NUMBER() OVER (ORDER BY "date" ASC) AS "rn", RANK() OVER (PARTITION BY "region" ORDER BY "amount" DESC) AS "rnk", SUM("amount") OVER (PARTITION BY "region" ORDER BY "date" ASC) AS "running_total" FROM "sales"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testExplainAnalyzeWithCteAndJoin(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->with('big_orders', $cteQuery) + ->from('users') + ->select(['users.name', 'big_orders.total']) + ->join('big_orders', 'users.id', 'big_orders.user_id') + ->explain(analyze: true, verbose: true, format: 'json'); + + $this->assertStringStartsWith('EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON)', $result->query); + $this->assertSame('EXPLAIN (ANALYZE, VERBOSE, FORMAT JSON) WITH "big_orders" AS (SELECT "user_id", "total" FROM "orders" WHERE "total" > ?) SELECT "users"."name", "big_orders"."total" FROM "users" JOIN "big_orders" ON "users"."id" = "big_orders"."user_id"', $result->query); + $this->assertTrue($result->readOnly); + $this->assertBindingCount($result); + } + + public function testVectorDistanceOrderByWithLimit(): void + { + $result = (new Builder()) + ->from('items') + ->select(['id', 'title']) + ->orderByVectorDistance('embedding', [0.1, 0.2, 0.3], VectorMetric::Cosine) + ->limit(10) + ->build(); + + $this->assertSame( + 'SELECT "id", "title" FROM "items" ORDER BY ("embedding" <=> ?::vector) ASC LIMIT ?', + $result->query + ); + $this->assertSame(['[0.1,0.2,0.3]', 10], $result->bindings); + $this->assertBindingCount($result); + } + + public function testUpsertWithReturning(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'alice@test.com']) + ->onConflict(['id'], ['name', 'email']) + ->returning(['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" RETURNING "id", "name", "email"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testUpsertConflictSetRawWithRegularOnConflict(): void + { + $result = (new Builder()) + ->into('stats') + ->set(['id' => 'page', 'views' => 1, 'updated_at' => '2024-01-01']) + ->onConflict(['id'], ['views', 'updated_at']) + ->conflictSetRaw('views', '"stats"."views" + EXCLUDED."views"') + ->upsert(); + + $this->assertSame('INSERT INTO "stats" ("id", "views", "updated_at") VALUES (?, ?, ?) ON CONFLICT ("id") DO UPDATE SET "views" = "stats"."views" + EXCLUDED."views", "updated_at" = EXCLUDED."updated_at"', $result->query); + $this->assertBindingCount($result); + } + + public function testInsertAsWithComplexCteQuery(): void + { + $cteQuery = (new Builder()) + ->from('staging') + ->select(['id', 'name']) + ->filter([Query::equal('status', ['ready'])]); + + $source = (new Builder()) + ->with('ready', $cteQuery) + ->from('ready') + ->select(['id', 'name']); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source) + ->insertSelect(); + + $this->assertSame('INSERT INTO "users" ("id", "name") WITH "ready" AS (SELECT "id", "name" FROM "staging" WHERE "status" IN (?)) SELECT "id", "name" FROM "ready"', $result->query); + $this->assertBindingCount($result); + } + + public function testForUpdateWithJoin(): void + { + $result = (new Builder()) + ->from('accounts') + ->select(['accounts.id', 'accounts.balance']) + ->join('users', 'accounts.user_id', 'users.id') + ->filter([Query::equal('users.active', [true])]) + ->forUpdate() + ->build(); + + $this->assertSame('SELECT "accounts"."id", "accounts"."balance" FROM "accounts" JOIN "users" ON "accounts"."user_id" = "users"."id" WHERE "users"."active" IN (?) FOR UPDATE', $result->query); + $this->assertBindingCount($result); + } + + public function testForShareWithSubquery(): void + { + $sub = (new Builder()) + ->from('vip_users') + ->select(['id']); + + $result = (new Builder()) + ->from('accounts') + ->filterWhereIn('user_id', $sub) + ->forShare() + ->build(); + + $this->assertSame('SELECT * FROM "accounts" WHERE "user_id" IN (SELECT "id" FROM "vip_users") FOR SHARE', $result->query); + $this->assertBindingCount($result); + } + + public function testNestedOrAndFilterParenthesization(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([ + Query::equal('a', [1]), + Query::greaterThan('b', 10), + ]), + Query::and([ + Query::lessThan('c', 5), + Query::between('d', 100, 200), + ]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE (("a" IN (?) AND "b" > ?) OR ("c" < ? AND "d" BETWEEN ? AND ?))', + $result->query + ); + $this->assertSame([1, 10, 5, 100, 200], $result->bindings); + $this->assertBindingCount($result); + } + + public function testTripleNestedAndOrFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::and([ + Query::or([ + Query::equal('status', ['active']), + Query::notEqual('status', 'banned'), + ]), + Query::or([ + Query::greaterThan('age', 18), + Query::lessThan('age', 65), + ]), + Query::between('score', 0, 100), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE (("status" IN (?) OR "status" != ?) AND ("age" > ? OR "age" < ?) AND "score" BETWEEN ? AND ?)', + $result->query + ); + $this->assertSame(['active', 'banned', 18, 65, 0, 100], $result->bindings); + $this->assertBindingCount($result); + } + + public function testIsNullAndIsNotNullSameQuery(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::isNull('deleted_at'), + Query::isNotNull('email'), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "deleted_at" IS NULL AND "email" IS NOT NULL', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testBetweenNotEqualGreaterThanCombined(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::between('price', 10, 100), + Query::notEqual('category', 'deprecated'), + Query::greaterThan('stock', 0), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "price" BETWEEN ? AND ? AND "category" != ? AND "stock" > ?', + $result->query + ); + $this->assertSame([10, 100, 'deprecated', 0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testStartsWithAndContainsOnSameColumn(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::startsWith('name', 'John'), + Query::containsString('name', ['Doe']), + ]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "name" ILIKE ? AND "name" ILIKE ?', $result->query); + $this->assertCount(2, $result->bindings); + $this->assertSame('John%', $result->bindings[0]); + $this->assertSame('%Doe%', $result->bindings[1]); + $this->assertBindingCount($result); + } + + public function testRegexAndEqualCombined(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::regex('slug', '^test-'), + Query::equal('status', ['active']), + ]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "slug" ~ ? AND "status" IN (?)', $result->query); + $this->assertSame(['^test-', 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNotContainsAndContainsDifferentColumns(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::notContains('bio', ['spam']), + Query::containsString('title', ['important']), + ]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "bio" NOT ILIKE ? AND "title" ILIKE ?', $result->query); + $this->assertBindingCount($result); + } + + public function testMultipleOrGroupsInSameFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::equal('color', ['red']), + Query::equal('color', ['blue']), + ]), + Query::or([ + Query::equal('size', ['S']), + Query::equal('size', ['M']), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE ("color" IN (?) OR "color" IN (?)) AND ("size" IN (?) OR "size" IN (?))', + $result->query + ); + $this->assertSame(['red', 'blue', 'S', 'M'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testAndWrappingOrWrappingAnd(): 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]), + ]), + ]), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE ((("a" IN (?) AND "b" IN (?)) OR "c" IN (?)))', + $result->query + ); + $this->assertSame([1, 2, 3], $result->bindings); + $this->assertBindingCount($result); + } + + public function testFilterWithBooleanValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('active', [true])]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "active" IN (?)', + $result->query + ); + $this->assertSame([true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testCteBindingsMainQueryBindingsHavingBindingsOrder(): void + { + $cteQuery = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->filter([Query::equal('status', ['shipped'])]); + + $result = (new Builder()) + ->with('shipped', $cteQuery) + ->from('shipped') + ->count('*', 'cnt') + ->filter([Query::greaterThan('total', 50)]) + ->groupBy(['customer_id']) + ->having([Query::greaterThan('cnt', 3)]) + ->build(); + + $this->assertSame('shipped', $result->bindings[0]); + $this->assertSame(50, $result->bindings[1]); + $this->assertSame(3, $result->bindings[2]); + $this->assertBindingCount($result); + } + + public function testUnionBothBranchesBindingsOrder(): void + { + $other = (new Builder()) + ->from('archived') + ->select(['id', 'name']) + ->filter([Query::equal('year', [2023])]); + + $result = (new Builder()) + ->from('current') + ->select(['id', 'name']) + ->filter([Query::equal('year', [2024])]) + ->union($other) + ->build(); + + $this->assertSame(2024, $result->bindings[0]); + $this->assertSame(2023, $result->bindings[1]); + $this->assertBindingCount($result); + } + + public function testSubqueryInWhereAndSelectBindingOrder(): void + { + $selectSub = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::equal('orders.user_id', [99])]); + + $whereSub = (new Builder()) + ->from('vip_users') + ->select(['id']) + ->filter([Query::equal('level', ['gold'])]); + + $result = (new Builder()) + ->from('users') + ->selectSub($selectSub, 'order_count') + ->filter([Query::equal('status', ['active'])]) + ->filterWhereIn('id', $whereSub) + ->build(); + + $this->assertSame(99, $result->bindings[0]); + $this->assertSame('active', $result->bindings[1]); + $this->assertSame('gold', $result->bindings[2]); + $this->assertBindingCount($result); + } + + public function testJoinOnBindingsWhereBindingsHavingBindingsOrder(): void + { + $result = (new Builder()) + ->from('users') + ->count('*', 'cnt') + ->joinWhere('orders', function (JoinBuilder $join): void { + $join->on('users.id', 'orders.user_id') + ->where('orders.amount', '>', 50); + }) + ->filter([Query::equal('users.active', [true])]) + ->groupBy(['users.id']) + ->having([Query::greaterThan('cnt', 2)]) + ->build(); + + $this->assertSame(50, $result->bindings[0]); + $this->assertSame(true, $result->bindings[1]); + $this->assertSame(2, $result->bindings[2]); + $this->assertBindingCount($result); + } + + public function testInsertAsBindings(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name']) + ->filter([Query::equal('ready', [true])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name'], $source) + ->insertSelect(); + + $this->assertSame('INSERT INTO "users" ("id", "name") SELECT "id", "name" FROM "staging" WHERE "ready" IN (?)', $result->query); + $this->assertSame([true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testUpsertFilterValuesConflictUpdateBindingOrder(): void + { + $result = (new Builder()) + ->into('counters') + ->set(['id' => 'views', 'count' => 1]) + ->onConflict(['id'], ['count']) + ->conflictSetRaw('count', '"counters"."count" + ?', [1]) + ->upsert(); + + $this->assertSame('views', $result->bindings[0]); + $this->assertSame(1, $result->bindings[1]); + $this->assertSame(1, $result->bindings[2]); + $this->assertBindingCount($result); + } + + public function testMergeSourceBindingsActionBindingsOrder(): void + { + $source = (new Builder()) + ->from('staging') + ->filter([Query::equal('status', ['pending'])]); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id AND src.region = ?', 'US') + ->whenMatched('UPDATE SET count = users.count + ?', 1) + ->whenNotMatched('INSERT (id, count) VALUES (src.id, ?)', 0) + ->executeMerge(); + + $this->assertSame('pending', $result->bindings[0]); + $this->assertSame('US', $result->bindings[1]); + $this->assertSame(1, $result->bindings[2]); + $this->assertSame(0, $result->bindings[3]); + $this->assertBindingCount($result); + } + + public function testSelectEmptyArray(): void + { + $result = (new Builder()) + ->from('t') + ->select([]) + ->build(); + + $this->assertSame('SELECT FROM "t"', $result->query); + $this->assertBindingCount($result); + } + + public function testGroupByThreeColumns(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'cnt') + ->groupBy(['a', 'b', 'c']) + ->build(); + + $this->assertSame( + 'SELECT COUNT(*) AS "cnt" FROM "t" GROUP BY "a", "b", "c"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testInterleavedSortAscDesc(): void + { + $result = (new Builder()) + ->from('t') + ->sortAsc('a') + ->sortDesc('b') + ->sortAsc('c') + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" ORDER BY "a" ASC, "b" DESC, "c" ASC', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testLimitOneOffsetZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(1) + ->offset(0) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame([1, 0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testLimitZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(0) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" LIMIT ?', + $result->query + ); + $this->assertSame([0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testDistinctWithMultipleAggregates(): void + { + $result = (new Builder()) + ->from('t') + ->distinct() + ->count('*', 'cnt') + ->sum('price', 'total') + ->build(); + + $this->assertSame('SELECT DISTINCT COUNT(*) AS "cnt", SUM("price") AS "total" FROM "t"', $result->query); + $this->assertBindingCount($result); + } + + public function testCountStarWithoutAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*') + ->build(); + + $this->assertSame('SELECT COUNT(*) FROM "t"', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + $this->assertBindingCount($result); + } + + public function testCountStarWithAlias(): void + { + $result = (new Builder()) + ->from('t') + ->count('*', 'total') + ->build(); + + $this->assertSame('SELECT COUNT(*) AS "total" FROM "t"', $result->query); + $this->assertBindingCount($result); + } + + public function testSelfJoinWithAlias(): void + { + $result = (new Builder()) + ->from('employees', 'e') + ->select(['e.name', 'm.name']) + ->leftJoin('employees', 'e.manager_id', 'm.id', '=', 'm') + ->build(); + + $this->assertSame( + 'SELECT "e"."name", "m"."name" FROM "employees" AS "e" LEFT JOIN "employees" AS "m" ON "e"."manager_id" = "m"."id"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testThreeWayJoin(): void + { + $result = (new Builder()) + ->from('users') + ->select(['users.name', 'orders.total', 'products.title']) + ->join('orders', 'users.id', 'orders.user_id') + ->join('products', 'orders.product_id', 'products.id') + ->build(); + + $this->assertSame( + 'SELECT "users"."name", "orders"."total", "products"."title" FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" JOIN "products" ON "orders"."product_id" = "products"."id"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testCrossJoinWithFilter(): void + { + $result = (new Builder()) + ->from('colors') + ->select(['colors.name', 'sizes.label']) + ->crossJoin('sizes') + ->filter([Query::equal('colors.active', [true])]) + ->build(); + + $this->assertSame('SELECT "colors"."name", "sizes"."label" FROM "colors" CROSS JOIN "sizes" WHERE "colors"."active" IN (?)', $result->query); + $this->assertBindingCount($result); + } + + public function testLeftJoinWhereRightSideIsNull(): void + { + $result = (new Builder()) + ->from('users') + ->select(['users.id', 'users.name']) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->filter([Query::isNull('orders.id')]) + ->build(); + + $this->assertSame( + 'SELECT "users"."id", "users"."name" FROM "users" LEFT JOIN "orders" ON "users"."id" = "orders"."user_id" WHERE "orders"."id" IS NULL', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testBeforeBuildCallbackAddsFilter(): void + { + $result = (new Builder()) + ->from('t') + ->beforeBuild(function (Builder $b): void { + $b->filter([Query::equal('tenant_id', [42])]); + }) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "tenant_id" IN (?)', $result->query); + $this->assertContains(42, $result->bindings); + $this->assertBindingCount($result); + } + + public function testAfterBuildCallbackWrapsQuery(): void + { + $result = (new Builder()) + ->from('t') + ->select(['id']) + ->afterBuild(function (\Utopia\Query\Builder\Statement $r): \Utopia\Query\Builder\Statement { + return new \Utopia\Query\Builder\Statement( + 'SELECT * FROM (' . $r->query . ') AS wrapped', + $r->bindings, + $r->readOnly, + ); + }) + ->build(); + + $this->assertSame('SELECT * FROM (SELECT "id" FROM "t") AS wrapped', $result->query); + $this->assertBindingCount($result); + } + + public function testCloneModifyOriginalUnchanged(): void + { + $original = (new Builder()) + ->from('users') + ->select(['id', 'name']); + + $cloned = $original->clone(); + $cloned->filter([Query::equal('active', [true])]); + + $originalResult = $original->build(); + $clonedResult = $cloned->build(); + + $this->assertSame('SELECT "id", "name" FROM "users"', $originalResult->query); + $this->assertSame('SELECT "id", "name" FROM "users" WHERE "active" IN (?)', $clonedResult->query); + $this->assertBindingCount($originalResult); + $this->assertBindingCount($clonedResult); + } + + public function testResetAndRebuild(): void + { + $builder = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + + $builder->reset(); + + $result = $builder + ->from('orders') + ->select(['total']) + ->build(); + + $this->assertSame('SELECT "total" FROM "orders"', $result->query); + $this->assertSame([], $result->bindings); + $this->assertStringNotContainsString('users', $result->query); + $this->assertBindingCount($result); + } + + public function testReadOnlyFlagSelectIsTrue(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + + $this->assertTrue($result->readOnly); + } + + public function testReadOnlyFlagInsertImplicit(): void + { + $result = (new Builder()) + ->into('t') + ->set(['a' => 1]) + ->insert(); + + $this->assertFalse($result->readOnly); + } + + public function testReadOnlyFlagUpdateImplicit(): void + { + $result = (new Builder()) + ->from('t') + ->set(['a' => 1]) + ->update(); + + $this->assertFalse($result->readOnly); + } + + public function testReadOnlyFlagDeleteImplicit(): void + { + $result = (new Builder()) + ->from('t') + ->delete(); + + $this->assertFalse($result->readOnly); + } + + public function testMultipleSetCallsForMultiRowInsert(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'age' => 30]) + ->set(['name' => 'Bob', 'age' => 25]) + ->set(['name' => 'Charlie', 'age' => 35]) + ->insert(); + + $this->assertSame( + 'INSERT INTO "users" ("name", "age") VALUES (?, ?), (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['Alice', 30, 'Bob', 25, 'Charlie', 35], $result->bindings); + $this->assertBindingCount($result); + } + + public function testSetWithBooleanAndNullValues(): void + { + $result = (new Builder()) + ->into('t') + ->set(['active' => true, 'deleted' => false, 'notes' => null]) + ->insert(); + + $this->assertSame( + 'INSERT INTO "t" ("active", "deleted", "notes") VALUES (?, ?, ?)', + $result->query + ); + $this->assertSame([true, false, null], $result->bindings); + $this->assertBindingCount($result); + } + + public function testInsertOrIgnorePostgreSQLSyntax(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'John', 'email' => 'john@test.com']) + ->insertOrIgnore(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name", "email") VALUES (?, ?, ?) ON CONFLICT DO NOTHING', + $result->query + ); + $this->assertSame([1, 'John', 'john@test.com'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNotStartsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notStartsWith('name', 'test')]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "name" NOT ILIKE ?', $result->query); + $this->assertSame(['test%'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNotEndsWithFilter(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEndsWith('name', 'test')]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "name" NOT ILIKE ?', $result->query); + $this->assertSame(['%test'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNaturalJoin(): void + { + $result = (new Builder()) + ->from('a') + ->naturalJoin('b') + ->build(); + + $this->assertSame('SELECT * FROM "a" NATURAL JOIN "b"', $result->query); + $this->assertBindingCount($result); + } + + public function testNaturalJoinWithAlias(): void + { + $result = (new Builder()) + ->from('a') + ->naturalJoin('b', 'b_alias') + ->build(); + + $this->assertSame('SELECT * FROM "a" NATURAL JOIN "b" AS "b_alias"', $result->query); + $this->assertBindingCount($result); + } + + public function testFilterWhereNotIn(): void + { + $sub = (new Builder()) + ->from('blocked') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + + $this->assertSame('SELECT * FROM "users" WHERE "id" NOT IN (SELECT "user_id" FROM "blocked")', $result->query); + $this->assertBindingCount($result); + } + + public function testExplainReadOnlyFlag(): void + { + $result = (new Builder()) + ->from('users') + ->explain(); + + $this->assertTrue($result->readOnly); + } + + public function testExplainAnalyzeReadOnlyFlag(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertTrue($result->readOnly); + } + + public function testUnionAllWithBindingsOrder(): void + { + $other = (new Builder()) + ->from('b') + ->filter([Query::equal('type', ['beta'])]); + + $result = (new Builder()) + ->from('a') + ->filter([Query::equal('type', ['alpha'])]) + ->unionAll($other) + ->build(); + + $this->assertSame('(SELECT * FROM "a" WHERE "type" IN (?)) UNION ALL (SELECT * FROM "b" WHERE "type" IN (?))', $result->query); + $this->assertSame('alpha', $result->bindings[0]); + $this->assertSame('beta', $result->bindings[1]); + $this->assertBindingCount($result); + } + + public function testExceptAll(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->exceptAll($other) + ->build(); + + $this->assertSame('(SELECT * FROM "a") EXCEPT ALL (SELECT * FROM "b")', $result->query); + $this->assertBindingCount($result); + } + + public function testIntersectAll(): void + { + $other = (new Builder())->from('b'); + + $result = (new Builder()) + ->from('a') + ->intersectAll($other) + ->build(); + + $this->assertSame('(SELECT * FROM "a") INTERSECT ALL (SELECT * FROM "b")', $result->query); + $this->assertBindingCount($result); + } + + public function testInsertAlias(): void + { + $result = (new Builder()) + ->into('users') + ->insertAs('new_row') + ->set(['id' => 1, 'name' => 'Alice']) + ->onConflict(['id'], ['name']) + ->conflictSetRaw('name', 'COALESCE("new_row"."name", EXCLUDED."name")') + ->upsert(); + + $this->assertSame('INSERT INTO "users" AS "new_row" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = COALESCE("new_row"."name", EXCLUDED."name")', $result->query); + $this->assertBindingCount($result); + } + + public function testFromNone(): void + { + $result = (new Builder()) + ->from() + ->select('1 AS one') + ->build(); + + $this->assertSame('SELECT 1 AS one', $result->query); + $this->assertBindingCount($result); + } + + public function testSelectRawWithBindings(): void + { + $result = (new Builder()) + ->from('t') + ->select('COALESCE("name", ?) AS display_name', ['Unknown']) + ->build(); + + $this->assertSame('SELECT COALESCE("name", ?) AS display_name FROM "t"', $result->query); + $this->assertSame(['Unknown'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testInsertColumnExpression(): void + { + $result = (new Builder()) + ->into('locations') + ->set(['name' => 'NYC', 'coords' => 'POINT(40.7128 -74.0060)']) + ->insertColumnExpression('coords', 'ST_GeomFromText(?, 4326)') + ->insert(); + + $this->assertSame('INSERT INTO "locations" ("name", "coords") VALUES (?, ST_GeomFromText(?, 4326))', $result->query); + $this->assertBindingCount($result); + } + + public function testResetClearsReturningColumns(): void + { + $builder = (new Builder()) + ->into('users') + ->set(['name' => 'Test']) + ->returning(['id']); + + $builder->reset(); + + $result = $builder + ->into('users') + ->set(['name' => 'Test2']) + ->insert(); + + $this->assertStringNotContainsString('RETURNING', $result->query); + $this->assertBindingCount($result); + } + + public function testResetClearsLateralJoins(): void + { + $sub = (new Builder())->from('orders')->select(['id']); + + $builder = (new Builder()) + ->from('users') + ->joinLateral($sub, 'o'); + + $builder->reset(); + + $result = $builder->from('users')->build(); + $this->assertStringNotContainsString('LATERAL', $result->query); + $this->assertBindingCount($result); + } + + public function testCteWithDeleteReturning(): void + { + $cteQuery = (new Builder()) + ->from('users') + ->select(['id']) + ->filter([Query::equal('status', ['inactive'])]); + + $result = (new Builder()) + ->with('inactive_users', $cteQuery) + ->from('users') + ->filterWhereIn('id', (new Builder())->from('inactive_users')->select(['id'])) + ->returning(['id', 'name']) + ->delete(); + + $this->assertSame('DELETE FROM "users" WHERE "id" IN (SELECT "id" FROM "inactive_users") RETURNING "id", "name"', $result->query); + $this->assertBindingCount($result); + } + + public function testMultipleConditionalAggregates(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->countWhen('status = ?', 'cancelled_count', 'cancelled') + ->sumWhen('amount', 'status = ?', 'active_total', 'active') + ->groupBy(['region']) + ->build(); + + $this->assertSame('SELECT COUNT(*) FILTER (WHERE status = ?) AS "active_count", COUNT(*) FILTER (WHERE status = ?) AS "cancelled_count", SUM("amount") FILTER (WHERE status = ?) AS "active_total" FROM "orders" GROUP BY "region"', $result->query); + $this->assertSame(['active', 'cancelled', 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testTableSampleWithFilter(): void + { + $result = (new Builder()) + ->from('events') + ->tablesample(5.0) + ->filter([Query::greaterThan('ts', '2024-01-01')]) + ->limit(100) + ->build(); + + $this->assertSame('SELECT * FROM "events" TABLESAMPLE BERNOULLI(5) WHERE "ts" > ? LIMIT ?', $result->query); + $this->assertBindingCount($result); + } + + public function testLeftJoinLateralWithFilter(): void + { + $sub = (new Builder()) + ->from('comments') + ->select(['body']) + ->filter([Query::equal('approved', [true])]) + ->sortDesc('created_at') + ->limit(3); + + $result = (new Builder()) + ->from('posts') + ->select(['posts.title']) + ->leftJoinLateral($sub, 'recent_comments') + ->filter([Query::equal('posts.published', [true])]) + ->build(); + + $this->assertSame('SELECT "posts"."title" FROM "posts" LEFT JOIN LATERAL (SELECT "body" FROM "comments" WHERE "approved" IN (?) ORDER BY "created_at" DESC LIMIT ?) AS "recent_comments" ON true WHERE "posts"."published" IN (?)', $result->query); + $this->assertBindingCount($result); + } + + public function testFullOuterJoinWithOperator(): void + { + $result = (new Builder()) + ->from('a') + ->fullOuterJoin('b', 'a.key', 'b.key', '!=') + ->build(); + + $this->assertSame('SELECT * FROM "a" FULL OUTER JOIN "b" ON "a"."key" != "b"."key"', $result->query); + $this->assertBindingCount($result); + } + + public function testJoinWhereWithLeftType(): 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->assertSame('SELECT * FROM "users" LEFT JOIN "orders" ON "users"."id" = "orders"."user_id" AND orders.status = ?', $result->query); + $this->assertBindingCount($result); + } + + public function testJoinWhereWithMultipleOnAndWhere(): 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') + ->where('orders.amount', '>', 100) + ->where('orders.status', '=', 'active'); + }) + ->build(); + + $this->assertSame('SELECT * FROM "users" JOIN "orders" ON "users"."id" = "orders"."user_id" AND "users"."tenant_id" = "orders"."tenant_id" AND orders.amount > ? AND orders.status = ?', $result->query); + $this->assertSame([100, 'active'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testEqualWithMultipleValues(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal('id', [1, 2, 3])]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "id" IN (?, ?, ?)', + $result->query + ); + $this->assertSame([1, 2, 3], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNotEqualSingleValue(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::notEqual('status', 'deleted')]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "status" != ?', + $result->query + ); + $this->assertSame(['deleted'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testContainsAllArrayFilter(): void + { + $query = Query::containsAll('tags', ['php', 'go']); + $query->setOnArray(true); + + $result = (new Builder()) + ->from('docs') + ->filter([$query]) + ->build(); + + $this->assertSame('SELECT * FROM "docs" WHERE "tags" @> ?::jsonb', $result->query); + $this->assertBindingCount($result); + } + + public function testContainsAnyArrayFilter(): void + { + $query = Query::containsAny('tags', ['php', 'go']); + $query->setOnArray(true); + + $result = (new Builder()) + ->from('docs') + ->filter([$query]) + ->build(); + + $this->assertSame('SELECT * FROM "docs" WHERE ("tags" @> ?::jsonb OR "tags" @> ?::jsonb)', $result->query); + $this->assertBindingCount($result); + } + + public function testNotContainsArrayFilter(): void + { + $query = Query::notContains('tags', ['deprecated']); + $query->setOnArray(true); + + $result = (new Builder()) + ->from('docs') + ->filter([$query]) + ->build(); + + $this->assertSame('SELECT * FROM "docs" WHERE NOT ("tags" @> ?::jsonb)', $result->query); + $this->assertBindingCount($result); + } + + public function testSelectSubquery(): void + { + $sub = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->filter([Query::equal('orders.user_id', [1])]); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->selectSub($sub, 'order_count') + ->filter([Query::equal('id', [1])]) + ->build(); + + $this->assertSame('SELECT "id", "name", (SELECT COUNT(*) AS "cnt" FROM "orders" WHERE "orders"."user_id" IN (?)) AS "order_count" FROM "users" WHERE "id" IN (?)', $result->query); + $this->assertBindingCount($result); + } + + public function testRawFilterWithMultipleBindings(): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::raw('score BETWEEN ? AND ?', [10, 90])]) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE score BETWEEN ? AND ?', $result->query); + $this->assertSame([10, 90], $result->bindings); + $this->assertBindingCount($result); + } + + public function testCursorBeforeDescOrder(): void + { + $result = (new Builder()) + ->from('t') + ->sortDesc('id') + ->cursorBefore(100) + ->limit(25) + ->build(); + + $this->assertSame('SELECT * FROM "t" WHERE "_cursor" < ? ORDER BY "id" DESC LIMIT ?', $result->query); + $this->assertContains(100, $result->bindings); + $this->assertBindingCount($result); + } + + public function testSetRawInUpdate(): void + { + $result = (new Builder()) + ->from('counters') + ->setRaw('count', '"count" + ?', [1]) + ->filter([Query::equal('id', ['page_views'])]) + ->update(); + + $this->assertSame( + 'UPDATE "counters" SET "count" = "count" + ? WHERE "id" IN (?)', + $result->query + ); + $this->assertSame([1, 'page_views'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testMultipleSetRawInUpdate(): void + { + $result = (new Builder()) + ->from('t') + ->setRaw('count', '"count" + ?', [1]) + ->setRaw('updated_at', 'NOW()') + ->filter([Query::equal('id', [1])]) + ->update(); + + $this->assertSame('UPDATE "t" SET "count" = "count" + ?, "updated_at" = NOW() WHERE "id" IN (?)', $result->query); + $this->assertBindingCount($result); + } + + public function testJsonPathInvalidOperatorThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->filterJsonPath('data', 'key', 'INVALID', 'val') + ->build(); + } + + public function testJsonPathInvalidPathThrows(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->filterJsonPath('data', 'key; DROP TABLE users', '=', 'val') + ->build(); + } + + public function testDeleteWithoutConditions(): void + { + $result = (new Builder()) + ->from('t') + ->delete(); + + $this->assertSame('DELETE FROM "t"', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testUpdateWithMultipleFilters(): void + { + $result = (new Builder()) + ->from('t') + ->set(['status' => 'archived']) + ->filter([ + Query::equal('active', [false]), + Query::lessThan('updated_at', '2023-01-01'), + ]) + ->update(); + + $this->assertSame( + 'UPDATE "t" SET "status" = ? WHERE "active" IN (?) AND "updated_at" < ?', + $result->query + ); + $this->assertSame(['archived', false, '2023-01-01'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testPageEdgeCasePageOne(): void + { + $result = (new Builder()) + ->from('t') + ->page(1, 10) + ->build(); + + $this->assertSame([10, 0], $result->bindings); + $this->assertBindingCount($result); + } + + public function testPageThrowsForPageZero(): void + { + $this->expectException(ValidationException::class); + + (new Builder()) + ->from('t') + ->page(0, 10); + } + + public function testTransactionMethods(): void + { + $builder = new Builder(); + + $this->assertSame('BEGIN', $builder->begin()->query); + $this->assertSame('COMMIT', $builder->commit()->query); + $this->assertSame('ROLLBACK', $builder->rollback()->query); + $this->assertSame('ROLLBACK TO SAVEPOINT "sp1"', $builder->rollbackToSavepoint('sp1')->query); + $this->assertSame('RELEASE SAVEPOINT "sp1"', $builder->releaseSavepoint('sp1')->query); + } + + public function testSpatialDistanceWithMeters(): void + { + $result = (new Builder()) + ->from('locations') + ->filterDistance('coords', [40.7128, -74.0060], '>', 10000.0, true) + ->build(); + + $this->assertSame('SELECT * FROM "locations" WHERE ST_Distance(("coords"::geography), ST_SetSRID(ST_GeomFromText(?), 4326)::geography) > ?', $result->query); + $this->assertSame('POINT(40.7128 -74.006)', $result->bindings[0]); + $this->assertSame(10000.0, $result->bindings[1]); + $this->assertBindingCount($result); + } + + public function testCteUpdateReturning(): void + { + $result = (new Builder()) + ->from('orders') + ->set(['status' => 'processed']) + ->filter([Query::equal('status', ['pending'])]) + ->returning(['id', 'status']) + ->update(); + + $this->assertSame( + 'UPDATE "orders" SET "status" = ? WHERE "status" IN (?) RETURNING "id", "status"', + $result->query + ); + $this->assertSame(['processed', 'pending'], $result->bindings); + $this->assertBindingCount($result); + } + + public function testInsertOrIgnoreReturningPostgreSQL(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice']) + ->returning(['id']) + ->insertOrIgnore(); + + $this->assertSame( + 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING "id"', + $result->query + ); + $this->assertBindingCount($result); + } + + public function testUpsertSelectWithBindings(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['id', 'name', 'email']) + ->filter([Query::equal('status', ['ready'])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['id', 'name', 'email'], $source) + ->onConflict(['id'], ['name', 'email']) + ->returning(['id']) + ->upsertSelect(); + + $this->assertSame('INSERT INTO "users" ("id", "name", "email") SELECT "id", "name", "email" FROM "staging" WHERE "status" IN (?) ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email" RETURNING "id"', $result->query); + $this->assertContains('ready', $result->bindings); + $this->assertBindingCount($result); + } + + public function testMultipleHooks(): void + { + $hook1 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('tenant_id = ?', [1]); + } + }; + + $hook2 = new class () implements Filter { + public function filter(string $table): Condition + { + return new Condition('deleted = ?', [false]); + } + }; + + $result = (new Builder()) + ->from('users') + ->addHook($hook1) + ->addHook($hook2) + ->build(); + + $this->assertSame('SELECT * FROM "users" WHERE tenant_id = ? AND deleted = ?', $result->query); + $this->assertSame([1, false], $result->bindings); + $this->assertBindingCount($result); + } + + public function testToRawSqlWithNullAndBooleans(): void + { + $raw = (new Builder()) + ->from('t') + ->filter([ + Query::equal('active', [true]), + Query::equal('deleted', [false]), + ]) + ->toRawSql(); + + $this->assertSame('SELECT * FROM "t" WHERE "active" IN (1) AND "deleted" IN (0)', $raw); + $this->assertStringNotContainsString('?', $raw); + } + + public function testFromSubWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['user_id', 'total']) + ->filter([Query::greaterThan('total', 100)]); + + $result = (new Builder()) + ->fromSub($sub, 'big_orders') + ->select(['user_id']) + ->filter([Query::greaterThan('total', 500)]) + ->build(); + + $this->assertSame('SELECT "user_id" FROM (SELECT "user_id", "total" FROM "orders" WHERE "total" > ?) AS "big_orders" WHERE "total" > ?', $result->query); + $this->assertSame([100, 500], $result->bindings); + $this->assertBindingCount($result); + } + + public function testHavingRawWithGroupByAndFilter(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->sum('amount', 'total') + ->filter([Query::equal('status', ['active'])]) + ->groupBy(['user_id']) + ->havingRaw('SUM("amount") > ? AND COUNT(*) > ?', [1000, 5]) + ->build(); + + $this->assertSame('SELECT COUNT(*) AS "cnt", SUM("amount") AS "total" FROM "orders" WHERE "status" IN (?) GROUP BY "user_id" HAVING SUM("amount") > ? AND COUNT(*) > ?', $result->query); + $this->assertSame(['active', 1000, 5], $result->bindings); + $this->assertBindingCount($result); + } + + public function testNotBetweenWithOtherFilters(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::notBetween('price', 50, 100), + Query::equal('active', [true]), + Query::isNotNull('name'), + ]) + ->build(); + + $this->assertSame( + 'SELECT * FROM "t" WHERE "price" NOT BETWEEN ? AND ? AND "active" IN (?) AND "name" IS NOT NULL', + $result->query + ); + $this->assertSame([50, 100, true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testCaseExpressionWithBindingsInSelect(): void + { + $case = (new CaseExpression()) + ->when('price', Operator::GreaterThan, 100, 'expensive') + ->when('price', Operator::GreaterThan, 50, 'moderate') + ->else('cheap') + ->alias('price_tier'); + + $result = (new Builder()) + ->from('products') + ->select(['id', 'name']) + ->selectCase($case) + ->filter([Query::equal('active', [true])]) + ->build(); + + $this->assertSame('SELECT "id", "name", CASE WHEN "price" > ? THEN ? WHEN "price" > ? THEN ? ELSE ? END AS "price_tier" FROM "products" WHERE "active" IN (?)', $result->query); + $this->assertSame([100, 'expensive', 50, 'moderate', 'cheap', true], $result->bindings); + $this->assertBindingCount($result); + } + + public function testMergeWithDeleteAction(): void + { + $source = (new Builder())->from('staging'); + + $result = (new Builder()) + ->mergeInto('users') + ->using($source, 'src') + ->on('users.id = src.id') + ->whenMatched('DELETE') + ->whenNotMatched('INSERT (id, name) VALUES (src.id, src.name)') + ->executeMerge(); + + $this->assertSame('MERGE INTO "users" USING (SELECT * FROM "staging") AS "src" ON users.id = src.id WHEN MATCHED THEN DELETE WHEN NOT MATCHED THEN INSERT (id, name) VALUES (src.id, src.name)', $result->query); + $this->assertBindingCount($result); + } + + public function testFromNoneEmitsNoFromClause(): void + { + $result = (new Builder()) + ->fromNone() + ->selectRaw('1 + 1') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FROM', $result->query); + $this->assertSame('SELECT 1 + 1', $result->query); + } + + public function testSelectCastEmitsCastExpression(): void + { + $result = (new Builder()) + ->from('products') + ->selectCast('price', 'DECIMAL(10, 2)', 'price_decimal') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CAST("price" AS DECIMAL(10, 2)) AS "price_decimal" FROM "products"', $result->query); + } + + public function testSelectCastRejectsInvalidType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new Builder()) + ->from('t') + ->selectCast('c', 'INT); DROP TABLE x;--', 'a'); + } + + public function testSelectWindowRejectsInvalidFunction(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()); DROP --', 'w'); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $this->assertStringContainsString('"' . $word . '"', $result->query); + $stripped = \preg_replace('/"[^"]+"/', '', $result->query) ?? ''; + // Lowercase reserved word must not appear bare outside quotes + $this->assertDoesNotMatchRegularExpression( + '/(?from($word) + ->build(); + + $this->assertStringContainsString('FROM "' . $word . '"', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $this->assertStringContainsString('"' . $word . '"', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $this->assertStringContainsString('"' . $identifier . '"', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $this->assertStringContainsString('"' . $identifier . '"', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $this->assertStringContainsString('"' . $identifier . '"', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testWhereColumnEmitsQualifiedIdentifiers(): void + { + $result = (new Builder()) + ->from('users') + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "users"."id" = "orders"."user_id"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testWhereColumnRejectsUnknownOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid whereColumn operator: NOT_AN_OP'); + + (new Builder()) + ->from('users') + ->whereColumn('a', 'NOT_AN_OP', 'b'); + } + + public function testWhereColumnCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM "users" WHERE "status" IN (?) AND "users"."id" = "orders"."user_id"', $result->query); + $this->assertContains('active', $result->bindings); + } + + public function testImplementsSequences(): void + { + $this->assertInstanceOf(Sequences::class, new Builder()); + } + + public function testNextValEmitsSequenceCall(): void + { + $result = (new Builder()) + ->fromNone() + ->nextVal('seq_user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT nextval(\'seq_user_id\')', $result->query); + } + + public function testCurrValEmitsSequenceCall(): void + { + $result = (new Builder()) + ->fromNone() + ->currVal('seq_user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT currval(\'seq_user_id\')', $result->query); + } + + public function testNextValWithAlias(): void + { + $result = (new Builder()) + ->fromNone() + ->nextVal('seq_user_id', 'next_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT nextval(\'seq_user_id\') AS "next_id"', $result->query); + } + + public function testNextValRejectsInvalidName(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid sequence name'); + + (new Builder())->nextVal('bad name; DROP TABLE x'); + } +} diff --git a/tests/Query/Builder/SQLiteTest.php b/tests/Query/Builder/SQLiteTest.php new file mode 100644 index 0000000..c75599d --- /dev/null +++ b/tests/Query/Builder/SQLiteTest.php @@ -0,0 +1,2019 @@ +assertInstanceOf(Compiler::class, new Builder()); + } + + public function testImplementsJson(): void + { + $this->assertInstanceOf(Json::class, new Builder()); + } + + public function testImplementsConditionalAggregates(): void + { + $this->assertInstanceOf(ConditionalAggregates::class, new Builder()); + } + + public function testSortRandom(): void + { + $result = (new Builder()) + ->from('t') + ->sortRandom() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` ORDER BY RANDOM()', $result->query); + } + + public function testRegexThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('REGEXP is not natively supported in SQLite.'); + + (new Builder()) + ->from('t') + ->filter([Query::regex('slug', '^[a-z]+$')]) + ->build(); + } + + public function testSearchThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Full-text search is not supported in the SQLite query builder.'); + + (new Builder()) + ->from('t') + ->filter([Query::search('content', 'hello')]) + ->build(); + } + + public function testNotSearchThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::notSearch('content', 'hello')]) + ->build(); + } + + public function testUpsertUsesOnConflict(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Alice', 'email' => 'a@b.com']) + ->onConflict(['id'], ['name', 'email']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?) ON CONFLICT (`id`) DO UPDATE SET `name` = excluded.`name`, `email` = excluded.`email`', + $result->query + ); + $this->assertSame([1, 'Alice', 'a@b.com'], $result->bindings); + } + + public function testUpsertMultipleConflictKeys(): 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->assertSame( + 'INSERT INTO `user_roles` (`user_id`, `role_id`, `granted_at`) VALUES (?, ?, ?) ON CONFLICT (`user_id`, `role_id`) DO UPDATE SET `granted_at` = excluded.`granted_at`', + $result->query + ); + $this->assertSame([1, 2, '2024-01-01'], $result->bindings); + } + + public function testUpsertWithSetRaw(): void + { + $result = (new Builder()) + ->into('counters') + ->set(['id' => 1, 'count' => 1]) + ->onConflict(['id'], ['count']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `counters` (`id`, `count`) VALUES (?, ?) ON CONFLICT (`id`) DO UPDATE SET `count` = excluded.`count`', $result->query); + } + + public function testInsertOrIgnore(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'John', 'email' => 'john@example.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT OR IGNORE INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['John', 'john@example.com'], $result->bindings); + } + + public function testInsertOrIgnoreBatch(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->set(['name' => 'Bob', 'email' => 'b@b.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT OR IGNORE INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['Alice', 'a@b.com', 'Bob', 'b@b.com'], $result->bindings); + } + + public function testSetJsonAppend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonAppend('tags', ['new_tag']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `docs` SET `tags` = json_group_array(value) FROM (SELECT value FROM json_each(IFNULL(`tags`, \'[]\')) UNION ALL SELECT value FROM json_each(?)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPrepend(): void + { + $result = (new Builder()) + ->from('docs') + ->setJsonPrepend('tags', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `docs` SET `tags` = json_group_array(value) FROM (SELECT value FROM json_each(?) UNION ALL SELECT value FROM json_each(IFNULL(`tags`, \'[]\'))) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonPrependOrderMatters(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonPrepend('items', ['first']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `items` = json_group_array(value) FROM (SELECT value FROM json_each(?) UNION ALL SELECT value FROM json_each(IFNULL(`items`, \'[]\'))) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = json_insert(`tags`, \'$[0]\', json(?)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonInsertWithIndex(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonInsert('items', 3, 'value') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `items` = json_insert(`items`, \'$[3]\', json(?)) WHERE `id` IN (?)', $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->assertSame('UPDATE `docs` SET `tags` = (SELECT json_group_array(value) FROM json_each(`tags`) WHERE value != json(?)) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonIntersect(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonIntersect('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT json_group_array(value) FROM json_each(IFNULL(`tags`, \'[]\')) WHERE value IN (SELECT value FROM json_each(?))) WHERE `id` IN (?)', $result->query); + } + + public function testSetJsonDiff(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['x']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame('UPDATE `t` SET `tags` = (SELECT json_group_array(value) FROM json_each(IFNULL(`tags`, \'[]\')) WHERE value NOT IN (SELECT value FROM json_each(?))) WHERE `id` IN (?)', $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->assertSame('UPDATE `t` SET `tags` = (SELECT json_group_array(DISTINCT value) FROM json_each(IFNULL(`tags`, \'[]\'))) WHERE `id` IN (?)', $result->query); + } + + public function testUpdateClearsJsonSets(): void + { + $builder = (new Builder()) + ->from('t') + ->setJsonAppend('tags', ['a']) + ->filter([Query::equal('id', [1])]); + + $result1 = $builder->update(); + $this->assertBindingCount($result1); + $this->assertSame('UPDATE `t` SET `tags` = json_group_array(value) FROM (SELECT value FROM json_each(IFNULL(`tags`, \'[]\')) UNION ALL SELECT value FROM json_each(?)) WHERE `id` IN (?)', $result1->query); + + $builder->reset(); + + $result2 = $builder + ->from('t') + ->set(['name' => 'test']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result2); + $this->assertStringNotContainsString('json_group_array', $result2->query); + } + + public function testCountWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? THEN 1 END) AS `active_count` FROM `orders`', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testCountWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? THEN 1 END) FROM `orders`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSumWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', 'total_active', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(CASE WHEN status = ? THEN `amount` END) AS `total_active` FROM `orders`', $result->query); + $this->assertSame(['active'], $result->bindings); + } + + public function testSumWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->sumWhen('amount', 'status = ?', '', 'active') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT SUM(CASE WHEN status = ? THEN `amount` END) FROM `orders`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testAvgWhenWithAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'region = ?', 'avg_east', 'east') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT AVG(CASE WHEN region = ? THEN `amount` END) AS `avg_east` FROM `orders`', $result->query); + $this->assertSame(['east'], $result->bindings); + } + + public function testAvgWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('orders') + ->avgWhen('amount', 'region = ?', '', 'east') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT AVG(CASE WHEN region = ? THEN `amount` END) FROM `orders`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMinWhenWithAlias(): void + { + $result = (new Builder()) + ->from('products') + ->minWhen('price', 'category = ?', 'min_electronics', 'electronics') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MIN(CASE WHEN category = ? THEN `price` END) AS `min_electronics` FROM `products`', $result->query); + $this->assertSame(['electronics'], $result->bindings); + } + + public function testMinWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('products') + ->minWhen('price', 'category = ?', '', 'electronics') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MIN(CASE WHEN category = ? THEN `price` END) FROM `products`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testMaxWhenWithAlias(): void + { + $result = (new Builder()) + ->from('products') + ->maxWhen('price', 'category = ?', 'max_electronics', 'electronics') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MAX(CASE WHEN category = ? THEN `price` END) AS `max_electronics` FROM `products`', $result->query); + $this->assertSame(['electronics'], $result->bindings); + } + + public function testMaxWhenWithoutAlias(): void + { + $result = (new Builder()) + ->from('products') + ->maxWhen('price', 'category = ?', '', 'electronics') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT MAX(CASE WHEN category = ? THEN `price` END) FROM `products`', $result->query); + $this->assertStringNotContainsString(' AS ', $result->query); + } + + public function testSpatialDistanceThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Spatial distance queries are not supported in SQLite.'); + + (new Builder()) + ->from('t') + ->filter([Query::distanceLessThan('attr', [0, 0], 1000, true)]) + ->build(); + } + + public function testSpatialPredicateThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Spatial predicates are not supported in SQLite.'); + + (new Builder()) + ->from('t') + ->filter([Query::intersects('attr', [1.0, 2.0])]) + ->build(); + } + + public function testSpatialNotIntersectsThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::notIntersects('attr', [1.0, 2.0])]) + ->build(); + } + + public function testSpatialCoversThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Spatial covers predicates are not supported in SQLite.'); + + (new Builder()) + ->from('t') + ->filterCovers('area', [1.0, 2.0]) + ->build(); + } + + public function testFilterJsonContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonContains('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) AND EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testFilterJsonNotContains(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonNotContains('tags', ['admin']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE NOT (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testFilterJsonOverlaps(): void + { + $result = (new Builder()) + ->from('docs') + ->filterJsonOverlaps('tags', ['php', 'js']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `docs` WHERE (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) OR EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testFilterJsonPathValid(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', '>=', 21) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE json_extract(`data`, \'$.age\') >= ?', $result->query); + $this->assertSame(21, $result->bindings[0]); + } + + public function testFilterJsonPathInvalidPathThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid JSON path'); + + (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age; DROP TABLE users', '=', 1) + ->build(); + } + + public function testFilterJsonPathInvalidOperatorThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid JSON path operator'); + + (new Builder()) + ->from('users') + ->filterJsonPath('data', 'age', 'LIKE', 1) + ->build(); + } + + public function testFilterJsonPathAllOperators(): void + { + $operators = ['=', '!=', '<', '>', '<=', '>=', '<>']; + foreach ($operators as $op) { + $result = (new Builder()) + ->from('t') + ->filterJsonPath('data', 'val', $op, 42) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringContainsString("json_extract(`data`, '$.val') {$op} ?", $result->query); + } + } + + public function testFilterJsonContainsSingleItem(): void + { + $result = (new Builder()) + ->from('t') + ->filterJsonContains('tags', 'php') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` WHERE (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testFilterJsonContainsMultipleItems(): void + { + $result = (new Builder()) + ->from('t') + ->filterJsonContains('tags', ['php', 'js', 'rust']) + ->build(); + $this->assertBindingCount($result); + + $count = substr_count($result->query, 'EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?))'); + $this->assertSame(3, $count); + $this->assertSame('SELECT * FROM `t` WHERE (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) AND EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) AND EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testFilterJsonOverlapsMultipleItems(): void + { + $result = (new Builder()) + ->from('t') + ->filterJsonOverlaps('tags', ['a', 'b', 'c']) + ->build(); + $this->assertBindingCount($result); + + $count = substr_count($result->query, 'EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?))'); + $this->assertSame(3, $count); + $this->assertSame('SELECT * FROM `t` WHERE (EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) OR EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)) OR EXISTS (SELECT 1 FROM json_each(`tags`) WHERE json_each.value = json(?)))', $result->query); + } + + public function testResetClearsJsonSets(): void + { + $builder = new Builder(); + $builder->from('t')->setJsonAppend('tags', ['a']); + $builder->reset(); + + $result = $builder + ->from('t') + ->set(['name' => 'test']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('json_group_array', $result->query); + $this->assertSame('UPDATE `t` SET `name` = ? WHERE `id` IN (?)', $result->query); + } + + public function testBasicSelect(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t`', $result->query); + } + + public function testSelectWithColumns(): void + { + $result = (new Builder()) + ->select(['name', 'email']) + ->from('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `email` FROM `users`', $result->query); + } + + public function testFilterAndSort(): void + { + $result = (new Builder()) + ->select(['name']) + ->from('users') + ->filter([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + ]) + ->sortAsc('name') + ->limit(10) + ->offset(5) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `name` FROM `users` WHERE `status` IN (?) AND `age` > ? ORDER BY `name` ASC LIMIT ? OFFSET ?', + $result->query + ); + $this->assertSame(['active', 18, 10, 5], $result->bindings); + } + + public function testInsertSingleRow(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'Alice', 'email' => 'a@b.com']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?)', + $result->query + ); + $this->assertSame(['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->assertSame( + 'INSERT INTO `users` (`name`, `email`) VALUES (?, ?), (?, ?)', + $result->query + ); + $this->assertSame(['Alice', 'a@b.com', 'Bob', 'b@b.com'], $result->bindings); + } + + public function testUpdateWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([Query::equal('status', ['inactive'])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertSame( + 'UPDATE `users` SET `status` = ? WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['archived', 'inactive'], $result->bindings); + } + + public function testDeleteWithWhere(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::lessThan('last_login', '2024-01-01')]) + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame( + 'DELETE FROM `users` WHERE `last_login` < ?', + $result->query + ); + $this->assertSame(['2024-01-01'], $result->bindings); + } + + public function testDeleteWithoutWhere(): void + { + $result = (new Builder()) + ->from('users') + ->delete(); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `users`', $result->query); + } + + public function testTransactionStatements(): void + { + $builder = new Builder(); + + $this->assertSame('BEGIN', $builder->begin()->query); + $this->assertSame('COMMIT', $builder->commit()->query); + $this->assertSame('ROLLBACK', $builder->rollback()->query); + } + + public function testSavepoint(): void + { + $builder = new Builder(); + + $this->assertSame('SAVEPOINT `sp1`', $builder->savepoint('sp1')->query); + $this->assertSame('RELEASE SAVEPOINT `sp1`', $builder->releaseSavepoint('sp1')->query); + $this->assertSame('ROLLBACK TO SAVEPOINT `sp1`', $builder->rollbackToSavepoint('sp1')->query); + } + + public function testForUpdate(): void + { + $result = (new Builder()) + ->from('t') + ->forUpdate() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` FOR UPDATE', $result->query); + } + + public function testForShare(): void + { + $result = (new Builder()) + ->from('t') + ->forShare() + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` FOR SHARE', $result->query); + } + + public function testSetJsonAppendBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonAppend('tags', ['a', 'b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode(['a', 'b']), $result->bindings); + } + + public function testSetJsonPrependBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonPrepend('tags', ['x', 'y']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode(['x', 'y']), $result->bindings); + } + + public function testSetJsonInsertBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonInsert('items', 5, 'hello') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode('hello'), $result->bindings); + } + + public function testSetJsonRemoveBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonRemove('tags', 'remove_me') + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode('remove_me'), $result->bindings); + } + + public function testSetJsonIntersectBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonIntersect('tags', ['keep_a', 'keep_b']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode(['keep_a', 'keep_b']), $result->bindings); + } + + public function testSetJsonDiffBindingValues(): void + { + $result = (new Builder()) + ->from('t') + ->setJsonDiff('tags', ['remove_a']) + ->filter([Query::equal('id', [1])]) + ->update(); + $this->assertBindingCount($result); + + $this->assertContains(\json_encode(['remove_a']), $result->bindings); + } + + public function testConditionalAggregatesMultipleBindings(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ? AND region = ?', 'combo', 'active', 'east') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? AND region = ? THEN 1 END) AS `combo` FROM `orders`', $result->query); + $this->assertSame(['active', 'east'], $result->bindings); + } + + public function testSpatialDistanceGreaterThanThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::distanceGreaterThan('attr', [0, 0], 500, false)]) + ->build(); + } + + public function testSpatialEqualsThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filterSpatialEquals('area', [1.0, 2.0]) + ->build(); + } + + public function testSpatialCrossesThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::crosses('path', [1.0, 2.0])]) + ->build(); + } + + public function testSpatialTouchesThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::touches('area', [1.0, 2.0])]) + ->build(); + } + + public function testSpatialOverlapsThrows(): void + { + $this->expectException(UnsupportedException::class); + + (new Builder()) + ->from('t') + ->filter([Query::overlaps('area', [[0, 0], [1, 1]])]) + ->build(); + } + + public function testExactUpsertQuery(): void + { + $result = (new Builder()) + ->into('settings') + ->set(['key' => 'theme', 'value' => 'dark']) + ->onConflict(['key'], ['value']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT INTO `settings` (`key`, `value`) VALUES (?, ?) ON CONFLICT (`key`) DO UPDATE SET `value` = excluded.`value`', + $result->query + ); + $this->assertSame(['theme', 'dark'], $result->bindings); + } + + public function testExactInsertOrIgnoreQuery(): void + { + $result = (new Builder()) + ->into('t') + ->set(['id' => 1, 'name' => 'test']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertSame( + 'INSERT OR IGNORE INTO `t` (`id`, `name`) VALUES (?, ?)', + $result->query + ); + $this->assertSame([1, 'test'], $result->bindings); + } + + public function testExactCountWhenQuery(): void + { + $result = (new Builder()) + ->from('t') + ->countWhen('active = ?', 'active_count', 1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT COUNT(CASE WHEN active = ? THEN 1 END) AS `active_count` FROM `t`', + $result->query + ); + $this->assertSame([1], $result->bindings); + } + + public function testExactSumWhenQuery(): void + { + $result = (new Builder()) + ->from('t') + ->sumWhen('amount', 'type = ?', 'credit_total', 'credit') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT SUM(CASE WHEN type = ? THEN `amount` END) AS `credit_total` FROM `t`', + $result->query + ); + $this->assertSame(['credit'], $result->bindings); + } + + public function testExactAvgWhenQuery(): void + { + $result = (new Builder()) + ->from('t') + ->avgWhen('score', 'grade = ?', 'avg_a', 'A') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT AVG(CASE WHEN grade = ? THEN `score` END) AS `avg_a` FROM `t`', + $result->query + ); + $this->assertSame(['A'], $result->bindings); + } + + public function testExactMinWhenQuery(): void + { + $result = (new Builder()) + ->from('t') + ->minWhen('price', 'in_stock = ?', 'min_available', 1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MIN(CASE WHEN in_stock = ? THEN `price` END) AS `min_available` FROM `t`', + $result->query + ); + $this->assertSame([1], $result->bindings); + } + + public function testExactMaxWhenQuery(): void + { + $result = (new Builder()) + ->from('t') + ->maxWhen('price', 'in_stock = ?', 'max_available', 1) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT MAX(CASE WHEN in_stock = ? THEN `price` END) AS `max_available` FROM `t`', + $result->query + ); + $this->assertSame([1], $result->bindings); + } + + public function testExactFilterJsonPathQuery(): void + { + $result = (new Builder()) + ->from('users') + ->filterJsonPath('profile', 'settings.theme', '=', 'dark') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + "SELECT * FROM `users` WHERE json_extract(`profile`, '$.settings.theme') = ?", + $result->query + ); + $this->assertSame(['dark'], $result->bindings); + } + + public function testSetJsonAppendReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonAppend('tags', ['a']); + $this->assertSame($builder, $returned); + } + + public function testSetJsonPrependReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonPrepend('tags', ['a']); + $this->assertSame($builder, $returned); + } + + public function testSetJsonInsertReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonInsert('tags', 0, 'a'); + $this->assertSame($builder, $returned); + } + + public function testSetJsonRemoveReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonRemove('tags', 'a'); + $this->assertSame($builder, $returned); + } + + public function testSetJsonIntersectReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonIntersect('tags', ['a']); + $this->assertSame($builder, $returned); + } + + public function testSetJsonDiffReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonDiff('tags', ['a']); + $this->assertSame($builder, $returned); + } + + public function testSetJsonUniqueReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->from('t')->setJsonUnique('tags'); + $this->assertSame($builder, $returned); + } + + public function testResetReturnsSelf(): void + { + $builder = new Builder(); + $returned = $builder->reset(); + $this->assertSame($builder, $returned); + } + + public function testCteJoinWhereGroupByHavingOrderLimit(): void + { + $cte = (new Builder()) + ->from('raw_orders') + ->select(['customer_id', 'amount']) + ->filter([Query::greaterThan('amount', 0)]); + + $result = (new Builder()) + ->with('filtered_orders', $cte) + ->from('filtered_orders') + ->join('customers', 'filtered_orders.customer_id', 'customers.id') + ->filter([Query::equal('customers.active', [1])]) + ->sum('filtered_orders.amount', 'total') + ->groupBy(['customers.country']) + ->having([Query::greaterThan('total', 100)]) + ->sortDesc('total') + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `filtered_orders` AS (SELECT `customer_id`, `amount` FROM `raw_orders` WHERE `amount` > ?) SELECT SUM(`filtered_orders`.`amount`) AS `total` FROM `filtered_orders` JOIN `customers` ON `filtered_orders`.`customer_id` = `customers`.`id` WHERE `customers`.`active` IN (?) GROUP BY `customers`.`country` HAVING SUM(`filtered_orders`.`amount`) > ? ORDER BY `total` DESC LIMIT ?', $result->query); + } + + public function testRecursiveCteWithFilter(): void + { + $seed = (new Builder()) + ->from('categories') + ->select(['id', 'name', 'parent_id']) + ->filter([Query::isNull('parent_id')]); + + $step = (new Builder()) + ->from('categories') + ->select(['categories.id', 'categories.name', 'categories.parent_id']) + ->join('tree', 'categories.parent_id', 'tree.id'); + + $result = (new Builder()) + ->withRecursiveSeedStep('tree', $seed, $step) + ->from('tree') + ->filter([Query::notEqual('name', 'Hidden')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH RECURSIVE `tree` AS (SELECT `id`, `name`, `parent_id` FROM `categories` WHERE `parent_id` IS NULL UNION ALL SELECT `categories`.`id`, `categories`.`name`, `categories`.`parent_id` FROM `categories` JOIN `tree` ON `categories`.`parent_id` = `tree`.`id`) SELECT * FROM `tree` WHERE `name` != ?', $result->query); + } + + public function testMultipleCTEs(): void + { + $cte1 = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->sum('total', 'order_sum') + ->groupBy(['customer_id']); + + $cte2 = (new Builder()) + ->from('customers') + ->select(['id', 'name']) + ->filter([Query::equal('active', [1])]); + + $result = (new Builder()) + ->with('order_sums', $cte1) + ->with('active_customers', $cte2) + ->from('order_sums') + ->join('active_customers', 'order_sums.customer_id', 'active_customers.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('WITH `order_sums` AS (SELECT SUM(`total`) AS `order_sum`, `customer_id` FROM `orders` GROUP BY `customer_id`), `active_customers` AS (SELECT `id`, `name` FROM `customers` WHERE `active` IN (?)) SELECT * FROM `order_sums` JOIN `active_customers` ON `order_sums`.`customer_id` = `active_customers`.`id`', $result->query); + } + + public function testWindowFunctionWithJoin(): void + { + $result = (new Builder()) + ->from('sales') + ->join('products', 'sales.product_id', 'products.id') + ->selectWindow('ROW_NUMBER()', 'rn', ['products.category'], ['sales.amount']) + ->select(['products.name', 'sales.amount']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `products`.`name`, `sales`.`amount`, ROW_NUMBER() OVER (PARTITION BY `products`.`category` ORDER BY `sales`.`amount` ASC) AS `rn` FROM `sales` JOIN `products` ON `sales`.`product_id` = `products`.`id`', $result->query); + } + + public function testWindowFunctionWithGroupBy(): void + { + $result = (new Builder()) + ->from('sales') + ->selectWindow('SUM(amount)', 'running', ['category'], ['date']) + ->select(['category', 'date']) + ->groupBy(['category', 'date']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `category`, `date`, SUM(amount) OVER (PARTITION BY `category` ORDER BY `date` ASC) AS `running` FROM `sales` GROUP BY `category`, `date`', $result->query); + } + + public function testMultipleWindowFunctions(): void + { + $result = (new Builder()) + ->from('employees') + ->selectWindow('ROW_NUMBER()', 'rn', ['department'], ['salary']) + ->selectWindow('RANK()', 'rnk', ['department'], ['-salary']) + ->select(['name', 'department', 'salary']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `name`, `department`, `salary`, ROW_NUMBER() OVER (PARTITION BY `department` ORDER BY `salary` ASC) AS `rn`, RANK() OVER (PARTITION BY `department` ORDER BY `salary` DESC) AS `rnk` FROM `employees`', $result->query); + } + + public function testNamedWindowDefinition(): void + { + $result = (new Builder()) + ->from('sales') + ->window('w', ['category'], ['date']) + ->selectWindow('SUM(amount)', 'total', null, null, 'w') + ->select(['category', 'date', 'amount']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `category`, `date`, `amount`, SUM(amount) OVER `w` AS `total` FROM `sales` WINDOW `w` AS (PARTITION BY `category` ORDER BY `date` ASC)', $result->query); + } + + public function testJoinAggregateGroupByHaving(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->count('*', 'cnt') + ->sum('orders.total', 'revenue') + ->groupBy(['customers.country']) + ->having([Query::greaterThan('cnt', 5)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt`, SUM(`orders`.`total`) AS `revenue` FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` GROUP BY `customers`.`country` HAVING COUNT(*) > ?', $result->query); + } + + public function testSelfJoin(): void + { + $result = (new Builder()) + ->from('employees', 'e') + ->leftJoin('employees', 'e.manager_id', 'm.id', '=', 'm') + ->select(['e.name', 'm.name']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT `e`.`name`, `m`.`name` FROM `employees` AS `e` LEFT JOIN `employees` AS `m` ON `e`.`manager_id` = `m`.`id`', $result->query); + } + + public function testTripleJoin(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->join('products', 'orders.product_id', 'products.id') + ->leftJoin('categories', 'products.category_id', 'categories.id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` JOIN `products` ON `orders`.`product_id` = `products`.`id` LEFT JOIN `categories` ON `products`.`category_id` = `categories`.`id`', $result->query); + } + + public function testLeftJoinWithInnerJoinCombined(): void + { + $result = (new Builder()) + ->from('orders') + ->join('customers', 'orders.customer_id', 'customers.id') + ->leftJoin('discounts', 'orders.discount_id', 'discounts.id') + ->filter([Query::isNotNull('customers.email')]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` JOIN `customers` ON `orders`.`customer_id` = `customers`.`id` LEFT JOIN `discounts` ON `orders`.`discount_id` = `discounts`.`id` WHERE `customers`.`email` IS NOT NULL', $result->query); + } + + public function testUnionAndUnionAllMixed(): void + { + $q2 = (new Builder())->from('t2')->filter([Query::equal('year', [2023])]); + $q3 = (new Builder())->from('t3')->filter([Query::equal('year', [2022])]); + + $result = (new Builder()) + ->from('t1') + ->filter([Query::equal('year', [2024])]) + ->union($q2) + ->unionAll($q3) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t1` WHERE `year` IN (?) UNION SELECT * FROM `t2` WHERE `year` IN (?) UNION ALL SELECT * FROM `t3` WHERE `year` IN (?)', $result->query); + $this->assertStringNotContainsString('(SELECT', $result->query); + } + + public function testUnionEmitsBareCompoundSelect(): void + { + $other = (new Builder()) + ->from('archived_users') + ->select(['id', 'name']); + + $result = (new Builder()) + ->from('users') + ->select(['id', 'name']) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT `id`, `name` FROM `users` UNION SELECT `id`, `name` FROM `archived_users`', + $result->query, + ); + } + + public function testMultipleUnions(): void + { + $q2 = (new Builder())->from('t2'); + $q3 = (new Builder())->from('t3'); + $q4 = (new Builder())->from('t4'); + + $result = (new Builder()) + ->from('t1') + ->union($q2) + ->union($q3) + ->union($q4) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(3, substr_count($result->query, 'UNION')); + } + + public function testSubSelectWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->select(['customer_id']) + ->sum('total', 'total_spent') + ->groupBy(['customer_id']); + + $result = (new Builder()) + ->from('customers') + ->selectSub($sub, 'spending') + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT (SELECT SUM(`total`) AS `total_spent`, `customer_id` FROM `orders` GROUP BY `customer_id`) AS `spending` FROM `customers` WHERE `active` IN (?)', $result->query); + } + + public function testFromSubqueryWithJoin(): void + { + $sub = (new Builder()) + ->from('events') + ->select(['user_id']) + ->count('*', 'event_count') + ->groupBy(['user_id']); + + $result = (new Builder()) + ->fromSub($sub, 'user_events') + ->join('users', 'user_events.user_id', 'users.id') + ->filter([Query::greaterThan('event_count', 10)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM (SELECT COUNT(*) AS `event_count`, `user_id` FROM `events` GROUP BY `user_id`) AS `user_events` JOIN `users` ON `user_events`.`user_id` = `users`.`id` WHERE `event_count` > ?', $result->query); + } + + public function testFilterWhereInSubquery(): void + { + $sub = (new Builder()) + ->from('premium_users') + ->select(['id']) + ->filter([Query::equal('tier', ['gold'])]); + + $result = (new Builder()) + ->from('orders') + ->filterWhereIn('user_id', $sub) + ->filter([Query::greaterThan('total', 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `total` > ? AND `user_id` IN (SELECT `id` FROM `premium_users` WHERE `tier` IN (?))', $result->query); + } + + public function testExistsSubqueryWithFilter(): void + { + $sub = (new Builder()) + ->from('orders') + ->filter([Query::raw('orders.customer_id = customers.id')]) + ->filter([Query::greaterThan('total', 500)]); + + $result = (new Builder()) + ->from('customers') + ->filterExists($sub) + ->filter([Query::equal('active', [1])]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `customers` WHERE `active` IN (?) AND EXISTS (SELECT * FROM `orders` WHERE orders.customer_id = customers.id AND `total` > ?)', $result->query); + } + + public function testInsertOrIgnoreVerifySyntax(): void + { + $result = (new Builder()) + ->into('users') + ->set(['id' => 1, 'name' => 'Test', 'email' => 'test@test.com']) + ->insertOrIgnore(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('INSERT OR IGNORE INTO', $result->query); + $this->assertSame('INSERT OR IGNORE INTO `users` (`id`, `name`, `email`) VALUES (?, ?, ?)', $result->query); + } + + public function testUpsertConflictHandling(): void + { + $result = (new Builder()) + ->into('settings') + ->set(['key' => 'theme', 'value' => 'dark', 'updated_at' => '2024-01-01']) + ->onConflict(['key'], ['value', 'updated_at']) + ->upsert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `settings` (`key`, `value`, `updated_at`) VALUES (?, ?, ?) ON CONFLICT (`key`) DO UPDATE SET `value` = excluded.`value`, `updated_at` = excluded.`updated_at`', $result->query); + } + + public function testCaseExpressionWithWhere(): void + { + $case = (new CaseExpression()) + ->when('status', Operator::Equal, 'active', 'Active') + ->when('status', Operator::Equal, 'inactive', 'Inactive') + ->else('Unknown') + ->alias('label'); + + $result = (new Builder()) + ->from('users') + ->selectCase($case) + ->filter([Query::greaterThan('age', 18)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CASE WHEN `status` = ? THEN ? WHEN `status` = ? THEN ? ELSE ? END AS `label` FROM `users` WHERE `age` > ?', $result->query); + } + + public function testBeforeBuildCallback(): void + { + $callbackCalled = false; + $result = (new Builder()) + ->from('users') + ->beforeBuild(function (Builder $b) use (&$callbackCalled) { + $callbackCalled = true; + $b->filter([Query::equal('injected', ['yes'])]); + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($callbackCalled); + $this->assertSame('SELECT * FROM `users` WHERE `injected` IN (?)', $result->query); + } + + public function testAfterBuildCallback(): void + { + $capturedQuery = ''; + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->afterBuild(function (Statement $r) use (&$capturedQuery) { + $capturedQuery = 'executed'; + return $r; + }) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('executed', $capturedQuery); + } + + public function testUpdateWithComplexFilter(): void + { + $result = (new Builder()) + ->from('users') + ->set(['status' => 'archived']) + ->filter([ + Query::or([ + Query::lessThan('last_login', '2023-01-01'), + Query::and([ + Query::equal('role', ['guest']), + Query::isNull('email_verified_at'), + ]), + ]), + ]) + ->update(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('UPDATE `users` SET', $result->query); + $this->assertSame('UPDATE `users` SET `status` = ? WHERE (`last_login` < ? OR (`role` IN (?) AND `email_verified_at` IS NULL))', $result->query); + } + + public function testDeleteWithSubqueryFilter(): void + { + $sub = (new Builder()) + ->from('blocked_ids') + ->select(['user_id']); + + $result = (new Builder()) + ->from('sessions') + ->filterWhereIn('user_id', $sub) + ->delete(); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('DELETE FROM `sessions`', $result->query); + $this->assertSame('DELETE FROM `sessions` WHERE `user_id` IN (SELECT `user_id` FROM `blocked_ids`)', $result->query); + } + + public function testNestedLogicalOperatorsDepth3(): void + { + $result = (new Builder()) + ->from('t') + ->filter([ + Query::or([ + Query::and([ + Query::or([ + Query::equal('a', [1]), + Query::equal('b', [2]), + ]), + Query::greaterThan('c', 3), + ]), + Query::lessThan('d', 4), + ]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([1, 2, 3, 4], $result->bindings); + } + + public function testIsNullAndEqualCombined(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::isNull('deleted_at'), + Query::equal('status', ['active']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `deleted_at` IS NULL AND `status` IN (?)', $result->query); + } + + public function testBetweenAndGreaterThanCombined(): void + { + $result = (new Builder()) + ->from('products') + ->filter([ + Query::between('price', 10, 100), + Query::greaterThan('stock', 0), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `products` WHERE `price` BETWEEN ? AND ? AND `stock` > ?', $result->query); + $this->assertSame([10, 100, 0], $result->bindings); + } + + public function testStartsWithAndContainsCombined(): void + { + $result = (new Builder()) + ->from('files') + ->filter([ + Query::startsWith('path', '/usr'), + Query::containsString('name', ['test']), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `files` WHERE `path` LIKE ? AND `name` LIKE ?', $result->query); + } + + public function testDistinctWithAggregate(): void + { + $result = (new Builder()) + ->from('orders') + ->distinct() + ->countDistinct('customer_id', 'unique_customers') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT DISTINCT COUNT(DISTINCT `customer_id`) AS `unique_customers` FROM `orders`', $result->query); + } + + public function testMultipleOrderByColumns(): void + { + $result = (new Builder()) + ->from('users') + ->sortAsc('last_name') + ->sortAsc('first_name') + ->sortDesc('created_at') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame( + 'SELECT * FROM `users` ORDER BY `last_name` ASC, `first_name` ASC, `created_at` DESC', + $result->query + ); + } + + public function testEmptySelectReturnsAllColumns(): void + { + $result = (new Builder()) + ->from('t') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t`', $result->query); + } + + public function testBooleanValuesInFilters(): void + { + $result = (new Builder()) + ->from('users') + ->filter([ + Query::equal('active', [true]), + Query::equal('deleted', [false]), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame([true, false], $result->bindings); + } + + public function testBindingOrderVerification(): void + { + $cte = (new Builder()) + ->from('raw') + ->filter([Query::greaterThan('val', 0)]); + + $result = (new Builder()) + ->with('filtered', $cte) + ->from('filtered') + ->filter([Query::equal('status', ['active'])]) + ->count('*', 'cnt') + ->groupBy(['region']) + ->having([Query::greaterThan('cnt', 5)]) + ->limit(10) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame(0, $result->bindings[0]); + $this->assertSame('active', $result->bindings[1]); + $this->assertSame(5, $result->bindings[2]); + $this->assertSame(10, $result->bindings[3]); + } + + public function testCloneAndModify(): void + { + $original = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]); + + $cloned = $original->clone(); + $cloned->filter([Query::greaterThan('age', 18)]); + + $origResult = $original->build(); + $clonedResult = $cloned->build(); + $this->assertBindingCount($origResult); + $this->assertBindingCount($clonedResult); + + $this->assertStringNotContainsString('`age`', $origResult->query); + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `age` > ?', $clonedResult->query); + } + + public function testResetAndRebuild(): void + { + $builder = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->sortAsc('name') + ->limit(10); + + $builder->build(); + $builder->reset(); + + $result = $builder + ->from('orders') + ->filter([Query::greaterThan('total', 100)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `orders` WHERE `total` > ?', $result->query); + $this->assertSame([100], $result->bindings); + } + + public function testReadOnlyFlagOnSelect(): void + { + $result = (new Builder()) + ->from('users') + ->build(); + $this->assertBindingCount($result); + + $this->assertTrue($result->readOnly); + } + + public function testReadOnlyFlagOnInsert(): void + { + $result = (new Builder()) + ->into('users') + ->set(['name' => 'test']) + ->insert(); + $this->assertBindingCount($result); + + $this->assertFalse($result->readOnly); + } + + public function testMultipleSetForInsertUpdate(): void + { + $result = (new Builder()) + ->into('events') + ->set(['name' => 'a', 'value' => 1]) + ->set(['name' => 'b', 'value' => 2]) + ->set(['name' => 'c', 'value' => 3]) + ->insert(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `events` (`name`, `value`) VALUES (?, ?), (?, ?), (?, ?)', $result->query); + } + + public function testGroupByMultipleColumns(): void + { + $result = (new Builder()) + ->from('orders') + ->count('*', 'cnt') + ->groupBy(['region', 'category', 'year']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(*) AS `cnt` FROM `orders` GROUP BY `region`, `category`, `year`', $result->query); + } + + public function testExplainQuery(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->explain(); + + $this->assertStringStartsWith('EXPLAIN SELECT', $result->query); + $this->assertTrue($result->readOnly); + } + + public function testExplainAnalyzeQuery(): void + { + $result = (new Builder()) + ->from('users') + ->explain(true); + + $this->assertStringStartsWith('EXPLAIN ANALYZE SELECT', $result->query); + } + + public function testFilterWhereNotInSubquery(): void + { + $sub = (new Builder()) + ->from('blocked') + ->select(['user_id']); + + $result = (new Builder()) + ->from('users') + ->filterWhereNotIn('id', $sub) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `id` NOT IN (SELECT `user_id` FROM `blocked`)', $result->query); + } + + public function testInsertSelectFromSubquery(): void + { + $source = (new Builder()) + ->from('staging') + ->select(['name', 'email']) + ->filter([Query::equal('imported', [0])]); + + $result = (new Builder()) + ->into('users') + ->fromSelect(['name', 'email'], $source) + ->insertSelect(); + $this->assertBindingCount($result); + + $this->assertSame('INSERT INTO `users` (`name`, `email`) SELECT `name`, `email` FROM `staging` WHERE `imported` IN (?)', $result->query); + } + + public function testLimitOneOffsetZero(): void + { + $result = (new Builder()) + ->from('t') + ->limit(1) + ->offset(0) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $result->query); + $this->assertSame([1, 0], $result->bindings); + } + + public function testSelectRawExpression(): void + { + $result = (new Builder()) + ->from('users') + ->select("strftime('%Y', created_at) AS year") + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT strftime(\'%Y\', created_at) AS year FROM `users`', $result->query); + } + + public function testCountWhenWithGroupBy(): void + { + $result = (new Builder()) + ->from('orders') + ->countWhen('status = ?', 'active_count', 'active') + ->countWhen('status = ?', 'pending_count', 'pending') + ->groupBy(['region']) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT COUNT(CASE WHEN status = ? THEN 1 END) AS `active_count`, COUNT(CASE WHEN status = ? THEN 1 END) AS `pending_count` FROM `orders` GROUP BY `region`', $result->query); + } + + public function testNotBetweenFilter(): void + { + $result = (new Builder()) + ->from('products') + ->filter([Query::notBetween('price', 10, 50)]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `products` WHERE `price` NOT BETWEEN ? AND ?', $result->query); + $this->assertSame([10, 50], $result->bindings); + } + + public function testMultipleFilterTypes(): void + { + $result = (new Builder()) + ->from('products') + ->filter([ + Query::greaterThan('price', 10), + Query::startsWith('name', 'Pro'), + Query::containsString('description', ['premium']), + Query::isNotNull('sku'), + ]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `products` WHERE `price` > ? AND `name` LIKE ? AND `description` LIKE ? AND `sku` IS NOT NULL', $result->query); + } + + public function testFromNoneEmitsNoFromClause(): void + { + $result = (new Builder()) + ->fromNone() + ->selectRaw('1 + 1') + ->build(); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('FROM', $result->query); + $this->assertSame('SELECT 1 + 1', $result->query); + } + + public function testSelectCastEmitsCastExpression(): void + { + $result = (new Builder()) + ->from('products') + ->selectCast('price', 'DECIMAL(10, 2)', 'price_decimal') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT CAST(`price` AS DECIMAL(10, 2)) AS `price_decimal` FROM `products`', $result->query); + } + + public function testSelectCastRejectsInvalidType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new Builder()) + ->from('t') + ->selectCast('c', 'INT); DROP TABLE x;--', 'a'); + } + + public function testSelectWindowRejectsInvalidFunction(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new Builder()) + ->from('t') + ->selectWindow('ROW_NUMBER()); DROP --', 'w'); + } + + /** + * @return list + */ + public static function reservedWordsProvider(): array + { + return [ + ['select'], + ['from'], + ['where'], + ['order'], + ['group'], + ['having'], + ['user'], + ['table'], + ['insert'], + ['update'], + ['delete'], + ['join'], + ['on'], + ['and'], + ['or'], + ['not'], + ['in'], + ['between'], + ['like'], + ['is'], + ['null'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInSelect(string $word): void + { + $result = (new Builder()) + ->from('t') + ->select([$word]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $stripped = \preg_replace('/`[^`]+`/', '', $result->query) ?? ''; + // Lowercase reserved word must not appear bare outside quotes + $this->assertDoesNotMatchRegularExpression( + '/(?from($word) + ->build(); + + $this->assertStringContainsString('FROM `' . $word . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('reservedWordsProvider')] + public function testReservedWordInFilter(string $word): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($word, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $word . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + /** + * @return list + */ + public static function unicodeIdentifiersProvider(): array + { + return [ + ['café'], + ['日本'], + ['column_with_émoji'], + ['Ω_omega'], + ['данные'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInSelect(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->select([$identifier]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFrom(string $identifier): void + { + $result = (new Builder()) + ->from($identifier) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + } + + #[\PHPUnit\Framework\Attributes\DataProvider('unicodeIdentifiersProvider')] + public function testUnicodeIdentifierInFilter(string $identifier): void + { + $result = (new Builder()) + ->from('t') + ->filter([Query::equal($identifier, ['x'])]) + ->build(); + + $this->assertStringContainsString('`' . $identifier . '`', $result->query); + $this->assertSame(['x'], $result->bindings); + } + + public function testWhereRawAppendsFragmentAndBindings(): void + { + $result = (new Builder()) + ->from('users') + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE a = ?', $result->query); + $this->assertSame([1], $result->bindings); + } + + public function testWhereRawCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('b', [2])]) + ->whereRaw('a = ?', [1]) + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `b` IN (?) AND a = ?', $result->query); + $this->assertContains(1, $result->bindings); + $this->assertContains(2, $result->bindings); + } + + public function testWhereColumnEmitsQualifiedIdentifiers(): void + { + $result = (new Builder()) + ->from('users') + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testWhereColumnRejectsUnknownOperator(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid whereColumn operator: NOT_AN_OP'); + + (new Builder()) + ->from('users') + ->whereColumn('a', 'NOT_AN_OP', 'b'); + } + + public function testWhereColumnCombinesWithFilter(): void + { + $result = (new Builder()) + ->from('users') + ->filter([Query::equal('status', ['active'])]) + ->whereColumn('users.id', '=', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` WHERE `status` IN (?) AND `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertContains('active', $result->bindings); + } + +} diff --git a/tests/Query/Builder/SpatialDistanceFilterTest.php b/tests/Query/Builder/SpatialDistanceFilterTest.php new file mode 100644 index 0000000..9ce948b --- /dev/null +++ b/tests/Query/Builder/SpatialDistanceFilterTest.php @@ -0,0 +1,52 @@ +assertSame('POINT(1 2)', $filter->geometry); + $this->assertSame(10.5, $filter->distance); + $this->assertTrue($filter->meters); + } + + public function testConstructsFromArrayGeometry(): void + { + $filter = new SpatialDistanceFilter([1.0, 2.0], 42.0, false); + + $this->assertSame([1.0, 2.0], $filter->geometry); + $this->assertSame(42.0, $filter->distance); + $this->assertFalse($filter->meters); + } + + public function testFromTupleNormalizesArrayGeometry(): void + { + $filter = SpatialDistanceFilter::fromTuple([[10, 20], 100.0, true]); + + $this->assertSame([10, 20], $filter->geometry); + $this->assertSame(100.0, $filter->distance); + $this->assertTrue($filter->meters); + } + + public function testFromTupleCastsIntegerDistanceToFloat(): void + { + $filter = SpatialDistanceFilter::fromTuple(['POINT(0 0)', 50, false]); + + $this->assertSame(50.0, $filter->distance); + $this->assertFalse($filter->meters); + } + + public function testClassIsFinalAndReadonly(): void + { + $reflection = new \ReflectionClass(SpatialDistanceFilter::class); + + $this->assertTrue($reflection->isFinal()); + $this->assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Query/ConditionTest.php b/tests/Query/ConditionTest.php new file mode 100644 index 0000000..80a10a6 --- /dev/null +++ b/tests/Query/ConditionTest.php @@ -0,0 +1,78 @@ +assertSame('status = ?', $condition->expression); + } + + public function testGetBindings(): void + { + $condition = new Condition('status = ?', ['active']); + $this->assertSame(['active'], $condition->bindings); + } + + public function testEmptyBindings(): void + { + $condition = new Condition('1 = 1'); + $this->assertSame('1 = 1', $condition->expression); + $this->assertSame([], $condition->bindings); + } + + public function testMultipleBindings(): void + { + $condition = new Condition('age BETWEEN ? AND ?', [18, 65]); + $this->assertSame('age BETWEEN ? AND ?', $condition->expression); + $this->assertSame([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..674b8c2 --- /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->assertSame('Not supported', $e->getMessage()); + } +} diff --git a/tests/Query/Exception/ValidationExceptionTest.php b/tests/Query/Exception/ValidationExceptionTest.php new file mode 100644 index 0000000..5c57c4d --- /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->assertSame('Missing table', $e->getMessage()); + } +} diff --git a/tests/Query/ExceptionTest.php b/tests/Query/ExceptionTest.php index be16ef7..cc85514 100644 --- a/tests/Query/ExceptionTest.php +++ b/tests/Query/ExceptionTest.php @@ -10,31 +10,31 @@ class ExceptionTest extends TestCase public function testStringCodeCoercedToInt(): void { $exception = new Exception('test', '42'); - $this->assertEquals(42, $exception->getCode()); + $this->assertSame(42, $exception->getCode()); } public function testNonNumericStringCodeBecomesZero(): void { $exception = new Exception('test', 'abc'); - $this->assertEquals(0, $exception->getCode()); + $this->assertSame(0, $exception->getCode()); } public function testIntCodePassedThrough(): void { $exception = new Exception('test', 123); - $this->assertEquals(123, $exception->getCode()); + $this->assertSame(123, $exception->getCode()); } public function testDefaultCodeIsZero(): void { $exception = new Exception('test'); - $this->assertEquals(0, $exception->getCode()); + $this->assertSame(0, $exception->getCode()); } public function testMessageIsPreserved(): void { $exception = new Exception('Something went wrong'); - $this->assertEquals('Something went wrong', $exception->getMessage()); + $this->assertSame('Something went wrong', $exception->getMessage()); } public function testPreviousExceptionPreserved(): void diff --git a/tests/Query/FilterQueryTest.php b/tests/Query/FilterQueryTest.php index cd3f0ca..043982e 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,224 +11,224 @@ class FilterQueryTest extends TestCase public function testEqual(): void { $query = Query::equal('name', ['John', 'Jane']); - $this->assertEquals(Query::TYPE_EQUAL, $query->getMethod()); - $this->assertEquals('name', $query->getAttribute()); - $this->assertEquals(['John', 'Jane'], $query->getValues()); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertSame('name', $query->getAttribute()); + $this->assertSame(['John', 'Jane'], $query->getValues()); } public function testNotEqual(): void { $query = Query::notEqual('name', 'John'); - $this->assertEquals(Query::TYPE_NOT_EQUAL, $query->getMethod()); - $this->assertEquals(['John'], $query->getValues()); + $this->assertSame(Method::NotEqual, $query->getMethod()); + $this->assertSame(['John'], $query->getValues()); } public function testNotEqualWithList(): void { $query = Query::notEqual('name', ['John', 'Jane']); - $this->assertEquals(['John', 'Jane'], $query->getValues()); + $this->assertSame(['John', 'Jane'], $query->getValues()); } public function testNotEqualWithMap(): void { $query = Query::notEqual('data', ['key' => 'value']); - $this->assertEquals([['key' => 'value']], $query->getValues()); + $this->assertSame([['key' => 'value']], $query->getValues()); } public function testLessThan(): void { $query = Query::lessThan('age', 30); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); - $this->assertEquals('age', $query->getAttribute()); - $this->assertEquals([30], $query->getValues()); + $this->assertSame(Method::LessThan, $query->getMethod()); + $this->assertSame('age', $query->getAttribute()); + $this->assertSame([30], $query->getValues()); } public function testLessThanEqual(): void { $query = Query::lessThanEqual('age', 30); - $this->assertEquals(Query::TYPE_LESSER_EQUAL, $query->getMethod()); - $this->assertEquals([30], $query->getValues()); + $this->assertSame(Method::LessThanEqual, $query->getMethod()); + $this->assertSame([30], $query->getValues()); } public function testGreaterThan(): void { $query = Query::greaterThan('age', 18); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); - $this->assertEquals([18], $query->getValues()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); + $this->assertSame([18], $query->getValues()); } public function testGreaterThanEqual(): void { $query = Query::greaterThanEqual('age', 18); - $this->assertEquals(Query::TYPE_GREATER_EQUAL, $query->getMethod()); - $this->assertEquals([18], $query->getValues()); + $this->assertSame(Method::GreaterThanEqual, $query->getMethod()); + $this->assertSame([18], $query->getValues()); } public function testContains(): void { - $query = Query::contains('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); - $this->assertEquals(['php', 'js'], $query->getValues()); + $query = Query::containsString('tags', ['php', 'js']); + $this->assertSame(Method::Contains, $query->getMethod()); + $this->assertSame(['php', 'js'], $query->getValues()); } public function testContainsAny(): void { $query = Query::containsAny('tags', ['php', 'js']); - $this->assertEquals(Query::TYPE_CONTAINS_ANY, $query->getMethod()); - $this->assertEquals(['php', 'js'], $query->getValues()); + $this->assertSame(Method::ContainsAny, $query->getMethod()); + $this->assertSame(['php', 'js'], $query->getValues()); } public function testNotContains(): void { $query = Query::notContains('tags', ['php']); - $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); - $this->assertEquals(['php'], $query->getValues()); + $this->assertSame(Method::NotContains, $query->getMethod()); + $this->assertSame(['php'], $query->getValues()); } public function testContainsDeprecated(): void { - $query = Query::contains('tags', ['a', 'b']); - $this->assertEquals(Query::TYPE_CONTAINS, $query->getMethod()); - $this->assertEquals(['a', 'b'], $query->getValues()); + $query = Query::containsString('tags', ['a', 'b']); + $this->assertSame(Method::Contains, $query->getMethod()); + $this->assertSame(['a', 'b'], $query->getValues()); } public function testBetween(): void { $query = Query::between('age', 18, 65); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); - $this->assertEquals([18, 65], $query->getValues()); + $this->assertSame(Method::Between, $query->getMethod()); + $this->assertSame([18, 65], $query->getValues()); } public function testNotBetween(): void { $query = Query::notBetween('age', 18, 65); - $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); - $this->assertEquals([18, 65], $query->getValues()); + $this->assertSame(Method::NotBetween, $query->getMethod()); + $this->assertSame([18, 65], $query->getValues()); } public function testSearch(): void { $query = Query::search('content', 'hello world'); - $this->assertEquals(Query::TYPE_SEARCH, $query->getMethod()); - $this->assertEquals(['hello world'], $query->getValues()); + $this->assertSame(Method::Search, $query->getMethod()); + $this->assertSame(['hello world'], $query->getValues()); } public function testNotSearch(): void { $query = Query::notSearch('content', 'hello'); - $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); - $this->assertEquals(['hello'], $query->getValues()); + $this->assertSame(Method::NotSearch, $query->getMethod()); + $this->assertSame(['hello'], $query->getValues()); } public function testIsNull(): void { $query = Query::isNull('email'); - $this->assertEquals(Query::TYPE_IS_NULL, $query->getMethod()); - $this->assertEquals('email', $query->getAttribute()); - $this->assertEquals([], $query->getValues()); + $this->assertSame(Method::IsNull, $query->getMethod()); + $this->assertSame('email', $query->getAttribute()); + $this->assertSame([], $query->getValues()); } 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->assertEquals(['Jo'], $query->getValues()); + $this->assertSame(Method::StartsWith, $query->getMethod()); + $this->assertSame(['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->assertEquals(['.com'], $query->getValues()); + $this->assertSame(Method::EndsWith, $query->getMethod()); + $this->assertSame(['.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->assertEquals(['^Jo.*'], $query->getValues()); + $this->assertSame(Method::Regex, $query->getMethod()); + $this->assertSame(['^Jo.*'], $query->getValues()); } public function testExists(): void { $query = Query::exists(['name', 'email']); - $this->assertEquals(Query::TYPE_EXISTS, $query->getMethod()); - $this->assertEquals('', $query->getAttribute()); - $this->assertEquals(['name', 'email'], $query->getValues()); + $this->assertSame(Method::Exists, $query->getMethod()); + $this->assertSame('', $query->getAttribute()); + $this->assertSame(['name', 'email'], $query->getValues()); } public function testNotExistsArray(): void { $query = Query::notExists(['name']); - $this->assertEquals(Query::TYPE_NOT_EXISTS, $query->getMethod()); - $this->assertEquals(['name'], $query->getValues()); + $this->assertSame(Method::NotExists, $query->getMethod()); + $this->assertSame(['name'], $query->getValues()); } public function testNotExistsScalar(): void { $query = Query::notExists('name'); - $this->assertEquals(['name'], $query->getValues()); + $this->assertSame(['name'], $query->getValues()); } public function testCreatedBefore(): void { $query = Query::createdBefore('2024-01-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); - $this->assertEquals('$createdAt', $query->getAttribute()); - $this->assertEquals(['2024-01-01'], $query->getValues()); + $this->assertSame(Method::LessThan, $query->getMethod()); + $this->assertSame('$createdAt', $query->getAttribute()); + $this->assertSame(['2024-01-01'], $query->getValues()); } public function testCreatedAfter(): void { $query = Query::createdAfter('2024-01-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); - $this->assertEquals('$createdAt', $query->getAttribute()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); + $this->assertSame('$createdAt', $query->getAttribute()); } public function testUpdatedBefore(): void { $query = Query::updatedBefore('2024-06-01'); - $this->assertEquals(Query::TYPE_LESSER, $query->getMethod()); - $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertSame(Method::LessThan, $query->getMethod()); + $this->assertSame('$updatedAt', $query->getAttribute()); } public function testUpdatedAfter(): void { $query = Query::updatedAfter('2024-06-01'); - $this->assertEquals(Query::TYPE_GREATER, $query->getMethod()); - $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertSame(Method::GreaterThan, $query->getMethod()); + $this->assertSame('$updatedAt', $query->getAttribute()); } public function testCreatedBetween(): void { $query = Query::createdBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); - $this->assertEquals('$createdAt', $query->getAttribute()); - $this->assertEquals(['2024-01-01', '2024-12-31'], $query->getValues()); + $this->assertSame(Method::Between, $query->getMethod()); + $this->assertSame('$createdAt', $query->getAttribute()); + $this->assertSame(['2024-01-01', '2024-12-31'], $query->getValues()); } public function testUpdatedBetween(): void { $query = Query::updatedBetween('2024-01-01', '2024-12-31'); - $this->assertEquals(Query::TYPE_BETWEEN, $query->getMethod()); - $this->assertEquals('$updatedAt', $query->getAttribute()); + $this->assertSame(Method::Between, $query->getMethod()); + $this->assertSame('$updatedAt', $query->getAttribute()); } } diff --git a/tests/Query/Fixture/PermissionFilter.php b/tests/Query/Fixture/PermissionFilter.php new file mode 100644 index 0000000..5522f04 --- /dev/null +++ b/tests/Query/Fixture/PermissionFilter.php @@ -0,0 +1,94 @@ + $roles + * @param \Closure(string): string $permissionsTable + * @param list|null $columns + * @param Filter|null $subqueryFilter + */ + 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/tests/Query/Fixture/QuotesIdentifiersHarness.php b/tests/Query/Fixture/QuotesIdentifiersHarness.php new file mode 100644 index 0000000..fd62356 --- /dev/null +++ b/tests/Query/Fixture/QuotesIdentifiersHarness.php @@ -0,0 +1,15 @@ + '_uid', + '$createdAt' => '_createdAt', + ]); + + $this->assertSame('_uid', $hook->resolve('$id')); + $this->assertSame('_createdAt', $hook->resolve('$createdAt')); + } + + public function testUnmappedPassthrough(): void + { + $hook = new Map(['$id' => '_uid']); + + $this->assertSame('name', $hook->resolve('name')); + $this->assertSame('status', $hook->resolve('status')); + } + + public function testEmptyMap(): void + { + $hook = new Map([]); + + $this->assertSame('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..fdef489 --- /dev/null +++ b/tests/Query/Hook/Filter/FilterTest.php @@ -0,0 +1,270 @@ +filter('users'); + + $this->assertSame('tenant_id IN (?)', $condition->expression); + $this->assertSame(['t1'], $condition->bindings); + } + + public function testTenantMultipleIds(): void + { + $hook = new Tenant(['t1', 't2', 't3']); + $condition = $hook->filter('users'); + + $this->assertSame('tenant_id IN (?, ?, ?)', $condition->expression); + $this->assertSame(['t1', 't2', 't3'], $condition->bindings); + } + + public function testTenantCustomColumn(): void + { + $hook = new Tenant(['t1'], 'organization_id'); + $condition = $hook->filter('users'); + + $this->assertSame('organization_id IN (?)', $condition->expression); + $this->assertSame(['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->assertSame( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?, ?) AND type = ?)', + $condition->expression + ); + $this->assertSame(['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->assertSame('1 = 0', $condition->expression); + $this->assertSame([], $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->assertSame( + 'id IN (SELECT DISTINCT document_id FROM mydb_documents_perms WHERE role IN (?) AND type = ?)', + $condition->expression + ); + $this->assertSame(['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->assertSame( + 'uid IN (SELECT DISTINCT resource_id FROM acl WHERE principal IN (?) AND access = ?)', + $condition->expression + ); + $this->assertSame(['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->assertSame('id IN (SELECT DISTINCT document_id FROM permissions WHERE role IN (?) AND type = ?)', $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->assertSame( + '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->assertSame(['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->assertSame( + 'id IN (SELECT DISTINCT document_id FROM employees_perms WHERE role IN (?) AND type = ? AND (column IS NULL OR column IN (?)))', + $condition->expression + ); + $this->assertSame(['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->assertSame( + 'id IN (SELECT DISTINCT document_id FROM mydb_users_perms WHERE role IN (?) AND type = ? AND column IS NULL)', + $condition->expression + ); + $this->assertSame(['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->assertSame( + 'id IN (SELECT DISTINCT document_id FROM acl WHERE role IN (?) AND type = ? AND (field IS NULL OR field IN (?)))', + $condition->expression + ); + $this->assertSame(['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->assertSame('id IN (SELECT DISTINCT document_id FROM perms WHERE role IN (?) AND type = ? 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..cda9d83 --- /dev/null +++ b/tests/Query/Hook/Join/FilterTest.php @@ -0,0 +1,306 @@ +from('users') + ->addHook($hook) + ->leftJoin('orders', 'users.id', 'orders.user_id') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ?', $result->query); + $this->assertStringNotContainsString('WHERE', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE active = ?', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertSame('SELECT * FROM `users` JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE active = ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id`', $result->query); + $this->assertSame([], $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->assertSame('SELECT * FROM `users` CROSS JOIN `settings` WHERE active = ?', $result->query); + $this->assertStringNotContainsString('CROSS JOIN `settings` AND', $result->query); + $this->assertSame('SELECT * FROM `users` CROSS JOIN `settings` WHERE active = ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND active = ? AND visible = ?', $result->query); + $this->assertSame([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->assertSame(['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->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE deleted = ?', $result->query); + $this->assertStringNotContainsString('ON `users`.`id` = `orders`.`user_id` AND', $result->query); + $this->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` WHERE deleted = ?', $result->query); + $this->assertSame([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->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND join_active = ? WHERE main_active = ?', $result->query); + // JoinFilter applies to ON for join + $this->assertSame('SELECT * FROM `users` LEFT JOIN `orders` ON `users`.`id` = `orders`.`user_id` AND join_active = ? WHERE main_active = ?', $result->query); + // ON binding first, then WHERE binding + $this->assertSame([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->assertSame(Placement::On, $condition->placement); + $this->assertSame('id IN (SELECT DISTINCT document_id FROM mydb_orders_perms WHERE role IN (?) AND type = ?)', $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->assertSame(Placement::Where, $condition->placement); + } + + public function testTenantLeftJoinOnPlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', JoinType::Left); + + $this->assertNotNull($condition); + $this->assertSame(Placement::On, $condition->placement); + $this->assertSame('tenant_id IN (?)', $condition->condition->expression); + } + + public function testTenantInnerJoinWherePlacement(): void + { + $hook = new Tenant(['t1']); + $condition = $hook->filterJoin('orders', JoinType::Inner); + + $this->assertNotNull($condition); + $this->assertSame(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->assertSame(Placement::On, $rightJoinResult->placement); + + // Same hook returns Where for JOIN — verifying joinType discrimination + $innerJoinResult = $hook->filterJoin('orders', JoinType::Inner); + $this->assertNotNull($innerJoinResult); + $this->assertSame(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->assertSame('id IN (SELECT DISTINCT document_id FROM mydb_orders_perms WHERE role IN (?) AND type = ?)', $result->condition->expression); + } +} diff --git a/tests/Query/JoinQueryTest.php b/tests/Query/JoinQueryTest.php new file mode 100644 index 0000000..8dde983 --- /dev/null +++ b/tests/Query/JoinQueryTest.php @@ -0,0 +1,145 @@ +assertSame(Method::Join, $query->getMethod()); + $this->assertSame('orders', $query->getAttribute()); + $this->assertSame(['users.id', '=', 'orders.user_id'], $query->getValues()); + } + + public function testJoinWithOperator(): void + { + $query = Query::join('orders', 'users.id', 'orders.user_id', '!='); + $this->assertSame(['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->assertSame('profiles', $query->getAttribute()); + $this->assertSame(['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->assertSame('orders', $query->getAttribute()); + } + + public function testCrossJoin(): void + { + $query = Query::crossJoin('colors'); + $this->assertSame(Method::CrossJoin, $query->getMethod()); + $this->assertSame('colors', $query->getAttribute()); + $this->assertSame([], $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()); + $this->assertTrue(Method::FullOuterJoin->isJoin()); + $this->assertTrue(Method::NaturalJoin->isJoin()); + $joinMethods = array_filter(Method::cases(), fn (Method $m) => $m->isJoin()); + $this->assertCount(6, $joinMethods); + } + + public function testJoinWithEmptyTableName(): void + { + $query = Query::join('', 'left', 'right'); + $this->assertSame('', $query->getAttribute()); + $this->assertSame(['left', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyLeftColumn(): void + { + $query = Query::join('t', '', 'right'); + $this->assertSame(['', '=', 'right'], $query->getValues()); + } + + public function testJoinWithEmptyRightColumn(): void + { + $query = Query::join('t', 'left', ''); + $this->assertSame(['left', '=', ''], $query->getValues()); + } + + public function testJoinWithSpecialOperators(): void + { + $ops = ['!=', '<>', '<', '>', '<=', '>=']; + foreach ($ops as $op) { + $query = Query::join('t', 'a', 'b', $op); + $this->assertSame(['a', $op, 'b'], $query->getValues()); + } + } + + public function testLeftJoinValues(): void + { + $query = Query::leftJoin('t', 'a.id', 'b.aid', '!='); + $this->assertSame(['a.id', '!=', 'b.aid'], $query->getValues()); + } + + public function testRightJoinValues(): void + { + $query = Query::rightJoin('t', 'a.id', 'b.aid'); + $this->assertSame(['a.id', '=', 'b.aid'], $query->getValues()); + } + + public function testCrossJoinEmptyTableName(): void + { + $query = Query::crossJoin(''); + $this->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testJoinCompileDispatch(): void + { + $builder = new MySQL(); + $query = Query::join('orders', 'users.id', 'orders.uid'); + $sql = $query->compile($builder); + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('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..7165ad4 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,22 +22,69 @@ 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->assertEquals(['php', 'js'], $query->getValues()); + $this->assertSame(Method::ContainsAll, $query->getMethod()); + $this->assertSame(['php', 'js'], $query->getValues()); } public function testElemMatch(): void { $inner = [Query::equal('field', ['val'])]; $query = Query::elemMatch('items', $inner); - $this->assertEquals(Query::TYPE_ELEM_MATCH, $query->getMethod()); - $this->assertEquals('items', $query->getAttribute()); + $this->assertSame(Method::ElemMatch, $query->getMethod()); + $this->assertSame('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->assertSame([], $query->getValues()); + } + + public function testEmptyOr(): void + { + $query = Query::or([]); + $this->assertSame([], $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/MethodTest.php b/tests/Query/MethodTest.php new file mode 100644 index 0000000..afb4346 --- /dev/null +++ b/tests/Query/MethodTest.php @@ -0,0 +1,63 @@ +assertSame('SUM', Method::Sum->sqlFunction()); + $this->assertSame('COUNT', Method::Count->sqlFunction()); + $this->assertSame('COUNT', Method::CountDistinct->sqlFunction()); + $this->assertSame('AVG', Method::Avg->sqlFunction()); + $this->assertSame('MIN', Method::Min->sqlFunction()); + $this->assertSame('MAX', Method::Max->sqlFunction()); + } + + public function testStatisticalMethodsMapToSqlFunctions(): void + { + $this->assertSame('STDDEV', Method::Stddev->sqlFunction()); + $this->assertSame('STDDEV_POP', Method::StddevPop->sqlFunction()); + $this->assertSame('STDDEV_SAMP', Method::StddevSamp->sqlFunction()); + $this->assertSame('VARIANCE', Method::Variance->sqlFunction()); + $this->assertSame('VAR_POP', Method::VarPop->sqlFunction()); + $this->assertSame('VAR_SAMP', Method::VarSamp->sqlFunction()); + } + + public function testBitwiseMethodsMapToSqlFunctions(): void + { + $this->assertSame('BIT_AND', Method::BitAnd->sqlFunction()); + $this->assertSame('BIT_OR', Method::BitOr->sqlFunction()); + $this->assertSame('BIT_XOR', Method::BitXor->sqlFunction()); + } + + public function testNonAggregationMethodsReturnNull(): void + { + $this->assertNull(Method::Equal->sqlFunction()); + $this->assertNull(Method::NotEqual->sqlFunction()); + $this->assertNull(Method::OrderAsc->sqlFunction()); + $this->assertNull(Method::Limit->sqlFunction()); + $this->assertNull(Method::GroupBy->sqlFunction()); + $this->assertNull(Method::Having->sqlFunction()); + $this->assertNull(Method::Select->sqlFunction()); + $this->assertNull(Method::Distinct->sqlFunction()); + $this->assertNull(Method::Join->sqlFunction()); + $this->assertNull(Method::Union->sqlFunction()); + $this->assertNull(Method::Raw->sqlFunction()); + } + + public function testEveryAggregateMethodHasSqlFunction(): void + { + foreach (Method::cases() as $method) { + if ($method->isAggregate()) { + $this->assertNotNull( + $method->sqlFunction(), + "Aggregate method {$method->value} must have a sqlFunction() mapping", + ); + } + } + } +} diff --git a/tests/Query/MongoDBClientObjectToArrayTest.php b/tests/Query/MongoDBClientObjectToArrayTest.php new file mode 100644 index 0000000..73ec6ba --- /dev/null +++ b/tests/Query/MongoDBClientObjectToArrayTest.php @@ -0,0 +1,197 @@ +newInstanceWithoutConstructor(); + $this->client = $instance; + + $method = $reflection->getMethod('objectToArray'); + /** @var callable(mixed): mixed $bound */ + $bound = $method->getClosure($this->client); + $this->convert = $bound; + } + + public function testScalarsPassThrough(): void + { + $fn = $this->convert; + + $this->assertSame(1, $fn(1)); + $this->assertSame('hello', $fn('hello')); + $this->assertNull($fn(null)); + $this->assertTrue($fn(true)); + $this->assertSame(1.5, $fn(1.5)); + } + + public function testPopulatedObjectBecomesArray(): void + { + $fn = $this->convert; + + $input = new \stdClass(); + $input->name = 'alice'; + $input->age = 30; + + $result = $fn($input); + + $this->assertIsArray($result); + $this->assertSame(['name' => 'alice', 'age' => 30], $result); + } + + public function testNestedPopulatedObjectsBecomeArrays(): void + { + $fn = $this->convert; + + $inner = new \stdClass(); + $inner->b = 2; + + $outer = new \stdClass(); + $outer->a = 1; + $outer->nested = $inner; + + $result = $fn($outer); + + $this->assertSame(['a' => 1, 'nested' => ['b' => 2]], $result); + } + + public function testEmptyObjectAtTopLevelStaysStdClass(): void + { + $fn = $this->convert; + + $empty = new \stdClass(); + $result = $fn($empty); + + $this->assertInstanceOf(\stdClass::class, $result); + $this->assertSame([], \get_object_vars($result)); + } + + public function testEmptyObjectNestedInPopulatedObjectStaysStdClass(): void + { + $fn = $this->convert; + + $empty = new \stdClass(); + + $outer = new \stdClass(); + $outer->match = $empty; + $outer->other = 'scalar'; + + $result = $fn($outer); + + $this->assertIsArray($result); + $this->assertArrayHasKey('match', $result); + $this->assertInstanceOf(\stdClass::class, $result['match']); + $this->assertSame([], \get_object_vars($result['match'])); + $this->assertSame('scalar', $result['other']); + } + + public function testEmptyObjectNestedInArrayStaysStdClass(): void + { + $fn = $this->convert; + + $input = ['match' => new \stdClass()]; + $result = $fn($input); + + $this->assertIsArray($result); + $this->assertInstanceOf(\stdClass::class, $result['match']); + } + + public function testEmptyObjectInsideListArrayStaysStdClass(): void + { + $fn = $this->convert; + + $input = [new \stdClass(), new \stdClass()]; + $result = $fn($input); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertInstanceOf(\stdClass::class, $result[0]); + $this->assertInstanceOf(\stdClass::class, $result[1]); + } + + public function testMultipleEmptyObjectsAtMixedDepthsAllStayStdClass(): void + { + $fn = $this->convert; + + $deep = new \stdClass(); + $deep->empty1 = new \stdClass(); + $deep->list = [new \stdClass(), ['nested' => new \stdClass()]]; + + $root = new \stdClass(); + $root->empty2 = new \stdClass(); + $root->deep = $deep; + + $result = $fn($root); + + $this->assertIsArray($result); + $this->assertInstanceOf(\stdClass::class, $result['empty2']); + $this->assertIsArray($result['deep']); + $this->assertInstanceOf(\stdClass::class, $result['deep']['empty1']); + $this->assertIsArray($result['deep']['list']); + $this->assertInstanceOf(\stdClass::class, $result['deep']['list'][0]); + $this->assertIsArray($result['deep']['list'][1]); + $this->assertInstanceOf(\stdClass::class, $result['deep']['list'][1]['nested']); + } + + public function testAggregatePipelineShapeIsPreserved(): void + { + $fn = $this->convert; + + // Simulates the output of json_decode($json, false) for: + // {"pipeline":[{"$match":{}},{"$project":{"name":1}}]} + $matchStage = new \stdClass(); + $matchStage->{'$match'} = new \stdClass(); + + $projectInner = new \stdClass(); + $projectInner->name = 1; + $projectStage = new \stdClass(); + $projectStage->{'$project'} = $projectInner; + + $root = new \stdClass(); + $root->pipeline = [$matchStage, $projectStage]; + + $result = $fn($root); + + $this->assertIsArray($result); + $this->assertIsArray($result['pipeline']); + $this->assertCount(2, $result['pipeline']); + + // $match: {} — must remain stdClass so BSON encodes as document, not array + $this->assertIsArray($result['pipeline'][0]); + $this->assertArrayHasKey('$match', $result['pipeline'][0]); + $this->assertInstanceOf(\stdClass::class, $result['pipeline'][0]['$match']); + + // $project: {name: 1} — populated, gets converted to array + $this->assertIsArray($result['pipeline'][1]); + $this->assertSame(['name' => 1], $result['pipeline'][1]['$project']); + } +} diff --git a/tests/Query/Parser/MongoDBTest.php b/tests/Query/Parser/MongoDBTest.php new file mode 100644 index 0000000..f9f60f6 --- /dev/null +++ b/tests/Query/Parser/MongoDBTest.php @@ -0,0 +1,554 @@ +parser = new MongoDB(); + } + + /** + * Build a MongoDB OP_MSG packet with a BSON command document + * + * @param array $document Command document + */ + private function buildOpMsg(array $document): string + { + $bson = $this->encodeBsonDocument($document); + + $sectionKind = "\x00"; // kind 0 = body + $flags = \pack('V', 0); + + // Header: length (4) + requestId (4) + responseTo (4) + opcode (4) + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) // message length + . \pack('V', 1) // request ID + . \pack('V', 0) // response to + . \pack('V', 2013); // opcode: OP_MSG + + return $header . $body; + } + + /** + * Encode a simple BSON document (supports string, int, bool, and nested documents) + * + * @param array $doc + */ + private function encodeBsonDocument(array $doc): string + { + $body = ''; + + foreach ($doc as $key => $value) { + if (\is_string($value)) { + // Type 0x02: string + $body .= "\x02" . $key . "\x00" . \pack('V', \strlen($value) + 1) . $value . "\x00"; + } elseif (\is_int($value)) { + // Type 0x10: int32 + $body .= "\x10" . $key . "\x00" . \pack('V', $value); + } elseif (\is_bool($value)) { + // Type 0x08: boolean + $body .= "\x08" . $key . "\x00" . ($value ? "\x01" : "\x00"); + } elseif (\is_array($value)) { + // Type 0x03: embedded document + /** @var array $value */ + $body .= "\x03" . $key . "\x00" . $this->encodeBsonDocument($value); + } + } + + $body .= "\x00"; // terminator + + return \pack('V', 4 + \strlen($body)) . $body; + } + + // -- Read Commands -- + + public function testFindCommand(): void + { + $data = $this->buildOpMsg(['find' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testAggregateCommand(): void + { + $data = $this->buildOpMsg(['aggregate' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testCountCommand(): void + { + $data = $this->buildOpMsg(['count' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testDistinctCommand(): void + { + $data = $this->buildOpMsg(['distinct' => 'users', 'key' => 'name', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testListCollectionsCommand(): void + { + $data = $this->buildOpMsg(['listCollections' => 1, '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testListDatabasesCommand(): void + { + $data = $this->buildOpMsg(['listDatabases' => 1, '$db' => 'admin']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testListIndexesCommand(): void + { + $data = $this->buildOpMsg(['listIndexes' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testDbStatsCommand(): void + { + $data = $this->buildOpMsg(['dbStats' => 1, '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testCollStatsCommand(): void + { + $data = $this->buildOpMsg(['collStats' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testExplainCommand(): void + { + $data = $this->buildOpMsg(['explain' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testGetMoreCommand(): void + { + $data = $this->buildOpMsg(['getMore' => 12345, '$db' => 'mydb']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testServerStatusCommand(): void + { + $data = $this->buildOpMsg(['serverStatus' => 1, '$db' => 'admin']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testPingCommand(): void + { + $data = $this->buildOpMsg(['ping' => 1, '$db' => 'admin']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testHelloCommand(): void + { + $data = $this->buildOpMsg(['hello' => 1, '$db' => 'admin']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testIsMasterCommand(): void + { + $data = $this->buildOpMsg(['isMaster' => 1, '$db' => 'admin']); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + // -- Write Commands -- + + public function testInsertCommand(): void + { + $data = $this->buildOpMsg(['insert' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testUpdateCommand(): void + { + $data = $this->buildOpMsg(['update' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testDeleteCommand(): void + { + $data = $this->buildOpMsg(['delete' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testFindAndModifyCommand(): void + { + $data = $this->buildOpMsg(['findAndModify' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testCreateCommand(): void + { + $data = $this->buildOpMsg(['create' => 'new_collection', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testDropCommand(): void + { + $data = $this->buildOpMsg(['drop' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testCreateIndexesCommand(): void + { + $data = $this->buildOpMsg(['createIndexes' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testDropIndexesCommand(): void + { + $data = $this->buildOpMsg(['dropIndexes' => 'users', '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testDropDatabaseCommand(): void + { + $data = $this->buildOpMsg(['dropDatabase' => 1, '$db' => 'mydb']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + public function testRenameCollectionCommand(): void + { + $data = $this->buildOpMsg(['renameCollection' => 'users', '$db' => 'admin']); + $this->assertSame(Type::Write, $this->parser->parse($data)); + } + + // -- Transaction Commands -- + + public function testStartTransaction(): void + { + $data = $this->buildOpMsg(['find' => 'users', '$db' => 'mydb', 'startTransaction' => true]); + $this->assertSame(Type::TransactionBegin, $this->parser->parse($data)); + } + + public function testCommitTransaction(): void + { + $data = $this->buildOpMsg(['commitTransaction' => 1, '$db' => 'admin']); + $this->assertSame(Type::TransactionEnd, $this->parser->parse($data)); + } + + public function testAbortTransaction(): void + { + $data = $this->buildOpMsg(['abortTransaction' => 1, '$db' => 'admin']); + $this->assertSame(Type::TransactionEnd, $this->parser->parse($data)); + } + + public function testStartTransactionFlagOnNonEligibleCommandIsIgnored(): void + { + // Only CRUD/aggregate commands can piggy-back startTransaction. A + // rogue `ping` carrying `startTransaction: true` must NOT classify + // as TransactionBegin — ping is not transaction-eligible, and the + // parser skips the scan entirely on the hot path. + $data = $this->buildOpMsg(['ping' => 1, '$db' => 'admin', 'startTransaction' => true]); + $this->assertSame(Type::Read, $this->parser->parse($data)); + } + + public function testStartTransactionFlagOnEligibleCommands(): void + { + foreach (['find', 'insert', 'update', 'delete', 'aggregate', 'findAndModify'] as $command) { + $data = $this->buildOpMsg([$command => 'users', '$db' => 'mydb', 'startTransaction' => true]); + $this->assertSame( + Type::TransactionBegin, + $this->parser->parse($data), + \sprintf('Command %s with startTransaction should be TransactionBegin', $command), + ); + } + } + + // -- Edge Cases -- + + public function testTooShortPacket(): void + { + $this->assertSame(Type::Unknown, $this->parser->parse("\x00\x00\x00\x00")); + } + + public function testWrongOpcode(): void + { + // Build a packet with opcode 2004 (OP_QUERY, legacy) instead of 2013 + $bson = $this->encodeBsonDocument(['find' => 'users']); + $body = \pack('V', 0) . "\x00" . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2004); // wrong opcode + + $this->assertSame(Type::Unknown, $this->parser->parse($header . $body)); + } + + public function testUnknownCommand(): void + { + $data = $this->buildOpMsg(['customCommand' => 1, '$db' => 'mydb']); + $this->assertSame(Type::Unknown, $this->parser->parse($data)); + } + + public function testEmptyBsonDocument(): void + { + // OP_MSG with an empty BSON document (just 5 bytes: length + terminator) + $bson = \pack('V', 5) . "\x00"; + $body = \pack('V', 0) . "\x00" . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $this->assertSame(Type::Unknown, $this->parser->parse($header . $body)); + } + + public function testMalformedBsonStringLengthDoesNotCrash(): void + { + // Build a BSON doc with a string element whose declared strLen is + // 0xFFFFFFFF (maximum uint32). On 32-bit PHP this is a negative int; + // on 64-bit it vastly exceeds the buffer. Either way, skipBsonString + // must reject it and the parser must not read past the buffer. + // Layout: [docLen][0x02 "foo" \0 0xFFFFFFFF "x" \0][00] + $malicious = "\x02" . 'foo' . "\x00" + . "\xFF\xFF\xFF\xFF" // claimed strLen (huge / negative) + . 'x' . "\x00"; // some placeholder bytes; we will not read them + $bsonBody = $malicious . "\x00"; // document terminator + $bson = \pack('V', 4 + \strlen($bsonBody)) . $bsonBody; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + // The hasBsonKey scan for startTransaction must bail safely + // (returning false), and the first-key command lookup is 'foo', + // which is unknown — so classification is Unknown. + $this->assertSame(Type::Unknown, $this->parser->parse($data)); + } + + public function testMalformedBsonBinaryLengthDoesNotCrash(): void + { + // Same attack but via a binary element (type 0x05). + $malicious = "\x05" . 'foo' . "\x00" + . "\xFF\xFF\xFF\xFF" // claimed binLen + . "\x00"; // subtype byte + $bsonBody = $malicious . "\x00"; + $bson = \pack('V', 4 + \strlen($bsonBody)) . $bsonBody; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + $this->assertSame(Type::Unknown, $this->parser->parse($data)); + } + + public function testMalformedBsonNestedDocumentLengthDoesNotCrash(): void + { + // Attack via a nested document (type 0x03) whose declared docLen + // far exceeds the remaining buffer. skipBsonDocument must reject + // by returning false; any out-of-bounds string index access would + // raise a warning, promoted to exception by withStrictErrors(). + // Layout: [outer docLen][0x03 "sub" \0 ...][00] + $malicious = "\x03" . 'sub' . "\x00" + . "\xFF\xFF\xFF\x7F" // claimed nested docLen ~ 2GB + . "\x00\x00\x00\x00\x00"; + $bsonBody = $malicious . "\x00"; + $bson = \pack('V', 4 + \strlen($bsonBody)) . $bsonBody; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $result); + } + + public function testMalformedBsonOuterDocumentLengthDoesNotCrash(): void + { + // Outer BSON document claims a length larger than the packet. + // hasBsonKey must reject before scanning. + $bsonBody = "\x10" . 'find' . "\x00" . \pack('V', 1) . "\x00"; + // Declare a docLen far larger than actual content. + $bson = \pack('V', 0x7FFFFFFF) . $bsonBody; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + // Both hasBsonKey and extractFirstBsonKey must reject the out-of-bounds + // docLen before scanning keys. With no valid classification available, + // the parser returns Unknown. The important guarantee is no crash / + // out-of-bounds read, so we run under strict error handling. + $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $result); + } + + public function testMalformedBsonRegexRunsToEofWithoutCrash(): void + { + // A regex element (type 0x0B) with no null terminator for either + // cstring. skipBsonRegex must return false, not run off the end. + $malicious = "\x0B" . 'rx' . "\x00" + . 'pattern-without-null-terminator-at-all'; + $bsonBody = $malicious; // no trailing doc terminator + $bson = \pack('V', 4 + \strlen($bsonBody) + 1) . $bsonBody . "\x00"; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + // No crash; first key 'rx' is unknown → Unknown. + $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $result); + } + + public function testMalformedBsonDbPointerLengthDoesNotCrash(): void + { + // DBPointer (0x0C) = string + 12-byte ObjectId. The string part + // is valid, but there aren't 12 bytes after it for the ObjectId. + // skipBsonDbPointer must reject via the new advance() bound check. + $malicious = "\x0C" . 'ref' . "\x00" + . \pack('V', 2) . 'a' . "\x00" // tiny valid string + . "\x01\x02\x03"; // only 3 bytes, not 12 + $bsonBody = $malicious; + $bson = \pack('V', 4 + \strlen($bsonBody) + 1) . $bsonBody . "\x00"; + + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + + $data = $header . $body; + + // First key 'ref' is unknown → Unknown. Important: no crash while + // hasBsonKey walks the malformed DBPointer. + $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $result); + } + + /** + * Run a parser call with PHP warnings/notices promoted to exceptions. + * + * Out-of-bounds string index access in PHP emits a warning rather than + * failing hard. To prove the parser never reads past the buffer we run + * the call under a custom error handler that throws on any warning. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + private function withStrictErrors(callable $callback): mixed + { + \set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + throw new \ErrorException($message, 0, $severity, $file, $line); + }); + + try { + return $callback(); + } finally { + \restore_error_handler(); + } + } + + public function testClassifySqlReturnsUnknown(): void + { + $this->assertSame(Type::Unknown, $this->parser->classifySQL('SELECT * FROM users')); + } + + public function testExtractKeywordReturnsEmpty(): void + { + $this->assertSame('', $this->parser->extractKeyword('SELECT')); + } + + // -- Performance -- + + #[Group('performance')] + public function testParsePerformance(): void + { + if (\getenv('CI') !== false) { + $this->markTestSkipped('Performance targets assume dedicated hardware; CI runners are too variable.'); + } + + $data = $this->buildOpMsg(['find' => 'users', '$db' => 'mydb']); + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->parser->parse($data); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + $this->assertLessThan( + 2.0, + $perQuery, + \sprintf('MongoDB parse took %.3f us/query (target: < 2.0 us)', $perQuery) + ); + } + + #[Group('performance')] + public function testTransactionScanPerformance(): void + { + if (\getenv('CI') !== false) { + $this->markTestSkipped('Performance targets assume dedicated hardware; CI runners are too variable.'); + } + + // Document with many keys before startTransaction to test scanning + $data = $this->buildOpMsg([ + 'find' => 'users', + '$db' => 'mydb', + 'filter' => ['active' => 1], + 'projection' => ['name' => 1], + 'sort' => ['created' => 1], + 'startTransaction' => true, + ]); + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->parser->parse($data); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + $this->assertLessThan( + 5.0, + $perQuery, + \sprintf('MongoDB transaction scan took %.3f us/query (target: < 5.0 us)', $perQuery) + ); + } +} diff --git a/tests/Query/Parser/MySQLTest.php b/tests/Query/Parser/MySQLTest.php new file mode 100644 index 0000000..bf9c422 --- /dev/null +++ b/tests/Query/Parser/MySQLTest.php @@ -0,0 +1,204 @@ +parser = new MySQL(); + } + + /** + * Build a MySQL COM_QUERY packet + */ + private function buildQuery(string $sql): string + { + $payloadLen = 1 + \strlen($sql); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x03" . $sql; + } + + /** + * Build a MySQL COM_STMT_PREPARE packet + */ + private function buildStmtPrepare(string $sql): string + { + $payloadLen = 1 + \strlen($sql); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x16" . $sql; + } + + /** + * Build a MySQL COM_STMT_EXECUTE packet + */ + private function buildStmtExecute(int $stmtId): string + { + $body = \pack('V', $stmtId) . "\x00" . \pack('V', 1); + $payloadLen = 1 + \strlen($body); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x17" . $body; + } + + // -- Read Queries -- + + public function testSelectQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SELECT * FROM users WHERE id = 1'))); + } + + public function testSelectLowercase(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('select id from users'))); + } + + public function testShowQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SHOW DATABASES'))); + } + + public function testDescribeQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESCRIBE users'))); + } + + public function testDescQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESC users'))); + } + + public function testExplainQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('EXPLAIN SELECT * FROM users'))); + } + + // -- Write Queries -- + + public function testInsertQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); + } + + public function testUpdateQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); + } + + public function testDeleteQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DELETE FROM users WHERE id = 1'))); + } + + public function testCreateTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); + } + + public function testDropTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DROP TABLE test'))); + } + + public function testAlterTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('ALTER TABLE users ADD COLUMN email VARCHAR(255)'))); + } + + public function testTruncate(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('TRUNCATE TABLE users'))); + } + + // -- Transaction Commands -- + + public function testBeginTransaction(): void + { + $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('BEGIN'))); + } + + public function testStartTransaction(): void + { + $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('START TRANSACTION'))); + } + + public function testCommit(): void + { + $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('COMMIT'))); + } + + public function testRollback(): void + { + $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('ROLLBACK'))); + } + + public function testSetCommand(): void + { + $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('SET autocommit = 0'))); + } + + // -- Prepared Statement Protocol -- + + public function testStmtPrepareRoutesToWrite(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildStmtPrepare('SELECT * FROM users WHERE id = ?'))); + } + + public function testStmtExecuteRoutesToWrite(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildStmtExecute(1))); + } + + // -- Edge Cases -- + + public function testTooShortPacket(): void + { + $this->assertSame(Type::Unknown, $this->parser->parse("\x00\x00")); + } + + public function testUnknownCommand(): void + { + $header = \pack('V', 1); + $header[3] = "\x00"; + $data = $header . "\x01"; // COM_QUIT + $this->assertSame(Type::Unknown, $this->parser->parse($data)); + } + + // -- Performance -- + + #[Group('performance')] + public function testParsePerformance(): void + { + if (\getenv('CI') !== false) { + $this->markTestSkipped('Performance targets assume dedicated hardware; CI runners are too variable.'); + } + + $data = $this->buildQuery('SELECT * FROM users WHERE id = 1'); + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->parser->parse($data); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + $this->assertLessThan( + 1.0, + $perQuery, + \sprintf('MySQL parse took %.3f us/query (target: < 1.0 us)', $perQuery) + ); + } +} diff --git a/tests/Query/Parser/PostgreSQLTest.php b/tests/Query/Parser/PostgreSQLTest.php new file mode 100644 index 0000000..1b3b179 --- /dev/null +++ b/tests/Query/Parser/PostgreSQLTest.php @@ -0,0 +1,259 @@ +parser = new PostgreSQL(); + } + + /** + * Build a PostgreSQL Simple Query ('Q') message + */ + private function buildQuery(string $sql): string + { + $body = $sql . "\x00"; + $length = \strlen($body) + 4; + + return 'Q' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Parse ('P') message + */ + private function buildParse(string $stmtName, string $sql): string + { + $body = $stmtName . "\x00" . $sql . "\x00" . \pack('n', 0); + $length = \strlen($body) + 4; + + return 'P' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Bind ('B') message + */ + private function buildBind(): string + { + $body = "\x00\x00" . \pack('n', 0) . \pack('n', 0) . \pack('n', 0); + $length = \strlen($body) + 4; + + return 'B' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Execute ('E') message + */ + private function buildExecute(): string + { + $body = "\x00" . \pack('N', 0); + $length = \strlen($body) + 4; + + return 'E' . \pack('N', $length) . $body; + } + + // -- Read Queries -- + + public function testSelectQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SELECT * FROM users WHERE id = 1'))); + } + + public function testSelectLowercase(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('select id, name from users'))); + } + + public function testSelectMixedCase(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SeLeCt * FROM users'))); + } + + public function testShowQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SHOW TABLES'))); + } + + public function testDescribeQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESCRIBE users'))); + } + + public function testExplainQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('EXPLAIN SELECT * FROM users'))); + } + + public function testTableQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('TABLE users'))); + } + + public function testValuesQuery(): void + { + $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery("VALUES (1, 'a'), (2, 'b')"))); + } + + // -- Write Queries -- + + public function testInsertQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); + } + + public function testUpdateQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); + } + + public function testDeleteQuery(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DELETE FROM users WHERE id = 1'))); + } + + public function testCreateTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); + } + + public function testDropTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DROP TABLE IF EXISTS test'))); + } + + public function testAlterTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('ALTER TABLE users ADD COLUMN email TEXT'))); + } + + public function testTruncate(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('TRUNCATE TABLE users'))); + } + + public function testGrant(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('GRANT SELECT ON users TO readonly'))); + } + + public function testRevoke(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('REVOKE ALL ON users FROM public'))); + } + + public function testLockTable(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('LOCK TABLE users IN ACCESS EXCLUSIVE MODE'))); + } + + public function testCall(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CALL my_procedure()'))); + } + + public function testDo(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("DO \$\$ BEGIN RAISE NOTICE 'hello'; END \$\$"))); + } + + // -- Transaction Commands -- + + public function testBeginTransaction(): void + { + $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('BEGIN'))); + } + + public function testStartTransaction(): void + { + $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('START TRANSACTION'))); + } + + public function testCommit(): void + { + $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('COMMIT'))); + } + + public function testRollback(): void + { + $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('ROLLBACK'))); + } + + public function testSavepoint(): void + { + $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('SAVEPOINT sp1'))); + } + + public function testReleaseSavepoint(): void + { + $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('RELEASE SAVEPOINT sp1'))); + } + + public function testSetCommand(): void + { + $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery("SET search_path TO 'public'"))); + } + + // -- Extended Query Protocol -- + + public function testParseMessageRoutesToWrite(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildParse('stmt1', 'SELECT * FROM users'))); + } + + public function testBindMessageRoutesToWrite(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildBind())); + } + + public function testExecuteMessageRoutesToWrite(): void + { + $this->assertSame(Type::Write, $this->parser->parse($this->buildExecute())); + } + + // -- Edge Cases -- + + public function testTooShortPacket(): void + { + $this->assertSame(Type::Unknown, $this->parser->parse('Q')); + } + + public function testUnknownMessageType(): void + { + $data = 'X' . \pack('N', 5) . "\x00"; + $this->assertSame(Type::Unknown, $this->parser->parse($data)); + } + + // -- Performance -- + + #[Group('performance')] + public function testParsePerformance(): void + { + if (\getenv('CI') !== false) { + $this->markTestSkipped('Performance targets assume dedicated hardware; CI runners are too variable.'); + } + + $data = $this->buildQuery('SELECT * FROM users WHERE id = 1'); + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->parser->parse($data); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + $this->assertLessThan( + 1.0, + $perQuery, + \sprintf('PostgreSQL parse took %.3f us/query (target: < 1.0 us)', $perQuery) + ); + } +} diff --git a/tests/Query/Parser/SQLTest.php b/tests/Query/Parser/SQLTest.php new file mode 100644 index 0000000..f00ad54 --- /dev/null +++ b/tests/Query/Parser/SQLTest.php @@ -0,0 +1,239 @@ +parser = new PostgreSQL(); + } + + // -- classifySQL Edge Cases -- + + public function testClassifyLeadingWhitespace(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL(" \t\n SELECT * FROM users")); + } + + public function testClassifyLeadingLineComment(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL("-- this is a comment\nSELECT * FROM users")); + } + + public function testClassifyLeadingBlockComment(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL("/* block comment */ SELECT * FROM users")); + } + + public function testClassifyMultipleComments(): void + { + $sql = "-- line comment\n/* block comment */\n -- another line\n SELECT 1"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyNestedBlockComment(): void + { + $sql = "/* outer /* inner */ SELECT 1"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyEmptyQuery(): void + { + $this->assertSame(Type::Unknown, $this->parser->classifySQL('')); + } + + public function testClassifyWhitespaceOnly(): void + { + $this->assertSame(Type::Unknown, $this->parser->classifySQL(" \t\n ")); + } + + public function testClassifyCommentOnly(): void + { + $this->assertSame(Type::Unknown, $this->parser->classifySQL('-- just a comment')); + } + + public function testClassifySelectWithParenthesis(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL('SELECT(1)')); + } + + public function testClassifySelectWithSemicolon(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL('SELECT;')); + } + + // -- COPY Direction -- + + public function testClassifyCopyTo(): void + { + $this->assertSame(Type::Read, $this->parser->classifySQL('COPY users TO STDOUT')); + } + + public function testClassifyCopyFrom(): void + { + $this->assertSame(Type::Write, $this->parser->classifySQL("COPY users FROM '/tmp/data.csv'")); + } + + public function testClassifyCopyAmbiguous(): void + { + $this->assertSame(Type::Write, $this->parser->classifySQL('COPY users')); + } + + // -- CTE (WITH) -- + + public function testClassifyCteWithSelect(): void + { + $sql = 'WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users'; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteWithInsert(): void + { + $sql = 'WITH new_data AS (SELECT 1 AS id) INSERT INTO users SELECT * FROM new_data'; + $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteWithUpdate(): void + { + $sql = 'WITH src AS (SELECT id FROM staging) UPDATE users SET active = true FROM src WHERE users.id = src.id'; + $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteWithDelete(): void + { + $sql = 'WITH old AS (SELECT id FROM users WHERE created_at < now()) DELETE FROM users WHERE id IN (SELECT id FROM old)'; + $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteRecursiveSelect(): void + { + $sql = 'WITH RECURSIVE tree AS (SELECT id, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree'; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteNoFinalKeyword(): void + { + $sql = 'WITH x AS (SELECT 1)'; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteInsertKeywordInsideStringLiteralTreatedAsRead(): void + { + // The inner INSERT is inside a string literal and must not influence classification. + $sql = "WITH foo AS (SELECT 'INSERT INTO dangerous VALUES (1)' AS payload FROM t) SELECT * FROM foo"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteCloseParenInsideStringLiteralDoesNotBreakDepth(): void + { + // The ')' inside the string literal must not drop the depth counter. + // If literals were ignored, the parser would see depth go to 0 early and + // mis-classify on the trailing 'DELETE' token inside the literal. + $sql = "WITH foo AS (SELECT ') DELETE FROM users' AS payload FROM t) SELECT * FROM foo"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteDeleteKeywordInsideBlockCommentIsIgnored(): void + { + $sql = "WITH foo AS (SELECT 1 FROM t) /* DELETE FROM users */ SELECT * FROM foo"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteInsertKeywordInsideLineCommentIsIgnored(): void + { + $sql = "WITH foo AS (SELECT 1 FROM t)\n-- INSERT INTO dangerous\nSELECT * FROM foo"; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteKeywordInsideDoubleQuotedIdentifierIsIgnored(): void + { + // A quoted identifier literally named "DELETE FROM x" is a valid identifier. + $sql = 'WITH foo AS (SELECT 1 FROM "DELETE FROM x") SELECT * FROM foo'; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + public function testClassifyCteKeywordInsideDollarQuotedStringIsIgnored(): void + { + // Dollar-quoted strings must be skipped end-to-end. + $sql = 'WITH foo AS (SELECT $body$INSERT INTO dangerous$body$ FROM t) SELECT * FROM foo'; + $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + } + + // -- extractKeyword -- + + public function testExtractKeywordSimple(): void + { + $this->assertSame('SELECT', $this->parser->extractKeyword('SELECT * FROM users')); + } + + public function testExtractKeywordLowercase(): void + { + $this->assertSame('INSERT', $this->parser->extractKeyword('insert into users')); + } + + public function testExtractKeywordWithWhitespace(): void + { + $this->assertSame('DELETE', $this->parser->extractKeyword(" \t\n DELETE FROM users")); + } + + public function testExtractKeywordWithComments(): void + { + $this->assertSame('UPDATE', $this->parser->extractKeyword("-- comment\nUPDATE users SET x = 1")); + } + + public function testExtractKeywordEmpty(): void + { + $this->assertSame('', $this->parser->extractKeyword('')); + } + + public function testExtractKeywordParenthesized(): void + { + $this->assertSame('SELECT', $this->parser->extractKeyword('SELECT(1)')); + } + + // -- Performance -- + + #[Group('performance')] + public function testClassifySqlPerformance(): void + { + if (\getenv('CI') !== false) { + $this->markTestSkipped('Performance targets assume dedicated hardware; CI runners are too variable.'); + } + + $queries = [ + 'SELECT * FROM users WHERE id = 1', + "INSERT INTO logs (msg) VALUES ('test')", + 'BEGIN', + ' /* comment */ SELECT 1', + 'WITH cte AS (SELECT 1) SELECT * FROM cte', + ]; + + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->parser->classifySQL($queries[$i % \count($queries)]); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + $this->assertLessThan( + 2.0, + $perQuery, + \sprintf('classifySQL took %.3f us/query (target: < 2.0 us)', $perQuery) + ); + } +} diff --git a/tests/Query/QueryHelperTest.php b/tests/Query/QueryHelperTest.php index 22807f4..f6ff288 100644 --- a/tests/Query/QueryHelperTest.php +++ b/tests/Query/QueryHelperTest.php @@ -3,6 +3,9 @@ namespace Tests\Query; use PHPUnit\Framework\TestCase; +use Utopia\Query\CursorDirection; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Method; use Utopia\Query\Query; class QueryHelperTest extends TestCase @@ -35,6 +38,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,14 +109,14 @@ 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 { $query = Query::equal('name', ['John', 42, true]); $cloned = clone $query; - $this->assertEquals(['John', 42, true], $cloned->getValues()); + $this->assertSame(['John', 42, true], $cloned->getValues()); } public function testGetByType(): void @@ -110,10 +128,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 +139,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 +148,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 +170,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 +196,18 @@ 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->assertSame(25, $grouped->limit); + $this->assertSame(10, $grouped->offset); - $this->assertEquals(['name', 'age'], $grouped['orderAttributes']); - $this->assertEquals([Query::ORDER_ASC, Query::ORDER_DESC], $grouped['orderTypes']); - - $this->assertEquals('doc123', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertSame('doc123', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeFirstLimitWins(): void @@ -203,7 +218,7 @@ public function testGroupByTypeFirstLimitWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(10, $grouped['limit']); + $this->assertSame(10, $grouped->limit); } public function testGroupByTypeFirstOffsetWins(): void @@ -214,7 +229,7 @@ public function testGroupByTypeFirstOffsetWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals(5, $grouped['offset']); + $this->assertSame(5, $grouped->offset); } public function testGroupByTypeFirstCursorWins(): void @@ -225,8 +240,8 @@ public function testGroupByTypeFirstCursorWins(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('first', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_AFTER, $grouped['cursorDirection']); + $this->assertSame('first', $grouped->cursor); + $this->assertSame(CursorDirection::After, $grouped->cursorDirection); } public function testGroupByTypeCursorBefore(): void @@ -236,34 +251,721 @@ public function testGroupByTypeCursorBefore(): void ]; $grouped = Query::groupByType($queries); - $this->assertEquals('doc456', $grouped['cursor']); - $this->assertEquals(Query::CURSOR_BEFORE, $grouped['cursorDirection']); + $this->assertSame('doc456', $grouped->cursor); + $this->assertSame(CursorDirection::Before, $grouped->cursorDirection); + } + + /** + * Regression: previously `cursor = $values[0] ?? $limit`, which coerced a + * null cursor into the row-count limit. A missing cursor value must stay + * null, never fall back to an unrelated numeric limit. + */ + public function testGroupByTypeCursorFallsBackToNullNotLimit(): void + { + $queries = [ + Query::limit(25), + Query::cursorAfter(null), + ]; + + $grouped = Query::groupByType($queries); + $this->assertSame(25, $grouped->limit); + $this->assertNull($grouped->cursor); + $this->assertSame(CursorDirection::After, $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->assertSame([], $grouped->filters); + $this->assertSame([], $grouped->selections); + $this->assertNull($grouped->limit); + $this->assertNull($grouped->offset); + $this->assertNull($grouped->cursor); + $this->assertNull($grouped->cursorDirection); } - public function testGroupByTypeOrderRandom(): void + public function testGroupByTypeSkipsNonQueryInstances(): void { - $queries = [Query::orderRandom()]; + $grouped = Query::groupByType(['not a query', null, 42]); + $this->assertSame([], $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->assertEquals([Query::ORDER_RANDOM], $grouped['orderTypes']); - $this->assertEquals([], $grouped['orderAttributes']); + $this->assertCount(5, $grouped->aggregations); + $this->assertSame(Method::Count, $grouped->aggregations[0]->getMethod()); + $this->assertSame(Method::Max, $grouped->aggregations[4]->getMethod()); } - public function testGroupByTypeSkipsNonQueryInstances(): void + public function testGroupByTypeGroupBy(): void { - $grouped = Query::groupByType(['not a query', null, 42]); - $this->assertEquals([], $grouped['filters']); + $queries = [Query::groupBy(['status', 'country'])]; + $grouped = Query::groupByType($queries); + $this->assertSame(['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->assertSame(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->assertSame(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->assertSame('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->assertSame(10, $result[0]->getValue()); + $this->assertSame(Method::Offset, $result[1]->getMethod()); + $this->assertSame(20, $result[1]->getValue()); + } + + public function testPageStaticHelperFirstPage(): void + { + $result = Query::page(1); + $this->assertSame(25, $result[0]->getValue()); + $this->assertSame(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->assertSame(50, $result[0]->getValue()); + $this->assertSame(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->assertSame(['status'], $grouped->groupBy); + $this->assertCount(1, $grouped->having); + $this->assertTrue($grouped->distinct); + $this->assertCount(1, $grouped->joins); + $this->assertCount(1, $grouped->unions); + $this->assertSame(10, $grouped->limit); + $this->assertSame(5, $grouped->offset); + } + + public function testGroupByTypeMultipleGroupByMerges(): void + { + $queries = [ + Query::groupBy(['a', 'b']), + Query::groupBy(['c']), + ]; + $grouped = Query::groupByType($queries); + $this->assertSame(['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->assertSame([], $grouped->aggregations); + $this->assertSame([], $grouped->groupBy); + $this->assertSame([], $grouped->having); + $this->assertFalse($grouped->distinct); + $this->assertSame([], $grouped->joins); + $this->assertSame([], $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->assertSame(50, array_values($limits)[0]->getValue()); + $this->assertSame(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::containsString() ──────────────────── + + 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::containsString('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..a6293ac 100644 --- a/tests/Query/QueryParseTest.php +++ b/tests/Query/QueryParseTest.php @@ -4,6 +4,8 @@ use PHPUnit\Framework\TestCase; use Utopia\Query\Exception; +use Utopia\Query\Exception\ValidationException; +use Utopia\Query\Method; use Utopia\Query\Query; class QueryParseTest extends TestCase @@ -12,9 +14,9 @@ public function testParseValidJson(): void { $json = '{"method":"equal","attribute":"name","values":["John"]}'; $query = Query::parse($json); - $this->assertEquals('equal', $query->getMethod()); - $this->assertEquals('name', $query->getAttribute()); - $this->assertEquals(['John'], $query->getValues()); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertSame('name', $query->getAttribute()); + $this->assertSame(['John'], $query->getValues()); } public function testParseInvalidJson(): void @@ -56,9 +58,9 @@ public function testParseWithDefaultValues(): void { $json = '{"method":"isNull"}'; $query = Query::parse($json); - $this->assertEquals('isNull', $query->getMethod()); - $this->assertEquals('', $query->getAttribute()); - $this->assertEquals([], $query->getValues()); + $this->assertSame(Method::IsNull, $query->getMethod()); + $this->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); } public function testParseQueryFromArray(): void @@ -68,7 +70,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,10 +85,10 @@ 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()); + $this->assertSame('John', $query->getValues()[0]->getValue()); } public function testParseQueries(): void @@ -96,15 +98,15 @@ 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 { $query = Query::equal('name', ['John']); $array = $query->toArray(); - $this->assertEquals([ + $this->assertSame([ 'method' => 'equal', 'attribute' => 'name', 'values' => ['John'], @@ -116,7 +118,7 @@ public function testToArrayEmptyAttribute(): void $query = Query::limit(25); $array = $query->toArray(); $this->assertArrayNotHasKey('attribute', $array); - $this->assertEquals(['method' => 'limit', 'values' => [25]], $array); + $this->assertSame(['method' => 'limit', 'values' => [25]], $array); } public function testToArrayNested(): void @@ -126,15 +128,15 @@ public function testToArrayNested(): void Query::greaterThan('age', 18), ]); $array = $query->toArray(); - $this->assertEquals('or', $array['method']); + $this->assertSame('or', $array['method']); $values = $array['values'] ?? []; $this->assertIsArray($values); $this->assertCount(2, $values); $this->assertIsArray($values[0]); $this->assertIsArray($values[1]); - $this->assertEquals('equal', $values[0]['method']); - $this->assertEquals('greaterThan', $values[1]['method']); + $this->assertSame('equal', $values[0]['method']); + $this->assertSame('greaterThan', $values[1]['method']); } public function testToString(): void @@ -144,9 +146,9 @@ public function testToString(): void /** @var array $decoded */ $decoded = json_decode($string, true); - $this->assertEquals('equal', $decoded['method']); - $this->assertEquals('name', $decoded['attribute']); - $this->assertEquals(['John'], $decoded['values']); + $this->assertSame('equal', $decoded['method']); + $this->assertSame('name', $decoded['attribute']); + $this->assertSame(['John'], $decoded['values']); } public function testToStringNested(): void @@ -158,7 +160,7 @@ public function testToStringNested(): void /** @var array $decoded */ $decoded = json_decode($string, true); - $this->assertEquals('and', $decoded['method']); + $this->assertSame('and', $decoded['method']); $values = $decoded['values'] ?? []; $this->assertIsArray($values); @@ -170,9 +172,9 @@ public function testRoundTripParseSerialization(): void $original = Query::equal('name', ['John']); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals($original->getMethod(), $parsed->getMethod()); - $this->assertEquals($original->getAttribute(), $parsed->getAttribute()); - $this->assertEquals($original->getValues(), $parsed->getValues()); + $this->assertSame($original->getMethod(), $parsed->getMethod()); + $this->assertSame($original->getAttribute(), $parsed->getAttribute()); + $this->assertSame($original->getValues(), $parsed->getValues()); } public function testRoundTripNestedParseSerialization(): void @@ -183,8 +185,510 @@ public function testRoundTripNestedParseSerialization(): void ]); $json = $original->toString(); $parsed = Query::parse($json); - $this->assertEquals($original->getMethod(), $parsed->getMethod()); + $this->assertSame($original->getMethod(), $parsed->getMethod()); $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->assertSame('id', $parsed->getAttribute()); + $this->assertSame(['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->assertSame('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->assertSame(['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->assertSame('orders', $parsed->getAttribute()); + $this->assertSame(['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->assertSame('colors', $parsed->getAttribute()); + } + + /** + * Raw round-tripping is only permitted when the caller explicitly opts in + * via `$allowRaw = true`. Raw queries bypass the binding pipeline and are + * therefore rejected by default when parsed from JSON. + */ + public function testRoundTripRaw(): void + { + $original = Query::raw('score > ?', [10]); + $json = $original->toString(); + $parsed = Query::parse($json, allowRaw: true); + $this->assertSame(Method::Raw, $parsed->getMethod()); + $this->assertSame('score > ?', $parsed->getAttribute()); + $this->assertSame([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->assertSame('score', $parsed->getAttribute()); + $this->assertSame(['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->assertSame('price', $parsed->getAttribute()); + $this->assertSame([], $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->assertSame(['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->assertSame('id', $parsed->getAttribute()); + $this->assertSame([], $parsed->getValues()); + } + + public function testRoundTripGroupByEmpty(): void + { + $original = Query::groupBy([]); + $json = $original->toString(); + $parsed = Query::parse($json); + $this->assertSame(Method::GroupBy, $parsed->getMethod()); + $this->assertSame([], $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->assertSame('profiles', $parsed->getAttribute()); + $this->assertSame(['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->assertSame(['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]); + } + + /** + * Raw round-tripping is only permitted when the caller explicitly opts in + * via `$allowRaw = true`. + */ + public function testRoundTripRawNoBindings(): void + { + $original = Query::raw('1 = 1'); + $json = $original->toString(); + $parsed = Query::parse($json, allowRaw: true); + $this->assertSame(Method::Raw, $parsed->getMethod()); + $this->assertSame('1 = 1', $parsed->getAttribute()); + $this->assertSame([], $parsed->getValues()); + } + + /** + * Raw round-tripping is only permitted when the caller explicitly opts in + * via `$allowRaw = true`. + */ + public function testRoundTripRawWithMultipleBindings(): void + { + $original = Query::raw('a > ? AND b < ?', [10, 20]); + $json = $original->toString(); + $parsed = Query::parse($json, allowRaw: true); + $this->assertSame([10, 20], $parsed->getValues()); + } + + public function testParseQueryRejectsRawByDefault(): void + { + $json = (string) \json_encode([ + 'method' => 'raw', + 'attribute' => 'DROP TABLE users;--', + 'values' => [], + ]); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Raw queries cannot be parsed from untrusted input'); + Query::parse($json); + } + + public function testParseQueryRejectsRawInParseQueryByDefault(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Raw queries cannot be parsed from untrusted input'); + Query::parseQuery([ + 'method' => 'raw', + 'attribute' => 'SELECT 1', + 'values' => [], + ]); + } + + public function testParseQueriesRejectsRawByDefault(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Raw queries cannot be parsed from untrusted input'); + Query::parseQueries([ + '{"method":"equal","attribute":"name","values":["John"]}', + '{"method":"raw","attribute":"1=1","values":[]}', + ]); + } + + public function testParseQueryAcceptsRawWhenOptedIn(): void + { + $json = '{"method":"raw","attribute":"score > ?","values":[10]}'; + $parsed = Query::parse($json, allowRaw: true); + $this->assertSame(Method::Raw, $parsed->getMethod()); + $this->assertSame('score > ?', $parsed->getAttribute()); + $this->assertSame([10], $parsed->getValues()); + } + + public function testParseQueryAcceptsRawInParseQueryWhenOptedIn(): void + { + $parsed = Query::parseQuery([ + 'method' => 'raw', + 'attribute' => 'SELECT 1', + 'values' => [], + ], allowRaw: true); + $this->assertSame(Method::Raw, $parsed->getMethod()); + } + + public function testParseQueriesAcceptsRawWhenOptedIn(): void + { + $parsed = Query::parseQueries([ + '{"method":"equal","attribute":"name","values":["John"]}', + '{"method":"raw","attribute":"1=1","values":[]}', + ], allowRaw: true); + $this->assertCount(2, $parsed); + $this->assertSame(Method::Raw, $parsed[1]->getMethod()); + } + + public function testParseQueryRejectsRawNestedInsideLogicalByDefault(): void + { + $json = (string) \json_encode([ + 'method' => 'or', + 'attribute' => '', + 'values' => [ + ['method' => 'equal', 'attribute' => 'name', 'values' => ['John']], + ['method' => 'raw', 'attribute' => '1=1', 'values' => []], + ], + ]); + + $this->expectException(ValidationException::class); + Query::parse($json); + } + + public function testParseQueryAcceptsRawNestedInsideLogicalWhenOptedIn(): void + { + $json = (string) \json_encode([ + 'method' => 'or', + 'attribute' => '', + 'values' => [ + ['method' => 'equal', 'attribute' => 'name', 'values' => ['John']], + ['method' => 'raw', 'attribute' => '1=1', 'values' => []], + ], + ]); + + $parsed = Query::parse($json, allowRaw: true); + $this->assertSame(Method::Or, $parsed->getMethod()); + $nested = $parsed->getValues(); + $this->assertCount(2, $nested); + $this->assertInstanceOf(Query::class, $nested[1]); + $this->assertSame(Method::Raw, $nested[1]->getMethod()); + } + + 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->assertSame('', $query->getAttribute()); + } + + public function testParseMissingValuesDefaultsToEmpty(): void + { + $query = Query::parse('{"method":"isNull"}'); + $this->assertSame([], $query->getValues()); + } + + public function testParseExtraFieldsIgnored(): void + { + $query = Query::parse('{"method":"equal","attribute":"x","values":[1],"extra":"ignored"}'); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertSame('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->assertSame('count', $array['method']); + $this->assertSame('id', $array['attribute']); + $this->assertSame(['total'], $array['values']); + } + + public function testToArrayCountWithoutAlias(): void + { + $query = Query::count(); + $array = $query->toArray(); + $this->assertSame('count', $array['method']); + $this->assertSame('*', $array['attribute']); + $this->assertSame([], $array['values']); + } + + public function testToArrayDistinct(): void + { + $query = Query::distinct(); + $array = $query->toArray(); + $this->assertSame('distinct', $array['method']); + $this->assertArrayNotHasKey('attribute', $array); + $this->assertSame([], $array['values']); + } + + public function testToArrayJoinPreservesOperator(): void + { + $query = Query::join('t', 'a', 'b', '!='); + $array = $query->toArray(); + $this->assertSame(['a', '!=', 'b'], $array['values']); + } + + public function testToArrayCrossJoin(): void + { + $query = Query::crossJoin('t'); + $array = $query->toArray(); + $this->assertSame('crossJoin', $array['method']); + $this->assertSame('t', $array['attribute']); + $this->assertSame([], $array['values']); + } + + public function testToArrayHaving(): void + { + $query = Query::having([Query::greaterThan('x', 1), Query::lessThan('y', 10)]); + $array = $query->toArray(); + $this->assertSame('having', $array['method']); + + /** @var array> $values */ + $values = $array['values'] ?? []; + $this->assertCount(2, $values); + $this->assertSame('greaterThan', $values[0]['method']); + } + + public function testToArrayUnionAll(): void + { + $query = Query::unionAll([Query::equal('x', [1])]); + $array = $query->toArray(); + $this->assertSame('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->assertSame('raw', $array['method']); + $this->assertSame('a > ?', $array['attribute']); + $this->assertSame([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->assertSame('groupBy', $decoded['method']); + $this->assertSame(['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->assertSame('raw', $decoded['method']); + $this->assertSame('x > ? AND y < ?', $decoded['attribute']); + $this->assertSame([1, 2], $decoded['values']); + } } diff --git a/tests/Query/QueryTest.php b/tests/Query/QueryTest.php index 6ecfc38..ea08871 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,47 +14,47 @@ class QueryTest extends TestCase public function testConstructorDefaults(): void { $query = new Query('equal'); - $this->assertEquals('equal', $query->getMethod()); - $this->assertEquals('', $query->getAttribute()); - $this->assertEquals([], $query->getValues()); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); } public function testConstructorWithAllParams(): void { $query = new Query('equal', 'name', ['John']); - $this->assertEquals('equal', $query->getMethod()); - $this->assertEquals('name', $query->getAttribute()); - $this->assertEquals(['John'], $query->getValues()); + $this->assertSame(Method::Equal, $query->getMethod()); + $this->assertSame('name', $query->getAttribute()); + $this->assertSame(['John'], $query->getValues()); } public function testConstructorOrderAscDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC); - $this->assertEquals('', $query->getAttribute()); + $query = new Query(Method::OrderAsc); + $this->assertSame('', $query->getAttribute()); } public function testConstructorOrderDescDefaultAttribute(): void { - $query = new Query(Query::TYPE_ORDER_DESC); - $this->assertEquals('', $query->getAttribute()); + $query = new Query(Method::OrderDesc); + $this->assertSame('', $query->getAttribute()); } public function testConstructorOrderAscWithAttribute(): void { - $query = new Query(Query::TYPE_ORDER_ASC, 'name'); - $this->assertEquals('name', $query->getAttribute()); + $query = new Query(Method::OrderAsc, 'name'); + $this->assertSame('name', $query->getAttribute()); } public function testGetValue(): void { $query = new Query('equal', 'name', ['John', 'Jane']); - $this->assertEquals('John', $query->getValue()); + $this->assertSame('John', $query->getValue()); } public function testGetValueDefault(): void { $query = new Query('equal', 'name'); - $this->assertEquals('fallback', $query->getValue('fallback')); + $this->assertSame('fallback', $query->getValue('fallback')); } public function testGetValueDefaultNull(): void @@ -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); } @@ -71,7 +75,7 @@ public function testSetAttribute(): void { $query = new Query('equal', 'name', ['John']); $result = $query->setAttribute('age'); - $this->assertEquals('age', $query->getAttribute()); + $this->assertSame('age', $query->getAttribute()); $this->assertSame($query, $result); } @@ -79,7 +83,7 @@ public function testSetValues(): void { $query = new Query('equal', 'name', ['John']); $result = $query->setValues(['Jane', 'Doe']); - $this->assertEquals(['Jane', 'Doe'], $query->getValues()); + $this->assertSame(['Jane', 'Doe'], $query->getValues()); $this->assertSame($query, $result); } @@ -87,7 +91,7 @@ public function testSetValue(): void { $query = new Query('equal', 'name', ['John', 'Jane']); $result = $query->setValue('Only'); - $this->assertEquals(['Only'], $query->getValues()); + $this->assertSame(['Only'], $query->getValues()); $this->assertSame($query, $result); } @@ -95,7 +99,7 @@ public function testSetAttributeType(): void { $query = new Query('equal', 'name'); $query->setAttributeType('string'); - $this->assertEquals('string', $query->getAttributeType()); + $this->assertSame('string', $query->getAttributeType()); } public function testOnArray(): void @@ -106,37 +110,38 @@ 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->assertSame('ASC', OrderDirection::Asc->value); + $this->assertSame('DESC', OrderDirection::Desc->value); + $this->assertSame('RANDOM', OrderDirection::Random->value); + $this->assertSame('after', CursorDirection::After->value); + $this->assertSame('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 { $query = Query::equal('name', []); - $this->assertEquals([], $query->getValues()); + $this->assertSame([], $query->getValues()); } public function testFingerprint(): void @@ -177,35 +182,35 @@ public function testFingerprint(): void public function testFingerprintNestedLogicalQueries(): void { // AND queries with different inner shapes produce different fingerprints - $andEqName = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice'])]); - $andEqEmail = new Query(Query::TYPE_AND, '', [Query::equal('email', ['a@b.c'])]); + $andEqName = new Query(Method::And, '', [Query::equal('name', ['Alice'])]); + $andEqEmail = new Query(Method::And, '', [Query::equal('email', ['a@b.c'])]); $this->assertNotSame(Query::fingerprint([$andEqName]), Query::fingerprint([$andEqEmail])); // AND queries with same inner shape produce the same fingerprint (values differ) - $andEqNameBob = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Bob'])]); + $andEqNameBob = new Query(Method::And, '', [Query::equal('name', ['Bob'])]); $this->assertSame(Query::fingerprint([$andEqName]), Query::fingerprint([$andEqNameBob])); // Order of children inside a logical query does not matter - $andA = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); - $andB = new Query(Query::TYPE_AND, '', [Query::greaterThan('age', 42), Query::equal('name', ['Bob'])]); + $andA = new Query(Method::And, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); + $andB = new Query(Method::And, '', [Query::greaterThan('age', 42), Query::equal('name', ['Bob'])]); $this->assertSame(Query::fingerprint([$andA]), Query::fingerprint([$andB])); // AND of two filters differs from OR of the same two filters - $orA = new Query(Query::TYPE_OR, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); + $orA = new Query(Method::Or, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); $this->assertNotSame(Query::fingerprint([$andA]), Query::fingerprint([$orA])); // AND with one child differs from AND with two children - $andOne = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice'])]); - $andTwo = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); + $andOne = new Query(Method::And, '', [Query::equal('name', ['Alice'])]); + $andTwo = new Query(Method::And, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); $this->assertNotSame(Query::fingerprint([$andOne]), Query::fingerprint([$andTwo])); // elemMatch attribute matters: same inner shape on different fields must NOT collide - $elemTags = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); - $elemCategories = new Query(Query::TYPE_ELEM_MATCH, 'categories', [Query::equal('name', ['php'])]); + $elemTags = new Query(Method::ElemMatch, 'tags', [Query::equal('name', ['php'])]); + $elemCategories = new Query(Method::ElemMatch, 'categories', [Query::equal('name', ['php'])]); $this->assertNotSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemCategories])); // elemMatch values-only change (same field, same child shape) still collides — as expected - $elemTagsOther = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['js'])]); + $elemTagsOther = new Query(Method::ElemMatch, 'tags', [Query::equal('name', ['js'])]); $this->assertSame(Query::fingerprint([$elemTags]), Query::fingerprint([$elemTagsOther])); } @@ -222,18 +227,18 @@ public function testShape(): void $this->assertSame('greaterThan:age', Query::greaterThan('age', 18)->shape()); // Logical with empty attribute - $and = new Query(Query::TYPE_AND, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); + $and = new Query(Method::And, '', [Query::equal('name', ['Alice']), Query::greaterThan('age', 18)]); $this->assertSame('and:(equal:name|greaterThan:age)', $and->shape()); // elemMatch preserves the attribute (the field being matched) - $elem = new Query(Query::TYPE_ELEM_MATCH, 'tags', [Query::equal('name', ['php'])]); + $elem = new Query(Method::ElemMatch, 'tags', [Query::equal('name', ['php'])]); $this->assertSame('elemMatch:tags(equal:name)', $elem->shape()); // Deeply nested — iterative traversal must match recursive result - $deep = new Query(Query::TYPE_AND, '', [ - new Query(Query::TYPE_OR, '', [ + $deep = new Query(Method::And, '', [ + new Query(Method::Or, '', [ Query::equal('a', ['x']), - new Query(Query::TYPE_AND, '', [ + new Query(Method::And, '', [ Query::equal('b', ['y']), Query::lessThan('c', 5), ]), @@ -245,4 +250,503 @@ public function testShape(): void $deep->shape(), ); } + + 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->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testRawFactory(): void + { + $query = Query::raw('score > ?', [10]); + $this->assertSame(Method::Raw, $query->getMethod()); + $this->assertSame('score > ?', $query->getAttribute()); + $this->assertSame([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->assertSame(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->assertSame(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->assertSame(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->assertSame('', $query->getAttribute()); + $this->assertSame([], $query->getValues()); + } + + public function testRawFactoryEmptyBindings(): void + { + $query = Query::raw('1 = 1', []); + $this->assertSame([], $query->getValues()); + } + + public function testRawFactoryMixedBindings(): void + { + $query = Query::raw('a = ? AND b = ? AND c = ?', ['str', 42, 3.14]); + $this->assertSame(['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->assertSame('count', Method::Count->value); + } + + public function testSumEnumValue(): void + { + $this->assertSame('sum', Method::Sum->value); + } + + public function testAvgEnumValue(): void + { + $this->assertSame('avg', Method::Avg->value); + } + + public function testMinEnumValue(): void + { + $this->assertSame('min', Method::Min->value); + } + + public function testMaxEnumValue(): void + { + $this->assertSame('max', Method::Max->value); + } + + public function testGroupByEnumValue(): void + { + $this->assertSame('groupBy', Method::GroupBy->value); + } + + public function testHavingEnumValue(): void + { + $this->assertSame('having', Method::Having->value); + } + + public function testDistinctEnumValue(): void + { + $this->assertSame('distinct', Method::Distinct->value); + } + + public function testJoinEnumValue(): void + { + $this->assertSame('join', Method::Join->value); + } + + public function testLeftJoinEnumValue(): void + { + $this->assertSame('leftJoin', Method::LeftJoin->value); + } + + public function testRightJoinEnumValue(): void + { + $this->assertSame('rightJoin', Method::RightJoin->value); + } + + public function testCrossJoinEnumValue(): void + { + $this->assertSame('crossJoin', Method::CrossJoin->value); + } + + public function testUnionEnumValue(): void + { + $this->assertSame('union', Method::Union->value); + } + + public function testUnionAllEnumValue(): void + { + $this->assertSame('unionAll', Method::UnionAll->value); + } + + public function testRawEnumValue(): void + { + $this->assertSame('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->assertSame('equal', $decoded['method']); + $this->assertSame('name', $decoded['attribute']); + $this->assertSame(['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->assertSame('and', $decoded['method']); + $this->assertIsArray($decoded['values']); + $this->assertCount(1, $decoded['values']); + /** @var array $inner */ + $inner = $decoded['values'][0]; + $this->assertSame('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->assertSame('equal', $array['method']); + $this->assertSame('age', $array['attribute']); + $this->assertSame([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->assertSame('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->assertSame('tags', $query->getAttribute()); + $this->assertSame(['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->assertSame([['a', 'b']], $query->getValues()); + } + + public function testJsonPathFactory(): void + { + $query = Query::jsonPath('data', 'name', '=', 'test'); + $this->assertSame(Method::JsonPath, $query->getMethod()); + $this->assertSame(['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/QuotesIdentifiersTest.php b/tests/Query/QuotesIdentifiersTest.php new file mode 100644 index 0000000..36df425 --- /dev/null +++ b/tests/Query/QuotesIdentifiersTest.php @@ -0,0 +1,56 @@ +wrapper = new QuotesIdentifiersHarness(); + } + + public function testDotlessIdentifierIsWrapped(): void + { + $this->assertSame('`users`', $this->wrapper->quote('users')); + } + + public function testBareStarIsPreserved(): void + { + $this->assertSame('*', $this->wrapper->quote('*')); + } + + public function testTableStarWrapsTableAndKeepsStar(): void + { + $this->assertSame('`users`.*', $this->wrapper->quote('users.*')); + } + + public function testDottedIdentifierIsWrappedSegmentwise(): void + { + $this->assertSame('`schema`.`users`', $this->wrapper->quote('schema.users')); + } + + public function testWrapCharIsDoubledInsideIdentifier(): void + { + $this->assertSame('```weird```', $this->wrapper->quote('`weird`')); + } + + public function testWrapCharIsDoubledInsideDottedSegment(): void + { + $this->assertSame('`schema`.```weird```', $this->wrapper->quote('schema.`weird`')); + } + + public function testStarInNonFinalSegmentIsQuotedAsLiteral(): void + { + $this->assertSame('`foo`.`*`.`bar`', $this->wrapper->quote('foo.*.bar')); + } + + public function testStarOnlyAllowedBareInFinalSegment(): void + { + $this->assertSame('`a`.`b`.*', $this->wrapper->quote('a.b.*')); + } +} diff --git a/tests/Query/Regression/CorrectnessRegressionTest.php b/tests/Query/Regression/CorrectnessRegressionTest.php new file mode 100644 index 0000000..ba6fb58 --- /dev/null +++ b/tests/Query/Regression/CorrectnessRegressionTest.php @@ -0,0 +1,472 @@ +from('users') + ->hint('INDEX(`users` `idx_users_age`)') + ->build(); + $this->assertBindingCount($result); + + $this->assertSame('SELECT /*+ INDEX(`users` `idx_users_age`) */ * FROM `users`', $result->query); + } + + public function testSQLiteUnionEmitsBareCompound(): void + { + // Pre-fix: the UNION wrapper emitted `(SELECT ...) UNION (SELECT ...)`, + // which SQLite rejects — SQLite requires bare compound selects. + $other = (new SQLiteBuilder()) + ->from('archived_users') + ->select(['id']); + + $result = (new SQLiteBuilder()) + ->from('users') + ->select(['id']) + ->union($other) + ->build(); + $this->assertBindingCount($result); + + $this->assertStringStartsNotWith('(', $result->query); + $this->assertStringNotContainsString(') UNION (', $result->query); + $this->assertSame('SELECT `id` FROM `users` UNION SELECT `id` FROM `archived_users`', $result->query); + } + + public function testClickHouseAlterRejectsEmptyAlterations(): void + { + // Pre-fix: `ALTER TABLE t` with no alterations would emit invalid SQL; + // post-fix the builder throws up-front. + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ALTER TABLE requires at least one alteration.'); + + $schema = new ClickHouseSchema(); + $schema->alter('events', function (Table $table): void { + // intentionally empty — triggers the guard + }); + } + + public function testMySqlHashCommentReplacementEmitsValidDoubleDash(): void + { + // Pre-fix: replaceHashComments replaced `#` with `--` but did not emit + // the trailing space, so `#cmt\n` became `--cmt\n`, which the retokenizer + // could then misparse. Post-fix, the replacement is `-- cmt\n`, leaving + // the rest of the SQL tokenizable. + $tokenizer = new MySQLTokenizer(); + $tokens = $tokenizer->tokenize("SELECT 1 # tail\nFROM `users`"); + + $joined = \implode(' ', \array_map(fn ($t) => $t->value, $tokens)); + $this->assertSame('SELECT 1 -- tail + FROM `users` ', $joined); + } + + public function testStatementCarriesMongoArrayFilters(): void + { + // Pre-fix: arrayFilter() wrapped the condition under the identifier + // key, producing [['elem' => ['elem.grade' => ...]]]. Post-fix, the + // Statement payload carries the flat filter MongoDB expects. + $result = (new MongoBuilder()) + ->from('students') + ->set(['grades.$[elem].mean' => 0]) + ->arrayFilter('elem', ['elem.grade' => ['$gte' => 85]]) + ->filter([Query::equal('_id', ['abc'])]) + ->update(); + + /** @var array $op */ + $op = \json_decode($result->query, true); + $this->assertArrayHasKey('options', $op); + /** @var array $options */ + $options = $op['options']; + $this->assertArrayHasKey('arrayFilters', $options); + /** @var list> $filters */ + $filters = $options['arrayFilters']; + $this->assertCount(1, $filters); + $this->assertArrayHasKey('elem.grade', $filters[0]); + $this->assertArrayNotHasKey('elem', $filters[0]); + } + + public function testMongoUpdateBindingsOrder(): void + { + // Pre-fix: update() built update operators before filters, but binding + // replacement walks serialized keys filter-first — causing the wrong + // bindings to land in each slot. Post-fix, bindings are filter-first, + // then update. + $result = (new MongoBuilder()) + ->from('users') + ->set(['city' => 'New York']) + ->filter([Query::equal('name', ['Alice'])]) + ->update(); + + $this->assertSame(['Alice', 'New York'], $result->bindings); + } + + public function testFingerprintDistinguishesElemMatchAttribute(): void + { + // Pre-fix: the fingerprint shape ignored the elemMatch attribute, so + // elemMatch('tags', ...) and elemMatch('categories', ...) with the + // same inner shape produced identical fingerprints. + $elemTags = Query::elemMatch('tags', [Query::equal('name', ['x'])]); + $elemCategories = Query::elemMatch('categories', [Query::equal('name', ['x'])]); + + $this->assertNotSame( + Query::fingerprint([$elemTags]), + Query::fingerprint([$elemCategories]), + ); + } + + public function testFingerprintRecursesIntoLogicalQueries(): void + { + // Pre-fix: AND/OR logical queries were fingerprinted by method alone, + // so AND([equal('a', ...)]) collided with AND([equal('b', ...)]). + // Post-fix, inner child shapes are recursed into. + $andA = Query::and([Query::equal('name', ['x'])]); + $andB = Query::and([Query::equal('email', ['x'])]); + + $this->assertNotSame( + Query::fingerprint([$andA]), + Query::fingerprint([$andB]), + ); + + // AND and OR with the same child shape must still differ. + $orA = Query::or([Query::equal('name', ['x'])]); + $this->assertNotSame( + Query::fingerprint([$andA]), + Query::fingerprint([$orA]), + ); + } + + public function testParsedQueryHasNoOrderAttributesField(): void + { + $this->assertFalse( + \property_exists(ParsedQuery::class, 'orderAttributes'), + 'ParsedQuery::$orderAttributes must be removed; nothing in the compile pipeline reads it.', + ); + } + + public function testParsedQueryHasNoOrderTypesField(): void + { + $this->assertFalse( + \property_exists(ParsedQuery::class, 'orderTypes'), + 'ParsedQuery::$orderTypes must be removed; nothing in the compile pipeline reads it.', + ); + } + + public function testOrderingStillEmittedThroughPendingQueries(): void + { + // Removing orderAttributes/orderTypes must not regress ORDER BY + // emission — the compiler reads order queries from pendingQueries + // directly via Query::getByType in compileOrderAndLimit. + $plan = (new MySQLBuilder()) + ->from('t') + ->queries([ + Query::orderAsc('name'), + Query::orderDesc('age'), + Query::orderRandom(), + ]) + ->build(); + + $this->assertSame('SELECT * FROM `t` ORDER BY `name` ASC, `age` DESC, RAND()', $plan->query); + } + + public function testResetClearsAliasQualificationState(): void + { + // prepareAliasQualification() sets $qualify and $aggregationAliases + // on each build(). reset() must clear both so the builder is in a + // clean state between builds — the values are per-build transient + // and not part of the user-facing API surface. + $builder = new MySQLBuilder(); + $builder + ->from('users', 'u') + ->queries([ + Query::join('orders', 'id', 'user_id', '=', 'o'), + Query::sum('amount', 'total'), + ]) + ->build(); + + $qualify = new \ReflectionProperty(Builder::class, 'qualify'); + $aggregationAliases = new \ReflectionProperty(Builder::class, 'aggregationAliases'); + + $this->assertTrue($qualify->getValue($builder)); + $this->assertNotSame([], $aggregationAliases->getValue($builder)); + + $builder->reset(); + + $this->assertFalse($qualify->getValue($builder), 'reset() must clear $qualify.'); + $this->assertSame([], $aggregationAliases->getValue($builder), 'reset() must clear $aggregationAliases.'); + } + + public function testResetPreservesUserInstalledHooks(): void + { + // Hooks and the executor are user-installed infrastructure, orthogonal + // to per-query state. They MUST survive reset() — this is the + // contract established by testResetPreservesConditionProviders and + // testResetPreservesAttributeResolver in MySQLTest. + $builder = new MySQLBuilder(); + $filterHook = new class () implements FilterHook { + public int $calls = 0; + + public function filter(string $table): Condition + { + $this->calls++; + + return new Condition('1 = 1', []); + } + }; + $attributeHook = new class () implements Attribute { + public int $calls = 0; + + public function resolve(string $attribute): string + { + $this->calls++; + + return $attribute; + } + }; + $joinHook = new class () implements JoinFilterHook { + public int $calls = 0; + + public function filterJoin(string $table, JoinType $joinType): ?JoinHookCondition + { + $this->calls++; + + return null; + } + }; + + $builder->from('t')->addHook($filterHook)->addHook($attributeHook)->addHook($joinHook); + $builder->reset(); + + $builder + ->from('users', 'u') + ->queries([ + Query::equal('id', [1]), + Query::join('orders', 'id', 'user_id', '=', 'o'), + ]) + ->build(); + + $this->assertGreaterThan(0, $filterHook->calls, 'Filter hook must survive reset().'); + $this->assertGreaterThan(0, $attributeHook->calls, 'Attribute hook must survive reset().'); + $this->assertGreaterThan(0, $joinHook->calls, 'Join filter hook must survive reset().'); + } + + public function testOffsetWithoutLimitThrowsOnMySQL(): void + { + $builder = (new MySQLBuilder())->from('t')->queries([Query::offset(5)]); + + $this->expectException(ValidationException::class); + $builder->build(); + } + + public function testOffsetWithLimitStillWorksOnMySQL(): void + { + $plan = (new MySQLBuilder()) + ->from('t') + ->queries([Query::limit(10), Query::offset(5)]) + ->build(); + + $this->assertSame('SELECT * FROM `t` LIMIT ? OFFSET ?', $plan->query); + } + + public function testOffsetWithoutLimitStillWorksOnPostgreSQL(): void + { + $plan = (new PostgreSQLBuilder()) + ->from('t') + ->queries([Query::offset(5)]) + ->build(); + + $this->assertSame('SELECT * FROM "t" OFFSET ?', $plan->query); + $this->assertStringNotContainsString('LIMIT ?', $plan->query); + } + + /** + * The join-type match arm used to have `default => JoinType::Inner`, which + * silently downgraded unknown join methods to INNER. Every real join enum + * member is already listed in the match, so the only reliable regression + * test is to read the compiled source and verify the default arm now + * throws UnsupportedException — any future join method added to + * Method::isJoin() but missed in the match will fail loudly. + */ + public function testUnsupportedJoinMethodMatchThrows(): void + { + $reflection = new \ReflectionMethod(Builder::class, 'buildJoinsClause'); + $source = $this->readMethodSource($reflection); + + $this->assertStringNotContainsString( + 'default => JoinType::Inner', + $source, + 'buildJoinsClause must not silently coerce unknown join methods to INNER.', + ); + $this->assertMatchesRegularExpression( + '/default\s*=>\s*throw\s+new\s+UnsupportedException/', + $source, + 'buildJoinsClause must throw UnsupportedException on unknown join methods.', + ); + } + + public function testBaseUpsertThrowsUnsupported(): void + { + $builder = new class () extends Builder { + use \Utopia\Query\Builder\Trait\Selects; + + protected function quote(string $identifier): string + { + return '`' . $identifier . '`'; + } + + protected function compileRandom(): string + { + return 'RAND()'; + } + + protected function compileRegex(string $attribute, array $values): string + { + return $attribute . ' REGEXP ?'; + } + }; + + $this->expectException(UnsupportedException::class); + $builder->from('t')->upsert(); + } + + public function testMongoDbHasNoForUpdate(): void + { + $this->assertFalse( + \method_exists(MongoBuilder::class, 'forUpdate'), + 'MongoDB must not expose forUpdate(); the base Builder duplicate is removed and MongoDB does not use the Locking trait.', + ); + } + + public function testMysqlJsonContainsRejectsInvalidUtf8(): void + { + $query = Query::containsAll('tags', ["\xB1\x31"]); + $query->setOnArray(true); + + $this->expectException(ValidationException::class); + (new MySQLBuilder()) + ->from('t') + ->queries([$query]) + ->build(); + } + + public function testMysqlJsonOverlapsRejectsInvalidUtf8(): void + { + $query = Query::containsAny('tags', ["\xB1\x31"]); + $query->setOnArray(true); + + $this->expectException(ValidationException::class); + (new MySQLBuilder()) + ->from('t') + ->queries([$query]) + ->build(); + } + + public function testOrderWithFillRejectsInjectionInDirection(): void + { + $builder = (new ClickHouseBuilder())->from('t'); + + $this->expectException(ValidationException::class); + $builder->orderWithFill('ts', 'ASC, 1; DROP TABLE t'); + } + + private function readMethodSource(\ReflectionMethod $method): string + { + $file = $method->getFileName(); + $this->assertIsString($file); + $lines = \file($file, FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + + $start = $method->getStartLine() - 1; + $end = $method->getEndLine(); + + return \implode("\n", \array_slice($lines, $start, $end - $start)); + } +} diff --git a/tests/Query/Regression/SecurityRegressionTest.php b/tests/Query/Regression/SecurityRegressionTest.php new file mode 100644 index 0000000..3e8261e --- /dev/null +++ b/tests/Query/Regression/SecurityRegressionTest.php @@ -0,0 +1,369 @@ + method(s): + * + * - d203ed7 fix(security): DDL input validation + wire-parser state machine + * testAlterColumnTypeRejectsSemicolonInUsingExpression + * testAlterColumnTypeRejectsDisallowedTypeCharacters + * testCreatePartitionRejectsCommentInjection + * testExtractKeywordIgnoresKeywordInsideStringLiteral + * testExtractKeywordIgnoresKeywordInsideBlockComment + * testClassifyDeleteInsideCteIsWrite + * - 5662d27 fix: cast/window selectors + mongo field names + parser depth + tokenizer bounds + * testMongoBuilderRejectsDollarPrefixedFieldName + * testMongoBuilderRejectsEmptyFieldName + * - c5a4ed3 fix: escape backslashes in DDL string literals + * testMySqlCreateTypeEnumEscapesTrailingBackslash + * testPostgreSqlCreateCollationRejectsInvalidOptionKey + * testPostgreSqlTablesampleRejectsInvalidMethod + * - 4eb2996 fix(ast): binary associativity + unary spacing + literal escaping + * (already covered directly in tests/Query/AST/SerializerTest.php by the + * commit itself; adding a belt-and-braces case for string literal backslash) + * testDdlStringLiteralEscapesBackslashes + * - ff64121 fix(query): reject Method::Raw from parse() by default + * testQueryParseRejectsRawByDefault + * testQueryParseAcceptsRawWhenAllowRawTrue + * testQueryParseRejectsRawNestedInsideOr + */ +class SecurityRegressionTest extends TestCase +{ + public function testAlterColumnTypeRejectsSemicolonInUsingExpression(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + $schema->alterColumnType('users', 'age', 'INTEGER', 'age::integer; DROP TABLE users'); + } + + public function testAlterColumnTypeRejectsDisallowedTypeCharacters(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + $schema->alterColumnType('users', 'age', "INTEGER'; DROP TABLE users; --"); + } + + public function testCreatePartitionRejectsCommentInjection(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + $schema->createPartition('orders', 'p1', "FOR VALUES IN ('a') /* evil */"); + } + + public function testExtractKeywordIgnoresKeywordInsideStringLiteral(): void + { + $parser = new MySQLParser(); + + // The keyword hidden inside the quoted string must not leak out. + // Pre-fix naive byte-scan would see "DELETE" as the first word after + // the SELECT in position, but extractKeyword should still report SELECT. + $this->assertSame('SELECT', $parser->extractKeyword("SELECT 'DELETE FROM users' AS x")); + } + + public function testExtractKeywordIgnoresKeywordInsideBlockComment(): void + { + $parser = new MySQLParser(); + + $this->assertSame('SELECT', $parser->extractKeyword('/* DELETE FROM users */ SELECT 1')); + } + + public function testCteClassifierIgnoresKeywordHiddenInStringLiteral(): void + { + $parser = new PostgreSQLParser(); + + // Pre-fix: a naive byte-scan could match INSERT inside the string and + // misclassify as Write. With the state machine, the quoted literal is + // skipped and the outer SELECT is the classifying keyword (Read). + $sql = "WITH x AS (SELECT 'INSERT INTO users VALUES(1)' AS s) SELECT * FROM x"; + $this->assertSame(Type::Read, $parser->classifySQL($sql)); + } + + public function testMongoBuilderRejectsDollarPrefixedFieldNameInPush(): void + { + $builder = new \Utopia\Query\Builder\MongoDB(); + $builder->from('users'); + + $this->expectException(ValidationException::class); + $builder->push('$where', 'value'); + } + + public function testMongoBuilderRejectsEmptyFieldNameInSet(): void + { + $builder = new \Utopia\Query\Builder\MongoDB(); + $builder->from('users'); + + $this->expectException(ValidationException::class); + $builder->set(['' => 'value']); + } + + public function testMySqlCreateTableEnumEscapesTrailingBackslash(): void + { + $schema = new MySQLSchema(); + $plan = $schema->create('widgets', function (\Utopia\Query\Schema\Table $t): void { + $t->enum('grade', ['A', 'B', "bad\\"]); + }); + + // Pre-fix: the trailing backslash could escape the closing quote. After + // the fix it must appear doubled inside the literal. + $this->assertSame("CREATE TABLE `widgets` (`grade` ENUM('A','B','bad\\\\') NOT NULL)", $plan->query); + } + + public function testPostgreSqlCreateCollationRejectsInvalidOptionKey(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + // Key with spaces/quotes would break out of option list if not validated + $schema->createCollation('c1', ["provider = 'x', danger" => 'icu']); + } + + public function testPostgreSqlTablesampleRejectsInvalidMethod(): void + { + $builder = new \Utopia\Query\Builder\PostgreSQL(); + $builder->from('users'); + + $this->expectException(ValidationException::class); + $builder->tablesample(10.0, "BERNOULLI); DROP TABLE users; --"); + } + + public function testDdlStringLiteralEscapesBackslashes(): void + { + // Belt-and-braces: a default column value ending in backslash must be + // serialised with the backslash doubled so the closing quote cannot be + // escaped by the payload under MySQL default SQL mode. + $schema = new MySQLSchema(); + $plan = $schema->create('notes', function (\Utopia\Query\Schema\Table $t): void { + $t->string('body')->default("evil\\"); + }); + + $this->assertSame("CREATE TABLE `notes` (`body` VARCHAR(255) NOT NULL DEFAULT 'evil\\\\')", $plan->query); + } + + public function testQueryParseRejectsRawByDefault(): void + { + $this->expectException(ValidationException::class); + Query::parseQuery([ + 'method' => Method::Raw->value, + 'values' => ["SELECT * FROM users; DROP TABLE users"], + ]); + } + + public function testQueryParseAcceptsRawWhenAllowRawTrue(): void + { + $query = Query::parseQuery([ + 'method' => Method::Raw->value, + 'values' => ['trusted_raw'], + ], allowRaw: true); + + $this->assertSame(Method::Raw, $query->getMethod()); + } + + public function testQueryParseRejectsRawNestedInsideOr(): void + { + $this->expectException(ValidationException::class); + Query::parseQuery([ + 'method' => Method::Or->value, + 'values' => [ + ['method' => Method::Equal->value, 'attribute' => 'a', 'values' => [1]], + ['method' => Method::Raw->value, 'values' => ['DROP TABLE users']], + ], + ]); + } + + public function testSelectWindowRejectsInjectionInsideParens(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new MySQLBuilder()) + ->from('t') + ->selectWindow('ROW_NUMBER(1); DROP TABLE x;-- )', 'w'); + } + + public function testSelectWindowRejectsSemicolonInsideArgs(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid window function'); + + (new MySQLBuilder()) + ->from('t') + ->selectWindow('SUM(a; DROP TABLE x)', 'w'); + } + + public function testSelectWindowAcceptsValidArgs(): void + { + $builder = (new PostgreSQLBuilder()) + ->from('t') + ->selectWindow('SUM("amount")', 'w', ['dept']); + + $this->assertInstanceOf(PostgreSQLBuilder::class, $builder); + } + + public function testCreateIndexRejectsDoubleQuoteInCollation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation'); + + new Index( + name: 'idx_name', + columns: ['name'], + collations: ['name' => '"en_US"'], + ); + } + + public function testCreateProcedureRejectsCommaInjectionInType(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid procedure parameter type'); + + $schema->createProcedure('p', [ + [ParameterDirection::In, 'x', 'VARCHAR(255), DROP TABLE x --'], + ], 'SELECT 1'); + } + + public function testSelectCastRejectsCommaInjectionInType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new MySQLBuilder()) + ->from('t') + ->selectCast('c', 'VARCHAR(255), DROP TABLE x --', 'a'); + } + + public function testSelectCastRejectsQuoteInjectionInType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new MySQLBuilder()) + ->from('t') + ->selectCast('c', "INT'; DROP TABLE x; --", 'a'); + } + + public function testSelectCastRejectsCommentSequenceInType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid cast type'); + + (new MySQLBuilder()) + ->from('t') + ->selectCast('c', 'INT /* danger */', 'a'); + } + + public function testAlterColumnTypeRejectsCommaInjectionInType(): void + { + $schema = new PostgreSQLSchema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column type'); + + $schema->alterColumnType('users', 'age', 'INTEGER, DROP TABLE users --'); + } + + public function testSelectCastAcceptsStructuredType(): void + { + $result = (new MySQLBuilder()) + ->from('t') + ->selectCast('c', 'DECIMAL(10, 2)', 'a') + ->build(); + + $this->assertSame('SELECT CAST(`c` AS DECIMAL(10, 2)) AS `a` FROM `t`', $result->query); + } + + public function testExtractFirstBsonKeyRejectsOutOfBoundsDocLength(): void + { + // OP_MSG header (16 bytes) + section kind + BSON with bogus doc length + $bsonBody = "\x10" . 'find' . "\x00" . \pack('V', 1) . "\x00"; + $bson = \pack('V', 0x7FFFFFFF) . $bsonBody; + $sectionKind = "\x00"; + $flags = \pack('V', 0); + $body = $flags . $sectionKind . $bson; + $header = \pack('V', 16 + \strlen($body)) + . \pack('V', 1) + . \pack('V', 0) + . \pack('V', 2013); + $data = $header . $body; + + $parser = new MongoDBParser(); + // Malformed packet must not produce a classification — extractFirstBsonKey + // must bail on the out-of-bounds docLen instead of scanning past it. + $this->assertSame(Type::Unknown, $parser->parse($data)); + } + + public function testQuoteRejectsNullByteInIdentifier(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Identifier contains control character'); + + (new MySQLBuilder())->from("users\x00 DROP TABLE x")->build(); + } + + public function testQuoteRejectsControlByteInIdentifier(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Identifier contains control character'); + + (new MySQLBuilder())->from("users\x1f")->build(); + } + + public function testQuoteAcceptsValidIdentifier(): void + { + $result = (new MySQLBuilder())->from('users')->build(); + + $this->assertSame('SELECT * FROM `users`', $result->query); + } + + public function testJoinOnRejectsInjectionInLeftIdentifier(): void + { + $join = new JoinBuilder(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column name'); + + $join->on('users.id); DROP TABLE users; --', 'orders.user_id'); + } + + public function testJoinOnRejectsInjectionInRightIdentifier(): void + { + $join = new JoinBuilder(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column name'); + + $join->on('users.id', 'orders.user_id OR 1=1'); + } + + public function testJoinOnAcceptsValidIdentifiers(): void + { + $join = new JoinBuilder(); + $join->on('users.id', 'orders.user_id'); + + $this->assertCount(1, $join->ons); + } +} diff --git a/tests/Query/Schema/ClickHouseTest.php b/tests/Query/Schema/ClickHouseTest.php new file mode 100644 index 0000000..74c3d6c --- /dev/null +++ b/tests/Query/Schema/ClickHouseTest.php @@ -0,0 +1,723 @@ +create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->datetime('created_at', 3); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, `created_at` DateTime64(3)) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Table $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->assertSame('CREATE TABLE `test_types` (`int_col` Int32, `uint_col` UInt32, `big_col` Int64, `ubig_col` UInt64, `float_col` Float64, `bool_col` UInt8, `text_col` String, `json_col` String, `bin_col` String) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + } + + public function testCreateTableNullableWrapping(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name')->nullable(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`name` Nullable(String)) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + } + + public function testCreateTableWithEnum(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->enum('status', ['active', 'inactive']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`status` Enum8(\'active\' = 1, \'inactive\' = 2)) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + } + + public function testCreateTableWithVector(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Table $table) { + $table->vector('embedding', 768); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `embeddings` (`embedding` Array(Float64)) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('geo', function (Table $table) { + $table->point('coords'); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `geo` (`coords` Tuple(Float64, Float64), `path` Array(Tuple(Float64, Float64)), `area` Array(Array(Tuple(Float64, Float64)))) ENGINE = MergeTree() ORDER BY tuple()', $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 (Table $table) { + $table->foreignKey('user_id')->references('id')->on('users'); + }); + } + + public function testCreateTableWithIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->index(['name']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, INDEX `idx_name` `name` TYPE minmax GRANULARITY 3) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->addColumn('score', 'float'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `events` ADD COLUMN `score` Float64', $result->query); + } + + public function testAlterModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->modifyColumn('name', 'string'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `events` MODIFY COLUMN `name` String', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->renameColumn('old', 'new'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `events` RENAME COLUMN `old` TO `new`', $result->query); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->dropColumn('old_col'); + }); + $this->assertBindingCount($result); + + $this->assertSame('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 (Table $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->assertSame('DROP TABLE `events`', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('events'); + $this->assertBindingCount($result); + + $this->assertSame('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->assertSame( + 'CREATE VIEW `active_events` AS SELECT * FROM `events` WHERE `status` IN (?)', + $result->query + ); + $this->assertSame(['active'], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_events'); + + $this->assertSame('DROP VIEW `active_events`', $result->query); + } + // DROP INDEX (ClickHouse-specific) + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('events', 'idx_name'); + + $this->assertSame('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->assertSame('DROP TABLE IF EXISTS `events`', $result->query); + } + + public function testCreateTableWithDefaultValue(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->integer('count')->default(0); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` Int64, `count` Int32 DEFAULT 0) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name')->comment('User name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` Int64, `name` String COMMENT \'User name\') ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testCreateTableMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->datetime('created_at', 3)->primary(); + $table->string('name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `created_at` DateTime64(3), `name` String) ENGINE = MergeTree() ORDER BY (`id`, `created_at`)', $result->query); + } + + public function testCreateTableWithCompositePrimaryKey(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id'); + $table->datetime('created_at', 3); + $table->string('name'); + $table->primary(['id', 'created_at']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `created_at` DateTime64(3), `name` String) ENGINE = MergeTree() ORDER BY (`id`, `created_at`)', $result->query); + } + + public function testCreateTableRejectsMixedColumnAndTablePrimary(): void + { + $schema = new Schema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot combine column-level primary() with Table::primary() composite key.'); + + $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->datetime('created_at', 3); + $table->primary(['id', 'created_at']); + }); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->addColumn('score', 'float'); + $table->dropColumn('old_col'); + $table->renameColumn('nm', 'name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `events` ADD COLUMN `score` Float64, RENAME COLUMN `nm` TO `name`, DROP COLUMN `old_col`', $result->query); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('events', function (Table $table) { + $table->dropIndex('idx_name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `events` DROP INDEX `idx_name`', $result->query); + } + + public function testCreateTableWithMultipleIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name']); + $table->index(['type']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, `type` String, INDEX `idx_name` `name` TYPE minmax GRANULARITY 3, INDEX `idx_type` `type` TYPE minmax GRANULARITY 3) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->timestamp('ts_col'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` Int64, `ts_col` DateTime) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->datetime('dt_col'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` Int64, `dt_col` DateTime) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + $this->assertStringNotContainsString('DateTime64', $result->query); + } + + public function testCreateTableWithCompositeIndex(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->string('type'); + $table->index(['name', 'type']); + }); + $this->assertBindingCount($result); + + // Composite index wraps in parentheses + $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, `type` String, INDEX `idx_name_type` (`name`, `type`) TYPE minmax GRANULARITY 3) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testAlterForeignKeyStillThrows(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->alter('events', function (Table $table) { + $table->dropForeignKey('fk_old'); + }); + } + + public function testExactCreateTableWithEngine(): void + { + $schema = new Schema(); + $result = $schema->create('metrics', function (Table $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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('metrics', function (Table $table) { + $table->addColumn('description', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE `metrics` ADD COLUMN `description` Nullable(String)', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('metrics'); + + $this->assertSame('DROP TABLE `metrics`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testImplementsTableComments(): void + { + $this->assertInstanceOf(TableComments::class, new Schema()); + } + + public function testImplementsColumnComments(): void + { + $this->assertInstanceOf(ColumnComments::class, new Schema()); + } + + public function testImplementsDropPartition(): void + { + $this->assertInstanceOf(DropPartition::class, new Schema()); + } + + public function testCommentOnTable(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('events', 'Main events table'); + + $this->assertSame("ALTER TABLE `events` MODIFY COMMENT 'Main events table'", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCommentOnColumn(): void + { + $schema = new Schema(); + $result = $schema->commentOnColumn('events', 'name', 'Event name'); + + $this->assertSame("ALTER TABLE `events` COMMENT COLUMN `name` 'Event name'", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropPartition(): void + { + $schema = new Schema(); + $result = $schema->dropPartition('events', '202401'); + + $this->assertSame("ALTER TABLE `events` DROP PARTITION '202401'", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateTableWithPartition(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + $table->datetime('created_at', 3); + $table->partitionByRange('toYYYYMM(created_at)'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `name` String, `created_at` DateTime64(3)) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at) ORDER BY (`id`)', $result->query); + } + + public function testCreateTableIfNotExists(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->string('name'); + }, ifNotExists: true); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE IF NOT EXISTS `events` (`id` Int64, `name` String) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } + + public function testCompileAutoIncrementReturnsEmpty(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigInteger('id')->primary()->autoIncrement(); + }); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('AUTO_INCREMENT', $result->query); + $this->assertStringNotContainsString('IDENTITY', $result->query); + } + + public function testCompileUnsignedReturnsEmpty(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->integer('val')->unsigned(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`val` UInt32) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + $this->assertStringNotContainsString('UNSIGNED', $result->query); + } + + public function testCommentOnTableEscapesSingleQuotes(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('events', "User's events"); + + $this->assertSame('ALTER TABLE `events` MODIFY COMMENT \'User\'\'s events\'', $result->query); + } + + public function testCommentOnColumnEscapesSingleQuotes(): void + { + $schema = new Schema(); + $result = $schema->commentOnColumn('events', 'name', "It's a name"); + + $this->assertSame('ALTER TABLE `events` COMMENT COLUMN `name` \'It\'\'s a name\'', $result->query); + } + + public function testDropPartitionEscapesSingleQuotes(): void + { + $schema = new Schema(); + $result = $schema->dropPartition('events', "test'val"); + + $this->assertSame('ALTER TABLE `events` DROP PARTITION \'test\'\'val\'', $result->query); + } + + public function testEnumEscapesBackslash(): void + { + $schema = new Schema(); + $result = $schema->create('items', function (Table $table) { + // Input: a\' ; backslash must be escaped BEFORE the quote + // so the quote-escape `\'` is not cancelled by a trailing `\`. + $table->enum('status', ["a\\'b"]); + }); + + // Output literal: 'a\\\'b' (a, 2 backslashes, escaped quote, b) + $this->assertSame("CREATE TABLE `items` (`status` Enum8('a\\\\\\'b' = 1)) ENGINE = MergeTree() ORDER BY tuple()", $result->query); + } + + public function testCreateMergeTreeWithoutPrimaryKeysEmitsOrderByTuple(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->string('name'); + $table->integer('count'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`name` String, `count` Int32) ENGINE = MergeTree() ORDER BY tuple()', $result->query); + $this->assertStringNotContainsString('ORDER BY (', $result->query); + } + + public function testAlterTableWithNoAlterationsThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('ALTER TABLE requires at least one alteration.'); + + $schema = new Schema(); + $schema->alter('events', function (Table $table) { + // no alterations + }); + } + + public function testCreateReplacingMergeTreeEmitsEngineWithVersion(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->bigInteger('id')->primary(); + $table->integer('version'); + $table->engine(Engine::ReplacingMergeTree, 'version'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int64, `version` Int32) ENGINE = ReplacingMergeTree(`version`) ORDER BY (`id`)', $result->query); + } + + public function testCreateSummingMergeTreeEmitsEngineWithColumns(): void + { + $schema = new Schema(); + $result = $schema->create('metrics', function (Table $table) { + $table->integer('key')->primary(); + $table->bigInteger('total')->unsigned(); + $table->bigInteger('count')->unsigned(); + $table->engine(Engine::SummingMergeTree, 'total', 'count'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `metrics` (`key` Int32, `total` UInt64, `count` UInt64) ENGINE = SummingMergeTree(`total`, `count`) ORDER BY (`key`)', $result->query); + } + + public function testCreateCollapsingMergeTreeRejectsMissingSignColumn(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('CollapsingMergeTree requires a sign column.'); + + $schema = new Schema(); + $schema->create('events', function (Table $table) { + $table->integer('id')->primary(); + $table->engine(Engine::CollapsingMergeTree); + }); + } + + public function testCreateMemoryEngineSkipsOrderBy(): void + { + $schema = new Schema(); + $result = $schema->create('cache', function (Table $table) { + $table->integer('id')->primary(); + $table->string('value'); + $table->engine(Engine::Memory); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `cache` (`id` Int32, `value` String) ENGINE = Memory', $result->query); + $this->assertStringNotContainsString('ORDER BY', $result->query); + } + + public function testCreateAggregatingMergeTreeEmitsEmptyArgs(): void + { + $schema = new Schema(); + $result = $schema->create('agg', function (Table $table) { + $table->integer('key')->primary(); + $table->engine(Engine::AggregatingMergeTree); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `agg` (`key` Int32) ENGINE = AggregatingMergeTree() ORDER BY (`key`)', $result->query); + } + + public function testCreateReplicatedMergeTreeRejectsMissingArgs(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->create('events', function (Table $table) { + $table->integer('id')->primary(); + $table->engine(Engine::ReplicatedMergeTree, '/clickhouse/tables/events'); + }); + } + + public function testTableLevelTTL(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->integer('id')->primary(); + $table->datetime('ts'); + $table->ttl('ts + INTERVAL 1 DAY'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int32, `ts` DateTime) ENGINE = MergeTree() ORDER BY (`id`) TTL ts + INTERVAL 1 DAY', $result->query); + } + + public function testTableLevelTTLRejectsSemicolon(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->create('events', function (Table $table) { + $table->integer('id')->primary(); + $table->ttl('ts + INTERVAL 1 DAY;'); + }); + } + + public function testColumnLevelTTL(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->integer('id')->primary(); + $table->string('temporary')->ttl('ts + INTERVAL 1 DAY'); + $table->datetime('ts'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` Int32, `temporary` String TTL ts + INTERVAL 1 DAY, `ts` DateTime) ENGINE = MergeTree() ORDER BY (`id`)', $result->query); + } +} diff --git a/tests/Query/Schema/MongoDBTest.php b/tests/Query/Schema/MongoDBTest.php new file mode 100644 index 0000000..77adc31 --- /dev/null +++ b/tests/Query/Schema/MongoDBTest.php @@ -0,0 +1,418 @@ +create('users', function (Table $table) { + $table->id('id'); + $table->string('name'); + $table->string('email'); + $table->integer('age'); + }); + + $op = $this->decode($result->query); + $this->assertSame('createCollection', $op['command']); + $this->assertSame('users', $op['collection']); + $this->assertArrayHasKey('validator', $op); + /** @var array $validator */ + $validator = $op['validator']; + $this->assertArrayHasKey('$jsonSchema', $validator); + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + $this->assertSame('object', $jsonSchema['bsonType']); + /** @var array $properties */ + $properties = $jsonSchema['properties']; + $this->assertArrayHasKey('id', $properties); + $this->assertArrayHasKey('name', $properties); + } + + public function testCreateCollectionWithTypes(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id('id'); + $table->string('title'); + $table->text('body'); + $table->integer('views'); + $table->float('rating'); + $table->boolean('published'); + $table->datetime('created_at'); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $props */ + $props = $jsonSchema['properties']; + $this->assertSame('int', $props['id']['bsonType']); + $this->assertSame('string', $props['title']['bsonType']); + $this->assertSame('string', $props['body']['bsonType']); + $this->assertSame('int', $props['views']['bsonType']); + $this->assertSame('double', $props['rating']['bsonType']); + $this->assertSame('bool', $props['published']['bsonType']); + $this->assertSame('date', $props['created_at']['bsonType']); + } + + public function testCreateCollectionWithEnumValidation(): void + { + $schema = new Schema(); + $result = $schema->create('tasks', function (Table $table) { + $table->id('id'); + $table->enum('status', ['pending', 'active', 'completed']); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $properties */ + $properties = $jsonSchema['properties']; + $statusProp = $properties['status']; + $this->assertSame('string', $statusProp['bsonType']); + $this->assertSame(['pending', 'active', 'completed'], $statusProp['enum']); + } + + public function testCreateCollectionWithRequired(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Table $table) { + $table->id('id'); + $table->string('name'); + $table->string('email')->nullable(); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var list $required */ + $required = $jsonSchema['required']; + $this->assertContains('id', $required); + $this->assertContains('name', $required); + $this->assertNotContains('email', $required); + } + + public function testCreateCollectionRejectsCompositePrimaryKey(): void + { + $schema = new Schema(); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Composite primary keys are not supported in MongoDB'); + + $schema->create('order_items', function (Table $table) { + $table->integer('order_id'); + $table->integer('product_id'); + $table->primary(['order_id', 'product_id']); + }); + } + + public function testDrop(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + + $op = $this->decode($result->query); + $this->assertSame('drop', $op['command']); + $this->assertSame('users', $op['collection']); + } + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $op = $this->decode($result->query); + $this->assertSame('drop', $op['command']); + $this->assertSame('users', $op['collection']); + } + + public function testRename(): void + { + $schema = new Schema(); + $result = $schema->rename('old_users', 'new_users'); + + $op = $this->decode($result->query); + $this->assertSame('renameCollection', $op['command']); + $this->assertSame('old_users', $op['from']); + $this->assertSame('new_users', $op['to']); + } + + public function testTruncate(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + + $op = $this->decode($result->query); + $this->assertSame('deleteMany', $op['command']); + $this->assertSame('users', $op['collection']); + } + + public function testCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], true); + + $op = $this->decode($result->query); + $this->assertSame('createIndex', $op['command']); + $this->assertSame('users', $op['collection']); + /** @var array $index */ + $index = $op['index']; + $this->assertSame(['email' => 1], $index['key']); + $this->assertSame('idx_email', $index['name']); + $this->assertTrue($index['unique']); + } + + public function testCreateCompoundIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'events', + 'idx_user_action', + ['user_id', 'action'], + orders: ['action' => 'desc'], + ); + + $op = $this->decode($result->query); + /** @var array $index */ + $index = $op['index']; + $this->assertSame(['user_id' => 1, 'action' => -1], $index['key']); + } + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $op = $this->decode($result->query); + $this->assertSame('dropIndex', $op['command']); + $this->assertSame('users', $op['collection']); + $this->assertSame('idx_email', $op['index']); + } + + public function testAnalyzeTable(): void + { + $schema = new Schema(); + $result = $schema->analyzeTable('users'); + + $op = $this->decode($result->query); + $this->assertSame('collStats', $op['command']); + $this->assertSame('users', $op['collection']); + } + + public function testCreateDatabase(): void + { + $schema = new Schema(); + $result = $schema->createDatabase('mydb'); + + $op = $this->decode($result->query); + $this->assertSame('createDatabase', $op['command']); + $this->assertSame('mydb', $op['database']); + } + + public function testDropDatabase(): void + { + $schema = new Schema(); + $result = $schema->dropDatabase('mydb'); + + $op = $this->decode($result->query); + $this->assertSame('dropDatabase', $op['command']); + $this->assertSame('mydb', $op['database']); + } + + public function testAlter(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->string('phone'); + $table->boolean('verified'); + }); + + $op = $this->decode($result->query); + $this->assertSame('collMod', $op['command']); + $this->assertSame('users', $op['collection']); + $this->assertArrayHasKey('validator', $op); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array $props */ + $props = $jsonSchema['properties']; + $this->assertArrayHasKey('phone', $props); + $this->assertArrayHasKey('verified', $props); + } + + public function testColumnComment(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Table $table) { + $table->string('name')->comment('The display name'); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $properties */ + $properties = $jsonSchema['properties']; + $nameProp = $properties['name']; + $this->assertSame('The display name', $nameProp['description']); + } + + public function testAlterWithMultipleColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->string('phone'); + $table->integer('age'); + $table->boolean('verified'); + }); + + $op = $this->decode($result->query); + $this->assertSame('collMod', $op['command']); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array $props */ + $props = $jsonSchema['properties']; + $this->assertArrayHasKey('phone', $props); + $this->assertArrayHasKey('age', $props); + $this->assertArrayHasKey('verified', $props); + /** @var list $required */ + $required = $jsonSchema['required']; + $this->assertContains('phone', $required); + $this->assertContains('age', $required); + $this->assertContains('verified', $required); + } + + public function testAlterWithColumnComment(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->string('phone')->comment('User phone number'); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $props */ + $props = $jsonSchema['properties']; + $this->assertSame('User phone number', $props['phone']['description']); + } + + public function testAlterDropColumnThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('MongoDB does not support dropping or renaming columns via schema'); + + $schema = new Schema(); + $schema->alter('users', function (Table $table) { + $table->dropColumn('old_field'); + }); + } + + public function testAlterRenameColumnThrows(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('MongoDB does not support dropping or renaming columns via schema'); + + $schema = new Schema(); + $schema->alter('users', function (Table $table) { + $table->renameColumn('old_name', 'new_name'); + }); + } + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new Builder()) + ->from('users') + ->select(['name', 'email']) + ->filter([Query::equal('active', [true])]); + + $result = $schema->createView('active_users', $builder); + + $op = $this->decode($result->query); + $this->assertSame('createView', $op['command']); + $this->assertSame('active_users', $op['view']); + $this->assertSame('users', $op['source']); + $this->assertArrayHasKey('pipeline', $op); + $this->assertSame([true], $result->bindings); + } + + public function testCreateViewFromAggregation(): void + { + $schema = new Schema(); + $builder = (new Builder()) + ->from('orders') + ->count('*', 'total') + ->groupBy(['user_id']); + + $result = $schema->createView('order_counts', $builder); + + $op = $this->decode($result->query); + $this->assertSame('createView', $op['command']); + $this->assertSame('order_counts', $op['view']); + $this->assertSame('orders', $op['source']); + /** @var list> $pipeline */ + $pipeline = $op['pipeline']; + $this->assertNotEmpty($pipeline); + } + + public function testCreateCollectionWithAllBsonTypes(): void + { + $schema = new Schema(); + $result = $schema->create('all_types', function (Table $table) { + $table->json('meta'); + $table->binary('data'); + $table->point('location'); + $table->linestring('path'); + $table->polygon('area'); + $table->addColumn('uid', ColumnType::Uuid7); + $table->vector('embedding', 768); + }); + + $op = $this->decode($result->query); + /** @var array $validator */ + $validator = $op['validator']; + /** @var array $jsonSchema */ + $jsonSchema = $validator['$jsonSchema']; + /** @var array> $props */ + $props = $jsonSchema['properties']; + $this->assertSame('object', $props['meta']['bsonType']); + $this->assertSame('binData', $props['data']['bsonType']); + $this->assertSame('object', $props['location']['bsonType']); + $this->assertSame('object', $props['path']['bsonType']); + $this->assertSame('object', $props['area']['bsonType']); + $this->assertSame('string', $props['uid']['bsonType']); + $this->assertSame('array', $props['embedding']['bsonType']); + } + + /** + * @return array + */ + private function decode(string $json): array + { + /** @var array */ + return \json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Query/Schema/MySQLTest.php b/tests/Query/Schema/MySQLTest.php new file mode 100644 index 0000000..ea8376c --- /dev/null +++ b/tests/Query/Schema/MySQLTest.php @@ -0,0 +1,1228 @@ +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 (Table $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + $this->assertBindingCount($result); + + $this->assertSame( + '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->assertSame([], $result->bindings); + } + + public function testCreateTableAllColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Table $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->assertSame('CREATE TABLE `test_types` (`int_col` INT NOT NULL, `big_col` BIGINT NOT NULL, `float_col` DOUBLE NOT NULL, `bool_col` TINYINT(1) NOT NULL, `text_col` TEXT NOT NULL, `dt_col` DATETIME(3) NOT NULL, `ts_col` TIMESTAMP(6) NOT NULL, `json_col` JSON NOT NULL, `bin_col` BLOB NOT NULL, `status` ENUM(\'active\',\'inactive\') NOT NULL)', $result->query); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $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->assertSame('CREATE TABLE `posts` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `bio` TEXT NULL, `active` TINYINT(1) NOT NULL DEFAULT 1, `score` INT NOT NULL DEFAULT 0, `status` VARCHAR(255) NOT NULL DEFAULT \'draft\', PRIMARY KEY (`id`))', $result->query); + } + + public function testCreateTableWithUnsigned(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->integer('age')->unsigned(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`age` INT UNSIGNED NOT NULL)', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->timestamps(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `posts` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `created_at` DATETIME(3) NOT NULL, `updated_at` DATETIME(3) NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade)->onUpdate(ForeignKeyAction::SetNull); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `posts` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`), 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 (Table $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->index(['name', 'email']); + $table->uniqueIndex(['email']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `users` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), INDEX `idx_name_email` (`name`, `email`), UNIQUE INDEX `uniq_email` (`email`))', $result->query); + } + + public function testCreateTableWithSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Table $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `locations` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `coords` POINT SRID 4326 NOT NULL, `path` LINESTRING SRID 4326 NOT NULL, `area` POLYGON SRID 4326 NOT NULL, PRIMARY KEY (`id`))', $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 (Table $table) { + $table->vector('embedding', 768); + }); + } + + public function testCreateTableWithComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name')->comment('User display name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`name` VARCHAR(255) NOT NULL COMMENT \'User display name\')', $result->query); + } + // ALTER TABLE + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('avatar_url', 'string', 255)->nullable()->after('email'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + '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 (Table $table) { + $table->modifyColumn('name', 'string', 500); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` MODIFY COLUMN `name` VARCHAR(500) NOT NULL', + $result->query + ); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` RENAME COLUMN `bio` TO `biography`', + $result->query + ); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->dropColumn('age'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` DROP COLUMN `age`', + $result->query + ); + } + + public function testAlterAddIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addIndex('idx_name', ['name']); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` ADD INDEX `idx_name` (`name`)', + $result->query + ); + } + + public function testAlterDropIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->dropIndex('idx_old'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` DROP INDEX `idx_old`', + $result->query + ); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addForeignKey('dept_id') + ->references('id')->on('departments'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `users` ADD FOREIGN KEY (`dept_id`) REFERENCES `departments` (`id`)', $result->query); + } + + public function testAlterDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->dropForeignKey('fk_old'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` DROP FOREIGN KEY `fk_old`', + $result->query + ); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('avatar', 'string', 255)->nullable(); + $table->dropColumn('age'); + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `users` ADD COLUMN `avatar` VARCHAR(255) NULL, RENAME COLUMN `bio` TO `biography`, DROP COLUMN `age`', $result->query); + } + // DROP TABLE + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + $this->assertBindingCount($result); + + $this->assertSame('DROP TABLE `users`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropTableIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertSame('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->assertSame('RENAME TABLE `users` TO `members`', $result->query); + } + // TRUNCATE TABLE + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + $this->assertBindingCount($result); + + $this->assertSame('TRUNCATE TABLE `users`', $result->query); + } + // CREATE / DROP INDEX (standalone) + + public function testCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertSame('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->assertSame('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->assertSame('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->assertSame('CREATE SPATIAL INDEX `idx_geo` ON `locations` (`coords`)', $result->query); + } + + public function testDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertSame('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->assertSame( + 'CREATE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertSame([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->assertSame( + 'CREATE OR REPLACE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertSame([true], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertSame('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: ForeignKeyAction::Cascade, + onUpdate: ForeignKeyAction::SetNull + ); + + $this->assertSame( + '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->assertSame( + '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->assertSame( + '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: [[ParameterDirection::In, 'user_id', 'INT'], [ParameterDirection::Out, 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertSame( + '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->assertSame('DROP PROCEDURE `update_stats`', $result->query); + } + // TRIGGER + + public function testCreateTrigger(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: TriggerTiming::Before, + event: TriggerEvent::Update, + body: 'SET NEW.updated_at = NOW(3);' + ); + + $this->assertSame( + '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->assertSame('DROP TRIGGER `trg_updated_at`', $result->query); + } + + // Schema edge cases + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + $table->integer('quantity'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `order_items` (`order_id` INT NOT NULL, `product_id` INT NOT NULL, `quantity` INT NOT NULL, PRIMARY KEY (`order_id`, `product_id`))', $result->query); + } + + public function testCreateTableWithCompositePrimaryKey(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Table $table) { + $table->integer('order_id'); + $table->integer('product_id'); + $table->integer('quantity'); + $table->primary(['order_id', 'product_id']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `order_items` (`order_id` INT NOT NULL, `product_id` INT NOT NULL, `quantity` INT NOT NULL, PRIMARY KEY (`order_id`, `product_id`))', $result->query); + } + + public function testCreateTableRejectsMixedColumnAndTablePrimary(): void + { + $schema = new Schema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot combine column-level primary() with Table::primary() composite key.'); + + $schema->create('order_items', function (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id'); + $table->primary(['order_id', 'product_id']); + }); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name')->nullable()->default(null); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`name` VARCHAR(255) NULL DEFAULT NULL)', $result->query); + } + + public function testCreateTableWithNumericDefault(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->float('score')->default(0.5); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`score` DOUBLE NOT NULL DEFAULT 0.5)', $result->query); + } + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertSame('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 (Table $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->assertSame('ALTER TABLE `users` ADD COLUMN `first_name` VARCHAR(100) NOT NULL, ADD COLUMN `last_name` VARCHAR(100) NOT NULL, DROP COLUMN `name`, ADD INDEX `idx_names` (`first_name`, `last_name`)', $result->query); + } + + public function testCreateTableForeignKeyWithAllActions(): void + { + $schema = new Schema(); + $result = $schema->create('comments', function (Table $table) { + $table->id(); + $table->foreignKey('post_id') + ->references('id')->on('posts') + ->onDelete(ForeignKeyAction::Cascade)->onUpdate(ForeignKeyAction::Restrict); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `comments` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (`post_id`) REFERENCES `posts` (`id`) ON DELETE CASCADE 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->assertSame('DROP TRIGGER `trg_old`', $result->query); + } + + public function testCreateTableTimestampWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->timestamp('ts_col'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`ts_col` TIMESTAMP NOT NULL)', $result->query); + $this->assertStringNotContainsString('TIMESTAMP(', $result->query); + } + + public function testCreateTableDatetimeWithoutPrecision(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->datetime('dt_col'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`dt_col` 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->assertSame('CREATE INDEX `idx_multi` ON `users` (`first_name`, `last_name`)', $result->query); + } + + public function testAlterAddAndDropForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->addForeignKey('user_id')->references('id')->on('users'); + $table->dropForeignKey('fk_old_user'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `orders` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`id`), DROP FOREIGN KEY `fk_old_user`', $result->query); + } + + public function testTableAutoGeneratedIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('first'); + $table->string('last'); + $table->index(['first', 'last']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`first` VARCHAR(255) NOT NULL, `last` VARCHAR(255) NOT NULL, INDEX `idx_first_last` (`first`, `last`))', $result->query); + } + + public function testTableAutoGeneratedUniqueIndexName(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('email'); + $table->uniqueIndex(['email']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`email` VARCHAR(255) NOT NULL, UNIQUE INDEX `uniq_email` (`email`))', $result->query); + } + + public function testExactCreateTableWithColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('products', function (Table $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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddAndDropColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('orders', function (Table $table) { + $table->id(); + $table->integer('customer_id'); + $table->foreignKey('customer_id') + ->references('id')->on('customers') + ->onDelete(ForeignKeyAction::Cascade)->onUpdate(ForeignKeyAction::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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE `sessions`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testImplementsTableComments(): void + { + $this->assertInstanceOf(TableComments::class, new Schema()); + } + + public function testImplementsCreatePartition(): void + { + $this->assertInstanceOf(CreatePartition::class, new Schema()); + } + + public function testImplementsDropPartition(): void + { + $this->assertInstanceOf(DropPartition::class, new Schema()); + } + + public function testCreateDatabase(): void + { + $schema = new Schema(); + $result = $schema->createDatabase('myapp'); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE DATABASE `myapp` /*!40100 DEFAULT CHARACTER SET utf8mb4 */', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testChangeColumn(): void + { + $schema = new Schema(); + $result = $schema->changeColumn('users', 'name', 'full_name', 'VARCHAR(500)'); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` CHANGE COLUMN `name` `full_name` VARCHAR(500)', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testModifyColumn(): void + { + $schema = new Schema(); + $result = $schema->modifyColumn('users', 'email', 'TEXT'); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` MODIFY `email` TEXT', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testCommentOnTable(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('users', 'Main user table'); + $this->assertBindingCount($result); + + $this->assertSame( + "ALTER TABLE `users` COMMENT = 'Main user table'", + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testCommentOnTableEscapesSingleQuotes(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('users', "User's table"); + $this->assertBindingCount($result); + + $this->assertSame( + "ALTER TABLE `users` COMMENT = 'User''s table'", + $result->query + ); + } + + public function testCreatePartition(): void + { + $schema = new Schema(); + $result = $schema->createPartition('events', 'p2024', "VALUES LESS THAN ('2025-01-01')"); + $this->assertBindingCount($result); + + $this->assertSame( + "ALTER TABLE `events` ADD PARTITION (PARTITION `p2024` VALUES LESS THAN ('2025-01-01'))", + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testDropPartition(): void + { + $schema = new Schema(); + $result = $schema->dropPartition('events', 'p2023'); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `events` DROP PARTITION `p2023`', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testCreateIfNotExists(): void + { + $schema = new Schema(); + $result = $schema->createIfNotExists('users', function (Table $table) { + $table->id(); + $table->string('name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE IF NOT EXISTS `users` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testCreateTableWithRawColumnDefs(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->id(); + $table->rawColumn('`custom_col` VARCHAR(255) NOT NULL DEFAULT ""'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `custom_col` VARCHAR(255) NOT NULL DEFAULT "", PRIMARY KEY (`id`))', $result->query); + } + + public function testCreateTableWithRawIndexDefs(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->id(); + $table->string('name'); + $table->rawIndex('INDEX `idx_custom` (`name`(10))'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), INDEX `idx_custom` (`name`(10)))', $result->query); + } + + public function testCreateTableWithPartitionByRange(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->datetime('created_at'); + $table->partitionByRange('YEAR(created_at)'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`)) PARTITION BY RANGE(YEAR(created_at))', $result->query); + } + + public function testCreateTableWithPartitionByList(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->string('region'); + $table->partitionByList('region'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, `region` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`)) PARTITION BY LIST(region)', $result->query); + } + + public function testCreateTableWithPartitionByHash(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->partitionByHash('id'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `events` (`id` BIGINT UNSIGNED AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`)) PARTITION BY HASH(id)', $result->query); + } + + public function testAlterWithForeignKeyOnDeleteAndUpdate(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->addForeignKey('user_id') + ->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade) + ->onUpdate(ForeignKeyAction::SetNull); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `orders` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE SET NULL', $result->query); + } + + public function testCreateIndexWithMethod(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], method: 'btree'); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_email` ON `users` USING BTREE (`email`)', $result->query); + } + + public function testCompileIndexColumnsWithCollation(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + collations: ['name' => 'utf8mb4_bin'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_name` ON `users` (`name` COLLATE utf8mb4_bin)', $result->query); + } + + public function testCompileIndexColumnsWithLength(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + lengths: ['name' => 10] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_name` ON `users` (`name`(10))', $result->query); + } + + public function testCompileIndexColumnsWithOrder(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + orders: ['name' => 'desc'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_name` ON `users` (`name` DESC)', $result->query); + } + + public function testCompileIndexColumnsWithOperatorClass(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'docs', + 'idx_content', + ['content'], + operatorClass: 'gin_trgm_ops' + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_content` ON `docs` (`content` gin_trgm_ops)', $result->query); + } + + public function testCompileIndexColumnsWithRawColumns(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'docs', + 'idx_mixed', + ['id'], + rawColumns: ['CAST(data AS CHAR(100))'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX `idx_mixed` ON `docs` (`id`, CAST(data AS CHAR(100)))', $result->query); + } + + public function testRenameIndexSql(): void + { + $schema = new Schema(); + $result = $schema->renameIndex('users', 'idx_old', 'idx_new'); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` RENAME INDEX `idx_old` TO `idx_new`', + $result->query + ); + } + + public function testDropDatabase(): void + { + $schema = new Schema(); + $result = $schema->dropDatabase('mydb'); + $this->assertBindingCount($result); + + $this->assertSame('DROP DATABASE `mydb`', $result->query); + } + + public function testAnalyzeTable(): void + { + $schema = new Schema(); + $result = $schema->analyzeTable('users'); + $this->assertBindingCount($result); + + $this->assertSame('ANALYZE TABLE `users`', $result->query); + } + + public function testTableJsonColumn(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->json('metadata'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`metadata` JSON NOT NULL)', $result->query); + } + + public function testTableBinaryColumn(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->binary('data'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`data` BLOB NOT NULL)', $result->query); + } + + public function testColumnCollation(): void + { + $col = new Column('name', ColumnType::String, 255); + $col->collation('utf8mb4_unicode_ci'); + + $this->assertSame('utf8mb4_unicode_ci', $col->collation); + } + + public function testColumnPrecision(): void + { + $col = new Column('amount', ColumnType::Float, precision: 10); + + $this->assertSame(10, $col->precision); + $this->assertNull($col->length); + } + + public function testTableAddIndexWithStringType(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addIndex('idx_name', ['name'], 'unique'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `users` ADD UNIQUE INDEX `idx_name` (`name`)', $result->query); + } + + public function testIndexValidationInvalidMethod(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid index method'); + + new Index('idx', ['col'], method: 'DROP TABLE;'); + } + + public function testIndexValidationInvalidOperatorClass(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid operator class'); + + new Index('idx', ['col'], operatorClass: 'DROP;'); + } + + public function testIndexValidationInvalidCollation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation'); + + new Index('idx', ['col'], collations: ['col' => 'DROP;']); + } + + public function testEnumBackslashEscaping(): void + { + $schema = new Schema(); + $result = $schema->create('items', function (Table $table) { + // Input: `a\` and `b'c`. Expect backslash doubled and quote doubled. + $table->enum('status', ['a\\', "b'c"]); + }); + + // Expect literal sequence: ENUM('a\\','b''c') (a + two backslashes) + $this->assertSame("CREATE TABLE `items` (`status` ENUM('a\\\\','b''c') NOT NULL)", $result->query); + } + + public function testDefaultValueBackslashEscaping(): void + { + $schema = new Schema(); + $result = $schema->create('items', function (Table $table) { + // Input: a\' OR 1=1 -- . Expect backslash doubled, quote doubled. + $table->string('name')->default("a\\' OR 1=1 --"); + }); + + $this->assertSame("CREATE TABLE `items` (`name` VARCHAR(255) NOT NULL DEFAULT 'a\\\\'' OR 1=1 --')", $result->query); + } + + public function testCommentBackslashEscaping(): void + { + $schema = new Schema(); + $result = $schema->create('items', function (Table $table) { + $table->string('name')->comment('trailing\\'); + }); + + $this->assertSame("CREATE TABLE `items` (`name` VARCHAR(255) NOT NULL COMMENT 'trailing\\\\')", $result->query); + } + + public function testTableCommentBackslashEscaping(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('items', 'trailing\\'); + + $this->assertSame("ALTER TABLE `items` COMMENT = 'trailing\\\\'", $result->query); + } + + public function testSerialColumnMapsToIntWithAutoIncrement(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->serial('id')->primary(); + }); + + $this->assertSame('CREATE TABLE `t` (`id` INT AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testBigSerialColumnMapsToBigIntWithAutoIncrement(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigSerial('id')->primary(); + }); + + $this->assertSame('CREATE TABLE `t` (`id` BIGINT AUTO_INCREMENT NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testUserTypeColumnThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->create('t', function (Table $table) { + $table->integer('id')->primary(); + $table->string('mood')->userType('mood_type'); + }); + } +} diff --git a/tests/Query/Schema/PostgreSQLTest.php b/tests/Query/Schema/PostgreSQLTest.php new file mode 100644 index 0000000..a4e00b4 --- /dev/null +++ b/tests/Query/Schema/PostgreSQLTest.php @@ -0,0 +1,1291 @@ +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 (Table $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "users" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "name" VARCHAR(255) NOT NULL, "email" VARCHAR(255) NOT NULL, PRIMARY KEY ("id"), UNIQUE ("email"))', $result->query); + } + + public function testCreateTableColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Table $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->assertSame('CREATE TABLE "test_types" ("int_col" INTEGER NOT NULL, "big_col" BIGINT NOT NULL, "float_col" DOUBLE PRECISION NOT NULL, "bool_col" BOOLEAN NOT NULL, "text_col" TEXT NOT NULL, "dt_col" TIMESTAMP(3) NOT NULL, "ts_col" TIMESTAMP(6) WITHOUT TIME ZONE NOT NULL, "json_col" JSONB NOT NULL, "bin_col" BYTEA NOT NULL, "status" TEXT NOT NULL CHECK ("status" IN (\'active\', \'inactive\')))', $result->query); + } + + public function testCreateTableSpatialTypes(): void + { + $schema = new Schema(); + $result = $schema->create('locations', function (Table $table) { + $table->id(); + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "locations" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "coords" GEOMETRY(POINT, 4326) NOT NULL, "path" GEOMETRY(LINESTRING, 4326) NOT NULL, "area" GEOMETRY(POLYGON, 4326) NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableVectorType(): void + { + $schema = new Schema(); + $result = $schema->create('embeddings', function (Table $table) { + $table->id(); + $table->vector('embedding', 128); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "embeddings" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "embedding" VECTOR(128) NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableUnsignedIgnored(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->integer('age')->unsigned(); + }); + $this->assertBindingCount($result); + + // PostgreSQL doesn't support UNSIGNED + $this->assertStringNotContainsString('UNSIGNED', $result->query); + $this->assertSame('CREATE TABLE "t" ("age" INTEGER NOT NULL)', $result->query); + } + + public function testCreateTableNoInlineComment(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $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 (Table $table) { + $table->id(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY ("id"))', $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->assertSame('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->assertSame( + '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->assertSame( + '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->assertSame( + '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: [[ParameterDirection::In, 'user_id', 'INT'], [ParameterDirection::Out, 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertSame('CREATE FUNCTION "update_stats"(IN "user_id" INT, OUT "total" INT) RETURNS VOID LANGUAGE plpgsql AS $$ BEGIN SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id; END; $$', $result->query); + $this->assertStringNotContainsString('CREATE PROCEDURE', $result->query); + } + + public function testDropProcedureUsesFunction(): void + { + $schema = new Schema(); + $result = $schema->dropProcedure('update_stats'); + + $this->assertSame('DROP FUNCTION "update_stats"', $result->query); + } + // TRIGGERS — EXECUTE FUNCTION + + public function testCreateTriggerUsesExecuteFunction(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: TriggerTiming::Before, + event: TriggerEvent::Update, + body: 'NEW.updated_at = NOW();' + ); + + $this->assertSame('CREATE FUNCTION "trg_updated_at_func"() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$; CREATE TRIGGER "trg_updated_at" BEFORE UPDATE ON "users" FOR EACH ROW EXECUTE FUNCTION "trg_updated_at_func"()', $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->assertSame( + 'ALTER TABLE "orders" DROP CONSTRAINT "fk_user"', + $result->query + ); + } + // ALTER — PostgreSQL specifics + + public function testAlterModifyUsesAlterColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->modifyColumn('name', 'string', 500); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" ALTER COLUMN "name" TYPE VARCHAR(500)', $result->query); + } + + public function testAlterAddIndexUsesCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addIndex('idx_email', ['email']); + }); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('ADD INDEX', $result->query); + $this->assertSame('CREATE INDEX "idx_email" ON "users" ("email")', $result->query); + } + + public function testAlterDropIndexIsStandalone(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->dropIndex('idx_email'); + }); + $this->assertBindingCount($result); + + $this->assertSame('DROP INDEX "idx_email"', $result->query); + } + + public function testAlterColumnAndIndexSeparateStatements(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('score', 'integer'); + $table->addIndex('idx_score', ['score']); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" ADD COLUMN "score" INTEGER NOT NULL; CREATE INDEX "idx_score" ON "users" ("score")', $result->query); + } + + public function testAlterDropForeignKeyUsesConstraint(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->dropForeignKey('fk_old'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "orders" DROP CONSTRAINT "fk_old"', $result->query); + } + // EXTENSIONS + + public function testCreateExtension(): void + { + $schema = new Schema(); + $result = $schema->createExtension('vector'); + + $this->assertSame('CREATE EXTENSION IF NOT EXISTS "vector"', $result->query); + } + + public function testDropExtension(): void + { + $schema = new Schema(); + $result = $schema->dropExtension('vector'); + + $this->assertSame('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->assertSame( + '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->assertSame('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->assertSame('DROP TABLE "users"', $result->query); + } + + public function testTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + $this->assertBindingCount($result); + + $this->assertSame('TRUNCATE TABLE "users"', $result->query); + } + + public function testRenameTableUsesAlterTable(): void + { + $schema = new Schema(); + $result = $schema->rename('users', 'members'); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" RENAME TO "members"', $result->query); + } + + // Edge cases + + public function testDropIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertSame('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 (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "order_items" ("order_id" INTEGER NOT NULL, "product_id" INTEGER NOT NULL, PRIMARY KEY ("order_id", "product_id"))', $result->query); + } + + public function testCreateTableWithCompositePrimaryKey(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Table $table) { + $table->integer('order_id'); + $table->integer('product_id'); + $table->integer('quantity'); + $table->primary(['order_id', 'product_id']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "order_items" ("order_id" INTEGER NOT NULL, "product_id" INTEGER NOT NULL, "quantity" INTEGER NOT NULL, PRIMARY KEY ("order_id", "product_id"))', $result->query); + } + + public function testCreateTableRejectsMixedColumnAndTablePrimary(): void + { + $schema = new Schema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot combine column-level primary() with Table::primary() composite key.'); + + $schema->create('order_items', function (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id'); + $table->primary(['order_id', 'product_id']); + }); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name')->nullable()->default(null); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("name" VARCHAR(255) NULL DEFAULT NULL)', $result->query); + } + + public function testAlterAddMultipleColumns(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('first_name', 'string', 100); + $table->addColumn('last_name', 'string', 100); + $table->dropColumn('name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" ADD COLUMN "first_name" VARCHAR(100) NOT NULL, ADD COLUMN "last_name" VARCHAR(100) NOT NULL, DROP COLUMN "name"', $result->query); + } + + public function testAlterAddForeignKey(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->addForeignKey('user_id')->references('id')->on('users')->onDelete(ForeignKeyAction::Cascade); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "orders" 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->assertSame('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->assertSame('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->assertSame('CREATE INDEX "idx_name" ON "users" ("first_name", "last_name")', $result->query); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" RENAME COLUMN "bio" TO "biography"', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->timestamps(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "posts" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "created_at" TIMESTAMP(3) NOT NULL, "updated_at" TIMESTAMP(3) NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "posts" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY ("id"), 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', ForeignKeyAction::Cascade, ForeignKeyAction::SetNull); + + $this->assertSame( + '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->assertSame('DROP TRIGGER "trg_old"', $result->query); + } + + public function testAlterWithUniqueIndex(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addIndex('idx_email', ['email']); + $table->addIndex('idx_name', ['name']); + }); + $this->assertBindingCount($result); + + // Both should be standalone CREATE INDEX statements + $this->assertSame('CREATE INDEX "idx_email" ON "users" ("email"); CREATE INDEX "idx_name" ON "users" ("name")', $result->query); + } + + public function testExactCreateTableWithTypes(): void + { + $schema = new Schema(); + $result = $schema->create('accounts', function (Table $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->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactAlterTableAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('accounts', function (Table $table) { + $table->addColumn('bio', 'text')->nullable(); + }); + + $this->assertSame( + 'ALTER TABLE "accounts" ADD COLUMN "bio" TEXT NULL', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE "sessions"', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testImplementsTypes(): void + { + $this->assertInstanceOf(Types::class, new Schema()); + } + + public function testImplementsSequences(): void + { + $this->assertInstanceOf(Sequences::class, new Schema()); + } + + public function testImplementsTableComments(): void + { + $this->assertInstanceOf(TableComments::class, new Schema()); + } + + public function testImplementsColumnComments(): void + { + $this->assertInstanceOf(ColumnComments::class, new Schema()); + } + + public function testImplementsCreatePartition(): void + { + $this->assertInstanceOf(CreatePartition::class, new Schema()); + } + + public function testImplementsDropPartition(): void + { + $this->assertInstanceOf(DropPartition::class, new Schema()); + } + + public function testCreateCollation(): void + { + $schema = new Schema(); + $result = $schema->createCollation('ci_collation', ['provider' => 'icu', 'locale' => 'und-u-ks-level1']); + + $this->assertSame( + "CREATE COLLATION IF NOT EXISTS \"ci_collation\" (provider = 'icu', locale = 'und-u-ks-level1', deterministic = true)", + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testCreateCollationNonDeterministic(): void + { + $schema = new Schema(); + $result = $schema->createCollation('nd_collation', ['provider' => 'icu'], false); + + $this->assertSame('CREATE COLLATION IF NOT EXISTS "nd_collation" (provider = \'icu\', deterministic = false)', $result->query); + } + + public function testCreateCollationRejectsInvalidOptionKey(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation option key'); + + $schema = new Schema(); + $schema->createCollation('bad', ['provider = pg_catalog, invalid_key' => 'icu']); + } + + public function testCreateCollationRejectsKeyWithSpace(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->createCollation('bad', ['pro vider' => 'icu']); + } + + public function testRenameIndex(): void + { + $schema = new Schema(); + $result = $schema->renameIndex('users', 'idx_old', 'idx_new'); + + $this->assertSame('ALTER INDEX "idx_old" RENAME TO "idx_new"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateDatabase(): void + { + $schema = new Schema(); + $result = $schema->createDatabase('my_schema'); + + $this->assertSame('CREATE SCHEMA "my_schema"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropDatabase(): void + { + $schema = new Schema(); + $result = $schema->dropDatabase('my_schema'); + + $this->assertSame('DROP SCHEMA IF EXISTS "my_schema" CASCADE', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testAnalyzeTable(): void + { + $schema = new Schema(); + $result = $schema->analyzeTable('users'); + + $this->assertSame('ANALYZE "users"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testAlterColumnType(): void + { + $schema = new Schema(); + $result = $schema->alterColumnType('users', 'age', 'BIGINT'); + + $this->assertSame('ALTER TABLE "users" ALTER COLUMN "age" TYPE BIGINT', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testAlterColumnTypeWithUsing(): void + { + $schema = new Schema(); + $result = $schema->alterColumnType('users', 'age', 'INTEGER', '"age"::integer'); + + $this->assertSame('ALTER TABLE "users" ALTER COLUMN "age" TYPE INTEGER USING "age"::integer', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testAlterColumnTypeRejectsInjectionInType(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column type'); + + $schema = new Schema(); + $schema->alterColumnType('users', 'age', 'INTEGER; DROP TABLE users'); + } + + public function testAlterColumnTypeRejectsSemicolonInUsing(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid USING expression'); + + $schema = new Schema(); + $schema->alterColumnType('users', 'age', 'INTEGER', 'age::integer; DROP TABLE users'); + } + + public function testAlterColumnTypeRejectsLineCommentInUsing(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->alterColumnType('users', 'age', 'INTEGER', 'age::integer -- trailing'); + } + + public function testAlterColumnTypeRejectsBlockCommentInUsing(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->alterColumnType('users', 'age', 'INTEGER', 'age::integer /* comment */'); + } + + public function testAlterColumnTypeRejectsOversizedUsing(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('1024 character limit'); + + $schema = new Schema(); + $schema->alterColumnType('users', 'age', 'INTEGER', \str_repeat('a', 1025)); + } + + public function testAlterColumnTypeAllowsCastExpressionInUsing(): void + { + $schema = new Schema(); + $result = $schema->alterColumnType('users', 'age', 'INTEGER', 'CAST("age" AS INTEGER)'); + + $this->assertSame('ALTER TABLE "users" ALTER COLUMN "age" TYPE INTEGER USING CAST("age" AS INTEGER)', $result->query); + } + + public function testCreatePartitionRejectsSemicolon(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid partition expression'); + + $schema = new Schema(); + $schema->createPartition('orders', 'orders_bad', "IN ('2024'); DROP TABLE orders"); + } + + public function testCreatePartitionRejectsLineComment(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->createPartition('orders', 'orders_bad', "IN ('2024') -- bad"); + } + + public function testCreatePartitionRejectsOversizedExpression(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->createPartition('orders', 'orders_bad', \str_repeat('a', 1025)); + } + + public function testDropIndexConcurrently(): void + { + $schema = new Schema(); + $result = $schema->dropIndexConcurrently('idx_email'); + + $this->assertSame('DROP INDEX CONCURRENTLY "idx_email"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateType(): void + { + $schema = new Schema(); + $result = $schema->createType('mood', ['happy', 'sad', 'neutral']); + + $this->assertSame("CREATE TYPE \"mood\" AS ENUM ('happy', 'sad', 'neutral')", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropType(): void + { + $schema = new Schema(); + $result = $schema->dropType('mood'); + + $this->assertSame('DROP TYPE "mood"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateSequence(): void + { + $schema = new Schema(); + $result = $schema->createSequence('order_seq', 100, 5); + + $this->assertSame('CREATE SEQUENCE "order_seq" START 100 INCREMENT BY 5', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateSequenceDefaults(): void + { + $schema = new Schema(); + $result = $schema->createSequence('seq'); + + $this->assertSame('CREATE SEQUENCE "seq" START 1 INCREMENT BY 1', $result->query); + } + + public function testDropSequence(): void + { + $schema = new Schema(); + $result = $schema->dropSequence('order_seq'); + + $this->assertSame('DROP SEQUENCE "order_seq"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testNextVal(): void + { + $schema = new Schema(); + $result = $schema->nextVal('order_seq'); + + $this->assertSame("SELECT nextval('order_seq')", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCommentOnTable(): void + { + $schema = new Schema(); + $result = $schema->commentOnTable('users', 'Main users table'); + + $this->assertSame("COMMENT ON TABLE \"users\" IS 'Main users table'", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCommentOnColumn(): void + { + $schema = new Schema(); + $result = $schema->commentOnColumn('users', 'email', 'Primary email address'); + + $this->assertSame("COMMENT ON COLUMN \"users\".\"email\" IS 'Primary email address'", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreatePartition(): void + { + $schema = new Schema(); + $result = $schema->createPartition('orders', 'orders_2024', "IN ('2024')"); + + $this->assertSame("CREATE TABLE \"orders_2024\" PARTITION OF \"orders\" FOR VALUES IN ('2024')", $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropPartition(): void + { + $schema = new Schema(); + $result = $schema->dropPartition('orders', 'orders_2024'); + + $this->assertSame('DROP TABLE "orders_2024"', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateIndexConcurrently(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], concurrently: true); + + $this->assertSame('CREATE INDEX CONCURRENTLY "idx_email" ON "users" ("email")', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testCreateUniqueIndexConcurrently(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email'], unique: true, concurrently: true); + + $this->assertSame('CREATE UNIQUE INDEX CONCURRENTLY "idx_email" ON "users" ("email")', $result->query); + } + + public function testCreateIndexInvalidMethodThrows(): void + { + $this->expectException(\Utopia\Query\Exception\ValidationException::class); + + $schema = new Schema(); + $schema->createIndex('t', 'idx', ['col'], method: 'DROP TABLE;--'); + } + + public function testCreateIndexInvalidOperatorClassThrows(): void + { + $this->expectException(\Utopia\Query\Exception\ValidationException::class); + + $schema = new Schema(); + $schema->createIndex('t', 'idx', ['col'], operatorClass: 'foo;bar'); + } + + public function testAlterAddColumnAndRenameAndDropCombined(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('phone', 'string', 20); + $table->renameColumn('bio', 'biography'); + $table->dropColumn('old_field'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "users" ADD COLUMN "phone" VARCHAR(20) NOT NULL, RENAME COLUMN "bio" TO "biography", DROP COLUMN "old_field"', $result->query); + } + + public function testAlterAddForeignKeyWithOnUpdate(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->addForeignKey('user_id') + ->references('id') + ->on('users') + ->onDelete(ForeignKeyAction::Cascade) + ->onUpdate(ForeignKeyAction::SetNull); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "orders" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL', $result->query); + } + + public function testAlterAddIndexWithMethod(): void + { + $schema = new Schema(); + $result = $schema->alter('docs', function (Table $table) { + $table->addIndex('idx_content', ['content'], IndexType::Index, method: 'gin'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_content" ON "docs" USING GIN ("content")', $result->query); + } + + public function testColumnDefinitionUnsignedIgnored(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->integer('val')->unsigned(); + }); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('UNSIGNED', $result->query); + } + + public function testCreateIfNotExists(): void + { + $schema = new Schema(); + $result = $schema->createIfNotExists('users', function (Table $table) { + $table->id(); + $table->string('name'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE IF NOT EXISTS "users" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableWithRawColumnDefs(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->id(); + $table->rawColumn('"custom_col" TEXT NOT NULL DEFAULT \'\''); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "custom_col" TEXT NOT NULL DEFAULT \'\', PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableWithRawIndexDefs(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->id(); + $table->string('name'); + $table->rawIndex('INDEX "idx_custom" ("name")'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "name" VARCHAR(255) NOT NULL, PRIMARY KEY ("id"), INDEX "idx_custom" ("name"))', $result->query); + } + + public function testCreateTableWithPartitionByRange(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->datetime('created_at'); + $table->partitionByRange('created_at'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "events" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "created_at" TIMESTAMP NOT NULL, PRIMARY KEY ("id")) PARTITION BY RANGE(created_at)', $result->query); + } + + public function testCreateTableWithPartitionByList(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->string('region'); + $table->partitionByList('region'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "events" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "region" VARCHAR(255) NOT NULL, PRIMARY KEY ("id")) PARTITION BY LIST(region)', $result->query); + } + + public function testCreateTableWithPartitionByHash(): void + { + $schema = new Schema(); + $result = $schema->create('events', function (Table $table) { + $table->id(); + $table->partitionByHash('id'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "events" ("id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, PRIMARY KEY ("id")) PARTITION BY HASH(id)', $result->query); + } + + public function testAlterWithForeignKeyOnDeleteAndUpdate(): void + { + $schema = new Schema(); + $result = $schema->alter('orders', function (Table $table) { + $table->addForeignKey('user_id') + ->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade) + ->onUpdate(ForeignKeyAction::SetNull); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE "orders" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE SET NULL', $result->query); + } + + public function testCreateIndexWithMethod(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_name', ['name'], method: 'btree'); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_name" ON "users" USING BTREE ("name")', $result->query); + } + + public function testCompileIndexColumnsWithCollation(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + collations: ['name' => 'en_US'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_name" ON "users" ("name" COLLATE en_US)', $result->query); + } + + public function testCompileIndexColumnsWithLength(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + lengths: ['name' => 10] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_name" ON "users" ("name"(10))', $result->query); + } + + public function testCompileIndexColumnsWithOrder(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + orders: ['name' => 'desc'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_name" ON "users" ("name" DESC)', $result->query); + } + + public function testCompileIndexColumnsWithRawColumns(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'docs', + 'idx_mixed', + ['id'], + rawColumns: ['(data->>\'name\')'] + ); + $this->assertBindingCount($result); + + $this->assertSame('CREATE INDEX "idx_mixed" ON "docs" ("id", (data->>\'name\'))', $result->query); + } + + public function testCreateIndexRejectsInjectionInCollation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation'); + + $schema = new Schema(); + $schema->createIndex( + 'users', + 'idx_name', + ['name'], + collations: ['name' => 'en_US; DROP TABLE users'], + ); + } + + public function testCreateIndexRejectsInjectionInOrder(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid index order'); + + $schema = new Schema(); + $schema->createIndex( + 'users', + 'idx_name', + ['name'], + orders: ['name' => 'ASC; DROP TABLE users'], + ); + } + + public function testCreateIndexRejectsNonAscDescOrder(): void + { + $this->expectException(ValidationException::class); + + $schema = new Schema(); + $schema->createIndex( + 'users', + 'idx_name', + ['name'], + orders: ['name' => 'random'], + ); + } + + public function testCreateIndexAcceptsPlainCollation(): void + { + $schema = new Schema(); + $result = $schema->createIndex( + 'users', + 'idx_name', + ['name'], + collations: ['name' => 'en_US'], + ); + + $this->assertSame('CREATE INDEX "idx_name" ON "users" ("name" COLLATE en_US)', $result->query); + } + + public function testCreateIndexRejectsQuotedCollation(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid collation'); + + new Index( + name: 'idx_name', + columns: ['name'], + collations: ['name' => '"en_US"'], + ); + } + + public function testCreateTriggerRejectsDollarQuoteTerminatorInBody(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('dollar-quote terminator'); + + $schema = new Schema(); + $schema->createTrigger( + 'my_trigger', + 'users', + TriggerTiming::Before, + TriggerEvent::Insert, + "$$; DROP TABLE users; --", + ); + } + + public function testCreateProcedureRejectsDollarQuoteTerminatorInBody(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('dollar-quote terminator'); + + $schema = new Schema(); + $schema->createProcedure( + 'my_proc', + [[ParameterDirection::In, 'x', 'INT']], + "$$; DROP TABLE users; --", + ); + } + + public function testTableAddIndexWithStringType(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addIndex('idx_name', ['name'], 'unique'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE UNIQUE INDEX "idx_name" ON "users" ("name")', $result->query); + } + + public function testCreateTableWithSerialColumnEmitsSerial(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->serial('id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" SERIAL NOT NULL, PRIMARY KEY ("id"))', $result->query); + $this->assertStringNotContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + $this->assertSame('CREATE TABLE "t" ("id" SERIAL NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testCreateTableWithBigSerialColumnEmitsBigSerial(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->bigSerial('id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" BIGSERIAL NOT NULL, PRIMARY KEY ("id"))', $result->query); + $this->assertStringNotContainsString('GENERATED BY DEFAULT AS IDENTITY', $result->query); + } + + public function testCreateTableWithSmallSerialColumnEmitsSmallSerial(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->smallSerial('id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "t" ("id" SMALLSERIAL NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testReferenceUserDefinedType(): void + { + $schema = new Schema(); + $result = $schema->create('surveys', function (Table $table) { + $table->integer('id')->primary(); + $table->string('mood')->userType('mood_type'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE "surveys" ("id" INTEGER NOT NULL, "mood" "mood_type" NOT NULL, PRIMARY KEY ("id"))', $result->query); + } + + public function testUserTypeRejectsInvalidIdentifier(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid user-defined type name'); + + $col = new Column('mood', ColumnType::String); + $col->userType('bad; DROP TABLE users'); + } +} diff --git a/tests/Query/Schema/SQLiteTest.php b/tests/Query/Schema/SQLiteTest.php new file mode 100644 index 0000000..3363433 --- /dev/null +++ b/tests/Query/Schema/SQLiteTest.php @@ -0,0 +1,718 @@ +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()); + } + + public function testCreateTableBasic(): void + { + $schema = new Schema(); + $result = $schema->create('users', function (Table $table) { + $table->id(); + $table->string('name', 255); + $table->string('email', 255)->unique(); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `users` (`id` INTEGER AUTOINCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), UNIQUE (`email`))', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testCreateTableAllColumnTypes(): void + { + $schema = new Schema(); + $result = $schema->create('test_types', function (Table $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->assertSame('CREATE TABLE `test_types` (`int_col` INTEGER NOT NULL, `big_col` INTEGER NOT NULL, `float_col` REAL NOT NULL, `bool_col` INTEGER NOT NULL, `text_col` TEXT NOT NULL, `dt_col` TEXT NOT NULL, `ts_col` TEXT NOT NULL, `json_col` TEXT NOT NULL, `bin_col` BLOB NOT NULL, `status` TEXT NOT NULL)', $result->query); + } + + public function testColumnTypeStringMapsToVarchar(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name', 100); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`name` VARCHAR(100) NOT NULL)', $result->query); + } + + public function testColumnTypeBooleanMapsToInteger(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->boolean('active'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`active` INTEGER NOT NULL)', $result->query); + } + + public function testColumnTypeDatetimeMapsToText(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->datetime('created_at'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`created_at` TEXT NOT NULL)', $result->query); + } + + public function testColumnTypeTimestampMapsToText(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->timestamp('updated_at'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`updated_at` TEXT NOT NULL)', $result->query); + } + + public function testColumnTypeJsonMapsToText(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->json('data'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`data` TEXT NOT NULL)', $result->query); + } + + public function testColumnTypeBinaryMapsToBlob(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->binary('content'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`content` BLOB NOT NULL)', $result->query); + } + + public function testColumnTypeEnumMapsToText(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->enum('status', ['a', 'b']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`status` TEXT NOT NULL)', $result->query); + } + + public function testColumnTypeSpatialMapsToText(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->point('coords', 4326); + $table->linestring('path'); + $table->polygon('area'); + }); + $this->assertBindingCount($result); + + $count = substr_count($result->query, 'TEXT NOT NULL'); + $this->assertGreaterThanOrEqual(3, $count); + } + + public function testColumnTypeUuid7MapsToVarchar36(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('uid', 36); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`uid` VARCHAR(36) NOT NULL)', $result->query); + } + + public function testColumnTypeVectorThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('Vector type is not supported in SQLite.'); + + $schema = new Schema(); + $schema->create('t', function (Table $table) { + $table->vector('embedding', 768); + }); + } + + public function testAutoIncrementUsesAutoincrement(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->id(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`id` INTEGER AUTOINCREMENT NOT NULL, PRIMARY KEY (`id`))', $result->query); + $this->assertStringNotContainsString('AUTO_INCREMENT', $result->query); + } + + public function testUnsignedIsEmptyString(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->integer('age')->unsigned(); + }); + $this->assertBindingCount($result); + + $this->assertStringNotContainsString('UNSIGNED', $result->query); + } + + public function testCreateDatabaseThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('SQLite does not support CREATE DATABASE.'); + + $schema = new Schema(); + $schema->createDatabase('mydb'); + } + + public function testDropDatabaseThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('SQLite does not support DROP DATABASE.'); + + $schema = new Schema(); + $schema->dropDatabase('mydb'); + } + + public function testRenameUsesAlterTable(): void + { + $schema = new Schema(); + $result = $schema->rename('old_table', 'new_table'); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `old_table` RENAME TO `new_table`', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testTruncateUsesDeleteFrom(): void + { + $schema = new Schema(); + $result = $schema->truncate('users'); + $this->assertBindingCount($result); + + $this->assertSame('DELETE FROM `users`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropIndexWithoutTableName(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + $this->assertBindingCount($result); + + $this->assertSame('DROP INDEX `idx_email`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testRenameIndexThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('SQLite does not support renaming indexes directly.'); + + $schema = new Schema(); + $schema->renameIndex('users', 'old_idx', 'new_idx'); + } + + public function testCreateTableWithNullableAndDefault(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $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->assertSame('CREATE TABLE `posts` (`id` INTEGER AUTOINCREMENT NOT NULL, `bio` TEXT NULL, `active` INTEGER NOT NULL DEFAULT 1, `score` INTEGER NOT NULL DEFAULT 0, `status` VARCHAR(255) NOT NULL DEFAULT \'draft\', PRIMARY KEY (`id`))', $result->query); + } + + public function testCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->foreignKey('user_id') + ->references('id')->on('users') + ->onDelete(ForeignKeyAction::Cascade)->onUpdate(ForeignKeyAction::SetNull); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `posts` (`id` INTEGER AUTOINCREMENT NOT NULL, PRIMARY KEY (`id`), 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 (Table $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->index(['name', 'email']); + $table->uniqueIndex(['email']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `users` (`id` INTEGER AUTOINCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, `email` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`), INDEX `idx_name_email` (`name`, `email`), UNIQUE INDEX `uniq_email` (`email`))', $result->query); + } + + public function testDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('users'); + $this->assertBindingCount($result); + + $this->assertSame('DROP TABLE `users`', $result->query); + $this->assertSame([], $result->bindings); + } + + public function testDropTableIfExists(): void + { + $schema = new Schema(); + $result = $schema->dropIfExists('users'); + + $this->assertSame('DROP TABLE IF EXISTS `users`', $result->query); + } + + public function testAlterAddColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('avatar_url', 'string', 255)->nullable(); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(255) NULL', $result->query); + } + + public function testAlterDropColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->dropColumn('age'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` DROP COLUMN `age`', + $result->query + ); + } + + public function testAlterRenameColumn(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'ALTER TABLE `users` RENAME COLUMN `bio` TO `biography`', + $result->query + ); + } + + public function testCreateIndex(): void + { + $schema = new Schema(); + $result = $schema->createIndex('users', 'idx_email', ['email']); + + $this->assertSame('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->assertSame('CREATE UNIQUE INDEX `idx_email` ON `users` (`email`)', $result->query); + } + + public function testCreateView(): void + { + $schema = new Schema(); + $builder = (new SQLBuilder())->from('users')->filter([Query::equal('active', [true])]); + $result = $schema->createView('active_users', $builder); + + $this->assertSame( + 'CREATE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertSame([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->assertSame( + 'CREATE OR REPLACE VIEW `active_users` AS SELECT * FROM `users` WHERE `active` IN (?)', + $result->query + ); + $this->assertSame([true], $result->bindings); + } + + public function testDropView(): void + { + $schema = new Schema(); + $result = $schema->dropView('active_users'); + + $this->assertSame('DROP VIEW `active_users`', $result->query); + } + + public function testAddForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->addForeignKey( + 'orders', + 'fk_user', + 'user_id', + 'users', + 'id', + onDelete: ForeignKeyAction::Cascade, + onUpdate: ForeignKeyAction::SetNull + ); + + $this->assertSame( + '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->assertStringNotContainsString('ON DELETE', $result->query); + $this->assertStringNotContainsString('ON UPDATE', $result->query); + } + + public function testDropForeignKeyStandalone(): void + { + $schema = new Schema(); + $result = $schema->dropForeignKey('orders', 'fk_user'); + + $this->assertSame( + 'ALTER TABLE `orders` DROP FOREIGN KEY `fk_user`', + $result->query + ); + } + + public function testCreateProcedure(): void + { + $schema = new Schema(); + $result = $schema->createProcedure( + 'update_stats', + params: [[ParameterDirection::In, 'user_id', 'INT'], [ParameterDirection::Out, 'total', 'INT']], + body: 'SELECT COUNT(*) INTO total FROM orders WHERE orders.user_id = user_id;' + ); + + $this->assertSame( + '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->assertSame('DROP PROCEDURE `update_stats`', $result->query); + } + + public function testCreateTrigger(): void + { + $schema = new Schema(); + $result = $schema->createTrigger( + 'trg_updated_at', + 'users', + timing: TriggerTiming::Before, + event: TriggerEvent::Update, + body: 'SET NEW.updated_at = datetime();' + ); + + $this->assertSame( + 'CREATE TRIGGER `trg_updated_at` BEFORE UPDATE ON `users` FOR EACH ROW BEGIN SET NEW.updated_at = datetime(); END', + $result->query + ); + } + + public function testDropTrigger(): void + { + $schema = new Schema(); + $result = $schema->dropTrigger('trg_updated_at'); + + $this->assertSame('DROP TRIGGER `trg_updated_at`', $result->query); + } + + public function testCreateTableWithMultiplePrimaryKeys(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id')->primary(); + $table->integer('quantity'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `order_items` (`order_id` INTEGER NOT NULL, `product_id` INTEGER NOT NULL, `quantity` INTEGER NOT NULL, PRIMARY KEY (`order_id`, `product_id`))', $result->query); + } + + public function testCreateTableWithCompositePrimaryKey(): void + { + $schema = new Schema(); + $result = $schema->create('order_items', function (Table $table) { + $table->integer('order_id'); + $table->integer('product_id'); + $table->integer('quantity'); + $table->primary(['order_id', 'product_id']); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `order_items` (`order_id` INTEGER NOT NULL, `product_id` INTEGER NOT NULL, `quantity` INTEGER NOT NULL, PRIMARY KEY (`order_id`, `product_id`))', $result->query); + } + + public function testCreateTableRejectsMixedColumnAndTablePrimary(): void + { + $schema = new Schema(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Cannot combine column-level primary() with Table::primary() composite key.'); + + $schema->create('order_items', function (Table $table) { + $table->integer('order_id')->primary(); + $table->integer('product_id'); + $table->primary(['order_id', 'product_id']); + }); + } + + public function testCreateTableWithDefaultNull(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->string('name')->nullable()->default(null); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`name` VARCHAR(255) NULL DEFAULT NULL)', $result->query); + } + + public function testCreateTableWithNumericDefault(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->float('score')->default(0.5); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`score` REAL NOT NULL DEFAULT 0.5)', $result->query); + } + + public function testCreateTableWithTimestamps(): void + { + $schema = new Schema(); + $result = $schema->create('posts', function (Table $table) { + $table->id(); + $table->timestamps(); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `posts` (`id` INTEGER AUTOINCREMENT NOT NULL, `created_at` TEXT NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testExactCreateTableWithColumnsAndIndexes(): void + { + $schema = new Schema(); + $result = $schema->create('products', function (Table $table) { + $table->id(); + $table->string('name', 100); + $table->integer('price'); + $table->index(['name']); + }); + $this->assertBindingCount($result); + + $this->assertSame( + 'CREATE TABLE `products` (`id` INTEGER AUTOINCREMENT NOT NULL, `name` VARCHAR(100) NOT NULL, `price` INTEGER NOT NULL, PRIMARY KEY (`id`), INDEX `idx_name` (`name`))', + $result->query + ); + $this->assertSame([], $result->bindings); + } + + public function testExactDropTable(): void + { + $schema = new Schema(); + $result = $schema->drop('sessions'); + + $this->assertSame('DROP TABLE `sessions`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactRenameTable(): void + { + $schema = new Schema(); + $result = $schema->rename('old_name', 'new_name'); + + $this->assertSame('ALTER TABLE `old_name` RENAME TO `new_name`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactTruncateTable(): void + { + $schema = new Schema(); + $result = $schema->truncate('logs'); + + $this->assertSame('DELETE FROM `logs`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactDropIndex(): void + { + $schema = new Schema(); + $result = $schema->dropIndex('users', 'idx_email'); + + $this->assertSame('DROP INDEX `idx_email`', $result->query); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testExactCreateTableWithForeignKey(): void + { + $schema = new Schema(); + $result = $schema->create('orders', function (Table $table) { + $table->id(); + $table->integer('customer_id'); + $table->foreignKey('customer_id') + ->references('id')->on('customers') + ->onDelete(ForeignKeyAction::Cascade)->onUpdate(ForeignKeyAction::Cascade); + }); + + $this->assertSame( + 'CREATE TABLE `orders` (`id` INTEGER AUTOINCREMENT NOT NULL, `customer_id` INTEGER NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE ON UPDATE CASCADE)', + $result->query + ); + $this->assertSame([], $result->bindings); + $this->assertBindingCount($result); + } + + public function testColumnTypeFloatMapsToReal(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->float('ratio'); + }); + $this->assertBindingCount($result); + + $this->assertSame('CREATE TABLE `t` (`ratio` REAL NOT NULL)', $result->query); + } + + public function testCreateIfNotExists(): void + { + $schema = new Schema(); + $result = $schema->createIfNotExists('t', function (Table $table) { + $table->integer('id')->primary(); + }); + $this->assertBindingCount($result); + + $this->assertStringStartsWith('CREATE TABLE IF NOT EXISTS', $result->query); + } + + public function testAlterMultipleOperations(): void + { + $schema = new Schema(); + $result = $schema->alter('users', function (Table $table) { + $table->addColumn('avatar', 'string', 255)->nullable(); + $table->dropColumn('age'); + $table->renameColumn('bio', 'biography'); + }); + $this->assertBindingCount($result); + + $this->assertSame('ALTER TABLE `users` ADD COLUMN `avatar` VARCHAR(255) NULL, RENAME COLUMN `bio` TO `biography`, DROP COLUMN `age`', $result->query); + } + + public function testSerialColumnMapsToInteger(): void + { + $schema = new Schema(); + $result = $schema->create('t', function (Table $table) { + $table->serial('id')->primary(); + }); + + $this->assertSame('CREATE TABLE `t` (`id` INTEGER AUTOINCREMENT NOT NULL, PRIMARY KEY (`id`))', $result->query); + } + + public function testUserTypeColumnThrowsUnsupported(): void + { + $this->expectException(UnsupportedException::class); + + $schema = new Schema(); + $schema->create('t', function (Table $table) { + $table->integer('id')->primary(); + $table->string('mood')->userType('mood_type'); + }); + } +} diff --git a/tests/Query/Schema/TableTest.php b/tests/Query/Schema/TableTest.php new file mode 100644 index 0000000..fc96d7a --- /dev/null +++ b/tests/Query/Schema/TableTest.php @@ -0,0 +1,545 @@ +assertSame([], $bp->columns); + } + + public function testColumnsPropertyPopulatedByString(): void + { + $bp = new Table(); + $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 Table(); + $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 Table(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->columns = [new Column('x', ColumnType::String)]; + } + + public function testColumnsPopulatedById(): void + { + $bp = new Table(); + $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 Table(); + $bp->addColumn('score', ColumnType::Integer); + + $this->assertCount(1, $bp->columns); + $this->assertSame('score', $bp->columns[0]->name); + } + + public function testColumnsPopulatedByModifyColumn(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->indexes); + } + + public function testIndexesPopulatedByIndex(): void + { + $bp = new Table(); + $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 Table(); + $bp->uniqueIndex(['email']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('uniq_email', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByFulltextIndex(): void + { + $bp = new Table(); + $bp->fulltextIndex(['body']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('ft_body', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedBySpatialIndex(): void + { + $bp = new Table(); + $bp->spatialIndex(['location']); + + $this->assertCount(1, $bp->indexes); + $this->assertSame('sp_location', $bp->indexes[0]->name); + } + + public function testIndexesPopulatedByAddIndex(): void + { + $bp = new Table(); + $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 Table(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->indexes = []; + } + + // ── foreignKeys (public private(set)) ────────────────────── + + public function testForeignKeysPropertyIsReadable(): void + { + $bp = new Table(); + $this->assertSame([], $bp->foreignKeys); + } + + public function testForeignKeysPopulatedByForeignKey(): void + { + $bp = new Table(); + $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 Table(); + $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 Table(); + + $this->expectException(\Error::class); + /** @phpstan-ignore-next-line */ + $bp->foreignKeys = []; + } + + // ── dropColumns (public private(set)) ────────────────────── + + public function testDropColumnsPropertyIsReadable(): void + { + $bp = new Table(); + $this->assertSame([], $bp->dropColumns); + } + + public function testDropColumnsPopulatedByDropColumn(): void + { + $bp = new Table(); + $bp->dropColumn('old_field'); + + $this->assertCount(1, $bp->dropColumns); + $this->assertSame('old_field', $bp->dropColumns[0]); + } + + public function testDropColumnsMultiple(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->renameColumns); + } + + public function testRenameColumnsPopulatedByRenameColumn(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->dropIndexes); + } + + public function testDropIndexesPopulatedByDropIndex(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->dropForeignKeys); + } + + public function testDropForeignKeysPopulatedByDropForeignKey(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->rawColumnDefs); + } + + public function testRawColumnDefsPopulatedByRawColumn(): void + { + $bp = new Table(); + $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 Table(); + $this->assertSame([], $bp->rawIndexDefs); + } + + public function testRawIndexDefsPopulatedByRawIndex(): void + { + $bp = new Table(); + $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 Table(); + + $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 Table(); + $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 Table(); + $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 Table(); + $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 Table(); + $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); + } + + public function testChecksPropertyIsReadable(): void + { + $bp = new Table(); + $this->assertSame([], $bp->checks); + } + + public function testCheckPopulatesChecksList(): void + { + $bp = new Table(); + $bp->check('age_range', '`age` >= 0 AND `age` < 150'); + + $this->assertCount(1, $bp->checks); + $this->assertInstanceOf(CheckConstraint::class, $bp->checks[0]); + $this->assertSame('age_range', $bp->checks[0]->name); + $this->assertSame('`age` >= 0 AND `age` < 150', $bp->checks[0]->expression); + } + + public function testCheckRejectsInvalidName(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid check constraint name'); + + $bp->check('bad name;', 'x > 0'); + } + + public function testColumnCheckAttachesExpression(): void + { + $bp = new Table(); + $col = $bp->integer('age')->check('`age` >= 0'); + + $this->assertSame('`age` >= 0', $col->checkExpression); + } + + public function testColumnGeneratedAsDefaultsToVirtualOnCompile(): void + { + $col = new Column('area', ColumnType::Integer); + $col->generatedAs('`width` * `height`'); + + $this->assertSame('`width` * `height`', $col->generatedExpression); + $this->assertNull($col->generatedStored); + } + + public function testColumnStoredAndVirtualAreMutuallyExclusive(): void + { + $col = new Column('area', ColumnType::Integer); + $col->generatedAs('`width` * `height`')->stored(); + $this->assertTrue($col->generatedStored); + + $col->virtual(); + $this->assertFalse($col->generatedStored); + + $col->stored(); + $this->assertTrue($col->generatedStored); + } + + public function testPartitionByHashWithCount(): void + { + $bp = new Table(); + $bp->partitionByHash('`id`', 4); + + $this->assertSame(4, $bp->partitionCount); + } + + public function testPartitionByHashWithoutCount(): void + { + $bp = new Table(); + $bp->partitionByHash('`id`'); + + $this->assertNull($bp->partitionCount); + } + + public function testPartitionByHashRejectsZeroCount(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Partition count must be at least 1.'); + + $bp->partitionByHash('`id`', 0); + } + + public function testPartitionByHashRejectsNegativeCount(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + + $bp->partitionByHash('`id`', -5); + } + + public function testCompositePrimaryKeyPropertyIsReadable(): void + { + $bp = new Table(); + $this->assertSame([], $bp->compositePrimaryKey); + } + + public function testPrimaryPopulatesCompositePrimaryKey(): void + { + $bp = new Table(); + $bp->primary(['id', 'created_at']); + + $this->assertSame(['id', 'created_at'], $bp->compositePrimaryKey); + } + + public function testPrimaryReturnsStaticForChaining(): void + { + $bp = new Table(); + $result = $bp->primary(['a', 'b']); + + $this->assertSame($bp, $result); + } + + public function testPrimaryRejectsSingleColumn(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('at least two columns'); + + $bp->primary(['id']); + } + + public function testPrimaryRejectsEmptyArray(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + + $bp->primary([]); + } + + public function testPrimaryRejectsInvalidColumnName(): void + { + $bp = new Table(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid column name'); + + $bp->primary(['id', 'bad name;']); + } +} diff --git a/tests/Query/SelectionQueryTest.php b/tests/Query/SelectionQueryTest.php index 582cd23..c5a9d18 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,67 +11,67 @@ class SelectionQueryTest extends TestCase public function testSelect(): void { $query = Query::select(['name', 'email']); - $this->assertEquals(Query::TYPE_SELECT, $query->getMethod()); - $this->assertEquals(['name', 'email'], $query->getValues()); + $this->assertSame(Method::Select, $query->getMethod()); + $this->assertSame(['name', 'email'], $query->getValues()); } public function testOrderAsc(): void { $query = Query::orderAsc('name'); - $this->assertEquals(Query::TYPE_ORDER_ASC, $query->getMethod()); - $this->assertEquals('name', $query->getAttribute()); + $this->assertSame(Method::OrderAsc, $query->getMethod()); + $this->assertSame('name', $query->getAttribute()); } public function testOrderAscNoAttribute(): void { $query = Query::orderAsc(); - $this->assertEquals('', $query->getAttribute()); + $this->assertSame('', $query->getAttribute()); } public function testOrderDesc(): void { $query = Query::orderDesc('name'); - $this->assertEquals(Query::TYPE_ORDER_DESC, $query->getMethod()); - $this->assertEquals('name', $query->getAttribute()); + $this->assertSame(Method::OrderDesc, $query->getMethod()); + $this->assertSame('name', $query->getAttribute()); } public function testOrderDescNoAttribute(): void { $query = Query::orderDesc(); - $this->assertEquals('', $query->getAttribute()); + $this->assertSame('', $query->getAttribute()); } 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->assertEquals([25], $query->getValues()); + $this->assertSame(Method::Limit, $query->getMethod()); + $this->assertSame([25], $query->getValues()); } public function testOffset(): void { $query = Query::offset(10); - $this->assertEquals(Query::TYPE_OFFSET, $query->getMethod()); - $this->assertEquals([10], $query->getValues()); + $this->assertSame(Method::Offset, $query->getMethod()); + $this->assertSame([10], $query->getValues()); } public function testCursorAfter(): void { $query = Query::cursorAfter('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_AFTER, $query->getMethod()); - $this->assertEquals(['doc123'], $query->getValues()); + $this->assertSame(Method::CursorAfter, $query->getMethod()); + $this->assertSame(['doc123'], $query->getValues()); } public function testCursorBefore(): void { $query = Query::cursorBefore('doc123'); - $this->assertEquals(Query::TYPE_CURSOR_BEFORE, $query->getMethod()); - $this->assertEquals(['doc123'], $query->getValues()); + $this->assertSame(Method::CursorBefore, $query->getMethod()); + $this->assertSame(['doc123'], $query->getValues()); } } diff --git a/tests/Query/SpatialQueryTest.php b/tests/Query/SpatialQueryTest.php index c65984e..5e173e7 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,80 +11,126 @@ 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->assertEquals([[[1.0, 2.0], 100, false]], $query->getValues()); + $this->assertSame(Method::DistanceEqual, $query->getMethod()); + $this->assertSame([[[1.0, 2.0], 100, false]], $query->getValues()); } public function testDistanceEqualWithMeters(): void { $query = Query::distanceEqual('location', [1.0, 2.0], 100, true); - $this->assertEquals([[[1.0, 2.0], 100, true]], $query->getValues()); + $this->assertSame([[[1.0, 2.0], 100, true]], $query->getValues()); } 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->assertEquals([[[0, 0], [1, 1]]], $query->getValues()); + $this->assertSame(Method::Intersects, $query->getMethod()); + $this->assertSame([[[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->assertSame('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->assertSame([[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/Tokenizer/ClickHouseTest.php b/tests/Query/Tokenizer/ClickHouseTest.php new file mode 100644 index 0000000..447f4c5 --- /dev/null +++ b/tests/Query/Tokenizer/ClickHouseTest.php @@ -0,0 +1,62 @@ +tokenizer = new ClickHouse(); + } + + /** + * @return Token[] + */ + private function meaningful(string $sql): array + { + return Tokenizer::filter($this->tokenizer->tokenize($sql)); + } + + /** + * @param Token[] $tokens + * @return TokenType[] + */ + private function types(array $tokens): array + { + return array_map(fn (Token $t) => $t->type, $tokens); + } + + public function testBasicTokenization(): void + { + $tokens = $this->meaningful('SELECT * FROM users WHERE id = 1'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Star, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, + TokenType::Operator, TokenType::Integer, TokenType::Eof, + ], + $this->types($tokens) + ); + } + + public function testBacktickQuoting(): void + { + $tokens = $this->meaningful('SELECT `name` FROM `events`'); + + $this->assertSame( + [TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('`name`', $tokens[1]->value); + $this->assertSame('`events`', $tokens[3]->value); + } +} diff --git a/tests/Query/Tokenizer/MySQLTest.php b/tests/Query/Tokenizer/MySQLTest.php new file mode 100644 index 0000000..c9b1b4d --- /dev/null +++ b/tests/Query/Tokenizer/MySQLTest.php @@ -0,0 +1,308 @@ +tokenizer = new MySQL(); + } + + /** + * @return Token[] + */ + private function meaningful(string $sql): array + { + return Tokenizer::filter($this->tokenizer->tokenize($sql)); + } + + /** + * @param Token[] $tokens + * @return TokenType[] + */ + private function types(array $tokens): array + { + return array_map(fn (Token $t) => $t->type, $tokens); + } + + /** + * @param Token[] $tokens + * @return string[] + */ + private function values(array $tokens): array + { + return array_map(fn (Token $t) => $t->value, $tokens); + } + + public function testHashComment(): void + { + $all = $this->tokenizer->tokenize("SELECT * # comment\nFROM users"); + + $comments = array_values(array_filter( + $all, + fn (Token $t) => $t->type === TokenType::LineComment + )); + + $this->assertCount(1, $comments); + $this->assertSame('-- comment', $comments[0]->value); + } + + public function testHashCommentFilteredOut(): void + { + $all = $this->tokenizer->tokenize("SELECT * # this is a hash comment\nFROM users"); + $filtered = Tokenizer::filter($all); + + $types = $this->types($filtered); + $this->assertNotContains(TokenType::LineComment, $types); + + $this->assertSame( + [TokenType::Keyword, TokenType::Star, TokenType::Keyword, TokenType::Identifier, TokenType::Eof], + $types + ); + } + + public function testBacktickQuoting(): void + { + $tokens = $this->meaningful('SELECT `name` FROM `users`'); + + $this->assertSame( + [TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('`name`', $tokens[1]->value); + $this->assertSame('`users`', $tokens[3]->value); + } + + public function testHashCommentInsideSingleQuotedString(): void + { + $tokens = $this->meaningful("SELECT '#not-a-comment' FROM t"); + + $values = $this->values($tokens); + $this->assertContains("'#not-a-comment'", $values); + + $stringToken = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::String) { + $stringToken = $t; + break; + } + } + $this->assertNotNull($stringToken); + $this->assertSame("'#not-a-comment'", $stringToken->value); + } + + public function testHashCommentInsideBacktickIdentifier(): void + { + $tokens = $this->meaningful('SELECT `col#name` FROM t'); + + $quotedId = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::QuotedIdentifier) { + $quotedId = $t; + break; + } + } + $this->assertNotNull($quotedId); + $this->assertSame('`col#name`', $quotedId->value); + } + + public function testHashCommentInsideDoubleQuotedIdentifier(): void + { + $tokens = $this->meaningful('SELECT "col#name" FROM t'); + + $found = false; + foreach ($tokens as $t) { + if ($t->value === '"col#name"') { + $found = true; + break; + } + } + $this->assertTrue($found, 'Double-quoted identifier with # should be preserved'); + } + + public function testHashCommentWithEscapedQuote(): void + { + $tokens = $this->meaningful("SELECT 'it''s a #test' FROM t"); + + $stringToken = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::String) { + $stringToken = $t; + break; + } + } + $this->assertNotNull($stringToken); + $this->assertSame("'it''s a #test'", $stringToken->value); + } + + public function testHashCommentWithBackslashEscape(): void + { + $tokens = $this->meaningful("SELECT 'it\\'s a #test' FROM t"); + + $stringToken = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::String) { + $stringToken = $t; + break; + } + } + $this->assertNotNull($stringToken); + $this->assertStringContainsString('#test', $stringToken->value); + } + + public function testMultipleHashComments(): void + { + $all = $this->tokenizer->tokenize("SELECT 1 #first\nSELECT 2 #second\nSELECT 3"); + + $comments = array_values(array_filter( + $all, + fn (Token $t) => $t->type === TokenType::LineComment + )); + + $this->assertCount(2, $comments); + $this->assertSame('-- first', $comments[0]->value); + $this->assertSame('-- second', $comments[1]->value); + } + + public function testHashCommentAtEndOfInput(): void + { + $all = $this->tokenizer->tokenize('SELECT 1 #comment'); + + $comments = array_values(array_filter( + $all, + fn (Token $t) => $t->type === TokenType::LineComment + )); + + $this->assertCount(1, $comments); + $this->assertSame('-- comment', $comments[0]->value); + + $filtered = Tokenizer::filter($all); + $this->assertSame( + [TokenType::Keyword, TokenType::Integer, TokenType::Eof], + $this->types($filtered) + ); + } + + public function testHashCommentInsideEscapedBacktickIdentifier(): void + { + $tokens = $this->meaningful('SELECT `col``#name` FROM t'); + + $quotedId = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::QuotedIdentifier) { + $quotedId = $t; + break; + } + } + $this->assertNotNull($quotedId); + $this->assertSame('`col``#name`', $quotedId->value); + } + + public function testHashCommentInsideEscapedDoubleQuotedIdentifier(): void + { + $tokens = $this->meaningful('SELECT "col""#name" FROM t'); + + $found = false; + foreach ($tokens as $t) { + if (str_contains($t->value, '#name')) { + $found = true; + break; + } + } + $this->assertTrue($found, 'Escaped double-quoted identifier with # should be preserved'); + } + + public function testHashInsideDoubleQuotedStringWithBackslashEscapeIsNotRewritten(): void + { + // MySQL default mode (no ANSI_QUOTES): " opens a string literal and + // \" is an escaped quote. The # inside must not be rewritten to --. + $sql = 'SELECT "a\\"# not a comment" FROM t'; + + $reflection = new ReflectionClass(MySQL::class); + $method = $reflection->getMethod('replaceHashComments'); + $rewritten = $method->invoke($this->tokenizer, $sql); + + $this->assertIsString($rewritten); + $this->assertStringContainsString('# not a comment', $rewritten); + $this->assertStringNotContainsString('-- not a comment', $rewritten); + $this->assertSame($sql, $rewritten); + } + + public function testHashInsidePlainDoubleQuotedIdentifierIsNotRewritten(): void + { + // With no backslash-escaped quotes, a plain "col#name" is still a + // double-quoted identifier under ANSI_QUOTES. The # inside must not + // be rewritten to a -- comment. + $sql = 'SELECT "col#name" FROM t'; + + $reflection = new ReflectionClass(MySQL::class); + $method = $reflection->getMethod('replaceHashComments'); + $rewritten = $method->invoke($this->tokenizer, $sql); + + $this->assertIsString($rewritten); + $this->assertStringContainsString('#name', $rewritten); + $this->assertStringNotContainsString('--name', $rewritten); + $this->assertSame($sql, $rewritten); + } + + public function testHashAfterDoubleQuotedIdentifierIsRewritten(): void + { + // A # that appears after a closed double-quoted identifier is a real + // line comment and must be rewritten to --. + $sql = "SELECT \"a\" # trailing comment\nFROM t"; + + $reflection = new ReflectionClass(MySQL::class); + $method = $reflection->getMethod('replaceHashComments'); + $rewritten = $method->invoke($this->tokenizer, $sql); + + $this->assertIsString($rewritten); + $this->assertStringContainsString('-- trailing comment', $rewritten); + $this->assertStringNotContainsString('# trailing comment', $rewritten); + + // End-to-end: the tokenizer should drop the comment after filtering. + $filtered = Tokenizer::filter($this->tokenizer->tokenize($sql)); + $types = array_map(fn (Token $t) => $t->type, $filtered); + $this->assertNotContains(TokenType::LineComment, $types); + } + + public function testHashReplacementEmitsTrailingSpaceForReTokenization(): void + { + // Regression: MySQL's `--` line-comment form requires a whitespace or + // control character after the two dashes. Replacing `#` with just `--` + // (no trailing space) emits SQL that is not re-tokenizable as a line + // comment. The replacement must be `-- ` (with trailing space). + $sql = 'SELECT 1 #comment'; + + $reflection = new ReflectionClass(MySQL::class); + $method = $reflection->getMethod('replaceHashComments'); + $rewritten = $method->invoke($this->tokenizer, $sql); + + $this->assertIsString($rewritten); + $this->assertStringContainsString('-- comment', $rewritten); + $this->assertStringNotContainsString('#comment', $rewritten); + } + + public function testHashInsideSingleQuotedStringIsNotRewritten(): void + { + $sql = "SELECT 'a#b' FROM t"; + + $reflection = new ReflectionClass(MySQL::class); + $method = $reflection->getMethod('replaceHashComments'); + $rewritten = $method->invoke($this->tokenizer, $sql); + + $this->assertIsString($rewritten); + $this->assertSame($sql, $rewritten); + $this->assertStringContainsString("'a#b'", $rewritten); + $this->assertStringNotContainsString("'a--b'", $rewritten); + } +} diff --git a/tests/Query/Tokenizer/PostgreSQLTest.php b/tests/Query/Tokenizer/PostgreSQLTest.php new file mode 100644 index 0000000..cc067fb --- /dev/null +++ b/tests/Query/Tokenizer/PostgreSQLTest.php @@ -0,0 +1,110 @@ +tokenizer = new PostgreSQL(); + } + + /** + * @return Token[] + */ + private function meaningful(string $sql): array + { + return Tokenizer::filter($this->tokenizer->tokenize($sql)); + } + + /** + * @param Token[] $tokens + * @return TokenType[] + */ + private function types(array $tokens): array + { + return array_map(fn (Token $t) => $t->type, $tokens); + } + + public function testDoubleQuoteIdentifier(): void + { + $tokens = $this->meaningful('SELECT "name" FROM "users"'); + + $this->assertSame( + [TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('"name"', $tokens[1]->value); + $this->assertSame('"users"', $tokens[3]->value); + } + + public function testJsonbContainsOperator(): void + { + $tokens = $this->meaningful("WHERE tags @> '[\"php\"]'"); + + $operators = array_values(array_filter( + $tokens, + fn (Token $t) => $t->type === TokenType::Operator + )); + + $this->assertCount(1, $operators); + $this->assertSame('@>', $operators[0]->value); + } + + public function testJsonbContainedByOperator(): void + { + $tokens = $this->meaningful("WHERE '[\"php\"]' <@ tags"); + + $operators = array_values(array_filter( + $tokens, + fn (Token $t) => $t->type === TokenType::Operator + )); + + $this->assertCount(1, $operators); + $this->assertSame('<@', $operators[0]->value); + } + + public function testVectorOperators(): void + { + $tokens1 = $this->meaningful('embedding <=> query_vec'); + $ops1 = array_values(array_filter( + $tokens1, + fn (Token $t) => $t->type === TokenType::Operator + )); + $this->assertCount(1, $ops1); + $this->assertSame('<=>', $ops1[0]->value); + + $tokens2 = $this->meaningful('embedding <-> query_vec'); + $ops2 = array_values(array_filter( + $tokens2, + fn (Token $t) => $t->type === TokenType::Operator + )); + $this->assertCount(1, $ops2); + $this->assertSame('<->', $ops2[0]->value); + + $tokens3 = $this->meaningful('embedding <#> query_vec'); + $ops3 = array_values(array_filter( + $tokens3, + fn (Token $t) => $t->type === TokenType::Operator + )); + $this->assertCount(1, $ops3); + $this->assertSame('<#>', $ops3[0]->value); + } + + public function testDoubleQuoteNotString(): void + { + $tokens = $this->meaningful('"col"'); + + $this->assertSame(TokenType::QuotedIdentifier, $tokens[0]->type); + $this->assertSame('"col"', $tokens[0]->value); + $this->assertNotSame(TokenType::String, $tokens[0]->type); + } +} diff --git a/tests/Query/Tokenizer/SQLiteTest.php b/tests/Query/Tokenizer/SQLiteTest.php new file mode 100644 index 0000000..a1e200c --- /dev/null +++ b/tests/Query/Tokenizer/SQLiteTest.php @@ -0,0 +1,89 @@ +tokenizer = new SQLite(); + } + + /** + * @return Token[] + */ + private function meaningful(string $sql): array + { + return Tokenizer::filter($this->tokenizer->tokenize($sql)); + } + + /** + * @param Token[] $tokens + * @return TokenType[] + */ + private function types(array $tokens): array + { + return array_map(fn (Token $t) => $t->type, $tokens); + } + + public function testDoubleQuoteIdentifier(): void + { + $tokens = $this->meaningful('SELECT "name" FROM "users"'); + + $this->assertSame( + [TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('"name"', $tokens[1]->value); + $this->assertSame('"users"', $tokens[3]->value); + } + + public function testDoubleQuoteNotString(): void + { + $tokens = $this->meaningful('"col"'); + + $this->assertSame(TokenType::QuotedIdentifier, $tokens[0]->type); + $this->assertSame('"col"', $tokens[0]->value); + $this->assertNotSame(TokenType::String, $tokens[0]->type); + } + + public function testDoubleQuoteWithEscapedQuote(): void + { + $tokens = $this->meaningful('SELECT "col""name" FROM t'); + + $this->assertSame(TokenType::QuotedIdentifier, $tokens[1]->type); + $this->assertSame('"col""name"', $tokens[1]->value); + } + + public function testMixedIdentifiersAndStrings(): void + { + $tokens = $this->meaningful("SELECT \"name\" FROM \"users\" WHERE status = 'active'"); + + $this->assertSame(TokenType::QuotedIdentifier, $tokens[1]->type); + $this->assertSame('"name"', $tokens[1]->value); + $this->assertSame(TokenType::QuotedIdentifier, $tokens[3]->type); + $this->assertSame('"users"', $tokens[3]->value); + $this->assertSame(TokenType::String, $tokens[7]->type); + $this->assertSame("'active'", $tokens[7]->value); + } + + public function testUnquotedIdentifiers(): void + { + $tokens = $this->meaningful('SELECT name FROM users'); + + $this->assertSame( + [TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('name', $tokens[1]->value); + $this->assertSame('users', $tokens[3]->value); + } +} diff --git a/tests/Query/Tokenizer/TokenizerTest.php b/tests/Query/Tokenizer/TokenizerTest.php new file mode 100644 index 0000000..f853d01 --- /dev/null +++ b/tests/Query/Tokenizer/TokenizerTest.php @@ -0,0 +1,720 @@ +tokenizer = new Tokenizer(); + } + + /** + * Helper: tokenize and filter to meaningful tokens. + * + * @return Token[] + */ + private function meaningful(string $sql): array + { + return Tokenizer::filter($this->tokenizer->tokenize($sql)); + } + + /** + * Helper: extract token types from an array of tokens. + * + * @param Token[] $tokens + * @return TokenType[] + */ + private function types(array $tokens): array + { + return array_map(fn (Token $t) => $t->type, $tokens); + } + + /** + * Helper: extract token values from an array of tokens. + * + * @param Token[] $tokens + * @return string[] + */ + private function values(array $tokens): array + { + return array_map(fn (Token $t) => $t->value, $tokens); + } + + public function testSelectStar(): void + { + $tokens = $this->meaningful('SELECT * FROM users'); + + $this->assertSame( + [TokenType::Keyword, TokenType::Star, TokenType::Keyword, TokenType::Identifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame( + ['SELECT', '*', 'FROM', 'users', ''], + $this->values($tokens) + ); + } + + public function testSelectColumns(): void + { + $tokens = $this->meaningful('SELECT name, email FROM users'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Comma, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame( + ['SELECT', 'name', ',', 'email', 'FROM', 'users', ''], + $this->values($tokens) + ); + } + + public function testWhereClause(): void + { + $tokens = $this->meaningful('SELECT * FROM users WHERE age > 18'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Star, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, + TokenType::Operator, TokenType::Integer, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame( + ['SELECT', '*', 'FROM', 'users', 'WHERE', 'age', '>', '18', ''], + $this->values($tokens) + ); + } + + public function testStringLiteral(): void + { + $tokens = $this->meaningful("SELECT * FROM users WHERE name = 'Alice'"); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Star, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, + TokenType::Operator, TokenType::String, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame("'Alice'", $tokens[7]->value); + } + + public function testStringLiteralWithEscapedQuote(): void + { + $tokens = $this->meaningful("WHERE name = 'O''Brien'"); + + $stringToken = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::String) { + $stringToken = $t; + break; + } + } + + $this->assertNotNull($stringToken); + $this->assertSame("'O''Brien'", $stringToken->value); + } + + public function testNumericLiterals(): void + { + $tokens = $this->meaningful('WHERE id = 42 AND score = 3.14'); + + $types = $this->types($tokens); + $values = $this->values($tokens); + + $this->assertSame(TokenType::Integer, $types[3]); + $this->assertSame('42', $values[3]); + + $this->assertSame(TokenType::Float, $types[7]); + $this->assertSame('3.14', $values[7]); + } + + public function testOperators(): void + { + $tokens = $this->meaningful('a = b != c <> d < e > f <= g >= h'); + + $operators = array_values(array_filter( + $tokens, + fn (Token $t) => $t->type === TokenType::Operator + )); + + $this->assertSame( + ['=', '!=', '<>', '<', '>', '<=', '>='], + array_map(fn (Token $t) => $t->value, $operators) + ); + } + + public function testLogicalOperators(): void + { + $tokens = $this->meaningful('WHERE a AND b OR NOT c'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Keyword, + TokenType::Identifier, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('AND', $tokens[2]->value); + $this->assertSame('OR', $tokens[4]->value); + $this->assertSame('NOT', $tokens[5]->value); + } + + public function testInExpression(): void + { + $tokens = $this->meaningful("WHERE status IN ('active', 'pending')"); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::LeftParen, TokenType::String, TokenType::Comma, + TokenType::String, TokenType::RightParen, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('IN', $tokens[2]->value); + } + + public function testBetweenExpression(): void + { + $tokens = $this->meaningful('WHERE age BETWEEN 18 AND 65'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::Integer, TokenType::Keyword, TokenType::Integer, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('BETWEEN', $tokens[2]->value); + } + + public function testLikeExpression(): void + { + $tokens = $this->meaningful("WHERE name LIKE 'A%'"); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::String, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('LIKE', $tokens[2]->value); + } + + public function testIsNull(): void + { + $tokens = $this->meaningful('WHERE deleted_at IS NULL'); + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::Null, TokenType::Eof, + ], + $this->types($tokens) + ); + + $tokens2 = $this->meaningful('WHERE deleted_at IS NOT NULL'); + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::Keyword, TokenType::Null, TokenType::Eof, + ], + $this->types($tokens2) + ); + } + + public function testJoin(): void + { + $tokens = $this->meaningful('SELECT * FROM users JOIN orders ON users.id = orders.user_id'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Star, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, + TokenType::Keyword, TokenType::Identifier, TokenType::Dot, + TokenType::Identifier, TokenType::Operator, TokenType::Identifier, + TokenType::Dot, TokenType::Identifier, TokenType::Eof, + ], + $this->types($tokens) + ); + } + + public function testMultipleJoins(): void + { + $tokens = $this->meaningful('SELECT * FROM a LEFT JOIN b ON a.id = b.a_id RIGHT JOIN c ON a.id = c.a_id'); + + $keywords = array_values(array_filter( + $tokens, + fn (Token $t) => $t->type === TokenType::Keyword + )); + $kwValues = array_map(fn (Token $t) => $t->value, $keywords); + + $this->assertContains('LEFT', $kwValues); + $this->assertContains('RIGHT', $kwValues); + $this->assertContains('JOIN', $kwValues); + $this->assertContains('ON', $kwValues); + } + + public function testOrderBy(): void + { + $tokens = $this->meaningful('ORDER BY name ASC, age DESC'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Keyword, TokenType::Identifier, + TokenType::Keyword, TokenType::Comma, TokenType::Identifier, + TokenType::Keyword, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('ASC', $tokens[3]->value); + $this->assertSame('DESC', $tokens[6]->value); + } + + public function testGroupByHaving(): void + { + $tokens = $this->meaningful('GROUP BY status HAVING COUNT(*) > 5'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Keyword, TokenType::Identifier, + TokenType::Keyword, TokenType::Identifier, TokenType::LeftParen, + TokenType::Star, TokenType::RightParen, TokenType::Operator, + TokenType::Integer, TokenType::Eof, + ], + $this->types($tokens) + ); + } + + public function testLimitOffset(): void + { + $tokens = $this->meaningful('LIMIT 10 OFFSET 20'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Integer, TokenType::Keyword, + TokenType::Integer, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('10', $tokens[1]->value); + $this->assertSame('20', $tokens[3]->value); + } + + public function testFunctionCall(): void + { + $tokens = $this->meaningful('COUNT(*), SUM(amount), UPPER(name)'); + + // Aggregate function names are identifiers, not keywords + $this->assertSame(TokenType::Identifier, $tokens[0]->type); + $this->assertSame(TokenType::LeftParen, $tokens[1]->type); + $this->assertSame(TokenType::Star, $tokens[2]->type); + $this->assertSame(TokenType::RightParen, $tokens[3]->type); + } + + public function testNestedFunctionCall(): void + { + $tokens = $this->meaningful("COALESCE(UPPER(name), 'unknown')"); + + $this->assertSame(TokenType::Identifier, $tokens[0]->type); + $this->assertSame('COALESCE', $tokens[0]->value); + $this->assertSame(TokenType::LeftParen, $tokens[1]->type); + $this->assertSame(TokenType::Identifier, $tokens[2]->type); + $this->assertSame('UPPER', $tokens[2]->value); + $this->assertSame(TokenType::LeftParen, $tokens[3]->type); + $this->assertSame(TokenType::Identifier, $tokens[4]->type); + $this->assertSame('name', $tokens[4]->value); + $this->assertSame(TokenType::RightParen, $tokens[5]->type); + $this->assertSame(TokenType::Comma, $tokens[6]->type); + $this->assertSame(TokenType::String, $tokens[7]->type); + $this->assertSame(TokenType::RightParen, $tokens[8]->type); + } + + public function testPlaceholders(): void + { + $tokens = $this->meaningful('WHERE id = ? AND name = :name AND seq = $1'); + + $placeholder = null; + $named = null; + $numbered = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::Placeholder) { + $placeholder = $t; + } + if ($t->type === TokenType::NamedPlaceholder) { + $named = $t; + } + if ($t->type === TokenType::NumberedPlaceholder) { + $numbered = $t; + } + } + + $this->assertNotNull($placeholder); + $this->assertSame('?', $placeholder->value); + $this->assertNotNull($named); + $this->assertSame(':name', $named->value); + $this->assertNotNull($numbered); + $this->assertSame('$1', $numbered->value); + } + + public function testQuotedIdentifiers(): void + { + $tokens = $this->meaningful('SELECT `user name` FROM `my table`'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::QuotedIdentifier, TokenType::Keyword, + TokenType::QuotedIdentifier, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('`user name`', $tokens[1]->value); + $this->assertSame('`my table`', $tokens[3]->value); + } + + public function testLineComment(): void + { + $all = $this->tokenizer->tokenize("SELECT * -- this is a comment\nFROM users"); + + $comments = array_values(array_filter( + $all, + fn (Token $t) => $t->type === TokenType::LineComment + )); + + $this->assertCount(1, $comments); + $this->assertSame('-- this is a comment', $comments[0]->value); + + $filtered = Tokenizer::filter($all); + $types = $this->types($filtered); + $this->assertNotContains(TokenType::LineComment, $types); + } + + public function testBlockComment(): void + { + $all = $this->tokenizer->tokenize('SELECT /* columns */ * FROM users'); + + $comments = array_values(array_filter( + $all, + fn (Token $t) => $t->type === TokenType::BlockComment + )); + + $this->assertCount(1, $comments); + $this->assertSame('/* columns */', $comments[0]->value); + + $filtered = Tokenizer::filter($all); + $types = $this->types($filtered); + $this->assertNotContains(TokenType::BlockComment, $types); + } + + public function testFilterRemovesWhitespaceAndComments(): void + { + $all = $this->tokenizer->tokenize("SELECT * -- comment\n FROM users"); + + $hasWhitespace = false; + $hasComment = false; + foreach ($all as $t) { + if ($t->type === TokenType::Whitespace) { + $hasWhitespace = true; + } + if ($t->type === TokenType::LineComment) { + $hasComment = true; + } + } + $this->assertTrue($hasWhitespace); + $this->assertTrue($hasComment); + + $filtered = Tokenizer::filter($all); + foreach ($filtered as $t) { + $this->assertNotSame(TokenType::Whitespace, $t->type); + $this->assertNotSame(TokenType::LineComment, $t->type); + $this->assertNotSame(TokenType::BlockComment, $t->type); + } + } + + public function testCaseExpression(): void + { + $tokens = $this->meaningful("CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END"); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Keyword, TokenType::Identifier, + TokenType::Operator, TokenType::Integer, TokenType::Keyword, + TokenType::String, TokenType::Keyword, TokenType::String, + TokenType::Keyword, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('CASE', $tokens[0]->value); + $this->assertSame('WHEN', $tokens[1]->value); + $this->assertSame('THEN', $tokens[5]->value); + $this->assertSame('ELSE', $tokens[7]->value); + $this->assertSame('END', $tokens[9]->value); + } + + public function testSubquery(): void + { + $tokens = $this->meaningful('WHERE id IN (SELECT user_id FROM orders)'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::LeftParen, TokenType::Keyword, TokenType::Identifier, + TokenType::Keyword, TokenType::Identifier, TokenType::RightParen, + TokenType::Eof, + ], + $this->types($tokens) + ); + } + + public function testAliases(): void + { + $tokens = $this->meaningful('SELECT name AS n FROM users u'); + + $this->assertSame( + [ + TokenType::Keyword, TokenType::Identifier, TokenType::Keyword, + TokenType::Identifier, TokenType::Keyword, TokenType::Identifier, + TokenType::Identifier, TokenType::Eof, + ], + $this->types($tokens) + ); + $this->assertSame('AS', $tokens[2]->value); + $this->assertSame('n', $tokens[3]->value); + $this->assertSame('u', $tokens[6]->value); + } + + public function testComplexQuery(): void + { + $sql = "SELECT u.name, COUNT(*) AS total FROM users u " + . "LEFT JOIN orders o ON u.id = o.user_id " + . "WHERE u.status = 'active' AND o.amount > 100 " + . "GROUP BY u.name HAVING COUNT(*) > 5 " + . "ORDER BY total DESC LIMIT 10 OFFSET 0"; + + $tokens = $this->meaningful($sql); + + // Just verify it tokenizes without error and has expected start/end + $this->assertSame(TokenType::Keyword, $tokens[0]->type); + $this->assertSame('SELECT', $tokens[0]->value); + $this->assertSame(TokenType::Eof, $tokens[count($tokens) - 1]->type); + + // Verify some key tokens exist + $values = $this->values($tokens); + $this->assertContains('LEFT', $values); + $this->assertContains('JOIN', $values); + $this->assertContains('GROUP', $values); + $this->assertContains('HAVING', $values); + $this->assertContains('ORDER', $values); + $this->assertContains('LIMIT', $values); + $this->assertContains('OFFSET', $values); + $this->assertContains('DESC', $values); + } + + public function testEmptyInput(): void + { + $tokens = $this->meaningful(''); + + $this->assertCount(1, $tokens); + $this->assertSame(TokenType::Eof, $tokens[0]->type); + } + + public function testStarToken(): void + { + $tokens = $this->meaningful('SELECT *'); + + $star = null; + foreach ($tokens as $t) { + if ($t->value === '*') { + $star = $t; + break; + } + } + + $this->assertNotNull($star); + $this->assertSame(TokenType::Star, $star->type); + } + + public function testDotNotation(): void + { + $tokens = $this->meaningful('users.id'); + + $this->assertSame( + [TokenType::Identifier, TokenType::Dot, TokenType::Identifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('users', $tokens[0]->value); + $this->assertSame('.', $tokens[1]->value); + $this->assertSame('id', $tokens[2]->value); + } + + public function testCastOperator(): void + { + $tokens = $this->meaningful('value::integer'); + + $this->assertSame( + [TokenType::Identifier, TokenType::Operator, TokenType::Identifier, TokenType::Eof], + $this->types($tokens) + ); + $this->assertSame('::', $tokens[1]->value); + } + + public function testConcatOperator(): void + { + $tokens = $this->meaningful("first_name || ' ' || last_name"); + + $pipes = array_values(array_filter( + $tokens, + fn (Token $t) => $t->type === TokenType::Operator && $t->value === '||' + )); + + $this->assertCount(2, $pipes); + } + + public function testKeywordsCaseInsensitive(): void + { + $tokens1 = $this->meaningful('select * from users'); + $tokens2 = $this->meaningful('SELECT * FROM users'); + $tokens3 = $this->meaningful('Select * From Users'); + + // All should produce keyword tokens with uppercase values + $this->assertSame(TokenType::Keyword, $tokens1[0]->type); + $this->assertSame('SELECT', $tokens1[0]->value); + + $this->assertSame(TokenType::Keyword, $tokens2[0]->type); + $this->assertSame('SELECT', $tokens2[0]->value); + + $this->assertSame(TokenType::Keyword, $tokens3[0]->type); + $this->assertSame('SELECT', $tokens3[0]->value); + + // "Users" is not a keyword (it's an identifier), but "From" is + $this->assertSame(TokenType::Keyword, $tokens3[2]->type); + $this->assertSame('FROM', $tokens3[2]->value); + } + + public function testBackslashEscapeInString(): void + { + $tokens = $this->meaningful("WHERE name = 'hello\\'world'"); + + $stringToken = null; + foreach ($tokens as $t) { + if ($t->type === TokenType::String) { + $stringToken = $t; + break; + } + } + + $this->assertNotNull($stringToken); + $this->assertSame("'hello\\'world'", $stringToken->value); + } + + public function testScientificNotation(): void + { + $tokens = $this->meaningful('1.5e10'); + $this->assertSame(TokenType::Float, $tokens[0]->type); + $this->assertSame('1.5e10', $tokens[0]->value); + + $tokens = $this->meaningful('1e-3'); + $this->assertSame(TokenType::Float, $tokens[0]->type); + $this->assertSame('1e-3', $tokens[0]->value); + + $tokens = $this->meaningful('2.5E+8'); + $this->assertSame(TokenType::Float, $tokens[0]->type); + $this->assertSame('2.5E+8', $tokens[0]->value); + } + + public function testEscapedQuotedIdentifiers(): void + { + $tokens = $this->meaningful('SELECT `col``name` FROM t'); + $this->assertSame(TokenType::QuotedIdentifier, $tokens[1]->type); + $this->assertSame('`col``name`', $tokens[1]->value); + } + + public function testUnterminatedBlockComment(): void + { + $tokens = $this->tokenizer->tokenize('/*/b'); + // Unterminated block comment should consume all remaining input + // Only BlockComment + Eof tokens (no leaked 'b' identifier) + $this->assertCount(2, $tokens); + $this->assertSame(TokenType::BlockComment, $tokens[0]->type); + $this->assertSame('/*/b', $tokens[0]->value); + $this->assertSame(TokenType::Eof, $tokens[1]->type); + } + + public function testUnknownCharactersEmittedAsOperators(): void + { + $tokens = $this->meaningful('a @ b'); + $this->assertSame(TokenType::Operator, $tokens[1]->type); + $this->assertSame('@', $tokens[1]->value); + } + + public function testDotPrefixedFloat(): void + { + $tokens = $this->meaningful('.5'); + $this->assertSame(TokenType::Float, $tokens[0]->type); + $this->assertSame('.5', $tokens[0]->value); + } + + public function testIdentifiersCasePreserved(): void + { + $tokens = $this->meaningful('SELECT myColumn FROM MyTable'); + $this->assertSame('myColumn', $tokens[1]->value); + $this->assertSame('MyTable', $tokens[3]->value); + } + + public function testEofAlwaysLast(): void + { + $inputs = [ + '', + 'SELECT 1', + "SELECT * FROM users WHERE id = 1", + "-- just a comment", + ]; + + foreach ($inputs as $sql) { + $tokens = $this->meaningful($sql); + $last = $tokens[count($tokens) - 1]; + $this->assertSame(TokenType::Eof, $last->type, "EOF should be last token for: $sql"); + } + } + + public function testStringWithTrailingBackslashThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unterminated string literal'); + + // 'abc\ — opening quote, three chars, backslash, then EOF. + $this->tokenizer->tokenize("'abc\\"); + } + + public function testUnterminatedStringLiteralThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unterminated string literal'); + + $this->tokenizer->tokenize("SELECT 'unclosed"); + } + + public function testUnterminatedQuotedIdentifierThrows(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Unterminated quoted identifier'); + + $this->tokenizer->tokenize('SELECT `unclosed'); + } +} diff --git a/tests/Query/VectorQueryTest.php b/tests/Query/VectorQueryTest.php index 40cf24b..10720d0 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,21 +12,21 @@ 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->assertEquals([$vector], $query->getValues()); + $this->assertSame(Method::VectorDot, $query->getMethod()); + $this->assertSame([$vector], $query->getValues()); } 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()); } }