From 512c345365b68b330c802b7ccf9e703918d683c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zden=C4=9Bk=20Papu=C4=8D=C3=ADk?= Date: Mon, 19 Oct 2026 08:11:37 +0200 Subject: [PATCH] Refractoring --- .gitattributes | 1 + .github/workflows/coding-style.yml | 12 +- .github/workflows/static-analysis.yml | 16 ++- .github/workflows/tests.yml | 72 +++++++--- AGENTS.md | 6 + composer.json | 4 +- phpstan.neon | 5 + readme.md | 109 ++++++++-------- src/Database/ExtraFluent.php | 17 ++- tests/Database/AttributeDetection.phpt | 79 +++++++++++ tests/Database/Database.phpt | 13 +- tests/Database/ExtraFluent.phpt | 123 ++++++++++++++++++ .../{Database.php => DatabaseConnection.php} | 2 +- tests/TraitStub.php | 20 --- tests/bootstrap.php | 8 +- 15 files changed, 373 insertions(+), 114 deletions(-) create mode 100644 .gitattributes create mode 100644 tests/Database/AttributeDetection.phpt create mode 100644 tests/Database/ExtraFluent.phpt rename tests/{Database.php => DatabaseConnection.php} (92%) delete mode 100644 tests/TraitStub.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml index 16f92a0..a95beb7 100644 --- a/.github/workflows/coding-style.yml +++ b/.github/workflows/coding-style.yml @@ -1,6 +1,16 @@ name: Coding Style -on: [push, pull_request] +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: nette_cc: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 50d72f3..e0e8b84 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,9 +1,16 @@ -name: Static Analysis (only informative) +name: Static Analysis on: push: - branches: - - main + branches: ["**"] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: phpstan: @@ -13,9 +20,8 @@ jobs: - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: "8.3" coverage: none - run: composer install --no-progress --prefer-dist - run: composer phpstan -- --no-progress - continue-on-error: true # is only informative diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bf1af5b..fbd98aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,8 +2,16 @@ name: Tests on: push: + branches: ["**"] pull_request: +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: php-extensions: iconv, json, mbstring, xml, gd, intl, tokenizer, mysqli php-tools: composer:v2 @@ -11,26 +19,21 @@ env: jobs: tests: name: PHP ${{ matrix.php }} tests - runs-on: ubuntu-latest strategy: fail-fast: false - matrix: php: ["8.3", "8.4", "8.5"] services: mysql: image: mysql:8.4 - env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: test - ports: - 3306:3306 - options: >- --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" --health-interval=10s @@ -53,24 +56,51 @@ jobs: - name: Validate composer.json run: composer validate --strict - - name: Cache Composer dependencies - uses: actions/cache@v6 - with: - path: vendor - key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }} - restore-keys: | - composer-${{ runner.os }}-${{ matrix.php }}- - - name: Install dependencies run: composer install --no-progress --prefer-dist - - name: Import MySQL database - run: | - mysql --host 127.0.0.1 \ - --port 3306 \ - -uroot \ - -proot \ - test < tests/db.sql + - name: Import test database + run: mysql --host 127.0.0.1 --port 3306 -uroot -proot test < tests/db.sql + + - name: Run tests + run: composer tester + + lowest_dependencies: + name: Lowest dependencies + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + --health-start-period=10s + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: ${{ env.php-extensions }} + tools: ${{ env.php-tools }} + coverage: none + + - name: Install lowest dependencies + run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable + + - name: Import test database + run: mysql --host 127.0.0.1 --port 3306 -uroot -proot test < tests/db.sql - name: Run tests - run: vendor/bin/tester -p php tests -s -C + run: composer tester diff --git a/AGENTS.md b/AGENTS.md index 416b5b5..35b9fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ Use this file as package-specific guidance when working in this repository. Keep - `.github/`: CI workflows; keep their PHP versions and commands consistent with Composer and the package scripts. - `composer.json`: package metadata, dependency constraints, autoloading, and available Composer scripts. +## Key Parts +- `Drago\Database\Database` trait reads the table, primary key and entity class from the `Drago\Attr\Table` attribute and needs a `$connection` property. +- `ExtraFluent::record()` and `recordAll()` return the entity class when one is set, otherwise Dibi rows; never pass `null` to `Result::setRowClass()` (Dibi would return arrays). +- `save()` updates when the primary key value is greater than zero, otherwise inserts. `EntityOracle` uses uppercase keys. +- `tests/Database/Database.phpt` needs MySQL (CI provides it and fails there instead of skipping); the other tests run without a database. + ## Dependencies Composer constraints describe the package contract. Before changing integration behavior, inspect the source and the actual call sites in this package; do not infer runtime usage solely from dependency declarations. diff --git a/composer.json b/composer.json index 0056af6..2ca74b7 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "require-dev": { "nette/bootstrap": "^3.2", "nette/tester": "^2.5", - "tracy/tracy": "^2.7", + "tracy/tracy": "^2.10", "phpstan/phpstan-nette": "^2.0" }, "autoload": { @@ -29,6 +29,6 @@ }, "scripts": { "phpstan": "phpstan analyse", - "tester": "tester tests -s" + "tester": "tester tests -s -C" } } diff --git a/phpstan.neon b/phpstan.neon index 397d32f..e5ae702 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,3 +6,8 @@ parameters: paths: - src - tests + excludePaths: + analyseAndScan: + - tests/tmp (?) + - tests/*/temp/* (?) + - tests/*/*/temp/* (?) diff --git a/readme.md b/readme.md index 57374c0..c154743 100644 --- a/readme.md +++ b/readme.md @@ -26,108 +26,113 @@ composer require drago-ex/database ## Basic Model Example +A model uses the `Database` trait and describes its table with the `Table` attribute +(table name and primary key). The trait reads the connection from the `$connection` property: + ```php -#[Table('table_name', 'primary_key')] +use Dibi\Connection; +use Drago\Attr\Table; +use Drago\Database\Database; + +#[Table(table_name, id)] class Model { - use Database; + use Database; + + public function __construct( + protected Connection $connection, + ) { + } } ``` ## Common Queries -Reading records from a table: -```php -$this->model->read('*'); -``` +The query methods return a query builder (`ExtraFluent`). Fetch the result with `record()` or +`recordAll()`, or run a write query with `execute()`: -Find records by column name: ```php -$this->model->find('column', 'value'); -``` +// All records +$rows = $this->model->read(*)->recordAll(); -Get a record by ID: -```php -$this->model->get(1); -``` +// Records by column value +$rows = $this->model->find(column, value)->recordAll(); -Delete a record by column name: -```php -$this->model->delete('column', 'value'); +// One record by the primary key, or null +$row = $this->model->get(1)->record(); + +// Delete records by column value +$this->model->delete(column, value)->execute(); ``` -Save records as an array (update if `id` is provided): +Without an entity class, records are returned as `Dibi\Row` objects. + +Save a record as an array. When the array contains the primary key with a value greater than zero, +the record is updated, otherwise a new record is inserted: ```php $this->model->save(['column' => 'value']); +$this->model->save(['id' => 1, 'column' => 'value']); ``` ## Using Entities ```php -class SampleEntity extends Drago\Database\Entity +use Drago\Database\Entity; + +class SampleEntity extends Entity { - public const Table = 'name'; - public const PrimaryKey = 'id'; + public const string Table = name; + public const string PrimaryKey = id; public ?int $id = null; public string $sample; } ``` -Use the entity in a model: +Use the entity in a model, so records are returned as `SampleEntity` objects: ```php #[Table(SampleEntity::Table, SampleEntity::PrimaryKey, entity: SampleEntity::class)] class Model { /** @use Database */ - use Database; -} -``` + use Database; -Fetch records as objects: -```php -$row = $this->model->find('id', 1)->record(); + public function __construct( + protected Connection $connection, + ) { + } +} -// Accessing properties -echo $row->id; +$row = $this->model->get(1)->record(); echo $row->sample; + +$rows = $this->model->read(*)->recordAll(); ``` ## Save Entity Records -To save entity data (update record if `id` is present): +Entities are saved the same way as arrays (updated when the primary key is set): ```php $entity = new SampleEntity; $entity->id = 1; -$entity->sample = 'sample'; +$entity->sample = sample; -$this->save($entity); +$this->model->save($entity); ``` -## Advanced Features +## Transactions -### Entity Class for Database Mapping - -You can use a custom entity class with database mapping: ```php -#[Table(SampleEntity::Table, SampleEntity::PrimaryKey, entity: SampleEntity::class)] -class Model -{ - /** @use Database */ - use Database; +$this->model->beginTransaction(); +try { + $this->model->save($entity); + $this->model->commit(); +} catch (Throwable $e) { + $this->model->rollBack(); + throw $e; } - -// Fetch records directly as objects -$row = $this->model->find('id', 1)->record(); - -// Access the object's properties -echo $row->id; -echo $row->sample; - -// Fetch all records -$allRecords = $this->model->read('*')->recordAll(); ``` -### Entity Generation +## Entity Generation For automatic entity generation, consider using the Drago Generator tool: [https://github.com/drago-ex/generator](https://github.com/drago-ex/generator) diff --git a/src/Database/ExtraFluent.php b/src/Database/ExtraFluent.php index bae2adb..88cc5ec 100644 --- a/src/Database/ExtraFluent.php +++ b/src/Database/ExtraFluent.php @@ -6,6 +6,7 @@ use Dibi\Exception; use Dibi\Fluent; +use Dibi\Result; use Dibi\Row; @@ -126,7 +127,7 @@ public function record(): ?Row } /** @var T|null $row */ - $row = $result->setRowClass($this->className)->fetch(); + $row = $this->applyRowClass($result)->fetch(); return $row; } @@ -143,7 +144,19 @@ public function recordAll(?int $offset = null, ?int $limit = null): array } /** @var list $rows */ - $rows = $result->setRowClass($this->className)->fetchAll($offset, $limit); + $rows = $this->applyRowClass($result)->fetchAll($offset, $limit); return $rows; } + + + /** + * Without an entity class, Dibi's default Row class is kept; + * setting null would make Dibi return plain arrays. + */ + private function applyRowClass(Result $result): Result + { + return $this->className === null + ? $result + : $result->setRowClass($this->className); + } } diff --git a/tests/Database/AttributeDetection.phpt b/tests/Database/AttributeDetection.phpt new file mode 100644 index 0000000..436ce9f --- /dev/null +++ b/tests/Database/AttributeDetection.phpt @@ -0,0 +1,79 @@ +getTableName()); + Assert::same('id', $model->getPrimaryKey()); + Assert::same(TestEntity::class, $model->getEntityClassName()); +}); + + +test('A model without the Table attribute is reported', function () { + Assert::exception( + fn() => (new ModelWithoutTable)->getTableName(), + AttributeDetectionException::class, + 'In the model ModelWithoutTable you do not have a table name in the Table attribute.', + ); +}); + + +test('A missing primary key is reported only when it is needed', function () { + $model = new ModelWithoutPrimaryKey; + Assert::same('items', $model->getTableName()); + Assert::null($model->getEntityClassName()); + Assert::exception( + fn() => $model->getPrimaryKey(), + AttributeDetectionException::class, + 'In the model ModelWithoutPrimaryKey you do not have a primary key in the Table attribute.', + ); +}); + + +test('An entity class that is not a Dibi row is rejected', function () { + Assert::exception( + fn() => (new ModelWithInvalidEntity)->getTableName(), + AttributeDetectionException::class, + 'Class "stdClass" in the Table attribute of ModelWithInvalidEntity is not an instance of Dibi\Row.', + ); +}); diff --git a/tests/Database/Database.phpt b/tests/Database/Database.phpt index 0d91a29..c3bb064 100644 --- a/tests/Database/Database.phpt +++ b/tests/Database/Database.phpt @@ -12,15 +12,20 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; try { - (new Database)->connection(); -} catch (\Throwable) { + (new DatabaseConnection)->connection(); +} catch (Throwable $e) { + // CI provides the database, so a failed connection there is an error, not a reason to skip. + if (getenv('CI')) { + throw $e; + } + Tester\Environment::skip('Database server is not available in local environment.'); } function database(): TestDatabase { - $db = new Database; + $db = new DatabaseConnection; return new TestDatabase($db->connection()); } @@ -103,7 +108,7 @@ test('Get class name', function () { test('Get entity class name from legacy class option', function () { - $db = new Database; + $db = new DatabaseConnection; $className = (new TestDatabaseLegacyClass($db->connection())) ->getClassName(); diff --git a/tests/Database/ExtraFluent.phpt b/tests/Database/ExtraFluent.phpt new file mode 100644 index 0000000..7beafb7 --- /dev/null +++ b/tests/Database/ExtraFluent.phpt @@ -0,0 +1,123 @@ +> $rows */ + public function __construct( + private array $rows, + ) { + } + + + public function getRowCount(): int + { + return count($this->rows); + } + + + public function seek(int $row): bool + { + $this->position = $row; + return isset($this->rows[$row]); + } + + + public function fetch(bool $type): ?array + { + return $this->rows[$this->position++] ?? null; + } + + + public function free(): void + { + } + + + public function getResultColumns(): array + { + return []; + } + + + public function getResultResource(): mixed + { + return null; + } + + + public function unescapeBinary(string $value): string + { + return $value; + } +} + + +/** @extends ExtraFluent */ +class ArrayFluent extends ExtraFluent +{ + /** @param list> $rows */ + public function __construct( + private array $rows, + ) { + parent::__construct(new Connection(['driver' => 'mysqli', 'lazy' => true])); + } + + + public function execute(?string $return = null): Result + { + return new Result(new ArrayResultDriver($this->rows), normalize: false); + } +} + + +test('Without an entity class, records are fetched as Dibi rows', function () { + $fluent = new ArrayFluent([['id' => 1, 'sample' => 'Hello'], ['id' => 2, 'sample' => 'World']]); + + $row = $fluent->record(); + Assert::type(Row::class, $row); + Assert::same('Hello', $row->sample); + + $rows = (new ArrayFluent([['id' => 1], ['id' => 2]]))->recordAll(); + Assert::count(2, $rows); + Assert::type(Row::class, $rows[0]); +}); + + +test('With an entity class, records are fetched as entities', function () { + $fluent = new ArrayFluent([['id' => 1, 'sample' => 'Hello']]); + $fluent->className = TestEntity::class; + + $row = $fluent->record(); + Assert::type(TestEntity::class, $row); + Assert::same(1, $row->id); + + $fluent = new ArrayFluent([['id' => 1, 'sample' => 'Hello']]); + $fluent->className = TestEntity::class; + Assert::type(TestEntity::class, $fluent->recordAll()[0]); +}); + + +test('A missing record is returned as null', function () { + Assert::null((new ArrayFluent([]))->record()); + Assert::same([], (new ArrayFluent([]))->recordAll()); +}); diff --git a/tests/Database.php b/tests/DatabaseConnection.php similarity index 92% rename from tests/Database.php rename to tests/DatabaseConnection.php index a0d69c7..8df6a62 100644 --- a/tests/Database.php +++ b/tests/DatabaseConnection.php @@ -6,7 +6,7 @@ use Dibi\Exception; -class Database +class DatabaseConnection { /** * @throws Exception diff --git a/tests/TraitStub.php b/tests/TraitStub.php deleted file mode 100644 index b82f4fe..0000000 --- a/tests/TraitStub.php +++ /dev/null @@ -1,20 +0,0 @@ - */ - use Database; - - public function __construct( - protected Connection $connection, - ) { - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 400966e..c013e08 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -25,10 +25,6 @@ ->addDirectory(__DIR__ . '/../src') ->register(); -return $boot->createContainer(); - +Tester\Environment::setupFunctions(); -function test(string $title, Closure $function): void -{ - $function(); -} +return $boot->createContainer();