diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..84c42e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Istanbul" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "composer" + commit-message: + prefix: "composer" + include: "scope" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Istanbul" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" diff --git a/.github/workflows/composer-validate.yml b/.github/workflows/composer-validate.yml new file mode 100644 index 0000000..e67965b --- /dev/null +++ b/.github/workflows/composer-validate.yml @@ -0,0 +1,30 @@ +name: Validate composer.json + +on: + push: + paths: + - 'composer.json' + - 'composer.lock' + pull_request: + paths: + - 'composer.json' + - 'composer.lock' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml new file mode 100644 index 0000000..bd5687a --- /dev/null +++ b/.github/workflows/phpcs.yml @@ -0,0 +1,40 @@ +name: PHP_CodeSniffer + +on: + push: + branches: ["master", "main", "v2.x", "v2.0.x"] + pull_request: + branches: ["master", "main", "v2.x", "v2.0.x"] + +permissions: + contents: read + +jobs: + phpcs: + name: PSR-12 lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ runner.os }}-cs-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ runner.os }}-cs- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHP_CodeSniffer + run: composer cs-ci diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..f7902ea --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,40 @@ +name: PHPStan + +on: + push: + branches: ["master", "main", "v2.x", "v2.0.x"] + pull_request: + branches: ["master", "main", "v2.x", "v2.0.x"] + +permissions: + contents: read + +jobs: + phpstan: + name: Static analysis (level 6) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ runner.os }}-stan-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ runner.os }}-stan- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPStan + run: composer stan diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..b3a69d3 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,46 @@ +name: PHPUnit + +on: + push: + branches: ["master", "main", "v2.x", "v2.0.x"] + pull_request: + branches: ["master", "main", "v2.x", "v2.0.x"] + +permissions: + contents: read + +jobs: + test: + name: PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ["8.1", "8.2", "8.3", "8.4"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo + coverage: pcov + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests with coverage + run: vendor/bin/phpunit --coverage-text=php://stdout diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a2aa68b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,137 @@ +# Changelog + +All notable changes to **InitORM QueryBuilder** are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [2.0.0] — 2026-05-24 + +The 2.0 line is a quality push: bug fixes (including one with SQL-injection +implications), an end-to-end refactor that splits the previous god-class into +clause traits + compilers, full PHPDoc + PHPStan level 6 + PSR-12, a 293-test +suite at 96 % line coverage, and CI on PHP 8.1 → 8.4. + +### Added +- **`Operator\Operators`** — every operator list (comparison, arithmetic, + null-check, LIKE family, BETWEEN, IN, FIND\_IN\_SET, sort directions, + logical) is now defined once as `const`. +- **`Helper\SqlValueDetector`** — `isSqlParameter()` / `isSqlParameterOrFunction()` + extracted to a stateless helper class. +- **`Helper\BucketCompiler`** — shared AND / OR bucket joiner used by both + the runtime clause builders and the compilers. +- **`Compiler\*`** — six dedicated compiler classes + (`SelectCompiler`, `InsertCompiler`, `BatchInsertCompiler`, + `UpdateCompiler`, `BatchUpdateCompiler`, `DeleteCompiler`) plus + `AbstractCompiler` with shared helpers and `CompilerInterface` as marker. +- **`Clause\*`** — six clause traits split out of the previous god-class: + `StructureTrait`, `FromClauseTrait`, `SelectClauseTrait`, + `JoinClauseTrait`, `WhereClauseTrait`, `SetClauseTrait`. +- **`Drivers\GenericDriver`** — the previous default `BaseDriver` is now an + explicit driver class; `AbstractDriver` is the new shared base. +- **`Drivers\AbstractDriver`** — replaces the old `BaseDriver`. Concrete + drivers declare `NAME` and `ESCAPE_CHAR` as class constants. +- **`QueryBuilder::__clone()`** — deep-clones the parameter bag so that + cloning the builder does not bleed parameter mutations between instances. +- **`QueryBuilderInterface::getDriver()`** — exposes the active driver. +- PHP 8.4 support; CI matrix now covers 8.1 / 8.2 / 8.3 / 8.4. +- PHPStan level 6 static analysis (`composer stan`). +- PHP\_CodeSniffer with a PSR-12 ruleset (`composer cs` / `cs-fix` / `cs-ci`). +- GitHub Actions workflows: PHPUnit, PHPCS, PHPStan, composer-validate. +- Dependabot weekly updates for Composer and GitHub Actions. +- Coverage reporting via pcov in `phpunit.xml`. +- Comprehensive `docs/` developer documentation (see [docs/en/index.md](docs/en/index.md)). + +### Changed +- **Breaking — driver class renames.** + - `Drivers\BaseDriver` → `Drivers\AbstractDriver` (now `abstract`). + - `Drivers\MySQL` → `Drivers\MySqlDriver`. + - `Drivers\PgSQL` → `Drivers\PostgreSqlDriver`. + - `Drivers\SQLite` → `Drivers\SqliteDriver`. +- **Breaking — `DriverInterface`.** + - `escapeIdentify(string &$s): string` → `escapeIdentifier(string $s): string` + (pure — no by-reference mutation). + - `getDriver()` → `getName()`. +- **Breaking — `QueryBuilderInterface::naturalJoin()`** no longer accepts + `$onStmt`; NATURAL JOIN does not carry an ON clause. +- **Breaking — `QueryBuilderInterface::where/having/on/andWhere/orWhere`** + declare `$operator` as `mixed` (matching the implementation), so the + value-shortcut `where('id', 5)` is preserved without LSP-violating type + contracts. +- **Breaking — `QueryBuilderInterface::group()`** now declares the + `string $logical = 'AND'` parameter that the implementation always accepted. +- **Breaking — return types `self` → `static`** across the interface, so + subclasses correctly inherit their own static return type from chained calls. +- **Breaking — minimum PHP version** raised from 8.0 to 8.1. +- Internal `__generate*` private helpers renamed to non-magic names + (`compileWhere`, `compileHaving`, etc.) inside the compilers. +- Every file's license header was reduced from a 10-line block to a single + `@package` / `@license` pair — the canonical legal text lives in `LICENSE`. + +### Fixed +- **B1 / B7 — `andWhereNotIn`** previously called `where(…, 'IN', …)`, + emitting `IN` instead of `NOT IN`. +- **B2 — `orLike` / `andLike` were swapped**: the AND-named method used + `'OR'`, the OR-named method used the default `'AND'`. +- **B3 — `orBetween`** corrupted its arguments by passing the bounds and + `'OR'` into the wrong positional slots, producing an unusable array and + losing the OR connector. +- **B4 — `RawQuery::set()`** used `instanceof Closure` without + `use Closure;`, so PHP resolved the comparison against the non-existent + `InitORM\QueryBuilder\Closure` and the closure branch was dead code. +- **B5 / B6 — `selfJoin` / `naturalJoin` signatures** now mirror the + implementations (`naturalJoin` no longer demands an unused `$onStmt`). +- **B8 — `preg_match(...) !== FALSE`** was effectively always true and + could read an unpopulated `$matches`; replaced with `=== 1`. +- **B9 — `whereOrHavingStatementPrepare`** now coerces a non-string + operator argument safely before `trim()`. +- **B16 — `startLike` / `endLike` wildcard semantics inverted.** + - `startLike('A')` now produces `LIKE 'A%'` (was `LIKE '%A'`). + - `endLike('A')` now produces `LIKE '%A'` (was `LIKE 'A%'`). +- **B20 / B21 — UPDATE exception message** said "insert" instead of "update". +- **B23 — Abstract test bases** `AbstractQueryBuilderUnit`, + `AbstractQueryBuilderDriverUnit` are now actually declared `abstract`. +- **B27 — `whereOrHavingPrepare`** used a loose `in_array()` which made + boolean operators (`where('active', true)`) compare equal to non-empty + operator strings; the value-shortcut was therefore skipped and the WHERE + collapsed to `WHERE active`. Strict mode is now enforced. +- **B28 (security)** — the `FIND_IN_SET` / `NOT FIND_IN_SET` case had the + parameterization branch inverted: raw string values were inlined verbatim + into SQL while pre-bound `RawQuery` placeholders were re-parameterized. + This was a latent SQL-injection vector for any caller passing + user-supplied strings to `findInSet()` / `notFindInSet()`. Fixed. +- **V3 (security, hardening)** — `AbstractDriver::escapeIdentifier()` now + rejects identifiers containing `;` or `--`. These sequences never appear + in legitimate identifiers and are the canonical pivot for SQL injection + when a caller forwards user input as a table or column name. The check + runs before the dialect's quoting, so even the no-op `GenericDriver` + gets the defense-in-depth. PostgreSQL — which allows multi-statement + queries by default — was the highest-risk consumer. +- **V4 (security, hardening)** — `WhereClauseTrait::prepareStatement()` + now escapes the SQL wildcard characters (`%`, `_`) and the escape + character itself (`\`) in any user-supplied value flowing through the + LIKE family. Previously, `like('name', '%')` compiled to + `LIKE '%%%'` (equivalent to `LIKE '%'`) — which let any user enumerate + every row by typing `%` into a search box. Opt-out: pass a `RawQuery` + when raw wildcards are deliberately desired. +- **V6 (security, hardening)** — `SqlValueDetector` placeholder regex + tightened from `/^:[(\w)]+$/` to `/^:\w+$/`. The previous character + class spuriously permitted `(` and `)` inside placeholder names — never + a valid PDO bind name. +- **B26** — `BucketCompiler` previously joined the AND-bucket and the + OR-bucket of every WHERE / HAVING / ON clause with `" AND "`, which + silently collapsed every top-level `orX()` chain into an `AND`. A call + like `where('country', 'TR')->orWhere('country', 'US')` compiled to + `country = :a AND country = :b` — clearly not OR semantics. The + connector is now `" OR "`; SQL precedence (`AND > OR`) gives the + expected `(a AND b) OR c` parse for mixed chains. The + pre-existing `testWhereGroupMultipleStatement` test was updated to + pin the corrected behavior. +- The PHP 8.4 implicit-nullable-parameter deprecation on `join($table, $onStmt = null, …)` + is no longer emitted — the parameter is now explicitly typed + `RawQuery|Closure|string|null`. + +[Unreleased]: https://github.com/InitORM/QueryBuilder/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/InitORM/QueryBuilder/releases/tag/v2.0.0 diff --git a/README.md b/README.md index 99bb0af..65c04af 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,215 @@ -# InitORM (QueryBuilder) - -``` -composer require initorm/query-builder -``` - -Test : -``` -php vendor/bin/phpunit -``` \ No newline at end of file +# InitORM QueryBuilder + +[![Packagist Version](https://img.shields.io/packagist/v/initorm/query-builder.svg)](https://packagist.org/packages/initorm/query-builder) +[![Total Downloads](https://img.shields.io/packagist/dt/initorm/query-builder.svg)](https://packagist.org/packages/initorm/query-builder) +[![PHP Version](https://img.shields.io/packagist/php-v/initorm/query-builder.svg)](https://packagist.org/packages/initorm/query-builder) +[![License](https://img.shields.io/packagist/l/initorm/query-builder.svg)](LICENSE) +[![PHPUnit](https://github.com/InitORM/QueryBuilder/actions/workflows/phpunit.yml/badge.svg)](https://github.com/InitORM/QueryBuilder/actions/workflows/phpunit.yml) +[![PHPStan](https://github.com/InitORM/QueryBuilder/actions/workflows/phpstan.yml/badge.svg)](https://github.com/InitORM/QueryBuilder/actions/workflows/phpstan.yml) +[![PHP_CodeSniffer](https://github.com/InitORM/QueryBuilder/actions/workflows/phpcs.yml/badge.svg)](https://github.com/InitORM/QueryBuilder/actions/workflows/phpcs.yml) + +A lightweight, dialect-aware **SQL query builder** for PHP. It turns fluent +calls into a SQL string plus a separate parameter bag suitable for direct +execution with **PDO** — without ever concatenating user values into SQL. + +InitORM QueryBuilder is the lowest layer of the [InitORM](https://github.com/InitORM) +package family; it has **no runtime dependencies** beyond the `pdo` extension +and is designed to be used either standalone or as part of the +`initorm/database` and `initorm/orm` stack. + +## Why this library + +- **Safe by default** — every value goes through a collision-safe parameter + bag. Raw fragments are opt-in via `RawQuery`. +- **Dialect aware** — identifier escaping is delegated to pluggable drivers + for MySQL/MariaDB, PostgreSQL, SQLite, plus a no-op generic driver. +- **Tiny and predictable** — single namespace, no service container, no + reflection, no annotations; the whole thing is around 1 600 lines of code. +- **Battle-tested clause DSL** — comparison operators, BETWEEN, IN, LIKE + family (`like` / `startLike` / `endLike`), NULL checks, REGEXP, SOUNDEX, + FIND\_IN\_SET, sub-queries, parenthesized groups, closure-based JOIN ON + expressions. + +## Requirements + +- **PHP ≥ 8.1** +- **`ext-pdo`** (only needed by the consumer at execution time; the builder + itself does not require an open connection) + +## Installation + +```bash +composer require initorm/query-builder +``` + +## Quick start + +```php +use InitORM\QueryBuilder\QueryBuilder; + +$qb = new QueryBuilder('mysql'); + +$sql = $qb + ->select('u.id', 'u.name') + ->from('users AS u') + ->where('u.status', 1) + ->andWhere('u.country', 'TR') + ->orderBy('u.id', 'DESC') + ->limit(20) + ->generateSelectQuery(); + +// $sql ───────────────────────────────────────────────────────────────── +// SELECT `u`.`id`, `u`.`name` +// FROM `users` AS `u` +// WHERE `u`.`status` = 1 AND `u`.`country` = :country +// ORDER BY `u`.`id` DESC +// LIMIT 20 + +$pdo = new PDO('mysql:host=localhost;dbname=app', 'app', 'secret'); +$stmt = $pdo->prepare($sql); +$stmt->execute($qb->getParameter()->all()); +$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); +``` + +### INSERT + +```php +$qb->from('users')->set([ + 'name' => 'Muhammet', + 'email' => 'info@muhammetsafak.com.tr', + 'created' => $qb->raw('NOW()'), +]); + +echo $qb->generateInsertQuery(); +// INSERT INTO `users` (`name`, `email`, `created`) +// VALUES (:name, :email, NOW()); +``` + +### Sub-query in WHERE IN + +```php +$qb->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id')->from('roles')->where('name', 'admin'); + })); +// SELECT `u`.`name` FROM `users` AS `u` +// WHERE `u`.`id` IN (SELECT `id` FROM `roles` WHERE `name` = :name) +``` + +### Closure-based JOIN ON + +```php +$qb->select('p.title', 'u.name') + ->from('posts AS p') + ->innerJoin('users AS u', function (QueryBuilder $j) { + $j->on('u.id', 'p.user_id') + ->where('u.active', 1); + }); +``` + +### Batch UPDATE (CASE/WHEN) + +```php +$qb->from('posts') + ->set(['id' => 1, 'title' => 'First', 'views' => 100]) + ->set(['id' => 2, 'title' => 'Second', 'views' => 42]); + +echo $qb->generateUpdateBatchQuery('id'); +// UPDATE `posts` +// SET `title` = CASE WHEN `id` = 1 THEN :title WHEN `id` = 2 THEN :title_1 ELSE `title` END, +// `views` = CASE WHEN `id` = 1 THEN 100 WHEN `id` = 2 THEN 42 ELSE `views` END +// WHERE `id` IN (1, 2) +``` + +## Supported drivers + +| String | Driver class | Escape char | +|-----------------------------------|----------------------------------------------|-------------| +| `'mysql'` | `Drivers\MySqlDriver` | `` ` `` | +| `'pgsql'` / `'postgres'` / `'postgresql'` | `Drivers\PostgreSqlDriver` | `"` | +| `'sqlite'` | `Drivers\SqliteDriver` | `` ` `` | +| `null` (or anything unknown) | `Drivers\GenericDriver` (no quoting) | _(none)_ | + +A custom dialect can be added by extending `Drivers\AbstractDriver` and +setting the `NAME` and `ESCAPE_CHAR` class constants. + +## Documentation + +Full developer documentation with runnable examples lives in +[`docs/`](docs/) — see [`docs/en/index.md`](docs/en/index.md) for the table +of contents. + +## Security + +InitORM QueryBuilder is built around the rule **"user input is a value, never +an identifier or a SQL fragment"**. Defenses shipped in 2.0.0: + +- **Identifier hardening** — `escapeIdentifier()` rejects `;` and `--` so + query-breakout characters in a column or table name cannot survive the + escape pass (relevant especially on PostgreSQL, where PDO allows + multi-statement queries by default). +- **LIKE wildcard auto-escape** — `%`, `_`, and `\` inside user-supplied + LIKE values are escaped by default. Opt out with `$qb->raw(...)` when + raw wildcards are intentional. +- **Strict placeholder regex** — placeholder names are now tightly bound + to `^:\w+$`. +- **FIND\_IN\_SET parameter fix (B28)** — a pre-2.0.0 inversion bug + inlined raw user strings as SQL; fixed. + +The full threat model, residual application-level concerns +(`ORDER BY` whitelisting, value-shaped function detection), and a complete +regression suite live in [`docs/en/security.md`](docs/en/security.md) and +[`tests/SecurityTest.php`](tests/SecurityTest.php). + +Report vulnerabilities through the +[organization-wide security policy](https://github.com/InitORM/.github/blob/main/SECURITY.md). + +## Tests, lint, static analysis + +```bash +composer install +composer test # phpunit (with pcov line-coverage summary) +composer cs # PHP_CodeSniffer (PSR-12) +composer cs-fix # phpcbf — auto-fix style violations +composer stan # PHPStan level 6 +composer qa # cs-ci + stan + test +``` + +The repository ships with GitHub Actions workflows under +[`.github/workflows/`](.github/workflows) that run the same checks on every +push and pull request, across the PHP 8.1 → 8.4 matrix. + +Current numbers: **293 tests / 391 assertions / 96.46 % line coverage**. + +## Contributing + +The contribution workflow, code style, and pull-request template are shared +across the InitORM organization. See +[InitORM/.github → CONTRIBUTING](https://github.com/InitORM/.github/blob/main/CONTRIBUTING.md) +and the [PR template](https://github.com/InitORM/.github/blob/main/PULL_REQUEST_TEMPLATE.md). +A short summary: + +1. Branch from `master`. +2. Stick to **PSR-12**; run `composer qa` before opening a PR. +3. Add tests for new behavior — the test suite is the contract. +4. Reference issues with `Fixes #123` / `Refs #123`. + +Security issues should follow the disclosure process in +[InitORM/.github → SECURITY](https://github.com/InitORM/.github/blob/main/SECURITY.md). + +## Versioning + +This package follows [Semantic Versioning](https://semver.org). The +behavioral and structural changes between 1.x and 2.x are listed in +[CHANGELOG.md](CHANGELOG.md). + +## License + +MIT — see [LICENSE](LICENSE). + +## Credits + +Authored and maintained by [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) +<info@muhammetsafak.com.tr>. Issues and contributions are welcome on +[GitHub](https://github.com/InitORM/QueryBuilder). diff --git a/composer.json b/composer.json index b97fe75..0bc0257 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,14 @@ { "name": "initorm/query-builder", - "description": "InitORM QueryBuilder Library", + "description": "Lightweight, dialect-aware SQL query builder for PHP with parameterized output suitable for PDO.", "type": "library", "license": "MIT", + "keywords": ["query-builder", "sql", "pdo", "mysql", "pgsql", "sqlite", "orm", "initorm"], + "homepage": "https://github.com/InitORM/QueryBuilder", + "support": { + "issues": "https://github.com/InitORM/QueryBuilder/issues", + "source": "https://github.com/InitORM/QueryBuilder" + }, "autoload": { "psr-4": { "InitORM\\QueryBuilder\\": "src/" @@ -23,10 +29,24 @@ ], "minimum-stability": "stable", "require": { - "php": ">=8.0", + "php": "^8.1", "ext-pdo": "*" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10", + "phpstan/phpstan": "^1.12" + }, + "scripts": { + "test": "phpunit", + "cs": "phpcs", + "cs-ci": "phpcs --warning-severity=0", + "cs-fix": "phpcbf", + "stan": "phpstan analyse", + "qa": [ + "@cs-ci", + "@stan", + "@test" + ] } } diff --git a/docs/en/api-reference.md b/docs/en/api-reference.md new file mode 100644 index 0000000..2f94d2f --- /dev/null +++ b/docs/en/api-reference.md @@ -0,0 +1,243 @@ +# API reference + +A single-page lookup of every public method on `QueryBuilderInterface`, +plus the related helper classes. For full PHPDoc — including thrown +exceptions, generic types, and per-method examples — see the source. + +Methods that mutate the query structure return `static` so calls chain. +The `compile` and getter family return a `string` or the corresponding +value. + +## QueryBuilder — structure + +| Method | Returns | Purpose | +|------------------------------------------------------------|----------|--------------------------------------------------------| +| `newBuilder()` | `static` | Fresh sibling builder, same driver | +| `clone()` | `static` | PHP-level clone (parameter bag deep-cloned) | +| `importQB(array, bool $merge = false)` | `static` | Replace (or merge) the structure | +| `exportQB()` | `array` | Snapshot the structure | +| `resetStructure(?string\|array $keys = null, ?bool $isIgnore = null)` | `static` | Blank-slate, optionally keep/zero named keys | +| `getParameter()` | `ParameterInterface` | Access the parameter bag | +| `getDriver()` | `DriverInterface` | Access the active dialect driver | +| `setParameter(string $key, mixed $value)` | `static` | Overwrite a single parameter | +| `setParameters(array $parameters)` | `static` | Bulk-overwrite parameters | + +## QueryBuilder — projection + +| Method | Returns | Purpose | +|------------------------------------------------------------|----------|--------------------------------------------------------| +| `select(string\|RawQuery ...$columns)` | `static` | Append projection(s) | +| `clearSelect()` | `static` | Empty the projection list | +| `selectAs(col, alias)` | `static` | `col AS alias` | +| `selectCount(col, ?alias = null)` | `static` | `COUNT(col)` / aliased | +| `selectCountDistinct(col, ?alias = null)` | `static` | `COUNT(DISTINCT col)` | +| `selectMax / selectMin / selectAvg / selectSum` | `static` | Aggregate projections | +| `selectUpper / selectLower / selectLength` | `static` | String-function projections | +| `selectMid(col, offset, length, ?alias)` | `static` | `MID(col, offset, length)` — MySQL | +| `selectLeft(col, length, ?alias)` / `selectRight(col, length, ?alias)` | `static` | MySQL substring helpers | +| `selectDistinct(col, ?alias)` | `static` | `DISTINCT(col)` | +| `selectCoalesce(col, default = '0', ?alias)` | `static` | `COALESCE(col, default)` — default escaped if non-numeric string | +| `selectConcat(array $columns, ?alias)` | `static` | `CONCAT(col, col, …)` | + +## QueryBuilder — FROM / table + +| Method | Returns | Purpose | +|-----------------------------------------|----------|--------------------------------------| +| `from(table, ?alias = null)` | `static` | Set FROM to a single table | +| `addFrom(table, ?alias = null)` | `static` | Append to FROM (comma-separated) | +| `table(table)` | `static` | Alias of `from()` without alias arg | + +## QueryBuilder — grouping & ordering + +| Method | Returns | Purpose | +|-----------------------------------------|----------|--------------------------------------| +| `groupBy(...$columns)` | `static` | Append GROUP BY columns (arrays flattened) | +| `orderBy(col, dir = 'ASC')` | `static` | Append ORDER BY (`'ASC'` / `'DESC'` only) | +| `offset(int = 0)` | `static` | Set OFFSET (`abs()`-normalized) | +| `limit(int)` | `static` | Set LIMIT (`abs()`-normalized) | + +## QueryBuilder — JOIN + +| Method | Returns | Emits | +|-----------------------------------------|----------|--------------------------------------| +| `join(table, $on = null, type = 'INNER')` | `static` | Generic — `type` is the SQL keyword | +| `innerJoin(table, $on)` | `static` | `INNER JOIN … ON …` | +| `leftJoin(table, $on)` | `static` | `LEFT JOIN … ON …` | +| `rightJoin(table, $on)` | `static` | `RIGHT JOIN … ON …` | +| `leftOuterJoin(table, $on)` | `static` | `LEFT OUTER JOIN … ON …` | +| `rightOuterJoin(table, $on)` | `static` | `RIGHT OUTER JOIN … ON …` | +| `naturalJoin(table)` | `static` | `NATURAL JOIN …` (no ON) | +| `selfJoin(table, $on)` | `static` | Comma FROM + ON-as-WHERE | + +ON arguments accept `string`, `RawQuery`, or `Closure(QueryBuilder)`. + +## QueryBuilder — WHERE / HAVING / ON + +The signature shared by all three: + +``` +where (col, op = '=', val = null, logical = 'AND') +having (col, op = '=', val = null, logical = 'AND') +on (col, op = '=', val = null, logical = 'AND') +``` + +| Convenience method | Equivalent to | +|------------------------------------------|----------------------------------------------| +| `andWhere(col, op, val)` | `where(col, op, val, 'AND')` | +| `orWhere(col, op, val)` | `where(col, op, val, 'OR')` | + +### NULL + +| Helper | Bucket | +|------------------------------------------|----------| +| `whereIsNull(col, logical = 'AND')` | dispatch | +| `andWhereIsNull(col)` / `orWhereIsNull(col)` | AND / OR | +| `whereIsNotNull(col, logical = 'AND')` | dispatch | +| `andWhereIsNotNull(col)` / `orWhereIsNotNull(col)` | AND / OR | + +### BETWEEN + +| Helper | Emits | +|-----------------------------------------------------|------------------------| +| `between(col, $a, $b, logical = 'AND')` | `col BETWEEN a AND b` | +| `andBetween(col, $a, $b)` / `orBetween(col, $a, $b)`| AND / OR variants | +| `notBetween(col, $a, $b, logical = 'AND')` | `col NOT BETWEEN …` | +| `andNotBetween / orNotBetween` | AND / OR variants | + +Two-arg form `between(col, [a, b])` is also supported. + +### IN + +| Helper | Emits | +|----------------------------------------------|-------------------| +| `whereIn(col, $vals, logical = 'AND')` | `col IN (…)` | +| `whereNotIn(col, $vals, logical = 'AND')` | `col NOT IN (…)` | +| `andWhereIn / orWhereIn` | AND / OR variants | +| `andWhereNotIn / orWhereNotIn` | AND / OR variants | + +Numeric items inlined; strings parameterized; `RawQuery` passed through. + +### LIKE family + +| Helper | Pattern shape | NOT? | +|-------------------------------------------------------|-----------------|-------| +| `like(col, val, type = 'both', logical = 'AND')` | per `type` | no | +| `orLike / andLike` | per `type` | no | +| `notLike(col, val, type = 'both', logical = 'AND')` | per `type` | yes | +| `orNotLike / andNotLike` | per `type` | yes | +| `startLike(col, val, logical = 'AND')` | `val%` | no | +| `orStartLike / andStartLike` | `val%` | no | +| `notStartLike(col, val, logical = 'AND')` | `val%` | yes | +| `orStartNotLike / andStartNotLike` | `val%` | yes | +| `endLike(col, val, logical = 'AND')` | `%val` | no | +| `orEndLike / andEndLike` | `%val` | no | +| `notEndLike(col, val, logical = 'AND')` | `%val` | yes | +| `orEndNotLike / andEndNotLike` | `%val` | yes | + +`like(col, val, 'both')` → `%val%`. `'before'` / `'start'` → `val%`. +`'after'` / `'end'` → `%val`. + +### REGEXP / SOUNDEX + +| Helper | Emits | +|---------------------------------------------------|--------------------------------------------| +| `regexp(col, val, logical = 'AND')` | `col REGEXP …` — MySQL | +| `andRegexp / orRegexp` | AND / OR variants | +| `soundex(col, val, logical = 'AND')` | `SOUNDEX(col) LIKE CONCAT('%', TRIM(...))` | +| `andSoundex / orSoundex` | AND / OR variants | + +### FIND\_IN\_SET (MySQL) + +| Helper | Emits | +|-------------------------------------------------------|----------------------------------| +| `findInSet(col, val, logical = 'AND')` | `FIND_IN_SET(val, col)` | +| `notFindInSet(col, val, logical = 'AND')` | `NOT FIND_IN_SET(val, col)` | +| `andFindInSet / orFindInSet` | AND / OR variants | +| `andNotFindInSet / orNotFindInSet` | AND / OR variants | + +### Grouping & sub-queries + +| Method | Returns | +|-------------------------------------------------------|---------------| +| `group(Closure $c, logical = 'AND')` | `static` | +| `subQuery(Closure $c, ?alias = null, bool $isInterval = true)` | `RawQuery` | +| `raw(mixed)` | `RawQuery` | + +## QueryBuilder — SET (INSERT / UPDATE) + +| Method | Returns | +|-----------------------------------------------------|----------| +| `set(col, val = null, strict = true)` | `static` | +| `addSet(col, val = null, strict = true)` | `static` | + +`$column` may be `string`, `RawQuery`, or an associative array (the +"full row" form, in which case `$value` must be omitted). + +## QueryBuilder — compile + +| Method | Returns | +|--------------------------------------------------------------|----------| +| `generateSelectQuery(array $selector = [], array $conditions = [])` | `string` | +| `generateInsertQuery()` | `string` | +| `generateBatchInsertQuery()` | `string` | +| `generateUpdateQuery()` | `string` | +| `generateUpdateBatchQuery(string $referenceColumn)` | `string` | +| `generateDeleteQuery()` | `string` | +| `__toString()` | `string` (heuristic dispatch) | +| `isBatch()` | `bool` (any SET row has >1 column) | + +All `generate*Query()` methods may throw `QueryBuilderException`. + +## RawQuery + +| Method | Returns | +|-----------------------------------------|--------------| +| `__construct(mixed $rawQuery)` | | +| `set(mixed $rawQuery)` | `self` | +| `get()` | `string` | +| `__toString()` | `string` | +| `RawQuery::raw(mixed)` | `RawQuery` | + +## Parameters (`ParameterInterface`) + +| Method | Returns | +|-----------------------------------------|--------------| +| `set(string $key, mixed $value)` | `self` | +| `add(string\|RawQuery $key, mixed $value)` | `string` placeholder name or `'NULL'` | +| `get(?string $key = null, mixed $default = null)` | full map or single value or default | +| `all()` | `array` | +| `merge(array\|ParameterInterface ...)` | `self` | +| `reset()` | `self` | + +## Drivers (`DriverInterface`) + +| Method | Returns | +|-----------------------------------------|--------------| +| `escapeIdentifier(string)` | `string` (pure) | +| `getName()` | `?string` | + +Built-in drivers: `GenericDriver`, `MySqlDriver`, `PostgreSqlDriver`, +`SqliteDriver`. All extend `AbstractDriver`. + +## Factory + +| Method | Returns | +|-----------------------------------------|--------------------------| +| `QueryBuilderFactory::createQueryBuilder(?string $driver = null)` | `QueryBuilderInterface` | + +## Exceptions + +| Class | Extends | +|----------------------------------------------------------------|------------------------| +| `Exceptions\QueryBuilderException` | `\Exception` | +| `Exceptions\QueryBuilderInvalidArgumentException` | `\InvalidArgumentException` | + +`QueryBuilderException` is raised for structural problems (missing +table, missing data, alias-on-non-interval sub-query, batch reference +column missing). `QueryBuilderInvalidArgumentException` is raised for +malformed input (unknown logical connector, non-`ASC|DESC` sort +direction). + +--- + +**Back to the index:** [Table of contents →](index.md) diff --git a/docs/en/drivers.md b/docs/en/drivers.md new file mode 100644 index 0000000..7f083d5 --- /dev/null +++ b/docs/en/drivers.md @@ -0,0 +1,211 @@ +# Drivers + +A driver is responsible for **identifier escaping** — quoting table and +column names, alias references, and dotted paths — and reporting its +canonical name. Nothing else; the dialect-specific clause grammar lives +in the compilers. + +## The four built-ins + +| Driver string | Class | Escape char | Notes | +|----------------------------------------------|--------------------------------------|-------------|-------| +| `'mysql'` | `Drivers\MySqlDriver` | `` ` `` | | +| `'pgsql'` / `'postgres'` / `'postgresql'` | `Drivers\PostgreSqlDriver` | `"` | | +| `'sqlite'` | `Drivers\SqliteDriver` | `` ` `` | Same as MySQL — SQLite accepts both. | +| `null` (or anything unknown) | `Drivers\GenericDriver` | _(none)_ | No identifier quoting — pass-through. | + +```php +use InitORM\QueryBuilder\QueryBuilder; + +new QueryBuilder('mysql'); // → MySqlDriver +new QueryBuilder('pgsql'); // → PostgreSqlDriver +new QueryBuilder('sqlite'); // → SqliteDriver +new QueryBuilder(); // → GenericDriver +new QueryBuilder('unknown'); // → GenericDriver +``` + +## The contract + +```php +interface DriverInterface +{ + public function escapeIdentifier(string $identifier): string; + public function getName(): ?string; +} +``` + +- **`escapeIdentifier()`** is pure — the input is returned (possibly + modified) but never mutated. +- **`getName()`** returns the canonical lowercase name, or `null` for + drivers that don't apply a dialect. + +## What the escape regex does + +The escape implementation (`AbstractDriver::escapeIdentifier()`) uses a +single regex with three pieces of behavior: + +1. **Identifier-shaped tokens** (`[a-zA-Z_][a-zA-Z0-9_]*`) get wrapped + with the escape char. +2. **Bind-parameter prefixes** (`:foo`) are skipped — `:foo` stays + `:foo`, never `` :`foo` ``. +3. **SQL keywords** AND, OR, AS, ON (both cases) are skipped. +4. **Pre-existing escape characters** inside the identifier are doubled + (the standard SQL "escape the escape" rule). + +Examples (using `MySqlDriver`, with `` ` `` as the escape char): + +```php +$d = new MySqlDriver(); + +$d->escapeIdentifier('id'); // `id` +$d->escapeIdentifier('users.id'); // `users`.`id` +$d->escapeIdentifier('users AS u'); // `users` AS `u` +$d->escapeIdentifier('a.id AND b.id'); // `a`.`id` AND `b`.`id` +$d->escapeIdentifier(':bind_value'); // :bind_value (untouched) +$d->escapeIdentifier('weird`name'); // `weird``name` (escape doubled) +``` + +Numeric literals (digit-leading tokens) are NOT matched by the regex — +they pass through unquoted. That's intentional — it means string +fragments like `x = 1 OR y = 2` only quote the identifiers: + +```php +$d->escapeIdentifier('x=1 OR y=2'); // `x`=1 OR `y`=2 +``` + +## When the driver is invoked + +Every public clause builder that takes an identifier-shaped argument +runs it through `escapeIdentifier()` before storing it in the structure: + +```php +$qb = new QueryBuilder('mysql'); +$qb->from('users AS u')->where('u.country', 'TR'); +$qb->exportQB()['table']; +// [ '`users` AS `u`' ] +``` + +By the time the structure is compiled, every identifier is already +quoted. The compilers themselves do no quoting. + +For projections that build a function call (`COUNT(...)`, `MAX(...)`, +…), only the column argument is escaped — the function keyword stays +unquoted: + +```php +$qb->selectMax('age'); +$qb->exportQB()['select']; +// [ 'MAX(`age`)' ] +``` + +## Adding a custom driver + +Extend `AbstractDriver` and override the two class constants: + +```php +namespace App\Db; + +use InitORM\QueryBuilder\Drivers\AbstractDriver; + +final class OracleDriver extends AbstractDriver +{ + protected const NAME = 'oracle'; + protected const ESCAPE_CHAR = '"'; +} +``` + +Use it by constructing a builder with your driver directly — the +constructor's match expression only knows about the four built-ins: + +```php +$qb = new QueryBuilder(); // GenericDriver +// Swap the driver via reflection / a subclass, or compose the builder yourself. +``` + +Or extend `QueryBuilder` if you want first-class support for your driver +string: + +```php +namespace App\Db; + +use InitORM\QueryBuilder\QueryBuilder as BaseBuilder; + +final class QueryBuilder extends BaseBuilder +{ + public function __construct(?string $driver = null) + { + parent::__construct($driver); + if ($driver === 'oracle') { + $this->driver = new OracleDriver(); + } + } +} +``` + +## A custom driver with non-trivial escaping + +Override `escapeIdentifier()` directly if your dialect needs more than +a single escape character. Below: a hypothetical driver that +upper-cases identifiers and uses square brackets (SQL Server style): + +```php +final class SqlServerDriver extends AbstractDriver +{ + protected const NAME = 'sqlsrv'; + protected const ESCAPE_CHAR = ''; // disable the default + + public function escapeIdentifier(string $identifier): string + { + return preg_replace( + '/\b(?select('u.id', 'u.name') + ->from('users AS u') + ->where('u.country', 'TR') + ->generateSelectQuery(); +}; + +$build(null); +// SELECT u.id, u.name FROM users AS u WHERE u.country = :u_country + +$build('mysql'); +// SELECT `u`.`id`, `u`.`name` FROM `users` AS `u` WHERE `u`.`country` = :u_country + +$build('pgsql'); +// SELECT "u"."id", "u"."name" FROM "users" AS "u" WHERE "u"."country" = :u_country + +$build('sqlite'); +// SELECT `u`.`id`, `u`.`name` FROM `users` AS `u` WHERE `u`.`country` = :u_country +``` + +## Reading the active driver + +`QueryBuilder::getDriver()` returns the live `DriverInterface` instance — +handy when you want to escape an identifier yourself in a raw fragment: + +```php +$col = $qb->getDriver()->escapeIdentifier('users.id'); +$qb->where($qb->raw($col . ' = ' . $qb->raw('NOW()'))); +``` + +`getDriver()->getName()` exposes the driver's name (or `null` for the +generic driver) and is what `newBuilder()` uses to propagate the +dialect to a sibling builder. + +**Next:** [Security →](security.md) diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md new file mode 100644 index 0000000..0f2b22b --- /dev/null +++ b/docs/en/getting-started.md @@ -0,0 +1,165 @@ +# Getting started + +This page walks you through installing the library, building your first +query, and executing it against a live database with PDO. Five minutes, +end to end. + +## Requirements + +- **PHP ≥ 8.1** +- **`ext-pdo`** — only the consumer needs it; the builder itself never + touches a connection. + +## Install + +```bash +composer require initorm/query-builder +``` + +The package is **dependency-free at runtime**. It pulls in `phpunit`, +`squizlabs/php_codesniffer` and `phpstan/phpstan` only as `require-dev`. + +## Hello, query + +```php +use InitORM\QueryBuilder\QueryBuilder; + +require __DIR__ . '/vendor/autoload.php'; + +$qb = new QueryBuilder('mysql'); + +$qb->select('id', 'name') + ->from('users') + ->where('status', 1); + +echo $qb->generateSelectQuery(); +// SELECT `id`, `name` FROM `users` WHERE `status` = 1 +``` + +The constructor accepts a driver name (`'mysql'`, `'pgsql'`/`'postgres'`/ +`'postgresql'`, `'sqlite'`) or `null` for the no-op generic driver. Pick +the one that matches your target database; the only behavior that changes +is identifier quoting. + +## The factory + +For dependency-injected setups, use the factory instead of `new`: + +```php +use InitORM\QueryBuilder\QueryBuilderFactory; + +$factory = new QueryBuilderFactory(); + +$qb = $factory->createQueryBuilder('pgsql'); +// instance of QueryBuilderInterface, configured for PostgreSQL +``` + +Reuse the same factory across requests; it carries no state. + +## Executing the SQL with PDO + +The builder returns a SQL string and stores bound values in a separate +[parameter bag](parameters.md). You hand both to PDO: + +```php +$pdo = new PDO( + 'mysql:host=localhost;dbname=app;charset=utf8mb4', + 'app', + 'secret', + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], +); + +$qb->select('id', 'name', 'email') + ->from('users') + ->where('status', 1) + ->andWhere('country', 'TR') + ->orderBy('id', 'DESC') + ->limit(20); + +$sql = $qb->generateSelectQuery(); +$parameters = $qb->getParameter()->all(); + +$stmt = $pdo->prepare($sql); +$stmt->execute($parameters); + +foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + var_dump($row); +} +``` + +The parameters bag is just an array keyed by placeholder name: + +```php +$qb->getParameter()->all(); +// [ +// ':country' => 'TR', +// ] +``` + +> ⚠️ Integer values (and a handful of other "safe" forms — see +> [Parameters](parameters.md) for the full list) are **inlined directly** +> into the SQL rather than parameterized. That keeps simple lookups +> readable while still routing every user-supplied string through PDO. + +## Re-using a builder + +Every public method that mutates the structure returns `$this`, so chaining +is the usual style. When you want to start over without throwing away the +builder, reset its structure: + +```php +$qb->resetStructure(); // blank-slate +$qb->select('id')->from('posts'); +``` + +To carry the structure across calls, snapshot it: + +```php +$snapshot = $qb->exportQB(); +// later … +$qb->importQB($snapshot); +``` + +To spawn a sibling builder configured for the same dialect: + +```php +$other = $qb->newBuilder(); // fresh structure, same driver +``` + +## A first INSERT and UPDATE + +```php +$qb->resetStructure() + ->from('users') + ->set([ + 'name' => 'Muhammet', + 'email' => 'info@muhammetsafak.com.tr', + ]); + +echo $qb->generateInsertQuery(); +// INSERT INTO `users` (`name`, `email`) VALUES (:name, :email); + +$qb->resetStructure() + ->from('users') + ->where('id', 5) + ->set(['name' => 'Updated']); + +echo $qb->generateUpdateQuery(); +// UPDATE `users` SET `name` = :name WHERE `id` = 5 +``` + +See [INSERT / UPDATE / DELETE](insert-update-delete.md) for batch shapes, +`CASE / WHEN`-based batch updates, and the edge-case error paths. + +## What's next + +- The [SELECT chapter](select.md) covers every projection helper and the + ordering / pagination knobs. +- The [WHERE chapter](where.md) is where most of the API surface lives — + comparison operators, BETWEEN, IN, the LIKE family, NULL checks, REGEXP, + SOUNDEX, FIND\_IN\_SET. +- For complex queries, jump to [Sub-queries](subqueries.md), + [JOINs](joins.md) and [Grouped conditions](grouping.md). + +Need a quick lookup? The [API reference](api-reference.md) lists every +public method on one page. diff --git a/docs/en/grouping.md b/docs/en/grouping.md new file mode 100644 index 0000000..bb3b538 --- /dev/null +++ b/docs/en/grouping.md @@ -0,0 +1,157 @@ +# Grouped conditions + +For parenthesized WHERE / HAVING / ON sub-expressions, use `group()`. It +wraps a sub-expression in parentheses and folds it into the chosen +bucket (AND or OR). + +## Signature + +```php +public function group(Closure $closure, string $logical = 'AND'): static +``` + +- The closure receives a fresh sub-builder. Its WHERE, HAVING and ON + buckets are each independently wrapped in parentheses and merged back. +- `$logical` is `'AND'` (default) or `'OR'`. `'&&'` / `'||'` aliases are + accepted. +- Anything else raises `QueryBuilderException`. + +## A single grouped block + +```php +$qb->from('users') + ->where('status', 1) + ->group(function (QueryBuilder $g) { + $g->where('type', 3) + ->where('type', 4); + }); + +echo $qb->generateSelectQuery(); +// SELECT * FROM `users` +// WHERE `status` = 1 AND (`type` = 3 AND `type` = 4) +``` + +## OR-grouped block + +```php +$qb->from('users') + ->where('status', 1) + ->group(function (QueryBuilder $g) { + $g->where('country', 'TR') + ->where('country', 'US'); + }, 'OR'); +// Buckets: +// where['AND'] = ['status = 1'] +// where['OR'] = ['(country = TR AND country = US)'] +// +// Compiled — AND-bucket and OR-bucket joined by " OR ": +// WHERE `status` = 1 OR (`country` = 'TR' AND `country` = 'US') +``` + +## Nested groups + +`group()` can be nested arbitrarily deep — each closure spawns its own +sub-builder: + +```php +$qb->select('id', 'title', 'content', 'url') + ->from('posts') + ->where('status', 1) + ->group(function (QueryBuilder $g1) { + $g1->where('user_id', 1) + ->where('datetime', '>=', date('Y-m-d')); + }, 'or') + ->group(function (QueryBuilder $g2) { + $g2->group(function (QueryBuilder $g3) { + $g3->where('id', 2)->where('status', 3); + }, 'or') + ->group(function (QueryBuilder $g4) { + $g4->where('id', 4)->where('status', 5); + }, 'or'); + }, 'or'); +// SELECT `id`, `title`, `content`, `url` FROM `posts` +// WHERE `status` = 1 +// AND (`user_id` = 1 AND `datetime` >= :datetime) +// OR ((`id` = 2 AND `status` = 3) OR (`id` = 4 AND `status` = 5)) +``` + +## Grouping with HAVING and ON + +The closure's HAVING and ON buckets are folded independently — useful in +JOIN closures: + +```php +$qb->from('posts AS p') + ->innerJoin('users AS u', function (QueryBuilder $j) { + $j->on('u.id', 'p.user_id') + ->group(function (QueryBuilder $g) { + $g->where('u.country', 'TR') + ->where('u.country', 'US'); + }, 'OR') + ->having($j->raw('COUNT(p.id) > 5')); + }); +// FROM `posts` AS `p` +// INNER JOIN `users` AS `u` ON `u`.`id` = `p`.`user_id` +// WHERE (`u`.`country` = :u_country AND `u`.`country` = :u_country_1) +// HAVING COUNT(p.id) > 5 +``` + +## How the AND / OR buckets compile + +Internally, each WHERE / HAVING / ON clause is filed into one of two +sub-lists per bucket — `AND` or `OR`. The bucket compiler joins them as: + +```text + + + (if both lists non-empty: " OR ") + + +``` + +SQL precedence (`AND` binds tighter than `OR`) parses +`a AND b OR c` as `(a AND b) OR c`, which matches the natural reading +of a fluent chain like `where(a).where(b).orWhere(c)`: + +```php +$qb->from('post') + ->where('a', 1) + ->where('b', 2) + ->orWhere('c', 3); +// WHERE `a` = 1 AND `b` = 2 OR `c` = 3 +// (parsed: (`a` = 1 AND `b` = 2) OR `c` = 3) +``` + +A simpler chain — one AND clause + one OR clause: + +```php +$qb->from('users') + ->where('country', 'TR') + ->orWhere('country', 'US'); +// WHERE `country` = :country OR `country` = :country_1 +``` + +> ℹ️ This was previously buggy (tracked as B26): the inter-bucket +> connector was hard-coded to `" AND "`, which silently collapsed every +> top-level `orX()` chain into an `AND`. The 2.0.0 release fixes it. + +If you need something other than the precedence default — e.g. +`a OR (b AND c)` — use `group()` to introduce explicit parentheses: + +```php +$qb->from('post') + ->where('a', 1) + ->group(function (QueryBuilder $g) { + $g->where('b', 2)->where('c', 3); + }, 'OR'); +// WHERE `a` = 1 OR (`b` = 2 AND `c` = 3) +``` + +## Quick lookup + +| You want… | Use | +|----------------------------------------|--------------------------------| +| `WHERE a AND (b AND c)` | `where(a).group(fn ($g) => $g->where(b)->where(c))` | +| `WHERE a AND (b OR c)` | `where(a).group(fn ($g) => $g->where(b)->orWhere(c))` | +| `WHERE a OR (b)` | `where(a).group(fn ($g) => $g->where(b), 'OR')` | +| `WHERE (a OR b) AND (c OR d)` | Two `group(..., 'OR')` chained — see "Nested groups" | + +**Next:** [Raw queries →](raw-queries.md) diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 0000000..d5136cc --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,127 @@ +# InitORM QueryBuilder — Developer Guide + +InitORM QueryBuilder is a small, dialect-aware SQL query builder for PHP. +It turns a fluent chain of method calls into a SQL string and a separate +[parameter bag](parameters.md) that you hand to PDO at execution time. + +## Table of contents + +1. **[Getting started](getting-started.md)** — install, the first query, + factories, executing the generated SQL with PDO. +2. **[SELECT](select.md)** — projection helpers (`select*`), aggregates + (`COUNT`, `SUM`, …), `GROUP BY`, `ORDER BY`, `LIMIT` / `OFFSET`. +3. **[WHERE / HAVING / ON](where.md)** — the comparison matrix, the + value-shortcut, `BETWEEN`, `IN`, the LIKE family, NULL checks, REGEXP, + SOUNDEX, FIND\_IN\_SET, and AND / OR connectors. +4. **[JOINs](joins.md)** — `innerJoin`, `leftJoin`, `rightJoin`, + `leftOuterJoin`, `rightOuterJoin`, `selfJoin`, `naturalJoin`, plus the + closure-based ON form that folds WHERE / HAVING side-conditions back into + the outer query. +5. **[INSERT, UPDATE, DELETE](insert-update-delete.md)** — `set` / `addSet`, + single-row + batch INSERT, single-row UPDATE, the `CASE / WHEN`-based + batch UPDATE, DELETE. +6. **[Sub-queries](subqueries.md)** — `subQuery()` as a `WHERE IN` value, + as a derived `FROM` table, inside a JOIN, or as a stand-alone fragment. +7. **[Grouped conditions](grouping.md)** — `group()` for parenthesized + WHERE / HAVING / ON sub-expressions and the AND-vs-OR connector caveat. +8. **[Raw queries](raw-queries.md)** — when and how to use `RawQuery` to + inline a SQL fragment without escaping or parameter binding. +9. **[Parameters](parameters.md)** — the parameter bag (`Parameters`), + collision auto-suffixing, the NULL short-circuit, RawQuery key hashing, + plugging the bag into PDO. +10. **[Drivers](drivers.md)** — built-in dialects (MySQL, PostgreSQL, + SQLite, generic), identifier escape rules, and how to write a custom + driver. +11. **[Security](security.md)** — threat model, defenses shipped in + v2.0.0, application-level residual risks, and the safe-patterns + cookbook. +12. **[Recipes](recipes.md)** — common scenarios distilled into runnable + snippets: pagination, soft-delete, dynamic filters, upsert, ranking. +13. **[API reference](api-reference.md)** — a categorized table of every + public method exposed by `QueryBuilderInterface`. + +## How this documentation is structured + +Every chapter ships **runnable PHP snippets** followed by the SQL they +generate. Examples use the `mysql` driver by default (with backtick +quoting); the same calls produce equivalent SQL under the other drivers, +only the quoting character changes. See [drivers.md](drivers.md) for the +side-by-side comparison. + +A typical example: + +```php +use InitORM\QueryBuilder\QueryBuilder; + +$qb = new QueryBuilder('mysql'); +$qb->select('id', 'name') + ->from('users') + ->where('status', 1); + +echo $qb->generateSelectQuery(); +// SELECT `id`, `name` FROM `users` WHERE `status` = 1 +``` + +## Namespace map + +``` +InitORM\QueryBuilder\ +├─ QueryBuilder — the fluent facade (see this guide top-to-bottom) +├─ QueryBuilderInterface — the public contract +├─ QueryBuilderFactory — driver-string → builder +├─ QueryBuilderFactoryInterface +├─ Parameters — the bound-parameter bag +├─ ParameterInterface +├─ RawQuery — inline SQL fragment (escape bypass) +│ +├─ Clause\ +│ ├─ StructureTrait — structure access, clone, import/export +│ ├─ FromClauseTrait — table / from / addFrom +│ ├─ SelectClauseTrait — select* / groupBy / orderBy / limit / offset +│ ├─ JoinClauseTrait — join / inner / left / right / outer / self / natural +│ ├─ WhereClauseTrait — where / having / on + sugar +│ └─ SetClauseTrait — set / addSet +│ +├─ Compiler\ +│ ├─ CompilerInterface — marker +│ ├─ AbstractCompiler — shared compile helpers +│ ├─ SelectCompiler +│ ├─ InsertCompiler +│ ├─ BatchInsertCompiler +│ ├─ UpdateCompiler +│ ├─ BatchUpdateCompiler +│ └─ DeleteCompiler +│ +├─ Drivers\ +│ ├─ DriverInterface — dialect contract +│ ├─ AbstractDriver +│ ├─ GenericDriver — no-op default +│ ├─ MySqlDriver +│ ├─ PostgreSqlDriver +│ └─ SqliteDriver +│ +├─ Operator\ +│ └─ Operators — operator-set constants +│ +├─ Helper\ +│ ├─ SqlValueDetector — placeholder / function / RawQuery detection +│ └─ BucketCompiler — AND / OR clause joining +│ +└─ Exceptions\ + ├─ QueryBuilderException + └─ QueryBuilderInvalidArgumentException +``` + +## Conventions in this guide + +- **Code blocks**: PHP snippets that compile to SQL are followed by the + generated SQL as a `// comment` (or in a separate `sql` block when the + query is long). +- **Bound parameters** are shown in their canonical `:foo` / `:foo_1` form + — every example skips the PDO `execute()` call unless the example is + specifically about parameter retrieval. +- **Driver hints**: where the chosen driver matters (identifier quoting, + dialect-specific functions), the example notes it inline. +- **Caveats** are called out with a leading `> ⚠️` block. + +Ready to start? Open **[Getting started →](getting-started.md)** diff --git a/docs/en/insert-update-delete.md b/docs/en/insert-update-delete.md new file mode 100644 index 0000000..9a51cde --- /dev/null +++ b/docs/en/insert-update-delete.md @@ -0,0 +1,231 @@ +# INSERT, UPDATE, DELETE + +The non-SELECT shapes are all driven by **`set()`** / **`addSet()`** for +data and **`where()`** for filters. Which compile method you call decides +whether you get an INSERT, UPDATE, or batch variant. + +## INSERT (single row) + +`set()` accepts either an associative array (full row) or two arguments +(single column / value): + +```php +$qb->from('users')->set([ + 'name' => 'Muhammet', + 'email' => 'info@muhammetsafak.com.tr', + 'status' => true, +]); + +echo $qb->generateInsertQuery(); +// INSERT INTO `users` (`name`, `email`, `status`) +// VALUES (:name, :email, :status); +``` + +Two-argument form: + +```php +$qb->from('counter')->set('value', 42); +echo $qb->generateInsertQuery(); +// INSERT INTO `counter` (`value`) VALUES (42); +``` + +(Integer values are inlined — see [Parameters](parameters.md).) + +## INSERT (batch) + +Call `set()` multiple times — each call appends one row: + +```php +$qb->from('post') + ->set([ + 'title' => 'Post Title #1', + 'content' => 'Body #1', + 'author' => 5, + 'status' => true, + ]) + ->set([ + 'title' => 'Post Title #2', + 'content' => 'Body #2', + 'status' => false, + ]); + +echo $qb->generateBatchInsertQuery(); +// INSERT INTO `post` (`title`, `content`, `author`, `status`) VALUES +// (:title, :content, 5, :status), +// (:title_1, :content_1, NULL, :status_1); +``` + +Missing columns (here `author` in row 2) are compiled to the literal +`NULL`. Bound parameters are auto-suffixed to avoid collisions — +`:title`, `:title_1`, `:title_2`, … + +## INSERT errors + +| Condition | Exception | +|---------------------------------|----------------------------| +| `set()` never called | `QueryBuilderException`: *The data set for the insert could not be found.* | +| `from()` / `table()` never called | `QueryBuilderException`: *Table name not found when query.* | + +```php +$qb->from('users'); +$qb->generateInsertQuery(); +// → QueryBuilderException: The data set for the insert could not be found. +``` + +## UPDATE (single row) + +Combine `set()` with `where()`: + +```php +$qb->from('post') + ->where('status', '=', true) + ->limit(5) + ->set([ + 'title' => 'New Title', + 'status' => false, + ]); + +echo $qb->generateUpdateQuery(); +// UPDATE `post` +// SET `title` = :title, `status` = :status_1 +// WHERE `status` = :status +// LIMIT 5 +``` + +A WHERE-less UPDATE compiles to `WHERE 1` — intentional, callers gate as +needed: + +```php +$qb->from('post')->set(['title' => 'updated']); +echo $qb->generateUpdateQuery(); +// UPDATE `post` SET `title` = :title WHERE 1 +``` + +## UPDATE (batch — CASE / WHEN) + +When you need to update many rows with **per-row values**, +`generateUpdateBatchQuery($referenceColumn)` builds a CASE/WHEN +expression keyed by the reference column: + +```php +$qb->from('post') + ->where('status', '=', true) + ->set(['id' => 5, 'title' => 'New Title #5', 'content' => 'New Content #5']) + ->set(['id' => 10, 'title' => 'New Title #10']); + +echo $qb->generateUpdateBatchQuery('id'); +// UPDATE `post` +// SET `title` = CASE WHEN `id` = 5 THEN :title WHEN `id` = 10 THEN :title_1 ELSE `title` END, +// `content` = CASE WHEN `id` = 5 THEN :content ELSE `content` END +// WHERE `status` = :status AND `id` IN (5, 10) +``` + +Notes on the shape: + +- Every row in `set()` **must** contain the reference column — otherwise + the call raises *The reference column does not exist in one or more of + the set arrays*. +- Columns missing from a row keep their existing value via the + `ELSE column END` tail. +- The batch update appends a `WHERE … IN (...)` filter for the reference + column on top of any WHERE you supplied. + +## UPDATE errors + +| Condition | Exception | +|---------------------------------|----------------------------| +| `set()` never called | `QueryBuilderException`: *The data set for the update could not be found.* | +| Reference column missing in any row | `QueryBuilderException`: *The reference column does not exist in one or more of the set arrays.* | + +## DELETE + +```php +$qb->from('post') + ->where('authorId', '=', 5) + ->limit(100); + +echo $qb->generateDeleteQuery(); +// DELETE FROM `post` WHERE `authorId` = 5 LIMIT 100 +``` + +Multi-condition DELETE: + +```php +$qb->from('post') + ->where('status', 1) + ->where('author_id', 5); +// DELETE FROM `post` WHERE `status` = 1 AND `author_id` = 5 +``` + +WHERE-less DELETE compiles to `WHERE 1` — same convention as UPDATE: + +```php +$qb->from('post'); +echo $qb->generateDeleteQuery(); +// DELETE FROM `post` WHERE 1 +``` + +> ⚠️ A `WHERE 1` DELETE will wipe the table. The builder does not gate +> for you; callers are responsible for ensuring a WHERE clause is in place +> for non-truncating deletes. + +## DELETE errors + +| Condition | Exception | +|---------------------------------|----------------------------| +| `from()` / `table()` never called | `QueryBuilderException`: *Table name not found when query.* | + +## The `__toString()` heuristic + +For convenience, `(string) $qb` dispatches based on the structure: + +- No SET data → SELECT. +- SET data, no WHERE / HAVING → INSERT (or batch INSERT when any row has + more than one column). +- SET data, WHERE / HAVING present → UPDATE. + +```php +echo (string) $qb; // automatic dispatch +``` + +It's handy for quick prototyping but explicit `generate*Query()` calls +are more readable in real code — and they raise more descriptive errors +when the structure is incomplete. + +## Putting it together + +The full lifecycle of a CRUD endpoint: + +```php +// Create +$qb->from('post')->set([ + 'title' => 'Hello', + 'content' => 'World', + 'user_id' => 5, +]); +$pdo->prepare($qb->generateInsertQuery())->execute($qb->getParameter()->all()); + +// Read +$qb->resetStructure() + ->from('post') + ->where('user_id', 5) + ->orderBy('id', 'DESC') + ->limit(20); +$rows = $pdo->prepare($qb->generateSelectQuery()); +$rows->execute($qb->getParameter()->all()); + +// Update +$qb->resetStructure() + ->from('post') + ->where('id', 42) + ->set(['title' => 'Renamed']); +$pdo->prepare($qb->generateUpdateQuery())->execute($qb->getParameter()->all()); + +// Delete +$qb->resetStructure() + ->from('post') + ->where('id', 42); +$pdo->prepare($qb->generateDeleteQuery())->execute($qb->getParameter()->all()); +``` + +**Next:** [Sub-queries →](subqueries.md) diff --git a/docs/en/joins.md b/docs/en/joins.md new file mode 100644 index 0000000..47465d4 --- /dev/null +++ b/docs/en/joins.md @@ -0,0 +1,177 @@ +# JOINs + +All seven JOIN flavors share a single entry point — `join()` — and seven +sugar methods that fix the JOIN keyword for you. The ON expression can +be supplied as a string, a `RawQuery`, or a closure that composes the +expression with the same fluent DSL. + +## The seven shapes + +| Helper | SQL keyword | +|-------------------------------------|-------------------------| +| `innerJoin(table, on)` | `INNER JOIN … ON …` | +| `leftJoin(table, on)` | `LEFT JOIN … ON …` | +| `rightJoin(table, on)` | `RIGHT JOIN … ON …` | +| `leftOuterJoin(table, on)` | `LEFT OUTER JOIN … ON …`| +| `rightOuterJoin(table, on)` | `RIGHT OUTER JOIN … ON …`| +| `naturalJoin(table)` | `NATURAL JOIN …` | +| `selfJoin(table, on)` | comma-FROM + WHERE | +| `join(table, on, type)` | generic — type is the keyword | + +`naturalJoin()` does not take an ON parameter (NATURAL JOIN does not carry +one). `selfJoin()` compiles to a comma-separated FROM with the ON +expression promoted to WHERE — convenient when you need a join-like +self-reference without an explicit JOIN keyword. + +## Simple string ON + +The string is escaped via the active driver before being inlined: + +```php +$qb->select('post.id', 'user.name AS author') + ->from('post') + ->innerJoin('user', 'user.id = post.user_id'); +// SELECT `post`.`id`, `user`.`name` AS `author` +// FROM `post` +// INNER JOIN `user` ON `user`.`id` = `post`.`user_id` +// WHERE 1 +``` + +Numeric literals (and operators / punctuation) inside the ON string are +left untouched — only identifier-shaped tokens get quoted. See +[drivers.md](drivers.md) for the exact escape rules. + +## Aliased tables + +Just spell the alias inline in the table argument: + +```php +$qb->from('users AS u') + ->leftJoin('posts AS p', 'p.user_id = u.id'); +// FROM `users` AS `u` LEFT JOIN `posts` AS `p` ON `p`.`user_id` = `u`.`id` +``` + +The shorter form `'users u'` (no `AS`) also works. + +## SELF JOIN + +`selfJoin()` is a convenience for the comma-FROM form where the "join" +condition lives in WHERE rather than ON: + +```php +$qb->select('post.id', 'post.title', 'user.name AS authorName') + ->table('post') + ->selfJoin('user', 'user.id = post.user_id'); +// SELECT `post`.`id`, `post`.`title`, `user`.`name` AS `authorName` +// FROM `post`, `user` +// WHERE `user`.`id` = `post`.`user_id` +``` + +Useful when the target dialect / query planner prefers an implicit join. + +## NATURAL JOIN + +```php +$qb->select('*') + ->from('orders') + ->naturalJoin('customers'); +// SELECT * FROM `orders` NATURAL JOIN `customers` WHERE 1 +``` + +NATURAL JOIN matches on identically-named columns; nothing else to say. + +## Closure-based ON + +Closure form gives you the full WHERE DSL for the ON expression. The +closure receives a fresh sub-builder; its **ON**, **WHERE** and +**HAVING** buckets are all folded back into the parent query: + +```php +$qb->select('u.id', 'u.name', 'p.title') + ->from('users AS u') + ->where('u.status', 1) + ->join('posts AS p', function (QueryBuilder $j) { + $j->on('p.user_id', 'u.id') + ->where('p.publisher_time', '>=', $j->raw('NOW()')); + }) + ->join('categories AS c', function (QueryBuilder $j) { + $j->on('c.id', 'p.category_id') + ->on('c.blog_id', 'u.blog_id') + ->where('c.status', 1) + ->having($j->raw('COUNT(p.category_id) > 1')); + }) + ->limit(5); + +echo $qb->generateSelectQuery(); +// SELECT `u`.`id`, `u`.`name`, `p`.`title` FROM `users` AS `u` +// INNER JOIN `posts` AS `p` ON `p`.`user_id` = `u`.`id` +// INNER JOIN `categories` AS `c` ON `c`.`id` = `p`.`category_id` AND `c`.`blog_id` = `u`.`blog_id` +// WHERE `u`.`status` = 1 +// AND `p`.`publisher_time` >= NOW() +// AND `c`.`status` = 1 +// HAVING COUNT(p.category_id) > 1 +// LIMIT 5 +``` + +The mental model: + +- `on()` lands in the closure's **ON** bucket — those clauses appear after + the `ON` keyword on the JOIN itself. +- `where()` lands in the closure's **WHERE** bucket — those clauses are + appended to the outer query's WHERE. +- `having()` lands in the closure's **HAVING** bucket — same idea. + +This lets you wire a JOIN that depends on outer-query conditions without +leaving the JOIN's "scope" syntactically. + +## When the closure returns a string + +If the closure returns a string (rather than calling builder methods), +that string is taken as the literal ON expression: + +```php +$qb->from('users AS u') + ->innerJoin('posts AS p', function () { + return 'p.user_id = u.id'; + }); +// FROM `users` AS `u` INNER JOIN `posts` AS `p` ON p.user_id = u.id +``` + +This form is rarely needed — the closure-with-methods form is more +expressive — but it's a useful escape hatch. + +## Using a sub-query as the JOIN target + +The first argument can also be a [sub-query](subqueries.md): + +```php +$derived = $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id', 'title', 'user_id') + ->from('posts') + ->where('user_id', 5); +}, 'p'); + +$qb->select('u.name', 'p.title') + ->from('users AS u') + ->join($derived, 'p.user_id = u.id', ''); +// SELECT `u`.`name`, `p`.`title` FROM `users` AS `u` +// JOIN (SELECT `id`, `title`, `user_id` FROM `posts` WHERE `user_id` = 5) AS `p` +// ON `p`.`user_id` = `u`.`id` +// WHERE 1 +``` + +The third argument (`''` above) keeps the JOIN keyword empty for an +unqualified `JOIN`. Use `'INNER'`, `'LEFT'`, etc. as you would normally. + +## Pitfalls + +- **The closure-fold direction is one-way**: WHERE/HAVING clauses you + add inside the closure escape into the outer query. If you only want + an ON-expression, only call `on()` inside the closure. +- **`selfJoin()` writes to WHERE, not ON.** That means a subsequent + call to `where()` lands in the same bucket — useful, but worth knowing. +- **NATURAL JOIN ignores ON entirely.** If you call + `naturalJoin('customers')` after `where('customers.id', 5)`, the + WHERE clause survives, but no `ON` is emitted on the JOIN itself. + +**Next:** [INSERT, UPDATE, DELETE →](insert-update-delete.md) diff --git a/docs/en/parameters.md b/docs/en/parameters.md new file mode 100644 index 0000000..9fea76e --- /dev/null +++ b/docs/en/parameters.md @@ -0,0 +1,247 @@ +# Parameters + +The parameter bag is what makes the builder safe to use with PDO without +ever concatenating user input. This page covers the API in detail — +collision auto-suffixing, the NULL short-circuit, `RawQuery` key hashing, +and the value-inlining decision tree. + +## The contract + +`ParameterInterface` exposes six methods: + +| Method | Returns | Purpose | +|-------------------------------------------|-------------------|----------------------------------| +| `set(string $key, mixed $value): self` | `$this` | overwrite by key | +| `add(string\|RawQuery $key, mixed $value): string` | placeholder name | append, auto-suffix on collision | +| `get(?string $key = null, mixed $default = null): mixed` | value or full map | read | +| `all(): array` | `array` | PDO-ready map | +| `merge(array\|ParameterInterface ...$arrays): self` | `$this` | bulk merge | +| `reset(): self` | `$this` | empty the bag | + +The default implementation, `Parameters`, ships with the package. + +## Accessing the bag + +Every builder has a parameter bag accessible via `getParameter()`: + +```php +$qb = new QueryBuilder('mysql'); +$qb->from('users')->where('country', 'TR'); + +$bag = $qb->getParameter(); +$bag->all(); // [':country' => 'TR'] +``` + +## `set()` vs `add()` + +The two write methods have **different semantics** and you'll want to +know when to reach for each: + +### `set()` — overwrites by key + +```php +$bag->set('id', 1); +$bag->set('id', 2); +$bag->all(); +// [':id' => 2] ← overwritten +``` + +Use `set()` when you have control over the key and want it to remain +stable across rebinds. `setParameter()` on the builder delegates here. + +### `add()` — collision auto-suffix + +```php +$bag->add('id', 1); // returns ':id' +$bag->add('id', 2); // returns ':id_1' +$bag->add('id', 3); // returns ':id_2' +$bag->all(); +// [':id' => 1, ':id_1' => 2, ':id_2' => 3] +``` + +This is what the clause builders use internally — every value bound by +`where('id', ...)`, `set('id', ...)` etc. goes through `add()` so a +chain that mentions the same column multiple times still produces a +valid SQL statement. + +## Key sanitization + +Both `set()` and `add()` strip non-alphanumeric characters from the key +before prefixing with `:`: + +```php +$bag->add('user.id', 1); // returns ':userid' (dot removed) +$bag->add('user-id', 2); // returns ':userid_1' (dash removed) +``` + +Why? Because PDO bind names only accept `[A-Za-z0-9_]`. The sanitization +is silent — be aware of it if you reach for "exotic" key shapes. + +## The NULL short-circuit + +`add()` does **not** register a binding when the value is `null`: + +```php +$placeholder = $bag->add('deleted_at', null); +// $placeholder === 'NULL' +$bag->all(); +// [] ← nothing was added +``` + +This lets the compiler inline the literal `NULL` into the SQL — a +parameterized `:deleted_at = ?` bound to PHP `null` would compile to +`= NULL` which is **not** the same as `IS NULL`. The short-circuit +sidesteps that footgun by emitting `NULL` directly when the value is +unambiguously null. Use `whereIsNull()` / `whereIsNotNull()` for the +correct SQL form. + +## RawQuery keys + +When the key passed to `add()` is itself a `RawQuery` (used internally by +batch UPDATE when the column reference is a complex expression), the +implementation hashes it with `md5()` to produce a stable, opaque +placeholder name: + +```php +$bag->add(new RawQuery('some expression'), 1); +// returns ':<32 hex chars>' +``` + +You won't usually trigger this directly — it's mostly an internal +plumbing detail — but it explains the occasional `:` placeholder +in compiled SQL when reading complex queries. + +## Reading values + +`get()` is multi-purpose: + +```php +$bag->set('id', 99); + +$bag->get(); // returns the whole map +$bag->get('id'); // 99 +$bag->get(':id'); // 99 — leading colon is optional +$bag->get('missing'); // null +$bag->get('missing', 'fallback'); // 'fallback' +$bag->get('missing', fn () => 'lazy'); // 'lazy' — closure invoked lazily +``` + +A `Closure` default is invoked **only** when the key is missing. If the +key is present, the closure is never called — handy for expensive +fallbacks. + +## Merging bags + +`merge()` accepts both plain arrays and other `ParameterInterface` +instances: + +```php +$other = (new Parameters())->set('c', 3)->set('d', 4); + +$bag = new Parameters(); +$bag->merge(['a' => 1, 'b' => 2], $other); +$bag->all(); +// [':a' => 1, ':b' => 2, ':c' => 3, ':d' => 4] +``` + +`merge()` uses `set()` semantics — colliding keys overwrite. If you need +collision-safe merging, iterate the source manually and call `add()` for +each entry. + +## Resetting + +```php +$bag->reset(); // empty the bag +$bag->all(); // [] +``` + +`QueryBuilder::resetStructure()` does **not** reset the parameter bag — +they are independent. If you reuse a builder for a fresh query and you +want a clean bag too, call both: + +```php +$qb->resetStructure(); +$qb->getParameter()->reset(); +``` + +## When the builder DOES NOT parameterize + +Not every value flows through the bag. The internal helper +`SqlValueDetector::isSqlParameterOrFunction()` returns `true` (and the +value is **inlined** instead of bound) for: + +- Integers — `5` becomes `5` in SQL. +- `?` — positional placeholder. +- `:foo` shape — pre-formed named placeholder. +- `table.column` shape — dotted column reference. +- `function()` shape — parameterless SQL function call. +- `RawQuery` — always inlined verbatim. + +```php +$qb->where('id', 5); +// WHERE `id` = 5 ← integer inlined + +$qb->where('id', '?'); +// WHERE `id` = ? ← positional placeholder inlined + +$qb->where('id', $qb->raw('NOW()')); +// WHERE `id` = NOW() ← RawQuery inlined +``` + +Everything else — strings, booleans, floats, DateTime objects, +unrecognized scalars — goes through `add()`. + +> ⚠️ Floats and `DateTime` are bound, not inlined. Cast them to your +> dialect's expected form beforehand if you need a specific format. + +## Plugging the bag into PDO + +The map returned by `all()` is already keyed for PDO: + +```php +$pdo = new PDO(/* … */); +$qb->select('*')->from('users')->where('country', 'TR'); + +$stmt = $pdo->prepare($qb->generateSelectQuery()); +$stmt->execute($qb->getParameter()->all()); +``` + +PDO ignores the leading `:` on bind names, so either form (`':country'` +or `'country'`) works at execute time — but the bag always emits the +colon-prefixed form. + +## Hoisting a value into the outer bag + +Useful for sub-queries (see [subqueries.md](subqueries.md#sub-query-parameters)) +or for hand-rolling a `RawQuery`: + +```php +$qb->setParameter('admin_role', 'admin'); +$qb->where($qb->raw('role = :admin_role')); +// WHERE role = :admin_role +// Bag: [':admin_role' => 'admin'] +``` + +`setParameter()` is a convenience for `getParameter()->set(...)`. + +## A worked example + +```php +$qb = new QueryBuilder('mysql'); +$qb->from('users') + ->where('country', 'TR') + ->where('country', 'US') // collision → :country_1 + ->whereIn('role_id', [1, 2, 3]) // integers inlined + ->set('updated_at', $qb->raw('NOW()')); // RawQuery inlined + +$qb->getParameter()->all(); +// [ +// ':country' => 'TR', +// ':country_1' => 'US', +// ] +``` + +Note how only the strings landed in the bag — the integers and the +`NOW()` call were inlined directly into the SQL. + +**Next:** [Drivers →](drivers.md) diff --git a/docs/en/raw-queries.md b/docs/en/raw-queries.md new file mode 100644 index 0000000..7d98f33 --- /dev/null +++ b/docs/en/raw-queries.md @@ -0,0 +1,183 @@ +# Raw queries + +`RawQuery` is the explicit escape hatch for SQL fragments that should be +inlined verbatim — no identifier escaping, no parameter binding. Use it +where the fluent DSL does not (or should not) cover the case. + +## When to use it + +- **Database functions** — `NOW()`, `CURRENT_TIMESTAMP`, `JSON_EXTRACT(…)`, + `INET_ATON(…)`, custom UDFs. +- **Dialect-specific operators** — PostgreSQL `~`, SQLite `LIKE` with + ESCAPE, MySQL `MATCH () AGAINST ()`. +- **Inline sub-queries** — a static SQL fragment you have hand-written + and want to embed. +- **Anything the builder doesn't expose directly** — set-returning + functions, window functions, lateral joins, etc. + +## When NOT to use it + +> 🚨 **Never embed unsanitized user input.** `RawQuery` bypasses the +> parameter bag entirely — if you concatenate a `$_GET` value into the +> string, you've reintroduced SQL injection. Route user values through +> `where()` / `set()` / `setParameter()`. + +## Three input forms + +`RawQuery::__construct(mixed $rawQuery)` accepts: + +### 1. A string + +Used as-is: + +```php +use InitORM\QueryBuilder\RawQuery; + +$raw = new RawQuery('NOW()'); +echo (string) $raw; +// NOW() +``` + +### 2. A Closure + +The closure is invoked with a fresh `QueryBuilder`. It may either: + +- **Return a string** — that string becomes the raw fragment. +- **Return a stringable object** — its `__toString()` is captured. +- **Return nothing** — the inner builder's `__toString()` is captured + (i.e. the closure built a query against the supplied builder). + +```php +$raw = new RawQuery(function () { + return 'CURRENT_TIMESTAMP'; +}); +// CURRENT_TIMESTAMP + +$raw = new RawQuery(function (QueryBuilder $qb) { + $qb->select('id')->from('users')->where('active', 1); +}); +// SELECT `id` FROM `users` WHERE `active` = 1 +``` + +### 3. Any other value + +Cast to string: + +```php +$raw = new RawQuery(42); +echo (string) $raw; +// 42 +``` + +## The two factory shortcuts + +You can construct a `RawQuery` directly, but it's often nicer to use: + +- **`$qb->raw($value)`** — the builder method, useful inside chains. +- **`RawQuery::raw($value)`** — the static factory, useful in places + where you don't have a builder handy. + +```php +// Inside a builder chain +$qb->from('users')->where('created_at', '>=', $qb->raw('NOW() - INTERVAL 7 DAY')); + +// Outside a chain +$now = RawQuery::raw('NOW()'); +``` + +## Where you can drop a RawQuery + +Anywhere the builder accepts `RawQuery|string` (or `RawQuery|whatever`): + +- **Projections** — `select($qb->raw('NOW() AS now'))`, + `selectAs($qb->raw('JSON_EXTRACT(payload, "$.name")'), 'name')`. +- **Tables** — `from($qb->raw('users FORCE INDEX (idx_status)'))`. +- **JOIN ON** — `innerJoin('posts', $qb->raw('posts.user_id = users.id'))`. +- **WHERE values** — `where('created_at', '<', $qb->raw('NOW()'))`. +- **BETWEEN bounds** — `between('ts', $qb->raw('NOW() - INTERVAL 1 DAY'), $qb->raw('NOW()'))`. +- **IN values** — `whereIn('id', $qb->raw('(SELECT user_id FROM bans)'))`. +- **SET column values** — `set('updated_at', $qb->raw('NOW()'))`. + +The builder checks each value with `SqlValueDetector::isSqlParameterOrFunction()`; +RawQuery instances always pass that check and are emitted verbatim. + +## Patterns + +### Database function on the right-hand side + +```php +$qb->set([ + 'created_at' => $qb->raw('NOW()'), + 'token' => $qb->raw('UUID()'), + 'name' => 'Muhammet', +]); +// (`created_at`, `token`, `name`) VALUES (NOW(), UUID(), :name) +``` + +### Dialect-specific WHERE + +PostgreSQL ILIKE: + +```php +$qb->from('users') + ->where('email', $qb->raw('ILIKE :search')); +$qb->setParameter('search', '%@example.test'); +``` + +### Free-form HAVING + +```php +$qb->select('author_id') + ->selectCount('id', 'post_count') + ->from('post') + ->groupBy('author_id') + ->having($qb->raw('COUNT(id) > 5')); +// HAVING COUNT(id) > 5 +``` + +### Inline sub-query without `subQuery()` + +```php +$qb->whereIn('user_id', $qb->raw('(SELECT id FROM admin_users)')); +// WHERE `user_id` IN (SELECT id FROM admin_users) +``` + +This is the right form when the inner SELECT is **static** — you keep +the SQL self-contained and skip the closure indirection. +For dynamic inner SELECTs, prefer [`subQuery()`](subqueries.md). + +### Closure that returns the builder it built + +Convenient for one-liner derived expressions: + +```php +$raw = $qb->raw(function (QueryBuilder $inner) { + $inner->select('AVG(views)')->from('posts')->where('status', 1); +}); +$qb->select('post.title') + ->selectAs($raw, 'avg_views') + ->from('post'); +``` + +## Updating an existing RawQuery + +```php +$raw = new RawQuery('NOW()'); +$raw->set('CURRENT_TIMESTAMP'); +echo (string) $raw; +// CURRENT_TIMESTAMP +``` + +`set()` returns the `RawQuery` instance for chaining. + +## Reading a RawQuery's contents + +```php +$raw = new RawQuery('NOW()'); +echo $raw->get(); // 'NOW()' +echo (string) $raw; // 'NOW()' +``` + +`__toString()` and `get()` are equivalent. + +**Next:** [Parameters →](parameters.md) diff --git a/docs/en/recipes.md b/docs/en/recipes.md new file mode 100644 index 0000000..72d349d --- /dev/null +++ b/docs/en/recipes.md @@ -0,0 +1,278 @@ +# Recipes + +A grab-bag of patterns distilled from real applications. Each recipe is +self-contained — copy, adjust the table / column names, and you've got a +working snippet. + +All examples use the `mysql` driver. Substitute `'pgsql'` / `'sqlite'` +in the constructor for other dialects — the SQL emitted is otherwise +identical except for the quoting character. + +## Pagination + +A classic page-N-of-K listing. Inputs: page (1-based), page size. + +```php +function paginatedPosts(PDO $pdo, int $page = 1, int $perPage = 20): array +{ + $page = max(1, $page); + $offset = ($page - 1) * $perPage; + + $qb = new InitORM\QueryBuilder\QueryBuilder('mysql'); + $qb->select('id', 'title', 'created_at') + ->from('post') + ->where('published', 1) + ->orderBy('created_at', 'DESC') + ->offset($offset) + ->limit($perPage); + + $stmt = $pdo->prepare($qb->generateSelectQuery()); + $stmt->execute($qb->getParameter()->all()); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} +``` + +For a total count alongside, do it in a sibling builder: + +```php +$count = $qb->newBuilder(); +$count->selectCount('*', 'total')->from('post')->where('published', 1); +$total = (int) $pdo->query($count->generateSelectQuery())->fetchColumn(); +``` + +## Soft delete + +Convention: `deleted_at` is `NULL` for live rows, set to a timestamp on +delete. + +```php +// Reading — exclude soft-deleted rows +$qb->from('post') + ->whereIsNull('deleted_at'); + +// Soft-deleting a single row +$qb->from('post') + ->where('id', 42) + ->set('deleted_at', $qb->raw('NOW()')); +$pdo->prepare($qb->generateUpdateQuery())->execute($qb->getParameter()->all()); + +// Restoring +$qb->resetStructure() + ->from('post') + ->where('id', 42) + ->set('deleted_at', null); +$pdo->prepare($qb->generateUpdateQuery())->execute($qb->getParameter()->all()); +``` + +## Dynamic search filters + +Build a search query whose conditions depend on which inputs the caller +supplied: + +```php +function search(PDO $pdo, array $filters): array +{ + $qb = new InitORM\QueryBuilder\QueryBuilder('mysql'); + $qb->from('product'); + + if (!empty($filters['name'])) { + $qb->like('name', $filters['name']); + } + if (!empty($filters['category_id'])) { + $qb->where('category_id', (int) $filters['category_id']); + } + if (!empty($filters['min_price'])) { + $qb->where('price', '>=', (float) $filters['min_price']); + } + if (!empty($filters['max_price'])) { + $qb->where('price', '<=', (float) $filters['max_price']); + } + if (!empty($filters['in_stock'])) { + $qb->where('stock', '>', 0); + } + + $qb->orderBy($filters['sort'] ?? 'created_at', 'DESC'); + + $stmt = $pdo->prepare($qb->generateSelectQuery()); + $stmt->execute($qb->getParameter()->all()); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} +``` + +## Multi-tenant scoping + +Every query is gated by a tenant ID. Wrap the building step: + +```php +function tenantScopedBuilder(int $tenantId): InitORM\QueryBuilder\QueryBuilder +{ + return (new InitORM\QueryBuilder\QueryBuilder('mysql')) + ->where('tenant_id', $tenantId); +} + +$qb = tenantScopedBuilder(42); +$qb->select('*')->from('post')->orderBy('id', 'DESC'); +// SELECT * FROM `post` WHERE `tenant_id` = 42 ORDER BY `id` DESC +``` + +## Batch insert from an array of records + +```php +$qb = new InitORM\QueryBuilder\QueryBuilder('mysql'); +$qb->from('post'); + +foreach ($records as $record) { + $qb->set($record); +} +$sql = $qb->generateBatchInsertQuery(); +$pdo->prepare($sql)->execute($qb->getParameter()->all()); +``` + +Missing columns in any record compile to `NULL` — useful when records +are partially populated. + +## Batch UPDATE (CASE/WHEN) + +Given an array of `[id => [...changes]]` pairs: + +```php +$qb = new InitORM\QueryBuilder\QueryBuilder('mysql'); +$qb->from('post'); + +foreach ($changes as $id => $row) { + $qb->set(['id' => $id] + $row); +} + +$sql = $qb->generateUpdateBatchQuery('id'); +$pdo->prepare($sql)->execute($qb->getParameter()->all()); +// UPDATE `post` +// SET `title` = CASE WHEN `id` = 1 THEN :title WHEN `id` = 2 THEN :title_1 ELSE `title` END, +// ... +// WHERE `id` IN (1, 2, …) +``` + +## "Upsert" via INSERT ... ON DUPLICATE KEY UPDATE (MySQL) + +The builder does not yet emit the `ON DUPLICATE KEY UPDATE` tail, but +you can hand-craft the trailer with `RawQuery`: + +```php +$qb->from('counter') + ->set(['key' => 'visits', 'value' => 1]); + +$sql = $qb->generateInsertQuery(); +$sql = rtrim($sql, ';') . ' ON DUPLICATE KEY UPDATE `value` = `value` + 1;'; +$pdo->prepare($sql)->execute($qb->getParameter()->all()); +``` + +Same idea on PostgreSQL with `ON CONFLICT (...) DO UPDATE SET …`. + +## "Most recent N per group" via a sub-query + +```php +$qb = new InitORM\QueryBuilder\QueryBuilder('mysql'); +$qb->select('p.*') + ->from('post AS p') + ->whereIn('p.id', $qb->subQuery(function (InitORM\QueryBuilder\QueryBuilder $sub) { + $sub->select($sub->raw('MAX(id)')) + ->from('post') + ->groupBy('user_id'); + })); +// SELECT `p`.* FROM `post` AS `p` +// WHERE `p`.`id` IN (SELECT MAX(id) FROM `post` WHERE 1 GROUP BY `user_id`) +``` + +## Active-record style helper + +Wrap the builder in a tiny per-table helper to cut boilerplate: + +```php +final class UserQueries +{ + public function __construct(private PDO $pdo) {} + + private function qb(): InitORM\QueryBuilder\QueryBuilder + { + return (new InitORM\QueryBuilder\QueryBuilder('mysql'))->from('users'); + } + + public function findActiveById(int $id): ?array + { + $qb = $this->qb()->where('id', $id)->whereIsNull('deleted_at')->limit(1); + $stmt = $this->pdo->prepare($qb->generateSelectQuery()); + $stmt->execute($qb->getParameter()->all()); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; + } + + public function markDeleted(int $id): void + { + $qb = $this->qb() + ->where('id', $id) + ->set('deleted_at', $qb->raw('NOW()')); + $this->pdo->prepare($qb->generateUpdateQuery()) + ->execute($qb->getParameter()->all()); + } +} +``` + +## Ranking & windowed counts (MySQL 8 / PostgreSQL) + +Window functions aren't first-class in the DSL, but `RawQuery` slots +right in: + +```php +$qb->select('id', 'user_id', 'created_at') + ->select($qb->raw('ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn')) + ->from('post'); +// SELECT `id`, `user_id`, `created_at`, +// ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn +// FROM `post` WHERE 1 +``` + +## Auditing a query before execution + +Sometimes you want to log the SQL before sending it off: + +```php +$sql = $qb->generateSelectQuery(); +$parameters = $qb->getParameter()->all(); + +$logger->debug('SQL', ['sql' => $sql, 'params' => $parameters]); + +$stmt = $pdo->prepare($sql); +$stmt->execute($parameters); +``` + +`(string) $qb` triggers the dispatch heuristic ([insert-update-delete.md](insert-update-delete.md#the-__tostring-heuristic)) +— `generate*Query()` is more explicit and recommended in production. + +## A safe-by-default helper + +Wrap PDO + builder in a single function so callers can't forget either +side of the parameter handoff: + +```php +function exec(PDO $pdo, InitORM\QueryBuilder\QueryBuilderInterface $qb, string $type = 'select'): PDOStatement +{ + $sql = match ($type) { + 'select' => $qb->generateSelectQuery(), + 'insert' => $qb->generateInsertQuery(), + 'insert-batch' => $qb->generateBatchInsertQuery(), + 'update' => $qb->generateUpdateQuery(), + 'update-batch' => $qb->generateUpdateBatchQuery('id'), + 'delete' => $qb->generateDeleteQuery(), + }; + + $stmt = $pdo->prepare($sql); + $stmt->execute($qb->getParameter()->all()); + return $stmt; +} +``` + +## More patterns? + +Got a recipe you'd like to see here? Open an issue or PR against +[InitORM/QueryBuilder](https://github.com/InitORM/QueryBuilder/issues). + +**Next:** [API reference →](api-reference.md) diff --git a/docs/en/security.md b/docs/en/security.md new file mode 100644 index 0000000..498f13e --- /dev/null +++ b/docs/en/security.md @@ -0,0 +1,320 @@ +# Security + +This page is the security threat model for InitORM QueryBuilder: what the +library defends against, what it deliberately delegates to the caller, and +how to write application code that does not regress those guarantees. + +The matching regression suite lives in +[`tests/SecurityTest.php`](../../tests/SecurityTest.php) — every defense +listed here has a test that pins the behavior. + +## Threat model at a glance + +| # | Risk | Defense | Where | +|---|-------------------------------------------------------------------|-----------------------------------------------------------|----------| +| V1 | "Function-shaped" string in a value slot bypasses parameterisation | **Documented**, application-level | §V1 | +| V2 | "Dotted column reference" in a value slot bypasses parameterisation | **Documented**, application-level | §V2 | +| V3 | Query-breakout characters (`;`, `--`) in identifier names | **Hardened** — `escapeIdentifier()` raises | §V3 | +| V4 | LIKE wildcard injection (`%`, `_`, `\` in user values) | **Hardened** — default auto-escape | §V4 | +| V5 | ORDER BY column enumeration via unvalidated input | **Documented**, application-level | §V5 | +| V6 | Placeholder regex permitted `(` and `)` characters | **Hardened** — regex tightened to `^:\w+$` | §V6 | + +> 🚨 **Golden rule.** User input must always reach the database as a *value*, +> never as an *identifier* (column / table / alias name) and never as a SQL +> fragment. The library is designed around that boundary; once a caller +> crosses it, no defense the builder can mount is reliable. + +## §V1 — Function-shaped strings in values + +### What happens + +`SqlValueDetector::isSqlParameterOrFunction()` treats a string matching +`^[a-zA-Z_]+\(\)$` as a parameterless SQL function call and inlines it +verbatim. This is what lets you write: + +```php +$qb->set('updated_at', 'NOW()'); +// SET `updated_at` = NOW() +``` + +If a caller passes user input straight into a value slot and the input +happens to match that shape, the same path triggers: + +```php +$qb->from('user')->where('id', $_GET['id']); +// Attacker: $_GET['id'] = 'CURRENT_USER()' +// → WHERE `id` = CURRENT_USER() +// Attacker: $_GET['id'] = 'DATABASE()' +// → WHERE `id` = DATABASE() +``` + +Empty-parens form only: `'SLEEP(10)'` does NOT match (the regex rejects +content between the parens) and is parameterised normally. + +### Why it's not auto-disabled + +Function inlining for trusted programmer strings (`'NOW()'`, `'UUID()'`, +`'CURDATE()'`) is the most common reason developers reach for the +library. Stripping the auto-detection would force every caller through +`RawQuery` and break the README's "Quick start" example. + +### What you must do + +Treat values flowing into `where()` / `having()` / `on()` / `set()` as +**parameter data**. If you need a function call, supply it via `RawQuery`: + +```php +// ❌ NEVER +$qb->where('id', $_GET['id']); + +// ✅ if you have a fixed expected shape, cast and validate +$qb->where('id', (int) $_GET['id']); + +// ✅ if you want a SQL function, mark it as raw — programmer intent +$qb->set('updated_at', $qb->raw('NOW()')); +``` + +## §V2 — Dotted column references in values + +### What happens + +`SqlValueDetector::isSqlParameterOrFunction()` also matches +`^[a-zA-Z_]+\.[a-zA-Z_]+$` and inlines as a column reference — the +shorthand that lets you compare against another column without +ceremony: + +```php +$qb->where('post.user_id', 'users.id'); +// WHERE `post`.`user_id` = users.id +``` + +User input shaped like that bypasses parameterisation: + +```php +// Attacker: $_GET['id'] = 'users.password' +$qb->where('id', $_GET['id']); +// WHERE `id` = users.password +// ↑ Boolean comparison against another column rather than a literal. +// ↑ Wrong rows on purpose-controlled input. +``` + +### Why it's not auto-disabled + +Same trade-off as §V1 — JOIN ON expressions and side-by-side column +comparisons would all require `RawQuery` if this auto-detection were +removed. + +### What you must do + +Same as §V1 — values originating from the request body / query string / +headers MUST be coerced to an explicit primitive type (or rejected) before +being passed in. + +## §V3 — Query-breakout characters in identifiers — *defended* + +### What it would have been + +The identifier-escape regex in `AbstractDriver::escapeIdentifier()` quotes +identifier-shaped runs but leaves operator and punctuation characters +alone: + +```php +// Pre-v2.0.0 behavior (hypothetical, user-supplied table): +$qb->from('users; DROP TABLE x; --'); +// FROM `users`; `DROP` `TABLE` `x`; -- +// ↑ The `;` and `--` survive the escape pass. On PostgreSQL, where +// ↑ PDO multi-statement queries are allowed by default, this is a +// ↑ direct injection vector. +``` + +### The defense + +`escapeIdentifier()` now rejects any identifier containing `;` or `--`: + +```php +$qb->from('users; DROP'); +// throws QueryBuilderInvalidArgumentException: +// Identifier contains a forbidden SQL sequence (;) +``` + +The check runs **before** the dialect's quoting logic, so it applies to +the no-op `GenericDriver` as well — generic-driver users get the same +defense-in-depth as MySQL / PgSQL / SQLite callers. + +Operator characters (`=`, `>`, `<`, `.`) and whitespace continue to pass +through, so legitimate JOIN ON expressions (`'user.id = post.user_id'`) +still work. + +### What you must do + +The defense is opt-in by virtue of existing — no caller change needed. +Continue to validate user input before passing it as a table or column +name, but the library will now fail loudly rather than emit unsafe SQL +when that validation slips. + +## §V4 — LIKE wildcard injection — *defended* + +### What it would have been + +The LIKE compiler wraps the user-supplied value with `%` characters +according to the `$type` argument. Pre-v2.0.0 the value was used as-is, +so `%` and `_` inside the value retained their SQL wildcard meaning: + +```php +// Pre-fix behavior: +$qb->like('name', '%'); +// LIKE '%%%' ≡ LIKE '%' — matches every row +``` + +A search box that lets users type their query straight into `like()` +would let any user enumerate the whole table by typing `%`. + +### The defense + +The LIKE branch in `WhereClauseTrait::prepareStatement()` now escapes +the SQL wildcard characters before concatenating the surrounding `%`: + +```php +$qb->like('name', '50%'); +// param :name = '%50\%%' +// ↑ The user's literal "%" is escaped with "\". +$qb->like('name', 'a_b'); +// param :name = '%a\_b%' +// ↑ Underscore is also a single-character wildcard in SQL. +$qb->like('name', '\\'); +// param :name = '%\\\\%' +// ↑ The escape character is doubled. +``` + +The same applies to `notLike`, `startLike`, `endLike`, and every +`and*` / `or*` variant. + +### Opt out + +When the caller deliberately wants raw wildcards (e.g. a programmer +building a hand-shaped pattern), pass a `RawQuery`: + +```php +$qb->like('name', $qb->raw("'custom%pattern'")); +// LIKE 'custom%pattern' — no escape applied +``` + +Passing a placeholder-shaped string (e.g. `':needle'` wrapped in `raw()`) +also bypasses the escape — the value is emitted verbatim. + +## §V5 — ORDER BY column whitelist + +### What happens + +`orderBy()` escapes the column identifier (no SQL injection possible +through the identifier itself, post-V3) but does not constrain it to a +predefined set of columns. A user who supplies the sort column directly +can therefore: + +- Sort by *any* column in the table — including columns the application + did not intend to expose (a hashed password column, an internal status + flag). +- Use timing differences across sorts to enumerate column types. + +### What you must do + +Whitelist sort columns in the application layer: + +```php +$sortable = ['id', 'created_at', 'title']; +$sortColumn = $_GET['sort'] ?? 'id'; +if (!in_array($sortColumn, $sortable, true)) { + $sortColumn = 'id'; +} +$qb->orderBy($sortColumn, 'DESC'); +``` + +The sort *direction* is already validated against `ASC` / `DESC` by the +library — `orderBy('id', $_GET['dir'])` is safe to that extent. + +## §V6 — placeholder-shape regex — *defended* + +### What it would have been + +`SqlValueDetector::isSqlParameter()` and the same check inside +`isSqlParameterOrFunction()` used the regex `/^:[(\w)]+$/`. The +character class `[(\w)]+` permitted `(`, `)`, and word characters — +almost certainly a typo for `^:\w+$`. PDO bind names only accept +`[A-Za-z0-9_]`, so the extra characters were never legal. + +### The defense + +Both call sites now use `/^:\w+$/`. No legitimate caller is affected; +the change just trims latent room for surprising matches. + +## Putting it together — safe patterns + +A representative "safe" search endpoint, end to end: + +```php +use InitORM\QueryBuilder\QueryBuilder; + +function search(PDO $pdo, array $get): array +{ + $qb = new QueryBuilder('mysql'); + $qb->from('product'); + + // Values — always parameter-bound. Cast where the shape is known. + if (!empty($get['name'])) { + // V4: % / _ in user input is auto-escaped by like(). + $qb->like('name', (string) $get['name']); + } + if (!empty($get['category_id'])) { + $qb->where('category_id', (int) $get['category_id']); + } + + // Sort — whitelist the column, library validates direction. + $sortable = ['id', 'name', 'price']; + $sort = in_array($get['sort'] ?? '', $sortable, true) ? $get['sort'] : 'id'; + $dir = strcasecmp($get['dir'] ?? '', 'asc') === 0 ? 'ASC' : 'DESC'; + $qb->orderBy($sort, $dir); + + // Pagination — coerce to int, library rectifies negative values. + $qb->limit((int) ($get['limit'] ?? 20)) + ->offset((int) ($get['offset'] ?? 0)); + + $stmt = $pdo->prepare($qb->generateSelectQuery()); + $stmt->execute($qb->getParameter()->all()); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} +``` + +Conversely, every example below is **unsafe** and the library cannot +defend against it: + +```php +// ❌ Identifier from user input — even with §V3 active, this lets the +// user pick which table to query, which is rarely intentional. +$qb->from($_GET['table']); + +// ❌ Value from user input passed straight to a function-shaped check +// that may match §V1. +$qb->where('id', $_GET['id']); // attacker: id=CURRENT_USER() + +// ❌ ORDER BY without a whitelist — §V5. +$qb->orderBy($_GET['sort']); + +// ❌ RawQuery built from user input — escape hatch by design. +$qb->where($qb->raw('id = ' . $_GET['id'])); +``` + +## Reporting a vulnerability + +Do **not** open public issues for security problems. Follow the +[organization-wide disclosure process](https://github.com/InitORM/.github/blob/main/SECURITY.md). + +## Further reading + +- [`tests/SecurityTest.php`](../../tests/SecurityTest.php) — every + defense and documented residual risk has a regression test. +- [Raw queries](raw-queries.md) — when and how to use `RawQuery` + responsibly. +- [Parameters](parameters.md) — the value-inlining decision tree. +- [Drivers](drivers.md) — the identifier-escape rules driver by driver. + +**Back to the index:** [Table of contents →](index.md) diff --git a/docs/en/select.md b/docs/en/select.md new file mode 100644 index 0000000..f31d7b9 --- /dev/null +++ b/docs/en/select.md @@ -0,0 +1,245 @@ +# SELECT + +This chapter covers everything that goes between `SELECT` and `WHERE` — +projections, table picking, grouping, ordering and pagination. WHERE / ON +clauses live in their [own chapter](where.md); JOINs in [joins.md](joins.md). + +## Picking a table + +```php +$qb->from('users'); +// FROM `users` + +$qb->from('users', 'u'); +// FROM `users` AS `u` +``` + +To target multiple tables (comma-separated FROM list — uncommon but legal): + +```php +$qb->from('users', 'u') + ->addFrom('orders', 'o'); +// FROM `users` AS `u`, `orders` AS `o` +``` + +`table()` is an alias of `from()` without the alias parameter — handy when +you literally just want one table: + +```php +$qb->table('users'); +// FROM `users` +``` + +When no projection is set, the compiler emits `SELECT *`: + +```php +$qb->from('users'); +echo $qb->generateSelectQuery(); +// SELECT * FROM `users` WHERE 1 +``` + +The trailing `WHERE 1` is intentional — it is a stable shape that callers +can always append further conditions to. + +## Projections — `select()` + +`select(...$columns)` takes any number of column references. Strings go +through the active driver's identifier escaping; [RawQuery](raw-queries.md) +values are inlined verbatim. + +```php +$qb->select('id', 'name', $qb->raw('NOW() AS now')) + ->from('users'); +// SELECT `id`, `name`, NOW() AS now FROM `users` WHERE 1 +``` + +Calling `select()` twice appends — it does not replace: + +```php +$qb->select('id') + ->select('name'); +// SELECT `id`, `name` +``` + +Use `clearSelect()` to wipe the projection list: + +```php +$qb->select('id')->clearSelect()->select('name'); +// SELECT `name` +``` + +## Aliases — `selectAs()` + +```php +$qb->selectAs('full_name', 'name') + ->from('users'); +// SELECT `full_name` AS `name` FROM `users` WHERE 1 +``` + +For a `RawQuery` projection that needs an alias, either use `selectAs` +with the raw fragment, or append `AS …` directly in the raw string — +both work. + +## Aggregate functions + +The aggregate-projection helpers are shorthand for the common SQL function +calls. Each accepts an optional alias. + +| Helper | Emits | +|-----------------------|-------------------| +| `selectCount('id')` | `COUNT(id)` | +| `selectCount('id', 'total')` | `COUNT(id) AS total` | +| `selectCountDistinct('email')` | `COUNT(DISTINCT email)` | +| `selectMax('age')` | `MAX(age)` | +| `selectMin('age')` | `MIN(age)` | +| `selectAvg('age')` | `AVG(age)` | +| `selectSum('amount')` | `SUM(amount)` | + +Example combining several: + +```php +$qb->selectCount('id', 'total') + ->selectAvg('age', 'avg_age') + ->from('users'); +// SELECT COUNT(`id`) AS `total`, AVG(`age`) AS `avg_age` FROM `users` WHERE 1 +``` + +## String functions + +| Helper | Emits | +|-------------------------------------|--------------------------------| +| `selectUpper('name')` | `UPPER(name)` | +| `selectLower('name')` | `LOWER(name)` | +| `selectLength('bio')` | `LENGTH(bio)` | +| `selectMid('name', 1, 5)` | `MID(name, 1, 5)` | +| `selectLeft('name', 3)` | `LEFT(name, 3)` | +| `selectRight('name', 3)` | `RIGHT(name, 3)` | +| `selectConcat(['fn', 'ln'])` | `CONCAT(fn, ln)` | +| `selectDistinct('name')` | `DISTINCT(name)` | + +> ⚠️ `MID()` and `LEFT()` / `RIGHT()` are MySQL-flavored. For PostgreSQL +> or SQLite use `selectSum`/`selectAvg`/`selectCount` (all standard) and +> reach for [RawQuery](raw-queries.md) for `SUBSTRING(... FROM ... FOR ...)`. + +## COALESCE + +`selectCoalesce()` is the standard `COALESCE(column, default)` projection. +The default can be: + +- a numeric literal — inlined as-is; +- a non-numeric string — treated as another identifier and escaped; +- a `RawQuery` — inlined verbatim. + +```php +$qb->select('post.title') + ->selectCoalesce('stat.views', 0, 'views') + ->from('post') + ->leftJoin('stat', 'stat.id = post.id'); +// SELECT `post`.`title`, COALESCE(`stat`.`views`, 0) AS `views` +// FROM `post` LEFT JOIN `stat` ON `stat`.`id` = `post`.`id` WHERE 1 +``` + +A fallback to another column: + +```php +$qb->selectCoalesce('stat.views', 'post.legacy_views', 'views'); +// COALESCE(`stat`.`views`, `post`.`legacy_views`) AS `views` +``` + +## CONCAT + +```php +$qb->selectConcat(['first_name', $qb->raw("' '"), 'last_name'], 'full_name') + ->from('users'); +// SELECT CONCAT(`first_name`, ' ', `last_name`) AS `full_name` FROM `users` WHERE 1 +``` + +## GROUP BY + +`groupBy()` is variadic and recursively flattens arrays: + +```php +$qb->select('author_id') + ->selectCount('id', 'post_count') + ->from('post') + ->groupBy('author_id'); +// SELECT `author_id`, COUNT(`id`) AS `post_count` +// FROM `post` WHERE 1 GROUP BY `author_id` + +$qb->groupBy(['a', 'b'], 'c'); +// GROUP BY `a`, `b`, `c` +``` + +Duplicate columns are deduplicated — calling `groupBy('a')` twice does +not emit `a, a`. + +For `HAVING` (which is just WHERE-against-aggregates), see the +[WHERE chapter](where.md#having). + +## ORDER BY + +`orderBy(column, direction)` — direction is `'ASC'` (default) or `'DESC'`, +case-insensitive. Anything else throws `QueryBuilderInvalidArgumentException`. + +```php +$qb->orderBy('id', 'desc') + ->orderBy('name', 'ASC'); +// ORDER BY `id` DESC, `name` ASC +``` + +Duplicate `(column, direction)` pairs are deduplicated. + +## LIMIT / OFFSET + +`limit()` / `offset()` set the corresponding clauses. Negative arguments +are reflected to their absolute value: + +```php +$qb->limit(10); // LIMIT 10 +$qb->offset(20)->limit(10); // LIMIT 20, 10 +$qb->offset(20); // OFFSET 20 (without LIMIT) +$qb->limit(-5); // LIMIT 5 (sign-flipped) +``` + +## The compile-time shortcut + +`generateSelectQuery()` accepts two optional arguments — a selector array +and a conditions array. Useful for one-liners: + +```php +$qb->from('post'); +echo $qb->generateSelectQuery( + ['id', 'title'], + ['status' => 1], +); +// SELECT `id`, `title` FROM `post` WHERE `status` = 1 +``` + +Conditions with string keys turn into `where(key, value)`; entries with +integer keys are passed as a single argument (typically a `RawQuery`). + +## Putting it together + +A representative end-to-end SELECT: + +```php +$qb->select('p.id', 'p.title') + ->selectCount('c.id', 'comments') + ->from('post', 'p') + ->leftJoin('comment AS c', 'c.post_id = p.id') + ->where('p.published', 1) + ->groupBy('p.id') + ->orderBy('p.created_at', 'DESC') + ->limit(20); + +echo $qb->generateSelectQuery(); +// SELECT `p`.`id`, `p`.`title`, COUNT(`c`.`id`) AS `comments` +// FROM `post` AS `p` +// LEFT JOIN `comment` AS `c` ON `c`.`post_id` = `p`.`id` +// WHERE `p`.`published` = 1 +// GROUP BY `p`.`id` +// ORDER BY `p`.`created_at` DESC +// LIMIT 20 +``` + +**Next:** the [WHERE / HAVING / ON chapter →](where.md) diff --git a/docs/en/subqueries.md b/docs/en/subqueries.md new file mode 100644 index 0000000..429d4bd --- /dev/null +++ b/docs/en/subqueries.md @@ -0,0 +1,178 @@ +# Sub-queries + +`subQuery()` builds a SELECT inside a closure that receives a fresh +builder. The result is returned as a `RawQuery`, which you can drop into +any of the contexts that accept one — `WHERE IN`, `FROM`, `JOIN`, or +standalone. + +## Signature + +```php +public function subQuery( + Closure $closure, + ?string $alias = null, + bool $isIntervalQuery = true +): RawQuery +``` + +- **`$closure`** — receives a fresh `QueryBuilder` (cloned from the + parent, with structure reset). Use the same fluent API to build the + inner SELECT. +- **`$alias`** — optional alias. Only valid when `$isIntervalQuery` is + `true` (otherwise the call throws). +- **`$isIntervalQuery`** — when `true` (default), the emitted SQL is + wrapped in parentheses. Set to `false` for a stand-alone fragment. + +## In a `WHERE IN` + +The most common use: + +```php +$qb->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id') + ->from('roles') + ->where('name', 'admin'); + })); + +echo $qb->generateSelectQuery(); +// SELECT `u`.`name` +// FROM `users` AS `u` +// WHERE `u`.`id` IN (SELECT `id` FROM `roles` WHERE `name` = :name) +``` + +The parentheses come from `$isIntervalQuery = true` — `whereIn()` is +happy to consume a `RawQuery` directly. + +## As a derived FROM table + +```php +$derived = $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id', 'title', 'user_id') + ->from('posts') + ->where('user_id', 5); +}, 'p'); + +$qb->select('u.name', 'p.title') + ->from('users AS u') + ->join($derived, 'p.user_id = u.id', ''); + +echo $qb->generateSelectQuery(); +// SELECT `u`.`name`, `p`.`title` +// FROM `users` AS `u` +// JOIN (SELECT `id`, `title`, `user_id` FROM `posts` WHERE `user_id` = 5) AS `p` +// ON `p`.`user_id` = `u`.`id` +// WHERE 1 +``` + +The alias goes on the sub-query call (`'p'`); the JOIN keyword can be +left empty (`''`) for an unqualified `JOIN`, or set to `'INNER'`, +`'LEFT'`, etc. + +## Standalone (no parentheses) + +When you want the raw inner SELECT without the wrapping parentheses, +pass `$isIntervalQuery = false`: + +```php +$raw = $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id')->from('users')->where('active', 1); +}, null, false); + +echo (string) $raw; +// SELECT `id` FROM `users` WHERE `active` = 1 +``` + +Useful when you want to compose a larger SQL fragment by hand and slot a +generated SELECT into it. + +> ⚠️ Passing an alias **and** `$isIntervalQuery = false` raises: +> *To define alias to a subquery, it must be an inner query.* Aliases +> only make sense on parenthesized fragments. + +## Inside `where()` directly + +`whereIn()` is the most common context, but any operator that accepts a +`RawQuery` works: + +```php +$qb->from('users') + ->where('id', '=', $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('user_id')->from('latest_login')->orderBy('logged_at', 'DESC')->limit(1); + })); +// WHERE `id` = (SELECT `user_id` FROM `latest_login` WHERE 1 ORDER BY `logged_at` DESC LIMIT 1) +``` + +## Sub-query parameters + +The closure receives a **clone** of the outer builder. That clone has +its own structure **and its own parameter bag**, so sub-query parameters +do not collide with the outer query's parameters. + +```php +$qb->select('u.name') + ->from('users AS u') + ->whereIn('u.role_id', $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id') + ->from('roles') + ->whereIn('name', ['admin', 'moderator']); + })); +// SELECT `u`.`name` +// FROM `users` AS `u` +// WHERE `u`.`role_id` IN ( +// SELECT `id` FROM `roles` WHERE `name` IN (:name, :name_1) +// ) +``` + +The `:name` / `:name_1` placeholders live in the inner builder's bag — +but because the resulting SQL is captured as a `RawQuery` and embedded +verbatim into the outer query, the placeholders are already part of the +final SQL string. When you execute the outer query, those placeholders +need to come from the **outer** parameter bag — which means you have to +pre-bind them with `setParameter()` if you intend to PDO-execute the +result. + +A simpler escape hatch when sub-query values are dynamic and must reach +PDO: hoist them out as outer parameters: + +```php +$adminRole = 'admin'; +$qb->setParameter('admin_role', $adminRole); +$qb->select('u.name') + ->from('users AS u') + ->whereIn('u.role_id', $qb->subQuery(function (QueryBuilder $sub) { + $sub->select('id')->from('roles')->where('name', $sub->raw(':admin_role')); + })); +// The placeholder :admin_role now lives in the OUTER bag. +``` + +## Multiple sub-queries + +You can compose more than one sub-query in the same outer query; each +gets its own scoped builder: + +```php +$qb->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $qb->subQuery(fn (QueryBuilder $sub) => + $sub->select('user_id')->from('orders')->where('status', 'paid') + )) + ->andWhereNotIn('u.id', $qb->subQuery(fn (QueryBuilder $sub) => + $sub->select('user_id')->from('bans') + )); +``` + +## Reaching for `RawQuery` instead + +When the sub-query is static — same SQL on every call — you can just +write it as a `RawQuery`: + +```php +$qb->whereIn('id', $qb->raw('(SELECT user_id FROM bans)')); +``` + +That skips the closure indirection. Reserve `subQuery()` for cases where +the inner SELECT is itself dynamic. + +**Next:** [Grouped conditions →](grouping.md) diff --git a/docs/en/where.md b/docs/en/where.md new file mode 100644 index 0000000..600cc75 --- /dev/null +++ b/docs/en/where.md @@ -0,0 +1,306 @@ +# WHERE / HAVING / ON + +These three clauses share the same internal builder: every method below +returns the same fragment shape, just into a different bucket (WHERE, +HAVING, or ON). The bulk of the DSL lives here. + +## The three buckets + +| Method | Bucket | Used by | +|----------|----------|----------------------------| +| `where` | `where` | SELECT, UPDATE, DELETE | +| `having` | `having` | SELECT (aggregate filter) | +| `on` | `on` | JOIN closure (see [joins.md](joins.md)) | + +Each of them carries the same signature: + +```php +where(column, operator = '=', value = null, logical = 'AND') +``` + +`logical` is `'AND'` (default) or `'OR'`. The aliases `'&&'` and `'||'` +are also accepted. An unknown connector throws +`QueryBuilderInvalidArgumentException`. + +## The value-shortcut + +Passing two arguments is the most common form: + +```php +$qb->where('id', 5); +// WHERE `id` = 5 +``` + +Internally the second argument is the operator slot; when the value slot +is `null` **and** the operator slot is not actually a SQL operator, the +two are swapped and `=` is assumed. Result: `where('id', 5)` and +`where('id', '=', 5)` produce identical SQL. + +> ⚠️ Boolean values are valid via this shortcut after the v2.0.0 fix +> (the previous loose `in_array` comparison made `where('active', true)` +> collapse to `WHERE active`). Booleans are routed through the parameter +> bag. + +## Comparison operators + +```php +$qb->where('age', '>=', 18); // WHERE `age` >= 18 +$qb->where('status', '!=', 'banned'); // WHERE `status` != :status +$qb->where('score', '<>', 0); // WHERE `score` <> 0 +``` + +The full set: `=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`. Anything not in this +list (or in the LIKE / BETWEEN / IN families described below) is treated +as a SQL operator and inlined verbatim. + +## AND / OR connectors + +```php +$qb->from('users') + ->where('country', 'TR') + ->andWhere('active', 1); +// WHERE `country` = :country AND `active` = 1 + +$qb->from('users') + ->where('country', 'TR') + ->orWhere('country', 'US'); +// see the caveat in grouping.md before relying on this shape +``` + +`andWhere` / `orWhere` are convenience aliases for +`where(..., 'AND')` / `where(..., 'OR')`. The same applies to **every** +where-sugar method below: each one comes in a base form, an `and*` form +and an `or*` form. + +> ℹ️ A top-level chain like `where(a).orWhere(b)` compiles to `a OR b` +> at the SQL level — see [Grouped conditions](grouping.md#how-the-and--or-buckets-compile) +> for the exact joining rules when you mix AND and OR clauses. + +## NULL checks + +| Helper | Emits | +|---------------------------------|--------------------------------| +| `whereIsNull(col)` | `col IS NULL` | +| `orWhereIsNull(col)` | `col IS NULL` (OR bucket) | +| `andWhereIsNull(col)` | `col IS NULL` (AND bucket) | +| `whereIsNotNull(col)` | `col IS NOT NULL` | +| `orWhereIsNotNull(col)` | `col IS NOT NULL` (OR bucket) | +| `andWhereIsNotNull(col)` | `col IS NOT NULL` (AND bucket) | + +Example: + +```php +$qb->from('post') + ->whereIsNull('deleted_at'); +// SELECT * FROM `post` WHERE `deleted_at` IS NULL +``` + +## BETWEEN + +The bounds may be supplied as two arguments or as a single two-element +array (the latter is the long-standing convenience form): + +```php +$qb->where('id', 'BETWEEN', [10, 20]); +$qb->between('id', 10, 20); // identical +$qb->between('id', [10, 20]); // identical +// WHERE `id` BETWEEN 10 AND 20 +``` + +Numeric bounds are inlined; strings, dates, and other values flow through +the parameter bag: + +```php +$qb->between('date', '2026-01-01', '2026-12-31'); +// WHERE `date` BETWEEN :date AND :date_1 +``` + +Mixing parameters with a `RawQuery` (e.g. a SQL function) on one side +works as expected: + +```php +$qb->between('date', '2026-01-01', $qb->raw('NOW()')); +// WHERE `date` BETWEEN :date AND NOW() +``` + +| Helper | Variant | +|------------------------------------------------|----------------------| +| `between(col, $a, $b)` / `andBetween(...)` | AND `BETWEEN` | +| `orBetween(col, $a, $b)` | OR `BETWEEN` | +| `notBetween(col, $a, $b)` / `andNotBetween(...)` | AND `NOT BETWEEN` | +| `orNotBetween(col, $a, $b)` | OR `NOT BETWEEN` | + +## IN / NOT IN + +```php +$qb->whereIn('id', [1, 2, 3]); // WHERE `id` IN (1, 2, 3) +$qb->whereIn('country', ['TR', 'US']); // WHERE `country` IN (:country, :country_1) +$qb->whereNotIn('id', [4, 5]); // WHERE `id` NOT IN (4, 5) +``` + +Numeric items are inlined; strings are parameterized with collision +auto-suffixing. The array is deduplicated before emission: + +```php +$qb->whereIn('id', [1, 2, 2, 3, 1]); +// WHERE `id` IN (1, 2, 3) +``` + +A `RawQuery` (e.g. a sub-query) is passed through verbatim: + +```php +$qb->whereIn('id', $qb->raw('(SELECT user_id FROM bans)')); +// WHERE `id` IN (SELECT user_id FROM bans) +``` + +| Helper | +|--------------------------------------------------------------| +| `whereIn(col, vals)` / `andWhereIn(col, vals)` / `orWhereIn(col, vals)` | +| `whereNotIn(col, vals)` / `andWhereNotIn(col, vals)` / `orWhereNotIn(col, vals)` | + +## LIKE family + +The LIKE helpers wrap the supplied value with `%` according to the chosen +**type** (`'both'`, `'before'`/`'start'`, `'after'`/`'end'`). After the +v2.0.0 fix the semantics are: + +| Helper | Value `'foo'` → pattern | SQL | +|-------------------------|---------------------------|------------------| +| `like(col, 'foo')` | `%foo%` | `col LIKE :p` | +| `notLike(col, 'foo')` | `%foo%` | `col NOT LIKE :p`| +| `startLike(col, 'foo')` | `foo%` | `col LIKE :p` | +| `notStartLike(col, 'foo')` | `foo%` | `col NOT LIKE :p`| +| `endLike(col, 'foo')` | `%foo` | `col LIKE :p` | +| `notEndLike(col, 'foo')`| `%foo` | `col NOT LIKE :p`| + +Each helper has the usual `and*` / `or*` variants: + +```php +$qb->from('user')->orLike('username', 'php'); +// WHERE `username` LIKE :username (in the OR bucket) +``` + +If you supply a value that is already a placeholder (e.g. `':needle'`), +the wildcard wrapping is skipped — the placeholder is emitted as-is: + +```php +$qb->from('user')->like('username', $qb->raw(':needle')); +// WHERE `username` LIKE :needle +``` + +## REGEXP + +```php +$qb->regexp('username', '^[a-z]+$'); +// WHERE `username` REGEXP :username +``` + +> ⚠️ `REGEXP` is MySQL-flavored POSIX. PostgreSQL users prefer `~`; +> reach for [RawQuery](raw-queries.md) to spell that out. + +`andRegexp(col, val)` and `orRegexp(col, val)` are the connector-specific +variants. + +## SOUNDEX + +`soundex()` produces a fuzzy match against the SOUNDEX of the supplied +value: + +```php +$qb->soundex('name', 'Robert'); +// WHERE SOUNDEX(`name`) LIKE CONCAT('%', TRIM(TRAILING '0' FROM SOUNDEX(:name)), '%') +``` + +`andSoundex(col, val)` and `orSoundex(col, val)` exist as expected. + +## FIND\_IN\_SET + +MySQL-specific set-membership check: + +```php +$qb->findInSet('roles', 'admin'); +// WHERE FIND_IN_SET(:roles, `roles`) + +$qb->notFindInSet('roles', 'admin'); +// WHERE NOT FIND_IN_SET(:roles, `roles`) +``` + +> 🔐 The v2.0.0 release fixed an SQL-injection vector in this method — +> raw string values are now always parameterized. See the CHANGELOG entry +> for B28. + +`andFindInSet`, `orFindInSet`, `andNotFindInSet`, `orNotFindInSet` are the +connector variants. + +## HAVING + +`having()` accepts the same arguments as `where()` and routes into the +HAVING bucket. Combine with `GROUP BY`: + +```php +$qb->select('author_id') + ->selectCount('id', 'post_count') + ->from('post') + ->groupBy('author_id') + ->having('post_count', '>', 5); +// SELECT `author_id`, COUNT(`id`) AS `post_count` +// FROM `post` WHERE 1 +// GROUP BY `author_id` +// HAVING `post_count` > 5 +``` + +The full helper family (between, in, like, …) is **WHERE-only** — for +HAVING you call `having()` directly with the operator you need, or use +`raw()` for complex aggregate predicates: + +```php +$qb->having($qb->raw('COUNT(id) > 5')); +``` + +## ON (for JOIN closures) + +`on()` is functionally identical to `where()` / `having()`, except that +**string values with a dot are treated as column references** rather than +parameter values: + +```php +$qb->on('c.id', 'p.category_id'); +// ON `c`.`id` = `p`.`category_id` — the right-hand side is NOT parameterized +``` + +That makes it natural to compose JOIN ON expressions via the closure form +of `join()`; see [joins.md](joins.md). + +## Grouping conditions + +For parenthesized conditions, use [`group()`](grouping.md): + +```php +$qb->where('status', 1) + ->group(function (QueryBuilder $g) { + $g->where('type', 3) + ->where('type', 4); + }); +// WHERE `status` = 1 AND (`type` = 3 AND `type` = 4) +``` + +## Quick lookup + +| You want… | Use | +|--------------------------------------------|-----------------------------------------| +| `col = value` | `where(col, value)` or `where(col, '=', value)` | +| `col >= value` | `where(col, '>=', value)` | +| `col IS NULL` | `whereIsNull(col)` | +| `col IS NOT NULL` | `whereIsNotNull(col)` | +| `col BETWEEN a AND b` | `between(col, a, b)` | +| `col IN (...)` | `whereIn(col, [...])` | +| `col LIKE '%foo%'` | `like(col, 'foo')` | +| `col LIKE 'foo%'` | `startLike(col, 'foo')` | +| `col LIKE '%foo'` | `endLike(col, 'foo')` | +| `col REGEXP '…'` | `regexp(col, '…')` | +| `SOUNDEX(col) ~= SOUNDEX(value)` | `soundex(col, value)` | +| `FIND_IN_SET(value, col)` | `findInSet(col, value)` | +| parenthesized group | `group(closure, 'AND' \| 'OR')` | +| raw SQL fragment | `where($qb->raw('…'))` | + +**Next:** [JOINs →](joins.md) diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..422d380 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,26 @@ + + + PSR-12 with a 120-char soft cap on src/, relaxed for tests where expected SQL strings legitimately run long. + + src + tests + + + + + + + + + + + + + + + + + tests/* + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..9ec3eeb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,22 @@ +parameters: + level: 6 + paths: + - src + treatPhpDocTypesAsCertain: false + ignoreErrors: + # The LIKE-family and set / addSet methods deliberately accept either + # a scalar column / value or an array form for batch use. The array + # contents vary by caller — we keep the loose "array" hint on these + # public-API methods to avoid noise in callers. + - + message: '#Method InitORM\\QueryBuilder\\QueryBuilderInterface::(or|and|not|orNot|andNot|start|orStart|andStart|notStart|orStartNot|andStartNot|end|orEnd|andEnd|notEnd|orEndNot|andEndNot)?(L|l)ike\(\) has parameter \$column with no value type specified in iterable type array\.#' + path: src/QueryBuilderInterface.php + - + message: '#Method InitORM\\QueryBuilder\\QueryBuilder::(or|and|not|orNot|andNot|start|orStart|andStart|notStart|orStartNot|andStartNot|end|orEnd|andEnd|notEnd|orEndNot|andEndNot)?(L|l)ike\(\) has parameter \$column with no value type specified in iterable type array\.#' + path: src/Clause/WhereClauseTrait.php + - + message: '#Method InitORM\\QueryBuilder\\QueryBuilderInterface::(set|addSet)\(\) has parameter \$column with no value type specified in iterable type array\.#' + path: src/QueryBuilderInterface.php + - + message: '#Method InitORM\\QueryBuilder\\QueryBuilder::(set|addSet)\(\) has parameter \$column with no value type specified in iterable type array\.#' + path: src/Clause/SetClauseTrait.php diff --git a/phpunit.xml b/phpunit.xml index 52ff4a8..bd42abb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,8 +1,24 @@ - - - - - tests - - - \ No newline at end of file + + + + + tests + + + + + src + + + + + + + + diff --git a/src/Clause/FromClauseTrait.php b/src/Clause/FromClauseTrait.php new file mode 100644 index 0000000..4de71ef --- /dev/null +++ b/src/Clause/FromClauseTrait.php @@ -0,0 +1,59 @@ +structure['table'] = []; + + return $this->addFrom($table, $alias); + } + + /** + * @inheritDoc + */ + public function addFrom(RawQuery|string $table, ?string $alias = null): static + { + if (is_string($table)) { + $table = $this->driver->escapeIdentifier($table); + } + $entry = $table . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + if (!in_array($entry, $this->structure['table'], true)) { + $this->structure['table'][] = $entry; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function table(RawQuery|string $table): static + { + if (is_string($table)) { + $table = $this->driver->escapeIdentifier($table); + } + $this->structure['table'] = [(string) $table]; + + return $this; + } +} diff --git a/src/Clause/JoinClauseTrait.php b/src/Clause/JoinClauseTrait.php new file mode 100644 index 0000000..9141904 --- /dev/null +++ b/src/Clause/JoinClauseTrait.php @@ -0,0 +1,124 @@ +driver->escapeIdentifier($table); + } + $table = (string) $table; + + if ($onStmt instanceof Closure) { + $sub = $this->clone()->resetStructure(); + $onStmt = $onStmt($sub); + if ($onStmt === null) { + $subStructure = $sub->exportQB(); + if ($where = BucketCompiler::compile($subStructure, 'where')) { + $this->where($this->raw($where)); + } + if ($having = BucketCompiler::compile($subStructure, 'having')) { + $this->having($this->raw($having)); + } + $onStmt = BucketCompiler::compile($subStructure, 'on'); + } + } elseif (is_string($onStmt)) { + $onStmt = $this->driver->escapeIdentifier($onStmt); + } + + $type = trim(strtoupper($type)); + switch ($type) { + case 'SELF': + $this->addFrom($table); + $this->where(is_string($onStmt) ? $this->raw($onStmt) : $onStmt); + break; + case 'NATURAL': + case 'NATURAL JOIN': + $this->structure['join'][$table] = 'NATURAL JOIN ' . $table; + break; + default: + $this->structure['join'][$table] = trim($type . ' JOIN ' . $table . ' ON ' . $onStmt); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function selfJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt, 'SELF'); + } + + /** + * @inheritDoc + */ + public function innerJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt); + } + + /** + * @inheritDoc + */ + public function leftJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt, 'LEFT'); + } + + /** + * @inheritDoc + */ + public function rightJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt, 'RIGHT'); + } + + /** + * @inheritDoc + */ + public function leftOuterJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt, 'LEFT OUTER'); + } + + /** + * @inheritDoc + */ + public function rightOuterJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): static + { + return $this->join($table, $onStmt, 'RIGHT OUTER'); + } + + /** + * @inheritDoc + */ + public function naturalJoin(RawQuery|string $table): static + { + return $this->join($table, null, 'NATURAL'); + } +} diff --git a/src/Clause/SelectClauseTrait.php b/src/Clause/SelectClauseTrait.php new file mode 100644 index 0000000..0ad8f99 --- /dev/null +++ b/src/Clause/SelectClauseTrait.php @@ -0,0 +1,309 @@ +driver->escapeIdentifier($column); + } + $this->structure['select'][] = (string) $column; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function clearSelect(): static + { + $this->structure['select'] = []; + + return $this; + } + + /** + * @inheritDoc + */ + public function selectCount(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('COUNT', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectCountDistinct(RawQuery|string $column, ?string $alias = null): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = 'COUNT(DISTINCT ' . $column . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectMax(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('MAX', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectMin(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('MIN', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectAvg(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('AVG', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectAs(RawQuery|string $column, string $alias): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = $column . ' AS ' . $this->driver->escapeIdentifier($alias); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectUpper(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('UPPER', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectLower(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('LOWER', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectLength(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('LENGTH', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectMid(RawQuery|string $column, int $offset, int $length, ?string $alias = null): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = 'MID(' . $column . ', ' . $offset . ', ' . $length . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectLeft(RawQuery|string $column, int $length, ?string $alias = null): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = 'LEFT(' . $column . ', ' . $length . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectRight(RawQuery|string $column, int $length, ?string $alias = null): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = 'RIGHT(' . $column . ', ' . $length . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectDistinct(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('DISTINCT', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectCoalesce(RawQuery|string $column, mixed $default = '0', ?string $alias = null): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + if (is_string($default) && !is_numeric($default)) { + $default = $this->driver->escapeIdentifier($default); + } + $this->structure['select'][] = 'COALESCE(' . $column . ', ' . $default . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function selectSum(RawQuery|string $column, ?string $alias = null): static + { + return $this->pushSelectFunction('SUM', $column, $alias); + } + + /** + * @inheritDoc + */ + public function selectConcat(array $columns, ?string $alias = null): static + { + $escaped = []; + foreach ($columns as $column) { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $escaped[] = (string) $column; + } + $this->structure['select'][] = 'CONCAT(' . implode(', ', $escaped) . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } + + /** + * @inheritDoc + */ + public function groupBy(string|RawQuery|array ...$columns): static + { + foreach ($columns as $column) { + if (is_array($column)) { + $this->groupBy(...$column); + continue; + } + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $value = (string) $column; + if (!in_array($value, $this->structure['group_by'], true)) { + $this->structure['group_by'][] = $value; + } + } + + return $this; + } + + /** + * @inheritDoc + * + * @throws QueryBuilderInvalidArgumentException + */ + public function orderBy(RawQuery|string $column, string $soft = 'ASC'): static + { + $soft = trim(strtoupper($soft)); + if (!in_array($soft, Operators::SORT_DIRECTIONS, true)) { + throw new QueryBuilderInvalidArgumentException('It can only sort as ASC or DESC.'); + } + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + + $orderBy = trim((string) $column) . ' ' . $soft; + if (!in_array($orderBy, $this->structure['order_by'], true)) { + $this->structure['order_by'][] = $orderBy; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function offset(int $offset = 0): static + { + $this->structure['offset'] = (int) abs($offset); + + return $this; + } + + /** + * @inheritDoc + */ + public function limit(int $limit): static + { + $this->structure['limit'] = (int) abs($limit); + + return $this; + } + + /** + * Generic single-argument SQL function projection — used by COUNT, MAX, + * MIN, AVG, SUM, UPPER, LOWER, LENGTH and DISTINCT. Emits + * "FUNCTION(column)" with an optional alias. + * + * @param string $function The function name (no parens). + * @param RawQuery|string $column The argument — escaped if string, + * passed through if {@see RawQuery}. + * @param string|null $alias Optional alias for the projection. + */ + private function pushSelectFunction(string $function, RawQuery|string $column, ?string $alias): static + { + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $this->structure['select'][] = $function . '(' . $column . ')' + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this; + } +} diff --git a/src/Clause/SetClauseTrait.php b/src/Clause/SetClauseTrait.php new file mode 100644 index 0000000..480e726 --- /dev/null +++ b/src/Clause/SetClauseTrait.php @@ -0,0 +1,64 @@ +addSet($column, $value, $strict); + } + + /** + * @inheritDoc + */ + public function addSet(RawQuery|array|string $column, mixed $value = null, bool $strict = true): static + { + unset($strict); // reserved for future use; kept for backwards-compatible signature + + if (is_array($column) && $value === null) { + $row = []; + foreach ($column as $name => $entry) { + $name = (string) $name; + $entry = SqlValueDetector::isSqlParameterOrFunction($entry) + ? $entry + : $this->parameters->add($name, $entry); + $name = $this->driver->escapeIdentifier($name); + $row[$name] = $entry; + } + $this->structure['set'][] = $row; + + return $this; + } + + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $column = (string) $column; + $value = SqlValueDetector::isSqlParameterOrFunction($value) + ? $value + : $this->parameters->add($column, $value); + + $this->structure['set'][][$column] = $value; + + return $this; + } +} diff --git a/src/Clause/StructureTrait.php b/src/Clause/StructureTrait.php new file mode 100644 index 0000000..5a102e6 --- /dev/null +++ b/src/Clause/StructureTrait.php @@ -0,0 +1,133 @@ +driver->getName()); + } + + /** + * @inheritDoc + */ + public function resetStructure(null|array|string $ignoreOrCare = null, ?bool $isIgnore = null): static + { + if ($ignoreOrCare === null) { + $this->structure = self::STRUCTURE; + + return $this; + } + + if (is_string($ignoreOrCare)) { + $ignoreOrCare = [$ignoreOrCare]; + } + + $newStructure = self::STRUCTURE; + foreach ($ignoreOrCare as $key) { + if (!isset($this->structure[$key])) { + continue; + } + $newStructure[$key] = $isIgnore + ? $this->structure[$key] + : (self::STRUCTURE[$key] ?? []); + } + $this->structure = $newStructure; + + return $this; + } + + /** + * @inheritDoc + */ + public function clone(): static + { + return clone $this; + } + + /** + * @inheritDoc + * + * @param array $structure + */ + public function importQB(array $structure, bool $merge = false): static + { + $this->structure = array_merge($merge ? $this->structure : self::STRUCTURE, $structure); + + return $this; + } + + /** + * @inheritDoc + * + * @return array + */ + public function exportQB(): array + { + return $this->structure; + } + + /** + * @inheritDoc + */ + public function getParameter(): ParameterInterface + { + return $this->parameters; + } + + /** + * @inheritDoc + */ + public function setParameter(string $key, mixed $value): static + { + $this->parameters->set($key, $value); + + return $this; + } + + /** + * @inheritDoc + * + * @param array $parameters + */ + public function setParameters(array $parameters = []): static + { + foreach ($parameters as $key => $value) { + $this->parameters->set($key, $value); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getDriver(): DriverInterface + { + return $this->driver; + } +} diff --git a/src/Clause/WhereClauseTrait.php b/src/Clause/WhereClauseTrait.php new file mode 100644 index 0000000..b1211ff --- /dev/null +++ b/src/Clause/WhereClauseTrait.php @@ -0,0 +1,740 @@ +prepareLogical($operator, $value, $logical); + $this->structure['where'][$logical][] = $this->prepareStatement($column, $operator, $value); + + return $this; + } + + /** + * @inheritDoc + */ + public function having(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): static + { + $this->prepareLogical($operator, $value, $logical); + $this->structure['having'][$logical][] = $this->prepareStatement($column, $operator, $value); + + return $this; + } + + /** + * @inheritDoc + */ + public function on(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): static + { + $this->prepareLogical($operator, $value, $logical); + + if (is_string($value) && str_contains($value, '.')) { + $value = $this->raw($this->driver->escapeIdentifier($value)); + } + $this->structure['on'][$logical][] = $this->prepareStatement($column, $operator, $value); + + return $this; + } + + /** + * @inheritDoc + */ + public function andWhere(RawQuery|string $column, mixed $operator = '=', mixed $value = null): static + { + return $this->where($column, $operator, $value); + } + + /** + * @inheritDoc + */ + public function orWhere(RawQuery|string $column, mixed $operator = '=', mixed $value = null): static + { + return $this->where($column, $operator, $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function between(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): static + { + $value = (is_array($firstValue) && count($firstValue) === 2 && $lastValue === null) + ? $firstValue + : [$firstValue, $lastValue]; + + return $this->where($column, 'BETWEEN', $value, $logical); + } + + /** + * @inheritDoc + */ + public function orBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): static + { + return $this->between($column, $firstValue, $lastValue, 'OR'); + } + + /** + * @inheritDoc + */ + public function andBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): static + { + return $this->between($column, $firstValue, $lastValue); + } + + /** + * @inheritDoc + */ + public function notBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): static + { + $value = (is_array($firstValue) && count($firstValue) === 2 && $lastValue === null) + ? $firstValue + : [$firstValue, $lastValue]; + + return $this->where($column, 'NOT BETWEEN', $value, $logical); + } + + /** + * @inheritDoc + */ + public function orNotBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): static + { + return $this->notBetween($column, $firstValue, $lastValue, 'OR'); + } + + /** + * @inheritDoc + */ + public function andNotBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): static + { + return $this->notBetween($column, $firstValue, $lastValue); + } + + /** + * @inheritDoc + */ + public function findInSet(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->where($column, 'FIND_IN_SET', $value, $logical); + } + + /** + * @inheritDoc + */ + public function andFindInSet(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'FIND_IN_SET', $value); + } + + /** + * @inheritDoc + */ + public function orFindInSet(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'FIND_IN_SET', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function notFindInSet(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->where($column, 'NOT FIND_IN_SET', $value, $logical); + } + + /** + * @inheritDoc + */ + public function andNotFindInSet(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'NOT FIND_IN_SET', $value); + } + + /** + * @inheritDoc + */ + public function orNotFindInSet(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'NOT FIND_IN_SET', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function whereIn(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->where($column, 'IN', $value, $logical); + } + + /** + * @inheritDoc + */ + public function whereNotIn(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->where($column, 'NOT IN', $value, $logical); + } + + /** + * @inheritDoc + */ + public function orWhereIn(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'IN', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function orWhereNotIn(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'NOT IN', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function andWhereIn(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'IN', $value); + } + + /** + * @inheritDoc + */ + public function andWhereNotIn(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'NOT IN', $value); + } + + /** + * @inheritDoc + */ + public function regexp(RawQuery|string $column, RawQuery|string $value, string $logical = 'AND'): static + { + return $this->where($column, 'REGEXP', $value, $logical); + } + + /** + * @inheritDoc + */ + public function andRegexp(RawQuery|string $column, RawQuery|string $value): static + { + return $this->where($column, 'REGEXP', $value); + } + + /** + * @inheritDoc + */ + public function orRegexp(RawQuery|string $column, RawQuery|string $value): static + { + return $this->where($column, 'REGEXP', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function soundex(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->where($column, 'SOUNDEX', $value, $logical); + } + + /** + * @inheritDoc + */ + public function andSoundex(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'SOUNDEX', $value); + } + + /** + * @inheritDoc + */ + public function orSoundex(RawQuery|string $column, mixed $value = null): static + { + return $this->where($column, 'SOUNDEX', $value, 'OR'); + } + + /** + * @inheritDoc + */ + public function whereIsNull(RawQuery|string $column, string $logical = 'AND'): static + { + return $this->where($column, 'IS', null, $logical); + } + + /** + * @inheritDoc + */ + public function orWhereIsNull(RawQuery|string $column): static + { + return $this->where($column, 'IS', null, 'OR'); + } + + /** + * @inheritDoc + */ + public function andWhereIsNull(RawQuery|string $column): static + { + return $this->where($column, 'IS'); + } + + /** + * @inheritDoc + */ + public function whereIsNotNull(RawQuery|string $column, string $logical = 'AND'): static + { + return $this->where($column, 'IS NOT', null, $logical); + } + + /** + * @inheritDoc + */ + public function orWhereIsNotNull(RawQuery|string $column): static + { + return $this->where($column, 'IS NOT', null, 'OR'); + } + + /** + * @inheritDoc + */ + public function andWhereIsNotNull(RawQuery|string $column): static + { + return $this->where($column, 'IS NOT'); + } + + /** + * @inheritDoc + */ + public function like(RawQuery|array|string $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): static + { + $operator = match (strtolower($type)) { + 'before', 'start' => 'START LIKE', + 'after', 'end' => 'END LIKE', + default => 'LIKE', + }; + + return $this->where($column, $operator, $value, $logical); + } + + /** + * @inheritDoc + */ + public function orLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): static + { + return $this->like($column, $value, $type, 'OR'); + } + + /** + * @inheritDoc + */ + public function andLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): static + { + return $this->like($column, $value, $type, 'AND'); + } + + /** + * @inheritDoc + */ + public function notLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): static + { + $operator = match (strtolower($type)) { + 'before', 'start' => 'NOT START LIKE', + 'after', 'end' => 'NOT END LIKE', + default => 'NOT LIKE', + }; + + return $this->where($column, $operator, $value, $logical); + } + + /** + * @inheritDoc + */ + public function orNotLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): static + { + return $this->notLike($column, $value, $type, 'OR'); + } + + /** + * @inheritDoc + */ + public function andNotLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): static + { + return $this->notLike($column, $value, $type); + } + + /** + * @inheritDoc + */ + public function startLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->like($column, $value, 'before', $logical); + } + + /** + * @inheritDoc + */ + public function orStartLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->like($column, $value, 'before', 'OR'); + } + + /** + * @inheritDoc + */ + public function andStartLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->like($column, $value, 'before'); + } + + /** + * @inheritDoc + */ + public function notStartLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->notLike($column, $value, 'before', $logical); + } + + /** + * @inheritDoc + */ + public function orStartNotLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->notLike($column, $value, 'before', 'OR'); + } + + /** + * @inheritDoc + */ + public function andStartNotLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->notLike($column, $value, 'before'); + } + + /** + * @inheritDoc + */ + public function endLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->like($column, $value, 'after', $logical); + } + + /** + * @inheritDoc + */ + public function orEndLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->like($column, $value, 'after', 'OR'); + } + + /** + * @inheritDoc + */ + public function andEndLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->like($column, $value, 'after'); + } + + /** + * @inheritDoc + */ + public function notEndLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): static + { + return $this->notLike($column, $value, 'after', $logical); + } + + /** + * @inheritDoc + */ + public function orEndNotLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->notLike($column, $value, 'after', 'OR'); + } + + /** + * @inheritDoc + */ + public function andEndNotLike(RawQuery|array|string $column, mixed $value = null): static + { + return $this->notLike($column, $value, 'after'); + } + + /** + * @inheritDoc + * + * @throws QueryBuilderException + */ + public function subQuery(Closure $closure, ?string $alias = null, bool $isIntervalQuery = true): RawQuery + { + $sub = $this->clone()->resetStructure(); + $closure($sub); + if ($alias !== null && $isIntervalQuery !== true) { + throw new QueryBuilderException('To define alias to a subquery, it must be an inner query.'); + } + + $rawQuery = ($isIntervalQuery ? '(' : '') + . $sub->generateSelectQuery() + . ($isIntervalQuery ? ')' : '') + . ($alias !== null ? ' AS ' . $this->driver->escapeIdentifier($alias) : ''); + + return $this->raw($rawQuery); + } + + /** + * @inheritDoc + * + * @throws QueryBuilderException + */ + public function group(Closure $closure, string $logical = 'AND'): static + { + $logical = strtoupper(strtr($logical, Operators::LOGICAL_ALIASES)); + if (!in_array($logical, Operators::LOGICAL, true)) { + throw new QueryBuilderException('Logical operator OR, AND, && or || it could be.'); + } + + $sub = $this->clone()->resetStructure(); + $closure($sub); + $subStructure = $sub->exportQB(); + + foreach (['where', 'on', 'having'] as $stmt) { + $statement = BucketCompiler::compile($subStructure, $stmt); + if (!empty($statement)) { + $this->structure[$stmt][$logical][] = '(' . $statement . ')'; + } + } + + return $this; + } + + /** + * @inheritDoc + */ + public function raw(mixed $rawQuery): RawQuery + { + return new RawQuery($rawQuery); + } + + // ---- internal helpers ------------------------------------------------ + + /** + * Normalize the logical connector and apply the value-shortcut. + * + * The "value-shortcut" is the convention that lets callers write + * {@code where('col', 5)} instead of {@code where('col', '=', 5)}: when + * the supplied operator slot does not actually look like a SQL operator + * AND the value slot is null, the two are swapped and "=" is assumed. + * + * The connector is also normalized — "&&" and "||" are translated to + * "AND" and "OR" respectively, and an unknown connector raises. + * + * @param mixed $operator Mutated by reference — may be swapped with + * $value when the value-shortcut applies. + * @param mixed $value Mutated by reference — may be filled in from + * $operator when the value-shortcut applies. + * @param string $logical Mutated by reference — normalized to "AND" or + * "OR". + * + * @throws QueryBuilderInvalidArgumentException When the connector is + * neither AND nor OR (after + * aliasing). + */ + private function prepareLogical(mixed &$operator, mixed &$value, string &$logical): void + { + $logical = strtoupper(strtr($logical, Operators::LOGICAL_ALIASES)); + if (!in_array($logical, Operators::LOGICAL, true)) { + throw new QueryBuilderInvalidArgumentException( + 'Logical operator OR, AND, && or || it could be.' + ); + } + + if ($value === null && !in_array($operator, Operators::VALUE_SHORTCUT_BYPASS, true)) { + $value = $operator; + $operator = '='; + } + } + + /** + * Build a single condition fragment ("col op value", "col IN (…)", etc.) + * and register any required parameter bindings on the parameter bag. + * + * Behaviour by operator family: + * + * - Comparison / arithmetic (=, !=, >, +, …): inlines numeric values + * verbatim; parameterizes everything else (RawQuery passes through). + * - IS / IS NOT: emits "col IS NULL" / "col IS NOT NULL" when $value + * is null, otherwise parameterizes the value. + * - LIKE family (LIKE, NOT LIKE, START LIKE, NOT START LIKE, END LIKE, + * NOT END LIKE): wraps the value with "%" wildcards according to the + * family (see {@see Operators::LIKE_PREFIX_WILDCARD} and + * {@see Operators::LIKE_SUFFIX_WILDCARD}), then parameterizes. + * - BETWEEN / NOT BETWEEN: emits "col BETWEEN p1 AND p2", parameterizing + * both bounds (numeric / RawQuery / placeholder inlined). + * - IN / NOT IN: walks the array, deduplicates, inlines numerics and + * parameterizes the rest. RawQuery (e.g. sub-queries) is passed + * through. + * - REGEXP: parameterizes the pattern. + * - FIND_IN_SET / NOT FIND_IN_SET: emits the SQL function call form. + * - SOUNDEX: wraps both sides in SOUNDEX() and trims trailing zeros. + * + * For any unknown operator the default branch parameterizes the value + * straight through; when $value is null and $column matches "fn(args)", + * the column is emitted verbatim with the function name uppercased. + */ + private function prepareStatement(mixed $column, mixed $operator, mixed $value): string + { + $operator = is_string($operator) ? trim($operator) : '='; + if (is_string($column)) { + $column = $this->driver->escapeIdentifier($column); + } + $column = (string) $column; + + if ($value !== null && in_array($operator, array_merge(Operators::COMPARISON, Operators::ARITHMETIC), true)) { + $value = SqlValueDetector::isSqlParameterOrFunction($value) + ? $value + : $this->parameters->add($column, $value); + + return $column . ' ' . $operator . ' ' . $value; + } + $upperCaseOperator = strtoupper($operator); + $searchOperator = str_replace([' ', '_'], '', $upperCaseOperator); + if ($value === null && !in_array($searchOperator, ['IS', 'ISNOT'], true)) { + return $column; + } + + switch ($searchOperator) { + case 'IS': + return $column . ' IS ' + . ($value === null + ? 'NULL' + : (SqlValueDetector::isSqlParameterOrFunction($value) + ? $value + : $this->parameters->add($column, $value))); + case 'ISNOT': + return $column . ' IS NOT ' + . ($value === null + ? 'NULL' + : (SqlValueDetector::isSqlParameterOrFunction($value) + ? $value + : $this->parameters->add($column, $value))); + case 'LIKE': + case 'NOTLIKE': + case 'STARTLIKE': + case 'NOTSTARTLIKE': + case 'ENDLIKE': + case 'NOTENDLIKE': + if ($value instanceof RawQuery) { + // RawQuery: caller has opted out of every safety net — + // inline verbatim, no parameterization, no escape. + return $column + . (in_array($searchOperator, Operators::LIKE_NEGATED, true) ? ' NOT' : '') + . ' LIKE ' . $value; + } + if (!SqlValueDetector::isSqlParameter($value)) { + // Escape LIKE wildcards (%, _) and the escape character + // itself (\) in the supplied value so user input that + // happens to contain those characters is treated as + // literal text rather than as part of the LIKE pattern. + $value = str_replace( + ['\\', '%', '_'], + ['\\\\', '\\%', '\\_'], + (string) $value, + ); + $prefix = in_array($searchOperator, Operators::LIKE_PREFIX_WILDCARD, true) ? '%' : ''; + $suffix = in_array($searchOperator, Operators::LIKE_SUFFIX_WILDCARD, true) ? '%' : ''; + $value = $prefix . $value . $suffix; + $value = $this->parameters->add($column, $value); + } + + return $column + . (in_array($searchOperator, Operators::LIKE_NEGATED, true) ? ' NOT' : '') + . ' LIKE ' . $value; + case 'BETWEEN': + case 'NOTBETWEEN': + return $column . ' ' + . ($searchOperator === 'NOTBETWEEN' ? 'NOT ' : '') + . 'BETWEEN ' + . (SqlValueDetector::isSqlParameterOrFunction($value[0]) + ? $value[0] + : $this->parameters->add($column, $value[0])) + . ' AND ' + . (SqlValueDetector::isSqlParameterOrFunction($value[1]) + ? $value[1] + : $this->parameters->add($column, $value[1])); + case 'IN': + case 'NOTIN': + if (is_array($value)) { + $values = []; + foreach (array_unique($value) as $item) { + if (is_numeric($item)) { + $values[] = $item; + } else { + $values[] = SqlValueDetector::isSqlParameterOrFunction($item) + ? $item + : $this->parameters->add($column, $item); + } + } + $value = '(' . implode(', ', $values) . ')'; + } + + return $column + . ($searchOperator === 'NOTIN' ? ' NOT' : '') + . ' IN ' . $value; + case 'REGEXP': + return $column . ' REGEXP ' + . (SqlValueDetector::isSqlParameterOrFunction($value) + ? $value + : $this->parameters->add($column, $value)); + case 'FINDINSET': + case 'NOTFINDINSET': + if (is_array($value)) { + $value = implode(', ', $value); + } elseif (!SqlValueDetector::isSqlParameterOrFunction($value)) { + // B28: previously inverted — placeholders/RawQuery were + // re-parameterized while raw strings were inlined verbatim. + $value = $this->parameters->add($column, $value); + } + + return ($searchOperator === 'NOTFINDINSET' ? 'NOT ' : '') + . 'FIND_IN_SET(' . $value . ', ' . $column . ')'; + case 'SOUNDEX': + if (!SqlValueDetector::isSqlParameterOrFunction($value)) { + $value = $this->parameters->add($column, $value); + } + + return "SOUNDEX(" . $column . ") LIKE CONCAT('%', TRIM(TRAILING '0' FROM SOUNDEX(" . $value . ")), '%')"; + default: + if ($value === null && preg_match('/([\w_]+)\((.+)\)$/iu', $column, $matches) === 1) { + return strtoupper($matches[1]) . '(' . $matches[2] . ')'; + } + + return $column . ' ' . $operator . ' ' . $this->parameters->add($column, $value); + } + } +} diff --git a/src/Compiler/AbstractCompiler.php b/src/Compiler/AbstractCompiler.php new file mode 100644 index 0000000..00dbcd3 --- /dev/null +++ b/src/Compiler/AbstractCompiler.php @@ -0,0 +1,98 @@ + $structure + */ + protected function compileWhere(array $structure): ?string + { + return $this->compileBucket($structure, 'where'); + } + + /** + * Compile the HAVING bucket, prefixed with " HAVING ". Returns null when + * the bucket is empty. + * + * @param array $structure + */ + protected function compileHaving(array $structure): ?string + { + $body = $this->compileBucket($structure, 'having'); + + return $body === null ? null : ' HAVING ' . $body; + } + + /** + * Compile the AND/OR bucket of WHERE, HAVING or ON. Delegates to + * {@see BucketCompiler::compile()}; see that method for the joining + * rules. + * + * @param array $structure + */ + protected function compileBucket(array $structure, string $key): ?string + { + return BucketCompiler::compile($structure, $key); + } + + /** + * Compile the LIMIT / OFFSET tail. Returns " LIMIT n", " LIMIT m, n", + * " OFFSET n" or null. + * + * @param array $structure + */ + protected function compileLimit(array $structure): ?string + { + if ($structure['limit'] === null && $structure['offset'] === null) { + return null; + } + + $statement = ' '; + if ($structure['limit'] === null) { + $statement .= 'OFFSET ' . $structure['offset']; + } else { + $statement .= 'LIMIT ' + . ($structure['offset'] !== null ? $structure['offset'] . ', ' : '') + . $structure['limit']; + } + + return $statement; + } + + /** + * Returns the schema (table) name targeted by INSERT/UPDATE/DELETE. + * Multiple tables in {@code structure['table']} mean the caller is in + * SELECT/JOIN territory; for mutation queries we use the last entry. + * + * @param array $structure + * + * @throws QueryBuilderException + */ + protected function compileSchemaName(array $structure): string + { + if (empty($structure['table'])) { + throw new QueryBuilderException('Table name not found when query.'); + } + + return (string) end($structure['table']); + } +} diff --git a/src/Compiler/BatchInsertCompiler.php b/src/Compiler/BatchInsertCompiler.php new file mode 100644 index 0000000..91bead4 --- /dev/null +++ b/src/Compiler/BatchInsertCompiler.php @@ -0,0 +1,46 @@ + $structure + * + * @throws QueryBuilderException + */ + public function compile(array $structure): string + { + $columns = array_keys(array_merge(...$structure['set'])); + if (empty($columns)) { + throw new QueryBuilderException('The data set for the insert could not be found.'); + } + $values = []; + foreach ($structure['set'] as $set) { + $value = []; + foreach ($columns as $column) { + $value[$column] = $set[$column] ?? 'NULL'; + } + $values[] = '(' . implode(', ', $value) . ')'; + } + + return 'INSERT INTO' + . ' ' . $this->compileSchemaName($structure) . ' ' + . '(' . implode(', ', $columns) . ')' + . ' VALUES ' + . implode(', ', $values) . ';'; + } +} diff --git a/src/Compiler/BatchUpdateCompiler.php b/src/Compiler/BatchUpdateCompiler.php new file mode 100644 index 0000000..995ebda --- /dev/null +++ b/src/Compiler/BatchUpdateCompiler.php @@ -0,0 +1,87 @@ +exportQB(); + $referenceColumn = $driver->escapeIdentifier($referenceColumn); + + $update = []; + $data = $structure['set']; + $updateData = $columns = $where = []; + + foreach ($data as $set) { + if (!isset($set[$referenceColumn])) { + throw new QueryBuilderException( + 'The reference column does not exist in one or more of the set arrays.' + ); + } + $setData = []; + $where[] = $set[$referenceColumn]; + unset($set[$referenceColumn]); + foreach ($set as $key => $value) { + $setData[$key] = $value; + if (!in_array($key, $columns, true)) { + $columns[] = $key; + } + } + $updateData[] = $setData; + } + + foreach ($columns as $column) { + $syntax = $column . ' = CASE'; + foreach ($updateData as $key => $values) { + if (!array_key_exists($column, $values)) { + continue; + } + $reference = SqlValueDetector::isSqlParameterOrFunction($where[$key]) + ? $where[$key] + : $parameters->add($referenceColumn, $where[$key]); + $syntax .= ' WHEN ' . $referenceColumn . ' = ' . $reference + . ' THEN ' . $values[$column]; + } + $update[] = $syntax . ' ELSE ' . $column . ' END'; + } + + $builder->whereIn($referenceColumn, $where); + $structure = $builder->exportQB(); + + return 'UPDATE ' . $this->compileSchemaName($structure) + . ' SET ' + . implode(', ', $update) + . ' WHERE ' + . (($whereSql = $this->compileWhere($structure)) !== null ? $whereSql : '1') + . ($this->compileHaving($structure) ?? '') + . ($this->compileLimit($structure) ?? ''); + } +} diff --git a/src/Compiler/CompilerInterface.php b/src/Compiler/CompilerInterface.php new file mode 100644 index 0000000..e8175c7 --- /dev/null +++ b/src/Compiler/CompilerInterface.php @@ -0,0 +1,20 @@ + $structure + * + * @throws QueryBuilderException + */ + public function compile(array $structure): string + { + return 'DELETE FROM' + . ' ' + . $this->compileSchemaName($structure) + . ' WHERE ' + . (($where = $this->compileWhere($structure)) !== null ? $where : '1') + . ($this->compileLimit($structure) ?? ''); + } +} diff --git a/src/Compiler/InsertCompiler.php b/src/Compiler/InsertCompiler.php new file mode 100644 index 0000000..b41295c --- /dev/null +++ b/src/Compiler/InsertCompiler.php @@ -0,0 +1,43 @@ + $structure + * + * @throws QueryBuilderException + */ + public function compile(array $structure): string + { + $columns = []; + $values = []; + $set = array_merge(...$structure['set']); + foreach ($set as $column => $value) { + $columns[] = $column; + $values[] = $value; + } + if (empty($columns)) { + throw new QueryBuilderException('The data set for the insert could not be found.'); + } + + return 'INSERT INTO' + . ' ' . $this->compileSchemaName($structure) . ' ' + . '(' . implode(', ', $columns) . ')' + . ' VALUES ' + . '(' . implode(', ', $values) . ');'; + } +} diff --git a/src/Compiler/SelectCompiler.php b/src/Compiler/SelectCompiler.php new file mode 100644 index 0000000..a57f75f --- /dev/null +++ b/src/Compiler/SelectCompiler.php @@ -0,0 +1,36 @@ + $structure + */ + public function compile(array $structure): string + { + return 'SELECT ' + . (empty($structure['select']) ? '*' : implode(', ', $structure['select'])) + . ' FROM ' + . implode(', ', $structure['table']) + . (!empty($structure['join']) ? ' ' . implode(' ', $structure['join']) : '') + . ' WHERE ' + . (($where = $this->compileWhere($structure)) !== null ? $where : '1') + . (!empty($structure['group_by']) ? ' GROUP BY ' . implode(', ', $structure['group_by']) : '') + . ($this->compileHaving($structure) ?? '') + . (!empty($structure['order_by']) ? ' ORDER BY ' . implode(', ', $structure['order_by']) : '') + . ($this->compileLimit($structure) ?? ''); + } +} diff --git a/src/Compiler/UpdateCompiler.php b/src/Compiler/UpdateCompiler.php new file mode 100644 index 0000000..4b88e64 --- /dev/null +++ b/src/Compiler/UpdateCompiler.php @@ -0,0 +1,42 @@ + $structure + * + * @throws QueryBuilderException + */ + public function compile(array $structure): string + { + $set = array_merge(...$structure['set']); + $updateSet = []; + foreach ($set as $column => $value) { + $updateSet[] = $column . ' = ' . $value; + } + if (empty($updateSet)) { + throw new QueryBuilderException('The data set for the update could not be found.'); + } + + return 'UPDATE ' . $this->compileSchemaName($structure) + . ' SET ' . implode(', ', $updateSet) + . ' WHERE ' + . (($where = $this->compileWhere($structure)) !== null ? $where : '1') + . ($this->compileHaving($structure) ?? '') + . ($this->compileLimit($structure) ?? ''); + } +} diff --git a/src/Drivers/AbstractDriver.php b/src/Drivers/AbstractDriver.php new file mode 100644 index 0000000..af8725d --- /dev/null +++ b/src/Drivers/AbstractDriver.php @@ -0,0 +1,93 @@ + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -namespace InitORM\QueryBuilder\Drivers; - -class BaseDriver implements DriverInterface -{ - - protected string $name; - - protected string $escapeChar = ''; - - /** - * @inheritDoc - */ - public function escapeIdentify(string &$string): string - { - if (!empty($this->escapeChar)) { - $string = preg_replace('/\b(?escapeChar . '$0' . $this->escapeChar, str_replace($this->escapeChar, $this->escapeChar . $this->escapeChar, trim($string, $this->escapeChar))); - } - - return $string; - } - - /** - * @inheritDoc - */ - public function getDriver(): ?string - { - return $this->name ?? null; - } - -} diff --git a/src/Drivers/DriverInterface.php b/src/Drivers/DriverInterface.php index 3aa584d..aa8c76a 100644 --- a/src/Drivers/DriverInterface.php +++ b/src/Drivers/DriverInterface.php @@ -1,31 +1,48 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Drivers; - -interface DriverInterface -{ - - /** - * @param string $string - * @return string - */ - public function escapeIdentify(string &$string): string; - - /** - * @return string|null - */ - public function getDriver(): ?string; - -} + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Drivers; - -class MySQL extends BaseDriver -{ - - protected string $name = 'mysql'; - - protected string $escapeChar = '`'; - -} diff --git a/src/Drivers/MySqlDriver.php b/src/Drivers/MySqlDriver.php new file mode 100644 index 0000000..2a81fb4 --- /dev/null +++ b/src/Drivers/MySqlDriver.php @@ -0,0 +1,19 @@ + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Drivers; - -class PgSQL extends BaseDriver -{ - - protected string $escapeChar = '"'; - - protected string $name = 'pgsql'; - - /** - * @inheritDoc - */ - public function escapeIdentify(string &$string): string - { - parent::escapeIdentify($string); - //$string = preg_replace('/(?escapeChar . '$1' . $this->escapeChar, $string); - - return $string; - } - -} diff --git a/src/Drivers/PostgreSqlDriver.php b/src/Drivers/PostgreSqlDriver.php new file mode 100644 index 0000000..2eb482e --- /dev/null +++ b/src/Drivers/PostgreSqlDriver.php @@ -0,0 +1,19 @@ + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Drivers; - -class SQLite extends BaseDriver -{ - - protected string $name = 'sqlite'; - - protected string $escapeChar = '`'; - -} diff --git a/src/Drivers/SqliteDriver.php b/src/Drivers/SqliteDriver.php new file mode 100644 index 0000000..2eff3b9 --- /dev/null +++ b/src/Drivers/SqliteDriver.php @@ -0,0 +1,19 @@ + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Exceptions; - -use Exception; - -class QueryBuilderException extends Exception -{ -} + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder\Exceptions; - -use InvalidArgumentException; - -class QueryBuilderInvalidArgumentException extends InvalidArgumentException -{ -} + OR` precedence for the + * {@code a AND b OR c → (a AND b) OR c} parse). + * + * @param array $structure + */ + public static function compile(array $structure, string $key): ?string + { + $isAndEmpty = empty($structure[$key]['AND']); + $isOrEmpty = empty($structure[$key]['OR']); + if ($isOrEmpty && $isAndEmpty) { + return null; + } + + return (!$isAndEmpty ? implode(' AND ', $structure[$key]['AND']) : '') + . (!$isAndEmpty && !$isOrEmpty ? ' OR ' : '') + . (!$isOrEmpty ? implode(' OR ', $structure[$key]['OR']) : ''); + } + + /** @codeCoverageIgnore */ + private function __construct() + { + } +} diff --git a/src/Helper/SqlValueDetector.php b/src/Helper/SqlValueDetector.php new file mode 100644 index 0000000..91e127c --- /dev/null +++ b/src/Helper/SqlValueDetector.php @@ -0,0 +1,61 @@ +', '<', '>=', '<=', '<>', + ]; + + /** + * Arithmetic operators recognized in WHERE expressions. + */ + public const ARITHMETIC = [ + '+', '-', '*', '/', '%', + '+=', '-=', '*=', '/=', '%=', '&=', '^-=', '|*=', + ]; + + /** + * NULL-check operators. Compiled to "IS NULL" / "IS NOT NULL" when the + * value is null. + */ + public const NULL_CHECK = [ + 'IS', 'IS NOT', + ]; + + /** + * The union of operators that bypass the value-shortcut in + * WhereClauseTrait::prepareLogical(). + */ + public const VALUE_SHORTCUT_BYPASS = [ + 'IS', 'IS NOT', + '=', '!=', '>', '<', '>=', '<=', '<>', + '+', '-', '*', '/', '%', + '+=', '-=', '*=', '/=', '%=', '&=', '^-=', '|*=', + ]; + + /** + * Logical connectors accepted as keys in the structure where/having/on + * buckets. + */ + public const LOGICAL = ['AND', 'OR']; + + /** + * Maps colloquial logical connectors to canonical form. + */ + public const LOGICAL_ALIASES = [ + '&&' => 'AND', + '||' => 'OR', + ]; + + /** + * LIKE-family operators (after stripping spaces/underscores from the + * user-provided string and uppercasing). + */ + public const LIKE_FAMILY = [ + 'LIKE', + 'NOTLIKE', + 'STARTLIKE', + 'NOTSTARTLIKE', + 'ENDLIKE', + 'NOTENDLIKE', + ]; + + /** + * LIKE-family operators whose pattern carries a leading "%" wildcard. + * (Matches strings ending with the supplied value.) + */ + public const LIKE_PREFIX_WILDCARD = [ + 'LIKE', 'NOTLIKE', 'ENDLIKE', 'NOTENDLIKE', + ]; + + /** + * LIKE-family operators whose pattern carries a trailing "%" wildcard. + * (Matches strings starting with the supplied value.) + */ + public const LIKE_SUFFIX_WILDCARD = [ + 'LIKE', 'NOTLIKE', 'STARTLIKE', 'NOTSTARTLIKE', + ]; + + /** + * LIKE-family operators that should be compiled with the "NOT LIKE" + * keyword. + */ + public const LIKE_NEGATED = [ + 'NOTLIKE', 'NOTSTARTLIKE', 'NOTENDLIKE', + ]; + + /** + * BETWEEN operators. + */ + public const BETWEEN = ['BETWEEN', 'NOTBETWEEN']; + + /** + * IN operators. + */ + public const IN = ['IN', 'NOTIN']; + + /** + * FIND_IN_SET operators. + */ + public const FIND_IN_SET = ['FINDINSET', 'NOTFINDINSET']; + + /** + * ORDER BY sort directions accepted by orderBy(). + */ + public const SORT_DIRECTIONS = ['ASC', 'DESC']; + + /** + * Recognize the user-supplied JOIN type aliases that map onto SQL JOIN + * keywords by the JOIN clause builder. + */ + public const JOIN_TYPES = [ + 'INNER', + 'LEFT', + 'RIGHT', + 'LEFT OUTER', + 'RIGHT OUTER', + 'NATURAL', + 'NATURAL JOIN', + 'SELF', + ]; + + /** @codeCoverageIgnore */ + private function __construct() + { + } +} diff --git a/src/ParameterInterface.php b/src/ParameterInterface.php index a058025..cf9cc8d 100644 --- a/src/ParameterInterface.php +++ b/src/ParameterInterface.php @@ -1,58 +1,75 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -interface ParameterInterface -{ - - /** - * @param string $key - * @param mixed $value - * @return self - */ - public function set(string $key, mixed $value): self; - - /** - * @param string|RawQuery $key - * @param mixed $value - * @return string - */ - public function add(RawQuery|string $key, mixed $value): string; - - - /** - * @param array|ParameterInterface ...$arrays - * @return self - */ - public function merge(array|ParameterInterface ...$arrays): self; - - /** - * @param string|null $key - * @param mixed|null $default - * @return array|mixed|null - */ - public function get(?string $key = null, mixed $default = null): mixed; - - /** - * @return array - */ - public function all(): array; - - /** - * @return self - */ - public function reset(): self; - -} +', 0)->where('id', '<', 100)} + * to compile to two distinct placeholders (":id" and ":id_1"). + * + * Keys are sanitized to {@code [A-Za-z0-9_]} and exposed with a leading ":" so + * the resulting array can be handed straight to {@see \PDOStatement::execute()}. + */ +interface ParameterInterface +{ + /** + * Set a single key, overwriting any existing value at that key. The + * stored key is sanitized and prefixed with ":" (so {@code set('id', 5)} + * yields {@code [":id" => 5]}). + */ + public function set(string $key, mixed $value): self; + + /** + * Register a value and return the placeholder name actually assigned. + * + * - Null values short-circuit and return the literal string "NULL" — the + * caller is expected to inline it into the SQL instead of binding. + * - {@see RawQuery} keys are hashed (md5) before sanitization so they + * produce stable, opaque placeholder names. + * - Colliding keys auto-suffix ":foo", ":foo_1", ":foo_2", … + * + * @return string The full placeholder name including the ":" prefix, or + * the literal "NULL" when $value is null. + */ + public function add(RawQuery|string $key, mixed $value): string; + + /** + * Bulk-merge one or more parameter sources onto this bag. Sources can be + * plain arrays or other {@see ParameterInterface} instances. + * + * @param array|ParameterInterface ...$arrays + */ + public function merge(array|ParameterInterface ...$arrays): self; + + /** + * Return either the full map (when $key is null) or a single value with + * an optional default. The default may be a {@see Closure} — in which + * case it is invoked lazily only when the key is missing. + */ + public function get(?string $key = null, mixed $default = null): mixed; + + /** + * The whole placeholder → value map. Suitable for passing to + * {@see \PDOStatement::execute()}. + * + * @return array + */ + public function all(): array; + + /** + * Empty the bag. Returned for fluent chaining. + */ + public function reset(): self; +} diff --git a/src/Parameters.php b/src/Parameters.php index 9c5cb08..1585370 100644 --- a/src/Parameters.php +++ b/src/Parameters.php @@ -1,116 +1,124 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -use Closure; - -class Parameters implements ParameterInterface -{ - - protected array $parameters; - - public function __construct() - { - $this->parameters = []; - } - - /** - * @inheritDoc - */ - public function set(string $key, mixed $value): self - { - $this->parameters[':' . preg_replace("/[^A-Za-z0-9_]/", "", $key)] = $value; - - return $this; - } - - /** - * @inheritDoc - */ - public function add(RawQuery|string $key, mixed $value): string - { - if ($value === null) { - return 'NULL'; - } - if ($key instanceof RawQuery) { - $key = md5((string)$key); - } - $key = preg_replace("/[^A-Za-z0-9_]/", "", $key); - $originKey = ltrim(str_replace('.', '', $key), ':'); - $i = 0; - do { - $key = ':' . ($i === 0 ? $originKey : $originKey . '_' . $i); - ++$i; - $hasParameter = isset($this->parameters[$key]); - } while($hasParameter); - - $this->parameters[$key] = $value; - - return $key; - } - - /** - * @inheritDoc - */ - public function merge(array|ParameterInterface ...$arrays): self - { - foreach ($arrays as $array) { - if ($array instanceof ParameterInterface) { - $array = $array->all(); - } - foreach ($array as $key => $value) { - $this->set($key, $value); - } - } - - return $this; - } - - /** - * @inheritDoc - */ - public function get(?string $key = null, mixed $default = null): mixed - { - if ($key === null) { - return $this->parameters; - } - - $key = ':' . ltrim($key, ':'); - if (isset($this->parameters[$key])) { - return $this->parameters[$key]; - } - - return ($default instanceof Closure) ? call_user_func_array($default, []) : $default; - } - - /** - * @inheritDoc - */ - public function all(): array - { - return $this->parameters; - } - - /** - * @inheritDoc - */ - public function reset(): self - { - $this->parameters = []; - - return $this; - } - -} + + */ + protected array $parameters; + + public function __construct() + { + $this->parameters = []; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value): self + { + $this->parameters[':' . preg_replace('/[^A-Za-z0-9_]/', '', $key)] = $value; + + return $this; + } + + /** + * @inheritDoc + */ + public function add(RawQuery|string $key, mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + if ($key instanceof RawQuery) { + $key = md5((string) $key); + } + $key = preg_replace('/[^A-Za-z0-9_]/', '', $key); + $originKey = ltrim((string) $key, ':'); + $i = 0; + do { + $key = ':' . ($i === 0 ? $originKey : $originKey . '_' . $i); + ++$i; + $hasParameter = isset($this->parameters[$key]); + } while ($hasParameter); + + $this->parameters[$key] = $value; + + return $key; + } + + /** + * @inheritDoc + * + * @param array|ParameterInterface ...$arrays + */ + public function merge(array|ParameterInterface ...$arrays): self + { + foreach ($arrays as $array) { + if ($array instanceof ParameterInterface) { + $array = $array->all(); + } + foreach ($array as $key => $value) { + $this->set($key, $value); + } + } + + return $this; + } + + /** + * @inheritDoc + */ + public function get(?string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->parameters; + } + + $key = ':' . ltrim($key, ':'); + if (isset($this->parameters[$key])) { + return $this->parameters[$key]; + } + + return $default instanceof Closure ? $default() : $default; + } + + /** + * @inheritDoc + */ + public function all(): array + { + return $this->parameters; + } + + /** + * @inheritDoc + */ + public function reset(): self + { + $this->parameters = []; + + return $this; + } +} diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index aaaf4ea..f29d4c6 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -1,1505 +1,198 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -use Closure; -use InitORM\QueryBuilder\Drivers\BaseDriver; -use InitORM\QueryBuilder\Drivers\DriverInterface; -use InitORM\QueryBuilder\Drivers\MySQL; -use InitORM\QueryBuilder\Drivers\PgSQL; -use InitORM\QueryBuilder\Drivers\SQLite; -use InitORM\QueryBuilder\Exceptions\{QueryBuilderInvalidArgumentException, QueryBuilderException}; - -class QueryBuilder implements QueryBuilderInterface -{ - - protected const STRUCTURE = [ - 'select' => [], - 'table' => [], - 'join' => [], - 'where' => [ - 'AND' => [], - 'OR' => [], - ], - 'having' => [ - 'AND' => [], - 'OR' => [], - ], - 'group_by' => [], - 'order_by' => [], - 'offset' => null, - 'limit' => null, - 'set' => [], - 'on' => [ - 'AND' => [], - 'OR' => [], - ], - ]; - - protected array $structure; - - protected ParameterInterface $parameters; - - protected DriverInterface $driver; - - public function __construct(?string $driver = null) - { - $this->structure = self::STRUCTURE; - $this->parameters = new Parameters(); - - $this->driver = match ($driver) { - 'mysql' => new MySQL(), - 'pgsql', 'postgres', 'postgresql' => new PgSQL(), - 'sqlite' => new SQLite(), - default => new BaseDriver(), - }; - } - - /** - * @return string - * @throws QueryBuilderException - */ - public function __toString(): string - { - if (empty($this->structure['set'])) { - return $this->generateSelectQuery(); - } - - $isBatch = $this->isBatch(); - $isInsert = empty($this->structure['where']['OR']) && empty($this->structure['where']['AND']) && empty($this->structure['having']['OR']) && empty($this->structure['having']['AND']); - - if ($isInsert) { - return $isBatch ? $this->generateBatchInsertQuery() : $this->generateInsertQuery(); - } - - return $this->generateUpdateQuery(); - } - - /** - * @inheritDoc - */ - public function newBuilder(): self - { - return new self($this->driver->getDriver()); - } - - /** - * @param string[]|string|null $ignoreOrCare - * @param null|bool $isIgnore - * @return $this - */ - public function resetStructure(null|array|string $ignoreOrCare = null, ?bool $isIgnore = null): self - { - if ($ignoreOrCare === null) { - $this->structure = self::STRUCTURE; - } else { - if (is_string($ignoreOrCare)) { - $ignoreOrCare = [$ignoreOrCare]; - } - - $newStructure = self::STRUCTURE; - foreach ($ignoreOrCare as $key) { - if (!isset($this->structure[$key])) { - continue; - } - if ($isIgnore) { - $newStructure[$key] = $this->structure[$key]; - } else { - $newStructure[$key] = self::STRUCTURE[$key] ?? []; - } - } - - $this->structure = $newStructure; - } - - return $this; - } - - public function clone(): self - { - return (clone $this); - } - - /** - * @inheritDoc - */ - public function importQB(array $structure, bool $merge = false): self - { - $this->structure = array_merge(($merge ? $this->structure : self::STRUCTURE), $structure); - - return $this; - } - - /** - * @inheritDoc - */ - public function exportQB(): array - { - return $this->structure; - } - - /** - * @inheritDoc - */ - public function getParameter(): ParameterInterface - { - return $this->parameters; - } - - /** - * @inheritDoc - */ - public function setParameter(string $key, mixed $value): self - { - $this->parameters->set($key, $value); - - return $this; - } - - /** - * @inheritDoc - */ - public function setParameters(array $parameters = []): self - { - foreach ($parameters as $key => $value) { - $this->parameters->set($key, $value); - } - - return $this; - } - - /** - * @inheritDoc - */ - public function select(...$columns): self - { - foreach ($columns as $column) { - is_string($column) && $this->driver->escapeIdentify($column); - $column = (string)$column; - $this->structure['select'][] = $column; - } - - return $this; - } - - /** - * @inheritDoc - */ - public function clearSelect(): self - { - $this->structure['select'] = []; - - return $this; - } - - /** - * @inheritDoc - */ - public function selectCount(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'COUNT(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectCountDistinct(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'COUNT(DISTINCT ' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - return $this; - } - - /** - * @inheritDoc - */ - public function selectMax(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'MAX(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectMin(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'MIN(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectAvg(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'AVG(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectAs(RawQuery|string $column, string $alias): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = $column . ' AS ' . $this->driver->escapeIdentify($alias); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectUpper(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'UPPER(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectLower(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'LOWER(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectLength(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'LENGTH(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectMid(RawQuery|string $column, int $offset, int $length, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'MID(' . $column . ', ' . $offset . ', ' . $length . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectLeft(RawQuery|string $column, int $length, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'LEFT(' . $column . ', ' . $length . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectRight(RawQuery|string $column, int $length, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'RIGHT(' . $column . ', ' . $length . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectDistinct(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'DISTINCT(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectCoalesce(RawQuery|string $column, $default = '0', ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - is_string($default) && !is_numeric($default) && $this->driver->escapeIdentify($default); - $this->structure['select'][] = 'COALESCE(' . $column . ', ' . $default . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectSum(RawQuery|string $column, ?string $alias = null): self - { - is_string($column) && $this->driver->escapeIdentify($column); - $this->structure['select'][] = 'SUM(' . $column . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function selectConcat(array $columns, ?string $alias = null): self - { - foreach ($columns as &$column) { - is_string($column) && $this->driver->escapeIdentify($column); - $column = (string)$column; - } - $this->structure['select'][] = 'CONCAT(' . implode(', ', $columns) . ')' - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this; - } - - /** - * @inheritDoc - */ - public function from(RawQuery|string $table, ?string $alias = null): self - { - $this->structure['table'] = []; - - return $this->addFrom($table, $alias); - } - - /** - * @inheritDoc - */ - public function addFrom(RawQuery|string $table, ?string $alias = null): self - { - is_string($table) && $this->driver->escapeIdentify($table); - $table = $table . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - if (!in_array($table, $this->structure['table'], true)) { - $this->structure['table'][] = $table; - } - - return $this; - } - - /** - * @inheritDoc - */ - public function table(RawQuery|string $table): self - { - is_string($table) && $this->driver->escapeIdentify($table); - $this->structure['table'] = [(string)$table]; - - return $this; - } - - /** - * @inheritDoc - */ - public function groupBy(string|RawQuery|array ...$columns): self - { - foreach ($columns as $column) { - if (is_array($column)) { - $this->groupBy(...$column); - continue; - } - - is_string($column) && $this->driver->escapeIdentify($column); - $column = (string)$column; - if (!in_array($column, $this->structure['group_by'])) { - $this->structure['group_by'][] = $column; - } - } - - return $this; - } - - /** - * @inheritDoc - */ - public function join(RawQuery|string $table, RawQuery|Closure|string $onStmt = null, string $type = 'INNER'): self - { - is_string($table) && $type !== 'SELF' && $this->driver->escapeIdentify($table); - $table = (string)$table; - - if ($onStmt instanceof Closure) { - $builder = $this->clone()->resetStructure(); - $onStmt = call_user_func_array($onStmt, [&$builder]); - if ($onStmt === null) { - if ($where = $builder->__generateWhereQuery()) { - $this->where($this->raw($where)); - } - if ($having = $builder->__generateHavingQuery()) { - if (str_starts_with($having, ' HAVING ')) { - $having = substr($having, 8); - } - $this->having($this->raw($having)); - } - $onStmt = $builder->__generateOnQuery(); - } - } else if (is_string($onStmt)) { - $this->driver->escapeIdentify($onStmt); - } - - $type = trim(strtoupper($type)); - switch ($type) { - case 'SELF': - $this->addFrom($table); - $this->where(is_string($onStmt) ? $this->raw($onStmt) : $onStmt); - break; - case 'NATURAL': - case 'NATURAL JOIN': - $this->structure['join'][$table] = 'NATURAL JOIN ' . $table; - break; - default: - $this->structure['join'][$table] = trim($type . ' JOIN ' . $table . ' ON ' . $onStmt); - } - - return $this; - } - - /** - * @inheritDoc - */ - public function selfJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt, 'SELF'); - } - - /** - * @inheritDoc - */ - public function innerJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt); - } - - /** - * @inheritDoc - */ - public function leftJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt, 'LEFT'); - } - - /** - * @inheritDoc - */ - public function rightJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt, 'RIGHT'); - } - - /** - * @inheritDoc - */ - public function leftOuterJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt, 'LEFT OUTER'); - } - - /** - * @inheritDoc - */ - public function rightOuterJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, $onStmt, 'RIGHT OUTER'); - } - - /** - * @inheritDoc - */ - public function naturalJoin(RawQuery|string $table, RawQuery|Closure|string $onStmt): self - { - return $this->join($table, null, 'NATURAL'); - } - - /** - * @inheritDoc - */ - public function orderBy(RawQuery|string $column, string $soft = 'ASC'): self - { - $soft = trim(strtoupper($soft)); - if (!in_array($soft, ['ASC', 'DESC'], true)) { - throw new QueryBuilderInvalidArgumentException('It can only sort as ASC or DESC.'); - } - is_string($column) && $this->driver->escapeIdentify($column); - - $orderBy = trim((string)$column) . ' ' . $soft; - - !in_array($orderBy, $this->structure['order_by'], true) && $this->structure['order_by'][] = $orderBy; - - return $this; - } - - /** - * @inheritDoc - */ - public function where(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): self - { - - $this->whereOrHavingPrepare($operator, $value, $logical); - - $this->structure['where'][$logical][] = $this->whereOrHavingStatementPrepare($column, $operator, $value); - - return $this; - } - - /** - * @inheritDoc - */ - public function having(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): self - { - $this->whereOrHavingPrepare($operator, $value, $logical); - $this->structure['having'][$logical][] = $this->whereOrHavingStatementPrepare($column, $operator, $value); - - return $this; - } - - /** - * @inheritDoc - */ - public function on(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): self - { - $this->whereOrHavingPrepare($operator, $value, $logical); - - if (is_string($value) && str_contains($value, '.')) { - $value = $this->raw($this->driver->escapeIdentify($value)); - } - - $this->structure['on'][$logical][] = $this->whereOrHavingStatementPrepare($column, $operator, $value); - - return $this; - } - - /** - * @inheritDoc - */ - public function set(RawQuery|array|string $column, mixed $value = null, bool $strict = true): self - { - return $this->addSet($column, $value, $strict); - } - - /** - * @inheritDoc - */ - public function addSet(RawQuery|array|string $column, mixed $value = null, bool $strict = true): self - { - if (is_array($column) && $value === null) { - $set = []; - foreach ($column as $name => $value) { - $name = (string)$name; - $value = $this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($name, $value); - $this->driver->escapeIdentify($name); - $set[$name] = $value; - } - $this->structure['set'][] = $set; - - return $this; - } - - is_string($column) && $this->driver->escapeIdentify($column); - $column = (string)$column; - $value = $this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($column, $value); - - $this->structure['set'][][$column] = $value; - - return $this; - } - - /** - * @inheritDoc - */ - public function andWhere(RawQuery|string $column, mixed $operator = '=', mixed $value = null): self - { - return $this->where($column, $operator, $value); - } - - /** - * @inheritDoc - */ - public function orWhere(RawQuery|string $column, mixed $operator = '=', mixed $value = null): self - { - return $this->where($column, $operator, $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function between(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): self - { - if (is_array($firstValue) && count($firstValue) == 2 && $lastValue === null) { - $value = $firstValue; - } else { - $value = [$firstValue, $lastValue]; - } - - return $this->where($column, 'BETWEEN', $value, $logical); - } - - /** - * @inheritDoc - */ - public function orBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): self - { - return $this->between($column, [$firstValue, $lastValue], 'OR'); - } - - /** - * @inheritDoc - */ - public function andBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): self - { - return $this->between($column, $firstValue, $lastValue); - } - - /** - * @inheritDoc - */ - public function notBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): self - { - if (is_array($firstValue) && count($firstValue) == 2 && $lastValue === null) { - $value = $firstValue; - } else { - $value = [$firstValue, $lastValue]; - } - - return $this->where($column, 'NOT BETWEEN', $value, $logical); - } - - /** - * @inheritDoc - */ - public function orNotBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): self - { - return $this->notBetween($column, $firstValue, $lastValue, 'OR'); - } - - /** - * @inheritDoc - */ - public function andNotBetween(RawQuery|string $column, mixed $firstValue = null, mixed $lastValue = null): self - { - return $this->notBetween($column, $firstValue, $lastValue); - } - - /** - * @inheritDoc - */ - public function findInSet(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->where($column, 'FIND_IN_SET', $value, $logical); - } - - /** - * @inheritDoc - */ - public function andFindInSet(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'FIND_IN_SET', $value); - } - - /** - * @inheritDoc - */ - public function orFindInSet(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'FIND_IN_SET', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function notFindInSet(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->where($column, 'NOT FIND_IN_SET', $value, $logical); - } - - /** - * @inheritDoc - */ - public function andNotFindInSet(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'NOT FIND_IN_SET', $value); - } - - /** - * @inheritDoc - */ - public function orNotFindInSet(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'NOT FIND_IN_SET', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function whereIn(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->where($column, 'IN', $value, $logical); - } - - /** - * @inheritDoc - */ - public function whereNotIn(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->where($column, 'NOT IN', $value, $logical); - } - - /** - * @inheritDoc - */ - public function orWhereIn(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'IN', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function orWhereNotIn(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'NOT IN', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function andWhereIn(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'IN', $value); - } - - /** - * @inheritDoc - */ - public function andWhereNotIn(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'IN', $value); - } - - /** - * @inheritDoc - */ - public function regexp(RawQuery|string $column, RawQuery|string $value, string $logical = 'AND'): self - { - return $this->where($column, 'REGEXP', $value, $logical); - } - - /** - * @inheritDoc - */ - public function andRegexp(RawQuery|string $column, RawQuery|string $value): self - { - return $this->where($column, 'REGEXP', $value); - } - - /** - * @inheritDoc - */ - public function orRegexp(RawQuery|string $column, RawQuery|string $value): self - { - return $this->where($column, 'REGEXP', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function soundex(RawQuery|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->where($column, 'SOUNDEX', $value, $logical); - } - - /** - * @inheritDoc - */ - public function andSoundex(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'SOUNDEX', $value); - } - - /** - * @inheritDoc - */ - public function orSoundex(RawQuery|string $column, mixed $value = null): self - { - return $this->where($column, 'SOUNDEX', $value, 'OR'); - } - - /** - * @inheritDoc - */ - public function whereIsNull(RawQuery|string $column, string $logical = 'AND'): self - { - return $this->where($column, 'IS', null, $logical); - } - - /** - * @inheritDoc - */ - public function orWhereIsNull(RawQuery|string $column): self - { - return $this->where($column, 'IS', null, 'OR'); - } - - /** - * @inheritDoc - */ - public function andWhereIsNull(RawQuery|string $column): self - { - return $this->where($column, 'IS'); - } - - /** - * @inheritDoc - */ - public function whereIsNotNull(RawQuery|string $column, string $logical = 'AND'): self - { - return $this->where($column, 'IS NOT', null, $logical); - } - - /** - * @inheritDoc - */ - public function orWhereIsNotNull(RawQuery|string $column): self - { - return $this->where($column, 'IS NOT', null, 'OR'); - } - - /** - * @inheritDoc - */ - public function andWhereIsNotNull(RawQuery|string $column): self - { - return $this->where($column, 'IS NOT'); - } - - /** - * @inheritDoc - */ - public function offset(int $offset = 0): self - { - $this->structure['offset'] = (int)abs($offset); - - return $this; - } - - /** - * @inheritDoc - */ - public function limit(int $limit): self - { - $this->structure['limit'] = (int)abs($limit); - - return $this; - } - - /** - * @inheritDoc - */ - public function like(RawQuery|array|string $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): self - { - $operator = match (strtolower($type)) { - 'before', 'start' => 'START LIKE', - 'after', 'end' => 'END LIKE', - default => 'LIKE' - }; - - return $this->where($column, $operator, $value, $logical); - } - - /** - * @inheritDoc - */ - public function orLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): self - { - return $this->like($column, $value, $type); - } - - /** - * @inheritDoc - */ - public function andLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): self - { - return $this->like($column, $value, $type, 'OR'); - } - - /** - * @inheritDoc - */ - public function notLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): self - { - $operator = match (strtolower($type)) { - 'before', 'start' => 'NOT START LIKE', - 'after', 'end' => 'NOT END LIKE', - default => 'NOT LIKE' - }; - - return $this->where($column, $operator, $value, $logical); - } - - /** - * @inheritDoc - */ - public function orNotLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): self - { - return $this->notLike($column, $value, $type, 'OR'); - } - - /** - * @inheritDoc - */ - public function andNotLike(RawQuery|array|string $column, mixed $value = null, string $type = 'both'): self - { - return $this->notLike($column, $value, $type); - } - - /** - * @inheritDoc - */ - public function startLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->like($column, $value, 'before', $logical); - } - - /** - * @inheritDoc - */ - public function orStartLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->like($column, $value, 'before', 'OR'); - } - - /** - * @inheritDoc - */ - public function andStartLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->like($column, $value, 'before'); - } - - /** - * @inheritDoc - */ - public function notStartLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->notLike($column, $value, 'before', $logical); - } - - /** - * @inheritDoc - */ - public function orStartNotLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->notLike($column, $value, 'before', 'OR'); - } - - /** - * @inheritDoc - */ - public function andStartNotLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->notLike($column, $value, 'before'); - } - - /** - * @inheritDoc - */ - public function endLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->like($column, $value, 'after', $logical); - } - - /** - * @inheritDoc - */ - public function orEndLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->like($column, $value, 'after', 'OR'); - } - - /** - * @inheritDoc - */ - public function andEndLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->like($column, $value, 'after'); - } - - /** - * @inheritDoc - */ - public function notEndLike(RawQuery|array|string $column, mixed $value = null, string $logical = 'AND'): self - { - return $this->notLike($column, $value, 'after', $logical); - } - - /** - * @inheritDoc - */ - public function orEndNotLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->notLike($column, $value, 'after', 'OR'); - } - - /** - * @inheritDoc - */ - public function andEndNotLike(RawQuery|array|string $column, mixed $value = null): self - { - return $this->notLike($column, $value, 'after'); - } - - /** - * @inheritDoc - */ - public function subQuery(Closure $closure, ?string $alias = null, bool $isIntervalQuery = true): RawQuery - { - $builder = $this->clone()->resetStructure(); - - call_user_func_array($closure, [&$builder]); - if ($alias !== null && $isIntervalQuery !== TRUE) { - throw new QueryBuilderException('To define alias to a subquery, it must be an inner query.'); - } - - $rawQuery = ($isIntervalQuery ? '(' : '') - . $builder->generateSelectQuery() - . ($isIntervalQuery ? ')' : '') - . ($alias !== null ? ' AS ' . $this->driver->escapeIdentify($alias) : ''); - - return $this->raw($rawQuery); - } - - /** - * @inheritDoc - */ - public function group(Closure $closure, string $logical = 'AND'): self - { - $logical = str_replace(['&&', '||'], ['AND', 'OR'], strtoupper($logical)); - if(!in_array($logical, ['AND', 'OR'], true)){ - throw new QueryBuilderException('Logical operator OR, AND, && or || it could be.'); - } - - $builder = $this->clone(); - call_user_func_array($closure, [$builder->resetStructure()]); - - foreach (['where', 'on', 'having'] as $stmt) { - $statement = $builder->__generateStructure($stmt); - !empty($statement) && $this->structure[$stmt][$logical][] = '(' . $statement . ')'; - } - - return $this; - } - - /** - * @inheritDoc - */ - public function raw(mixed $rawQuery): RawQuery - { - return new RawQuery($rawQuery); - } - - /** - * @return string - * @throws QueryBuilderException - */ - public function generateInsertQuery(): string - { - $columns = []; - $values = []; - $set = array_merge(...$this->structure['set']); - foreach ($set as $column => $value) { - $columns[] = $column; - $values[] = $value; - } - if (empty($columns)) { - throw new QueryBuilderException('The data set for the insert could not be found.'); - } - - return 'INSERT INTO' - . ' ' . $this->__generateSchemaName() . ' ' - . '(' . implode(', ', $columns) . ')' - . ' VALUES ' - . '(' . implode(', ', $values) . ');'; - } - - /** - * @inheritDoc - */ - public function generateBatchInsertQuery(): string - { - $columns = array_keys(array_merge(...$this->structure['set'])); - if (empty($columns)) { - throw new QueryBuilderException('The data set for the insert could not be found.'); - } - $values = []; - foreach ($this->structure['set'] as $set) { - $value = []; - foreach ($columns as $column) { - $value[$column] = $set[$column] ?? 'NULL'; - } - $values[] = '(' . implode(', ', $value) . ')'; - } - - return 'INSERT INTO' - . ' ' . $this->__generateSchemaName() . ' ' - . '(' . implode(', ', $columns) . ')' - . ' VALUES ' - . implode(', ', $values) . ';'; - } - - /** - * @inheritDoc - */ - public function generateDeleteQuery(): string - { - return 'DELETE FROM' - . ' ' - . $this->__generateSchemaName() - . ' WHERE ' - . (($where = $this->__generateWhereQuery()) !== null ? $where : '1') - . ($this->__generateLimitQuery() ?? ''); - } - - /** - * @inheritDoc - */ - public function generateSelectQuery(array $selector = [], array $conditions = []): string - { - !empty($selector) && $this->select(...$selector); - if (!empty($conditions)) { - foreach ($conditions as $column => $value) { - if (is_string($column)) { - $this->where($column, $value); - } else { - $this->where($value); - } - } - } - - return 'SELECT ' - . (empty($this->structure['select']) ? '*' : implode(', ', $this->structure['select'])) - . ' FROM ' - . implode(', ', $this->structure['table']) - . (!empty($this->structure['join']) ? ' ' . implode(' ', $this->structure['join']) : '') - . ' WHERE ' - . (($where = $this->__generateWhereQuery()) ? $where : '1') - . (!empty($this->structure['group_by']) ? ' GROUP BY ' . implode(', ', $this->structure['group_by']) : '') - . ($this->__generateHavingQuery() ?? '') - . (!empty($this->structure['order_by']) ? ' ORDER BY ' . implode(', ', $this->structure['order_by']) : '') - . ($this->__generateLimitQuery() ?? ''); - } - - /** - * @inheritDoc - */ - public function generateUpdateQuery(): string - { - $set = array_merge(...$this->structure['set']); - $updateSet = []; - foreach ($set as $column => $value) { - $updateSet[] = $column . ' = ' . $value; - } - if (empty($updateSet)) { - throw new QueryBuilderException('The data set for the insert could not be found.'); - } - - return 'UPDATE ' . $this->__generateSchemaName() - . ' SET ' . implode(', ', $updateSet) - . ' WHERE ' - . (($where = $this->__generateWhereQuery()) ? $where : '1') - . ($this->__generateHavingQuery() ?? '') - . ($this->__generateLimitQuery() ?? ''); - } - - /** - * @inheritDoc - */ - public function generateUpdateBatchQuery(string $referenceColumn): string - { - $update = []; - $this->driver->escapeIdentify($referenceColumn); - $data = $this->structure['set']; - $updateData = $columns = $where = []; - foreach ($data as $set) { - if (!isset($set[$referenceColumn])) { - throw new QueryBuilderException('The reference column does not exist in one or more of the set arrays.'); - } - $setData = []; - $where[] = $set[$referenceColumn]; - unset($set[$referenceColumn]); - foreach ($set as $key => $value) { - $setData[$key] = $value; - (!in_array($key, $columns)) && $columns[] = $key; - } - $updateData[] = $setData; - } - foreach ($columns as $column) { - $syntax = $column . ' = CASE'; - foreach ($updateData as $key => $values) { - if (!array_key_exists($column, $values)) { - continue; - } - $syntax .= ' WHEN ' . $referenceColumn . ' = ' - . ($this->isSQLParameterOrFunction($where[$key]) ? $where[$key] : $this->parameters->add($referenceColumn, $where[$key])) - . ' THEN ' - . $values[$column]; - } - $update[] = $syntax . ' ELSE ' . $column .' END'; - } - - $this->whereIn($referenceColumn, $where); - - return 'UPDATE ' . $this->__generateSchemaName() - . ' SET ' - . implode(', ', $update) - . ' WHERE ' - . (($where = $this->__generateWhereQuery()) ? $where : '1') - . ($this->__generateHavingQuery() ?? '') - . ($this->__generateLimitQuery() ?? ''); - } - - protected function isSQLParameter($value): bool - { - return (is_string($value)) && ($value === '?' || preg_match('/^:[(\w)]+$/', $value)); - } - - protected function isSQLParameterOrFunction($value): bool - { - return ((is_string($value)) && ( - $value === '?' - || preg_match('/^:[(\w)]+$/', $value) - || preg_match('/^[a-zA-Z_]+[.]+[a-zA-Z_]+$/', $value) - || preg_match('/^[a-zA-Z_]+\(\)$/', $value) - )) || ($value instanceof RawQuery) || is_int($value); - } - - public function isBatch(): bool - { - foreach ($this->structure['set'] as $set) { - if (is_array($set) && count($set) > 1) { - return true; - } - } - - return false; - } - - private function whereOrHavingStatementPrepare($column, $operator, $value): string - { - $operator = trim($operator); - is_string($column) && $this->driver->escapeIdentify($column); - $column = (string)$column; - - if ($value !== null && in_array($operator, [ - '=', '!=', '>', '<', '>=', '<=', '<>', - '+', '-', '*', '/', '%', - '+=', '-=', '*=', '/=', '%=', '&=', '^-=', '|*=' - ], true)) { - return $column . ' ' . $operator . ' ' - . ($this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($column, $value)); - } - $upperCaseOperator = strtoupper($operator); - $searchOperator = str_replace([' ', '_'], '', $upperCaseOperator); - if ($value === null && !in_array($searchOperator, ['IS', 'ISNOT'])) { - return $column; - } - - switch ($searchOperator) { - case 'IS': - return $column . ' IS ' - . ((($value === null) ? 'NULL' : ($this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($column, $value)))); - case 'ISNOT': - return $column . ' IS NOT ' - . ((($value === null) ? 'NULL' : ($this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($column, $value)))); - case 'LIKE': - case 'NOTLIKE': - case 'STARTLIKE': - case 'NOTSTARTLIKE': - case 'ENDLIKE': - case 'NOTENDLIKE': - if (!$this->isSQLParameter($value)) { - $value = (in_array($searchOperator, ['LIKE', 'NOTLIKE', 'STARTLIKE', 'NOTSTARTLIKE']) ? '%' : '') - . $value - . (in_array($searchOperator, ['LIKE', 'NOTLIKE', 'ENDLIKE', 'NOTENDLIKE']) ? '%' : ''); - - $value = $this->parameters->add($column, $value); - } - - return $column - . (in_array($searchOperator, ['NOTSTARTLIKE', 'NOTLIKE', 'NOTENDLIKE']) ? ' NOT' : '') - . ' LIKE ' . $value; - case 'BETWEEN': - case 'NOTBETWEEN': - return $column . ' ' - . ($searchOperator === 'NOTBETWEEN' ? 'NOT ' : '') - . 'BETWEEN ' - . ($this->isSQLParameterOrFunction($value[0]) ? $value[0] : $this->parameters->add($column, $value[0])) - . ' AND ' - . ($this->isSQLParameterOrFunction($value[1]) ? $value[1] : $this->parameters->add($column, $value[1])); - case 'IN': - case 'NOTIN': - if (is_array($value)) { - $values = []; - array_map(function ($item) use (&$values, $column) { - if (is_numeric($item)) { - $values[] = $item; - } else { - $values[] = $this->isSQLParameterOrFunction($item) ? $item : $this->parameters->add($column, $item); - } - }, array_unique($value)); - $value = '(' . implode(', ', $values) . ')'; - } - return $column - . ($searchOperator === 'NOTIN' ? ' NOT' : '') - . ' IN ' . $value; - case 'REGEXP': - return $column . ' REGEXP ' - . ($this->isSQLParameterOrFunction($value) ? $value : $this->parameters->add($column, $value)); - case 'FINDINSET': - case 'NOTFINDINSET': - if (is_array($value)) { - $value = implode(', ', $value); - } elseif ($this->isSQLParameterOrFunction($value)) { - $value = $this->parameters->add($column, $value); - } - return ($searchOperator === 'NOTFINDINSET' ? 'NOT ' : '') - . 'FIND_IN_SET(' . $value . ', ' . $column . ')'; - case 'SOUNDEX': - if (!$this->isSQLParameterOrFunction($value)) { - $value = $this->parameters->add($column, $value); - } - return "SOUNDEX(" . $column . ") LIKE CONCAT('%', TRIM(TRAILING '0' FROM SOUNDEX(" . $value . ")), '%')"; - default: - if ($value === null && preg_match('/([\w_]+)\((.+)\)$/iu', $column, $matches) !== FALSE) { - return strtoupper($matches[1]) . '(' . $matches[2] . ')'; - } - return $column . ' ' . $operator . ' ' . $this->parameters->add($column, $value); - } - } - - /** - * @return string - * @throws QueryBuilderException - */ - private function __generateSchemaName(): string - { - if (!empty($this->structure['table'])) { - $table = end($this->structure['table']); - } else { - throw new QueryBuilderException('Table name not found when query.'); - } - - return $table; - } - - private function __generateLimitQuery(): ?string - { - if ($this->structure['limit'] === null && $this->structure['offset'] === null) { - return null; - } - $statement = ' '; - if ($this->structure['limit'] === null) { - $statement .= 'OFFSET ' . $this->structure['offset']; - } else { - $statement .= 'LIMIT ' - . ($this->structure['offset'] !== null ? $this->structure['offset'] . ', ' : '') - . $this->structure['limit']; - } - - return $statement; - } - - private function __generateOnQuery(): ?string - { - return $this->__generateStructure('on'); - } - - private function __generateHavingQuery(): ?string - { - $stmt = $this->__generateStructure('having'); - - return $stmt === null ? null : ' HAVING ' . $stmt; - } - - private function __generateWhereQuery(): ?string - { - return $this->__generateStructure('where'); - } - - private function __generateStructure(string $key): ?string - { - $isAndEmpty = empty($this->structure[$key]['AND']); - $isOrEmpty = empty($this->structure[$key]['OR']); - if ($isOrEmpty && $isAndEmpty) { - return null; - } - - return (!$isAndEmpty ? implode(' AND ', $this->structure[$key]['AND']) : '') - . (!$isAndEmpty && !$isOrEmpty ? ' AND ' : '') - . (!$isOrEmpty ? implode(' OR ', $this->structure[$key]['OR']) : ''); - } - - private function whereOrHavingPrepare(&$operator, &$value, &$logical): void - { - $logical = strtoupper(strtr($logical, [ - '&&' => 'AND', - '||' => 'OR', - ])); - if (!in_array($logical, ['AND', 'OR'], true)) { - throw new QueryBuilderInvalidArgumentException('Logical operator OR, AND, && or || it could be.'); - } - - if ($value === null && !in_array($operator, [ - 'IS', 'IS NOT', - '=', '!=', '>', '<', '>=', '<=', '<>', - '+', '-', '*', '/', '%', - '+=', '-=', '*=', '/=', '%=', '&=', '^-=', '|*=' - ])) { - $value = $operator; - $operator = '='; - } - } - -} + [], + 'table' => [], + 'join' => [], + 'where' => ['AND' => [], 'OR' => []], + 'having' => ['AND' => [], 'OR' => []], + 'group_by' => [], + 'order_by' => [], + 'offset' => null, + 'limit' => null, + 'set' => [], + 'on' => ['AND' => [], 'OR' => []], + ]; + + /** @var array */ + protected array $structure; + protected ParameterInterface $parameters; + protected DriverInterface $driver; + + public function __construct(?string $driver = null) + { + $this->structure = self::STRUCTURE; + $this->parameters = new Parameters(); + $this->driver = match ($driver) { + 'mysql' => new MySqlDriver(), + 'pgsql', 'postgres', 'postgresql' => new PostgreSqlDriver(), + 'sqlite' => new SqliteDriver(), + default => new GenericDriver(), + }; + } + + /** + * Deep-clone the parameter bag so mutations on the clone do not bleed + * back into the original; the driver is stateless and may stay shared. + */ + public function __clone() + { + $this->parameters = clone $this->parameters; + } + + /** + * Heuristic dispatch: when no SET is present, emits a SELECT; otherwise + * picks between (batch) INSERT and UPDATE based on whether any WHERE / + * HAVING clauses have been set. + * + * @throws QueryBuilderException + */ + public function __toString(): string + { + if (empty($this->structure['set'])) { + return $this->generateSelectQuery(); + } + + $isBatch = $this->isBatch(); + $isInsert = empty($this->structure['where']['OR']) + && empty($this->structure['where']['AND']) + && empty($this->structure['having']['OR']) + && empty($this->structure['having']['AND']); + + if ($isInsert) { + return $isBatch ? $this->generateBatchInsertQuery() : $this->generateInsertQuery(); + } + + return $this->generateUpdateQuery(); + } + + /** + * @param array $selector + * @param array $conditions + * + * @throws QueryBuilderException + */ + public function generateSelectQuery(array $selector = [], array $conditions = []): string + { + if (!empty($selector)) { + $this->select(...$selector); + } + if (!empty($conditions)) { + foreach ($conditions as $column => $value) { + if (is_string($column)) { + $this->where($column, $value); + } else { + $this->where($value); + } + } + } + + return (new SelectCompiler())->compile($this->structure); + } + + /** + * @throws QueryBuilderException + */ + public function generateInsertQuery(): string + { + return (new InsertCompiler())->compile($this->structure); + } + + /** + * @throws QueryBuilderException + */ + public function generateBatchInsertQuery(): string + { + return (new BatchInsertCompiler())->compile($this->structure); + } + + /** + * @throws QueryBuilderException + */ + public function generateUpdateQuery(): string + { + return (new UpdateCompiler())->compile($this->structure); + } + + /** + * @throws QueryBuilderException + */ + public function generateUpdateBatchQuery(string $referenceColumn): string + { + return (new BatchUpdateCompiler())->compile($this, $referenceColumn, $this->driver, $this->parameters); + } + + /** + * @throws QueryBuilderException + */ + public function generateDeleteQuery(): string + { + return (new DeleteCompiler())->compile($this->structure); + } + + /** + * True if any row in the SET bucket carries more than one column — used + * by __toString() to pick between INSERT and INSERT-batch. + */ + public function isBatch(): bool + { + foreach ($this->structure['set'] as $set) { + if (is_array($set) && count($set) > 1) { + return true; + } + } + + return false; + } +} diff --git a/src/QueryBuilderFactory.php b/src/QueryBuilderFactory.php index 2ee9245..9cd2e4f 100644 --- a/src/QueryBuilderFactory.php +++ b/src/QueryBuilderFactory.php @@ -1,28 +1,26 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -class QueryBuilderFactory implements QueryBuilderFactoryInterface -{ - - /** - * @inheritDoc - */ - public function createQueryBuilder(?string $driver = null): QueryBuilderInterface - { - return new QueryBuilder($driver); - } - -} \ No newline at end of file + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -interface QueryBuilderFactoryInterface -{ - /** - * @param string|null $driver - * @return QueryBuilderInterface - * @throws - */ - public function createQueryBuilder(?string $driver = null): QueryBuilderInterface; - -} + - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -use Closure; -use InitORM\QueryBuilder\Exceptions\QueryBuilderException; - - -interface QueryBuilderInterface -{ - - /** - * @return self - */ - public function newBuilder(): self; - - /** - * @param array $structure - * @param bool $merge - * @return self - */ - public function importQB(array $structure, bool $merge = false): self; - - /** - * @return array - */ - public function exportQB(): array; - - /** - * @return ParameterInterface - */ - public function getParameter(): ParameterInterface; - - /** - * @param string $key - * @param string|int|float|bool|null $value - * @return $this - */ - public function setParameter(string $key, mixed $value): self; - - /** - * @param array $parameters - * @return $this - */ - public function setParameters(array $parameters = []): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] ...$columns - * @return $this - */ - public function select(...$columns): self; - - /** - * @return self - */ - public function clearSelect(): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectCount(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param RawQuery|string $column - * @param string|null $alias - * @return self - */ - public function selectCountDistinct(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectMax(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectMin(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectAvg(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string $alias - * @return $this - */ - public function selectAs(RawQuery|string $column, string $alias): self; - - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectUpper(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectLower(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectLength(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param int $offset - * @param int $length - * @param string|null $alias - * @return $this - */ - public function selectMid(RawQuery|string $column, int $offset, int $length, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param int $length - * @param string|null $alias - * @return $this - */ - public function selectLeft(RawQuery|string $column, int $length, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param int $length - * @param string|null $alias - * @return $this - */ - public function selectRight(RawQuery|string $column, int $length, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return $this - */ - public function selectDistinct(RawQuery|string $column, ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param mixed $default - * @param string|null $alias - * @return $this - */ - public function selectCoalesce(RawQuery|string $column, mixed $default = '0', ?string $alias = null): self; - - /** - * @param string|RawQuery $column - * @param string|null $alias - * @return self - */ - public function selectSum(string|RawQuery $column, ?string $alias = null): self; - - /** - * @param string[]|RawQuery[] $columns - * @param string|null $alias - * @return self - */ - public function selectConcat(array $columns, ?string $alias = null): self; - - - /** - * @param string|RawQuery $table - * @param string|null $alias - * @return self - */ - public function from(RawQuery|string $table, ?string $alias = null): self; - - /** - * @param string|RawQuery $table - * @param string|null $alias - * @return self - */ - public function addFrom(RawQuery|string $table, ?string $alias = null): self; - - /** - * @param string|RawQuery $table - * @return self - */ - public function table(string|RawQuery $table): self; - - /** - * @param string|RawQuery|array ...$columns - * @return self - */ - public function groupBy(string|RawQuery|array ...$columns): self; - - - /** - * @param string|RawQuery $table - * @param string|Closure|RawQuery|null $onStmt - * @param string $type - * @return self - */ - public function join(RawQuery|string $table, RawQuery|string|Closure $onStmt = null, string $type = 'INNER'): self; - - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function selfJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function innerJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function leftJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function rightJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function leftOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function rightOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $table - * @param string|RawQuery|Closure $onStmt - * @return self - */ - public function naturalJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): self; - - /** - * @param string|RawQuery $column - * @param string $soft [ASC|DESC] - * @return $this - */ - public function orderBy(RawQuery|string $column, string $soft = 'ASC'): self; - - /** - * @param string|RawQuery $column - * @param string $operator - * @param mixed|null $value - * @param string $logical [] - * @return self - */ - public function where(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery $column - * @param string $operator - * @param mixed|null $value - * @param string $logical [] - * @return self - */ - public function having(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND'): self; - - /** - * @param RawQuery|string $column - * @param string $operator - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function on(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND'): self; - - - /** - * @param array|string|RawQuery $column - * @param mixed|null $value - * @param bool $strict - * @return $this - */ - public function set(RawQuery|array|string $column, mixed $value = null, bool $strict = true): self; - - - /** - * @param array|string|RawQuery $column - * @param mixed|null $value - * @param bool $strict - * @return $this - */ - public function addSet(RawQuery|array|string $column, mixed $value = null, bool $strict = true): self; - - - /** - * @param string|RawQuery $column - * @param string $operator - * @param mixed|null $value - * @return self - */ - public function andWhere(string|RawQuery $column, string $operator = '=', mixed $value = null): self; - - - /** - * @param string|RawQuery $column - * @param string $operator - * @param mixed|null $value - * @return self - */ - public function orWhere(string|RawQuery $column, string $operator = '=', mixed $value = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @param string $logical - * @return self - */ - public function between(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @return self - */ - public function orBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @return self - */ - public function andBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @param string $logical - * @return self - */ - public function notBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @return self - */ - public function orNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $firstValue - * @param mixed|null $lastValue - * @return self - */ - public function andNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function findInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function andFindInSet(string|RawQuery $column, mixed $value = null): self; - - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function orFindInSet(string|RawQuery $column, mixed $value = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function notFindInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function andNotFindInSet(string|RawQuery $column, mixed $value = null): self; - - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function orNotFindInSet(string|RawQuery $column, mixed $value = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function whereIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function whereNotIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function orWhereIn(string|RawQuery $column, mixed $value = null): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function orWhereNotIn(string|RawQuery $column, mixed $value = null): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function andWhereIn(string|RawQuery $column, mixed $value = null): self; - - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function andWhereNotIn(string|RawQuery $column, mixed $value = null): self; - - - /** - * @param string|RawQuery $column - * @param string|RawQuery $value - * @param string $logical - * @return self - */ - public function regexp(string|RawQuery $column, string|RawQuery $value, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @param string|RawQuery $value - */ - public function andRegexp(string|RawQuery $column, string|RawQuery $value): self; - - /** - * @param string|RawQuery $column - * @param string|RawQuery $value - */ - public function orRegexp(string|RawQuery $column, string|RawQuery $value): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @param string $logical - * @return self - */ - public function soundex(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function andSoundex(string|RawQuery $column, mixed $value = null): self; - - /** - * @param string|RawQuery $column - * @param mixed|null $value - * @return self - */ - public function orSoundex(string|RawQuery $column, mixed $value = null): self; - - /** - * @param string|RawQuery $column - * @param string $logical - * @return self - */ - public function whereIsNull(string|RawQuery $column, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @return self - */ - public function orWhereIsNull(string|RawQuery $column): self; - - - /** - * @param string|RawQuery $column - * @return self - */ - public function andWhereIsNull(string|RawQuery $column): self; - - /** - * @param string|RawQuery $column - * @param string $logical - * @return self - */ - public function whereIsNotNull(string|RawQuery $column, string $logical = 'AND'): self; - - - /** - * @param string|RawQuery $column - * @return self - */ - public function orWhereIsNotNull(string|RawQuery $column): self; - - - /** - * @param string|RawQuery $column - * @return self - */ - public function andWhereIsNotNull(string|RawQuery $column): self; - - /** - * @param int $offset - * @return self - */ - public function offset(int $offset = 0): self; - - - /** - * @param int $limit - * @return self - */ - public function limit(int $limit): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @param string $logical - * @return self - */ - public function like(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @return self - */ - public function orLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @return self - */ - public function andLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): self; - - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @param string $logical - * @return self - */ - public function notLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @return self - */ - public function orNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $type [both|before|after|none] - * @return self - */ - public function andNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $logical - * @return self - */ - public function startLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function orStartLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function andStartLike(string|RawQuery|array $column, mixed $value = null): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $logical - * @return self - */ - public function notStartLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function orStartNotLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function andStartNotLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $logical - * @return self - */ - public function endLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function orEndLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function andEndLike(string|RawQuery|array $column, mixed $value = null): self; - - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @param string $logical - * @return self - */ - public function notEndLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): self; - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function orEndNotLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param string|RawQuery|string[]|RawQuery[] $column - * @param mixed $value - * @return self - */ - public function andEndNotLike(string|RawQuery|array $column, mixed $value = null): self; - - - /** - * @param Closure $closure - * @param string|null $alias - * @param bool $isIntervalQuery - * @return RawQuery - * @throws QueryBuilderException - */ - public function subQuery(Closure $closure, ?string $alias = null, bool $isIntervalQuery = true): RawQuery; - - /** - * Where|On|Having - * - * @param Closure $closure - * @return self - * @throws QueryBuilderException - */ - public function group(Closure $closure): self; - - /** - * @param mixed $rawQuery - * @return RawQuery - */ - public function raw(mixed $rawQuery): RawQuery; - - /** - * @param array $selector - * @param array $conditions - * @return string - * @throws QueryBuilderException - */ - public function generateSelectQuery(array $selector = [], array $conditions = []): string; - - /** - * @return mixed - * @throws QueryBuilderException - */ - public function generateUpdateQuery(): string; - - /** - * @param string $referenceColumn - * @return string - * @throws QueryBuilderException - */ - public function generateUpdateBatchQuery(string $referenceColumn): string; - - /** - * @return string - * @throws QueryBuilderException - */ - public function generateInsertQuery(): string; - - /** - * @return string - * @throws QueryBuilderException - */ - public function generateBatchInsertQuery(): string; - - /** - * @return string - * @throws QueryBuilderException - */ - public function generateDeleteQuery(): string; - - - -} + $structure A structure shape compatible + * with the builder. + * @param bool $merge When true, the supplied array + * is merged onto the current + * structure; when false (default) + * the structure is reset first. + */ + public function importQB(array $structure, bool $merge = false): static; + + /** + * Snapshot the current structure array — every clause builder mutates a + * value of this shape. Useful for serialization, diffing, or cloning. + * + * @return array + */ + public function exportQB(): array; + + /** + * Reset (or selectively keep / zero) parts of the structure. + * + * @param string[]|string|null $ignoreOrCare Structure keys to operate + * on. null resets everything. + * @param bool|null $isIgnore true → keep the listed keys + * (zero the rest); false → + * zero the listed keys (keep + * the rest). + */ + public function resetStructure(null|array|string $ignoreOrCare = null, ?bool $isIgnore = null): static; + + /** + * PHP-level shallow clone. Note that the parameter bag is also cloned + * — mutations on the clone do not bleed into the original. + */ + public function clone(): static; + + /** + * The parameter bag used to register bound values. Read after compiling + * to obtain the placeholder→value map for PDO execution. + */ + public function getParameter(): ParameterInterface; + + /** + * The active driver — exposed so callers can re-use its identifier + * escaping when assembling custom raw fragments. + */ + public function getDriver(): DriverInterface; + + /** + * Set a single named parameter, overwriting any existing value at that + * key. Use {@see self::getParameter()}->add() if you instead want + * collision-safe auto-suffixing. + */ + public function setParameter(string $key, mixed $value): static; + + /** + * Bulk-overwrite many parameters at once. + * + * @param array $parameters + */ + public function setParameters(array $parameters = []): static; + + // ---- SELECT projection ---------------------------------------------- + + /** + * Append one or more projection expressions. String columns are escaped + * via the active driver; {@see RawQuery} instances are passed through + * verbatim. + * + * @example + * $qb->select('id', 'name', $qb->raw('NOW() AS now')); + */ + public function select(string|RawQuery ...$columns): static; + + /** + * Drop every projection accumulated so far; the next compile would emit + * "SELECT *". + */ + public function clearSelect(): static; + + /** + * Append a {@code COUNT(column)} expression, optionally aliased. + */ + public function selectCount(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code COUNT(DISTINCT column)} expression. + */ + public function selectCountDistinct(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code MAX(column)} expression. + */ + public function selectMax(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code MIN(column)} expression. + */ + public function selectMin(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append an {@code AVG(column)} expression. + */ + public function selectAvg(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append "column AS alias". + */ + public function selectAs(RawQuery|string $column, string $alias): static; + + /** + * Append an {@code UPPER(column)} projection. + */ + public function selectUpper(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code LOWER(column)} projection. + */ + public function selectLower(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code LENGTH(column)} projection. + */ + public function selectLength(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code MID(column, offset, length)} projection (MySQL + * dialect — PostgreSQL/SQLite users should use {@see self::raw()} with + * SUBSTRING instead). + */ + public function selectMid(RawQuery|string $column, int $offset, int $length, ?string $alias = null): static; + + /** + * Append a {@code LEFT(column, length)} projection. + */ + public function selectLeft(RawQuery|string $column, int $length, ?string $alias = null): static; + + /** + * Append a {@code RIGHT(column, length)} projection. + */ + public function selectRight(RawQuery|string $column, int $length, ?string $alias = null): static; + + /** + * Append a {@code DISTINCT(column)} projection. + */ + public function selectDistinct(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code COALESCE(column, default)} projection. The default + * is escaped as an identifier only when it is a non-numeric string — + * numeric literals pass through unchanged. + */ + public function selectCoalesce(RawQuery|string $column, mixed $default = '0', ?string $alias = null): static; + + /** + * Append a {@code SUM(column)} projection. + */ + public function selectSum(RawQuery|string $column, ?string $alias = null): static; + + /** + * Append a {@code CONCAT(col1, col2, …)} projection. + * + * @param array $columns + */ + public function selectConcat(array $columns, ?string $alias = null): static; + + // ---- FROM / table --------------------------------------------------- + + /** + * Set the FROM list to a single table (clears any previously registered + * table), optionally aliased. + */ + public function from(RawQuery|string $table, ?string $alias = null): static; + + /** + * Append an additional FROM entry (for comma-separated FROM lists, + * e.g. {@code FROM users AS u, roles AS r}). + */ + public function addFrom(RawQuery|string $table, ?string $alias = null): static; + + /** + * Set the FROM list to a single table without supporting an alias. + * Equivalent to {@code from($table)} for callers that prefer the + * shorter name. + */ + public function table(RawQuery|string $table): static; + + // ---- grouping / ordering / pagination ------------------------------- + + /** + * Append one or more GROUP BY columns. Array arguments are flattened + * recursively. + * + * @param string|RawQuery|array ...$columns + */ + public function groupBy(string|RawQuery|array ...$columns): static; + + /** + * Append an ORDER BY clause. + * + * @param string $soft One of "ASC" (default) or "DESC" — case-insensitive. + * + * @throws QueryBuilderInvalidArgumentException When $soft is not ASC/DESC. + */ + public function orderBy(RawQuery|string $column, string $soft = 'ASC'): static; + + /** + * Set the OFFSET. Negative numbers are reflected to their absolute value. + */ + public function offset(int $offset = 0): static; + + /** + * Set the LIMIT. Negative numbers are reflected to their absolute value. + */ + public function limit(int $limit): static; + + // ---- JOIN ----------------------------------------------------------- + + /** + * Append a JOIN to the structure. + * + * When {@code $onStmt} is a {@see Closure}, it is invoked with a fresh + * builder so the caller can compose the ON expression — and optionally + * raise WHERE / HAVING side conditions that are folded back into the + * outer query. + * + * @param string $type One of "INNER" (default), "LEFT", "RIGHT", + * "LEFT OUTER", "RIGHT OUTER", "NATURAL", "SELF". + */ + public function join(RawQuery|string $table, RawQuery|string|Closure|null $onStmt = null, string $type = 'INNER'): static; + + /** + * Append a SELF JOIN. Implemented as a comma-separated FROM with the + * ON expression added to WHERE. + */ + public function selfJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append an INNER JOIN (the default). + */ + public function innerJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append a LEFT JOIN. + */ + public function leftJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append a RIGHT JOIN. + */ + public function rightJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append a LEFT OUTER JOIN. + */ + public function leftOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append a RIGHT OUTER JOIN. + */ + public function rightOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt): static; + + /** + * Append a NATURAL JOIN. NATURAL JOIN does not carry an ON clause. + */ + public function naturalJoin(string|RawQuery $table): static; + + // ---- WHERE / HAVING / ON -------------------------------------------- + + /** + * Add a WHERE condition. When only two arguments are supplied, the + * second is treated as the value and the operator defaults to "=": + * + * $qb->where('id', 5) // id = 5 + * $qb->where('id', '>', 5) // id > 5 + * $qb->where('name', 'IS', null)// name IS NULL + * + * @param string $logical "AND" (default), "OR" — "&&" and "||" are + * also accepted. + * + * @throws QueryBuilderInvalidArgumentException On unknown $logical. + */ + public function where(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): static; + + /** + * Add a HAVING condition. Mirrors {@see self::where()}. + * + * @throws QueryBuilderInvalidArgumentException On unknown $logical. + */ + public function having(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): static; + + /** + * Add an ON condition for JOIN closures. Dotted string values + * ({@code "u.id"}) are escaped as identifiers rather than parameterized, + * since they almost always refer to another column on the join side. + * + * @throws QueryBuilderInvalidArgumentException On unknown $logical. + */ + public function on(RawQuery|string $column, mixed $operator = '=', mixed $value = null, string $logical = 'AND'): static; + + /** + * Convenience: {@see self::where()} with $logical fixed to "AND". + */ + public function andWhere(string|RawQuery $column, mixed $operator = '=', mixed $value = null): static; + + /** + * Convenience: {@see self::where()} with $logical fixed to "OR". + */ + public function orWhere(string|RawQuery $column, mixed $operator = '=', mixed $value = null): static; + + // BETWEEN + + /** + * Add a BETWEEN clause. The bounds may be supplied as two separate + * arguments or as a two-element array via $firstValue. + * + * $qb->between('age', 18, 65); + * $qb->between('age', [18, 65]); + */ + public function between(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::between()}. + */ + public function orBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): static; + + /** + * AND-flavored {@see self::between()} (the default). + */ + public function andBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): static; + + /** + * Add a NOT BETWEEN clause. + */ + public function notBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::notBetween()}. + */ + public function orNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): static; + + /** + * AND-flavored {@see self::notBetween()}. + */ + public function andNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null): static; + + // FIND_IN_SET + + /** + * Add a {@code FIND_IN_SET(value, column)} clause (MySQL-specific + * function). PostgreSQL / SQLite users should use {@see self::raw()} + * with array_position / instr instead. + */ + public function findInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * AND-flavored {@see self::findInSet()}. + */ + public function andFindInSet(string|RawQuery $column, mixed $value = null): static; + + /** + * OR-flavored {@see self::findInSet()}. + */ + public function orFindInSet(string|RawQuery $column, mixed $value = null): static; + + /** + * Add a {@code NOT FIND_IN_SET(value, column)} clause. + */ + public function notFindInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * AND-flavored {@see self::notFindInSet()}. + */ + public function andNotFindInSet(string|RawQuery $column, mixed $value = null): static; + + /** + * OR-flavored {@see self::notFindInSet()}. + */ + public function orNotFindInSet(string|RawQuery $column, mixed $value = null): static; + + // IN + + /** + * Add an IN clause. Arrays are deduplicated; numeric elements are + * inlined verbatim while strings are parameterized. A {@see RawQuery} + * (e.g. a sub-query) is rendered as-is. + */ + public function whereIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * Add a NOT IN clause; otherwise behaves like {@see self::whereIn()}. + */ + public function whereNotIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::whereIn()}. + */ + public function orWhereIn(string|RawQuery $column, mixed $value = null): static; + + /** + * OR-flavored {@see self::whereNotIn()}. + */ + public function orWhereNotIn(string|RawQuery $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::whereIn()}. + */ + public function andWhereIn(string|RawQuery $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::whereNotIn()}. + */ + public function andWhereNotIn(string|RawQuery $column, mixed $value = null): static; + + // REGEXP / SOUNDEX + + /** + * Add a REGEXP comparison (MySQL — POSIX flavor). PostgreSQL users + * prefer {@code ~} which is not surfaced here. + */ + public function regexp(string|RawQuery $column, string|RawQuery $value, string $logical = 'AND'): static; + + /** + * AND-flavored {@see self::regexp()}. + */ + public function andRegexp(string|RawQuery $column, string|RawQuery $value): static; + + /** + * OR-flavored {@see self::regexp()}. + */ + public function orRegexp(string|RawQuery $column, string|RawQuery $value): static; + + /** + * Add a SOUNDEX-based fuzzy comparison + * ({@code SOUNDEX(col) LIKE CONCAT('%', TRIM(TRAILING '0' FROM SOUNDEX(value)), '%')}). + */ + public function soundex(string|RawQuery $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * AND-flavored {@see self::soundex()}. + */ + public function andSoundex(string|RawQuery $column, mixed $value = null): static; + + /** + * OR-flavored {@see self::soundex()}. + */ + public function orSoundex(string|RawQuery $column, mixed $value = null): static; + + // NULL + + /** + * Add a {@code col IS NULL} clause. + */ + public function whereIsNull(string|RawQuery $column, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::whereIsNull()}. + */ + public function orWhereIsNull(string|RawQuery $column): static; + + /** + * AND-flavored {@see self::whereIsNull()}. + */ + public function andWhereIsNull(string|RawQuery $column): static; + + /** + * Add a {@code col IS NOT NULL} clause. + */ + public function whereIsNotNull(string|RawQuery $column, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::whereIsNotNull()}. + */ + public function orWhereIsNotNull(string|RawQuery $column): static; + + /** + * AND-flavored {@see self::whereIsNotNull()}. + */ + public function andWhereIsNotNull(string|RawQuery $column): static; + + // LIKE family + + /** + * Add a LIKE clause whose wildcard placement is decided by $type: + * + * "both" (default) → "%value%" + * "before" / "start" → "value%" + * "after" / "end" → "%value" + * + * @param string $type One of "both", "before"/"start", "after"/"end". + */ + public function like(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::like()}. + */ + public function orLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): static; + + /** + * AND-flavored {@see self::like()}. + */ + public function andLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): static; + + /** + * Negated {@see self::like()} — compiles to NOT LIKE. + */ + public function notLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::notLike()}. + */ + public function orNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): static; + + /** + * AND-flavored {@see self::notLike()}. + */ + public function andNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both'): static; + + /** + * Match strings that begin with $value — equivalent to LIKE 'value%'. + */ + public function startLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::startLike()}. + */ + public function orStartLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::startLike()}. + */ + public function andStartLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * Negated {@see self::startLike()} — compiles to NOT LIKE 'value%'. + */ + public function notStartLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::notStartLike()}. + */ + public function orStartNotLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::notStartLike()}. + */ + public function andStartNotLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * Match strings that end with $value — equivalent to LIKE '%value'. + */ + public function endLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::endLike()}. + */ + public function orEndLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::endLike()}. + */ + public function andEndLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * Negated {@see self::endLike()} — compiles to NOT LIKE '%value'. + */ + public function notEndLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND'): static; + + /** + * OR-flavored {@see self::notEndLike()}. + */ + public function orEndNotLike(string|RawQuery|array $column, mixed $value = null): static; + + /** + * AND-flavored {@see self::notEndLike()}. + */ + public function andEndNotLike(string|RawQuery|array $column, mixed $value = null): static; + + // sub-query / grouping / raw + + /** + * Build a SELECT sub-query using a fresh builder. + * + * @param Closure(static): void $closure Receives the inner + * builder by reference. + * @param string|null $alias Aliases the sub-query + * (only valid when + * $isIntervalQuery is true). + * @param bool $isIntervalQuery When true (default), the + * emitted SQL is wrapped + * in parentheses — suitable + * for IN / FROM / JOIN + * contexts. + * + * @throws QueryBuilderException If $alias is supplied while + * $isIntervalQuery is false. + */ + public function subQuery(Closure $closure, ?string $alias = null, bool $isIntervalQuery = true): RawQuery; + + /** + * Group multiple where / having / on clauses in parentheses, joined by + * AND or OR. The closure receives a fresh builder; the closure's WHERE, + * HAVING and ON buckets are each independently wrapped and folded back + * into the outer query. + * + * @param Closure(static): void $closure + * + * @throws QueryBuilderException On unknown $logical. + */ + public function group(Closure $closure, string $logical = 'AND'): static; + + /** + * Wrap a SQL fragment that should be inlined verbatim, bypassing + * identifier escaping and parameter binding. Use sparingly — never + * embed unsanitized user input. + */ + public function raw(mixed $rawQuery): RawQuery; + + // ---- SET (INSERT / UPDATE) ------------------------------------------ + + /** + * Append a SET row for the next INSERT or UPDATE compile. When the + * first argument is an associative array and the second is null, every + * key/value pair in that array is added as one row. + * + * Multiple calls produce multi-row INSERTs (batch insert) or the + * input rows for {@see self::generateUpdateBatchQuery()}. + * + * @param bool $strict Reserved for future use. + */ + public function set(RawQuery|array|string $column, mixed $value = null, bool $strict = true): static; + + /** + * Alias of {@see self::set()} — appends a SET row. + */ + public function addSet(RawQuery|array|string $column, mixed $value = null, bool $strict = true): static; + + // ---- compile -------------------------------------------------------- + + /** + * Compile to a SELECT statement. + * + * @param array $selector Optional shortcut: items + * are appended via + * {@see self::select()}. + * @param array $conditions Optional shortcut: items + * with string keys become + * {@code where(key, value)} + * and items with integer + * keys are passed as a + * single argument to + * {@see self::where()}. + * + * @throws QueryBuilderException When the structure is invalid. + */ + public function generateSelectQuery(array $selector = [], array $conditions = []): string; + + /** + * Compile to an UPDATE statement. Requires at least one SET row and a + * target table. + * + * @throws QueryBuilderException When no SET data exists or no table is set. + */ + public function generateUpdateQuery(): string; + + /** + * Compile to a batch UPDATE that uses CASE/WHEN expressions keyed by + * $referenceColumn. Every SET row must contain the reference column. + * + * @throws QueryBuilderException When the reference column is missing + * from any of the SET rows. + */ + public function generateUpdateBatchQuery(string $referenceColumn): string; + + /** + * Compile to a single-row INSERT statement. + * + * @throws QueryBuilderException When no SET data exists. + */ + public function generateInsertQuery(): string; + + /** + * Compile to a multi-row INSERT statement. Missing columns in any row + * are compiled as the literal NULL. + * + * @throws QueryBuilderException When no SET data exists. + */ + public function generateBatchInsertQuery(): string; + + /** + * Compile to a DELETE statement. WHERE-less deletes compile to + * {@code WHERE 1} — fully intentional, callers are responsible for + * gating. + * + * @throws QueryBuilderException When no table is set. + */ + public function generateDeleteQuery(): string; +} diff --git a/src/RawQuery.php b/src/RawQuery.php index 537ce55..47b93b4 100644 --- a/src/RawQuery.php +++ b/src/RawQuery.php @@ -1,63 +1,81 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\QueryBuilder; - -class RawQuery -{ - - private string $raw; - - public function __construct(mixed $rawQuery) - { - $this->set($rawQuery); - } - - public function __toString(): string - { - return $this->get(); - } - - public function set(mixed $rawQuery): self - { - if (is_string($rawQuery)) { - $this->raw = $rawQuery; - } else if ($rawQuery instanceof Closure) { - $builder = new QueryBuilder(); - $res = call_user_func_array($rawQuery, [&$builder]); - if (is_string($res)) { - $this->raw = $res; - } else if (is_object($res) && method_exists($res, '__toString')) { - $this->raw = $res->__toString(); - } else { - $this->raw = $builder->__toString(); - } - } else { - $this->raw = (string)$rawQuery; - } - - return $this; - } - - public function get(): string - { - return $this->raw ?? ''; - } - - public static function raw($rawQuery): self - { - return new self($rawQuery); - } - -} +set($rawQuery); + } + + public function __toString(): string + { + return $this->get(); + } + + /** + * Replace the stored SQL fragment. See class docblock for accepted input + * forms. + */ + public function set(mixed $rawQuery): self + { + if (is_string($rawQuery)) { + $this->raw = $rawQuery; + } elseif ($rawQuery instanceof Closure) { + $builder = new QueryBuilder(); + $result = $rawQuery($builder); + if (is_string($result)) { + $this->raw = $result; + } elseif (is_object($result) && method_exists($result, '__toString')) { + $this->raw = $result->__toString(); + } else { + $this->raw = $builder->__toString(); + } + } else { + $this->raw = (string) $rawQuery; + } + + return $this; + } + + /** + * The stored SQL fragment (empty string if never set). + */ + public function get(): string + { + return $this->raw ?? ''; + } + + /** + * Convenience static factory — equivalent to {@code new RawQuery($rawQuery)}. + */ + public static function raw(mixed $rawQuery): self + { + return new self($rawQuery); + } +} diff --git a/tests/AbstractQueryBuilderDriverUnit.php b/tests/AbstractQueryBuilderDriverUnit.php index dc7efaf..a620593 100644 --- a/tests/AbstractQueryBuilderDriverUnit.php +++ b/tests/AbstractQueryBuilderDriverUnit.php @@ -1,32 +1,32 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use InitORM\QueryBuilder\QueryBuilderFactory; -use InitORM\QueryBuilder\QueryBuilderInterface; -use PHPUnit\Framework\TestCase; - -class AbstractQueryBuilderDriverUnit extends TestCase -{ - protected QueryBuilderInterface $db; - - protected function setUp(): void - { - $factory = new QueryBuilderFactory(); - $this->db = $factory->createQueryBuilder('mysql'); - parent::setUp(); - } - -} \ No newline at end of file + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0.1 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use InitORM\QueryBuilder\QueryBuilderFactory; +use InitORM\QueryBuilder\QueryBuilderInterface; +use PHPUnit\Framework\TestCase; + +abstract class AbstractQueryBuilderDriverUnit extends TestCase +{ + protected QueryBuilderInterface $db; + protected function setUp(): void + { + $factory = new QueryBuilderFactory(); + $this->db = $factory->createQueryBuilder('mysql'); + parent::setUp(); + } +} diff --git a/tests/AbstractQueryBuilderUnit.php b/tests/AbstractQueryBuilderUnit.php index 68eab74..71f656a 100644 --- a/tests/AbstractQueryBuilderUnit.php +++ b/tests/AbstractQueryBuilderUnit.php @@ -1,33 +1,32 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use InitORM\QueryBuilder\QueryBuilderFactory; -use InitORM\QueryBuilder\QueryBuilderInterface; -use PHPUnit\Framework\TestCase; - -abstract class AbstractQueryBuilderUnit extends TestCase -{ - - protected QueryBuilderInterface $db; - - protected function setUp(): void - { - $factory = new QueryBuilderFactory(); - $this->db = $factory->createQueryBuilder(); - parent::setUp(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use InitORM\QueryBuilder\QueryBuilderFactory; +use InitORM\QueryBuilder\QueryBuilderInterface; +use PHPUnit\Framework\TestCase; + +abstract class AbstractQueryBuilderUnit extends TestCase +{ + protected QueryBuilderInterface $db; + protected function setUp(): void + { + $factory = new QueryBuilderFactory(); + $this->db = $factory->createQueryBuilder(); + parent::setUp(); + } +} diff --git a/tests/BetweenInLikeTest.php b/tests/BetweenInLikeTest.php new file mode 100644 index 0000000..a0fd93d --- /dev/null +++ b/tests/BetweenInLikeTest.php @@ -0,0 +1,225 @@ +db->from('post')->between('id', 10, 20); + $expected = 'SELECT * FROM post WHERE id BETWEEN 10 AND 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testBetweenWithBoundsArray(): void + { + $this->db->from('post')->between('id', [10, 20]); + $expected = 'SELECT * FROM post WHERE id BETWEEN 10 AND 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testBetweenWithStringBoundsParameterizes(): void + { + $this->db->from('post')->between('date', '2026-01-01', '2026-12-31'); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString('BETWEEN :date AND :date_1', $sql); + $this->assertSame('2026-01-01', $params[':date']); + $this->assertSame('2026-12-31', $params[':date_1']); + } + + public function testBetweenMixedRawAndStringInlinesFunctionsAndParameterizesStrings(): void + { + $this->db->from('post')->between('date', '2026-01-01', $this->db->raw('NOW()')); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('BETWEEN :date AND NOW()', $sql); + } + + public function testNotBetweenInsertsNotKeyword(): void + { + $this->db->from('post')->notBetween('id', 10, 20); + $expected = 'SELECT * FROM post WHERE id NOT BETWEEN 10 AND 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndBetweenAliasMatchesBetween(): void + { + $this->db->from('post')->andBetween('id', 1, 5); + $expected = 'SELECT * FROM post WHERE id BETWEEN 1 AND 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndNotBetweenAliasMatchesNotBetween(): void + { + $this->db->from('post')->andNotBetween('id', 1, 5); + $expected = 'SELECT * FROM post WHERE id NOT BETWEEN 1 AND 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + // ---- IN ------------------------------------------------------------- + + public function testWhereInWithNumericArray(): void + { + $this->db->from('user')->whereIn('id', [1, 2, 3]); + $expected = 'SELECT * FROM user WHERE id IN (1, 2, 3)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereInWithStringArrayParameterizes(): void + { + $this->db->from('user')->whereIn('country', ['TR', 'US', 'DE']); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString('country IN (:country, :country_1, :country_2)', $sql); + $this->assertSame('TR', $params[':country']); + $this->assertSame('US', $params[':country_1']); + $this->assertSame('DE', $params[':country_2']); + } + + public function testWhereInDeduplicatesArray(): void + { + $this->db->from('user')->whereIn('id', [1, 2, 2, 3, 1]); + $expected = 'SELECT * FROM user WHERE id IN (1, 2, 3)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereNotInPrependsNot(): void + { + $this->db->from('user')->whereNotIn('id', [1, 2]); + $expected = 'SELECT * FROM user WHERE id NOT IN (1, 2)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereInAcceptsRawSubQueryVerbatim(): void + { + $this->db->from('user')->whereIn('id', $this->db->raw('(SELECT user_id FROM bans)')); + $expected = 'SELECT * FROM user WHERE id IN (SELECT user_id FROM bans)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testOrWhereInPushesToOrBucket(): void + { + $this->db->from('user')->orWhereIn('id', [1, 2, 3]); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertSame(['id IN (1, 2, 3)'], $structure['where']['OR']); + } + + public function testAndWhereInPushesToAndBucket(): void + { + $this->db->from('user')->andWhereIn('id', [1, 2]); + $structure = $this->db->exportQB(); + + $this->assertSame(['id IN (1, 2)'], $structure['where']['AND']); + $this->assertEmpty($structure['where']['OR']); + } + + // ---- LIKE family ---------------------------------------------------- + + public function testLikeBothBookendsWithWildcards(): void + { + $this->db->from('user')->like('name', 'fak'); + $params = $this->db->getParameter()->all(); + $this->db->generateSelectQuery(); + $this->assertSame('%fak%', $params[':name']); + } + + public function testNotLikeIncludesNotKeyword(): void + { + $this->db->from('user')->notLike('name', 'spam'); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString('name NOT LIKE :name', $sql); + $this->assertSame('%spam%', $params[':name']); + } + + public function testStartLikeWildcardSuffixOnly(): void + { + $this->db->from('user')->startLike('name', 'Mu'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + $this->assertSame('Mu%', $params[':name']); + } + + public function testEndLikeWildcardPrefixOnly(): void + { + $this->db->from('user')->endLike('name', 'AK'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + $this->assertSame('%AK', $params[':name']); + } + + public function testNotStartLikeWildcardSuffixOnlyWithNotKeyword(): void + { + $this->db->from('user')->notStartLike('name', 'Mu'); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString(' NOT LIKE ', $sql); + $this->assertSame('Mu%', $params[':name']); + } + + public function testNotEndLikeWildcardPrefixOnlyWithNotKeyword(): void + { + $this->db->from('user')->notEndLike('name', 'AK'); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString(' NOT LIKE ', $sql); + $this->assertSame('%AK', $params[':name']); + } + + /** + * @return array + */ + public static function likeMethodProvider(): array + { + return [ + // method, value, expected pattern, must contain + 'orLike' => ['orLike', 'php', '%php%', ' LIKE '], + 'andLike' => ['andLike', 'php', '%php%', ' LIKE '], + 'orNotLike' => ['orNotLike', 'php', '%php%', ' NOT LIKE '], + 'andNotLike' => ['andNotLike', 'php', '%php%', ' NOT LIKE '], + 'orStartLike' => ['orStartLike', 'Mu', 'Mu%', ' LIKE '], + 'andStartLike' => ['andStartLike', 'Mu', 'Mu%', ' LIKE '], + 'orStartNotLike' => ['orStartNotLike', 'Mu', 'Mu%', ' NOT LIKE '], + 'andStartNotLike' => ['andStartNotLike','Mu', 'Mu%', ' NOT LIKE '], + 'orEndLike' => ['orEndLike', 'AK', '%AK', ' LIKE '], + 'andEndLike' => ['andEndLike', 'AK', '%AK', ' LIKE '], + 'orEndNotLike' => ['orEndNotLike', 'AK', '%AK', ' NOT LIKE '], + 'andEndNotLike' => ['andEndNotLike', 'AK', '%AK', ' NOT LIKE '], + ]; + } + + /** + * @dataProvider likeMethodProvider + */ + public function testLikeAliasesProduceExpectedPatternAndKeyword(string $method, string $value, string $expectedPattern, string $mustContain): void + { + $this->db->from('user')->{$method}('name', $value); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertStringContainsString($mustContain, $sql); + $this->assertSame($expectedPattern, $params[':name']); + } +} diff --git a/tests/BugfixRegressionTest.php b/tests/BugfixRegressionTest.php new file mode 100644 index 0000000..4ec58d2 --- /dev/null +++ b/tests/BugfixRegressionTest.php @@ -0,0 +1,360 @@ + + * @license ./LICENSE MIT + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use InitORM\QueryBuilder\Exceptions\QueryBuilderException; +use InitORM\QueryBuilder\QueryBuilder; +use InitORM\QueryBuilder\RawQuery; + +class BugfixRegressionTest extends AbstractQueryBuilderUnit +{ + // ----------------------------------------------------------------------- + // B1 / B7 — andWhereNotIn must produce NOT IN (was producing IN) + // ----------------------------------------------------------------------- + + public function testAndWhereNotInProducesNotIn(): void + { + $this->db->from('post') + ->andWhereNotIn('id', [1, 2, 3]); + + $expected = 'SELECT * FROM post WHERE id NOT IN (1, 2, 3)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndWhereInProducesIn(): void + { + $this->db->from('post') + ->andWhereIn('id', [1, 2, 3]); + + $expected = 'SELECT * FROM post WHERE id IN (1, 2, 3)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testOrWhereNotInPushesToOrBucket(): void + { + // The OR-bucket vs AND-bucket join is handled by __generateStructure; + // here we only verify dispatch. See B26 (Aşama 2). + $this->db->from('post')->orWhereNotIn('id', [4, 5]); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertNotEmpty($structure['where']['OR']); + $this->assertSame('id NOT IN (4, 5)', $structure['where']['OR'][0]); + } + + // ----------------------------------------------------------------------- + // B2 — orLike / andLike were swapped + // ----------------------------------------------------------------------- + + public function testAndLikeUsesAndLogical(): void + { + $this->db->from('post') + ->where('status', 1) + ->andLike('title', 'php'); + + $expected = "SELECT * FROM post WHERE status = 1 AND title LIKE :title"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testOrLikePushesToOrBucket(): void + { + // See B26: structure compiler joins AND/OR buckets with " AND ". + // Here we only verify that orLike dispatches into the OR bucket. + $this->db->from('post')->orLike('title', 'php'); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertNotEmpty($structure['where']['OR']); + $this->assertSame('title LIKE :title', $structure['where']['OR'][0]); + } + + // ----------------------------------------------------------------------- + // B3 — orBetween parameter order was broken + // + // Note: the existing structure compiler joins the AND-bucket and the + // OR-bucket with " AND " (see QueryBuilder::__generateStructure). That + // is the established behavior — these tests therefore exercise that the + // OR clause is dispatched into the OR bucket (was previously corrupted + // to garbage) and that integer values are inlined as expected. + // ----------------------------------------------------------------------- + + public function testOrBetweenPushesToOrBucket(): void + { + $this->db->from('post')->orBetween('id', 10, 20); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertNotEmpty($structure['where']['OR']); + $this->assertSame('id BETWEEN 10 AND 20', $structure['where']['OR'][0]); + } + + public function testAndBetweenPushesToAndBucket(): void + { + $this->db->from('post')->andBetween('id', 10, 20); + $structure = $this->db->exportQB(); + + $this->assertNotEmpty($structure['where']['AND']); + $this->assertEmpty($structure['where']['OR']); + $this->assertSame('id BETWEEN 10 AND 20', $structure['where']['AND'][0]); + } + + public function testOrNotBetweenPushesToOrBucket(): void + { + $this->db->from('post')->orNotBetween('id', 10, 20); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertNotEmpty($structure['where']['OR']); + $this->assertSame('id NOT BETWEEN 10 AND 20', $structure['where']['OR'][0]); + } + + public function testOrBetweenParametersForStringBounds(): void + { + $this->db->from('post')->orBetween('date', '2026-01-01', '2026-12-31'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('2026-01-01', $params[':date']); + $this->assertSame('2026-12-31', $params[':date_1']); + } + + // ----------------------------------------------------------------------- + // B4 — RawQuery::set() must handle Closure arguments + // ----------------------------------------------------------------------- + + public function testRawQueryAcceptsClosure(): void + { + $raw = new RawQuery(function (QueryBuilder $qb) { + $qb->select('id')->from('users')->where('active', 1); + }); + + $expected = 'SELECT id FROM users WHERE active = 1'; + $this->assertEquals($expected, (string) $raw); + } + + public function testRawQueryClosureCanReturnString(): void + { + $raw = new RawQuery(function () { + return 'NOW()'; + }); + + $this->assertEquals('NOW()', (string) $raw); + } + + // ----------------------------------------------------------------------- + // B5 / B6 — selfJoin null guard + naturalJoin signature + // ----------------------------------------------------------------------- + + public function testNaturalJoinDoesNotRequireOnStatement(): void + { + $this->db->select('*') + ->from('orders') + ->naturalJoin('customers'); + + $expected = 'SELECT * FROM orders NATURAL JOIN customers WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testSelfJoinRejectsNullViaTypeSystem(): void + { + // PHP's type system already enforces non-null for $onStmt. + $this->expectException(\TypeError::class); + /** @noinspection PhpStrictTypeCheckingInspection */ + $this->db->selfJoin('user', null); + } + + // ----------------------------------------------------------------------- + // B16 — startLike / endLike pattern semantics + // ----------------------------------------------------------------------- + + public function testStartLikeMatchesStringsBeginningWithValue(): void + { + $this->db->from('user')->startLike('name', 'Mu'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + // startLike('Mu') must produce LIKE 'Mu%' + $this->assertEquals('Mu%', $params[':name']); + } + + public function testEndLikeMatchesStringsEndingWithValue(): void + { + $this->db->from('user')->endLike('name', 'AK'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + // endLike('AK') must produce LIKE '%AK' + $this->assertEquals('%AK', $params[':name']); + } + + public function testLikeBothBookendsWithWildcards(): void + { + $this->db->from('user')->like('name', 'fak'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertEquals('%fak%', $params[':name']); + } + + public function testNotStartLikePatternIsValuePercent(): void + { + $this->db->from('user')->notStartLike('name', 'Mu'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertEquals('Mu%', $params[':name']); + } + + public function testNotEndLikePatternIsPercentValue(): void + { + $this->db->from('user')->notEndLike('name', 'AK'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertEquals('%AK', $params[':name']); + } + + public function testStartLikeProducesNotLikeKeywordOnlyForNotVariant(): void + { + $this->db->from('user')->startLike('name', 'A'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString(' LIKE ', $sql); + $this->assertStringNotContainsString(' NOT LIKE ', $sql); + } + + public function testNotStartLikeIncludesNotLikeKeyword(): void + { + $this->db->from('user')->notStartLike('name', 'A'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString(' NOT LIKE ', $sql); + } + + // ----------------------------------------------------------------------- + // B9 / B27 — operator type safety + strict in_array for the value-shortcut + // ----------------------------------------------------------------------- + + public function testWhereShortcutWithIntegerValue(): void + { + $this->db->from('post')->where('id', 5); + $expected = 'SELECT * FROM post WHERE id = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereShortcutWithBoolTrueValue(): void + { + // Prior to B27, in_array(true, [...string ops...]) loose-matched + // and skipped the swap, producing "WHERE active" with no comparison. + $this->db->from('post')->where('active', true); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('active = :active', $sql); + $this->assertSame(true, $this->db->getParameter()->all()[':active']); + } + + public function testWhereShortcutWithBoolFalseValue(): void + { + $this->db->from('post')->where('active', false); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('active = :active', $sql); + $this->assertSame(false, $this->db->getParameter()->all()[':active']); + } + + public function testWhereShortcutWithZeroValue(): void + { + $this->db->from('post')->where('priority', 0); + $expected = 'SELECT * FROM post WHERE priority = 0'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + // ----------------------------------------------------------------------- + // B20 — UPDATE missing-data exception text + // ----------------------------------------------------------------------- + + public function testUpdateWithoutSetThrowsUpdateMessage(): void + { + $this->db->from('post')->where('id', 1); + + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('The data set for the update could not be found.'); + $this->db->generateUpdateQuery(); + } + + // ----------------------------------------------------------------------- + // B23 — abstract test bases + // ----------------------------------------------------------------------- + + public function testAbstractQueryBuilderUnitIsAbstract(): void + { + $ref = new \ReflectionClass(AbstractQueryBuilderUnit::class); + $this->assertTrue($ref->isAbstract()); + } + + public function testAbstractQueryBuilderDriverUnitIsAbstract(): void + { + $ref = new \ReflectionClass(AbstractQueryBuilderDriverUnit::class); + $this->assertTrue($ref->isAbstract()); + } + + // ----------------------------------------------------------------------- + // B26 — mixed AND/OR bucket join now uses " OR " as the inter-bucket + // connector. where(a).orWhere(b) compiles to "a OR b" (was "a AND b"). + // ----------------------------------------------------------------------- + + public function testWhereThenOrWhereCompilesToOrAtTopLevel(): void + { + $this->db->from('users') + ->where('country', 'TR') + ->orWhere('country', 'US'); + + $expected = 'SELECT * FROM users WHERE country = :country OR country = :country_1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereThenOrBetweenCompilesToOr(): void + { + $this->db->from('post') + ->where('status', 1) + ->orBetween('id', 10, 20); + + $expected = 'SELECT * FROM post WHERE status = 1 OR id BETWEEN 10 AND 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testMultipleAndPlusMultipleOrUsesAndPrecedence(): void + { + // (a AND b) OR c OR d — SQL precedence (AND > OR) keeps the AND-list + // bound tightly; the resulting expression matches the natural reading. + $this->db->from('post') + ->where('a', 1) + ->where('b', 2) + ->orWhere('c', 3) + ->orWhere('d', 4); + + $expected = 'SELECT * FROM post WHERE a = 1 AND b = 2 OR c = 3 OR d = 4'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } +} diff --git a/tests/CrudEdgeCasesTest.php b/tests/CrudEdgeCasesTest.php new file mode 100644 index 0000000..cbeab63 --- /dev/null +++ b/tests/CrudEdgeCasesTest.php @@ -0,0 +1,163 @@ +db->from('post'); + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('The data set for the insert could not be found.'); + $this->db->generateInsertQuery(); + } + + public function testBatchInsertWithoutSetThrows(): void + { + $this->db->from('post'); + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('The data set for the insert could not be found.'); + $this->db->generateBatchInsertQuery(); + } + + public function testInsertWithoutTableThrows(): void + { + $this->db->set(['name' => 'x']); + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('Table name not found when query.'); + $this->db->generateInsertQuery(); + } + + public function testBatchInsertMissingColumnRowFillsWithNullLiteral(): void + { + $this->db->from('post') + ->set(['title' => 'a', 'body' => 'A body']) + ->set(['title' => 'b']); + + $sql = $this->db->generateBatchInsertQuery(); + // The second row lacks "body" — must compile to NULL. + $this->assertStringContainsString('(:title_1, NULL)', $sql); + } + + // ---- UPDATE --------------------------------------------------------- + + public function testUpdateWithoutSetThrows(): void + { + $this->db->from('post')->where('id', 1); + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('The data set for the update could not be found.'); + $this->db->generateUpdateQuery(); + } + + public function testUpdateWithoutWhereCompilesToWhere1(): void + { + $this->db->from('post')->set(['title' => 'fresh']); + $sql = $this->db->generateUpdateQuery(); + $this->assertStringContainsString('WHERE 1', $sql); + } + + public function testUpdateBatchReferenceColumnMissingThrows(): void + { + $this->db->from('post') + ->set(['id' => 5, 'title' => 'a']) + ->set(['title' => 'b']); // no "id" — invalid for batch update. + + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('The reference column does not exist in one or more of the set arrays.'); + $this->db->generateUpdateBatchQuery('id'); + } + + public function testUpdateBatchAppendsWhereInFilter(): void + { + $this->db->from('post') + ->set(['id' => 1, 'title' => 'a']) + ->set(['id' => 2, 'title' => 'b']); + + $sql = $this->db->generateUpdateBatchQuery('id'); + $this->assertStringContainsString('WHERE id IN (1, 2)', $sql); + } + + // ---- DELETE --------------------------------------------------------- + + public function testDeleteWithoutTableThrows(): void + { + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('Table name not found when query.'); + $this->db->generateDeleteQuery(); + } + + public function testDeleteWithoutWhereCompilesToWhere1(): void + { + $this->db->from('post'); + $sql = $this->db->generateDeleteQuery(); + $this->assertEquals('DELETE FROM post WHERE 1', $sql); + } + + public function testDeleteWithMultipleWhereConditions(): void + { + $this->db->from('post') + ->where('status', 1) + ->where('author_id', 5); + $sql = $this->db->generateDeleteQuery(); + + $this->assertEquals('DELETE FROM post WHERE status = 1 AND author_id = 5', $sql); + } + + public function testDeleteWithLimit(): void + { + $this->db->from('post')->where('status', 1)->limit(10); + $sql = $this->db->generateDeleteQuery(); + + $this->assertStringContainsString(' LIMIT 10', $sql); + } + + // ---- SELECT edge cases --------------------------------------------- + + public function testGroupByWithHavingProducesGroupAndHavingClauses(): void + { + $this->db->select('author_id') + ->selectCount('id', 'post_count') + ->from('post') + ->groupBy('author_id') + ->having('post_count', '>', 5); + + $expected = 'SELECT author_id, COUNT(id) AS post_count FROM post WHERE 1 GROUP BY author_id HAVING post_count > 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testSelectWithoutTableThrowsImplicitlyMissingFrom(): void + { + // SELECT without FROM compiles to "SELECT * FROM WHERE 1" — the + // compiler does not raise here (only the schemaName-required + // compilers do). Documents the actual behavior. + $this->db->select('NOW()'); + $sql = $this->db->generateSelectQuery(); + $this->assertEquals('SELECT NOW() FROM WHERE 1', $sql); + } + + public function testGenerateSelectQueryWithSelectorAndConditionsShortcut(): void + { + $this->db->from('post'); + $sql = $this->db->generateSelectQuery( + ['id', 'title'], + ['status' => 1] + ); + $this->assertEquals('SELECT id, title FROM post WHERE status = 1', $sql); + } +} diff --git a/tests/DeleteQueryDriverUnitTest.php b/tests/DeleteQueryDriverUnitTest.php index ca9effa..9062780 100644 --- a/tests/DeleteQueryDriverUnitTest.php +++ b/tests/DeleteQueryDriverUnitTest.php @@ -1,33 +1,31 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -class DeleteQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit -{ - - public function testDeleteStatementBuild() - { - - $this->db->from('post') - ->where('authorId', '=', 5) - ->limit(100); - - $expected = 'DELETE FROM `post` WHERE `authorId` = 5 LIMIT 100'; - - $this->assertEquals($expected, $this->db->generateDeleteQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0.1 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +class DeleteQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit +{ + public function testDeleteStatementBuild() + { + + $this->db->from('post') + ->where('authorId', '=', 5) + ->limit(100); + $expected = 'DELETE FROM `post` WHERE `authorId` = 5 LIMIT 100'; + $this->assertEquals($expected, $this->db->generateDeleteQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/DeleteQueryUnitTest.php b/tests/DeleteQueryUnitTest.php index 61394af..cd29efa 100644 --- a/tests/DeleteQueryUnitTest.php +++ b/tests/DeleteQueryUnitTest.php @@ -1,34 +1,33 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; - -class DeleteQueryUnitTest extends AbstractQueryBuilderUnit -{ - public function testDeleteStatementBuild() - { - - $this->db->from('post') - ->where('authorId', '=', 5) - ->limit(100); - - $expected = 'DELETE FROM post WHERE authorId = 5 LIMIT 100'; - - $this->assertEquals($expected, $this->db->generateDeleteQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; + +class DeleteQueryUnitTest extends AbstractQueryBuilderUnit +{ + public function testDeleteStatementBuild() + { + + $this->db->from('post') + ->where('authorId', '=', 5) + ->limit(100); + $expected = 'DELETE FROM post WHERE authorId = 5 LIMIT 100'; + $this->assertEquals($expected, $this->db->generateDeleteQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/DriversTest.php b/tests/DriversTest.php new file mode 100644 index 0000000..9169609 --- /dev/null +++ b/tests/DriversTest.php @@ -0,0 +1,124 @@ +assertNull((new GenericDriver())->getName()); + } + + public function testGenericDriverPassesIdentifiersThroughUnchanged(): void + { + $driver = new GenericDriver(); + $this->assertSame('users.id', $driver->escapeIdentifier('users.id')); + $this->assertSame('users AS u', $driver->escapeIdentifier('users AS u')); + } + + /** + * @return array + */ + public static function driverNameProvider(): array + { + return [ + 'mysql' => [new MySqlDriver(), 'mysql', '`'], + 'pgsql' => [new PostgreSqlDriver(), 'pgsql', '"'], + 'sqlite' => [new SqliteDriver(), 'sqlite', '`'], + ]; + } + + /** + * @dataProvider driverNameProvider + */ + public function testNamedDriverNameAndEscapeChar(DriverInterface $driver, string $expectedName, string $expectedChar): void + { + $this->assertSame($expectedName, $driver->getName()); + // The escape char is a protected constant, but we can observe it via + // the result of a known identifier. + $this->assertSame($expectedChar . 'id' . $expectedChar, $driver->escapeIdentifier('id')); + } + + /** + * @dataProvider driverNameProvider + */ + public function testNamedDriverEscapesDottedIdentifierComponents(DriverInterface $driver, string $name, string $c): void + { + $expected = $c . 'users' . $c . '.' . $c . 'id' . $c; + $this->assertSame($expected, $driver->escapeIdentifier('users.id')); + } + + /** + * @dataProvider driverNameProvider + */ + public function testNamedDriverPreservesAsKeyword(DriverInterface $driver, string $name, string $c): void + { + $expected = $c . 'users' . $c . ' AS ' . $c . 'u' . $c; + $this->assertSame($expected, $driver->escapeIdentifier('users AS u')); + } + + /** + * @dataProvider driverNameProvider + */ + public function testNamedDriverPreservesOnAndOrKeywords(DriverInterface $driver, string $name, string $c): void + { + // "ON" and "AND"/"OR" are part of the regex's exception list. + $expected = $c . 'a' . $c . '.' . $c . 'id' . $c . ' AND ' . $c . 'b' . $c . '.' . $c . 'id' . $c; + $this->assertSame($expected, $driver->escapeIdentifier('a.id AND b.id')); + + // Numeric literals (digit-leading tokens) are not matched by the + // identifier regex, so they pass through unquoted. + $expected2 = $c . 'x' . $c . '=1 OR ' . $c . 'y' . $c . '=2'; + $this->assertSame($expected2, $driver->escapeIdentifier('x=1 OR y=2')); + } + + /** + * @dataProvider driverNameProvider + */ + public function testNamedDriverSkipsBindParameterPrefix(DriverInterface $driver, string $name, string $c): void + { + // Tokens starting with ":" should not be re-escaped. + $result = $driver->escapeIdentifier(':bind_value'); + $this->assertStringNotContainsString($c . 'bind_value' . $c, $result); + $this->assertStringContainsString(':bind_value', $result); + } + + public function testMySqlDriverDoublesPreExistingBacktickInIdentifier(): void + { + $driver = new MySqlDriver(); + // The regex doubles any existing escape character. + $result = $driver->escapeIdentifier('weird`name'); + $this->assertStringContainsString('``', $result); + } + + public function testCustomDriverViaSubclassing(): void + { + $driver = new class () extends AbstractDriver { + protected const NAME = 'oracle'; + protected const ESCAPE_CHAR = '"'; + }; + + $this->assertSame('oracle', $driver->getName()); + $this->assertSame('"users"."id"', $driver->escapeIdentifier('users.id')); + } +} diff --git a/tests/FactoryAndStructureTest.php b/tests/FactoryAndStructureTest.php new file mode 100644 index 0000000..c1c65db --- /dev/null +++ b/tests/FactoryAndStructureTest.php @@ -0,0 +1,197 @@ + + */ + public static function driverStringProvider(): array + { + return [ + 'mysql' => ['mysql', MySqlDriver::class], + 'pgsql' => ['pgsql', PostgreSqlDriver::class], + 'postgres' => ['postgres', PostgreSqlDriver::class], + 'postgresql' => ['postgresql', PostgreSqlDriver::class], + 'sqlite' => ['sqlite', SqliteDriver::class], + 'null' => [null, GenericDriver::class], + 'unknown' => ['unknown', GenericDriver::class], + ]; + } + + /** + * @dataProvider driverStringProvider + */ + public function testFactorySelectsCorrectDriver(?string $name, string $expectedClass): void + { + $qb = (new QueryBuilderFactory())->createQueryBuilder($name); + $this->assertInstanceOf($expectedClass, $qb->getDriver()); + } + + public function testNewBuilderInheritsDriverName(): void + { + $qb = (new QueryBuilderFactory())->createQueryBuilder('mysql'); + $sibling = $qb->newBuilder(); + + $this->assertInstanceOf(MySqlDriver::class, $sibling->getDriver()); + } + + public function testExportQbReturnsBlankStructureByDefault(): void + { + $qb = new QueryBuilder(); + $structure = $qb->exportQB(); + + $this->assertSame([], $structure['select']); + $this->assertSame([], $structure['table']); + $this->assertSame(['AND' => [], 'OR' => []], $structure['where']); + $this->assertNull($structure['limit']); + $this->assertNull($structure['offset']); + } + + public function testImportQbReplacesStructureByDefault(): void + { + $qb = new QueryBuilder(); + $qb->select('id')->from('users')->where('a', 1); + + $qb->importQB(['select' => ['name']]); + $structure = $qb->exportQB(); + + // import without merge zeroes everything not supplied. + $this->assertSame(['name'], $structure['select']); + $this->assertSame([], $structure['table']); + $this->assertSame(['AND' => [], 'OR' => []], $structure['where']); + } + + public function testImportQbWithMergeKeepsExistingFields(): void + { + $qb = new QueryBuilder(); + $qb->select('id')->from('users'); + + $qb->importQB(['select' => ['name']], true); + $structure = $qb->exportQB(); + + // merge overwrites the named field but keeps others. + $this->assertSame(['name'], $structure['select']); + $this->assertSame(['users'], $structure['table']); + } + + public function testResetStructureZeroesEverything(): void + { + $qb = new QueryBuilder(); + $qb->select('id')->from('users')->where('a', 1); + $qb->resetStructure(); + + $structure = $qb->exportQB(); + $this->assertSame([], $structure['select']); + $this->assertSame([], $structure['table']); + $this->assertSame(['AND' => [], 'OR' => []], $structure['where']); + } + + public function testResetStructureKeepListPreservesNamedKeys(): void + { + $qb = new QueryBuilder(); + $qb->select('id')->from('users')->where('a', 1); + $qb->resetStructure(['select'], true); + + $structure = $qb->exportQB(); + $this->assertSame(['id'], $structure['select']); + $this->assertSame([], $structure['table']); // zeroed + } + + public function testResetStructureIsIgnoreFalseBehavesLikeFullReset(): void + { + // Quirk of the existing API: when $isIgnore is false, the function + // starts from a blank structure and then explicitly re-zeroes the + // listed keys — effectively a full reset. + $qb = new QueryBuilder(); + $qb->select('id')->from('users')->where('a', 1); + $qb->resetStructure(['select'], false); + + $structure = $qb->exportQB(); + $this->assertSame([], $structure['select']); + $this->assertSame([], $structure['table']); + } + + public function testResetStructureAcceptsStringForSingleKey(): void + { + $qb = new QueryBuilder(); + $qb->select('id')->from('users'); + $qb->resetStructure('select', true); // keep 'select' + + $structure = $qb->exportQB(); + $this->assertSame(['id'], $structure['select']); + $this->assertSame([], $structure['table']); + } + + public function testCloneDecouplesStructureFromOriginal(): void + { + $qb = new QueryBuilder(); + $qb->from('users'); + $clone = $qb->clone(); + + $clone->from('posts'); + $this->assertSame(['users'], $qb->exportQB()['table']); + $this->assertSame(['posts'], $clone->exportQB()['table']); + } + + public function testCloneDecouplesParameterBag(): void + { + // Integer values are inlined, not parameterized — use strings so the + // bag actually fills. + $qb = new QueryBuilder(); + $qb->from('users')->where('country', 'TR'); + $clone = $qb->clone(); + + $clone->where('country', 'US'); + $this->assertCount(1, $qb->getParameter()->all()); + $this->assertCount(2, $clone->getParameter()->all()); + } + + public function testClearSelectEmptiesSelectBucket(): void + { + $qb = new QueryBuilder(); + $qb->select('a', 'b', 'c'); + $this->assertCount(3, $qb->exportQB()['select']); + + $qb->clearSelect(); + $this->assertSame([], $qb->exportQB()['select']); + } + + public function testSetParameterRegistersOnBag(): void + { + $qb = new QueryBuilder(); + $qb->setParameter('id', 5); + + $this->assertSame(5, $qb->getParameter()->get('id')); + } + + public function testSetParametersRegistersManyOnBag(): void + { + $qb = new QueryBuilder(); + $qb->setParameters(['a' => 1, 'b' => 2]); + + $this->assertSame(1, $qb->getParameter()->get('a')); + $this->assertSame(2, $qb->getParameter()->get('b')); + } +} diff --git a/tests/InsertQueryDriverUnitTest.php b/tests/InsertQueryDriverUnitTest.php index 26109ec..4268194 100644 --- a/tests/InsertQueryDriverUnitTest.php +++ b/tests/InsertQueryDriverUnitTest.php @@ -1,61 +1,55 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -class InsertQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit -{ - - - public function testInsertStatementBuild() - { - $this->db->from('post'); - - $data = [ - 'title' => 'Post Title', - 'content' => 'Post Content', - 'author' => 5, - 'status' => true, - ]; - $this->db->set($data); - - - $expected = 'INSERT INTO `post` (`title`, `content`, `author`, `status`) VALUES (:title, :content, 5, :status);'; - $this->assertEquals($expected, $this->db->generateInsertQuery()); - $this->db->resetStructure(); - } - - public function testInsertBatchStatementBuild() - { - - $this->db->from('post'); - - $this->db->set([ - 'title' => 'Post Title #1', - 'content' => 'Post Content #1', - 'author' => 5, - 'status' => true, - ]) - ->set([ - 'title' => 'Post Title #2', - 'content' => 'Post Content #2', - 'status' => false, - ]); - - $expected = 'INSERT INTO `post` (`title`, `content`, `author`, `status`) VALUES (:title, :content, 5, :status), (:title_1, :content_1, NULL, :status_1);'; - $this->assertEquals($expected, $this->db->generateBatchInsertQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0.1 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +class InsertQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit +{ + public function testInsertStatementBuild() + { + $this->db->from('post'); + $data = [ + 'title' => 'Post Title', + 'content' => 'Post Content', + 'author' => 5, + 'status' => true, + ]; + $this->db->set($data); + $expected = 'INSERT INTO `post` (`title`, `content`, `author`, `status`) VALUES (:title, :content, 5, :status);'; + $this->assertEquals($expected, $this->db->generateInsertQuery()); + $this->db->resetStructure(); + } + + public function testInsertBatchStatementBuild() + { + + $this->db->from('post'); + $this->db->set([ + 'title' => 'Post Title #1', + 'content' => 'Post Content #1', + 'author' => 5, + 'status' => true, + ]) + ->set([ + 'title' => 'Post Title #2', + 'content' => 'Post Content #2', + 'status' => false, + ]); + $expected = 'INSERT INTO `post` (`title`, `content`, `author`, `status`) VALUES (:title, :content, 5, :status), (:title_1, :content_1, NULL, :status_1);'; + $this->assertEquals($expected, $this->db->generateBatchInsertQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/InsertQueryUnitTest.php b/tests/InsertQueryUnitTest.php index 060fa23..9ff1bc2 100644 --- a/tests/InsertQueryUnitTest.php +++ b/tests/InsertQueryUnitTest.php @@ -1,62 +1,57 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; - -class InsertQueryUnitTest extends AbstractQueryBuilderUnit -{ - - public function testInsertStatementBuild() - { - $this->db->from('post'); - - $data = [ - 'title' => 'Post Title', - 'content' => 'Post Content', - 'author' => 5, - 'status' => true, - ]; - $this->db->set($data); - - - $expected = 'INSERT INTO post (title, content, author, status) VALUES (:title, :content, 5, :status);'; - $this->assertEquals($expected, $this->db->generateInsertQuery()); - $this->db->resetStructure(); - } - - public function testInsertBatchStatementBuild() - { - - $this->db->from('post'); - - $this->db->set([ - 'title' => 'Post Title #1', - 'content' => 'Post Content #1', - 'author' => 5, - 'status' => true, - ]) - ->set([ - 'title' => 'Post Title #2', - 'content' => 'Post Content #2', - 'status' => false, - ]); - - $expected = 'INSERT INTO post (title, content, author, status) VALUES (:title, :content, 5, :status), (:title_1, :content_1, NULL, :status_1);'; - $this->assertEquals($expected, $this->db->generateBatchInsertQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; + +class InsertQueryUnitTest extends AbstractQueryBuilderUnit +{ + public function testInsertStatementBuild() + { + $this->db->from('post'); + $data = [ + 'title' => 'Post Title', + 'content' => 'Post Content', + 'author' => 5, + 'status' => true, + ]; + $this->db->set($data); + $expected = 'INSERT INTO post (title, content, author, status) VALUES (:title, :content, 5, :status);'; + $this->assertEquals($expected, $this->db->generateInsertQuery()); + $this->db->resetStructure(); + } + + public function testInsertBatchStatementBuild() + { + + $this->db->from('post'); + $this->db->set([ + 'title' => 'Post Title #1', + 'content' => 'Post Content #1', + 'author' => 5, + 'status' => true, + ]) + ->set([ + 'title' => 'Post Title #2', + 'content' => 'Post Content #2', + 'status' => false, + ]); + $expected = 'INSERT INTO post (title, content, author, status) VALUES (:title, :content, 5, :status), (:title_1, :content_1, NULL, :status_1);'; + $this->assertEquals($expected, $this->db->generateBatchInsertQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/ParametersTest.php b/tests/ParametersTest.php new file mode 100644 index 0000000..6478dac --- /dev/null +++ b/tests/ParametersTest.php @@ -0,0 +1,165 @@ +set('user.id', 42); + + $this->assertSame([':userid' => 42], $bag->all()); + } + + public function testSetOverwritesOnRepeatedKey(): void + { + $bag = new Parameters(); + $bag->set('id', 1); + $bag->set('id', 2); + + $this->assertSame([':id' => 2], $bag->all()); + } + + public function testAddReturnsColonPrefixedPlaceholderName(): void + { + $bag = new Parameters(); + $placeholder = $bag->add('id', 5); + + $this->assertSame(':id', $placeholder); + $this->assertSame([':id' => 5], $bag->all()); + } + + public function testAddAutoSuffixesOnCollision(): void + { + $bag = new Parameters(); + $first = $bag->add('id', 1); + $second = $bag->add('id', 2); + $third = $bag->add('id', 3); + + $this->assertSame(':id', $first); + $this->assertSame(':id_1', $second); + $this->assertSame(':id_2', $third); + $this->assertSame([':id' => 1, ':id_1' => 2, ':id_2' => 3], $bag->all()); + } + + public function testAddReturnsLiteralNullForNullValueAndDoesNotRegister(): void + { + $bag = new Parameters(); + $result = $bag->add('id', null); + + $this->assertSame('NULL', $result); + $this->assertSame([], $bag->all()); + } + + public function testAddSanitizesNonAlphaNumericKeyChars(): void + { + $bag = new Parameters(); + $bag->add('user.id', 5); + + $this->assertArrayHasKey(':userid', $bag->all()); + } + + public function testAddRawQueryKeyHashesToStablePlaceholder(): void + { + $bag = new Parameters(); + $raw = new RawQuery('some expression'); + $placeholder = $bag->add($raw, 1); + + // md5 of "some expression" — stable hash, always 32 hex chars + $this->assertMatchesRegularExpression('/^:[a-f0-9]{32}$/', $placeholder); + } + + public function testGetReturnsAllWhenKeyIsNull(): void + { + $bag = new Parameters(); + $bag->set('a', 1)->set('b', 2); + + $this->assertSame([':a' => 1, ':b' => 2], $bag->get()); + } + + public function testGetReturnsSingleValueByKeyWithoutColon(): void + { + $bag = new Parameters(); + $bag->set('id', 99); + + $this->assertSame(99, $bag->get('id')); + $this->assertSame(99, $bag->get(':id')); // with colon also works + } + + public function testGetReturnsDefaultWhenKeyMissing(): void + { + $bag = new Parameters(); + $this->assertSame('fallback', $bag->get('missing', 'fallback')); + } + + public function testGetInvokesClosureDefaultLazily(): void + { + $bag = new Parameters(); + $bag->set('id', 1); + + $invocations = 0; + $closure = function () use (&$invocations) { + $invocations++; + return 'lazy'; + }; + + // Present key — closure NOT invoked. + $bag->get('id', $closure); + $this->assertSame(0, $invocations); + + // Missing key — closure invoked. + $result = $bag->get('missing', $closure); + $this->assertSame('lazy', $result); + $this->assertSame(1, $invocations); + } + + public function testResetEmptiesTheBag(): void + { + $bag = new Parameters(); + $bag->set('a', 1)->add('b', 2); + $this->assertCount(2, $bag->all()); + + $bag->reset(); + $this->assertSame([], $bag->all()); + } + + public function testMergeCombinesArrayAndOtherBags(): void + { + $other = (new Parameters())->set('c', 3)->set('d', 4); + + $bag = new Parameters(); + $bag->merge(['a' => 1, 'b' => 2], $other); + + $this->assertSame([ + ':a' => 1, + ':b' => 2, + ':c' => 3, + ':d' => 4, + ], $bag->all()); + } + + public function testFluentChainingReturnsSelf(): void + { + $bag = new Parameters(); + $this->assertSame($bag, $bag->set('a', 1)); + $this->assertSame($bag, $bag->merge(['b' => 2])); + $this->assertSame($bag, $bag->reset()); + } +} diff --git a/tests/QueryBuilderBehaviorTest.php b/tests/QueryBuilderBehaviorTest.php new file mode 100644 index 0000000..3d82cf9 --- /dev/null +++ b/tests/QueryBuilderBehaviorTest.php @@ -0,0 +1,198 @@ +from('users'); + $this->assertStringStartsWith('SELECT ', (string) $qb); + } + + public function testToStringWithSetAndNoWhereEmitsInsert(): void + { + $qb = new QueryBuilder(); + $qb->from('users')->set(['name' => 'a']); + $this->assertStringStartsWith('INSERT INTO ', (string) $qb); + } + + public function testToStringWithSetAndWhereEmitsUpdate(): void + { + $qb = new QueryBuilder(); + $qb->from('users')->where('id', 1)->set(['name' => 'a']); + $this->assertStringStartsWith('UPDATE ', (string) $qb); + } + + public function testToStringWithBatchSetAndNoWhereEmitsBatchInsert(): void + { + $qb = new QueryBuilder(); + $qb->from('users') + ->set(['name' => 'a', 'email' => 'a@a.test']) + ->set(['name' => 'b', 'email' => 'b@b.test']); + + $sql = (string) $qb; + $this->assertStringStartsWith('INSERT INTO ', $sql); + // Two rows in VALUES → batch. + $this->assertStringContainsString('), (', $sql); + } + + public function testIsBatchTrueWhenAnyRowHasMultipleColumns(): void + { + $qb = new QueryBuilder(); + $qb->set(['a' => 1, 'b' => 2]); + $this->assertTrue($qb->isBatch()); + } + + public function testIsBatchFalseWhenAllRowsAreSingleColumn(): void + { + $qb = new QueryBuilder(); + $qb->set('a', 1); + $this->assertFalse($qb->isBatch()); + } + + public function testSetSingleColumnFormAddsRow(): void + { + $qb = new QueryBuilder(); + $qb->from('users')->set('name', 'fred'); + + $structure = $qb->exportQB(); + $this->assertCount(1, $structure['set']); + // single-column form stores [[name => :name]] + $this->assertArrayHasKey('name', $structure['set'][0]); + } + + public function testSetSingleColumnWithIntegerInlinesValue(): void + { + $qb = new QueryBuilder(); + $qb->from('counter')->set('value', 42); + $structure = $qb->exportQB(); + + // Integer value is inlined as is (SqlValueDetector::isSqlParameterOrFunction(42) === true). + $this->assertSame(42, $structure['set'][0]['value']); + } + + public function testSetSingleColumnWithStringParameterizesValue(): void + { + $qb = new QueryBuilder(); + $qb->from('users')->set('name', 'fred'); + $structure = $qb->exportQB(); + + $this->assertSame(':name', $structure['set'][0]['name']); + $this->assertSame('fred', $qb->getParameter()->get('name')); + } + + public function testGetDriverReturnsActiveDriver(): void + { + $qb = new QueryBuilder('mysql'); + $this->assertSame('mysql', $qb->getDriver()->getName()); + } + + // ---- SqlValueDetector boundary tests -------------------------------- + + /** + * @return array + */ + public static function sqlParameterProvider(): array + { + return [ + 'positional' => ['?', true], + 'named' => [':id', true], + 'just colon' => [':', false], + 'word' => ['foo', false], + 'int' => [5, false], + 'null' => [null, false], + 'rawQuery' => [new RawQuery(':id'), false], // RawQuery NOT a placeholder per isSqlParameter + ]; + } + + /** + * @dataProvider sqlParameterProvider + */ + public function testIsSqlParameterIdentifiesPlaceholders(mixed $value, bool $expected): void + { + $this->assertSame($expected, SqlValueDetector::isSqlParameter($value)); + } + + /** + * @return array + */ + public static function sqlParameterOrFunctionProvider(): array + { + return [ + 'positional' => ['?', true], + 'named' => [':id', true], + 'function' => ['NOW()', true], + 'dotted col' => ['users.id', true], + 'rawQuery' => [new RawQuery('NOW()'),true], + 'integer' => [5, true], + 'string lit' => ['admin', false], + 'null' => [null, false], + 'array' => [[1, 2], false], + ]; + } + + /** + * @dataProvider sqlParameterOrFunctionProvider + */ + public function testIsSqlParameterOrFunctionIdentifiesInlinableValues(mixed $value, bool $expected): void + { + $this->assertSame($expected, SqlValueDetector::isSqlParameterOrFunction($value)); + } + + // ---- BucketCompiler tests ------------------------------------------- + + public function testBucketCompilerReturnsNullForEmptyBuckets(): void + { + $this->assertNull(BucketCompiler::compile([ + 'where' => ['AND' => [], 'OR' => []], + ], 'where')); + } + + public function testBucketCompilerJoinsAndOnlyWithAnd(): void + { + $structure = ['where' => ['AND' => ['a = 1', 'b = 2'], 'OR' => []]]; + $this->assertSame('a = 1 AND b = 2', BucketCompiler::compile($structure, 'where')); + } + + public function testBucketCompilerJoinsOrOnlyWithOr(): void + { + $structure = ['where' => ['AND' => [], 'OR' => ['a = 1', 'b = 2']]]; + $this->assertSame('a = 1 OR b = 2', BucketCompiler::compile($structure, 'where')); + } + + public function testBucketCompilerMixedAndOrUsesOrConnector(): void + { + // Post-B26 fix: when both buckets are non-empty, the AND-bucket and + // the OR-bucket are joined with " OR " — SQL precedence + // (AND > OR) gives "a AND b OR c" the parse "(a AND b) OR c", + // which matches the natural reading of where(a).orWhere(c). + $structure = ['where' => ['AND' => ['a = 1', 'b = 2'], 'OR' => ['c = 3']]]; + $this->assertSame('a = 1 AND b = 2 OR c = 3', BucketCompiler::compile($structure, 'where')); + } + + public function testBucketCompilerSimpleMixedAndOr(): void + { + $structure = ['where' => ['AND' => ['a = 1'], 'OR' => ['b = 2']]]; + $this->assertSame('a = 1 OR b = 2', BucketCompiler::compile($structure, 'where')); + } +} diff --git a/tests/RawQueryTest.php b/tests/RawQueryTest.php new file mode 100644 index 0000000..3f9354e --- /dev/null +++ b/tests/RawQueryTest.php @@ -0,0 +1,103 @@ +assertSame('NOW()', (string) $raw); + } + + public function testClosureCanReturnString(): void + { + $raw = new RawQuery(function () { + return 'CURRENT_TIMESTAMP'; + }); + + $this->assertSame('CURRENT_TIMESTAMP', (string) $raw); + } + + public function testClosureCanReturnStringableObject(): void + { + $raw = new RawQuery(function () { + return new class () { + public function __toString(): string + { + return 'INET_ATON(?)'; + } + }; + }); + + $this->assertSame('INET_ATON(?)', (string) $raw); + } + + public function testClosureMayBuildViaSuppliedBuilder(): void + { + $raw = new RawQuery(function (QueryBuilder $qb): void { + $qb->select('id')->from('users')->where('active', 1); + }); + + $this->assertSame('SELECT id FROM users WHERE active = 1', (string) $raw); + } + + public function testMixedInputIsCastToString(): void + { + $raw = new RawQuery(42); + $this->assertSame('42', (string) $raw); + } + + public function testGetReturnsEmptyStringByDefault(): void + { + // The constructor always calls set(), so this only exercises the + // null-coalesce fall-back inside get() — useful when subclasses + // bypass set(). + $raw = new RawQuery(''); + $this->assertSame('', $raw->get()); + } + + public function testSetReplacesStoredValue(): void + { + $raw = new RawQuery('old'); + $raw->set('new'); + + $this->assertSame('new', (string) $raw); + } + + public function testSetReturnsSelfForChaining(): void + { + $raw = new RawQuery('a'); + $this->assertSame($raw, $raw->set('b')); + } + + public function testStaticRawFactoryEqualsConstructor(): void + { + $a = RawQuery::raw('NOW()'); + $b = new RawQuery('NOW()'); + + $this->assertSame((string) $a, (string) $b); + $this->assertInstanceOf(RawQuery::class, $a); + } + + public function testToStringMagicMethodMatchesGet(): void + { + $raw = new RawQuery('foo'); + $this->assertSame($raw->get(), (string) $raw); + } +} diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php new file mode 100644 index 0000000..004a9e2 --- /dev/null +++ b/tests/SecurityTest.php @@ -0,0 +1,323 @@ +expectException(QueryBuilderInvalidArgumentException::class); + $this->expectExceptionMessage('forbidden SQL sequence (;)'); + (new MySqlDriver())->escapeIdentifier('users; DROP TABLE x'); + } + + public function testEscapeIdentifierRejectsDoubleHyphenCommentLeader(): void + { + $this->expectException(QueryBuilderInvalidArgumentException::class); + $this->expectExceptionMessage('forbidden SQL sequence (--)'); + (new MySqlDriver())->escapeIdentifier('users -- comment'); + } + + public function testEscapeIdentifierRejectsForbiddenSequenceEvenOnGenericDriver(): void + { + // GenericDriver is the no-op driver. The validation runs *before* + // the no-op return, so even unquoted drivers gain defense-in-depth. + $qb = new QueryBuilder(); // GenericDriver + + $this->expectException(QueryBuilderInvalidArgumentException::class); + $qb->getDriver()->escapeIdentifier('users; DROP TABLE x'); + } + + public function testFromWithSemicolonInjectionRaises(): void + { + $this->expectException(QueryBuilderInvalidArgumentException::class); + $this->db->from('users; DROP TABLE x; --'); + } + + public function testWhereWithDoubleHyphenInjectionRaises(): void + { + $this->expectException(QueryBuilderInvalidArgumentException::class); + $this->db->from('users')->where('name -- ', 'admin'); + } + + public function testPostgreSqlDriverAlsoRejectsForbiddenSequences(): void + { + // PostgreSQL allows multi-statement queries by default, which makes + // the validation particularly important on that driver. + $this->expectException(QueryBuilderInvalidArgumentException::class); + (new PostgreSqlDriver())->escapeIdentifier('users; DROP'); + } + + public function testLegitimateIdentifiersWithOperatorsStillWork(): void + { + $driver = new MySqlDriver(); + // JOIN-style ON expressions go through escapeIdentifier; operators + // like = and > must continue to pass through. + $this->assertSame('`a` = `b`', $driver->escapeIdentifier('a = b')); + $this->assertSame('`a`.`b` AND `c`.`d`', $driver->escapeIdentifier('a.b AND c.d')); + } + + // ----------------------------------------------------------------------- + // V4 — LIKE wildcard auto-escape + // ----------------------------------------------------------------------- + + public function testLikeEscapesPercentSignInUserValue(): void + { + $this->db->from('user')->like('name', '50%'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + // The user's literal "%" is escaped — the surrounding wildcards + // ("both" type) are still added by the builder. + $this->assertSame('%50\\%%', $params[':name']); + } + + public function testLikeEscapesUnderscoreInUserValue(): void + { + $this->db->from('user')->like('name', 'a_b'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('%a\\_b%', $params[':name']); + } + + public function testLikeEscapesBackslashFirstThenWildcards(): void + { + $this->db->from('user')->like('name', 'a\\b%c'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + // The \ is doubled, then the % is escaped. + $this->assertSame('%a\\\\b\\%c%', $params[':name']); + } + + public function testStartLikeEscapesWildcardsInValue(): void + { + $this->db->from('user')->startLike('name', 'a%b'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('a\\%b%', $params[':name']); + } + + public function testEndLikeEscapesWildcardsInValue(): void + { + $this->db->from('user')->endLike('name', 'a%b'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('%a\\%b', $params[':name']); + } + + public function testLikeWithRawQueryValueBypassesEscapeAsDocumented(): void + { + // Opt-out: when the caller deliberately passes RawQuery, the value + // is inlined verbatim and the wildcards survive untouched. + $this->db->from('user')->like('name', $this->db->raw("'custom%pattern'")); + $sql = $this->db->generateSelectQuery(); + + $this->assertEquals( + "SELECT * FROM user WHERE name LIKE 'custom%pattern'", + $sql, + ); + } + + public function testLikeWithExplicitNamedPlaceholderBypassesEscape(): void + { + // ":foo" placeholder is recognized by isSqlParameter() and emitted + // verbatim. Useful when the caller has pre-bound the parameter and + // wants full control over the pattern. + $this->db->from('user')->like('name', $this->db->raw(':needle')); + $sql = $this->db->generateSelectQuery(); + + $this->assertEquals( + 'SELECT * FROM user WHERE name LIKE :needle', + $sql, + ); + } + + public function testWildcardAttackOnlyMatchesEscapedRowsAfterFix(): void + { + // Pre-fix, like('name', '%') would compile to LIKE '%%%' which is + // semantically LIKE '%' (matches every row). Post-fix the % is + // escaped so the pattern only matches rows containing the literal + // "%" character. + $this->db->from('user')->like('name', '%'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('%\\%%', $params[':name']); + } + + // ----------------------------------------------------------------------- + // V6 — placeholder regex tightened + // ----------------------------------------------------------------------- + + public function testIsSqlParameterAcceptsCanonicalPlaceholderShapes(): void + { + $this->assertTrue(SqlValueDetector::isSqlParameter('?')); + $this->assertTrue(SqlValueDetector::isSqlParameter(':foo')); + $this->assertTrue(SqlValueDetector::isSqlParameter(':foo_1')); + $this->assertTrue(SqlValueDetector::isSqlParameter(':a_b_c')); + } + + public function testIsSqlParameterRejectsValuesContainingParens(): void + { + // Pre-V6 the character class [(\w)]+ permitted "(" and ")" inside + // placeholder names — never a legal PDO bind name. Now rejected. + $this->assertFalse(SqlValueDetector::isSqlParameter(':foo()')); + $this->assertFalse(SqlValueDetector::isSqlParameter(':(((')); + $this->assertFalse(SqlValueDetector::isSqlParameter(':foo)bar')); + } + + public function testIsSqlParameterRejectsNonStringAndShellLikeValues(): void + { + $this->assertFalse(SqlValueDetector::isSqlParameter('foo')); // no leading : + $this->assertFalse(SqlValueDetector::isSqlParameter(':')); // bare colon + $this->assertFalse(SqlValueDetector::isSqlParameter(':foo bar')); // space + $this->assertFalse(SqlValueDetector::isSqlParameter(5)); // not a string + $this->assertFalse(SqlValueDetector::isSqlParameter(null)); // not a string + } + + // ----------------------------------------------------------------------- + // V1 / V2 — DOCUMENTED RESIDUAL RISKS + // + // The auto-detection in SqlValueDetector::isSqlParameterOrFunction() is + // intentionally lenient so that callers can write + // $qb->set('updated_at', 'NOW()') + // and have it inlined without ceremony. The trade-off is that the same + // lenience applies when a value happens to look like a function call or + // a dotted column reference — including when that value originates from + // user input. Application code MUST NOT forward unsanitized user input + // into the value slot of where()/set() etc. The tests below pin the + // current behavior so refactors do not silently regress. + // + // See docs/en/security.md §V1, §V2 for the corresponding warnings. + // ----------------------------------------------------------------------- + + public function testFunctionShapedValueIsInlinedAsDocumentedRisk(): void + { + // ⚠️ This documents a known residual risk, not desired behavior for + // user-controlled inputs. Callers MUST sanitize / validate the + // value before passing it in. + $this->db->from('user')->where('id', 'CURRENT_USER()'); + $sql = $this->db->generateSelectQuery(); + + // The value is inlined as a SQL function call rather than being + // parameterized. + $this->assertStringContainsString('id = CURRENT_USER()', $sql); + } + + public function testFunctionWithArgumentsIsParameterizedNotInlined(): void + { + // The function-detection regex requires *empty* parentheses; calls + // with arguments (e.g. SLEEP(10)) do NOT match and are routed to + // the parameter bag. + $this->db->from('user')->where('id', 'SLEEP(10)'); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame('SLEEP(10)', $params[':id']); + } + + public function testDottedColumnReferenceIsInlinedAsDocumentedRisk(): void + { + // ⚠️ Same caveat — "table.column" shape in a value slot is treated + // as a SQL column reference. + $this->db->from('user')->where('id', 'users.password'); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('id = users.password', $sql); + } + + public function testNonFunctionStringValueIsAlwaysParameterized(): void + { + // Sanity: ordinary user input goes through the parameter bag. + $this->db->from('user')->where('id', "Robert'); DROP TABLE Students; --"); + $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertSame("Robert'); DROP TABLE Students; --", $params[':id']); + } + + // ----------------------------------------------------------------------- + // V5 — ORDER BY column whitelist is an application concern + // ----------------------------------------------------------------------- + + public function testOrderByOnlyEscapesIdentifierDoesNotWhitelist(): void + { + // The builder escapes the column identifier but does not constrain + // it to a predefined whitelist. Callers MUST whitelist before + // passing user-supplied sort columns. + $this->db->from('user')->orderBy('password', 'ASC'); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('ORDER BY password ASC', $sql); + } + + public function testOrderByDirectionIsValidatedAgainstAscDesc(): void + { + $this->expectException(QueryBuilderInvalidArgumentException::class); + $this->db->from('user')->orderBy('id', 'INJECTED;--'); + } + + // ----------------------------------------------------------------------- + // Integration — end-to-end: a hostile input scenario that lands safely + // ----------------------------------------------------------------------- + + public function testEndToEndPdoSafeWithHostileStringInput(): void + { + // What the application MUST do: pass user input as a *value*, never + // as an identifier. Below, "name" is a hard-coded column, and the + // attacker-controlled string lands in the parameter bag. + $attackerInput = "'; DROP TABLE users; --"; + + $this->db->from('user')->where('name', $attackerInput); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + // SQL is parameterized; the attacker string is in the bag, never in + // the compiled SQL. + $this->assertEquals('SELECT * FROM user WHERE name = :name', $sql); + $this->assertSame($attackerInput, $params[':name']); + $this->assertStringNotContainsString('DROP', $sql); + } +} diff --git a/tests/SelectHelpersTest.php b/tests/SelectHelpersTest.php new file mode 100644 index 0000000..e675449 --- /dev/null +++ b/tests/SelectHelpersTest.php @@ -0,0 +1,172 @@ + + */ + public static function singleArgProjectionProvider(): array + { + return [ + 'count' => ['selectCount', 'id', 'COUNT(id)'], + 'countDistinct' => ['selectCountDistinct', 'id', 'COUNT(DISTINCT id)'], + 'max' => ['selectMax', 'age', 'MAX(age)'], + 'min' => ['selectMin', 'age', 'MIN(age)'], + 'avg' => ['selectAvg', 'age', 'AVG(age)'], + 'sum' => ['selectSum', 'amt', 'SUM(amt)'], + 'upper' => ['selectUpper', 'name', 'UPPER(name)'], + 'lower' => ['selectLower', 'name', 'LOWER(name)'], + 'length' => ['selectLength', 'bio', 'LENGTH(bio)'], + 'distinct' => ['selectDistinct', 'name', 'DISTINCT(name)'], + ]; + } + + /** + * @dataProvider singleArgProjectionProvider + */ + public function testSingleArgumentProjection(string $method, string $column, string $expectedFragment): void + { + $this->db->{$method}($column); + $this->db->from('users'); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString($expectedFragment, $sql); + } + + /** + * @dataProvider singleArgProjectionProvider + */ + public function testSingleArgumentProjectionWithAlias(string $method, string $column, string $expectedFragment): void + { + $this->db->{$method}($column, 'alias'); + $this->db->from('users'); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString($expectedFragment . ' AS alias', $sql); + } + + public function testSelectAsEmitsAsKeyword(): void + { + $this->db->selectAs('name', 'username')->from('users'); + $expected = 'SELECT name AS username FROM users WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testSelectConcatJoinsColumnsWithCommas(): void + { + $this->db->selectConcat(['first_name', 'last_name'], 'full_name')->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('CONCAT(first_name, last_name) AS full_name', $sql); + } + + public function testSelectCoalesceWithNumericDefaultInlinesDirectly(): void + { + $this->db->selectCoalesce('view_count', 0, 'views')->from('post'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('COALESCE(view_count, 0) AS views', $sql); + } + + public function testSelectCoalesceWithNonNumericStringDefaultEscapesAsIdentifier(): void + { + // GenericDriver doesn't actually quote, but the escapeIdentifier + // pass-through is still exercised. + $this->db->selectCoalesce('a', 'fallback_col')->from('post'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('COALESCE(a, fallback_col)', $sql); + } + + public function testSelectMidEmitsMidFunction(): void + { + $this->db->selectMid('name', 1, 5, 'first5')->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('MID(name, 1, 5) AS first5', $sql); + } + + public function testSelectMidWithoutAlias(): void + { + $this->db->selectMid('name', 1, 5)->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('MID(name, 1, 5)', $sql); + $this->assertStringNotContainsString(' AS ', $sql); + } + + public function testSelectLeftEmitsLeftFunction(): void + { + $this->db->selectLeft('name', 3, 'prefix')->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('LEFT(name, 3) AS prefix', $sql); + } + + public function testSelectLeftWithoutAlias(): void + { + $this->db->selectLeft('name', 3)->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('LEFT(name, 3)', $sql); + } + + public function testSelectRightEmitsRightFunction(): void + { + $this->db->selectRight('name', 3, 'suffix')->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('RIGHT(name, 3) AS suffix', $sql); + } + + public function testSelectRightWithoutAlias(): void + { + $this->db->selectRight('name', 3)->from('users'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('RIGHT(name, 3)', $sql); + } + + public function testGroupByAcceptsNestedArrayRecursively(): void + { + $this->db->select('id')->from('post') + ->groupBy(['a', ['b', 'c']]); + $structure = $this->db->exportQB(); + + $this->assertSame(['a', 'b', 'c'], $structure['group_by']); + } + + public function testGroupByDeduplicates(): void + { + $this->db->select('id')->from('post') + ->groupBy('a') + ->groupBy('a'); + $this->assertSame(['a'], $this->db->exportQB()['group_by']); + } + + public function testOrderByAcceptsLowerCaseAndUpperCase(): void + { + $this->db->from('post') + ->orderBy('id', 'asc') + ->orderBy('name', 'DESC'); + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('ORDER BY id ASC, name DESC', $sql); + } + + public function testSelectConcatWithRawElementInArray(): void + { + $this->db->selectConcat([ + 'first_name', + $this->db->raw("' '"), + 'last_name', + ], 'full')->from('users'); + + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString("CONCAT(first_name, ' ', last_name) AS full", $sql); + } +} diff --git a/tests/SelectQueryDriverUnitTest.php b/tests/SelectQueryDriverUnitTest.php index 364ae5c..81f90ac 100644 --- a/tests/SelectQueryDriverUnitTest.php +++ b/tests/SelectQueryDriverUnitTest.php @@ -1,375 +1,338 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use InitORM\QueryBuilder\QueryBuilder; - -class SelectQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit -{ - - public function testSelectBuilder() - { - $this->db->select('id', 'name'); - $this->db->table('user'); - - $expected = "SELECT `id`, `name` FROM `user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testBlankBuild() - { - $this->db->from('post'); - - $expected = 'SELECT * FROM `post` WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelfJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name AS authorName') - ->table('post') - ->selfJoin('user', 'user.id = post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` AS `authorName` FROM `post`, `user` WHERE `user`.`id` = `post`.`user`"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testInnerJoinBuild() - { - $this->db->select('post.id, post.title', 'user.name as authorName') - ->from('post') - ->innerJoin('user', 'user.id = post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` INNER JOIN `user` ON `user`.`id` = `post`.`user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testLeftJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->leftJoin('user', 'user.id=post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` LEFT JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testRightJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->rightJoin('user', 'user.id=post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` RIGHT JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testLeftOuterJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->leftOuterJoin('user', 'user.id=post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` LEFT OUTER JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testRightOuterJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->rightOuterJoin('user', 'user.id=post.user'); - - $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` RIGHT OUTER JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testLimitStatement() - { - $this->db->select('id') - ->from('book') - ->limit(5); - - $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOffsetStatement() - { - $this->db->select('id') - ->from('book') - ->offset(5); - - $expected = 'SELECT `id` FROM `book` WHERE 1 OFFSET 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOffsetLimitStatement() - { - $this->db->select('id') - ->from('book') - ->offset(50) - ->limit(25); - - $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 50, 25'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testNegativeOffsetLimitStatement() - { - $this->db->select('id') - ->from('book') - ->offset(-25) - ->limit(-20); - - // If limit and offset are negative integers, their absolute values are taken. - $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 25, 20'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectDistinctStatement() - { - $this->db->selectDistinct('name') - ->from('book'); - $expected = 'SELECT DISTINCT(`name`) FROM `book` WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectDistinctJoinStatement() - { - $this->db->selectDistinct('author.name') - ->from('book') - ->innerJoin('author', 'author.id=book.author'); - $expected = 'SELECT DISTINCT(`author`.`name`) FROM `book` INNER JOIN `author` ON `author`.`id`=`book`.`author` WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOrderByStatement() - { - $this->db->select('name') - ->from('book') - ->orderBy('authorId', 'ASC') - ->orderBy('id', 'DESC') - ->limit(10); - - $expected = 'SELECT `name` FROM `book` WHERE 1 ORDER BY `authorId` ASC, `id` DESC LIMIT 10'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereSQLFunctionStatementBuild() - { - $this->db->from('post') - ->andBetween('date', ['2022-05-07', 'CURDATE()']); - - $expected = 'SELECT * FROM `post` WHERE `date` BETWEEN :date AND CURDATE()'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereRegexpSQLStatementBuild() - { - $this->db->from('post') - ->regexp('title', '^M[a-z]K$'); - - $expected = 'SELECT * FROM `post` WHERE `title` REGEXP :title'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectCoalesceSQLStatementBuild() - { - $this->db->select('post.title') - ->selectCoalesce('stat.view', 0) - ->from('post') - ->leftJoin('stat', 'stat.id=post.id') - ->where('post.id', 5); - - $expected = 'SELECT `post`.`title`, COALESCE(`stat`.`view`, 0) FROM `post` LEFT JOIN `stat` ON `stat`.`id`=`post`.`id` WHERE `post`.`id` = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectCoalesceDefaultValue() - { - $this->db->select('post.title') - ->selectCoalesce('stat.view', 'post.view', 'views') - ->from('post') - ->leftJoin('stat', 'stat.id=post.id') - ->where('post.id', 5); - - $expected = 'SELECT `post`.`title`, COALESCE(`stat`.`view`, `post`.`view`) AS `views` FROM `post` LEFT JOIN `stat` ON `stat`.`id`=`post`.`id` WHERE `post`.`id` = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testTableAliasSQLStatementBuild() - { - $this->db->select('p.title') - ->select('s.view as s_view') - ->from('post as p') - ->leftJoin('stat as s', 's.id=p.id') - ->where('p.id', 5); - - $expected = 'SELECT `p`.`title`, `s`.`view` as `s_view` FROM `post` as `p` LEFT JOIN `stat` as `s` ON `s`.`id`=`p`.`id` WHERE `p`.`id` = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testTableJoinAliasSQLStatementBuild() - { - $this->db->select('p.title') - ->select('s.view as s_view') - ->from('post p') - ->leftJoin('stat s', 's.id=p.id') - ->where('p.id', 5); - - $expected = 'SELECT `p`.`title`, `s`.`view` as `s_view` FROM `post` `p` LEFT JOIN `stat` `s` ON `s`.`id`=`p`.`id` WHERE `p`.`id` = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereGroupStatement() - { - $this->db->select('id') - ->from('users') - ->where('status', 1) - ->group(function (QueryBuilder $builder) { - $builder->where('type', 3) - ->where('type', 4); - }); - - $expected = 'SELECT `id` FROM `users` WHERE `status` = 1 AND (`type` = 3 AND `type` = 4)'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereGroupMultipleStatement() - { - $this->db->select('id, title, content, url') - ->from('posts') - ->where('status', 1) - ->group(function (QueryBuilder $db) { - $db->where('user_id', 1) - ->where('datetime', '>=', date("Y-m-d")); - }, 'or') - ->group(function (QueryBuilder $db) { - $db->group(function (QueryBuilder $db) { - $db->where('id', 2) - ->where('status', 3); - }, 'or') - ->group(function (QueryBuilder $db) { - $db->where('id', 4) - ->where('status', 5); - }, 'or'); - }, 'or'); - - $expected = 'SELECT `id`, `title`, `content`, `url` FROM `posts` WHERE `status` = 1 AND (`user_id` = 1 AND `datetime` >= :datetime) OR ((`id` = 2 AND `status` = 3) OR (`id` = 4 AND `status` = 5))'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testJoinClosureGive() - { - $this->db->select('u.id', 'u.name', 'u.status, p.title') - ->from('users AS u') - ->where('u.status', 1) - ->join('posts AS p', function (QueryBuilder $builder) { - $builder->on('p.user_id', 'u.id') - ->where('p.publisher_time', '>=', $builder->raw('NOW()')); - }) - ->join('categories AS c', function (QueryBuilder $builder) { - $builder->on('c.id', 'p.category_id') - ->on('c.blog_id', 'u.blog_id') - ->where('c.status', 1) - ->having($builder->raw('COUNT(p.category_id) > 1')); - })->limit(5); - - $expected = 'SELECT `u`.`id`, `u`.`name`, `u`.`status`, `p`.`title` FROM `users` AS `u` INNER JOIN `posts` AS `p` ON `p`.`user_id` = `u`.`id` INNER JOIN `categories` AS `c` ON `c`.`id` = `p`.`category_id` AND `c`.`blog_id` = `u`.`blog_id` WHERE `u`.`status` = 1 AND `p`.`publisher_time` >= NOW() AND `c`.`status` = 1 HAVING COUNT(p.category_id) > 1 LIMIT 5'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSubQuery() - { - - $this->db->select('u.name') - ->from('users AS u') - ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $builder) { - $builder->select('id') - ->from('roles') - ->where('name', 'admin'); - })); - $expected = 'SELECT `u`.`name` FROM `users` AS `u` WHERE `u`.`id` IN (SELECT `id` FROM `roles` WHERE `name` = :name)'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSubQueryJoinTable() - { - $this->db->select('u.name, p.title') - ->from('users AS u') - ->join($this->db->subQuery(function (QueryBuilder $builder) { - $builder->select('id, title, user_id') - ->from('posts') - ->where('user_id', 5); - }, 'p'), 'p.user_id = u.id', ''); - - $expected = 'SELECT `u`.`name`, `p`.`title` FROM `users` AS `u` JOIN (SELECT `id`, `title`, `user_id` FROM `posts` WHERE `user_id` = 5) AS `p` ON `p`.`user_id` = `u`.`id` WHERE 1'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0.1 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use InitORM\QueryBuilder\QueryBuilder; + +class SelectQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit +{ + public function testSelectBuilder() + { + $this->db->select('id', 'name'); + $this->db->table('user'); + $expected = "SELECT `id`, `name` FROM `user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testBlankBuild() + { + $this->db->from('post'); + $expected = 'SELECT * FROM `post` WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelfJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name AS authorName') + ->table('post') + ->selfJoin('user', 'user.id = post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` AS `authorName` FROM `post`, `user` WHERE `user`.`id` = `post`.`user`"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testInnerJoinBuild() + { + $this->db->select('post.id, post.title', 'user.name as authorName') + ->from('post') + ->innerJoin('user', 'user.id = post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` INNER JOIN `user` ON `user`.`id` = `post`.`user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testLeftJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->leftJoin('user', 'user.id=post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` LEFT JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testRightJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->rightJoin('user', 'user.id=post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` RIGHT JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testLeftOuterJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->leftOuterJoin('user', 'user.id=post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` LEFT OUTER JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testRightOuterJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->rightOuterJoin('user', 'user.id=post.user'); + $expected = "SELECT `post`.`id`, `post`.`title`, `user`.`name` as `authorName` FROM `post` RIGHT OUTER JOIN `user` ON `user`.`id`=`post`.`user` WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testLimitStatement() + { + $this->db->select('id') + ->from('book') + ->limit(5); + $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOffsetStatement() + { + $this->db->select('id') + ->from('book') + ->offset(5); + $expected = 'SELECT `id` FROM `book` WHERE 1 OFFSET 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOffsetLimitStatement() + { + $this->db->select('id') + ->from('book') + ->offset(50) + ->limit(25); + $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 50, 25'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testNegativeOffsetLimitStatement() + { + $this->db->select('id') + ->from('book') + ->offset(-25) + ->limit(-20); +// If limit and offset are negative integers, their absolute values are taken. + $expected = 'SELECT `id` FROM `book` WHERE 1 LIMIT 25, 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectDistinctStatement() + { + $this->db->selectDistinct('name') + ->from('book'); + $expected = 'SELECT DISTINCT(`name`) FROM `book` WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectDistinctJoinStatement() + { + $this->db->selectDistinct('author.name') + ->from('book') + ->innerJoin('author', 'author.id=book.author'); + $expected = 'SELECT DISTINCT(`author`.`name`) FROM `book` INNER JOIN `author` ON `author`.`id`=`book`.`author` WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOrderByStatement() + { + $this->db->select('name') + ->from('book') + ->orderBy('authorId', 'ASC') + ->orderBy('id', 'DESC') + ->limit(10); + $expected = 'SELECT `name` FROM `book` WHERE 1 ORDER BY `authorId` ASC, `id` DESC LIMIT 10'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereSQLFunctionStatementBuild() + { + $this->db->from('post') + ->andBetween('date', ['2022-05-07', 'CURDATE()']); + $expected = 'SELECT * FROM `post` WHERE `date` BETWEEN :date AND CURDATE()'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereRegexpSQLStatementBuild() + { + $this->db->from('post') + ->regexp('title', '^M[a-z]K$'); + $expected = 'SELECT * FROM `post` WHERE `title` REGEXP :title'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectCoalesceSQLStatementBuild() + { + $this->db->select('post.title') + ->selectCoalesce('stat.view', 0) + ->from('post') + ->leftJoin('stat', 'stat.id=post.id') + ->where('post.id', 5); + $expected = 'SELECT `post`.`title`, COALESCE(`stat`.`view`, 0) FROM `post` LEFT JOIN `stat` ON `stat`.`id`=`post`.`id` WHERE `post`.`id` = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectCoalesceDefaultValue() + { + $this->db->select('post.title') + ->selectCoalesce('stat.view', 'post.view', 'views') + ->from('post') + ->leftJoin('stat', 'stat.id=post.id') + ->where('post.id', 5); + $expected = 'SELECT `post`.`title`, COALESCE(`stat`.`view`, `post`.`view`) AS `views` FROM `post` LEFT JOIN `stat` ON `stat`.`id`=`post`.`id` WHERE `post`.`id` = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testTableAliasSQLStatementBuild() + { + $this->db->select('p.title') + ->select('s.view as s_view') + ->from('post as p') + ->leftJoin('stat as s', 's.id=p.id') + ->where('p.id', 5); + $expected = 'SELECT `p`.`title`, `s`.`view` as `s_view` FROM `post` as `p` LEFT JOIN `stat` as `s` ON `s`.`id`=`p`.`id` WHERE `p`.`id` = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testTableJoinAliasSQLStatementBuild() + { + $this->db->select('p.title') + ->select('s.view as s_view') + ->from('post p') + ->leftJoin('stat s', 's.id=p.id') + ->where('p.id', 5); + $expected = 'SELECT `p`.`title`, `s`.`view` as `s_view` FROM `post` `p` LEFT JOIN `stat` `s` ON `s`.`id`=`p`.`id` WHERE `p`.`id` = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereGroupStatement() + { + $this->db->select('id') + ->from('users') + ->where('status', 1) + ->group(function (QueryBuilder $builder) { + + $builder->where('type', 3) + ->where('type', 4); + }); + $expected = 'SELECT `id` FROM `users` WHERE `status` = 1 AND (`type` = 3 AND `type` = 4)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereGroupMultipleStatement() + { + $this->db->select('id, title, content, url') + ->from('posts') + ->where('status', 1) + ->group(function (QueryBuilder $db) { + + $db->where('user_id', 1) + ->where('datetime', '>=', date("Y-m-d")); + }, 'or') + ->group(function (QueryBuilder $db) { + + $db->group(function (QueryBuilder $db) { + + $db->where('id', 2) + ->where('status', 3); + }, 'or') + ->group(function (QueryBuilder $db) { + + $db->where('id', 4) + ->where('status', 5); + }, 'or'); + }, 'or'); + $expected = 'SELECT `id`, `title`, `content`, `url` FROM `posts` WHERE `status` = 1 OR (`user_id` = 1 AND `datetime` >= :datetime) OR ((`id` = 2 AND `status` = 3) OR (`id` = 4 AND `status` = 5))'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testJoinClosureGive() + { + $this->db->select('u.id', 'u.name', 'u.status, p.title') + ->from('users AS u') + ->where('u.status', 1) + ->join('posts AS p', function (QueryBuilder $builder) { + + $builder->on('p.user_id', 'u.id') + ->where('p.publisher_time', '>=', $builder->raw('NOW()')); + }) + ->join('categories AS c', function (QueryBuilder $builder) { + + $builder->on('c.id', 'p.category_id') + ->on('c.blog_id', 'u.blog_id') + ->where('c.status', 1) + ->having($builder->raw('COUNT(p.category_id) > 1')); + })->limit(5); + $expected = 'SELECT `u`.`id`, `u`.`name`, `u`.`status`, `p`.`title` FROM `users` AS `u` INNER JOIN `posts` AS `p` ON `p`.`user_id` = `u`.`id` INNER JOIN `categories` AS `c` ON `c`.`id` = `p`.`category_id` AND `c`.`blog_id` = `u`.`blog_id` WHERE `u`.`status` = 1 AND `p`.`publisher_time` >= NOW() AND `c`.`status` = 1 HAVING COUNT(p.category_id) > 1 LIMIT 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSubQuery() + { + + $this->db->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $builder) { + + $builder->select('id') + ->from('roles') + ->where('name', 'admin'); + })); + $expected = 'SELECT `u`.`name` FROM `users` AS `u` WHERE `u`.`id` IN (SELECT `id` FROM `roles` WHERE `name` = :name)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSubQueryJoinTable() + { + $this->db->select('u.name, p.title') + ->from('users AS u') + ->join($this->db->subQuery(function (QueryBuilder $builder) { + + $builder->select('id, title, user_id') + ->from('posts') + ->where('user_id', 5); + }, 'p'), 'p.user_id = u.id', ''); + $expected = 'SELECT `u`.`name`, `p`.`title` FROM `users` AS `u` JOIN (SELECT `id`, `title`, `user_id` FROM `posts` WHERE `user_id` = 5) AS `p` ON `p`.`user_id` = `u`.`id` WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/SelectQueryUnitTest.php b/tests/SelectQueryUnitTest.php index 0fb0c30..b1db899 100644 --- a/tests/SelectQueryUnitTest.php +++ b/tests/SelectQueryUnitTest.php @@ -1,375 +1,338 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use InitORM\QueryBuilder\QueryBuilder; - -class SelectQueryUnitTest extends AbstractQueryBuilderUnit -{ - - public function testSelectBuilder() - { - $this->db->select('id', 'name'); - $this->db->table('user'); - - $expected = "SELECT id, name FROM user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testBlankBuild() - { - $this->db->from('post'); - - $expected = 'SELECT * FROM post WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelfJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name AS authorName') - ->table('post') - ->selfJoin('user', 'user.id = post.user'); - - $expected = "SELECT post.id, post.title, user.name AS authorName FROM post, user WHERE user.id = post.user"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testInnerJoinBuild() - { - $this->db->select('post.id, post.title', 'user.name as authorName') - ->from('post') - ->innerJoin('user', 'user.id = post.user'); - - $expected = "SELECT post.id, post.title, user.name as authorName FROM post INNER JOIN user ON user.id = post.user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testLeftJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->leftJoin('user', 'user.id=post.user'); - - $expected = "SELECT post.id, post.title, user.name as authorName FROM post LEFT JOIN user ON user.id=post.user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testRightJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->rightJoin('user', 'user.id=post.user'); - - $expected = "SELECT post.id, post.title, user.name as authorName FROM post RIGHT JOIN user ON user.id=post.user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testLeftOuterJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->leftOuterJoin('user', 'user.id=post.user'); - - $expected = "SELECT post.id, post.title, user.name as authorName FROM post LEFT OUTER JOIN user ON user.id=post.user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testRightOuterJoinBuild() - { - $this->db->select('post.id', 'post.title', 'user.name as authorName'); - $this->db->from('post'); - $this->db->rightOuterJoin('user', 'user.id=post.user'); - - $expected = "SELECT post.id, post.title, user.name as authorName FROM post RIGHT OUTER JOIN user ON user.id=post.user WHERE 1"; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testLimitStatement() - { - $this->db->select('id') - ->from('book') - ->limit(5); - - $expected = 'SELECT id FROM book WHERE 1 LIMIT 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOffsetStatement() - { - $this->db->select('id') - ->from('book') - ->offset(5); - - $expected = 'SELECT id FROM book WHERE 1 OFFSET 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOffsetLimitStatement() - { - $this->db->select('id') - ->from('book') - ->offset(50) - ->limit(25); - - $expected = 'SELECT id FROM book WHERE 1 LIMIT 50, 25'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testNegativeOffsetLimitStatement() - { - $this->db->select('id') - ->from('book') - ->offset(-25) - ->limit(-20); - - // If limit and offset are negative integers, their absolute values are taken. - $expected = 'SELECT id FROM book WHERE 1 LIMIT 25, 20'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectDistinctStatement() - { - $this->db->selectDistinct('name') - ->from('book'); - $expected = 'SELECT DISTINCT(name) FROM book WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectDistinctJoinStatement() - { - $this->db->selectDistinct('author.name') - ->from('book') - ->innerJoin('author', 'author.id=book.author'); - $expected = 'SELECT DISTINCT(author.name) FROM book INNER JOIN author ON author.id=book.author WHERE 1'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testOrderByStatement() - { - $this->db->select('name') - ->from('book') - ->orderBy('authorId', 'ASC') - ->orderBy('id', 'DESC') - ->limit(10); - - $expected = 'SELECT name FROM book WHERE 1 ORDER BY authorId ASC, id DESC LIMIT 10'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereSQLFunctionStatementBuild() - { - $this->db->from('post') - ->andBetween('date', ['2022-05-07', 'CURDATE()']); - - $expected = 'SELECT * FROM post WHERE date BETWEEN :date AND CURDATE()'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereRegexpSQLStatementBuild() - { - $this->db->from('post') - ->regexp('title', '^M[a-z]K$'); - - $expected = 'SELECT * FROM post WHERE title REGEXP :title'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectCoalesceSQLStatementBuild() - { - $this->db->select('post.title') - ->selectCoalesce('stat.view', 0) - ->from('post') - ->leftJoin('stat', 'stat.id=post.id') - ->where('post.id', 5); - - $expected = 'SELECT post.title, COALESCE(stat.view, 0) FROM post LEFT JOIN stat ON stat.id=post.id WHERE post.id = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSelectCoalesceDefaultValue() - { - $this->db->select('post.title') - ->selectCoalesce('stat.view', 'post.view', 'views') - ->from('post') - ->leftJoin('stat', 'stat.id=post.id') - ->where('post.id', 5); - - $expected = 'SELECT post.title, COALESCE(stat.view, post.view) AS views FROM post LEFT JOIN stat ON stat.id=post.id WHERE post.id = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testTableAliasSQLStatementBuild() - { - $this->db->select('p.title') - ->select('s.view as s_view') - ->from('post as p') - ->leftJoin('stat as s', 's.id=p.id') - ->where('p.id', 5); - - $expected = 'SELECT p.title, s.view as s_view FROM post as p LEFT JOIN stat as s ON s.id=p.id WHERE p.id = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testTableJoinAliasSQLStatementBuild() - { - $this->db->select('p.title') - ->select('s.view as s_view') - ->from('post p') - ->leftJoin('stat s', 's.id=p.id') - ->where('p.id', 5); - - $expected = 'SELECT p.title, s.view as s_view FROM post p LEFT JOIN stat s ON s.id=p.id WHERE p.id = 5'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereGroupStatement() - { - $this->db->select('id') - ->from('users') - ->where('status', 1) - ->group(function (QueryBuilder $builder) { - $builder->where('type', 3) - ->where('type', 4); - }); - - $expected = 'SELECT id FROM users WHERE status = 1 AND (type = 3 AND type = 4)'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testWhereGroupMultipleStatement() - { - $this->db->select('id, title, content, url') - ->from('posts') - ->where('status', 1) - ->group(function (QueryBuilder $db) { - $db->where('user_id', 1) - ->where('datetime', '>=', date("Y-m-d")); - }, 'or') - ->group(function (QueryBuilder $db) { - $db->group(function (QueryBuilder $db) { - $db->where('id', 2) - ->where('status', 3); - }, 'or') - ->group(function (QueryBuilder $db) { - $db->where('id', 4) - ->where('status', 5); - }, 'or'); - }, 'or'); - - $expected = 'SELECT id, title, content, url FROM posts WHERE status = 1 AND (user_id = 1 AND datetime >= :datetime) OR ((id = 2 AND status = 3) OR (id = 4 AND status = 5))'; - - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - - public function testJoinClosureGive() - { - $this->db->select('u.id', 'u.name', 'u.status, p.title') - ->from('users AS u') - ->where('u.status', 1) - ->join('posts AS p', function (QueryBuilder $builder) { - $builder->on('p.user_id', 'u.id') - ->where('p.publisher_time', '>=', $builder->raw('NOW()')); - }) - ->join('categories AS c', function (QueryBuilder $builder) { - $builder->on('c.id', 'p.category_id') - ->on('c.blog_id', 'u.blog_id') - ->where('c.status', 1) - ->having($builder->raw('COUNT(p.category_id) > 1')); - })->limit(5); - - $expected = 'SELECT u.id, u.name, u.status, p.title FROM users AS u INNER JOIN posts AS p ON p.user_id = u.id INNER JOIN categories AS c ON c.id = p.category_id AND c.blog_id = u.blog_id WHERE u.status = 1 AND p.publisher_time >= NOW() AND c.status = 1 HAVING COUNT(p.category_id) > 1 LIMIT 5'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSubQuery() - { - - $this->db->select('u.name') - ->from('users AS u') - ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $builder) { - $builder->select('id') - ->from('roles') - ->where('name', 'admin'); - })); - $expected = 'SELECT u.name FROM users AS u WHERE u.id IN (SELECT id FROM roles WHERE name = :name)'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - - public function testSubQueryJoinTable() - { - $this->db->select('u.name, p.title') - ->from('users AS u') - ->join($this->db->subQuery(function (QueryBuilder $builder) { - $builder->select('id, title, user_id') - ->from('posts') - ->where('user_id', 5); - }, 'p'), 'p.user_id = u.id', ''); - - $expected = 'SELECT u.name, p.title FROM users AS u JOIN (SELECT id, title, user_id FROM posts WHERE user_id = 5) AS p ON p.user_id = u.id WHERE 1'; - $this->assertEquals($expected, $this->db->generateSelectQuery()); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use InitORM\QueryBuilder\QueryBuilder; + +class SelectQueryUnitTest extends AbstractQueryBuilderUnit +{ + public function testSelectBuilder() + { + $this->db->select('id', 'name'); + $this->db->table('user'); + $expected = "SELECT id, name FROM user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testBlankBuild() + { + $this->db->from('post'); + $expected = 'SELECT * FROM post WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelfJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name AS authorName') + ->table('post') + ->selfJoin('user', 'user.id = post.user'); + $expected = "SELECT post.id, post.title, user.name AS authorName FROM post, user WHERE user.id = post.user"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testInnerJoinBuild() + { + $this->db->select('post.id, post.title', 'user.name as authorName') + ->from('post') + ->innerJoin('user', 'user.id = post.user'); + $expected = "SELECT post.id, post.title, user.name as authorName FROM post INNER JOIN user ON user.id = post.user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testLeftJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->leftJoin('user', 'user.id=post.user'); + $expected = "SELECT post.id, post.title, user.name as authorName FROM post LEFT JOIN user ON user.id=post.user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testRightJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->rightJoin('user', 'user.id=post.user'); + $expected = "SELECT post.id, post.title, user.name as authorName FROM post RIGHT JOIN user ON user.id=post.user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testLeftOuterJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->leftOuterJoin('user', 'user.id=post.user'); + $expected = "SELECT post.id, post.title, user.name as authorName FROM post LEFT OUTER JOIN user ON user.id=post.user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testRightOuterJoinBuild() + { + $this->db->select('post.id', 'post.title', 'user.name as authorName'); + $this->db->from('post'); + $this->db->rightOuterJoin('user', 'user.id=post.user'); + $expected = "SELECT post.id, post.title, user.name as authorName FROM post RIGHT OUTER JOIN user ON user.id=post.user WHERE 1"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testLimitStatement() + { + $this->db->select('id') + ->from('book') + ->limit(5); + $expected = 'SELECT id FROM book WHERE 1 LIMIT 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOffsetStatement() + { + $this->db->select('id') + ->from('book') + ->offset(5); + $expected = 'SELECT id FROM book WHERE 1 OFFSET 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOffsetLimitStatement() + { + $this->db->select('id') + ->from('book') + ->offset(50) + ->limit(25); + $expected = 'SELECT id FROM book WHERE 1 LIMIT 50, 25'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testNegativeOffsetLimitStatement() + { + $this->db->select('id') + ->from('book') + ->offset(-25) + ->limit(-20); +// If limit and offset are negative integers, their absolute values are taken. + $expected = 'SELECT id FROM book WHERE 1 LIMIT 25, 20'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectDistinctStatement() + { + $this->db->selectDistinct('name') + ->from('book'); + $expected = 'SELECT DISTINCT(name) FROM book WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectDistinctJoinStatement() + { + $this->db->selectDistinct('author.name') + ->from('book') + ->innerJoin('author', 'author.id=book.author'); + $expected = 'SELECT DISTINCT(author.name) FROM book INNER JOIN author ON author.id=book.author WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testOrderByStatement() + { + $this->db->select('name') + ->from('book') + ->orderBy('authorId', 'ASC') + ->orderBy('id', 'DESC') + ->limit(10); + $expected = 'SELECT name FROM book WHERE 1 ORDER BY authorId ASC, id DESC LIMIT 10'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereSQLFunctionStatementBuild() + { + $this->db->from('post') + ->andBetween('date', ['2022-05-07', 'CURDATE()']); + $expected = 'SELECT * FROM post WHERE date BETWEEN :date AND CURDATE()'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereRegexpSQLStatementBuild() + { + $this->db->from('post') + ->regexp('title', '^M[a-z]K$'); + $expected = 'SELECT * FROM post WHERE title REGEXP :title'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectCoalesceSQLStatementBuild() + { + $this->db->select('post.title') + ->selectCoalesce('stat.view', 0) + ->from('post') + ->leftJoin('stat', 'stat.id=post.id') + ->where('post.id', 5); + $expected = 'SELECT post.title, COALESCE(stat.view, 0) FROM post LEFT JOIN stat ON stat.id=post.id WHERE post.id = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSelectCoalesceDefaultValue() + { + $this->db->select('post.title') + ->selectCoalesce('stat.view', 'post.view', 'views') + ->from('post') + ->leftJoin('stat', 'stat.id=post.id') + ->where('post.id', 5); + $expected = 'SELECT post.title, COALESCE(stat.view, post.view) AS views FROM post LEFT JOIN stat ON stat.id=post.id WHERE post.id = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testTableAliasSQLStatementBuild() + { + $this->db->select('p.title') + ->select('s.view as s_view') + ->from('post as p') + ->leftJoin('stat as s', 's.id=p.id') + ->where('p.id', 5); + $expected = 'SELECT p.title, s.view as s_view FROM post as p LEFT JOIN stat as s ON s.id=p.id WHERE p.id = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testTableJoinAliasSQLStatementBuild() + { + $this->db->select('p.title') + ->select('s.view as s_view') + ->from('post p') + ->leftJoin('stat s', 's.id=p.id') + ->where('p.id', 5); + $expected = 'SELECT p.title, s.view as s_view FROM post p LEFT JOIN stat s ON s.id=p.id WHERE p.id = 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereGroupStatement() + { + $this->db->select('id') + ->from('users') + ->where('status', 1) + ->group(function (QueryBuilder $builder) { + + $builder->where('type', 3) + ->where('type', 4); + }); + $expected = 'SELECT id FROM users WHERE status = 1 AND (type = 3 AND type = 4)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testWhereGroupMultipleStatement() + { + $this->db->select('id, title, content, url') + ->from('posts') + ->where('status', 1) + ->group(function (QueryBuilder $db) { + + $db->where('user_id', 1) + ->where('datetime', '>=', date("Y-m-d")); + }, 'or') + ->group(function (QueryBuilder $db) { + + $db->group(function (QueryBuilder $db) { + + $db->where('id', 2) + ->where('status', 3); + }, 'or') + ->group(function (QueryBuilder $db) { + + $db->where('id', 4) + ->where('status', 5); + }, 'or'); + }, 'or'); + $expected = 'SELECT id, title, content, url FROM posts WHERE status = 1 OR (user_id = 1 AND datetime >= :datetime) OR ((id = 2 AND status = 3) OR (id = 4 AND status = 5))'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + + public function testJoinClosureGive() + { + $this->db->select('u.id', 'u.name', 'u.status, p.title') + ->from('users AS u') + ->where('u.status', 1) + ->join('posts AS p', function (QueryBuilder $builder) { + + $builder->on('p.user_id', 'u.id') + ->where('p.publisher_time', '>=', $builder->raw('NOW()')); + }) + ->join('categories AS c', function (QueryBuilder $builder) { + + $builder->on('c.id', 'p.category_id') + ->on('c.blog_id', 'u.blog_id') + ->where('c.status', 1) + ->having($builder->raw('COUNT(p.category_id) > 1')); + })->limit(5); + $expected = 'SELECT u.id, u.name, u.status, p.title FROM users AS u INNER JOIN posts AS p ON p.user_id = u.id INNER JOIN categories AS c ON c.id = p.category_id AND c.blog_id = u.blog_id WHERE u.status = 1 AND p.publisher_time >= NOW() AND c.status = 1 HAVING COUNT(p.category_id) > 1 LIMIT 5'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSubQuery() + { + + $this->db->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $builder) { + + $builder->select('id') + ->from('roles') + ->where('name', 'admin'); + })); + $expected = 'SELECT u.name FROM users AS u WHERE u.id IN (SELECT id FROM roles WHERE name = :name)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } + + public function testSubQueryJoinTable() + { + $this->db->select('u.name, p.title') + ->from('users AS u') + ->join($this->db->subQuery(function (QueryBuilder $builder) { + + $builder->select('id, title, user_id') + ->from('posts') + ->where('user_id', 5); + }, 'p'), 'p.user_id = u.id', ''); + $expected = 'SELECT u.name, p.title FROM users AS u JOIN (SELECT id, title, user_id FROM posts WHERE user_id = 5) AS p ON p.user_id = u.id WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + $this->db->resetStructure(); + } +} diff --git a/tests/SubQueryTest.php b/tests/SubQueryTest.php new file mode 100644 index 0000000..d58b8af --- /dev/null +++ b/tests/SubQueryTest.php @@ -0,0 +1,110 @@ +db->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id')->from('roles')->where('name', 'admin'); + })); + + $expected = 'SELECT u.name FROM users AS u WHERE u.id IN (SELECT id FROM roles WHERE name = :name)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testSubQueryAsDerivedTableInJoin(): void + { + $this->db->select('u.name', 'p.title') + ->from('users AS u') + ->join( + $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id, title, user_id')->from('posts')->where('user_id', 5); + }, 'p'), + 'p.user_id = u.id', + '' + ); + + $expected = 'SELECT u.name, p.title FROM users AS u JOIN (SELECT id, title, user_id FROM posts WHERE user_id = 5) AS p ON p.user_id = u.id WHERE 1'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testSubQueryWithoutParensWhenNotIntervalQuery(): void + { + $raw = $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id')->from('users')->where('active', 1); + }, null, false); + + $this->assertEquals('SELECT id FROM users WHERE active = 1', (string) $raw); + } + + public function testSubQueryWithAliasAndNotIntervalQueryThrows(): void + { + $this->expectException(QueryBuilderException::class); + $this->expectExceptionMessage('To define alias to a subquery, it must be an inner query.'); + + $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id')->from('users'); + }, 'u', false); + } + + public function testSubQueryClosureReceivesIndependentBuilder(): void + { + $this->db->from('users'); + + $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->from('posts')->select('id'); + }); + + // The outer builder's FROM should still target "users", not "posts". + $structure = $this->db->exportQB(); + $this->assertSame(['users'], $structure['table']); + } + + public function testSubQueryParametersFlowIntoOuterBag(): void + { + $this->db->select('u.name') + ->from('users AS u') + ->whereIn('u.id', $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id')->from('roles')->where('name', 'admin'); + })); + $this->db->generateSelectQuery(); + + // The inner builder is a clone — its parameters are kept on its own + // bag. The outer parameters bag only carries what the outer builder + // added; the sub-query's bound value lives in the embedded SQL via + // the inner builder's add() call. + $sql = $this->db->generateSelectQuery(); + $this->assertStringContainsString('name = :name', $sql); + } + + public function testSubQueryAsInWithMultipleNamedRoles(): void + { + $this->db->select('u.id') + ->from('users AS u') + ->whereIn('u.role_id', $this->db->subQuery(function (QueryBuilder $sub): void { + $sub->select('id')->from('roles')->whereIn('name', ['admin', 'moderator']); + })); + + $expected = 'SELECT u.id FROM users AS u WHERE u.role_id IN (SELECT id FROM roles WHERE name IN (:name, :name_1))'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } +} diff --git a/tests/UpdateQueryDriverUnitTest.php b/tests/UpdateQueryDriverUnitTest.php index abe2cb3..b7940a3 100644 --- a/tests/UpdateQueryDriverUnitTest.php +++ b/tests/UpdateQueryDriverUnitTest.php @@ -1,60 +1,54 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0.1 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -class UpdateQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit -{ - - public function testUpdateStatementBuild() - { - - $this->db->from('post') - ->where('status', '=', true) - ->limit(5); - - $data = [ - 'title' => 'New Title', - 'status' => false, - ]; - $this->db->set($data); - - $expected = 'UPDATE `post` SET `title` = :title, `status` = :status_1 WHERE `status` = :status LIMIT 5'; - - $this->assertEquals($expected, $this->db->generateUpdateQuery()); - $this->db->resetStructure(); - } - - public function testUpdateBatchStatementBuild() - { - - $this->db->from('post') - ->where('status', '=', true); - - $this->db->set([ - 'id' => 5, - 'title' => 'New Title #5', - 'content' => 'New Content #5', - ])->set([ - 'id' => 10, - 'title' => 'New Title #10', - ]); - - $expected = 'UPDATE `post` SET `title` = CASE WHEN `id` = 5 THEN :title WHEN `id` = 10 THEN :title_1 ELSE `title` END, `content` = CASE WHEN `id` = 5 THEN :content ELSE `content` END WHERE `status` = :status AND `id` IN (5, 10)'; - - $this->assertEquals($expected, $this->db->generateUpdateBatchQuery('id')); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0.1 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +class UpdateQueryDriverUnitTest extends AbstractQueryBuilderDriverUnit +{ + public function testUpdateStatementBuild() + { + + $this->db->from('post') + ->where('status', '=', true) + ->limit(5); + $data = [ + 'title' => 'New Title', + 'status' => false, + ]; + $this->db->set($data); + $expected = 'UPDATE `post` SET `title` = :title, `status` = :status_1 WHERE `status` = :status LIMIT 5'; + $this->assertEquals($expected, $this->db->generateUpdateQuery()); + $this->db->resetStructure(); + } + + public function testUpdateBatchStatementBuild() + { + + $this->db->from('post') + ->where('status', '=', true); + $this->db->set([ + 'id' => 5, + 'title' => 'New Title #5', + 'content' => 'New Content #5', + ])->set([ + 'id' => 10, + 'title' => 'New Title #10', + ]); + $expected = 'UPDATE `post` SET `title` = CASE WHEN `id` = 5 THEN :title WHEN `id` = 10 THEN :title_1 ELSE `title` END, `content` = CASE WHEN `id` = 5 THEN :content ELSE `content` END WHERE `status` = :status AND `id` IN (5, 10)'; + $this->assertEquals($expected, $this->db->generateUpdateBatchQuery('id')); + $this->db->resetStructure(); + } +} diff --git a/tests/UpdateQueryUnitTest.php b/tests/UpdateQueryUnitTest.php index 827f970..9f404b1 100644 --- a/tests/UpdateQueryUnitTest.php +++ b/tests/UpdateQueryUnitTest.php @@ -1,62 +1,56 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace Test\InitORM\QueryBuilder; - -use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; - -class UpdateQueryUnitTest extends AbstractQueryBuilderUnit -{ - - public function testUpdateStatementBuild() - { - - $this->db->from('post') - ->where('status', '=', true) - ->limit(5); - - $data = [ - 'title' => 'New Title', - 'status' => false, - ]; - $this->db->set($data); - - $expected = 'UPDATE post SET title = :title, status = :status_1 WHERE status = :status LIMIT 5'; - - $this->assertEquals($expected, $this->db->generateUpdateQuery()); - $this->db->resetStructure(); - } - - public function testUpdateBatchStatementBuild() - { - - $this->db->from('post') - ->where('status', '=', true); - - $this->db->set([ - 'id' => 5, - 'title' => 'New Title #5', - 'content' => 'New Content #5', - ])->set([ - 'id' => 10, - 'title' => 'New Title #10', - ]); - - $expected = 'UPDATE post SET title = CASE WHEN id = 5 THEN :title WHEN id = 10 THEN :title_1 ELSE title END, content = CASE WHEN id = 5 THEN :content ELSE content END WHERE status = :status AND id IN (5, 10)'; - - $this->assertEquals($expected, $this->db->generateUpdateBatchQuery('id')); - $this->db->resetStructure(); - } - -} + + * @copyright Copyright © 2023 Muhammet ŞAFAK + * @license ./LICENSE MIT + * @version 1.0 + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace Test\InitORM\QueryBuilder; + +use Test\InitORM\QueryBuilder\AbstractQueryBuilderUnit; + +class UpdateQueryUnitTest extends AbstractQueryBuilderUnit +{ + public function testUpdateStatementBuild() + { + + $this->db->from('post') + ->where('status', '=', true) + ->limit(5); + $data = [ + 'title' => 'New Title', + 'status' => false, + ]; + $this->db->set($data); + $expected = 'UPDATE post SET title = :title, status = :status_1 WHERE status = :status LIMIT 5'; + $this->assertEquals($expected, $this->db->generateUpdateQuery()); + $this->db->resetStructure(); + } + + public function testUpdateBatchStatementBuild() + { + + $this->db->from('post') + ->where('status', '=', true); + $this->db->set([ + 'id' => 5, + 'title' => 'New Title #5', + 'content' => 'New Content #5', + ])->set([ + 'id' => 10, + 'title' => 'New Title #10', + ]); + $expected = 'UPDATE post SET title = CASE WHEN id = 5 THEN :title WHEN id = 10 THEN :title_1 ELSE title END, content = CASE WHEN id = 5 THEN :content ELSE content END WHERE status = :status AND id IN (5, 10)'; + $this->assertEquals($expected, $this->db->generateUpdateBatchQuery('id')); + $this->db->resetStructure(); + } +} diff --git a/tests/WhereOperatorsTest.php b/tests/WhereOperatorsTest.php new file mode 100644 index 0000000..fb0723e --- /dev/null +++ b/tests/WhereOperatorsTest.php @@ -0,0 +1,227 @@ + + */ + public static function comparisonOperatorProvider(): array + { + return [ + 'eq' => ['=', '='], + 'neq' => ['!=', '!='], + 'gt' => ['>', '>'], + 'lt' => ['<', '<'], + 'gte' => ['>=', '>='], + 'lte' => ['<=', '<='], + 'ne2' => ['<>', '<>'], + ]; + } + + /** + * @dataProvider comparisonOperatorProvider + */ + public function testComparisonOperatorsCompileToColOpPlaceholder(string $operator, string $emitted): void + { + $this->db->from('user')->where('age', $operator, 18); + + $expected = "SELECT * FROM user WHERE age {$emitted} 18"; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndWhereChainsWithExplicitAndConnector(): void + { + $this->db->from('user') + ->where('age', '>=', 18) + ->andWhere('country', 'TR'); + + $expected = 'SELECT * FROM user WHERE age >= 18 AND country = :country'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testOrWherePushesToOrBucket(): void + { + $this->db->from('user')->orWhere('email', 'a@b.test'); + $structure = $this->db->exportQB(); + + $this->assertEmpty($structure['where']['AND']); + $this->assertNotEmpty($structure['where']['OR']); + } + + // ---- NULL checks ---------------------------------------------------- + + public function testWhereIsNullCompilesToIsNull(): void + { + $this->db->from('post')->whereIsNull('deleted_at'); + $expected = 'SELECT * FROM post WHERE deleted_at IS NULL'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testWhereIsNotNullCompilesToIsNotNull(): void + { + $this->db->from('post')->whereIsNotNull('published_at'); + $expected = 'SELECT * FROM post WHERE published_at IS NOT NULL'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndOrIsNullVariantsHitCorrectBucket(): void + { + $this->db->from('post') + ->andWhereIsNull('deleted_at') + ->orWhereIsNull('archived_at'); + + $structure = $this->db->exportQB(); + $this->assertSame(['deleted_at IS NULL'], $structure['where']['AND']); + $this->assertSame(['archived_at IS NULL'], $structure['where']['OR']); + } + + public function testAndOrIsNotNullVariantsHitCorrectBucket(): void + { + $this->db->from('post') + ->andWhereIsNotNull('published_at') + ->orWhereIsNotNull('updated_at'); + + $structure = $this->db->exportQB(); + $this->assertSame(['published_at IS NOT NULL'], $structure['where']['AND']); + $this->assertSame(['updated_at IS NOT NULL'], $structure['where']['OR']); + } + + // ---- REGEXP --------------------------------------------------------- + + public function testRegexpEmitsRegexpKeyword(): void + { + $this->db->from('user')->regexp('username', '^[a-z]+$'); + $expected = 'SELECT * FROM user WHERE username REGEXP :username'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndOrRegexpHitCorrectBuckets(): void + { + $this->db->from('user') + ->andRegexp('username', '^[a-z]+$') + ->orRegexp('email', '@example\\.test$'); + + $structure = $this->db->exportQB(); + $this->assertCount(1, $structure['where']['AND']); + $this->assertCount(1, $structure['where']['OR']); + $this->assertStringContainsString('REGEXP', $structure['where']['AND'][0]); + $this->assertStringContainsString('REGEXP', $structure['where']['OR'][0]); + } + + // ---- SOUNDEX -------------------------------------------------------- + + public function testSoundexExpandsToConcatLikeForm(): void + { + $this->db->from('user')->soundex('name', 'Robert'); + $sql = $this->db->generateSelectQuery(); + + $this->assertStringContainsString('SOUNDEX(name)', $sql); + $this->assertStringContainsString("TRIM(TRAILING '0' FROM SOUNDEX(:name))", $sql); + } + + public function testAndOrSoundexHitCorrectBuckets(): void + { + $this->db->from('user') + ->andSoundex('name', 'Robert') + ->orSoundex('surname', 'Smith'); + + $structure = $this->db->exportQB(); + $this->assertCount(1, $structure['where']['AND']); + $this->assertCount(1, $structure['where']['OR']); + } + + // ---- FIND_IN_SET ---------------------------------------------------- + + public function testFindInSetEmitsFunctionCallForm(): void + { + $this->db->from('user')->findInSet('roles', $this->db->raw(':role')); + $expected = 'SELECT * FROM user WHERE FIND_IN_SET(:role, roles)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testNotFindInSetPrependsNot(): void + { + $this->db->from('user')->notFindInSet('roles', $this->db->raw(':role')); + $expected = 'SELECT * FROM user WHERE NOT FIND_IN_SET(:role, roles)'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } + + public function testAndOrFindInSetVariantsHitCorrectBuckets(): void + { + $this->db->from('user') + ->andFindInSet('roles', $this->db->raw(':r1')) + ->orFindInSet('groups', $this->db->raw(':g1')); + + $structure = $this->db->exportQB(); + $this->assertCount(1, $structure['where']['AND']); + $this->assertCount(1, $structure['where']['OR']); + } + + public function testAndOrNotFindInSetVariantsHitCorrectBuckets(): void + { + $this->db->from('user') + ->andNotFindInSet('roles', $this->db->raw(':r1')) + ->orNotFindInSet('groups', $this->db->raw(':g1')); + + $structure = $this->db->exportQB(); + $this->assertCount(1, $structure['where']['AND']); + $this->assertCount(1, $structure['where']['OR']); + $this->assertStringStartsWith('NOT FIND_IN_SET', $structure['where']['AND'][0]); + $this->assertStringStartsWith('NOT FIND_IN_SET', $structure['where']['OR'][0]); + } + + /** + * B28: raw string values must be parameterized, not inlined. + */ + public function testFindInSetParameterizesRawStringValue(): void + { + $this->db->from('user')->findInSet('roles', 'admin'); + $sql = $this->db->generateSelectQuery(); + $params = $this->db->getParameter()->all(); + + $this->assertEquals('SELECT * FROM user WHERE FIND_IN_SET(:roles, roles)', $sql); + $this->assertSame('admin', $params[':roles']); + } + + /** + * B28: RawQuery placeholder values must be passed through verbatim. + */ + public function testFindInSetKeepsRawQueryPlaceholderVerbatim(): void + { + $this->db->from('user')->findInSet('roles', $this->db->raw(':role')); + $sql = $this->db->generateSelectQuery(); + + $this->assertEquals('SELECT * FROM user WHERE FIND_IN_SET(:role, roles)', $sql); + } + + // ---- value shortcut + non-string operator --------------------------- + + public function testInvalidLogicalThrows(): void + { + $this->expectException(\InitORM\QueryBuilder\Exceptions\QueryBuilderInvalidArgumentException::class); + $this->db->where('a', '=', 1, 'XOR'); + } + + public function testValueShortcutSwapsOperatorAndValue(): void + { + $this->db->from('post')->where('id', 'php'); + $expected = 'SELECT * FROM post WHERE id = :id'; + $this->assertEquals($expected, $this->db->generateSelectQuery()); + } +}