From e30ae0e4b490b7a0874bd0934a18de3f780ebdae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 22:22:54 +0300 Subject: [PATCH 1/4] Bump initorm/orm requirement to ^2.0 in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 98d8e7e..f0fe47e 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.0", "ext-pdo": "*", - "initorm/orm": "^1.0" + "initorm/orm": "^2.0" }, "require-dev": { "phpunit/phpunit": "^10.4" From b3240261f5263138310be6a2a86cf30e69310dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 23:32:52 +0300 Subject: [PATCH 2/4] Add GitHub Actions CI (PHP 8.1-8.4), docs, and composer updates --- .github/workflows/ci.yml | 61 ++ .gitignore | 4 + README.md | 560 +++---------------- composer.json | 70 ++- docs/01-getting-started.md | 111 ++++ docs/02-query-builder.md | 176 ++++++ docs/03-crud.md | 147 +++++ docs/04-models.md | 173 ++++++ docs/05-entities.md | 137 +++++ docs/06-transactions.md | 61 ++ docs/07-query-log.md | 116 ++++ docs/08-datatables.md | 196 +++++++ docs/09-multi-connection.md | 82 +++ docs/10-upgrading.md | 118 ++++ docs/README.md | 18 + phpcs.xml.dist | 16 + phpstan.neon.dist | 6 + phpunit.xml | 20 +- src/DB.php | 181 ++++-- src/Database.php | 36 +- src/Entity.php | 35 +- src/Model.php | 31 +- src/Utils/Datatables.php | 284 ---------- src/Utils/Datatables/Datatables.php | 392 +++++++++++++ src/Utils/Datatables/Renderer.php | 78 +++ src/Utils/Datatables/RequestParser.php | 152 +++++ tests/DBTest.php | 118 ++++ tests/SubclassesSmokeTest.php | 47 ++ tests/Support/SqliteHelper.php | 92 +++ tests/Utils/Datatables/DatatablesTest.php | 293 ++++++++++ tests/Utils/Datatables/RendererTest.php | 86 +++ tests/Utils/Datatables/RequestParserTest.php | 111 ++++ 32 files changed, 3147 insertions(+), 861 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/01-getting-started.md create mode 100644 docs/02-query-builder.md create mode 100644 docs/03-crud.md create mode 100644 docs/04-models.md create mode 100644 docs/05-entities.md create mode 100644 docs/06-transactions.md create mode 100644 docs/07-query-log.md create mode 100644 docs/08-datatables.md create mode 100644 docs/09-multi-connection.md create mode 100644 docs/10-upgrading.md create mode 100644 docs/README.md create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist delete mode 100644 src/Utils/Datatables.php create mode 100644 src/Utils/Datatables/Datatables.php create mode 100644 src/Utils/Datatables/Renderer.php create mode 100644 src/Utils/Datatables/RequestParser.php create mode 100644 tests/DBTest.php create mode 100644 tests/SubclassesSmokeTest.php create mode 100644 tests/Support/SqliteHelper.php create mode 100644 tests/Utils/Datatables/DatatablesTest.php create mode 100644 tests/Utils/Datatables/RendererTest.php create mode 100644 tests/Utils/Datatables/RequestParserTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0c646e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [main, '*.x'] + pull_request: + branches: [main, '*.x'] + +permissions: + contents: read + +jobs: + qa: + name: PHP ${{ matrix.php }} / QA + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['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 }} + extensions: pdo, pdo_sqlite + coverage: none + tools: composer:v2 + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate composer.json + run: composer validate --strict + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Coding standards (phpcs) + run: composer cs-ci + + - name: Static analysis (PHPStan) + run: composer stan + + - name: Unit tests (PHPUnit) + run: composer test diff --git a/.gitignore b/.gitignore index 419237a..6f85e0b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,9 @@ /vendor/ /composer.lock /.phpunit.result.cache +/.phpunit.cache/ +/.phpcs-cache +/.php-cs-fixer.cache +/build/ /nbproject/private/ /*.log diff --git a/README.md b/README.md index 6415f09..71e44aa 100644 --- a/README.md +++ b/README.md @@ -1,527 +1,115 @@ # InitPHP Database -Manage your database with or without abstraction. This library is built on the PHP PDO plugin and is mainly used to build and execute SQL queries. +A Composer-friendly, batteries-included facade over the [InitORM](https://github.com/InitORM) stack — query builder, DBAL connection, ORM models and entities — plus a server-side helper for [DataTables.js](https://datatables.net/). -[![Latest Stable Version](http://poser.pugx.org/initphp/database/v)](https://packagist.org/packages/initphp/database) [![Total Downloads](http://poser.pugx.org/initphp/database/downloads)](https://packagist.org/packages/initphp/database) [![Latest Unstable Version](http://poser.pugx.org/initphp/database/v/unstable)](https://packagist.org/packages/initphp/database) [![License](http://poser.pugx.org/initphp/database/license)](https://packagist.org/packages/initphp/database) [![PHP Version Require](http://poser.pugx.org/initphp/database/require/php)](https://packagist.org/packages/initphp/database) +[![CI](https://github.com/InitPHP/Database/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/Database/actions/workflows/ci.yml) +[![Latest Stable Version](https://poser.pugx.org/initphp/database/v)](https://packagist.org/packages/initphp/database) +[![Total Downloads](https://poser.pugx.org/initphp/database/downloads)](https://packagist.org/packages/initphp/database) +[![License](https://poser.pugx.org/initphp/database/license)](https://packagist.org/packages/initphp/database) +[![PHP Version Require](https://poser.pugx.org/initphp/database/require/php)](https://packagist.org/packages/initphp/database) -## Requirements - -- PHP 8.0 and later. -- PHP PDO extension. - -## Supported Databases - -This library should work correctly in almost any database that uses basic SQL syntax. -Databases supported by PDO and suitable drivers are available at [https://www.php.net/manual/en/pdo.drivers.php](https://www.php.net/manual/en/pdo.drivers.php). - -## Installation - -``` -composer require initphp/database -``` - -## Usage - -### QueryBuilder & DBAL and CRUD - -```php -require_once "vendor/autoload.php"; -use \InitPHP\Database\DB; +## What is this? -// Connection -DB::createImmutable([ - 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4', - 'username' => 'root', - 'password' => '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_general_ci', -]); -``` - -#### Create - -```php -use \InitPHP\Database\DB; -$data = [ - 'title' => 'Post Title', - 'content' => 'Post Content', -]; - -$isInsert = DB::create('post', $data); - -/** -* This executes the following query. -* -* INSERT INTO post -* (title, content) -* VALUES -* ("Post Title", "Post Content"); -*/ -if($isInsert){ - // Success -} -``` +`initphp/database` does not reimplement an ORM. It is the InitPHP-branded entry point to the InitORM stack: -##### Create Batch +| You write | You actually get | +| --- | --- | +| `InitPHP\Database\DB` | A static facade over `InitORM\Database\Database`. | +| `InitPHP\Database\Database` | The InitORM Database class. | +| `InitPHP\Database\Model` | The InitORM active-record Model. | +| `InitPHP\Database\Entity` | The InitORM Entity with accessor / mutator hooks. | +| `InitPHP\Database\Utils\Datatables\Datatables` | **Original** server-side DataTables.js helper — the one piece that lives in this package. | -```php -use \InitPHP\Database\DB; - -$data = [ - [ - 'title' => 'Post Title 1', - 'content' => 'Post Content 1', - 'author' => 5 - ], - [ - 'title' => 'Post Title 2', - 'content' => 'Post Content 2' - ], -]; - -$isInsert = DB::createBatch('post', $data); - -/** -* This executes the following query. -* -* INSERT INTO post -* (title, content, author) -* VALUES -* ("Post Title 1", "Post Content 1", 5), -* ("Post Title 2", "Post Content 2", NULL); -*/ - -if($isInsert){ - // Success -} -``` +If a feature is documented for InitORM, it works here under the InitPHP namespace. -#### Read - -```php -use \InitPHP\Database\DB; - - -/** -* This executes the following query. -* -* SELECT user.name AS author_name, post.id, post.title -* FROM post, user -* WHERE user.id = post.author AND post.status = 1 -* ORDER BY post ASC, post.created_at DESC -* LIMIT 20, 10 -*/ - -/** @var \InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface $res */ -$res = DB::select('user.name as author_name', 'post.id', 'post.title') - ->from('post') - ->selfJoin('user', 'user.id=post.author') - ->where('post.status', 1) - ->orderBy('post.id', 'ASC') - ->orderBy('post.created_at', 'DESC') - ->offset(20)->limit(10) - ->read(); - -if($res->numRows() > 0){ - $results = $res->asAssoc() - ->rows(); - foreach ($results as $row) { - echo $row['title'] . ' by ' . $row['author_name'] . '
'; - } -} -``` - -#### Update - -```php -use \InitPHP\Database\DB; -$data = [ - 'title' => 'New Title', - 'content' => 'New Content', -]; - -$isUpdate = DB::where('id', 13) - ->update('post', $data); - -/** -* This executes the following query. -* -* UPDATE post -* SET title = "New Title", content = "New Content" -* WHERE id = 13 -*/ -if ($isUpdate) { - // Success -} -``` - -##### Update Batch - -```php -use \InitPHP\Database\DB; -$data = [ - [ - 'id' => 5, - 'title' => 'New Title #5', - 'content' => 'New Content #5', - ], - [ - 'id' => 10, - 'title' => 'New Title #10', - ] -]; - -$isUpdate = DB::where('status', '!=', 0) - ->updateBatch('id', 'post', $data); - -/** -* This executes the following query. -* -* UPDATE post SET -* title = CASE -* WHEN id = 5 THEN 'New Title #5' -* WHEN id = 10 THEN 'New Title #10' -* ELSE title END, -* content = CASE -* WHEN id = 5 THEN 'New Content #5' -* ELSE content END -* WHERE status != 0 AND id IN (5, 10) -*/ -if ($isUpdate) { - // Success -} -``` - -#### Delete - -```php -use \InitPHP\Database\DB; - -$isDelete = DB::where('id', 13) - ->delete('post'); - -/** -* This executes the following query. -* -* DELETE FROM post WHERE id = 13 -*/ -if ($isUpdate) { - // Success -} -``` - -### RAW - -```php -use \InitPHP\Database\DB; - -/** @var \InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface $res */ -$res = DB::query("SELECT id FROM post WHERE user_id = :id", [ - ':id' => 5 -]); -``` - -#### Builder for RAW - -```php -use \InitPHP\Database\DB; - -/** @var \InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface $res */ -$res = DB::select(DB::raw("CONCAT(name, ' ', surname) AS fullname")) - ->where(DB::raw("title = '' AND (status = 1 OR status = 0)")) - ->limit(5) - ->read('users'); - -/** - * SELECT CONCAT(name, ' ', surname) AS fullname - * FROM users - * WHERE title = '' AND (status = 1 OR status = 0) - * LIMIT 5 - */ -$results = $res->asAssoc() - ->rows(); -foreach ($results as $row) { - echo $row['fullname']; -} -``` - -#### Working With A Different Connection +## Requirements -This library was developed with the thought that you would work with a single database and connection, but I know that in some projects you work with more than one connection and database. +- PHP **8.1** or later +- `ext-pdo` and a PDO driver for your target database (`pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`, …) -If you want to work with a different non-global connection, use the `connect()` method. +## Installation -```php -use \InitPHP\Database\DB; - -DB::connect([ - 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4', - 'username' => 'root', - 'password' => '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_general_ci', -]); +```bash +composer require initphp/database ``` -### Model and Entity - -Model and Entity; are two common concepts used in database abstraction. To explain these two concepts in the roughest way; - -- **Model :** Each model is a class that represents a table in the database. -- **Entity :** Entity is a class that represents a single row of data. - -The most basic example of a model class would look like this. +## Quick start ```php -namespace App\Model; - -class Posts extends \InitPHP\Database\Model -{ - - /** - * If your model will use a connection other than your global connection, provide connection information. - * @var array|null

Default : NULL

- */ - protected array $credentials = [ - 'dsn' => '', - 'username' => 'root', - 'password' => '', - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - ]; - - /** - * If not specified, \InitPHP\Database\Entity::class is used by default. - * - * @var string<\InitPHP\Database\Entity> - */ - protected $entity = \App\Entities\PostEntity::class; - - /** - * If not specified, the name of your model class is used. - * - * @var string - */ - protected string $schema = 'posts'; - - /** - * The name of the PRIMARY KEY column. If not, define it as NULL. - * - * @var string - */ - protected string $schemaId = 'id'; - - /** - * Specify FALSE if you want the data to be permanently deleted. - * - * @var bool - */ - protected bool $useSoftDeletes = true; - - /** - * Column name to hold the creation time of the data. - * - * @var string|null - */ - protected ?string $createdField = 'created_at'; - - /** - * The column name to hold the last time the data was updated. - * - * @var string|null - */ - protected ?string $updatedField = 'updated_at'; - - /** - * Column name to keep deletion time if $useSoftDeletes is active. - * - * @var string|null - */ - protected ?string $deletedField = 'deleted_at'; - - protected bool $readable = true; - - protected bool $writable = true; - - protected bool $deletable = true; - - protected bool $updatable = true; - -} -``` +post_title; - */ - public function getPostTitleAttribute($title) - { - return strtoupper($title); - } - - /** - * An example of a setter method for the "post_title" column. - * - * Usage : - * $entity->post_title = 'New Post Title'; - */ - public function setPostTitleAttribute($title) - { - $this->post_title = strtolower($title); - } - -} -``` - -## Development Tools - -Below I have mentioned some developer tools that you can use during and after development. - -### Logger - -```php -use \InitPHP\Database\DB; +use InitPHP\Database\DB; DB::createImmutable([ - 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;', - 'username' => 'root', - 'password' => '', - - 'log' => __DIR__ '/logs/db.log', // string, callable or object + 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4', + 'username' => 'root', + 'password' => '', ]); -``` - -If you define a file path as a String; Attempts are made to write into it with `file_put_contents()`. - -_Note :_ You can define variables such as `{year}`, `{month}`, `{day}` in the filename. - -- You can also define an object with the `critical` method. The database library will pass the log message to this method as a parameter. Or define it as callable array to use any method of the object. - -```php -use \InitPHP\Database\DB; - -class Logger { - - public function critical(string $msg) - { - $path = __DIR__ . '/log.log'; - file_put_contents($path, $msg, FILE_APPEND); - } +$rows = DB::select('id', 'title', 'author_id') + ->from('posts') + ->where('status', '=', 1) + ->orderBy('id', 'DESC') + ->limit(10) + ->read() + ->asAssoc() + ->rows(); + +foreach ($rows as $row) { + echo $row['title'], PHP_EOL; } - -$logger = new Logger(); - -DB::createImmutable([ - 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;', - 'username' => 'root', - 'password' => '', - - 'log' => $logger, // or [$logger, 'critical'] -]); ``` -- Similarly it is possible to define it in a callable method. +## Documentation -```php -use \InitPHP\Database\DB; +Topic-by-topic guides live in [`docs/`](docs/): -DB::createImmutable([ - 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;', - 'username' => 'root', - 'password' => '', - - 'log' => function (string $msg) { - $path = __DIR__ . '/log.log'; - file_put_contents($path, $msg, FILE_APPEND); - }, -]); -``` +| # | Guide | +| --- | --- | +| 01 | [Getting started](docs/01-getting-started.md) — install, connect, first query, debug & logging | +| 02 | [Query Builder](docs/02-query-builder.md) — `select` / `where` / `join` / `groupBy` / `orderBy` / `limit` / raw | +| 03 | [CRUD](docs/03-crud.md) — `create` / `read` / `update` / `delete` and their `*Batch` siblings | +| 04 | [Models](docs/04-models.md) — table binding, soft deletes, timestamp columns, access gates | +| 05 | [Entities](docs/05-entities.md) — attribute bag, accessor / mutator hooks (and the one PHP 8.2+ pitfall) | +| 06 | [Transactions](docs/06-transactions.md) — automatic retry, dry-run / test mode | +| 07 | [Query log](docs/07-query-log.md) — `enableQueryLog` + the `log` connection channel | +| 08 | [Datatables](docs/08-datatables.md) — server-side DataTables.js integration end-to-end | +| 09 | [Multiple connections](docs/09-multi-connection.md) — secondary databases via `DB::connect()` / `Model::$credentials` | +| 10 | [Upgrading from 3.x / 4.x](docs/10-upgrading.md) — breaking changes in 5.0 and the migration path | -### DeBug Mode +## At a glance -Debug mode is used to include the executed SQL statement in the error message. *__It should only be activated in the development environment__*. +- Fluent CRUD that compiles to prepared statements (named parameter bag is handled internally). +- Per-driver SQL dialects (MySQL, PostgreSQL, SQLite, generic). +- Models with auto-derived schemas, configurable primary key, soft deletes, and `created_at` / `updated_at` plumbing. +- Entities with Laravel-style `getColumnAttribute()` / `setColumnAttribute()` hooks and dirty tracking via `getOriginal()`. +- Transaction helper with retry attempts and a `testMode` flag that always rolls back. +- Query log channel that accepts a file path (with `{year}`/`{month}`/`{day}` placeholders), a callable, or any object exposing a `critical()` method. +- Server-side DataTables.js helper that handles search, sort, paging and per-column render callbacks — see [docs/08-datatables.md](docs/08-datatables.md). -```php -use \InitPHP\Database\DB; +## Contributing -DB::createImmutable([ - 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;', - 'username' => 'root', - 'password' => '', - - 'debug' => true, // boolean -]); -``` +Org-wide guidelines (PSR-12, `declare(strict_types=1)`, Conventional Commits, PHPStan + PHPUnit on every PR) live in the InitPHP `.github` repo: -### Profiler Mode +- [CONTRIBUTING.md](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +- [CODE_OF_CONDUCT.md](https://github.com/InitPHP/.github/blob/main/CODE_OF_CONDUCT.md) -Profiler mode is a developer tool available in v3 and above. It is a feature that allows you to see the executed queries along with their execution times. +Local checks before opening a PR: -```php -use InitPHP\Database\DB; - -DB::enableQueryLog(); - -DB::table('users')->where('name', 'John')->read(); - -var_dump(DB::getQueryLogs()); - -/** - * The output of the above example looks like this; - * [ - * [ - * 'query' => 'SELECT * FROM users WHERE name = :name', - * 'time' => '0.00064', - * 'args' => [ - * ':name' => 'John', - * ] - * ] - * ] - * - */ +```bash +composer qa # phpcs + phpstan + phpunit +composer cs-fix # auto-format ``` -## To Do - -- [ ] A more detailed documentation will be prepared. - -## Getting Help - -If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker. - -## Getting Involved - -> All contributions to this project will be published under the MIT License. By submitting a pull request or filing a bug, issue, or feature request, you are agreeing to comply with this waiver of copyright interest. - -There are two primary ways to help: - -- Using the issue tracker, and -- Changing the code-base. - -### Using the issue tracker - -Use the issue tracker to suggest feature requests, report bugs, and ask questions. This is also a great way to connect with the developers of the project as well as others who are interested in this solution. - -Use the issue tracker to find ways to contribute. Find a bug or a feature, mention in the issue that you will take on that effort, then follow the Changing the code-base guidance below. - -### Changing the code-base +## Security -Generally speaking, you should fork this repository, make changes in your own fork, and then submit a pull request. All new code should have associated unit tests that validate implemented features and the presence or lack of defects. Additionally, the code should follow any stylistic and architectural guidelines prescribed by the project. In the absence of such guidelines, mimic the styles and patterns in the existing code-base. +Please **do not** open a public issue for security vulnerabilities. The org-wide [SECURITY.md](https://github.com/InitPHP/.github/blob/main/SECURITY.md) describes the private disclosure channels (GitHub PVR + email). ## Credits -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> +Maintained by [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) (`info@muhammetsafak.com.tr`). ## License -Copyright © 2022 [MIT License](./LICENSE) +[MIT](LICENSE). diff --git a/composer.json b/composer.json index f0fe47e..e563baa 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,27 @@ { "name": "initphp/database", - "description": "InitPHP DataBase (QueryBuilder, DBAL and ORM) Library", + "description": "InitPHP Database — QueryBuilder, DBAL and ORM facade for the InitORM stack, plus a server-side DataTables.js helper.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\Database\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Test\\InitPHP\\Database\\": "tests/" - } + "keywords": [ + "database", + "query-builder", + "dbal", + "orm", + "pdo", + "mysql", + "pgsql", + "sqlite", + "active-record", + "soft-delete", + "datatables", + "initphp" + ], + "homepage": "https://github.com/InitPHP/Database", + "support": { + "issues": "https://github.com/InitPHP/Database/issues", + "source": "https://github.com/InitPHP/Database", + "docs": "https://github.com/InitPHP/Database/tree/main/docs" }, "authors": [ { @@ -21,13 +31,47 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", "require": { - "php": ">=8.0", + "php": "^8.1", "ext-pdo": "*", "initorm/orm": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^10.4" + "ext-pdo_sqlite": "*", + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.10" + }, + "suggest": { + "ext-pdo_mysql": "Required for MySQL / MariaDB connections.", + "ext-pdo_pgsql": "Required for PostgreSQL connections.", + "ext-pdo_sqlite": "Required for SQLite connections." + }, + "autoload": { + "psr-4": { + "InitPHP\\Database\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\InitPHP\\Database\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text --coverage-html=build/coverage", + "cs": "phpcs", + "cs-ci": "phpcs --warning-severity=0", + "cs-fix": "phpcbf", + "stan": "phpstan analyse", + "qa": [ + "@cs-ci", + "@stan", + "@test" + ] + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true } } diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..14f5d5c --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,111 @@ +# 01 — Getting started + +## Install + +```bash +composer require initphp/database +``` + +Requirements: + +- PHP 8.1 or later +- `ext-pdo` plus the driver for your database (`pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`, …) + +## The connection array + +Every entry point — `DB::createImmutable()`, `DB::connect()`, `new Database(...)`, and `Model::$credentials` — accepts the same shape: + +```php +[ + // 1. EITHER pass a fully-formed DSN ... + 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4', + // 2. ... OR let the package build one from the parts: + 'driver' => 'mysql', // mysql | pgsql | sqlite | (any PDO driver name) + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => 'test', + + 'username' => 'root', + 'password' => '', + + 'charset' => 'utf8mb4', // applied via SET NAMES after connect (MySQL only) + 'collation' => 'utf8mb4_unicode_ci', + + 'options' => [], // passed straight to new PDO(..., ..., ..., $options) + 'queryOptions' => [], // passed to prepare() on every statement + + // Optional channels — see docs/07-query-log.md + 'log' => null, // string | callable | object with critical() + 'debug' => false, // include the executed SQL in error messages + 'queryLogs' => false, // start with the in-memory query log enabled +] +``` + +If both `dsn` and the driver-shaped fields are present, `dsn` wins. + +## Bootstrap one connection (the common case) + +```php + 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4', + 'username' => 'root', + 'password' => '', +]); +``` + +`createImmutable()` may be called **once per process**. A second call throws `InitORM\Database\Exceptions\DatabaseException` — if you genuinely need to swap the shared connection (test fixtures, multi-tenant routing), use `DB::replaceImmutable()` instead. + +## Your first query + +```php +use InitPHP\Database\DB; + +$rows = DB::select('id', 'title') + ->from('posts') + ->where('status', '=', 1) + ->orderBy('id', 'DESC') + ->limit(10) + ->read() + ->asAssoc() + ->rows(); +``` + +The fluent chain returns the shared `Database` instance until you call `read()`, at which point you get back a `DataMapperInterface`. From there `asAssoc()` / `asObject()` / `asClass()` pick the fetch mode and `row()` / `rows()` consume the result. + +## SQLite in two lines (handy for tests / scripts) + +```php +DB::createImmutable([ + 'driver' => 'sqlite', + 'database' => __DIR__ . '/data.sqlite', + 'charset' => '', +]); +``` + +For an in-memory database use `'database' => ':memory:'` — keep in mind that each PDO handle gets its own `:memory:` instance. + +## Debug mode + +```php +DB::createImmutable([ + 'dsn' => 'mysql:host=localhost;dbname=test;charset=utf8mb4', + 'username' => 'root', + 'password' => '', + + 'debug' => true, // include the compiled SQL in any thrown exception +]); +``` + +Debug mode is a development convenience — it leaks query text into error messages, so **never enable it in production**. + +## Where to go next + +- [02 — Query Builder](02-query-builder.md) — the chainable surface in depth. +- [03 — CRUD](03-crud.md) — `create` / `update` / `delete` and the batch variants. +- [04 — Models](04-models.md) — active-record-style table classes. +- [07 — Query log](07-query-log.md) — the `log` channel and per-call instrumentation. diff --git a/docs/02-query-builder.md b/docs/02-query-builder.md new file mode 100644 index 0000000..b3720a0 --- /dev/null +++ b/docs/02-query-builder.md @@ -0,0 +1,176 @@ +# 02 — Query Builder + +The query builder is fluent: every method returns the surrounding `Database` (or `Model`) so chains compose naturally. Calls that you do not see on the `DB` facade fall through to the underlying [InitORM query builder](https://github.com/InitORM/QueryBuilder), so the surface is wide — what follows is the slice you will reach for daily. + +All examples below assume `DB::createImmutable([...])` ran during bootstrap and `use InitPHP\Database\DB;` is at the top of the file. + +## SELECT + +```php +$res = DB::select('id', 'title', 'author_id') + ->from('posts') + ->where('status', '=', 1) + ->read(); +``` + +`select()` accepts plain column names, aliased strings (`'COUNT(*) AS total'` works), or `DB::raw(...)` fragments. Use `selectAs('column', 'alias')` for an explicit alias. + +Built-in aggregate / function helpers: + +| Helper | Emits | +| --- | --- | +| `selectCount($col, $alias = null)` | `COUNT($col) AS $alias` | +| `selectCountDistinct($col, $alias)` | `COUNT(DISTINCT $col) AS $alias` | +| `selectSum`, `selectAvg`, `selectMax`, `selectMin` | the matching SQL aggregate | +| `selectUpper`, `selectLower`, `selectLength` | string functions | +| `selectConcat([$a, $b, ...], $alias)` | `CONCAT(...)` (driver-specific) | +| `selectCoalesce($col, $default, $alias)` | `COALESCE(...)` | + +## FROM + +```php +DB::select('*')->from('posts'); +DB::select('*')->from('posts', 'p'); // FROM posts AS p +DB::select('*')->from('posts')->addFrom('users', 'u'); // FROM posts, users AS u +``` + +`table('posts')` is equivalent to `from('posts')` and is the conventional name when you start the chain from `Model::__call`. + +## WHERE + +```php +DB::select('*') + ->from('posts') + ->where('status', '=', 1) + ->andWhere('author_id', 5) + ->orWhere('pinned', true); +``` + +The third argument is the value; the second is the operator. Most callers omit the operator (it defaults to `=`): + +```php +DB::select('*')->from('users')->where('email', $email); +``` + +### NULL / NOT NULL + +```php +DB::select('*')->from('users')->whereIsNull('deleted_at'); +DB::select('*')->from('users')->whereIsNotNull('email_verified_at'); +``` + +### IN / NOT IN + +```php +DB::select('*')->from('posts')->whereIn('id', [1, 2, 3]); +DB::select('*')->from('posts')->whereNotIn('status', ['draft', 'spam']); +``` + +### BETWEEN + +```php +DB::select('*') + ->from('orders') + ->between('created_at', '2026-01-01', '2026-12-31'); +``` + +`notBetween()`, `andBetween()`, `orBetween()` round out the family. + +### LIKE + +```php +DB::select('*')->from('users')->like('email', '%@example.com'); +DB::select('*')->from('users')->like('name', 'ada', 'after'); // 'ada%' +DB::select('*')->from('users')->like('name', 'ace', 'before'); // '%ace' +``` + +Type values: `'both'` (default), `'before'`, `'after'`. `startLike()` / `endLike()` are friendlier wrappers around the latter two. + +### Grouped predicates + +```php +DB::select('*') + ->from('users') + ->where('active', 1) + ->group(function ($builder) { + $builder->where('role', 'admin') + ->orWhere('role', 'editor'); + }); +// WHERE active = 1 AND (role = 'admin' OR role = 'editor') +``` + +> Heads-up: as of `initorm/query-builder` 2.x, parameters bound inside the group callback do not propagate back to the outer builder's parameter bag. If a `group()` predicate references user input, prefer a raw fragment with explicit `setParameter()` calls until that is fixed upstream (the Datatables helper does exactly that — see `applySearchFilter()` in `src/Utils/Datatables/Datatables.php`). + +## JOIN + +```php +DB::select('posts.id', 'posts.title', 'users.name AS author') + ->from('posts') + ->leftJoin('users', 'users.id = posts.author_id') + ->where('posts.status', 1) + ->read(); +``` + +Variants: `join()` (defaults to `INNER`), `innerJoin()`, `leftJoin()`, `rightJoin()`, `leftOuterJoin()`, `rightOuterJoin()`, `naturalJoin()`, `selfJoin()`. The ON clause may be a string, a `DB::raw(...)` fragment, or a closure that uses `on()` for richer predicates. + +## GROUP BY / HAVING + +```php +DB::select('author_id', 'COUNT(*) AS post_count') + ->from('posts') + ->groupBy('author_id') + ->having('post_count', '>', 5) + ->orderBy('post_count', 'DESC') + ->read(); +``` + +`groupBy()` accepts one or many columns, strings or `RawQuery` fragments. + +## ORDER BY / LIMIT / OFFSET + +```php +DB::select('*') + ->from('posts') + ->orderBy('created_at', 'DESC') + ->offset(20) + ->limit(10) + ->read(); +``` + +`offset()` / `limit()` are integer-typed — pass coerced values when they originate in user input. + +## Raw fragments + +```php +$res = DB::select(DB::raw("CONCAT(name, ' ', surname) AS full_name")) + ->from('users') + ->where(DB::raw("status IN (1, 2) AND deleted_at IS NULL")) + ->limit(5) + ->read(); +``` + +`DB::raw($sql)` produces a `RawQuery` value object that the compiler emits verbatim — bypass it only when the SQL fragment is **fully under your control**. User input must flow through real parameters (`where('col', $value)` or `setParameter(':name', $value)`). + +## Sub-queries + +```php +$res = DB::select('*') + ->from('users') + ->whereIn('id', DB::subQuery(function ($builder) { + $builder->select('user_id') + ->from('orders') + ->where('total', '>', 1000); + })) + ->read(); +``` + +`subQuery($closure, $alias = null, $isIntervalQuery = true)` returns a `RawQuery` you can drop anywhere a value is accepted. + +## Resetting between queries + +`read()`, `create()`, `update()` and `delete()` reset the builder structure for you. If you build a chain and decide not to execute it, call `DB::withFreshBuilder()` (or `DB::builder()` — the deprecated alias) for a clean sibling. + +## Next up + +- [03 — CRUD](03-crud.md) for `create`, `update`, `delete` and the `*Batch` family. +- [07 — Query log](07-query-log.md) to inspect the SQL the builder actually produced. diff --git a/docs/03-crud.md b/docs/03-crud.md new file mode 100644 index 0000000..050a2fc --- /dev/null +++ b/docs/03-crud.md @@ -0,0 +1,147 @@ +# 03 — CRUD + +Four method families do the heavy lifting: + +| Operation | Method | Batch sibling | +| --- | --- | --- | +| Insert | `create()` | `createBatch()` | +| Select | `read()` | — | +| Update | `update()` | `updateBatch()` | +| Delete | `delete()` | — | + +All four are available on `DB::`, on a `Database` instance, and on any `Model` subclass. The model variants apply soft-delete / timestamp / writability gates on top — see [04 — Models](04-models.md). + +## Create + +```php +use InitPHP\Database\DB; + +DB::create('posts', [ + 'title' => 'Hello world', + 'content' => 'First post.', +]); +// INSERT INTO posts (title, content) VALUES ('Hello world', 'First post.') + +$id = DB::insertId(); // PDO::lastInsertId +``` + +`create()` returns `true` on success and throws on failure. Use `DB::insertId()` to read the autogenerated primary key. + +### Batch insert + +```php +DB::createBatch('posts', [ + ['title' => 'Post #1', 'content' => 'Body 1', 'author_id' => 5], + ['title' => 'Post #2', 'content' => 'Body 2'], // author_id missing — becomes NULL +]); +/* +INSERT INTO posts (title, content, author_id) VALUES + ('Post #1', 'Body 1', 5), + ('Post #2', 'Body 2', NULL); +*/ +``` + +The compiler unions the keys across all rows; missing columns are emitted as `NULL`. + +## Read + +```php +$res = DB::select('id', 'title') + ->from('posts') + ->where('status', '=', 1) + ->orderBy('id', 'DESC') + ->limit(20) + ->read(); +``` + +The `read()` call returns an `InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface`. From there: + +```php +// Pick a fetch mode (chainable, all return the mapper): +$res->asAssoc(); // PDO::FETCH_ASSOC (default for plain DB queries) +$res->asObject(); // PDO::FETCH_OBJ +$res->asClass('Entity'); // PDO::FETCH_CLASS, hydrating that class +$res->asLazy(); // PDO::FETCH_LAZY + +// Consume: +$first = $res->row(); // next row, or null +$all = $res->rows(); // all remaining rows as an array +``` + +### `numRows()` caveat + +`$res->numRows()` is `PDOStatement::rowCount()` under the hood. On SQLite and on unbuffered MySQL connections it is **unreliable for SELECT statements** — it can return 0 even when rows came back. For INSERT/UPDATE/DELETE on common drivers it works as expected. When in doubt, fetch with `rows()` and `count()` the array. + +### Short-form read + +```php +$res = DB::read('posts', ['id', 'title'], ['status' => 1]); +``` + +The signature is `read(?string $table, ?array $selectors, ?array $conditions)`. `$conditions` accepts a `column => value` map (which becomes `column = value`) and integer-keyed entries (which become bare WHERE clauses for raw fragments). + +## Update + +```php +DB::where('id', 13)->update('posts', [ + 'title' => 'New title', + 'content' => 'New body', +]); +// UPDATE posts SET title = 'New title', content = 'New body' WHERE id = 13 +``` + +`update($table, $set, $conditions = null)` returns `true` on success. The `$conditions` argument is the same short-form map as in `read()`. + +### Batch update (CASE / WHEN) + +```php +DB::where('status', '!=', 0)->updateBatch('id', 'posts', [ + ['id' => 5, 'title' => 'New #5', 'content' => 'Body #5'], + ['id' => 10, 'title' => 'New #10'], // partial update; content untouched +]); +/* +UPDATE posts SET + title = CASE + WHEN id = 5 THEN 'New #5' + WHEN id = 10 THEN 'New #10' + ELSE title END, + content = CASE + WHEN id = 5 THEN 'Body #5' + ELSE content END +WHERE status != 0 AND id IN (5, 10); +*/ +``` + +The first argument is the reference column (usually the primary key). Rows are matched on it; columns that some rows omit fall back to the existing value via `ELSE column`. + +## Delete + +```php +DB::where('id', 13)->delete('posts'); +// DELETE FROM posts WHERE id = 13 +``` + +`delete($table, $conditions = null)` shares the conditions-shape with `read()` / `update()`. + +When working through a `Model` with `$useSoftDeletes = true`, this becomes a soft delete by default — pass `$purge = true` to bypass the soft-delete path. See [04 — Models](04-models.md). + +## Raw SQL + +When the query is faster to write by hand: + +```php +$res = DB::query( + 'SELECT id, title FROM posts WHERE user_id = :uid AND status > :s', + [':uid' => 5, ':s' => 0] +); + +foreach ($res->asAssoc()->rows() as $row) { + echo $row['title'], PHP_EOL; +} +``` + +`query()` bypasses the builder entirely — it prepares the SQL, binds the parameters, returns a `DataMapperInterface`. Use it when the SQL is small and the builder would only obscure it. + +## Affected rows + +After any of the four operations, `DB::affectedRows()` returns the row count from the underlying `PDOStatement::rowCount()`. Same SQLite-on-SELECT caveat as above applies to read paths; on common drivers it is reliable for write operations. diff --git a/docs/04-models.md b/docs/04-models.md new file mode 100644 index 0000000..32ff45a --- /dev/null +++ b/docs/04-models.md @@ -0,0 +1,173 @@ +# 04 — Models + +A `Model` is a class that binds one database table and exposes the same CRUD surface as `DB::`, with a few model-only conveniences layered on top: + +- A configurable primary key column. +- Auto-derived schema name (`PostComment` → `post_comment`) when you don't set one. +- Soft deletes (`deleted_at` column). +- Auto-managed `created_at` / `updated_at` columns. +- Per-operation access gates (`$readable`, `$writable`, `$updatable`, `$deletable`). +- An `Entity` class used to hydrate `read()` results. + +## A minimal model + +```php +namespace App\Model; + +use InitPHP\Database\Model; + +final class Posts extends Model +{ + protected string $schema = 'posts'; + protected string $schemaId = 'id'; +} +``` + +The base class' constructor binds the model to the shared `DB::getDatabase()` instance — so as long as you ran `DB::createImmutable([...])` during bootstrap, the model needs no extra wiring. + +```php +$posts = new App\Model\Posts(); +$posts->create(['title' => 'Hello', 'content' => 'World']); +``` + +## What you can configure + +```php +namespace App\Model; + +use App\Entity\PostEntity; +use InitPHP\Database\Model; + +final class Posts extends Model +{ + // Optional. Defaults to the snake_case form of the class' short name. + protected string $schema = 'posts'; + + // Primary key column. Set to '' to disable the PK lift-out in update(). + protected string $schemaId = 'id'; + + // Entity class used by read() to hydrate rows. Defaults to InitPHP\Database\Entity. + protected string $entity = PostEntity::class; + + // Soft deletes — see below. + protected bool $useSoftDeletes = true; + protected ?string $deletedField = 'deleted_at'; + + // Timestamp columns — auto-filled with date($timestampFormat). + protected ?string $createdField = 'created_at'; + protected ?string $updatedField = 'updated_at'; + protected string $timestampFormat = 'Y-m-d H:i:s'; + + // Access gates. Setting any of these to false throws on the matching call. + protected bool $readable = true; + protected bool $writable = true; + protected bool $updatable = true; + protected bool $deletable = true; + + // Use a non-default connection — see docs/09-multi-connection.md + protected ?array $credentials = null; +} +``` + +## CRUD on a model + +```php +$posts = new App\Model\Posts(); + +// Insert; $createdField is filled in automatically when set. +$posts->create(['title' => 'Hello', 'content' => 'World']); + +// Select; soft-deleted rows are excluded automatically. +$rows = $posts->read(['id', 'title'], ['status' => 1]) + ->asAssoc() + ->rows(); + +// Update; if the primary key sits in $set, it is lifted into a WHERE +// clause and removed from the SET map. +$posts->update(['id' => 13, 'title' => 'New title']); + +// Or pass conditions explicitly: +$posts->update(['title' => 'New title'], ['id' => 13]); + +// Delete; soft-deletes when $useSoftDeletes = true, hard-deletes otherwise. +$posts->delete(['id' => 13]); +$posts->delete(['id' => 13], purge: true); // force a real DELETE +``` + +The fluent builder is available too — every unknown method falls through to the underlying `Database`: + +```php +$rows = $posts + ->where('status', 1) + ->orderBy('created_at', 'DESC') + ->limit(20) + ->read() + ->rows(); +``` + +## Soft deletes + +Enable the feature and point it at a nullable column: + +```php +protected bool $useSoftDeletes = true; +protected ?string $deletedField = 'deleted_at'; +``` + +After that: + +- `delete()` becomes `UPDATE ... SET deleted_at = NOW()` instead of `DELETE`. +- `read()` adds `WHERE deleted_at IS NULL` automatically. +- `update()` likewise scopes to non-deleted rows. +- `onlyDeleted()` flips the filter for the next `read()` only — useful for trash UIs: + + ```php + $trash = $posts->onlyDeleted()->read()->rows(); + ``` +- `ignoreDeleted()` appends `deleted_at IS NULL` to the current chain — call it when you compose your own builder chain and want the soft-delete predicate added. +- Pass `delete([...], purge: true)` to skip the soft-delete machinery and issue a real `DELETE`. + +If you turn `$useSoftDeletes` on but forget `$deletedField`, the constructor throws an `InitORM\ORM\Exceptions\ModelException` — there is no silent fallback. + +## Timestamp columns + +```php +protected ?string $createdField = 'created_at'; +protected ?string $updatedField = 'updated_at'; +protected string $timestampFormat = 'Y-m-d H:i:s'; +``` + +- `create()` / `createBatch()` set the `createdField` for the row(s) being inserted. +- `update()` / `updateBatch()` set the `updatedField` on the rows being modified. +- Set either field to `null` (or `''`) to disable that side. + +Set `$timestampFormat` to anything `date()` accepts — `'U'` for a Unix timestamp, `'c'` for ISO-8601, etc. + +## Access gates + +```php +protected bool $readable = true; +protected bool $writable = true; +protected bool $updatable = true; +protected bool $deletable = true; +``` + +Flip any of these to `false` and the matching call throws (`ReadableException`, `WritableException`, `UpdatableException`, `DeletableException`). Handy for read-only views or audit-log tables that must never be updated. + +## `save()` — insert-or-update via an entity + +```php +$entity = new PostEntity(['title' => 'New post']); +$posts->save($entity); // no id ⇒ create() + +$entity = new PostEntity(['id' => 13, 'title' => 'Edited']); +$posts->save($entity); // id present ⇒ update() +``` + +`save()` looks at `$schemaId` on the model and at the entity's attribute bag: present ⇒ update; absent / null / empty string ⇒ create. + +## Where to go next + +- [05 — Entities](05-entities.md) for the attribute bag, accessors and mutators (and the PHP 8.2+ pitfall). +- [06 — Transactions](06-transactions.md) for retry-aware atomic writes. +- [09 — Multiple connections](09-multi-connection.md) for `protected ?array $credentials`. diff --git a/docs/05-entities.md b/docs/05-entities.md new file mode 100644 index 0000000..b58f137 --- /dev/null +++ b/docs/05-entities.md @@ -0,0 +1,137 @@ +# 05 — Entities + +An entity is a typed-ish row container. Each instance carries: + +- An **attribute bag** (`protected array $attributes`) — the actual column → value map. +- A **snapshot** (`protected array $attributesOriginal`) taken at construction time, useful for dirty tracking. + +Reading or writing a column through `$entity->column` dispatches through `__get` / `__set`, which gives subclasses a clean place to hang accessor / mutator hooks. + +## A minimal entity + +```php +namespace App\Entity; + +use InitPHP\Database\Entity; + +final class PostEntity extends Entity +{ +} +``` + +Wire it into a model: + +```php +final class Posts extends \InitPHP\Database\Model +{ + protected string $entity = \App\Entity\PostEntity::class; +} +``` + +`read()` will then hydrate rows into instances of your subclass. + +## Reading and writing attributes + +```php +$post = new PostEntity(['id' => 1, 'title' => 'Hello']); + +echo $post->title; // 'Hello' +$post->title = 'Edited'; // routed through __set +$post->toArray(); // ['id' => 1, 'title' => 'Edited'] +$post->getAttributes(); // same as toArray() +$post->getOriginal(); // ['id' => 1, 'title' => 'Hello'] — snapshot at construct time +``` + +`isset($post->title)` and `unset($post->title)` work via `__isset` / `__unset`. + +`syncOriginal()` refreshes the snapshot — call it after a `save()` if you want subsequent dirty-tracking to be relative to the just-persisted state. + +## Accessors (read hooks) + +Define `get{Column}Attribute($value)` and the entity routes property reads through it: + +```php +final class PostEntity extends Entity +{ + public function getTitleAttribute(?string $value): string + { + return (string) ($value ?? '(untitled)'); + } +} + +$post = new PostEntity(['title' => null]); +echo $post->title; // '(untitled)' +``` + +The method receives whatever currently sits in the attribute bag (or `null` when nothing has been stored yet) and may return anything. + +## Mutators (write hooks) + +Define `set{Column}Attribute($value)` and the entity routes property writes through it. **This is where almost every entity bug in the wild comes from**, so read this section carefully. + +### ✅ Correct — write back via `setAttribute()` + +```php +final class PostEntity extends Entity +{ + public function setTitleAttribute(string $value): void + { + $this->setAttribute('title', mb_strtolower($value)); + } +} + +$post = new PostEntity(); +$post->title = 'Hello'; +echo $post->title; // 'hello' +``` + +### ❌ Wrong — direct assignment from inside a class method + +```php +final class PostEntity extends Entity +{ + public function setTitleAttribute(string $value): void + { + $this->title = mb_strtolower($value); // ⚠️ does NOT go through __set + } +} +``` + +Inside a class method, PHP resolves `$this->column = $value` **directly on the object**, bypassing `__set`. That means: + +1. The transformed value never reaches `$attributes`. +2. PHP creates a dynamic property instead — deprecated since 8.2, fatal in a future PHP release. + +Always use `$this->setAttribute($name, $value)` from inside a mutator. + +## Camel-cased accessor / mutator names + +The column-name → method-name translation is `snake_case` → `PascalCase`: + +| Column | Accessor | Mutator | +| --- | --- | --- | +| `title` | `getTitleAttribute` | `setTitleAttribute` | +| `author_id` | `getAuthorIdAttribute` | `setAuthorIdAttribute` | +| `post_meta_data` | `getPostMetaDataAttribute` | `setPostMetaDataAttribute` | + +`Entity::__call()` covers the `*Attribute` family even when you don't define a real method — the fallback simply forwards to the attribute bag. + +## Hydration paths + +There are two ways an entity gets populated: + +1. **Direct construction** — `new PostEntity(['title' => 'Hello'])`. The constructor dispatches every key through `__set`, so mutators run. +2. **`read()->asClass(...)`** — PDO's `FETCH_CLASS` populates the entity for you. PDO writes properties directly on the object, **so mutators do NOT run on hydration**. If you need transformation on read, do it via a `get{Column}Attribute()` accessor. + +## Debug-friendly output + +```php +var_dump($post); +``` + +`__debugInfo()` returns the attribute bag, so `var_dump` shows the row data rather than the internal fields. + +## Next up + +- [04 — Models](04-models.md) for the table side of the pair. +- [03 — CRUD](03-crud.md) for `save()` semantics (insert-or-update from an entity). diff --git a/docs/06-transactions.md b/docs/06-transactions.md new file mode 100644 index 0000000..7b6ae18 --- /dev/null +++ b/docs/06-transactions.md @@ -0,0 +1,61 @@ +# 06 — Transactions + +```php +use InitPHP\Database\DB; + +DB::transaction(function ($db) { + $db->create('posts', ['title' => 'New', 'author_id' => 5]); + $db->update('counters', ['posts' => DB::raw('posts + 1')], ['author_id' => 5]); +}); +``` + +`transaction()` opens a PDO transaction, runs the closure, and commits when the closure returns without throwing. Any throwable triggers a rollback and the exception propagates unchanged — your error handling stays normal. + +## Method signature + +```php +DB::transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool +``` + +| Argument | Meaning | +| --- | --- | +| `$closure` | Receives the `Database` (or `Model`) the transaction is bound to. Use it for the writes — `DB::` works too, since the facade points at the same instance. | +| `$attempt` | How many times to retry on failure. `1` means "no retry" (the default). | +| `$testMode` | When `true`, rolls back at the end even if the closure succeeded. Useful for assertions inside tests / dry runs. | + +The method returns `true` on a successful commit. On exhausted retries it throws `InitORM\Database\Exceptions\DatabaseException` whose `getPrevious()` is the underlying error from the last attempt. + +## Retry semantics + +```php +DB::transaction(function ($db) { + $db->create('orders', $order); + // ... maybe a deadlock or transient lock contention ... +}, attempt: 3); +``` + +Between attempts the helper rolls back any partial transaction and retries the entire closure. Retry is appropriate for **transient** failures — deadlocks, lock-wait timeouts, replica failover. Do not use it as a substitute for validating input; a closure that throws a `LogicException` will fail three times in a row. + +## Test mode + +```php +DB::transaction(function ($db) { + $db->create('audit', ['action' => 'probe']); + + self::assertSame(1, (int) $db->select('COUNT(*)')->from('audit')->read()->row()['COUNT(*)']); +}, testMode: true); +``` + +In `testMode = true` the helper calls `rollBack()` even on the success path. The closure can write freely, run assertions against the new state, and the database walks away untouched. Combine with attempt = 1 to keep tests deterministic. + +## Nesting is rejected + +Starting a transaction while another is already in progress on the same PDO connection throws `DatabaseException` — there is no savepoint emulation. If you need that pattern, model it as a single outer transaction that runs a small pipeline of operations. + +## Rollback failures + +If `rollBack()` itself fails mid-flight (e.g. the connection died), the helper wraps the rollback error and the original error into a single `DatabaseException` with a message that names both. `getCode()` and `getPrevious()` preserve the original error. + +## Closing a connection inside the closure + +Don't. Transactions live on the underlying PDO handle, and if that goes away mid-flight the rollback path cannot run. If you need to swap connections, do it outside the transaction — for example via `DB::replaceImmutable(...)` between two unrelated transactions. diff --git a/docs/07-query-log.md b/docs/07-query-log.md new file mode 100644 index 0000000..2bf0a92 --- /dev/null +++ b/docs/07-query-log.md @@ -0,0 +1,116 @@ +# 07 — Query log + +Two independent facilities, often confused: + +| Tool | What it captures | Where it goes | +| --- | --- | --- | +| **`enableQueryLog()`** | Every executed SQL, with the bound args and the elapsed time. | An in-memory array — read it back with `getQueryLogs()`. | +| **The `log` connection channel** | Connection-level errors and notable events (driver-level). | A file path, callable, PSR-3 logger, or any object with a `critical()` method. | + +Use the first for profiling and test assertions; use the second for the audit trail. + +## In-memory query log + +```php +use InitPHP\Database\DB; + +DB::enableQueryLog(); + +DB::table('users')->where('name', 'Ada')->read(); + +print_r(DB::getQueryLogs()); +/* +[ + [ + 'query' => 'SELECT * FROM `users` WHERE `name` = :name', + 'args' => [':name' => 'Ada'], + 'timer' => 0.000385, + ], +] +*/ + +DB::disableQueryLog(); // stop recording (the existing buffer stays untouched) +``` + +`enableQueryLog()` flips an in-memory flag on the connection — every subsequent prepare/execute appends to the buffer. Useful in tests: + +```php +$db->enableQueryLog(); +$model->read(); +self::assertStringContainsString('WHERE deleted_at IS NULL', $db->getQueryLogs()[0]['query']); +``` + +Each entry has: + +| Key | Type | Notes | +| --- | --- | --- | +| `query` | `string` | The prepared SQL exactly as sent to PDO. | +| `args` | `array` | The named-parameter map that was bound. | +| `timer` | `float` | Seconds elapsed inside `execute()`. | + +You can also enable it at bootstrap with `'queryLogs' => true` in the connection array, in which case the buffer fills from the very first query. + +## The `log` connection channel + +```php +DB::createImmutable([ + 'dsn' => 'mysql:host=localhost;dbname=test;charset=utf8mb4', + 'username' => 'root', + 'password' => '', + + 'log' => __DIR__ . '/logs/db-{year}-{month}-{day}.log', +]); +``` + +The `log` credential accepts four shapes: + +1. **PSR-3 logger** — anything implementing `Psr\Log\LoggerInterface`. The log writer calls `$logger->critical($message, $context)`. + + ```php + 'log' => new Monolog\Logger('db'), + ``` + +2. **Callable** — invoked with the formatted message. + + ```php + 'log' => function (string $message): void { + error_log('[DB] ' . $message); + }, + ``` + +3. **Object with a `critical()` method** — duck-typed; kept for callers that don't depend on `psr/log`. + + ```php + class Notifier { + public function critical(string $msg): void { /* … */ } + } + 'log' => new Notifier(), + ``` + +4. **String** — treated as a file path, written with `file_put_contents(..., FILE_APPEND)`. + + ```php + 'log' => __DIR__ . '/db.log', + ``` + +### Path placeholders + +The string form expands these tokens before opening the file: + +| Token | Replaced with | +| --- | --- | +| `{date}` | `Y-m-d` | +| `{datetime}` | `Y-m-d H:i:s` | +| `{timestamp}` | Unix timestamp | +| `{year}` / `{month}` / `{day}` | individual date parts | +| `{hour}` / `{minute}` / `{second}` | individual time parts | + +So `db-{year}-{month}-{day}.log` produces one file per day automatically. + +### Disable the channel + +Pass `null`, `false`, or an empty string for `'log'` (or simply omit it). The writer becomes a no-op. + +## Debug mode vs the log channel + +`'debug' => true` is **not** about the log channel — it includes the offending SQL inside any thrown `SQLExecuteException` so the message in your error reporter actually tells you what failed. Production environments should keep it off; the rendered SQL can leak data into stack traces that ship to third-party services. diff --git a/docs/08-datatables.md b/docs/08-datatables.md new file mode 100644 index 0000000..a2c3c26 --- /dev/null +++ b/docs/08-datatables.md @@ -0,0 +1,196 @@ +# 08 — Datatables + +Server-side helper for [DataTables.js](https://datatables.net/). Given a query and a list of columns, the helper takes care of: + +1. Parsing the DataTables request payload (`draw`, `start`, `length`, `search`, `order`). +2. Running the **unfiltered count** (the value DataTables wants in `recordsTotal`). +3. Running the **filtered count** (the value DataTables wants in `recordsFiltered`). +4. Running the page-of-results SELECT with `LIMIT` / `OFFSET` and the requested ordering. +5. Applying per-column render callbacks. +6. Returning the response envelope DataTables expects. + +The class lives at `InitPHP\Database\Utils\Datatables\Datatables`. It is the one piece of code that ships with this package rather than being re-exposed from InitORM. + +## Minimal example + +```php +from('users') + ->setColumns('id', 'name', 'email', 'created_at') + ->toArray(); + +echo json_encode($response); +``` + +`(string) $datatables` is equivalent to `json_encode($datatables->toArray())`, so: + +```php +header('Content-Type: application/json'); +echo new Datatables(DB::getDatabase()) + ->from('users') + ->setColumns('id', 'name', 'email'); +``` + +works too. + +## Constructor + +```php +new Datatables( + DatabaseInterface|ModelInterface $db, + ?RequestParser $request = null, + ?Renderer $renderer = null, +); +``` + +- `$db` — the connection / model to query against. Pass a `Model` and the helper inherits its soft-delete / timestamp scoping. +- `$request` — the DataTables payload, defaulting to `RequestParser::fromGlobals()` (which merges `$_GET`, `$_POST`, and the decoded JSON body if `php://input` carries one). Inject your own when feeding the helper from a PSR-7 request or a unit test. +- `$renderer` — the column → closure registry. Defaults to a fresh `Renderer`; you almost never need to pass one in (use `addRender()` instead). + +## Building the query + +Every `__call`-able method (anything the QueryBuilder exposes) is **captured** and replayed against the database for each round-trip — the count queries and the page query share the same chain. So you write the query exactly as you would on `DB::`: + +```php +$dt = new Datatables(DB::getDatabase()); +$dt->from('posts') + ->leftJoin('users', 'users.id = posts.author_id') + ->where('posts.status', 1) + ->groupBy('posts.id') + ->setColumns('posts.id', 'posts.title', 'users.name', 'posts.created_at'); +``` + +What is captured: `select`, `from`, `where` and friends, `join`, `groupBy`, `having`, `orderBy`, `limit`, `offset`, anything else the builder accepts. + +What gets stripped per pass: + +- During the count queries, `select*` and `orderBy*` calls are dropped — counting doesn't need them. +- During the page query, captured `orderBy*` calls are dropped **by default** and replaced with the client's order. Call `orderBySave()` to keep both (captured first, client second). + +## Columns + +```php +$dt->setColumns('id', 'name', 'email', 'created_at'); +``` + +The order you list columns here defines the `column[i]` indexing DataTables sends back in `order` directives. Two special cases: + +- Pass `null` for a slot that is render-only / not orderable / not searchable: + ```php + $dt->setColumns('id', 'name', null, 'created_at'); + // column 2 (the null slot) never appears in WHERE/ORDER BY. + ``` +- Multiple `setColumns()` calls **append** rather than replace, so the existing indexing is preserved. + +## Global search + +DataTables' search box sends `{search: {value: '...'}}`. The helper turns it into: + +```sql +WHERE (col1 LIKE :s1 OR col2 LIKE :s2 OR ...) +``` + +…using every column registered via `setColumns()` whose `db` slot is non-null. The search runs **on the filtered count** and **on the page query**, but not on the unfiltered total — so `recordsTotal` and `recordsFiltered` diverge when a search is active. That is exactly what DataTables expects. + +> Implementation note: the helper builds the search predicate as a raw SQL chunk with explicit parameter binding instead of using the upstream `group()` API. The reason is documented inside `applySearchFilter()` in `src/Utils/Datatables/Datatables.php` — `initorm/query-builder`'s sub-builder loses bound parameters when re-merged into the outer builder, which would silently match zero rows. Once that upstream behaviour is fixed, this can fold back into the idiomatic `group()` form. + +## Ordering + +```js +// Client sends: +{ order: [{ column: 1, dir: 'asc' }] } +``` + +The helper applies `ORDER BY {columns[1].db} ASC` after dropping any captured `orderBy*` calls. If you wrote `orderBySave()` on the chain, your captured order goes first and the client's order goes second — useful for things like "active rows first, then whatever the user clicked": + +```php +$dt->orderBy('active', 'DESC') + ->orderBySave() + ->setColumns(...); +``` + +## Pagination + +DataTables sends `start` and `length` as strings on the wire. The helper coerces them and applies `LIMIT length OFFSET start`. When `length` is `-1` ("show all"), the helper skips both — you get the entire filtered set. + +## Per-column render callbacks + +```php +$dt->setColumns('id', 'name', 'email', 'created_at') + ->addRender('email', fn (?string $email, array $row) => + sprintf('%s', htmlspecialchars((string) $email), htmlspecialchars((string) $email)) + ) + ->addRender('created_at', fn (?string $ts) => + $ts === null ? '' : (new DateTimeImmutable($ts))->format('d M Y') + ); +``` + +A render callback receives `($value, array $row)` and returns the value the client should see. The full row is passed by value — you cannot use a renderer to mutate sibling columns. + +## Permanent SELECTs + +```php +$dt->addPermanentSelect('users.role') + ->setColumns('users.id', 'users.name', 'users.email'); +``` + +Permanent selects are appended on **every** select pass — handy for columns you need in render callbacks but don't expose as orderable / searchable columns. + +## Group-by counts + +When the captured chain contains a `groupBy('col')`, the count query becomes `SELECT COUNT(DISTINCT col) AS data_length` instead of `COUNT(*)` — the helper assumes you grouped by the entity's identifier and want the count after grouping. Pass a single column (string or `RawQuery`) for the helper to pick it up; an array argument is ignored and the count falls back to `COUNT(*)`. + +## Response shape + +```php +[ + 'draw' => 7, + 'recordsTotal' => 1024, + 'recordsFiltered' => 12, + 'data' => [ + ['id' => 5, 'name' => 'Ada', 'email' => 'ada@…'], + ['id' => 8, 'name' => 'Bob', 'email' => 'bob@…'], + // … + ], + 'post' => [/* the original request payload, verbatim */], +] +``` + +`'post'` is not part of the DataTables spec — it is a convenience the helper adds so server logs can capture the exact request the client sent. + +## Plugging in a Model + +```php +$dt = new Datatables(new App\Model\Posts()); +$dt->setColumns('id', 'title', 'created_at'); +``` + +When you pass a model, the model's soft-delete / writability gates apply transparently — `read()` filters out soft-deleted rows by default, exactly as it does for direct model use. + +## Testing the helper + +The package ships with PHPUnit coverage at `tests/Utils/Datatables/`. Use `tests/Support/SqliteHelper.php` as a template if you want to write integration tests of your own: + +```php +$connection = SqliteHelper::makeConnection(); +$db = new InitPHP\Database\Database($connection); +SqliteHelper::seedUsers($connection); + +$dt = new Datatables($db, new RequestParser([ + 'search' => ['value' => 'Alice'], +])); +$dt->from('users')->setColumns('id', 'name', 'email'); + +$response = $dt->toArray(); +// assert on $response['recordsFiltered'], $response['data'], etc. +``` diff --git a/docs/09-multi-connection.md b/docs/09-multi-connection.md new file mode 100644 index 0000000..735fb1c --- /dev/null +++ b/docs/09-multi-connection.md @@ -0,0 +1,82 @@ +# 09 — Multiple connections + +The package is built around one **shared** connection — `DB::createImmutable([...])` — because that is what almost every application needs. The remaining 10% of applications need to talk to two (or more) databases, and the helpers below cover that. + +## The shared facade vs side-band connections + +```php +use InitPHP\Database\DB; + +// 1. The shared connection (your "main" database). +DB::createImmutable([ + 'dsn' => 'mysql:host=primary;dbname=app', + 'username' => 'app', + 'password' => '…', +]); + +// 2. A second connection, NOT routed through the facade. +$analytics = DB::connect([ + 'dsn' => 'mysql:host=analytics;dbname=warehouse', + 'username' => 'reader', + 'password' => '…', +]); + +$rows = $analytics->select('*')->from('events')->where('day', '2026-05-24')->read()->rows(); +``` + +`DB::connect($credentials)` builds a fresh `Database` and returns it — the shared facade slot is left untouched. Treat the returned object as you would `DB::` itself; the surface is identical. + +## Models on a non-shared connection + +```php +namespace App\Model; + +use InitPHP\Database\Model; + +final class WarehouseEvents extends Model +{ + protected string $schema = 'events'; + + protected ?array $credentials = [ + 'dsn' => 'mysql:host=analytics;dbname=warehouse', + 'username' => 'reader', + 'password' => '…', + ]; +} +``` + +A model whose `$credentials` is non-null spins up its **own** `Database` instance on construct rather than reaching for `DB::getDatabase()`. The shared facade is not consulted. + +> A new connection is opened **per model instance**. If you instantiate `new WarehouseEvents()` ten times in a request, that is ten TCP connections to the warehouse. For long-lived processes (workers, queues) this is fine; for synchronous web requests, share one instance via a container. + +## Swapping the shared connection at runtime + +Use case: routing per-tenant requests to per-tenant databases inside a long-lived worker process. + +```php +DB::replaceImmutable($tenantConnection); +``` + +`replaceImmutable()` accepts the same shapes as `createImmutable()` (an array of credentials or a `ConnectionInterface`), plus an already-built `DatabaseInterface` (handy when you cache per-tenant `Database` instances) and `null` (clears the slot entirely). + +Anything that holds a reference to the previous `Database` keeps working against the old connection — `replaceImmutable()` only changes what `DB::getDatabase()` returns from that point onward. + +## Transactions across two connections + +Don't. PDO transactions are per-connection; the helper rejects nested starts on the same handle and there is no two-phase-commit support in InitORM. If you need cross-database consistency, model it as a single source-of-truth database plus an outbox / event-stream that downstream databases subscribe to. + +## Detecting which connection a model is on + +```php +$model->getDatabase(); // the DatabaseInterface this model is bound to +``` + +Useful in test code to assert that a model picked up the connection you expected: + +```php +self::assertSame( + DB::getDatabase(), + (new App\Model\Posts())->getDatabase(), + 'Posts should be on the shared connection' +); +``` diff --git a/docs/10-upgrading.md b/docs/10-upgrading.md new file mode 100644 index 0000000..8c136f0 --- /dev/null +++ b/docs/10-upgrading.md @@ -0,0 +1,118 @@ +# 10 — Upgrading + +This page documents the breaking changes in **5.0** and the migration path from 3.x / 4.x. Everything not listed here is unchanged. + +## Why 5.0? + +- The 4.x line silently took `php: >=8.0` in `composer.json` even though one of its required dependencies (`initorm/orm ^2.0`) refused to install on PHP 8.0. The runtime requirement is being raised to match reality. +- `DB::createImmutable()` was the only caller that diverged from the upstream `InitORM\Database\Facade\DB` contract — fixing that divergence changes the public behaviour. +- The DataTables helper was the package's only non-trivial code path and it carried a handful of latent bugs (SQLite-incompatible result handling, type errors on string-typed payloads, `recordsFiltered === recordsTotal` regardless of the search filter). Fixing those required a re-shape of the class. + +## At a glance + +| Area | Before | After | +| --- | --- | --- | +| PHP minimum | `>= 8.0` (broken — install failed on 8.0) | `^8.1` | +| `DB::createImmutable()` second call | silently overwrote the shared instance | throws `DatabaseException` | +| `DB::createImmutable()` return type | `void` | `DatabaseInterface` | +| Swapping the shared instance | re-call `createImmutable()` | call `DB::replaceImmutable($connection)` | +| Instance use of `DB` (`(new DB())->foo()`) | worked via instance `__call` | constructor is now `private`; the class is `final` | +| DataTables namespace | `InitPHP\Database\Utils\Datatables` | `InitPHP\Database\Utils\Datatables\Datatables` | +| DataTables request handling | `$_GET` / `$_POST` / `php://input` directly | `RequestParser` (injectable; `fromGlobals()` for the live request) | +| DataTables render registry | inline closure array | `Renderer` class behind `addRender()` | +| `recordsFiltered` | always equal to `recordsTotal` | computed separately when a search is active | + +## Migration steps + +### 1. Bump PHP + +Update your CI / Dockerfile / runtime to PHP 8.1 or later. The package will not install on 8.0. + +```diff +- "php": "8.0", ++ "php": "8.1", +``` + +### 2. Adjust `DB::createImmutable()` callers + +If you were calling `createImmutable()` more than once intentionally (between requests, in a long-lived worker, in test fixtures), switch the subsequent calls to `replaceImmutable()`: + +```diff + DB::createImmutable($firstConnection); + // … later … +- DB::createImmutable($secondConnection); // silently overwrites ++ DB::replaceImmutable($secondConnection); // explicit swap +``` + +`replaceImmutable()` also accepts a `DatabaseInterface` instance directly (useful when you cache the `Database`) and `null` (clears the slot). + +If you were relying on the `void` return of `createImmutable()`, no change is required — ignoring the new return value is harmless. + +### 3. Drop instance-style facade use + +```diff +- $db = new DB(); +- $db->select('*')->from('posts')->read(); ++ DB::select('*')->from('posts')->read(); +``` + +The static surface is unchanged; only the (unintentional) instance path is gone. + +### 4. Update the DataTables import path + +```diff +- use InitPHP\Database\Utils\Datatables; ++ use InitPHP\Database\Utils\Datatables\Datatables; +``` + +The class name is the same — only the namespace moved one level deeper. The public API (`__construct`, `__call`, `setColumns`, `addRender`, `addPermanentSelect`, `orderBySave`, `handle`, `toArray`, `__toString`) is unchanged. + +### 5. (Optional) Inject the request payload instead of relying on globals + +The constructor signature is now: + +```php +new Datatables( + DatabaseInterface|ModelInterface $db, + ?RequestParser $request = null, // ← new + ?Renderer $renderer = null, // ← new +); +``` + +When `$request` is null the helper still reads `$_GET` / `$_POST` / `php://input` via `RequestParser::fromGlobals()` — so existing callers keep working unchanged. Injection is opt-in: + +```php +$request = new RequestParser($psr7Request->getParsedBody()); +$dt = new Datatables(DB::getDatabase(), $request); +``` + +### 6. Expect a different `recordsFiltered` value + +If your client previously coped with `recordsFiltered === recordsTotal` even during a search, it will now see the **correct** smaller value when a search is active. The two paginate / show-X-of-Y behaviours converge to whatever DataTables.js does by default. + +### 7. Fix entity mutators that wrote `$this->column = $value` + +Not strictly a 5.0 change — this was always wrong — but PHP 8.2+ has started emitting deprecation notices for it, and a future PHP release will make it fatal. Inside a `set{Column}Attribute($value)` method, always write through `setAttribute()`: + +```diff + public function setTitleAttribute(string $value): void + { +- $this->title = strtolower($value); ++ $this->setAttribute('title', strtolower($value)); + } +``` + +See [05 — Entities](05-entities.md) for the long version. + +## Things that did not change + +- The QueryBuilder surface (`select`, `where`, `join`, `groupBy`, …). +- CRUD signatures (`create`, `read`, `update`, `delete`, plus the `*Batch` siblings). +- Model configuration (`$schema`, `$schemaId`, `$entity`, `$useSoftDeletes`, `$createdField`, `$updatedField`, `$deletedField`, access gates, `$credentials`). +- `Entity` accessors / mutators / dirty tracking. +- Transaction semantics (`transaction()` with retry and `testMode`). +- The `log` / `debug` / `queryLogs` connection channels. + +## Reporting issues + +If something else breaks during the upgrade, please open an issue with a minimal reproducer — see [SUPPORT.md](https://github.com/InitPHP/.github/blob/main/SUPPORT.md) for the channels. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a653d96 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# InitPHP Database — Documentation + +Topic-focused guides for `initphp/database`. Each page is self-contained — read in order if you are new, jump around if you are looking something up. + +| # | Page | What you will find | +| --- | --- | --- | +| 01 | [Getting started](01-getting-started.md) | Installation, the connection array, your first query, debug & log channels. | +| 02 | [Query Builder](02-query-builder.md) | `select`, `where`, `join`, `groupBy`, `orderBy`, `limit`, raw fragments, sub-queries. | +| 03 | [CRUD](03-crud.md) | `create` / `read` / `update` / `delete`, their `*Batch` siblings, and raw SQL via `query()`. | +| 04 | [Models](04-models.md) | Subclassing `Model`, table binding, primary keys, soft deletes, timestamp columns, gates. | +| 05 | [Entities](05-entities.md) | The attribute bag, accessor / mutator hooks, dirty tracking, and the one PHP 8.2+ pitfall to avoid. | +| 06 | [Transactions](06-transactions.md) | `transaction()` with retry attempts and `testMode`. | +| 07 | [Query log](07-query-log.md) | `enableQueryLog` / `getQueryLogs` and the connection-level `log` channel. | +| 08 | [Datatables](08-datatables.md) | Server-side [DataTables.js](https://datatables.net/) integration end-to-end. | +| 09 | [Multiple connections](09-multi-connection.md) | Secondary databases via `DB::connect()` and `Model::$credentials`. | +| 10 | [Upgrading](10-upgrading.md) | Breaking changes in 5.0 and the migration path from 3.x / 4.x. | + +If something is missing or unclear, please [open an issue](https://github.com/InitPHP/Database/issues) or start a [Discussion](https://github.com/orgs/InitPHP/discussions). diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1331e0d --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,16 @@ + + + PSR-12 baseline for InitPHP/Database. + + src + tests + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..2d8dd79 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: 6 + paths: + - src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml index bc51414..dadb586 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,8 +1,24 @@ - + tests - \ No newline at end of file + + + src + + + diff --git a/src/DB.php b/src/DB.php index 7521283..66f5461 100644 --- a/src/DB.php +++ b/src/DB.php @@ -1,52 +1,129 @@ -{$name}(...$arguments); - } - - public static function __callStatic($name, $arguments) - { - return self::getDatabase()->{$name}(...$arguments); - } - - public static function createImmutable(array|ConnectionInterface $connection): void - { - self::$db = self::connect($connection); - } - - /** - * @param array|ConnectionInterface $connection - * @return DatabaseInterface - */ - public static function connect(array|ConnectionInterface $connection): DatabaseInterface - { - return new Database($connection); - } - - public static function getDatabase(): DatabaseInterface - { - if (!isset(self::$db)) { - throw new DatabaseException('To create an immutable, first use the "createImmutable()" method.'); - } - - return self::$db; - } - - -} +|ConnectionInterface $connection + * + * @throws DatabaseException When an immutable instance is already set. + */ + public static function createImmutable(array|ConnectionInterface $connection): DatabaseInterface + { + if (self::$database !== null) { + throw new DatabaseException( + 'An immutable Database instance has already been set. ' + . 'Call DB::replaceImmutable() to swap it explicitly.' + ); + } + + self::$database = self::connect($connection); + + return self::$database; + } + + /** + * Explicitly replace the shared facade target. Use when an application + * truly needs to reset the connection (e.g. between test cases). + * + * Pass {@code null} to clear the facade entirely. + * + * @param array|ConnectionInterface|DatabaseInterface|null $connection + */ + public static function replaceImmutable( + array|ConnectionInterface|DatabaseInterface|null $connection + ): ?DatabaseInterface { + if ($connection === null) { + self::$database = null; + + return null; + } + + self::$database = $connection instanceof DatabaseInterface + ? $connection + : self::connect($connection); + + return self::$database; + } + + /** + * Build a fresh, non-facade Database. The returned instance does not touch + * the shared facade slot — useful for working with secondary connections. + * + * @param array|ConnectionInterface $connection + */ + public static function connect(array|ConnectionInterface $connection): DatabaseInterface + { + return new Database($connection); + } + + /** + * The shared facade instance. + * + * @throws DatabaseException When no immutable instance has been set yet. + */ + public static function getDatabase(): DatabaseInterface + { + if (self::$database === null) { + throw new DatabaseException( + 'No immutable Database instance is configured. Call DB::createImmutable($connection) first.' + ); + } + + return self::$database; + } + + /** + * Forward unknown static calls to the shared Database instance. + * + * @param array $arguments + * + * @throws DatabaseException When no immutable instance is set, or when the + * underlying Database / query builder does not declare the method. + */ + public static function __callStatic(string $name, array $arguments): mixed + { + return self::getDatabase()->{$name}(...$arguments); + } +} diff --git a/src/Database.php b/src/Database.php index e866994..1de08de 100644 --- a/src/Database.php +++ b/src/Database.php @@ -1,8 +1,28 @@ -setAttribute($name, $value)}. A direct + * {@code $this->column = $value} assignment from inside a class method + * bypasses {@see InitORMEntity::__set()} and creates a dynamic property + * instead — deprecated in PHP 8.2+, fatal in a future PHP version. + */ +class Entity extends InitORMEntity +{ +} diff --git a/src/Model.php b/src/Model.php index 0d82c40..5b2a07d 100644 --- a/src/Model.php +++ b/src/Model.php @@ -1,8 +1,23 @@ - - * @copyright Copyright © 2022 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 3.0 - * @link https://www.muhammetsafak.com.tr - */ - -namespace InitPHP\Database\Utils; - -use Closure; -use Throwable; -use InitORM\Database\Interfaces\DatabaseInterface; -use InitORM\QueryBuilder\Exceptions\QueryBuilderException; -use InitORM\QueryBuilder\QueryBuilderInterface; -use InitORM\ORM\Interfaces\ModelInterface; - -/** - * @mixin QueryBuilderInterface - */ -class Datatables -{ - - private DatabaseInterface|ModelInterface $db; - - private array $request; - - private array $response = [ - 'draw' => 0, - 'recordsTotal' => 0, - 'recordsFiltered' => 0, - 'data' => [], - 'post' => [], - ]; - - private array $columns = []; - - private array $renders = []; - - private array $builder = []; - - private bool $orderByReset = true; - - private array $permanentSelect = []; - - public function __construct(DatabaseInterface|ModelInterface $db) - { - $this->request = array_merge($_GET ?? [], $_POST ?? []); - - if ($requestBody = @file_get_contents("php://input")) { - if (is_array($jsonBody = json_decode($requestBody, true))) { - $this->request = array_merge($this->request, $jsonBody); - } - } - $this->db = $db; - } - - public function __call(string $name, array $arguments) - { - $this->builder[] = [ - 'method' => $name, - 'arguments' => $arguments, - ]; - - return $this; - } - - /** - * @return string - * @throws Throwable - */ - public function __toString(): string - { - return json_encode($this->toArray()); - } - - /** - * @return array - * @throws Throwable - */ - public function toArray(): array - { - $this->handle(); - return $this->response; - } - - /** - * @return $this - * @throws Throwable - */ - public function handle(): self - { - $this->filterQuery(); - - $totalRow = $this->getCount(); - - $res = $this->orderQuery() - ->limitQuery() - ->getResults(); - - if (!empty($this->renders)) { - foreach ($res as &$row) { - foreach ($row as $column => &$value) { - if (!isset($this->renders[$column])) { - continue; - } - $value = call_user_func_array($this->renders[$column], [$value, &$row]); - } - } - } - - $this->response = [ - 'draw' => $this->request['draw'] ?? 0, - 'recordsTotal' => $totalRow, - 'recordsFiltered' => $totalRow, - 'data' => $res, - 'post' => $this->request, - ]; - - return $this; - } - - /** - * @param string ...$select - * @return $this - */ - public function addPermanentSelect(string ...$select): self - { - foreach ($select as $sel) { - $this->select($sel); - $this->permanentSelect[] = $sel; - } - - return $this; - } - - /** - * @param string|null ...$columns - * @return $this - */ - public function setColumns(?string ...$columns): self - { - $dt = count($this->columns) - 1; - foreach ($columns as $column) { - $this->columns[] = [ - 'db' => $column, - 'dt' => ++$dt, - ]; - } - - return $this; - } - - /** - * @param string $column - * @param Closure $render - * @return $this - */ - public function addRender(string $column, Closure $render): self - { - $this->renders[$column] = $render; - - return $this; - } - - /** - * @return $this - */ - public function orderBySave(): self - { - $this->orderByReset = false; - - return $this; - } - - /** - * @return int - * @throws Throwable - */ - private function getCount(): int - { - if (!empty($this->permanentSelect)) { - foreach ($this->permanentSelect as $select) { - $this->db->select($select); - } - } - $isGroupBy = false; - foreach ($this->builder as $process) { - if (str_starts_with($process['method'], 'select') || str_starts_with($process['method'], 'orderBy')) { - continue; - } - if ($process['method'] === 'groupBy') { - if ($isGroupBy === false) { - $this->db->selectCountDistinct(current($process['arguments']), 'data_length'); - $isGroupBy = true; - } - continue; - } - $this->db->{$process['method']}(...$process['arguments']); - } - $isGroupBy === false && $this->db->selectCount('*', 'data_length'); - $res = $this->db->read(); - - return $res->numRows() > 0 ? $res->asAssoc()->row()['data_length'] : 0; - } - - /** - * @return array - * @throws Throwable - */ - private function getResults(): array - { - foreach ($this->builder as $process) { - $this->db->{$process['method']}(...$process['arguments']); - } - $res = $this->db->read(); - - return $res->numRows() > 0 ? $res->asAssoc()->rows() : []; - } - - /** - * @return self - */ - private function orderQuery(): self - { - if (empty($this->request['order']) || !is_array($this->request['order'])) { - return $this; - } - if ($this->orderByReset) { - foreach ($this->builder as $key => $builder) { - if (str_starts_with($builder['method'], 'orderBy')) { - unset($this->builder[$key]); - } - } - } - $columns = $this->columns; - $count = count($this->request['order']); - for ($i = 0; $i < $count; ++$i) { - $columnId = intval($this->request['order'][$i]['column']); - $column = $columns[$columnId]; - if (!isset($column['db'])) { - continue; - } - $dir = strtolower($this->request['order'][$i]['dir']) === 'asc' ? 'ASC' : 'DESC'; - $this->db->orderBy($this->db->raw($column['db']), $dir); - } - - return $this; - } - - /** - * @return void - * @throws QueryBuilderException - */ - private function filterQuery(): void - { - $search = $this->request['search']['value'] ?? null; - if (empty($search) || empty($this->columns)) { - return; - } - $this->db->group(function (QueryBuilderInterface $builder) use ($search) { - foreach ($this->columns as $column) { - $builder->orLike($this->db->raw($column['db']), $search); - } - return $builder; - }); - } - - private function limitQuery(): self - { - if (isset($this->request['start']) && $this->request['length'] != -1) { - $this->offset($this->request['start']) - ->limit($this->request['length']); - } - - return $this; - } - -} diff --git a/src/Utils/Datatables/Datatables.php b/src/Utils/Datatables/Datatables.php new file mode 100644 index 0000000..34c1722 --- /dev/null +++ b/src/Utils/Datatables/Datatables.php @@ -0,0 +1,392 @@ +}> + */ + private array $captured = []; + + /** + * @var list + */ + private array $columns = []; + + /** + * Always-present SELECT columns, replayed before every count/select pass. + * + * @var list + */ + private array $permanentSelect = []; + + /** + * When true (the default), captured {@code orderBy} calls are discarded + * before applying the client-supplied order. Set false via + * {@see orderBySave()} to honour both. + */ + private bool $orderByReset = true; + + /** + * @var array{ + * draw: int, + * recordsTotal: int, + * recordsFiltered: int, + * data: array>, + * post: array + * } + */ + private array $response = [ + 'draw' => 0, + 'recordsTotal' => 0, + 'recordsFiltered' => 0, + 'data' => [], + 'post' => [], + ]; + + public function __construct( + private readonly DatabaseInterface|ModelInterface $db, + ?RequestParser $request = null, + ?Renderer $renderer = null + ) { + $this->request = $request ?? RequestParser::fromGlobals(); + $this->renderer = $renderer ?? new Renderer(); + } + + /** + * Capture an unknown method call (any QueryBuilder method) for later + * replay against the underlying database. Returns {@code $this} so the + * fluent chain stays unbroken. + * + * @param array $arguments + */ + public function __call(string $name, array $arguments): self + { + $this->captured[] = ['method' => $name, 'arguments' => $arguments]; + + return $this; + } + + /** + * JSON-encode the response envelope. {@see __toString()} cannot raise + * Throwables in older PHP versions and surfacing one from a magic method + * is still poor form on 8.0+, so any failure during {@see handle()} is + * swallowed and an empty JSON object is returned instead. + */ + public function __toString(): string + { + try { + $json = json_encode($this->toArray(), JSON_THROW_ON_ERROR); + } catch (Throwable) { + return '{}'; + } + + return $json; + } + + /** + * Execute the count + select round-trip and return the DataTables-shaped + * response envelope. + * + * @return array{ + * draw: int, + * recordsTotal: int, + * recordsFiltered: int, + * data: array>, + * post: array + * } + * + * @throws Throwable When any of the underlying queries fail. + */ + public function toArray(): array + { + $this->handle(); + + return $this->response; + } + + /** + * Register columns exposed to the client. The order of arguments defines + * the DataTables {@code column[i]} indexing the client will send back in + * {@code order} directives. Pass {@code null} for a non-orderable / + * unsearchable slot. Subsequent calls APPEND, preserving the existing + * indexing. + */ + public function setColumns(?string ...$columns): self + { + $next = count($this->columns); + foreach ($columns as $column) { + $this->columns[] = ['db' => $column, 'dt' => $next++]; + } + + return $this; + } + + /** + * Register a render callback for {@code $column} — see + * {@see Renderer::add()} for the callback signature. + */ + public function addRender(string $column, Closure $render): self + { + $this->renderer->add($column, $render); + + return $this; + } + + /** + * Columns added here are SELECTed on every pass (count + select), in + * addition to whatever the captured chain selects. + */ + public function addPermanentSelect(string ...$select): self + { + foreach ($select as $sel) { + $this->permanentSelect[] = $sel; + } + + return $this; + } + + /** + * Keep captured {@code orderBy} calls instead of overwriting them with the + * client-supplied order. By default the helper resets them so the client + * is the sole source of truth on ordering. + */ + public function orderBySave(): self + { + $this->orderByReset = false; + + return $this; + } + + /** + * Execute the three queries (total count, filtered count, page) and build + * the response envelope. + * + * @throws Throwable + */ + public function handle(): self + { + $recordsTotal = $this->runCount(applyFilter: false); + $recordsFiltered = $this->runCount(applyFilter: true); + $rows = $this->runSelect(); + + $this->response = [ + 'draw' => $this->request->draw(), + 'recordsTotal' => $recordsTotal, + 'recordsFiltered' => $recordsFiltered, + 'data' => $this->renderer->apply($rows), + 'post' => $this->request->all(), + ]; + + return $this; + } + + /** + * Run a count query against the captured chain. SELECT and ORDER BY + * fragments are skipped — counting does not need them. When the captured + * chain contains a {@code groupBy}, the count is built as + * {@code COUNT(DISTINCT firstGroupByColumn)} so it matches the post-GROUP + * BY row count. + */ + private function runCount(bool $applyFilter): int + { + $hasGroupBy = false; + + foreach ($this->captured as $call) { + $method = $call['method']; + if (str_starts_with($method, 'select') || str_starts_with($method, 'orderBy')) { + continue; + } + + if ($method === 'groupBy') { + if (!$hasGroupBy && isset($call['arguments'][0])) { + $first = $call['arguments'][0]; + if (is_string($first) || $this->isRawQuery($first)) { + $this->db->selectCountDistinct($first, 'data_length'); + $hasGroupBy = true; + } + } + continue; + } + + $this->db->{$method}(...$call['arguments']); + } + + if ($applyFilter) { + $this->applySearchFilter(); + } + + if (!$hasGroupBy) { + $this->db->selectCount('*', 'data_length'); + } + + // numRows() is unreliable on SELECT for drivers that don't buffer + // results (SQLite, unbuffered MySQL) — fetch directly. The count + // query always returns exactly one row. + $row = $this->db->read()->asAssoc()->row(); + $value = is_array($row) ? ($row['data_length'] ?? 0) : 0; + + return (int) $value; + } + + /** + * Run the page-of-results SELECT against the captured chain, applying + * the client-requested filter, order and limit on top. + * + * @return array> + */ + private function runSelect(): array + { + foreach ($this->permanentSelect as $select) { + $this->db->select($select); + } + + foreach ($this->captured as $call) { + if ($this->orderByReset && str_starts_with($call['method'], 'orderBy')) { + continue; + } + $this->db->{$call['method']}(...$call['arguments']); + } + + $this->applySearchFilter(); + $this->applyClientOrder(); + $this->applyClientPaging(); + + // numRows() is unreliable on SELECT for drivers that don't buffer + // results — fetch and let the empty array speak for itself. + /** @var array> $rows */ + $rows = $this->db->read()->asAssoc()->rows(); + + return $rows; + } + + /** + * Add a {@code (col1 LIKE :s OR col2 LIKE :s OR ...)} group when the + * client supplied a non-empty search value. No-op otherwise. + * + * Implementation note: this used to lean on {@code Database::group()} + + * {@code orLike()}, which is the idiomatic upstream API — but the + * QueryBuilder sub-builder spawned inside {@code group()} carries its own + * parameter bag that never gets merged back into the outer builder's + * bag, so the resulting SQL contains {@code :foo} placeholders with no + * bound values and matches zero rows. Until the upstream fix lands we + * compose the predicate as a single RAW chunk and bind the parameters + * directly on the outer builder, which keeps the values prepared. + */ + private function applySearchFilter(): void + { + $search = $this->request->searchValue(); + if ($search === null) { + return; + } + + $clauses = []; + $params = []; + $index = 0; + foreach ($this->columns as $column) { + if ($column['db'] === null) { + continue; + } + $placeholder = ':dt_search_' . $index++; + $clauses[] = $column['db'] . ' LIKE ' . $placeholder; + $params[$placeholder] = '%' . $search . '%'; + } + + if ($clauses === []) { + return; + } + + $this->db->where($this->db->raw('(' . implode(' OR ', $clauses) . ')')); + foreach ($params as $name => $value) { + $this->db->setParameter($name, $value); + } + } + + /** + * Translate the client's ORDER directives into builder calls. + */ + private function applyClientOrder(): void + { + foreach ($this->request->orders() as [$columnIndex, $direction]) { + $column = $this->columns[$columnIndex] ?? null; + if ($column === null || $column['db'] === null) { + continue; + } + $this->db->orderBy($this->db->raw($column['db']), $direction); + } + } + + /** + * Apply the client-requested LIMIT / OFFSET. No-op when the client asked + * for "all rows" ({@code length = -1}) or did not paginate at all. + */ + private function applyClientPaging(): void + { + if (!$this->request->hasPagination()) { + return; + } + $this->db + ->offset($this->request->start()) + ->limit($this->request->length()); + } + + /** + * Best-effort check for InitORM's {@code RawQuery} value object without + * forcing a hard import here — the package keeps working even if the + * upstream class is renamed (the check just degrades to "string only"). + */ + private function isRawQuery(mixed $value): bool + { + return is_object($value) + && is_a($value, 'InitORM\\QueryBuilder\\RawQuery'); + } +} diff --git a/src/Utils/Datatables/Renderer.php b/src/Utils/Datatables/Renderer.php new file mode 100644 index 0000000..b211828 --- /dev/null +++ b/src/Utils/Datatables/Renderer.php @@ -0,0 +1,78 @@ + + */ + private array $renders = []; + + /** + * Register a render callback for {@code $column}. Overwrites any previous + * binding for the same column. + */ + public function add(string $column, Closure $render): void + { + $this->renders[$column] = $render; + } + + /** + * True when at least one column has a registered renderer. Lets the + * caller skip the apply() loop when there is nothing to do. + */ + public function hasAny(): bool + { + return $this->renders !== []; + } + + /** + * Apply every registered renderer to {@code $rows} in place. Rows that + * are not associative arrays (e.g. objects produced by fetch-class mode) + * are passed through untouched. + * + * @param array> $rows + * + * @return array> + */ + public function apply(array $rows): array + { + if (!$this->hasAny() || $rows === []) { + return $rows; + } + + foreach ($rows as $index => $row) { + if (!is_array($row)) { + continue; + } + foreach ($row as $column => $value) { + if (!isset($this->renders[$column])) { + continue; + } + $row[$column] = ($this->renders[$column])($value, $row); + } + $rows[$index] = $row; + } + + return $rows; + } +} diff --git a/src/Utils/Datatables/RequestParser.php b/src/Utils/Datatables/RequestParser.php new file mode 100644 index 0000000..2f1c10b --- /dev/null +++ b/src/Utils/Datatables/RequestParser.php @@ -0,0 +1,152 @@ + $payload Raw request data, already merged + * from whichever transport(s) the caller chose (query string, form + * body, JSON body). + */ + public function __construct(private readonly array $payload) + { + } + + /** + * Build a parser from the live request — $_GET + $_POST merged, plus the + * decoded JSON body when {@code php://input} carries one. Returns an + * empty-payload parser outside of an HTTP context. + */ + public static function fromGlobals(): self + { + /** @var array $merged */ + $merged = array_merge($_GET, $_POST); + + $raw = file_get_contents('php://input'); + if ($raw !== false && $raw !== '') { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + /** @var array $merged */ + $merged = array_merge($merged, $decoded); + } + } + + return new self($merged); + } + + /** + * The full payload, as the caller passed it in. Useful for echoing the + * request back to the client unchanged. + * + * @return array + */ + public function all(): array + { + return $this->payload; + } + + /** + * The opaque {@code draw} value the client expects to see echoed back. + * Returns 0 when missing, which is also DataTables' "no draw" sentinel. + */ + public function draw(): int + { + return isset($this->payload['draw']) ? (int) $this->payload['draw'] : 0; + } + + /** + * Pagination offset; defaults to 0 when missing or invalid. + */ + public function start(): int + { + return isset($this->payload['start']) ? max(0, (int) $this->payload['start']) : 0; + } + + /** + * Page size. The DataTables protocol uses -1 to mean "all rows"; this + * method returns it untouched so the caller can decide whether to apply + * a {@code LIMIT}. Defaults to -1 (no limit) when missing. + */ + public function length(): int + { + return isset($this->payload['length']) ? (int) $this->payload['length'] : -1; + } + + /** + * Whether the request asked for paginated results — i.e. {@code start} is + * present and {@code length} is not -1. + */ + public function hasPagination(): bool + { + return isset($this->payload['start']) && $this->length() !== -1; + } + + /** + * The global search string, or null when none was supplied / it was empty. + */ + public function searchValue(): ?string + { + $value = $this->payload['search']['value'] ?? null; + if (!is_scalar($value)) { + return null; + } + $value = (string) $value; + + return $value === '' ? null : $value; + } + + /** + * Normalised ORDER BY directives. Each entry is a {@code [columnIndex, + * direction]} pair where direction is the canonical {@code 'ASC'} or + * {@code 'DESC'} string. Returns an empty array when no order was + * requested or when the payload is malformed. + * + * @return list + */ + public function orders(): array + { + $order = $this->payload['order'] ?? null; + if (!is_array($order)) { + return []; + } + + $orders = []; + foreach ($order as $directive) { + if (!is_array($directive) || !isset($directive['column'])) { + continue; + } + $columnIndex = (int) $directive['column']; + $direction = isset($directive['dir']) && strtolower((string) $directive['dir']) === 'asc' + ? 'ASC' + : 'DESC'; + $orders[] = [$columnIndex, $direction]; + } + + return $orders; + } +} diff --git a/tests/DBTest.php b/tests/DBTest.php new file mode 100644 index 0000000..b165950 --- /dev/null +++ b/tests/DBTest.php @@ -0,0 +1,118 @@ +expectException(DatabaseException::class); + DB::getDatabase(); + } + + public function testCreateImmutableStoresAndReturnsTheInstance(): void + { + $database = DB::createImmutable(SqliteHelper::makeConnection()); + + self::assertInstanceOf(DatabaseInterface::class, $database); + self::assertSame($database, DB::getDatabase()); + } + + public function testCreateImmutableRejectsASecondCall(): void + { + DB::createImmutable(SqliteHelper::makeConnection()); + + $this->expectException(DatabaseException::class); + DB::createImmutable(SqliteHelper::makeConnection()); + } + + public function testReplaceImmutableSwapsTheStoredInstance(): void + { + $first = DB::createImmutable(SqliteHelper::makeConnection()); + $second = DB::replaceImmutable(SqliteHelper::makeConnection()); + + self::assertNotSame($first, $second); + self::assertSame($second, DB::getDatabase()); + } + + public function testReplaceImmutableAcceptsAnExistingDatabaseInstance(): void + { + $database = SqliteHelper::makeDatabase(); + $stored = DB::replaceImmutable($database); + + self::assertSame($database, $stored); + self::assertSame($database, DB::getDatabase()); + } + + public function testReplaceImmutableWithNullClearsTheSlot(): void + { + DB::createImmutable(SqliteHelper::makeConnection()); + $cleared = DB::replaceImmutable(null); + + self::assertNull($cleared); + $this->expectException(DatabaseException::class); + DB::getDatabase(); + } + + public function testConnectReturnsAFreshDatabaseWithoutTouchingTheFacadeSlot(): void + { + $sideband = DB::connect(SqliteHelper::makeConnection()); + + self::assertInstanceOf(DatabaseInterface::class, $sideband); + // The facade slot stayed empty even though connect() was called. + $this->expectException(DatabaseException::class); + DB::getDatabase(); + } + + public function testCallStaticForwardsToTheUnderlyingDatabase(): void + { + $connection = SqliteHelper::makeConnection(); + SqliteHelper::seedUsers($connection); + DB::createImmutable($connection); + + // numRows() is unreliable for SELECT on SQLite (see DataMapperInterface + // docs); fetch and count instead. + $rows = DB::select('name')->from('users')->where('active', '=', 1)->read()->asAssoc()->rows(); + + self::assertCount(2, $rows); + } + + public function testStaticFacadeCannotBeInstantiated(): void + { + $reflection = new ReflectionClass(DB::class); + $constructor = $reflection->getConstructor(); + + self::assertNotNull($constructor); + self::assertTrue($constructor->isPrivate(), 'DB constructor must be private.'); + self::assertTrue($reflection->isFinal(), 'DB facade must be final.'); + } +} diff --git a/tests/SubclassesSmokeTest.php b/tests/SubclassesSmokeTest.php new file mode 100644 index 0000000..edd5816 --- /dev/null +++ b/tests/SubclassesSmokeTest.php @@ -0,0 +1,47 @@ + $overrides + */ + public static function makeConnection(array $overrides = []): ConnectionInterface + { + return new Connection(array_merge([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'charset' => '', + ], $overrides)); + } + + /** + * @param array $overrides + */ + public static function makeDatabase(array $overrides = []): DatabaseInterface + { + return new Database(self::makeConnection($overrides)); + } + + /** + * Seed a {@code users} table with a fixed three-row fixture suitable for + * filter / order / paging tests. + */ + public static function seedUsers(ConnectionInterface $connection): void + { + $pdo = $connection->getPDO(); + $pdo->exec( + 'CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + score INTEGER + )' + ); + $pdo->exec( + "INSERT INTO users (name, email, active, score) VALUES + ('Alice', 'alice@example.com', 1, 42), + ('Bob', 'bob@example.com', 0, 13), + ('Carol', 'carol@example.com', 1, 99)" + ); + } + + /** + * Seed a {@code posts} table with a fixture that exercises grouping — + * three users own one or more posts each. + */ + public static function seedPostsForGrouping(ConnectionInterface $connection): void + { + $pdo = $connection->getPDO(); + $pdo->exec( + 'CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL + )' + ); + $pdo->exec( + "INSERT INTO posts (user_id, title) VALUES + (1, 'Alice first'), + (1, 'Alice second'), + (2, 'Bob only'), + (3, 'Carol first'), + (3, 'Carol second'), + (3, 'Carol third')" + ); + } +} diff --git a/tests/Utils/Datatables/DatatablesTest.php b/tests/Utils/Datatables/DatatablesTest.php new file mode 100644 index 0000000..929d8be --- /dev/null +++ b/tests/Utils/Datatables/DatatablesTest.php @@ -0,0 +1,293 @@ +connection = SqliteHelper::makeConnection(); + $this->db = new Database($this->connection); + SqliteHelper::seedUsers($this->connection); + } + + public function testEmptyRequestReturnsAllRowsWithCorrectTotals(): void + { + $datatables = new Datatables($this->db, new RequestParser([])); + $datatables + ->from('users') + ->setColumns('id', 'name', 'email', 'active', 'score'); + + $response = $datatables->toArray(); + + self::assertSame(3, $response['recordsTotal']); + self::assertSame(3, $response['recordsFiltered']); + self::assertCount(3, $response['data']); + } + + public function testDrawValueIsEchoedBack(): void + { + $datatables = new Datatables($this->db, new RequestParser(['draw' => '17'])); + $datatables->from('users')->setColumns('id', 'name'); + + self::assertSame(17, $datatables->toArray()['draw']); + } + + /** + * B12 regression — when search is active, recordsFiltered MUST diverge + * from recordsTotal so client-side pagination sees the smaller filtered + * set. + */ + public function testRecordsFilteredIsComputedIndependentlyOfRecordsTotal(): void + { + $request = new RequestParser([ + 'search' => ['value' => 'Alice'], + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns('id', 'name', 'email'); + + $response = $datatables->toArray(); + + self::assertSame(3, $response['recordsTotal'], 'recordsTotal must ignore the search filter'); + self::assertSame(1, $response['recordsFiltered'], 'recordsFiltered must reflect the search filter'); + self::assertCount(1, $response['data']); + self::assertSame('Alice', $response['data'][0]['name']); + } + + public function testGlobalSearchMatchesAcrossAllRegisteredColumns(): void + { + $request = new RequestParser([ + 'search' => ['value' => 'example.com'], + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns('id', 'name', 'email'); + + $response = $datatables->toArray(); + + self::assertSame(3, $response['recordsFiltered']); + } + + public function testColumnsWithNullDbDoNotParticipateInSearch(): void + { + $request = new RequestParser([ + 'search' => ['value' => 'will-not-match-anything'], + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns(null, 'name'); // first slot is a render-only column + + $response = $datatables->toArray(); + + self::assertSame(0, $response['recordsFiltered']); + } + + public function testClientSuppliedOrderIsApplied(): void + { + $request = new RequestParser([ + 'order' => [['column' => 4, 'dir' => 'asc']], // score column + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns('id', 'name', 'email', 'active', 'score'); + + $rows = $datatables->toArray()['data']; + + self::assertSame('Bob', $rows[0]['name'], 'lowest score (13) should come first under ASC order'); + self::assertSame('Carol', $rows[2]['name']); + } + + /** + * B5 + B6 regression — pagination payload arrives as strings on real + * HTTP requests; the helper must coerce them without raising TypeError + * or notices. + */ + public function testPaginationArgumentsAreCoercedFromStringPayload(): void + { + $request = new RequestParser([ + 'start' => '1', + 'length' => '2', + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns('id', 'name'); + + $rows = $datatables->toArray()['data']; + + self::assertCount(2, $rows); + self::assertSame('Bob', $rows[0]['name']); + self::assertSame('Carol', $rows[1]['name']); + } + + public function testLengthOfMinusOneReturnsAllRows(): void + { + $request = new RequestParser([ + 'start' => '0', + 'length' => '-1', + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->setColumns('id', 'name'); + + self::assertCount(3, $datatables->toArray()['data']); + } + + public function testRendererTransformsTheReturnedData(): void + { + $datatables = new Datatables($this->db, new RequestParser([])); + $datatables + ->from('users') + ->setColumns('id', 'name') + ->addRender('name', fn (string $name): string => '*' . $name . '*'); + + $rows = $datatables->toArray()['data']; + $names = array_column($rows, 'name'); + sort($names); + + self::assertSame(['*Alice*', '*Bob*', '*Carol*'], $names); + } + + public function testCapturedOrderByIsResetByDefault(): void + { + $request = new RequestParser([ + 'order' => [['column' => 1, 'dir' => 'asc']], // name ASC + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->orderBy('score', 'DESC') // captured but should be reset + ->setColumns('id', 'name', 'email', 'active', 'score'); + + $rows = $datatables->toArray()['data']; + + self::assertSame('Alice', $rows[0]['name'], 'client order must win over captured order by default'); + } + + public function testOrderBySaveKeepsTheCapturedOrderAlongsideClientOrder(): void + { + $request = new RequestParser([ + 'order' => [['column' => 1, 'dir' => 'asc']], // name ASC + ]); + $datatables = new Datatables($this->db, $request); + $datatables + ->from('users') + ->orderBy('active', 'DESC') // captured; preserved + ->orderBySave() + ->setColumns('id', 'name', 'email', 'active', 'score'); + + $rows = $datatables->toArray()['data']; + + // active=1 rows must come before active=0; within active=1, name ASC. + self::assertSame(1, (int) $rows[0]['active']); + self::assertSame('Alice', $rows[0]['name']); + self::assertSame(1, (int) $rows[1]['active']); + self::assertSame('Carol', $rows[1]['name']); + self::assertSame(0, (int) $rows[2]['active']); + } + + public function testGroupByDrivesRecordsCountViaCountDistinct(): void + { + SqliteHelper::seedPostsForGrouping($this->connection); + + $datatables = new Datatables($this->db, new RequestParser([])); + $datatables + ->from('posts') + ->groupBy('user_id') + ->setColumns('user_id'); + + $response = $datatables->toArray(); + + // Three distinct user_ids in the seed. + self::assertSame(3, $response['recordsTotal']); + self::assertSame(3, $response['recordsFiltered']); + } + + /** + * B8 regression — groupBy(['a', 'b']) used to crash because the helper + * fed the array directly into selectCountDistinct. With the fix the call + * is simply skipped (we fall back to COUNT(*)). + */ + public function testGroupByWithArrayArgumentDoesNotCrash(): void + { + SqliteHelper::seedPostsForGrouping($this->connection); + + $datatables = new Datatables($this->db, new RequestParser([])); + $datatables + ->from('posts') + ->groupBy(['user_id']) + ->setColumns('user_id'); + + // Should not throw; the count is opaque here (driver-dependent) so + // we only assert that the call completed and produced an envelope. + $response = $datatables->toArray(); + self::assertArrayHasKey('recordsTotal', $response); + } + + public function testToStringEmitsJsonEnvelope(): void + { + $datatables = new Datatables($this->db, new RequestParser(['draw' => 4])); + $datatables->from('users')->setColumns('id', 'name'); + + $json = (string) $datatables; + /** @var array $decoded */ + $decoded = json_decode($json, true); + + self::assertIsArray($decoded); + self::assertSame(4, $decoded['draw']); + self::assertSame(3, $decoded['recordsTotal']); + } + + public function testPostEchoesTheFullRequestPayload(): void + { + $payload = ['draw' => 9, 'custom' => 'value', 'start' => '0']; + $datatables = new Datatables($this->db, new RequestParser($payload)); + $datatables->from('users')->setColumns('id', 'name'); + + self::assertSame($payload, $datatables->toArray()['post']); + } + + public function testPermanentSelectIsAppliedOnEverySelect(): void + { + $datatables = new Datatables($this->db, new RequestParser([])); + $datatables + ->from('users') + ->addPermanentSelect('name', 'email') + ->setColumns('name', 'email'); + + $rows = $datatables->toArray()['data']; + + self::assertArrayHasKey('name', $rows[0]); + self::assertArrayHasKey('email', $rows[0]); + } +} diff --git a/tests/Utils/Datatables/RendererTest.php b/tests/Utils/Datatables/RendererTest.php new file mode 100644 index 0000000..12c7a72 --- /dev/null +++ b/tests/Utils/Datatables/RendererTest.php @@ -0,0 +1,86 @@ + 1, 'name' => 'Alice']]; + + self::assertFalse($renderer->hasAny()); + self::assertSame($rows, $renderer->apply($rows)); + } + + public function testRegisteredCallbackTransformsTheTargetColumn(): void + { + $renderer = new Renderer(); + $renderer->add('name', fn (string $value): string => strtoupper($value)); + + $output = $renderer->apply([ + ['id' => 1, 'name' => 'Alice'], + ['id' => 2, 'name' => 'Bob'], + ]); + + self::assertSame('ALICE', $output[0]['name']); + self::assertSame('BOB', $output[1]['name']); + self::assertSame(1, $output[0]['id']); + } + + public function testCallbackReceivesTheFullRow(): void + { + $renderer = new Renderer(); + $renderer->add('full_name', static fn (?string $value, array $row): string => + ($row['first'] ?? '') . ' ' . ($row['last'] ?? '')); + + $output = $renderer->apply([ + ['first' => 'Ada', 'last' => 'Lovelace', 'full_name' => ''], + ]); + + self::assertSame('Ada Lovelace', $output[0]['full_name']); + } + + public function testColumnsWithoutAnyRendererPassThrough(): void + { + $renderer = new Renderer(); + $renderer->add('only_this', fn ($v) => 'X'); + + $output = $renderer->apply([['only_this' => 'a', 'untouched' => 'b']]); + + self::assertSame('X', $output[0]['only_this']); + self::assertSame('b', $output[0]['untouched']); + } + + public function testNonAssociativeRowsArePassedThroughUnchanged(): void + { + $renderer = new Renderer(); + $renderer->add('name', fn ($v) => 'X'); + + $object = (object) ['name' => 'Alice']; + $output = $renderer->apply([$object]); + + self::assertSame($object, $output[0]); + } + + public function testReAddingAColumnOverwritesThePreviousRenderer(): void + { + $renderer = new Renderer(); + $renderer->add('name', fn ($v) => 'first'); + $renderer->add('name', fn ($v) => 'second'); + + $output = $renderer->apply([['name' => 'whatever']]); + + self::assertSame('second', $output[0]['name']); + } +} diff --git a/tests/Utils/Datatables/RequestParserTest.php b/tests/Utils/Datatables/RequestParserTest.php new file mode 100644 index 0000000..4340832 --- /dev/null +++ b/tests/Utils/Datatables/RequestParserTest.php @@ -0,0 +1,111 @@ +draw()); + self::assertSame(0, $parser->start()); + self::assertSame(-1, $parser->length()); + self::assertFalse($parser->hasPagination()); + self::assertNull($parser->searchValue()); + self::assertSame([], $parser->orders()); + } + + public function testNumericFieldsAreCoercedToIntegers(): void + { + $parser = new RequestParser([ + 'draw' => '7', + 'start' => '20', + 'length' => '10', + ]); + + self::assertSame(7, $parser->draw()); + self::assertSame(20, $parser->start()); + self::assertSame(10, $parser->length()); + self::assertTrue($parser->hasPagination()); + } + + public function testNegativeStartIsClampedToZero(): void + { + $parser = new RequestParser(['start' => '-5']); + + self::assertSame(0, $parser->start()); + } + + public function testLengthMinusOneMeansNoPagination(): void + { + $parser = new RequestParser(['start' => '0', 'length' => '-1']); + + self::assertFalse($parser->hasPagination()); + self::assertSame(-1, $parser->length()); + } + + public function testEmptyStringSearchIsTreatedAsAbsent(): void + { + $parser = new RequestParser(['search' => ['value' => '']]); + + self::assertNull($parser->searchValue()); + } + + public function testNonScalarSearchValueIsRejected(): void + { + $parser = new RequestParser(['search' => ['value' => ['nested']]]); + + self::assertNull($parser->searchValue()); + } + + public function testSearchValueIsReturnedAsString(): void + { + $parser = new RequestParser(['search' => ['value' => 42]]); + + self::assertSame('42', $parser->searchValue()); + } + + public function testOrderEntriesAreNormalisedToAscOrDescPairs(): void + { + $parser = new RequestParser([ + 'order' => [ + ['column' => '2', 'dir' => 'asc'], + ['column' => '1', 'dir' => 'DESC'], + ['column' => '3'], // missing dir defaults to DESC + ['dir' => 'asc'], // missing column is dropped + 'malformed', // non-array is dropped + ], + ]); + + self::assertSame( + [[2, 'ASC'], [1, 'DESC'], [3, 'DESC']], + $parser->orders() + ); + } + + public function testMalformedOrderPayloadGivesEmptyArray(): void + { + $parser = new RequestParser(['order' => 'not-an-array']); + + self::assertSame([], $parser->orders()); + } + + public function testAllReturnsTheRawPayloadVerbatim(): void + { + $payload = ['draw' => 1, 'custom' => 'value']; + $parser = new RequestParser($payload); + + self::assertSame($payload, $parser->all()); + } +} From c703f1a372bb996003fa1cf0e72c09050f337f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 23:41:12 +0300 Subject: [PATCH 3/4] =?UTF-8?q?#23=20v5'te=20(initphp/database@^5.0)=20raw?= =?UTF-8?q?=20query=20yolunu=20kullanmak=20h=C3=A2l=C3=A2=20do=C4=9Fru=20p?= =?UTF-8?q?attern=20=E2=80=94=20REPLACE=20INTO=20standart=20SQL=20de=C4=9F?= =?UTF-8?q?il.=20Detayl=C4=B1=20=C3=B6rnekler=20ve=20cross-driver=20e?= =?UTF-8?q?=C5=9Fde=C4=9Ferleri=20(PostgreSQL=20ON=20CONFLICT,=20MySQL=208?= =?UTF-8?q?=20ON=20DUPLICATE=20KEY=20UPDATE)=20i=C3=A7in:=20docs/03-crud.m?= =?UTF-8?q?d#replace-into--upsert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/03-crud.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/03-crud.md b/docs/03-crud.md index 050a2fc..bfc7856 100644 --- a/docs/03-crud.md +++ b/docs/03-crud.md @@ -125,6 +125,60 @@ DB::where('id', 13)->delete('posts'); When working through a `Model` with `$useSoftDeletes = true`, this becomes a soft delete by default — pass `$purge = true` to bypass the soft-delete path. See [04 — Models](04-models.md). +## REPLACE INTO / UPSERT + +The query builder has no native `REPLACE INTO` (or "upsert") method — the operation is not standard SQL and the exact spelling differs from one driver to the next. Reach for `DB::query()` with raw SQL when you need it; every driver-specific dialect goes through the same prepared-statement path. + +### MySQL / MariaDB / SQLite — `REPLACE INTO` + +```php +DB::query( + 'REPLACE INTO items (id, name) VALUES (:id, :name)', + [':id' => 1, ':name' => 'Alice'] +); +``` + +`REPLACE INTO` deletes a conflicting row and inserts the new one — be aware that this fires `ON DELETE` triggers and reissues auto-increment IDs. SQLite supports the same syntax (and also accepts `INSERT OR REPLACE INTO` as a synonym). + +### PostgreSQL — `INSERT ... ON CONFLICT` + +```php +DB::query( + 'INSERT INTO items (id, name) VALUES (:id, :name) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name', + [':id' => 1, ':name' => 'Alice'] +); +``` + +`ON CONFLICT` is generally the better choice when it's available: no DELETE happens, triggers behave normally, and the conflict target is explicit. + +### MySQL 8 — `INSERT ... ON DUPLICATE KEY UPDATE` + +```php +DB::query( + 'INSERT INTO items (id, name) VALUES (:id, :name) + ON DUPLICATE KEY UPDATE name = VALUES(name)', + [':id' => 1, ':name' => 'Alice'] +); +``` + +Same intent as PostgreSQL's `ON CONFLICT` — preferred over `REPLACE INTO` for the same reasons. + +### Batching upserts + +`DB::query()` takes one statement at a time. If you need to upsert a batch, run them inside a transaction so the round-trips are amortised and you get atomicity for free: + +```php +DB::transaction(function ($db) use ($rows) { + foreach ($rows as $row) { + $db->query( + 'REPLACE INTO items (id, name) VALUES (:id, :name)', + [':id' => $row['id'], ':name' => $row['name']] + ); + } +}); +``` + ## Raw SQL When the query is faster to write by hand: From 73668446c8e4d4aedaf180ac66ba2e4c15a33748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 23:41:39 +0300 Subject: [PATCH 4/4] =?UTF-8?q?Issue=20#N=20(reserved=20keyword=20order):?= =?UTF-8?q?=20v5'te=20bu=20sorun=20=C3=A7=C3=B6z=C3=BCld=C3=BC=20=E2=80=94?= =?UTF-8?q?=20upstream=20query=20builder=20art=C4=B1k=20her=20tan=C4=B1mla?= =?UTF-8?q?y=C4=B1c=C4=B1y=C4=B1=20driver-spesifik=20karakterle=20(MySQL/S?= =?UTF-8?q?QLite:=20backtick,=20PostgreSQL:=20=C3=A7ift=20t=C4=B1rnak)=20o?= =?UTF-8?q?tomatik=20quote=20ediyor.=20tests/ReservedKeywordRegressionTest?= =?UTF-8?q?.php=20davran=C4=B1=C5=9F=C4=B1=20kal=C4=B1c=C4=B1=20kay=C4=B1t?= =?UTF-8?q?=20alt=C4=B1na=20al=C4=B1yor.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ReservedKeywordRegressionTest.php | 135 ++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/ReservedKeywordRegressionTest.php diff --git a/tests/ReservedKeywordRegressionTest.php b/tests/ReservedKeywordRegressionTest.php new file mode 100644 index 0000000..58193eb --- /dev/null +++ b/tests/ReservedKeywordRegressionTest.php @@ -0,0 +1,135 @@ +connection = SqliteHelper::makeConnection(); + $this->db = new Database($this->connection); + + // `order`, `select`, `from`, `where` are all reserved in standard SQL. + // SQLite tolerates them inside double quotes when defined. + $this->connection->getPDO()->exec( + 'CREATE TABLE posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT, + "order" INTEGER, + "select" TEXT + )' + ); + $this->connection->getPDO()->exec( + "INSERT INTO posts (title, \"order\", \"select\") VALUES + ('First', 1, 'a'), + ('Second', 2, 'b'), + ('Third', 3, 'c')" + ); + } + + public function testUpdateAcceptsReservedKeywordColumnInTheSetMap(): void + { + $this->db->where('id', 1)->update('posts', [ + 'title' => 'Renamed', + 'order' => 10, + ]); + + $row = $this->db + ->select('id', 'title', 'order') + ->from('posts') + ->where('id', 1) + ->read() + ->asAssoc() + ->row(); + + self::assertIsArray($row); + self::assertSame('Renamed', $row['title']); + self::assertSame(10, (int) $row['order']); + } + + public function testSelectAcceptsReservedKeywordColumnInTheProjection(): void + { + $rows = $this->db + ->select('id', 'order', 'select') + ->from('posts') + ->read() + ->asAssoc() + ->rows(); + + self::assertCount(3, $rows); + self::assertArrayHasKey('order', $rows[0]); + self::assertArrayHasKey('select', $rows[0]); + } + + public function testWhereAcceptsReservedKeywordColumn(): void + { + $rows = $this->db + ->select('id', 'title') + ->from('posts') + ->where('order', '>', 1) + ->read() + ->asAssoc() + ->rows(); + + self::assertCount(2, $rows); + } + + public function testOrderByAcceptsReservedKeywordColumn(): void + { + $rows = $this->db + ->select('id', 'title') + ->from('posts') + ->orderBy('order', 'DESC') + ->read() + ->asAssoc() + ->rows(); + + self::assertSame('Third', $rows[0]['title']); + self::assertSame('First', $rows[2]['title']); + } + + public function testCompiledSqlActuallyQuotesTheIdentifier(): void + { + $this->db->enableQueryLog(); + $this->db->where('id', 1)->update('posts', ['order' => 5]); + + $logs = $this->db->getQueryLogs(); + self::assertNotEmpty($logs); + self::assertStringContainsString( + '`order`', + $logs[0]['query'], + 'Reserved identifiers must be emitted in their quoted form. If this assertion fails, ' + . 'upstream initorm/query-builder stopped escaping identifiers — file a bug there before ' + . 'shipping a release.' + ); + } +}