diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d09a5f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore +/.scrutinizer.yml export-ignore +/doc export-ignore +/phpunit.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..206c8af --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,51 @@ +name: build + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Create runtime cache folder + run: mkdir -v -p -m 777 tests/runtime/cache + + - name: Create sqlite folder + run: mkdir -v -p -m 777 tests/runtime/sqlite && touch tests/runtime/sqlite/database.db + + - name: Execute Tests + run: vendor/bin/phpunit tests --colors=always --coverage-clover=coverage.clover + env: + XDEBUG_MODE: coverage + + - name: Upload coverage reports to Codecov + continue-on-error: true + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.clover diff --git a/.gitignore b/.gitignore index a4cb13c..caa2c69 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ .idea .phpunit.result.cache +.phpunit.cache +coverage.clover +coverage.txt vendor/ composer.lock tests/runtime/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..b9d8d04 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,16 @@ +checks: + php: true + +filter: + paths: + - "src/*" + +tools: + external_code_coverage: + timeout: 900 # Timeout in seconds. + runs: 2 # How many code coverage submissions Scrutinizer will wait + +build: + image: default-bionic + environment: + php: 8.1.2 \ No newline at end of file diff --git a/README.md b/README.md index c4f3f59..256a728 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,37 @@ # What is Composite DB +[](https://packagist.org/packages/compositephp/db) +[](https://github.com/compositephp/db/actions) +[](https://codecov.io/gh/compositephp/db/) -Composite DB is lightweight and fast PHP ORM, DataMapper and Table Gateway which allows you to represent your SQL tables +Composite DB is lightweight and fast PHP DataMapper and Table Gateway which allows you to represent your SQL tables scheme in OOP style using full power of PHP 8.1+ class syntax. It also gives you CRUD, query builder and automatic caching out of the box, so you can start to work with your database from php code in a minutes! Overview: -* [Mission](#mission) +* [Features](#features) * [Requirements](#requirements) * [Installation](#installation) * [Quick example](#quick-example) * [Documentation](doc/README.md) -## Mission -You probably may ask, why do you need another ORM if there are already popular Doctrine, CycleORM, etc.? - -Composite DB solves multiple problems: +## Features * **Lightweight** - easier entity schema, no getters and setters, you don't need attributes for each column definition, just use native php class syntax. -* **Speed** - it's 1.5x faster in pure SQL queries mode and many times faster in automatic caching mode. -* **Easy caching** - gives you CRUD operations caching out of the box and in general its much easier to work with cached "selects". +* **Speed** - it's 1.5x faster in pure SQL queries mode and many times faster in automatic caching mode (see [benchmark](https://github.com/compositephp/php-orm-benchmark)). +* **Easy caching** - gives you CRUD operations caching out of the box and in general it's much easier to work with cached "selects". * **Strict types** - Composite DB forces you to be more strict typed and makes your IDE happy. * **Hydration** - you can serialize your Entities to plain array or json and deserialize them back. -* **Flexibility** - gives you more freedom to extend Repositories, for example its easier to build sharding tables. +* **Flexibility** - gives you more freedom to extend Repositories, for example it's easier to build sharding tables. * **Code generation** - you can generate Entity and Repository classes from your SQL tables. -* **Division of responsibility** - there is no "god" entity manager, every Entity has its own Repository class and its the only entry point to make queries to your table. +* **Division of responsibility** - every Entity has its own Repository class, and it's the only entry point to make queries to your table. It also has many popular features such as: * **Query Builder** - build your queries with constructor, based on [doctrine/dbal](https://github.com/doctrine/dbal) -* **Migrations** - based on [doctrine/migrations](https://github.com/doctrine/migrations) - -But there is 1 sacrifice for all these features - there is no support for relations in Composite DB. Its too much -uncontrollable magic and hidden bottlenecks with "JOINs" and its not possible to implement automatic caching with -relations. We recommend to have full control and make several cached select queries instead of "JOINs". - -### When you shouldn't use Composite DB - -1. If you have intricate structure with many foreign keys in your database -2. You 100% sure in your indexes and fully trust "JOINs" performance -3. You dont want to do extra cached select queries and want some magic +* **Migrations** - synchronise your php entities with database tables ## Requirements @@ -177,7 +167,7 @@ $user = User::fromArray([ ]); ``` -And thats it, no special getters or setters, no "behaviours" or extra code, smart entity casts everything automatically. +And that's it, no special getters or setters, no "behaviours" or extra code, smart entity casts everything automatically. More about Entity and supported auto casting types you can find [here](doc/entity.md). ## License: diff --git a/composer.json b/composer.json index bc13c29..a6c1c43 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "compositephp/db", - "description": "PHP 8.1+ ORM and Table Gateway", + "description": "PHP 8.1+ DataMapper and Table Gateway", "type": "library", "license": "MIT", "minimum-stability": "dev", @@ -13,18 +13,16 @@ ], "require": { "php": "^8.1", + "ext-pdo": "*", "psr/simple-cache": "1 - 3", - "compositephp/entity": "^0.1.2", - "doctrine/dbal": "^3.5", - "doctrine/inflector": "^2.0", - "iamcal/sql-parser": "^0.4.0", - "nette/php-generator": "^4.0", - "symfony/console": "2 - 6" + "compositephp/entity": "^v0.1.11", + "doctrine/dbal": "^3.5" }, "require-dev": { "kodus/file-cache": "^2.0", - "phpunit/phpunit": "^9.5", - "phpstan/phpstan": "^1.9" + "phpunit/phpunit": "^10.1", + "phpstan/phpstan": "^1.9", + "phpunit/php-code-coverage": "^10.1" }, "autoload": { "psr-4": { diff --git a/doc/cache.md b/doc/cache.md index e3ca6ab..a5d6340 100644 --- a/doc/cache.md +++ b/doc/cache.md @@ -6,7 +6,7 @@ To start using auto-cache feature you need: to `Composite\DB\AbstractCachedTable` 3. Implement method `getFlushCacheKeys()` 4. Change all internal select methods to their cached versions (example: `findByPkInternal()` -to `findByPkCachedInternal()` etc.) +to `_findByPkCached()` etc.) You can also generate cached version of your table with console command: @@ -46,7 +46,7 @@ class PostsTable extends AbstractCachedTable public function findByPk(int $id): ?Post { - return $this->createEntity($this->findByPkInternalCached($id)); + return $this->_findByPkCached($id); } /** @@ -54,15 +54,12 @@ class PostsTable extends AbstractCachedTable */ public function findAllFeatured(): array { - return $this->createEntities($this->findAllInternal( - 'is_featured = :is_featured', - ['is_featured' => true], - )); + return $this->_findAll(['is_featured' => true]); } public function countAllFeatured(): int { - return $this->countAllCachedInternal( + return $this->_countAllCached( 'is_featured = :is_featured', ['is_featured' => true], ); diff --git a/doc/code-generators.md b/doc/code-generators.md index 276f294..ce53614 100644 --- a/doc/code-generators.md +++ b/doc/code-generators.md @@ -1,27 +1,90 @@ # Code generators -Before start, you need to [configure](configuration.md#configure-console-commands) code generators. +Code generation is on of key features of the Composite Sync package. +This enables you to generate Entity classes directly from SQL tables, thereby enabling a literal reflection of the SQL table schema into native PHP classes. -## Entity class generator -Arguments: -1. `db` - DatabaseManager database name -2. `table` - SQL table name -3. `entity` - Full classname of new entity -4. `--force` - option if existing file should be overwritten +## Supported Databases +- MySQL +- Postgres +- SQLite + +## Getting Started + +To begin using Composite Sync in your project, follow these steps: + +### 1. Install package via composer: + ```shell + $ composer require compositephp/sync + ``` +### 2. Configure connections +You need to configure ConnectionManager, see instructions [here](configuration.md) + +### 3. Configure commands + +Add [symfony/console](https://symfony.com/doc/current/components/console.html) commands to your application: +- Composite\Sync\Commands\MigrateCommand +- Composite\Sync\Commands\MigrateNewCommand +- Composite\Sync\Commands\MigrateDownCommand + +Here is an example of a minimalist, functional PHP file if you don't have configured symfony/console: + +```php +<?php declare(strict_types=1); +include 'vendor/autoload.php'; + +use Composite\Sync\Commands; +use Symfony\Component\Console\Application; + +//may be changed with .env file +putenv('CONNECTIONS_CONFIG_FILE=/path/to/your/connections/config.php'); + +$app = new Application(); +$app->addCommands([ + new Commands\GenerateEntityCommand(), + new Commands\GenerateTableCommand(), +]); +$app->run(); +``` +## Available commands + +* ### composite:generate-entity + +The command examines the specific SQL table and generates an [Composite\Entity\AbstractEntity](https://github.com/compositephp/entity) PHP class. +This class embodies the table structure using native PHP syntax, thereby representing the original SQL table in a more PHP-friendly format. -Example: ```shell -$ php console.php composite-db:generate-entity dbName Users 'App\User' --force +php cli.php composite:generate-entity connection_name TableName 'App\Models\EntityName' ``` -## Table class generator -Arguments: -1. `entity` - Entity full class name -2. `table` - Table full class name -3. `--cached` - Option if cached version of table class should be generated -4. `--force` - Option if existing file should be overwritten +| Argument | Required | Description | +|------------|----------|------------------------------------------------------| +| connection | Yes | Name of connection from connection config file | +| table | Yes | Name of SQL table | +| entity | Yes | Full classname of the class that needs to be created | + +Options: + +| Option | Description | +|---------|-------------------------| +| --force | Overwrite existing file | + +* ### composite:generate-table + +The command examines the specific Entity and generates a [Table](https://github.com/compositephp/db) PHP class. +This class acts as a gateway to a specific SQL table, providing user-friendly CRUD tools for interacting with SQL right off the bat. -Example: ```shell -$ php console.php composite-db:generate-table 'App\User' 'App\UsersTable' -``` \ No newline at end of file +php cli.php composite:generate-table connection_name TableName 'App\Models\EntityName' +``` + +| Argument | Required | Description | +|-----------|----------|-----------------------------------------------| +| entity | Yes | Full Entity classname | +| table | No | Full Table classname that needs to be created | + +Options: + +| Option | Description | +|----------|--------------------------------------------| +| --cached | Generate cached version of PHP Table class | +| --force | Overwrite existing file | \ No newline at end of file diff --git a/doc/example.md b/doc/example.md index 096ea6f..4709c67 100644 --- a/doc/example.md +++ b/doc/example.md @@ -43,7 +43,7 @@ class UsersTable extends \Composite\DB\AbstractTable public function findByPk(int $id): ?User { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } /** @@ -51,17 +51,13 @@ class UsersTable extends \Composite\DB\AbstractTable */ public function findAllActive(): array { - return $this->createEntities($this->findAllInternal( - 'status = :status', - ['status' => Status::ACTIVE->name], - )); + return $this->_findAll(['status' => Status::ACTIVE]); } public function countAllActive(): int { - return $this->countAllInternal( - 'status = :status', - ['status' => Status::ACTIVE->name], + return $this->_countAll( + ['status' => Status::ACTIVE], ); } diff --git a/doc/migrations.md b/doc/migrations.md index da0fe68..f772d25 100644 --- a/doc/migrations.md +++ b/doc/migrations.md @@ -2,96 +2,157 @@ > **_NOTE:_** This is experimental feature -Migrations used a bridge to [doctrine/migrations](https://github.com/doctrine/migrations) package. -If you are not familiar with it, please read documentation before using composite bridge. - -1. Install package: - ```shell - $ composer require compositephp/doctrine-migrations - ``` - -2. Configure bridge: - ```php - $bridge = new \Composite\DoctrineMigrations\SchemaProviderBridge( - entityDirs: [ - '/path/to/your/src', //path to your source code, where bridge will search for entities - ], - connectionName: 'sqlite', //only entities with this connection name will be affected - connection: $connection, //Doctrine\DBAL\Connection instance - ); - ``` - -3. Inject bridge into `\Doctrine\Migrations\DependencyFactory` as `\Doctrine\Migrations\Provider\SchemaProvider` -instance. - ```php - $dependencyFactory->setDefinition(SchemaProvider::class, static fn () => $bridge); - ``` - -Full example: +Migrations enable you to maintain your database schema within your PHP entity classes. +Any modification made in your class triggers the generation of migration files. +These files execute SQL queries which synchronize the schema from the PHP class to the corresponding SQL table. +This mechanism ensures consistent alignment between your codebase and the database structure. + +## Supported Databases +- MySQL +- Postgres (Coming soon) +- SQLite (Coming soon) + +## Getting Started + +To begin using migrations you need to add Composite Sync package into your project and configure it: + +### 1. Install package via composer: + ```shell + $ composer require compositephp/sync + ``` +### 2. Configure connections +You need to configure ConnectionManager, see instructions [here](configuration.md) + +### 3. Configure commands + +Add [symfony/console](https://symfony.com/doc/current/components/console.html) commands to your application: +- Composite\Sync\Commands\MigrateCommand +- Composite\Sync\Commands\MigrateNewCommand +- Composite\Sync\Commands\MigrateDownCommand +- Composite\Sync\Commands\GenerateEntityCommand +- Composite\Sync\Commands\GenerateTableCommand + +Here is an example of a minimalist, functional PHP file: + ```php <?php declare(strict_types=1); +include 'vendor/autoload.php'; -use Doctrine\DBAL\DriverManager; -use Doctrine\Migrations\Configuration\Configuration; -use Doctrine\Migrations\Configuration\Connection\ExistingConnection; -use Doctrine\Migrations\Configuration\Migration\ExistingConfiguration; -use Doctrine\Migrations\DependencyFactory; -use Doctrine\Migrations\Metadata\Storage\TableMetadataStorageConfiguration; -use Doctrine\Migrations\Provider\SchemaProvider; -use Doctrine\Migrations\Tools\Console\Command; +use Composite\Sync\Commands; use Symfony\Component\Console\Application; -include __DIR__ . '/vendor/autoload.php'; - -$connection = DriverManager::getConnection([ - 'driver' => 'pdo_mysql', - 'dbname' => 'test', - 'user' => 'test', - 'password' => 'test', - 'host' => '127.0.0.1', +//may be changed with .env file +putenv('CONNECTIONS_CONFIG_FILE=/path/to/your/connections/config.php'); +putenv('ENTITIES_DIR=/path/to/your/source/dir'); // e.g. "./src" +putenv('MIGRATIONS_DIR=/path/to/your/migrations/dir'); // e.g. "./src/Migrations" +putenv('MIGRATIONS_NAMESPACE=Migrations\Namespace'); // e.g. "App\Migrations" + +$app = new Application(); +$app->addCommands([ + new Commands\MigrateCommand(), + new Commands\MigrateNewCommand(), + new Commands\MigrateDownCommand(), + new Commands\GenerateEntityCommand(), + new Commands\GenerateTableCommand(), ]); +$app->run(); +``` +## Available commands + +* ### composite:migrate + +This command performs two primary functions depending on its usage context. Initially, when called for the first time, +it scans all entities located in the `ENTITIES_DIR` directory and generates migration files corresponding to these entities. +This initial step prepares the necessary migration scripts based on the current entity definitions. Upon its second +invocation, the command shifts its role to apply these generated migration scripts to the database. This two-step process +ensures that the database schema is synchronized with the entity definitions, first by preparing the migration scripts +and then by executing them to update the database. + +```shell +php cli.php composite:migrate +``` + +| Option | Short | Description | +|--------------|-------|-----------------------------------------------------------| +| --connection | -c | Check migrations for all entities with desired connection | +| --entity | -e | Check migrations only for entity class | +| --run | -r | Run migrations without asking for confirmation | +| --dry | -d | Dry run mode, no real SQL queries will be executed | + +* ### composite:migrate-new + +This command generates a new, empty migration file. The file is provided as a template for the user to fill with the +necessary database schema changes or updates. This command is typically used for initiating a new database migration, +where the user can define the specific changes to be applied to the database schema. The generated file needs to be +manually edited to include the desired migration logic before it can be executed with the migration commands. + +```shell +php cli.php composite:migrate-new +``` + +| Argument | Required | Description | +|-------------|----------|------------------------------------------| +| connection | No | Name of connection from your config file | +| description | No | Short description of desired changes | + +* ### composite:migrate-down + +This command rolls back the most recently applied migration. It is useful for undoing the last schema change made to +the database. This can be particularly helpful during development or testing phases, where you might need to revert +recent changes quickly. + +```shell +php cli.php composite:migrate-down +``` + +| Argument | Required | Description | +|------------|----------|---------------------------------------------------------------------------| +| connection | No | Name of connection from your config file | +| limit | No | Number of migrations should be rolled back from current state, default: 1 | + + +| Option | Short | Description | +|--------|-------|-----------------------------------------------------| +| --dry | -d | Dry run mode, no real SQL queries will be executed | + +* ### composite:generate-entity + +The command examines the specific SQL table and generates an [Composite\Entity\AbstractEntity](https://github.com/compositephp/entity) PHP class. +This class embodies the table structure using native PHP syntax, thereby representing the original SQL table in a more PHP-friendly format. + +```shell +php cli.php composite:generate-entity connection_name TableName 'App\Models\EntityName' +``` + +| Argument | Required | Description | +|------------|----------|------------------------------------------------------| +| connection | Yes | Name of connection from connection config file | +| table | Yes | Name of SQL table | +| entity | Yes | Full classname of the class that needs to be created | + +Options: + +| Option | Short | Description | +|---------|-------|-------------------------| +| --force | -f | Overwrite existing file | + +* ### composite:generate-table + +The command examines the specific Entity and generates a [Table](https://github.com/compositephp/db) PHP class. +This class acts as a gateway to a specific SQL table, providing user-friendly CRUD tools for interacting with SQL right off the bat. + +```shell +php cli.php composite:generate-table 'App\Models\EntityName' +``` + +| Argument | Required | Description | +|-----------|----------|-----------------------------------------------| +| entity | Yes | Full Entity classname | +| table | No | Full Table classname that needs to be created | + +Options: -$configuration = new Configuration(); - -$configuration->addMigrationsDirectory('Composite\DoctrineMigrations\Tests\runtime\migrations', __DIR__ . '/tests/runtime/migrations'); -$configuration->setAllOrNothing(true); -$configuration->setCheckDatabasePlatform(false); - -$storageConfiguration = new TableMetadataStorageConfiguration(); -$storageConfiguration->setTableName('doctrine_migration_versions'); - -$configuration->setMetadataStorageConfiguration($storageConfiguration); - -$dependencyFactory = DependencyFactory::fromConnection( - new ExistingConfiguration($configuration), - new ExistingConnection($connection) -); - -$bridge = new \Composite\DoctrineMigrations\SchemaProviderBridge( - entityDirs: [ - __DIR__ . '/src', - ], - connectionName: 'mysql', - connection: $connection, -); -$dependencyFactory->setDefinition(SchemaProvider::class, static fn () => $bridge); - -$cli = new Application('Migrations'); -$cli->setCatchExceptions(true); - -$cli->addCommands(array( - new Command\DumpSchemaCommand($dependencyFactory), - new Command\ExecuteCommand($dependencyFactory), - new Command\GenerateCommand($dependencyFactory), - new Command\LatestCommand($dependencyFactory), - new Command\ListCommand($dependencyFactory), - new Command\MigrateCommand($dependencyFactory), - new Command\DiffCommand($dependencyFactory), - new Command\RollupCommand($dependencyFactory), - new Command\StatusCommand($dependencyFactory), - new Command\SyncMetadataCommand($dependencyFactory), - new Command\VersionCommand($dependencyFactory), -)); - -$cli->run(); -``` \ No newline at end of file +| Option | Short | Description | +|----------|-------|--------------------------------------------| +| --cached | -c | Generate cached version of PHP Table class | +| --force | -f | Overwrite existing file | \ No newline at end of file diff --git a/doc/sync_illustration.png b/doc/sync_illustration.png new file mode 100644 index 0000000..94c451a Binary files /dev/null and b/doc/sync_illustration.png differ diff --git a/doc/table.md b/doc/table.md index 326f095..852c55b 100644 --- a/doc/table.md +++ b/doc/table.md @@ -38,7 +38,7 @@ class UsersTable extends AbstractTable public function findOne(int $id): ?User { - return $this->createEntity($this->findOneInternal($id)); + return $this->_findByPk($id); } /** @@ -46,12 +46,12 @@ class UsersTable extends AbstractTable */ public function findAll(): array { - return $this->createEntities($this->findAllInternal()); + return $this->_findAll(); } public function countAll(): int { - return $this->countAllInternal(); + return $this->_countAll(); } } ``` @@ -67,15 +67,30 @@ Example with internal helper: */ public function findAllActiveAdults(): array { - $rows = $this->findAllInternal( - 'age > :age AND status = :status', - ['age' => 18, 'status' => Status::ACTIVE->name], + return $this->_findAll( + new Where( + 'age > :age AND status = :status', + ['age' => 18, 'status' => Status::ACTIVE->name], + ) ); - return $this->createEntities($rows); } ``` -Example with pure query builder +Or it might be simplified to: +```php +/** + * @return User[] + */ +public function findAllActiveAdults(): array +{ + return $this->_findAll([ + 'age' => ['>', 18], + 'status' => Status:ACTIVE, + ]); +} +``` + +Or you can use standard Doctrine QueryBuilder ```php /** * @return User[] @@ -93,27 +108,36 @@ public function findCustom(): array ``` ## Transactions +In order to encapsulate your operations within a single transaction, you have two strategies at your disposal: +1. Use the internal table class method transaction() if your operations are confined to a single table. +2. Use the Composite\DB\CombinedTransaction class if your operations involve multiple tables within a single transaction. -To wrap you operations in 1 transactions there are 2 ways: -1. Use internal table class method `transaction()` if you are working only with 1 table. -2. Use class `Composite\DB\CombinedTransaction` if you need to work with several tables in 1 transaction. +Below is a sample code snippet illustrating how you can use the CombinedTransaction class: ```php + // Create instances of the tables you want to work with $usersTable = new UsersTable(); $photosTable = new PhotosTable(); - + + // Instantiate the CombinedTransaction class $transaction = new CombinedTransaction(); + // Create a new user and add it to the users table within the transaction $user = new User(...); $transaction->save($usersTable, $user); + // Create a new photo associated with the user and add it to the photos table within the transaction $photo = new Photo( user_id: $user->id, ... ); $transaction->save($photosTable, $photo); + + // Commit the transaction to finalize the changes $transaction->commit(); ``` + +Remember, using a transaction ensures that your operations are atomic. This means that either all changes are committed to the database, or if an error occurs, no changes are made. ## Locks If you worry about concurrency updates during your transaction and want to be sure that only 1 process changing your diff --git a/phpunit.xml b/phpunit.xml index 23ced23..1389feb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,6 +1,27 @@ -<?xml version="1.0" encoding="UTF-8" ?> -<phpunit - bootstrap="./tests/bootstrap.php" - verbose="true" - colors="true" -></phpunit> \ No newline at end of file +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd" bootstrap="tests/bootstrap.php" + executionOrder="depends,defects" beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true" + colors="true" cacheDirectory=".phpunit.cache" requireCoverageMetadata="false" + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnPhpunitDeprecations="true" + displayDetailsOnTestsThatTriggerWarnings="true" + beStrictAboutCoverageMetadata="true"> + <testsuites> + <testsuite name="default"> + <directory>tests</directory> + </testsuite> + </testsuites> + <coverage> + <report> + <html outputDirectory=".phpunit.cache"/> + </report> + </coverage> + <source> + <include> + <directory suffix=".php">src</directory> + </include> + </source> +</phpunit> diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php index 9d3064e..24ff5f9 100644 --- a/src/AbstractCachedTable.php +++ b/src/AbstractCachedTable.php @@ -2,12 +2,14 @@ namespace Composite\DB; -use Composite\DB\Exceptions\DbException; use Composite\Entity\AbstractEntity; use Psr\SimpleCache\CacheInterface; +use Ramsey\Uuid\UuidInterface; abstract class AbstractCachedTable extends AbstractTable { + use Helpers\SelectRawTrait; + protected const CACHE_VERSION = 1; public function __construct( @@ -24,72 +26,58 @@ abstract protected function getFlushCacheKeys(AbstractEntity $entity): array; /** * @throws \Throwable */ - public function save(AbstractEntity &$entity): void + public function save(AbstractEntity $entity): void { - $this->getConnection()->transactional(function () use (&$entity) { - $cacheKeys = $this->collectCacheKeysByEntity($entity); - parent::save($entity); - if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) { - throw new DbException('Failed to flush cache keys'); - } - }); + $cacheKeys = $this->collectCacheKeysByEntity($entity); + parent::save($entity); + if ($cacheKeys) { + $this->cache->deleteMultiple($cacheKeys); + } } /** * @param AbstractEntity[] $entities - * @return AbstractEntity[] * @throws \Throwable */ - public function saveMany(array $entities): array + public function saveMany(array $entities): void { - return $this->getConnection()->transactional(function() use ($entities) { - $cacheKeys = []; - foreach ($entities as $entity) { - $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity)); - } - foreach ($entities as $entity) { - parent::save($entity); - } - if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) { - throw new DbException('Failed to flush cache keys'); - } - return $entities; - }); + $cacheKeys = []; + foreach ($entities as $entity) { + $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity)); + } + parent::saveMany($entities); + if ($cacheKeys) { + $this->cache->deleteMultiple(array_unique($cacheKeys)); + } } /** * @throws \Throwable */ - public function delete(AbstractEntity &$entity): void + public function delete(AbstractEntity $entity): void { - $this->getConnection()->transactional(function () use (&$entity) { - $cacheKeys = $this->collectCacheKeysByEntity($entity); - parent::delete($entity); - if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) { - throw new DbException('Failed to flush cache keys'); - } - }); + $cacheKeys = $this->collectCacheKeysByEntity($entity); + parent::delete($entity); + if ($cacheKeys) { + $this->cache->deleteMultiple($cacheKeys); + } } /** * @param AbstractEntity[] $entities * @throws \Throwable */ - public function deleteMany(array $entities): bool + public function deleteMany(array $entities): void { - return $this->getConnection()->transactional(function() use ($entities) { - $cacheKeys = []; - foreach ($entities as $entity) { - $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity)); - } - foreach ($entities as $entity) { - parent::delete($entity); - } - if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) { - throw new DbException('Failed to flush cache keys'); - } - return true; - }); + $cacheKeys = []; + foreach ($entities as $entity) { + $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity)); + parent::delete($entity); + } + parent::deleteMany($entities); + if ($cacheKeys) { + $this->cache->deleteMultiple($cacheKeys); + } } /** @@ -102,62 +90,64 @@ private function collectCacheKeysByEntity(AbstractEntity $entity): array if (!$entity->isNew() || !$this->getConfig()->autoIncrementKey) { $keys[] = $this->getOneCacheKey($entity); } - return $keys; + return array_unique($keys); } /** - * @return array<string, mixed>|null + * @return AbstractEntity|null */ - protected function findByPkCachedInternal(mixed $pk, null|int|\DateInterval $ttl = null): ?array + protected function _findByPkCached(mixed $pk, null|int|\DateInterval $ttl = null): mixed { - return $this->findOneCachedInternal($this->getPkCondition($pk), $ttl); + return $this->_findOneCached($this->getPkCondition($pk), $ttl); } /** - * @param array<string, mixed> $condition + * @param array<string, mixed> $where * @param int|\DateInterval|null $ttl - * @return array<string, mixed>|null + * @return AbstractEntity|null */ - protected function findOneCachedInternal(array $condition, null|int|\DateInterval $ttl = null): ?array + protected function _findOneCached(array $where, null|int|\DateInterval $ttl = null): mixed { - return $this->getCached( - $this->getOneCacheKey($condition), - fn() => $this->findOneInternal($condition), + $row = $this->getCached( + $this->getOneCacheKey($where), + fn() => $this->_findOneRaw($where), $ttl, - ) ?: null; + ); + return $this->createEntity($row); } /** + * @param array<string, mixed>|Where $where * @param array<string, string>|string $orderBy - * @return array<string, mixed>[] + * @return array<AbstractEntity>|array<array-key, AbstractEntity> */ - protected function findAllCachedInternal( - string $whereString = '', - array $whereParams = [], + protected function _findAllCached( + array|Where $where = [], array|string $orderBy = [], ?int $limit = null, null|int|\DateInterval $ttl = null, + ?string $keyColumnName = null, ): array { - return $this->getCached( - $this->getListCacheKey($whereString, $whereParams, $orderBy, $limit), - fn() => $this->findAllInternal(whereString: $whereString, whereParams: $whereParams, orderBy: $orderBy, limit: $limit), + $rows = $this->getCached( + $this->getListCacheKey($where, $orderBy, $limit), + fn() => $this->_findAllRaw(where: $where, orderBy: $orderBy, limit: $limit), $ttl, ); + return $this->createEntities($rows, $keyColumnName); } /** - * @param array<string, mixed> $whereParams + * @param array<string, mixed>|Where $where */ - protected function countAllCachedInternal( - string $whereString = '', - array $whereParams = [], + protected function _countByAllCached( + array|Where $where = [], null|int|\DateInterval $ttl = null, ): int { return (int)$this->getCached( - $this->getCountCacheKey($whereString, $whereParams), - fn() => $this->countAllInternal(whereString: $whereString, whereParams: $whereParams), + $this->getCountCacheKey($where), + fn() => $this->_countAll(where: $where), $ttl, ); } @@ -175,7 +165,17 @@ protected function getCached(string $cacheKey, callable $dataCallback, null|int| return $data; } - protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $ttl = null): array + /** + * @param mixed[] $ids + * @param int|\DateInterval|null $ttl + * @return array<AbstractEntity>|array<array-key, AbstractEntity> + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + protected function _findMultiCached( + array $ids, + null|int|\DateInterval $ttl = null, + ?string $keyColumnName = null, + ): array { $result = $cacheKeys = $foundIds = []; foreach ($ids as $id) { @@ -184,22 +184,27 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t } $cache = $this->cache->getMultiple(array_keys($cacheKeys)); foreach ($cache as $cacheKey => $cachedRow) { - $result[] = $cachedRow; - if (empty($cacheKeys[$cacheKey])) { + if ($cachedRow === null) { continue; } - $foundIds[] = $cacheKeys[$cacheKey]; + if (isset($cacheKeys[$cacheKey])) { + $result[] = $cachedRow; + $foundIds[] = $cacheKeys[$cacheKey]; + } } $ids = array_diff($ids, $foundIds); foreach ($ids as $id) { - if ($row = $this->findOneCachedInternal($id, $ttl)) { + if ($row = $this->_findByPkCached($id, $ttl)) { $result[] = $row; } } - return $result; + return $this->createEntities($result, $keyColumnName); } - protected function getOneCacheKey(string|int|array|AbstractEntity $keyOrEntity): string + /** + * @param string|int|array<string, mixed>|AbstractEntity $keyOrEntity + */ + protected function getOneCacheKey(string|int|array|AbstractEntity|UuidInterface $keyOrEntity): string { if (!is_array($keyOrEntity)) { $condition = $this->getPkCondition($keyOrEntity); @@ -209,31 +214,36 @@ protected function getOneCacheKey(string|int|array|AbstractEntity $keyOrEntity): return $this->buildCacheKey('o', $condition ?: 'one'); } + /** + * @param array<string, mixed>|Where $where + * @param array<string, string>|string $orderBy + */ protected function getListCacheKey( - string $whereString = '', - array $whereParams = [], + array|Where $where = [], array|string $orderBy = [], ?int $limit = null ): string { - $wherePart = $this->prepareWhereKey($whereString, $whereParams); + $wherePart = is_array($where) ? $where : $this->prepareWhereKey($where); return $this->buildCacheKey( 'l', - $wherePart ?? 'all', + $wherePart ?: 'all', $orderBy ? ['ob' => $orderBy] : null, $limit ? ['limit' => $limit] : null, ); } + /** + * @param array<string, mixed>|Where $where + */ protected function getCountCacheKey( - string $whereString = '', - array $whereParams = [], + array|Where $where = [], ): string { - $wherePart = $this->prepareWhereKey($whereString, $whereParams); + $wherePart = is_array($where) ? $where : $this->prepareWhereKey($where); return $this->buildCacheKey( 'c', - $wherePart ?? 'all', + $wherePart ?: 'all', ); } @@ -244,7 +254,7 @@ protected function buildCacheKey(mixed ...$parts): string $formattedParts = []; foreach ($parts as $part) { if (is_array($part)) { - $string = json_encode($part); + $string = json_encode($part, JSON_THROW_ON_ERROR); } else { $string = strval($part); } @@ -268,24 +278,18 @@ protected function buildCacheKey(mixed ...$parts): string private function formatStringForCacheKey(string $string): string { - $string = mb_strtolower($string); + $string = strtolower($string); $string = str_replace(['!=', '<>', '>', '<', '='], ['_not_', '_not_', '_gt_', '_lt_', '_eq_'], $string); - $string = preg_replace('/\W/', '_', $string); - return trim(preg_replace('/_+/', '_', $string), '_'); + $string = (string)preg_replace('/\W/', '_', $string); + return trim((string)preg_replace('/_+/', '_', $string), '_'); } - private function prepareWhereKey(string $whereString, array $whereParams): ?string + private function prepareWhereKey(Where $where): string { - if (!$whereString) { - return null; - } - if (!$whereParams) { - return $whereString; - } return str_replace( - array_map(fn (string $key): string => ':' . $key, array_keys($whereParams)), - array_values($whereParams), - $whereString, + array_map(fn (string $key): string => ':' . $key, array_keys($where->params)), + array_values($where->params), + $where->condition, ); } } diff --git a/src/AbstractTable.php b/src/AbstractTable.php index 4d465f1..e144cd3 100644 --- a/src/AbstractTable.php +++ b/src/AbstractTable.php @@ -2,16 +2,21 @@ namespace Composite\DB; -use Composite\Entity\AbstractEntity; use Composite\DB\Exceptions\DbException; -use Composite\Entity\Exceptions\EntityException; +use Composite\DB\MultiQuery\MultiInsert; +use Composite\DB\MultiQuery\MultiSelect; +use Composite\Entity\AbstractEntity; +use Composite\Entity\Helpers\DateTimeHelper; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Query\QueryBuilder; +use Ramsey\Uuid\UuidInterface; abstract class AbstractTable { + use Helpers\SelectRawTrait; + use Helpers\DatabaseSpecificTrait; + protected readonly TableConfig $config; - private ?QueryBuilder $selectQuery = null; + abstract protected function getConfig(): TableConfig; @@ -40,167 +45,223 @@ public function getConnectionName(): string * @return void * @throws \Throwable */ - public function save(AbstractEntity &$entity): void + public function save(AbstractEntity $entity): void { $this->config->checkEntity($entity); if ($entity->isNew()) { $connection = $this->getConnection(); - $insertData = $entity->toArray(); + $this->checkUpdatedAt($entity); + + $insertData = $this->prepareDataForSql($entity->toArray()); $this->getConnection()->insert($this->getTableName(), $insertData); - if ($this->config->autoIncrementKey) { - $insertData[$this->config->autoIncrementKey] = intval($connection->lastInsertId()); - $entity = $entity::fromArray($insertData); - } else { - $entity->resetChangedColumns(); + if ($this->config->autoIncrementKey && ($lastInsertedId = $connection->lastInsertId())) { + $insertData[$this->config->autoIncrementKey] = intval($lastInsertedId); + $entity::schema() + ->getColumn($this->config->autoIncrementKey) + ->setValue($entity, $insertData[$this->config->autoIncrementKey]); } + $entity->resetChangedColumns($insertData); } else { if (!$changedColumns = $entity->getChangedColumns()) { return; } - $connection = $this->getConnection(); - $where = $this->getPkCondition($entity); - $this->enrichCondition($where); + $changedColumns = $this->prepareDataForSql($changedColumns); + if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at')) { + $entity->updated_at = new \DateTimeImmutable(); + $changedColumns['updated_at'] = DateTimeHelper::dateTimeToString($entity->updated_at); + } + $whereParams = $this->getPkCondition($entity); + if ($this->config->hasOptimisticLock() + && method_exists($entity, 'getVersion') + && method_exists($entity, 'incrementVersion')) { + $whereParams['lock_version'] = $entity->getVersion(); + $entity->incrementVersion(); + $changedColumns['lock_version'] = $entity->getVersion(); + } + $updateString = implode(', ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($changedColumns))); + $whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams))); - if ($this->config->isOptimisticLock && isset($entity->version)) { - $currentVersion = $entity->version; - try { - $connection->beginTransaction(); - $connection->update( - $this->getTableName(), - $changedColumns, - $where - ); - $versionUpdated = $connection->update( - $this->getTableName(), - ['version' => $currentVersion + 1], - $where + ['version' => $currentVersion] - ); - if (!$versionUpdated) { - throw new DbException('Failed to update entity version, concurrency modification, rolling back.'); - } - $connection->commit(); - } catch (\Throwable $e) { - $connection->rollBack(); - throw $e; - } - } else { - $connection->update( - $this->getTableName(), - $changedColumns, - $where - ); + $entityUpdated = (bool)$this->getConnection()->executeStatement( + sql: "UPDATE " . $this->escapeIdentifier($this->getTableName()) . " SET $updateString WHERE $whereString;", + params: array_merge(array_values($changedColumns), array_values($whereParams)), + ); + if ($this->config->hasOptimisticLock() && !$entityUpdated) { + throw new Exceptions\LockException('Failed to update entity version, concurrency modification, rolling back.'); } - $entity->resetChangedColumns(); + $entity->resetChangedColumns($changedColumns); } } /** * @param AbstractEntity[] $entities - * @return AbstractEntity[] $entities * @throws \Throwable */ - public function saveMany(array $entities): array + public function saveMany(array $entities): void { - return $this->getConnection()->transactional(function() use ($entities) { + $rowsToInsert = []; + foreach ($entities as $i => $entity) { + if ($entity->isNew()) { + $this->config->checkEntity($entity); + $this->checkUpdatedAt($entity); + $rowsToInsert[] = $this->prepareDataForSql($entity->toArray()); + unset($entities[$i]); + } + } + $connection = $this->getConnection(); + $connection->beginTransaction(); + try { foreach ($entities as $entity) { $this->save($entity); } - return $entities; - }); + if ($rowsToInsert) { + $chunks = array_chunk($rowsToInsert, 1000); + $connection = $this->getConnection(); + foreach ($chunks as $chunk) { + $multiInsert = new MultiInsert( + connection: $connection, + tableName: $this->getTableName(), + rows: $chunk, + ); + if ($multiInsert->getSql()) { + $connection->executeStatement($multiInsert->getSql(), $multiInsert->getParameters()); + } + } + } + $connection->commit(); + } catch (\Throwable $e) { + $connection->rollBack(); + throw $e; + } } /** - * @throws EntityException + * @param AbstractEntity $entity + * @throws \Throwable */ - public function delete(AbstractEntity &$entity): void + public function delete(AbstractEntity $entity): void { $this->config->checkEntity($entity); - if ($this->config->isSoftDelete) { + if ($this->config->hasSoftDelete()) { if (method_exists($entity, 'delete')) { $entity->delete(); $this->save($entity); } } else { - $where = $this->getPkCondition($entity); - $this->enrichCondition($where); - $this->getConnection()->delete($this->getTableName(), $where); + $whereParams = $this->getPkCondition($entity); + $whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams))); + $this->getConnection()->executeQuery( + sql: "DELETE FROM " . $this->escapeIdentifier($this->getTableName()) . " WHERE $whereString;", + params: array_values($whereParams), + ); } } /** * @param AbstractEntity[] $entities + * @throws \Throwable */ - public function deleteMany(array $entities): bool + public function deleteMany(array $entities): void { - return $this->getConnection()->transactional(function() use ($entities) { + $connection = $this->getConnection(); + $connection->beginTransaction(); + try { foreach ($entities as $entity) { $this->delete($entity); } - return true; - }); + $connection->commit(); + } catch (\Throwable $e) { + $connection->rollBack(); + throw $e; + } } - protected function countAllInternal(string $whereString = '', array $whereParams = []): int + /** + * @param array<string, mixed>|Where $where + * @throws \Doctrine\DBAL\Exception + */ + protected function _countAll(array|Where $where = []): int { $query = $this->select('COUNT(*)'); - if ($whereString) { - $query->where($whereString); - foreach ($whereParams as $param => $value) { + if (is_array($where)) { + $this->buildWhere($query, $where); + } else { + $query->where($where->condition); + foreach ($where->params as $param => $value) { $query->setParameter($param, $value); } } - $this->enrichCondition($query); return intval($query->executeQuery()->fetchOne()); } - protected function findByPkInternal(mixed $pk): ?array + /** + * @throws \Doctrine\DBAL\Exception + * @return AbstractEntity|null + */ + protected function _findByPk(mixed $pk): mixed { - $where = $this->getPkCondition($pk); - return $this->findOneInternal($where); + $whereParams = $this->getPkCondition($pk); + $whereString = implode(' AND ', array_map(fn ($key) => $this->escapeIdentifier($key) . "=?", array_keys($whereParams))); + $row = $this->getConnection() + ->executeQuery( + sql: "SELECT * FROM " . $this->escapeIdentifier($this->getTableName()) . " WHERE $whereString;", + params: array_values($whereParams), + ) + ->fetchAssociative(); + return $this->createEntity($row); } - protected function findOneInternal(array $where): ?array + /** + * @param array<string, mixed>|Where $where + * @param array<string, string>|string $orderBy + * @return AbstractEntity|null + * @throws \Doctrine\DBAL\Exception + */ + protected function _findOne(array|Where $where, array|string $orderBy = []): mixed + { + return $this->createEntity($this->_findOneRaw($where, $orderBy)); + } + + /** + * @param array<int|string|array<string,mixed>> $pkList + * @return array<AbstractEntity>| array<array-key, AbstractEntity> + * @throws DbException + * @throws \Doctrine\DBAL\Exception + */ + protected function _findMulti(array $pkList, ?string $keyColumnName = null): array { - $query = $this->select(); - $this->enrichCondition($where); - $this->buildWhere($query, $where); - return $query->fetchAssociative() ?: null; + if (!$pkList) { + return []; + } + $multiSelect = new MultiSelect($this->getConnection(), $this->config, $pkList); + return $this->createEntities( + $multiSelect->getQueryBuilder()->executeQuery()->fetchAllAssociative(), + $keyColumnName, + ); } - protected function findAllInternal( - string $whereString = '', - array $whereParams = [], + /** + * @param array<string, mixed>|Where $where + * @param array<string, string>|string $orderBy + * @return array<AbstractEntity>| array<array-key, AbstractEntity> + */ + protected function _findAll( + array|Where $where = [], array|string $orderBy = [], ?int $limit = null, ?int $offset = null, + ?string $keyColumnName = null, ): array { - $query = $this->select(); - if ($whereString) { - $query->where($whereString); - foreach ($whereParams as $param => $value) { - $query->setParameter($param, $value); - } - } - $this->enrichCondition($query); - - if ($orderBy) { - if (is_array($orderBy)) { - foreach ($orderBy as $column => $direction) { - $query->addOrderBy($column, $direction); - } - } else { - $query->orderBy($orderBy); - } - } - if ($limit > 0) { - $query->setMaxResults($limit); - } - if ($offset > 0) { - $query->setFirstResult($offset); - } - return $query->executeQuery()->fetchAllAssociative(); + return $this->createEntities( + data: $this->_findAllRaw( + where: $where, + orderBy: $orderBy, + limit: $limit, + offset: $offset, + ), + keyColumnName: $keyColumnName, + ); } final protected function createEntity(mixed $data): mixed @@ -209,7 +270,7 @@ final protected function createEntity(mixed $data): mixed return null; } try { - /** @psalm-var class-string<AbstractEntity> $entityClass */ + /** @var class-string<AbstractEntity> $entityClass */ $entityClass = $this->config->entityClass; return $entityClass::fromArray($data); } catch (\Throwable) { @@ -217,7 +278,10 @@ final protected function createEntity(mixed $data): mixed } } - final protected function createEntities(mixed $data): array + /** + * @return AbstractEntity[] + */ + final protected function createEntities(mixed $data, ?string $keyColumnName = null): array { if (!is_array($data)) { return []; @@ -227,10 +291,19 @@ final protected function createEntities(mixed $data): array $entityClass = $this->config->entityClass; $result = []; foreach ($data as $datum) { - if (!is_array($datum)) { - continue; + if (is_array($datum)) { + if ($keyColumnName && isset($datum[$keyColumnName])) { + $result[$datum[$keyColumnName]] = $entityClass::fromArray($datum); + } else { + $result[] = $entityClass::fromArray($datum); + } + } elseif ($datum instanceof $this->config->entityClass) { + if ($keyColumnName && property_exists($datum, $keyColumnName)) { + $result[$datum->{$keyColumnName}] = $datum; + } else { + $result[] = $datum; + } } - $result[] = $entityClass::fromArray($datum); } } catch (\Throwable) { return []; @@ -238,11 +311,22 @@ final protected function createEntities(mixed $data): array return $result; } - protected function getPkCondition(int|string|array|AbstractEntity $data): array + /** + * @param int|string|array<string, mixed>|AbstractEntity $data + * @return array<string, mixed> + */ + protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface $data): array { $condition = []; if ($data instanceof AbstractEntity) { - $data = $data->toArray(); + if ($data->isNew()) { + $data = $data->toArray(); + } else { + foreach ($this->config->primaryKeys as $key) { + $condition[$key] = $data->getOldValue($key); + } + return $condition; + } } if (is_array($data)) { foreach ($this->config->primaryKeys as $key) { @@ -256,36 +340,10 @@ protected function getPkCondition(int|string|array|AbstractEntity $data): array return $condition; } - protected function enrichCondition(array|QueryBuilder &$query): void + private function checkUpdatedAt(AbstractEntity $entity): void { - if ($this->config->isSoftDelete) { - if ($query instanceof QueryBuilder) { - $query->andWhere('deleted_at IS NULL'); - } else { - if (!isset($query['deleted_at'])) { - $query['deleted_at'] = null; - } - } - } - } - - protected function select(string $select = '*'): QueryBuilder - { - if ($this->selectQuery === null) { - $this->selectQuery = $this->getConnection()->createQueryBuilder()->from($this->getTableName()); - } - return (clone $this->selectQuery)->select($select); - } - - private function buildWhere(QueryBuilder $query, array $where): void - { - foreach ($where as $column => $value) { - if ($value === null) { - $query->andWhere("$column IS NULL"); - } else { - $query->andWhere("$column = :" . $column); - $query->setParameter($column, $value); - } + if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at') && $entity->updated_at === null) { + $entity->updated_at = new \DateTimeImmutable(); } } } diff --git a/src/Attributes/Column.php b/src/Attributes/Column.php deleted file mode 100644 index 11ac3ca..0000000 --- a/src/Attributes/Column.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Attributes; - -#[\Attribute] -class Column -{ - public function __construct( - public readonly string|int|float|bool|null $default = null, - public readonly ?int $size = null, - public readonly ?int $precision = null, - public readonly ?int $scale = null, - public readonly ?bool $unsigned = null, - ) {} -} diff --git a/src/Attributes/Index.php b/src/Attributes/Index.php deleted file mode 100644 index b16b850..0000000 --- a/src/Attributes/Index.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Attributes; - -#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS)] -class Index -{ - public function __construct( - public readonly array $columns, - public readonly bool $isUnique = false, - public readonly ?string $name = null, - ) {} -} \ No newline at end of file diff --git a/src/Attributes/PrimaryKey.php b/src/Attributes/PrimaryKey.php index a9540bc..d8db396 100644 --- a/src/Attributes/PrimaryKey.php +++ b/src/Attributes/PrimaryKey.php @@ -2,7 +2,7 @@ namespace Composite\DB\Attributes; -#[\Attribute] +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)] class PrimaryKey { public function __construct( diff --git a/src/Attributes/Strict.php b/src/Attributes/Strict.php deleted file mode 100644 index db186b4..0000000 --- a/src/Attributes/Strict.php +++ /dev/null @@ -1,6 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Attributes; - -#[\Attribute] -class Strict {} \ No newline at end of file diff --git a/src/Attributes/Table.php b/src/Attributes/Table.php index 8f09858..3c8eefc 100644 --- a/src/Attributes/Table.php +++ b/src/Attributes/Table.php @@ -2,7 +2,7 @@ namespace Composite\DB\Attributes; -#[\Attribute] +#[\Attribute(\Attribute::TARGET_CLASS)] class Table { public function __construct( diff --git a/src/CombinedTransaction.php b/src/CombinedTransaction.php index e5f2671..bb5a590 100644 --- a/src/CombinedTransaction.php +++ b/src/CombinedTransaction.php @@ -19,18 +19,29 @@ class CombinedTransaction */ public function save(AbstractTable $table, AbstractEntity &$entity): void { - try { - $connectionName = $table->getConnectionName(); - if (empty($this->transactions[$connectionName])) { - $connection = ConnectionManager::getConnection($connectionName); - $connection->beginTransaction(); - $this->transactions[$connectionName] = $connection; - } + $this->doInTransaction($table, function () use ($table, &$entity) { $table->save($entity); - } catch (\Throwable $e) { - $this->rollback(); - throw new Exceptions\DbException($e->getMessage(), 500, $e); - } + }); + } + + /** + * @param AbstractTable $table + * @param AbstractEntity[] $entities + * @throws DbException + */ + public function saveMany(AbstractTable $table, array $entities): void + { + $this->doInTransaction($table, fn () => $table->saveMany($entities)); + } + + /** + * @param AbstractTable $table + * @param AbstractEntity[] $entities + * @throws DbException + */ + public function deleteMany(AbstractTable $table, array $entities): void + { + $this->doInTransaction($table, fn () => $table->deleteMany($entities)); } /** @@ -38,14 +49,18 @@ public function save(AbstractTable $table, AbstractEntity &$entity): void */ public function delete(AbstractTable $table, AbstractEntity &$entity): void { - try { - $connectionName = $table->getConnectionName(); - if (empty($this->transactions[$connectionName])) { - $connection = ConnectionManager::getConnection($connectionName); - $connection->beginTransaction(); - $this->transactions[$connectionName] = $connection; - } + $this->doInTransaction($table, function () use ($table, &$entity) { $table->delete($entity); + }); + } + + /** + * @throws Exceptions\DbException + */ + public function try(callable $callback): void + { + try { + $callback(); } catch (\Throwable $e) { $this->rollback(); throw new Exceptions\DbException($e->getMessage(), 500, $e); @@ -70,34 +85,32 @@ public function commit(): void if (!$connection->commit()) { throw new Exceptions\DbException("Could not commit transaction for database `$connectionName`"); } + // I have no idea how to simulate failed commit + // @codeCoverageIgnoreStart } catch (\Throwable $e) { $this->rollback(); throw new Exceptions\DbException($e->getMessage(), 500, $e); } + // @codeCoverageIgnoreEnd } $this->finish(); } /** * Pessimistic lock + * @param string[] $keyParts * @throws DbException + * @throws InvalidArgumentException */ public function lock(CacheInterface $cache, array $keyParts, int $duration = 10): void { $this->cache = $cache; - $this->lockKey = implode('.', array_merge(['composite', 'lock'], $keyParts)); - if (strlen($this->lockKey) > 64) { - $this->lockKey = sha1($this->lockKey); + $this->lockKey = $this->buildLockKey($keyParts); + if ($this->cache->get($this->lockKey)) { + throw new DbException("Failed to get lock `{$this->lockKey}`"); } - try { - if ($this->cache->get($this->lockKey)) { - throw new DbException("Failed to get lock `{$this->lockKey}`"); - } - if (!$this->cache->set($this->lockKey, 1, $duration)) { - throw new DbException("Failed to save lock `{$this->lockKey}`"); - } - } catch (InvalidArgumentException) { - throw new DbException("Lock key is invalid `{$this->lockKey}`"); + if (!$this->cache->set($this->lockKey, 1, $duration)) { + throw new DbException("Failed to save lock `{$this->lockKey}`"); } } @@ -106,9 +119,37 @@ public function releaseLock(): void if (!$this->cache || !$this->lockKey) { return; } + $this->cache->delete($this->lockKey); + } + + private function doInTransaction(AbstractTable $table, callable $callback): void + { try { - $this->cache->delete($this->lockKey); - } catch (InvalidArgumentException) {} + $connectionName = $table->getConnectionName(); + if (empty($this->transactions[$connectionName])) { + $connection = ConnectionManager::getConnection($connectionName); + $connection->beginTransaction(); + $this->transactions[$connectionName] = $connection; + } + $callback(); + } catch (\Throwable $e) { + $this->rollback(); + throw new Exceptions\DbException($e->getMessage(), 500, $e); + } + } + + /** + * @param string[] $keyParts + * @return string + */ + private function buildLockKey(array $keyParts): string + { + $keyParts = array_merge(['composite', 'lock'], $keyParts); + $result = implode('.', $keyParts); + if (strlen($result) > 64) { + $result = sha1($result); + } + return $result; } private function finish(): void diff --git a/src/Commands/CommandHelperTrait.php b/src/Commands/CommandHelperTrait.php deleted file mode 100644 index 9c37488..0000000 --- a/src/Commands/CommandHelperTrait.php +++ /dev/null @@ -1,108 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Commands; - -use Composer\Autoload\ClassLoader; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\QuestionHelper; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Question\Question; - -trait CommandHelperTrait -{ - private function showSuccess(OutputInterface $output, string $text): int - { - $output->writeln("<fg=green>$text</fg=green>"); - return Command::SUCCESS; - } - - private function showAlert(OutputInterface $output, string $text): int - { - $output->writeln("<fg=yellow>$text</fg=yellow>"); - return Command::SUCCESS; - } - - private function showError(OutputInterface $output, string $text): int - { - $output->writeln("<fg=red>$text</fg=red>"); - return Command::INVALID; - } - - protected function ask(InputInterface $input, OutputInterface $output, Question $question): mixed - { - return (new QuestionHelper())->ask($input, $output, $question); - } - - private function saveClassToFile(InputInterface $input, OutputInterface $output, string $class, string $content): bool - { - if (!$filePath = $this->getClassFilePath($class)) { - return false; - } - $fileState = 'new'; - if (file_exists($filePath)) { - $fileState = 'overwrite'; - if (!$input->getOption('force') - && !$this->ask($input, $output, new ConfirmationQuestion("File `$filePath` is already exists, do you want to overwrite it?[y/n]: "))) { - return true; - } - } - if (file_put_contents($filePath, $content)) { - $this->showSuccess($output, "File `$filePath` was successfully generated ($fileState)"); - return true; - } else { - $this->showError($output, "Something went wrong can `$filePath` was successfully generated ($fileState)"); - return false; - } - } - - protected function getClassFilePath(string $class): ?string - { - $class = trim($class, '\\'); - $namespaceParts = explode('\\', $class); - - $loaders = ClassLoader::getRegisteredLoaders(); - $matchedPrefixes = $matchedDirs = []; - foreach ($loaders as $loader) { - foreach ($loader->getPrefixesPsr4() as $prefix => $dir) { - $prefixParts = explode('\\', trim($prefix, '\\')); - foreach ($namespaceParts as $i => $namespacePart) { - if (!isset($prefixParts[$i]) || $prefixParts[$i] !== $namespacePart) { - break; - } - if (!isset($matchedPrefixes[$prefix])) { - $matchedPrefixes[$prefix] = 0; - $matchedDirs[$prefix] = $dir; - } - $matchedPrefixes[$prefix] += 1; - } - } - } - if (empty($matchedPrefixes)) { - throw new \Exception("Failed to determine directory for class `$class` from psr4 autoloading"); - } - arsort($matchedPrefixes); - $prefix = key($matchedPrefixes); - $dirs = $matchedDirs[$prefix]; - - $namespaceParts = explode('\\', str_replace($prefix, '', $class)); - $filename = array_pop($namespaceParts) . '.php'; - - $relativeDir = implode( - DIRECTORY_SEPARATOR, - array_merge( - $dirs, - $namespaceParts, - ) - ); - if (!$realDir = realpath($relativeDir)) { - $dirCreateResult = mkdir($relativeDir, 0755, true); - if (!$dirCreateResult) { - throw new \Exception("Directory `$relativeDir` not exists and failed to create it, please create it manually."); - } - $realDir = realpath($relativeDir); - } - return $realDir . DIRECTORY_SEPARATOR . $filename; - } -} diff --git a/src/Commands/GenerateEntityCommand.php b/src/Commands/GenerateEntityCommand.php deleted file mode 100644 index 16c3cdd..0000000 --- a/src/Commands/GenerateEntityCommand.php +++ /dev/null @@ -1,90 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Commands; - -use Composite\DB\ConnectionManager; -use Composite\DB\Generator\EntityClassBuilder; -use Composite\DB\Generator\EnumClassBuilder; -use Composite\DB\Generator\Schema\SQLEnum; -use Composite\DB\Generator\Schema\SQLSchema; -use Composite\DB\Helpers\ClassHelper; -use Doctrine\Inflector\Rules\English\InflectorFactory; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\ConfirmationQuestion; -use Symfony\Component\Console\Question\Question; - -class GenerateEntityCommand extends Command -{ - use CommandHelperTrait; - - protected static $defaultName = 'composite-db:generate-entity'; - - protected function configure(): void - { - $this - ->addArgument('connection', InputArgument::REQUIRED, 'Connection name') - ->addArgument('table', InputArgument::REQUIRED, 'Table name') - ->addArgument('entity', InputArgument::OPTIONAL, 'Entity full class name') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'If existing file should be overwritten'); - } - - /** - * @throws \Exception - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - $connectionName = $input->getArgument('connection'); - $tableName = $input->getArgument('table'); - $connection = ConnectionManager::getConnection($connectionName); - - if (!$entityClass = $input->getArgument('entity')) { - $entityClass = $this->ask($input, $output, new Question('Enter entity full class name: ')); - } - $entityClass = str_replace('\\\\', '\\', $entityClass); - - $schema = SQLSchema::generate($connection, $tableName); - $enums = []; - foreach ($schema->enums as $columnName => $sqlEnum) { - if ($enumClass = $this->generateEnum($input, $output, $entityClass, $sqlEnum)) { - $enums[$columnName] = $enumClass; - } - } - $entityBuilder = new EntityClassBuilder($schema, $connectionName, $entityClass, $enums); - $content = $entityBuilder->getClassContent(); - - $this->saveClassToFile($input, $output, $entityClass, $content); - return Command::SUCCESS; - } - - private function generateEnum(InputInterface $input, OutputInterface $output, string $entityClass, SQLEnum $enum): ?string - { - $name = $enum->name; - $values = $enum->values; - $this->showAlert($output, "Found enum `$name` with values [" . implode(', ', $values) . "]"); - if (!$this->ask($input, $output, new ConfirmationQuestion('Do you want to generate Enum class?[y/n]: '))) { - return null; - } - $enumShortClassName = ucfirst((new InflectorFactory())->build()->camelize($name)); - $entityNamespace = ClassHelper::extractNamespace($entityClass); - $proposedClass = $entityNamespace . '\\Enums\\' . $enumShortClassName; - $enumClass = $this->ask( - $input, - $output, - new Question("Enter enum full class name [skip to use $proposedClass]: ") - ); - if (!$enumClass) { - $enumClass = $proposedClass; - } - $enumClassBuilder = new EnumClassBuilder($enumClass, $values); - - $content = $enumClassBuilder->getClassContent(); - if (!$this->saveClassToFile($input, $output, $enumClass, $content)) { - return null; - } - return $enumClass; - } -} \ No newline at end of file diff --git a/src/Commands/GenerateTableCommand.php b/src/Commands/GenerateTableCommand.php deleted file mode 100644 index fa14cbb..0000000 --- a/src/Commands/GenerateTableCommand.php +++ /dev/null @@ -1,95 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Commands; - -use Composite\DB\Attributes; -use Composite\DB\Generator\CachedTableClassBuilder; -use Composite\DB\Generator\TableClassBuilder; -use Composite\DB\TableConfig; -use Composite\Entity\AbstractEntity; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Question\Question; - -class GenerateTableCommand extends Command -{ - use CommandHelperTrait; - - protected static $defaultName = 'composite-db:generate-table'; - - protected function configure(): void - { - $this - ->addArgument('entity', InputArgument::REQUIRED, 'Entity full class name') - ->addArgument('table', InputArgument::OPTIONAL, 'Table full class name') - ->addOption('cached', 'c', InputOption::VALUE_NONE, 'Generate cache version') - ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite existing table class file'); - } - - /** - * @throws \Exception - */ - protected function execute(InputInterface $input, OutputInterface $output): int - { - /** @var class-string<AbstractEntity> $entityClass */ - $entityClass = $input->getArgument('entity'); - $reflection = new \ReflectionClass($entityClass); - - if (!$reflection->isSubclassOf(AbstractEntity::class)) { - return $this->showError($output, "Class `$entityClass` must be subclass of " . AbstractEntity::class); - } - $schema = $entityClass::schema(); - $tableConfig = TableConfig::fromEntitySchema($schema); - $tableName = $tableConfig->tableName; - - if (!$tableClass = $input->getArgument('table')) { - $proposedClass = preg_replace('/\w+$/', 'Tables', $reflection->getNamespaceName()) . "\\{$tableName}Table"; - $tableClass = $this->ask( - $input, - $output, - new Question("Enter table full class name [skip to use $proposedClass]: ") - ); - if (!$tableClass) { - $tableClass = $proposedClass; - } - } - if (str_starts_with($tableClass, '\\')) { - $tableClass = substr($tableClass, 1); - } - - if (!preg_match('/^(.+)\\\(\w+)$/', $tableClass)) { - return $this->showError($output, "Table class `$tableClass` is incorrect"); - } - if ($input->getOption('cached')) { - $template = new CachedTableClassBuilder( - tableClass: $tableClass, - schema: $schema, - tableConfig: $tableConfig, - ); - } else { - $template = new TableClassBuilder( - tableClass: $tableClass, - schema: $schema, - tableConfig: $tableConfig, - ); - } - $template->generate(); - $fileContent = $template->getFileContent(); - - $fileState = 'new'; - if (!$filePath = $this->getClassFilePath($tableClass)) { - return Command::FAILURE; - } - if (file_exists($filePath)) { - if (!$input->getOption('force')) { - return $this->showError($output, "File `$filePath` already exists, use --force flag to overwrite it"); - } - $fileState = 'overwrite'; - } - file_put_contents($filePath, $fileContent); - return $this->showSuccess($output, "File `$filePath` was successfully generated ($fileState)"); - } -} diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php index 5ff27db..7854ed6 100644 --- a/src/ConnectionManager.php +++ b/src/ConnectionManager.php @@ -8,11 +8,14 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; class ConnectionManager { private const CONNECTIONS_CONFIG_ENV_VAR = 'CONNECTIONS_CONFIG_FILE'; + /** @var array<string, array<string, mixed>>|null */ private static ?array $configs = null; + /** @var array<string, Connection> */ private static array $connections = []; /** @@ -22,6 +25,12 @@ public static function getConnection(string $name, ?Configuration $config = null { if (!isset(self::$connections[$name])) { try { + if (!$config) { + $config = new Configuration(); + } + if (!$config->getSchemaManagerFactory()) { + $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); + } self::$connections[$name] = DriverManager::getConnection( params: self::getConnectionParams($name), config: $config, @@ -35,50 +44,53 @@ public static function getConnection(string $name, ?Configuration $config = null } /** + * @return array<string, mixed> * @throws DbException */ private static function getConnectionParams(string $name): array { if (self::$configs === null) { - $configFile = getenv(self::CONNECTIONS_CONFIG_ENV_VAR, true); - if (empty($configFile)) { - throw new DbException(sprintf( - 'ConnectionManager is not configured, please call ConnectionManager::configure() method or setup putenv(\'%s=/path/to/config/file.php\') variable', - self::CONNECTIONS_CONFIG_ENV_VAR - )); - } - if (!file_exists($configFile)) { - throw new DbException(sprintf( - 'Connections config file `%s` does not exist', - $configFile - )); - } - $configContent = require_once $configFile; - if (empty($configContent) || !is_array($configContent)) { - throw new DbException(sprintf( - 'Connections config file `%s` should return array of connection params', - $configFile - )); - } - self::configure($configContent); + self::$configs = self::loadConfigs(); } return self::$configs[$name] ?? throw new DbException("Connection config `$name` not found"); } /** + * @return array<string, array<string, mixed>> * @throws DbException */ - private static function configure(array $configs): void + private static function loadConfigs(): array { - foreach ($configs as $name => $connectionConfig) { + $configFile = getenv(self::CONNECTIONS_CONFIG_ENV_VAR, true) ?: ($_ENV[self::CONNECTIONS_CONFIG_ENV_VAR] ?? false); + if (empty($configFile)) { + throw new DbException(sprintf( + 'ConnectionManager is not configured, please define ENV variable `%s`', + self::CONNECTIONS_CONFIG_ENV_VAR + )); + } + if (!file_exists($configFile)) { + throw new DbException(sprintf( + 'Connections config file `%s` does not exist', + $configFile + )); + } + $configFileContent = require $configFile; + if (empty($configFileContent) || !is_array($configFileContent)) { + throw new DbException(sprintf( + 'Connections config file `%s` should return array of connection params', + $configFile + )); + } + $result = []; + foreach ($configFileContent as $name => $connectionConfig) { if (empty($name) || !is_string($name)) { throw new DbException('Config has invalid connection name ' . var_export($name, true)); } if (empty($connectionConfig) || !is_array($connectionConfig)) { throw new DbException("Connection `$name` has invalid connection params"); } - self::$configs[$name] = $connectionConfig; + $result[$name] = $connectionConfig; } - self::$configs = $configs; + return $result; } } \ No newline at end of file diff --git a/src/Exceptions/LockException.php b/src/Exceptions/LockException.php new file mode 100644 index 0000000..650194d --- /dev/null +++ b/src/Exceptions/LockException.php @@ -0,0 +1,7 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Exceptions; + +class LockException extends DbException +{ +} \ No newline at end of file diff --git a/src/Generator/AbstractTableClassBuilder.php b/src/Generator/AbstractTableClassBuilder.php deleted file mode 100644 index f1e7063..0000000 --- a/src/Generator/AbstractTableClassBuilder.php +++ /dev/null @@ -1,68 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator; - -use Composite\DB\Helpers\ClassHelper; -use Composite\DB\TableConfig; -use Composite\Entity\Columns\AbstractColumn; -use Composite\Entity\Schema; -use Doctrine\Inflector\Rules\English\InflectorFactory; -use Nette\PhpGenerator\Method; -use Nette\PhpGenerator\PhpFile; - -abstract class AbstractTableClassBuilder -{ - protected readonly PhpFile $file; - protected readonly string $entityClassShortName; - - public function __construct( - protected readonly string $tableClass, - protected readonly Schema $schema, - protected readonly TableConfig $tableConfig, - ) - { - $this->entityClassShortName = ClassHelper::extractShortName($this->schema->class); - $this->file = new PhpFile(); - } - - abstract public function getParentNamespace(): string; - abstract public function generate(): void; - - final public function getFileContent(): string - { - return (string)$this->file; - } - - protected function generateGetConfig(): Method - { - return (new Method('getConfig')) - ->setProtected() - ->setReturnType(TableConfig::class) - ->setBody('return TableConfig::fromEntitySchema(' . $this->entityClassShortName . '::schema());'); - } - - protected function buildVarsList(array $vars): string - { - if (count($vars) === 1) { - $var = current($vars); - return '$' . $var; - } - $vars = array_map( - fn ($var) => "'$var' => \$" . $var, - $vars - ); - return '[' . implode(', ', $vars) . ']'; - } - - /** - * @param AbstractColumn[] $columns - */ - protected function addMethodParameters(Method $method, array $columns): void - { - foreach ($columns as $column) { - $method - ->addParameter($column->name) - ->setType($column->type); - } - } -} \ No newline at end of file diff --git a/src/Generator/CachedTableClassBuilder.php b/src/Generator/CachedTableClassBuilder.php deleted file mode 100644 index 760ad44..0000000 --- a/src/Generator/CachedTableClassBuilder.php +++ /dev/null @@ -1,97 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator; - -use Composite\DB\AbstractCachedTable; -use Composite\DB\TableConfig; -use Composite\Entity\AbstractEntity; -use Composite\Entity\Columns\AbstractColumn; -use Composite\DB\Helpers\ClassHelper; -use Nette\PhpGenerator\Method; - -class CachedTableClassBuilder extends AbstractTableClassBuilder -{ - public function getParentNamespace(): string - { - return AbstractCachedTable::class; - } - - public function generate(): void - { - $this->file - ->addNamespace(ClassHelper::extractNamespace($this->tableClass)) - ->addUse(AbstractEntity::class) - ->addUse(AbstractCachedTable::class) - ->addUse(TableConfig::class) - ->addUse($this->schema->class) - ->addClass(ClassHelper::extractShortName($this->tableClass)) - ->setExtends(AbstractCachedTable::class) - ->setMethods($this->getMethods()); - } - - private function getMethods(): array - { - return array_filter([ - $this->generateGetConfig(), - $this->generateGetFlushCacheKeys(), - $this->generateFindOne(), - $this->generateFindAll(), - $this->generateCountAll(), - ]); - } - - protected function generateGetFlushCacheKeys(): Method - { - $method = (new Method('getFlushCacheKeys')) - ->setProtected() - ->setReturnType('array') - ->addBody('return [') - ->addBody(' $this->getListCacheKey(),') - ->addBody(' $this->getCountCacheKey(),') - ->addBody('];'); - - $type = $this->schema->class . '|' . AbstractEntity::class; - $method - ->addParameter('entity') - ->setType($type); - return $method; - } - - protected function generateFindOne(): ?Method - { - $primaryColumns = array_map( - fn(string $key): AbstractColumn => $this->schema->getColumn($key) ?? throw new \Exception("Primary key column `$key` not found in entity."), - $this->tableConfig->primaryKeys - ); - if (count($this->tableConfig->primaryKeys) === 1) { - $body = 'return $this->createEntity($this->findByPkCachedInternal(' . $this->buildVarsList($this->tableConfig->primaryKeys) . '));'; - } else { - $body = 'return $this->createEntity($this->findOneCachedInternal(' . $this->buildVarsList($this->tableConfig->primaryKeys) . '));'; - } - - $method = (new Method('findByPk')) - ->setPublic() - ->setReturnType($this->schema->class) - ->setReturnNullable() - ->setBody($body); - $this->addMethodParameters($method, $primaryColumns); - return $method; - } - - protected function generateFindAll(): Method - { - return (new Method('findAll')) - ->setPublic() - ->setComment('@return ' . $this->entityClassShortName . '[]') - ->setReturnType('array') - ->setBody('return $this->createEntities($this->findAllCachedInternal());'); - } - - protected function generateCountAll(): Method - { - return (new Method('countAll')) - ->setPublic() - ->setReturnType('int') - ->setBody('return $this->countAllCachedInternal();'); - } -} \ No newline at end of file diff --git a/src/Generator/EntityClassBuilder.php b/src/Generator/EntityClassBuilder.php deleted file mode 100644 index 96453bd..0000000 --- a/src/Generator/EntityClassBuilder.php +++ /dev/null @@ -1,264 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator; - -use Composite\Entity\AbstractEntity; -use Composite\DB\Generator\Schema\ColumnType; -use Composite\DB\Generator\Schema\SQLColumn; -use Composite\DB\Generator\Schema\SQLSchema; -use Composite\DB\Helpers\DateTimeHelper; - -class EntityClassBuilder -{ - /** @var string[] */ - private array $useNamespaces = [ - AbstractEntity::class, - ]; - /** @var string[] */ - private array $useAttributes = [ - 'Table', - ]; - - public function __construct( - private readonly SQLSchema $schema, - private readonly string $connectionName, - private readonly string $entityClass, - private readonly array $enums, - ) {} - - /** - * @throws \Exception - */ - public function getClassContent(): string - { - return $this->renderTemplate('EntityTemplate', $this->getVars()); - } - - /** - * @return array<string, mixed> - * @throws \Exception - */ - private function getVars(): array - { - $traits = $properties = []; - $constructorParams = $this->getEntityProperties(); - if (!empty($this->schema->columns['deleted_at'])) { - $traits[] = 'Traits\SoftDelete'; - $this->useNamespaces[] = 'Composite\DB\Traits'; - unset($constructorParams['deleted_at']); - } - foreach ($constructorParams as $name => $constructorParam) { - if ($this->schema->columns[$name]->isAutoincrement) { - $properties[$name] = $constructorParam; - unset($constructorParams[$name]); - } - } - if (!preg_match('/^(.+)\\\(\w+)$/', $this->entityClass, $matches)) { - throw new \Exception("Entity class `$this->entityClass` is incorrect"); - } - - return [ - 'phpOpener' => '<?php declare(strict_types=1);', - 'connectionName' => $this->connectionName, - 'tableName' => $this->schema->tableName, - 'pkNames' => "'" . implode("', '", $this->schema->primaryKeys) . "'", - 'indexes' => $this->getIndexes(), - 'traits' => $traits, - 'entityNamespace' => $matches[1], - 'entityClassShortname' => $matches[2], - 'properties' => $properties, - 'constructorParams' => $constructorParams, - 'useNamespaces' => array_unique($this->useNamespaces), - 'useAttributes' => array_unique($this->useAttributes), - ]; - } - - private function getEntityProperties(): array - { - $noDefaultValue = $hasDefaultValue = []; - foreach ($this->schema->columns as $column) { - $attributes = []; - if ($this->schema->isPrimaryKey($column->name)) { - $this->useAttributes[] = 'PrimaryKey'; - $autoIncrement = $column->isAutoincrement ? '(autoIncrement: true)' : ''; - $attributes[] = '#[PrimaryKey' . $autoIncrement . ']'; - } - if ($columnAttributeProperties = $column->getColumnAttributeProperties()) { - $this->useAttributes[] = 'Column'; - $attributes[] = '#[Column(' . implode(', ', $columnAttributeProperties) . ')]'; - } - $propertyParts = [$this->getPropertyVisibility($column)]; - if ($this->isReadOnly($column)) { - $propertyParts[] = 'readonly'; - } - $propertyParts[] = $this->getColumnType($column); - $propertyParts[] = '$' . $column->name; - if ($column->hasDefaultValue) { - $defaultValue = $this->getDefaultValue($column); - $propertyParts[] = '= ' . $defaultValue; - $hasDefaultValue[$column->name] = [ - 'attributes' => $attributes, - 'var' => implode(' ', $propertyParts), - ]; - } else { - $noDefaultValue[$column->name] = [ - 'attributes' => $attributes, - 'var' => implode(' ', $propertyParts), - ]; - } - } - return array_merge($noDefaultValue, $hasDefaultValue); - } - - private function getPropertyVisibility(SQLColumn $column): string - { - return 'public'; - } - - private function isReadOnly(SQLColumn $column): bool - { - if ($column->isAutoincrement) { - return true; - } - $readOnlyColumns = array_merge( - $this->schema->primaryKeys, - [ - 'created_at', - 'createdAt', - ] - ); - return in_array($column->name, $readOnlyColumns); - } - - private function getColumnType(SQLColumn $column): string - { - if ($column->type === ColumnType::Enum) { - if (!$type = $this->getEnumName($column->name)) { - $type = 'string'; - } - } else { - $type = $column->type->value; - } - if ($column->isNullable) { - $type = '?' . $type; - } - return $type; - } - - public function getDefaultValue(SQLColumn $column): mixed - { - $defaultValue = $column->defaultValue; - if ($defaultValue === null) { - return 'null'; - } - if ($column->type === ColumnType::Datetime) { - $currentTimestamp = stripos($defaultValue, 'current_timestamp') === 0 || $defaultValue === 'now()'; - if ($currentTimestamp) { - $defaultValue = "new \DateTimeImmutable()"; - } else { - if ($defaultValue === 'epoch') { - $defaultValue = '1970-01-01 00:00:00'; - } elseif ($defaultValue instanceof \DateTimeInterface) { - $defaultValue = DateTimeHelper::dateTimeToString($defaultValue); - } - $defaultValue = "new \DateTimeImmutable('" . $defaultValue . "')"; - } - } elseif ($column->type === ColumnType::Enum) { - if ($enumName = $this->getEnumName($column->name)) { - $valueName = null; - /** @var \UnitEnum $enumClass */ - $enumClass = $this->enums[$column->name]; - foreach ($enumClass::cases() as $enumCase) { - if ($enumCase->name === $defaultValue) { - $valueName = $enumCase->name; - } - } - if ($valueName) { - $defaultValue = $enumName . '::' . $valueName; - } else { - return 'null'; - } - } else { - $defaultValue = "'$defaultValue'"; - } - } elseif ($column->type === ColumnType::Boolean) { - if (strcasecmp($defaultValue, 'false') === 0) { - return 'false'; - } - if (strcasecmp($defaultValue, 'true') === 0) { - return 'true'; - } - return !empty($defaultValue) ? 'true' : 'false'; - } elseif ($column->type === ColumnType::Array) { - if ($defaultValue === '{}' || $defaultValue === '[]') { - return '[]'; - } - if ($decoded = json_decode($defaultValue, true)) { - return var_export($decoded, true); - } - return $defaultValue; - } else { - if ($column->type !== ColumnType::Integer && $column->type !== ColumnType::Float) { - $defaultValue = "'$defaultValue'"; - } - } - return $defaultValue; - } - - private function getEnumName(string $columnName): ?string - { - if (empty($this->enums[$columnName])) { - return null; - } - $enumClass = $this->enums[$columnName]; - if (!\in_array($enumClass, $this->useNamespaces)) { - $this->useNamespaces[] = $enumClass; - } - return substr(strrchr($enumClass, "\\"), 1); - } - - private function getIndexes(): array - { - $result = []; - foreach ($this->schema->indexes as $index) { - $properties = [ - "columns: ['" . implode("', '", $index->columns) . "']", - ]; - if ($index->isUnique) { - $properties[] = "isUnique: true"; - } - if ($index->sort) { - $sortParts = []; - foreach ($index->sort as $key => $direction) { - $sortParts[] = "'$key' => '$direction'"; - } - $properties[] = 'sort: [' . implode(', ', $sortParts) . ']'; - } - if ($index->name) { - $properties[] = "name: '" . $index->name . "'"; - } - $this->useAttributes[] = 'Index'; - $result[] = '#[Index(' . implode(', ', $properties) . ')]'; - } - return $result; - } - - private function renderTemplate(string $templateName, array $variables = []): string - { - $filePath = implode( - DIRECTORY_SEPARATOR, - [ - __DIR__, - 'Templates', - "$templateName.php", - ] - ); - if (!file_exists($filePath)) { - throw new \Exception("File `$filePath` not found"); - } - extract($variables, EXTR_SKIP); - ob_start(); - include $filePath; - return ob_get_clean(); - } -} \ No newline at end of file diff --git a/src/Generator/EnumClassBuilder.php b/src/Generator/EnumClassBuilder.php deleted file mode 100644 index b2bd580..0000000 --- a/src/Generator/EnumClassBuilder.php +++ /dev/null @@ -1,32 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator; - -use Nette\PhpGenerator\EnumCase; -use Nette\PhpGenerator\PhpFile; - -class EnumClassBuilder -{ - public function __construct( - private readonly string $enumClass, - private readonly array $cases, - ) {} - - /** - * @throws \Exception - */ - public function getClassContent(): string - { - $enumCases = []; - foreach ($this->cases as $case) { - $enumCases[] = new EnumCase($case); - } - $file = new PhpFile(); - $file - ->setStrictTypes() - ->addEnum($this->enumClass) - ->setCases($enumCases); - - return (string)$file; - } -} \ No newline at end of file diff --git a/src/Generator/Schema/ColumnType.php b/src/Generator/Schema/ColumnType.php deleted file mode 100644 index 5f20634..0000000 --- a/src/Generator/Schema/ColumnType.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema; - -enum ColumnType: string -{ - case String = 'string'; - case Integer = 'int'; - case Float = 'float'; - case Boolean = 'bool'; - case Datetime = '\DateTimeImmutable'; - case Array = 'array'; - case Object = '\stdClass'; - case Enum = 'enum'; -} \ No newline at end of file diff --git a/src/Generator/Schema/Parsers/MySQLSchemaParser.php b/src/Generator/Schema/Parsers/MySQLSchemaParser.php deleted file mode 100644 index b9d640d..0000000 --- a/src/Generator/Schema/Parsers/MySQLSchemaParser.php +++ /dev/null @@ -1,122 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema\Parsers; - -use Composite\DB\Generator\Schema\ColumnType; -use Composite\DB\Generator\Schema\SQLColumn; -use Composite\DB\Generator\Schema\SQLEnum; -use Composite\DB\Generator\Schema\SQLIndex; -use Composite\DB\Generator\Schema\SQLSchema; -use Doctrine\DBAL\Connection; -use iamcal\SQLParser; - -class MySQLSchemaParser -{ - private readonly string $sql; - - /** - * @throws \Exception - */ - public function __construct( - Connection $connection, - string $tableName, - ) { - $showResult = $connection - ->executeQuery("SHOW CREATE TABLE $tableName") - ->fetchAssociative(); - $this->sql = $showResult['Create Table'] ?? throw new \Exception("Table `$tableName` not found"); - } - - public function getSchema(): SQLSchema - { - $columns = $enums = $primaryKeys = $indexes = []; - $parser = new SQLParser(); - $tokens = $parser->parse($this->sql); - $table = current($tokens); - $tableName = $table['name']; - - foreach ($table['fields'] as $field) { - $name = $field['name']; - $precision = $scale = null; - $sqlType = $field['type']; - $size = !empty($field['length']) ? (int)$field['length'] : null; - $type = $this->getType($sqlType, $size); - - if ($type === ColumnType::Enum) { - $enums[$name] = new SQLEnum(name: $name, values: $field['values']); - } elseif ($type === ColumnType::Float) { - $precision = $size; - $scale = !empty($field['decimals']) ? (int)$field['decimals'] : null; - $size = null; - } - if (isset($field['default'])) { - $hasDefaultValue = true; - $defaultValue = $this->getDefaultValue($type, $field['default']); - } else { - $hasDefaultValue = false; - $defaultValue = null; - } - $column = new SQLColumn( - name: $name, - sql: $sqlType, - type: $type, - size: $size, - precision: $precision, - scale: $scale, - isNullable: !empty($field['null']), - hasDefaultValue: $hasDefaultValue, - defaultValue: $defaultValue, - isAutoincrement: !empty($field['auto_increment']), - ); - $columns[$column->name] = $column; - } - foreach ($table['indexes'] as $index) { - $indexType = strtolower($index['type']); - $cols = []; - foreach ($index['cols'] as $col) { - $colName = $col['name']; - $cols[] = $colName; - } - if ($indexType === 'primary') { - $primaryKeys = $cols; - continue; - } - $indexes[] = new SQLIndex( - name: $index['name'] ?? null, - isUnique: $indexType === 'unique', - columns: $cols, - ); - } - return new SQLSchema( - tableName: $tableName, - columns: $columns, - enums: $enums, - primaryKeys: array_unique($primaryKeys), - indexes: $indexes, - ); - } - - private function getType(string $type, ?int $size): ColumnType - { - $type = strtolower($type); - if ($type === 'tinyint' && $size === 1) { - return ColumnType::Boolean; - } - return match ($type) { - 'integer', 'int', 'smallint', 'tinyint', 'mediumint', 'bigint' => ColumnType::Integer, - 'float', 'double', 'numeric', 'decimal' => ColumnType::Float, - 'timestamp', 'datetime' => ColumnType::Datetime, - 'json', 'set' => ColumnType::Array, - 'enum' => ColumnType::Enum, - default => ColumnType::String, - }; - } - - private function getDefaultValue(ColumnType $type, mixed $value): mixed - { - if ($value === null || (is_string($value) && strcasecmp($value, 'null') === 0)) { - return null; - } - return $value; - } -} \ No newline at end of file diff --git a/src/Generator/Schema/Parsers/PostgresSchemaParser.php b/src/Generator/Schema/Parsers/PostgresSchemaParser.php deleted file mode 100644 index bcf7ebd..0000000 --- a/src/Generator/Schema/Parsers/PostgresSchemaParser.php +++ /dev/null @@ -1,221 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema\Parsers; - -use Composite\DB\Generator\Schema\ColumnType; -use Composite\DB\Generator\Schema\SQLColumn; -use Composite\DB\Generator\Schema\SQLEnum; -use Composite\DB\Generator\Schema\SQLIndex; -use Composite\DB\Generator\Schema\SQLSchema; -use Doctrine\DBAL\Connection; - -class PostgresSchemaParser -{ - public const COLUMNS_SQL = " - SELECT * FROM information_schema.columns - WHERE table_schema = 'public' AND table_name = :tableName; - "; - - public const INDEXES_SQL = " - SELECT * FROM pg_indexes - WHERE schemaname = 'public' AND tablename = :tableName; - "; - - public const PRIMARY_KEY_SQL = <<<SQL - SELECT a.attname as column_name - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) - WHERE i.indrelid = '":tableName"'::regclass AND i.indisprimary; - SQL; - - public const ALL_ENUMS_SQL = " - SELECT t.typname as enum_name, e.enumlabel as enum_value - FROM pg_type t - JOIN pg_enum e ON t.oid = e.enumtypid - JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace - WHERE n.nspname = 'public'; - "; - - private readonly string $tableName; - private readonly array $informationSchemaColumns; - private readonly array $informationSchemaIndexes; - private readonly array $primaryKeys; - private readonly array $allEnums; - - public static function getPrimaryKeySQL(string $tableName): string - { - return " - SELECT a.attname as column_name - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey) - WHERE i.indrelid = '\"" . $tableName . "\"'::regclass AND i.indisprimary; - "; - } - - public function __construct(Connection $connection, string $tableName) { - $this->tableName = $tableName; - $this->informationSchemaColumns = $connection->executeQuery( - sql: PostgresSchemaParser::COLUMNS_SQL, - params: ['tableName' => $tableName], - )->fetchAllAssociative(); - $this->informationSchemaIndexes = $connection->executeQuery( - sql: PostgresSchemaParser::INDEXES_SQL, - params: ['tableName' => $tableName], - )->fetchAllAssociative(); - - if ($primaryKeySQL = PostgresSchemaParser::getPrimaryKeySQL($tableName)) { - $primaryKeys = array_map( - fn(array $row): string => $row['column_name'], - $connection->executeQuery($primaryKeySQL)->fetchAllAssociative() - ); - } else { - $primaryKeys = []; - } - $this->primaryKeys = $primaryKeys; - - $allEnumsRaw = $connection->executeQuery(PostgresSchemaParser::ALL_ENUMS_SQL)->fetchAllAssociative(); - $allEnums = []; - foreach ($allEnumsRaw as $enumRaw) { - $name = $enumRaw['enum_name']; - $value = $enumRaw['enum_value']; - if (!isset($allEnums[$name])) { - $allEnums[$name] = []; - } - $allEnums[$name][] = $value; - } - $this->allEnums = $allEnums; - } - - public function getSchema(): SQLSchema - { - $columns = $enums = []; - foreach ($this->informationSchemaColumns as $informationSchemaColumn) { - $name = $informationSchemaColumn['column_name']; - $type = $this->getType($informationSchemaColumn); - $sqlDefault = $informationSchemaColumn['column_default']; - $isNullable = $informationSchemaColumn['is_nullable'] === 'YES'; - $defaultValue = $this->getDefaultValue($type, $sqlDefault); - $hasDefaultValue = $defaultValue !== null || $isNullable; - $isAutoincrement = $sqlDefault && str_starts_with($sqlDefault, 'nextval('); - - if ($type === ColumnType::Enum) { - $udtName = $informationSchemaColumn['udt_name']; - $enums[$name] = new SQLEnum(name: $udtName, values: $this->allEnums[$udtName]); - } - $column = new SQLColumn( - name: $name, - sql: $informationSchemaColumn['udt_name'], - type: $type, - size: $this->getSize($type, $informationSchemaColumn), - precision: $this->getPrecision($type, $informationSchemaColumn), - scale: $this->getScale($type, $informationSchemaColumn), - isNullable: $isNullable, - hasDefaultValue: $hasDefaultValue, - defaultValue: $defaultValue, - isAutoincrement: $isAutoincrement, - ); - $columns[$column->name] = $column; - } - return new SQLSchema( - tableName: $this->tableName, - columns: $columns, - enums: $enums, - primaryKeys: $this->primaryKeys, - indexes: $this->parseIndexes(), - ); - } - - private function getType(array $informationSchemaColumn): ColumnType - { - $dataType = $informationSchemaColumn['data_type']; - $udtName = $informationSchemaColumn['udt_name']; - if ($dataType === 'USER-DEFINED' && !empty($this->allEnums[$udtName])) { - return ColumnType::Enum; - } - if (preg_match('/^int(\d?)$/', $udtName)) { - return ColumnType::Integer; - } - if (preg_match('/^float(\d?)$/', $udtName)) { - return ColumnType::Float; - } - $matchType = match ($udtName) { - 'numeric' => ColumnType::Float, - 'timestamp', 'timestamptz' => ColumnType::Datetime, - 'json', 'array' => ColumnType::Array, - 'bool' => ColumnType::Boolean, - default => null, - }; - return $matchType ?? ColumnType::String; - } - - private function getSize(ColumnType $type, array $informationSchemaColumn): ?int - { - if ($type === ColumnType::String) { - return $informationSchemaColumn['character_maximum_length']; - } - return null; - } - - private function getPrecision(ColumnType $type, array $informationSchemaColumn): ?int - { - if ($type !== ColumnType::Float) { - return null; - } - return $informationSchemaColumn['numeric_precision']; - } - - private function getScale(ColumnType $type, array $informationSchemaColumn): ?int - { - if ($type !== ColumnType::Float) { - return null; - } - return $informationSchemaColumn['numeric_scale']; - } - - private function getDefaultValue(ColumnType $type, ?string $sqlValue): mixed - { - if ($sqlValue === null || strcasecmp($sqlValue, 'null') === 0) { - return null; - } - if (str_starts_with($sqlValue, 'nextval(')) { - return null; - } - $parts = explode('::', $sqlValue); - return trim($parts[0], '\''); - } - - private function parseIndexes(): array - { - $result = []; - foreach ($this->informationSchemaIndexes as $informationSchemaIndex) { - $name = $informationSchemaIndex['indexname']; - $sql = $informationSchemaIndex['indexdef']; - $isUnique = stripos($sql, ' unique index ') !== false; - - if (!preg_match('/\(([`"\',\s\w]+)\)/', $sql, $columnsMatch)) { - continue; - } - $columnsRaw = array_map( - fn (string $column) => str_replace(['`', '\'', '"'], '', trim($column)), - explode(',', $columnsMatch[1]) - ); - $columns = $sort = []; - foreach ($columnsRaw as $columnRaw) { - $parts = explode(' ', $columnRaw); - $columns[] = $parts[0]; - if (!empty($parts[1])) { - $sort[$parts[0]] = strtoupper($parts[1]); - } - } - if ($columns === $this->primaryKeys) { - continue; - } - $result[] = new SQLIndex( - name: $name, - isUnique: $isUnique, - columns: $columns, - ); - } - return $result; - } -} \ No newline at end of file diff --git a/src/Generator/Schema/Parsers/SQLiteSchemaParser.php b/src/Generator/Schema/Parsers/SQLiteSchemaParser.php deleted file mode 100644 index c11c49a..0000000 --- a/src/Generator/Schema/Parsers/SQLiteSchemaParser.php +++ /dev/null @@ -1,247 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema\Parsers; - -use Composite\DB\Generator\Schema\ColumnType; -use Composite\DB\Generator\Schema\SQLColumn; -use Composite\DB\Generator\Schema\SQLEnum; -use Composite\DB\Generator\Schema\SQLIndex; -use Composite\DB\Generator\Schema\SQLSchema; -use Doctrine\DBAL\Connection; - -class SQLiteSchemaParser -{ - public const TABLE_SQL = "SELECT sql FROM sqlite_schema WHERE name = :tableName"; - public const INDEXES_SQL = "SELECT sql FROM sqlite_master WHERE type = 'index' and tbl_name = :tableName"; - - private const TABLE_NAME_PATTERN = '/^create table (?:`|\"|\')?(\w+)(?:`|\"|\')?/i'; - private const COLUMN_PATTERN = '/^(?!constraint|primary key)(?:`|\"|\')?(\w+)(?:`|\"|\')? ([a-zA-Z]+)\s?(\(([\d,\s]+)\))?/i'; - private const CONSTRAINT_PATTERN = '/^(?:constraint) (?:`|\"|\')?\w+(?:`|\"|\')? primary key \(([\w\s,\'\"`]+)\)/i'; - private const PRIMARY_KEY_PATTERN = '/^primary key \(([\w\s,\'\"`]+)\)/i'; - private const ENUM_PATTERN = '/check \((?:`|\"|\')?(\w+)(?:`|\"|\')? in \((.+)\)\)/i'; - - private readonly string $tableSql; - private readonly array $indexesSql; - - public function __construct( - Connection $connection, - string $tableName, - ) { - $this->tableSql = $connection->executeQuery( - sql: self::TABLE_SQL, - params: ['tableName' => $tableName], - )->fetchOne(); - $this->indexesSql = $connection->executeQuery( - sql: self::INDEXES_SQL, - params: ['tableName' => $tableName], - )->fetchFirstColumn(); - } - - public function getSchema(): SQLSchema - { - $columns = $enums = $primaryKeys = []; - $columnsStarted = false; - $tableName = ''; - $lines = array_map( - fn ($line) => trim(preg_replace("/\s+/", " ", $line)), - explode("\n", $this->tableSql), - ); - for ($i = 0; $i < count($lines); $i++) { - $line = $lines[$i]; - if (!$line) { - continue; - } - if (!$tableName && preg_match(self::TABLE_NAME_PATTERN, $line, $matches)) { - $tableName = $matches[1]; - } - if (!$columnsStarted) { - if (str_starts_with($line, '(') || str_ends_with($line, '(')) { - $columnsStarted = true; - } - continue; - } - if ($line === ')') { - break; - } - if (!str_ends_with($line, ',')) { - if (!empty($lines[$i + 1]) && !str_starts_with($lines[$i + 1], ')')) { - $lines[$i + 1] = $line . ' ' . $lines[$i + 1]; - continue; - } - } - if ($column = $this->parseSQLColumn($line)) { - $columns[$column->name] = $column; - } - $primaryKeys = array_merge($primaryKeys, $this->parsePrimaryKeys($line)); - if ($enum = $this->parseEnum($line)) { - $enums[$column?->name ?? $enum->name] = $enum; - } - } - return new SQLSchema( - tableName: $tableName, - columns: $columns, - enums: $enums, - primaryKeys: array_unique($primaryKeys), - indexes: $this->getIndexes(), - ); - } - - private function parseSQLColumn(string $sqlLine): ?SQLColumn - { - if (!preg_match(self::COLUMN_PATTERN, $sqlLine, $matches)) { - return null; - } - $name = $matches[1]; - $rawType = $matches[2]; - $rawTypeParams = !empty($matches[4]) ? str_replace(' ', '', $matches[4]) : null; - $type = $this->getColumnType($rawType) ?? ColumnType::String; - $hasDefaultValue = stripos($sqlLine, ' default ') !== false; - return new SQLColumn( - name: $name, - sql: $sqlLine, - type: $type, - size: $this->getColumnSize($type, $rawTypeParams), - precision: $this->getColumnPrecision($type, $rawTypeParams), - scale: $this->getScale($type, $rawTypeParams), - isNullable: stripos($sqlLine, ' not null') === false, - hasDefaultValue: $hasDefaultValue, - defaultValue: $hasDefaultValue ? $this->getDefaultValue($sqlLine) : null, - isAutoincrement: stripos($sqlLine, ' autoincrement') !== false, - ); - } - - private function getColumnType(string $rawType): ?ColumnType - { - if (!preg_match('/^([a-zA-Z]+).*/', $rawType, $matches)) { - return null; - } - $type = strtolower($matches[1]); - return match ($type) { - 'integer', 'int' => ColumnType::Integer, - 'real' => ColumnType::Float, - 'timestamp' => ColumnType::Datetime, - 'enum' => ColumnType::Enum, - default => ColumnType::String, - }; - } - - private function getColumnSize(ColumnType $type, ?string $typeParams): ?int - { - if ($type !== ColumnType::String || !$typeParams) { - return null; - } - return (int)$typeParams; - } - - private function getColumnPrecision(ColumnType $type, ?string $typeParams): ?int - { - if ($type !== ColumnType::Float || !$typeParams) { - return null; - } - $parts = explode(',', $typeParams); - return (int)$parts[0]; - } - - private function getScale(ColumnType $type, ?string $typeParams): ?int - { - if ($type !== ColumnType::Float || !$typeParams) { - return null; - } - $parts = explode(',', $typeParams); - return !empty($parts[1]) ? (int)$parts[1] : null; - } - - private function getDefaultValue(string $sqlLine): mixed - { - $sqlLine = $this->cleanCheckEnum($sqlLine); - if (preg_match('/default\s+\'(.*)\'/iu', $sqlLine, $matches)) { - return $matches[1]; - } elseif (preg_match('/default\s+([\w.]+)/iu', $sqlLine, $matches)) { - $defaultValue = $matches[1]; - if (strtolower($defaultValue) === 'null') { - return null; - } - return $defaultValue; - } - return null; - } - - private function parsePrimaryKeys(string $sqlLine): array - { - if (preg_match(self::COLUMN_PATTERN, $sqlLine, $matches)) { - $name = $matches[1]; - return stripos($sqlLine, ' primary key') !== false ? [$name] : []; - } - if (!preg_match(self::CONSTRAINT_PATTERN, $sqlLine, $matches) - && !preg_match(self::PRIMARY_KEY_PATTERN, $sqlLine, $matches)) { - return []; - } - $primaryColumnsRaw = $matches[1]; - $primaryColumnsRaw = str_replace(['\'', '"', '`', ' '], '', $primaryColumnsRaw); - return explode(',', $primaryColumnsRaw); - } - - private function parseEnum(string $sqlLine): ?SQLEnum - { - if (!preg_match(self::ENUM_PATTERN, $sqlLine, $matches)) { - return null; - } - $name = $matches[1]; - $values = []; - $sqlValues = array_map('trim', explode(',', $matches[2])); - foreach ($sqlValues as $value) { - $value = trim($value); - if (str_starts_with($value, '\'')) { - $value = trim($value, '\''); - } elseif (str_starts_with($value, '"')) { - $value = trim($value, '"'); - } - $values[] = $value; - } - return new SQLEnum(name: $name, values: $values); - } - - /** - * @return SQLIndex[] - */ - private function getIndexes(): array - { - $result = []; - foreach ($this->indexesSql as $indexSql) { - if (!$indexSql) continue; - $indexSql = trim(str_replace("\n", " ", $indexSql)); - $indexSql = preg_replace("/\s+/", " ", $indexSql); - if (!preg_match('/index\s+(?:`|\"|\')?(\w+)(?:`|\"|\')?/i', $indexSql, $nameMatch)) { - continue; - } - $name = $nameMatch[1]; - if (!preg_match('/\(([`"\',\s\w]+)\)/', $indexSql, $columnsMatch)) { - continue; - } - $columnsRaw = array_map( - fn (string $column) => str_replace(['`', '\'', '"'], '', trim($column)), - explode(',', $columnsMatch[1]) - ); - $columns = $sort = []; - foreach ($columnsRaw as $columnRaw) { - $parts = explode(' ', $columnRaw); - $columns[] = $parts[0]; - if (!empty($parts[1])) { - $sort[$parts[0]] = strtolower($parts[1]); - } - } - $result[] = new SQLIndex( - name: $name, - isUnique: stripos($indexSql, ' unique index ') !== false, - columns: $columns, - sort: $sort, - ); - } - return $result; - } - - private function cleanCheckEnum(string $sqlLine): string - { - return preg_replace('/ check \(\"\w+\" IN \(.+\)\)/i', '', $sqlLine); - } -} \ No newline at end of file diff --git a/src/Generator/Schema/SQLColumn.php b/src/Generator/Schema/SQLColumn.php deleted file mode 100644 index 00ad7d3..0000000 --- a/src/Generator/Schema/SQLColumn.php +++ /dev/null @@ -1,45 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema; - -class SQLColumn -{ - public function __construct( - public readonly string $name, - public readonly string|array $sql, - public readonly ColumnType $type, - public readonly ?int $size, - public readonly ?int $precision, - public readonly ?int $scale, - public readonly bool $isNullable, - public readonly bool $hasDefaultValue, - public readonly mixed $defaultValue, - public readonly bool $isAutoincrement, - ) {} - - public function sizeIsDefault(): bool - { - if ($this->type !== ColumnType::String) { - return true; - } - if ($this->size === null) { - return true; - } - return $this->size === 255; - } - - public function getColumnAttributeProperties(): array - { - $result = []; - if ($this->size && !$this->sizeIsDefault()) { - $result[] = 'size: ' . $this->size; - } - if ($this->precision) { - $result[] = 'precision: ' . $this->precision; - } - if ($this->scale) { - $result[] = 'scale: ' . $this->scale; - } - return $result; - } -} \ No newline at end of file diff --git a/src/Generator/Schema/SQLEnum.php b/src/Generator/Schema/SQLEnum.php deleted file mode 100644 index ba726df..0000000 --- a/src/Generator/Schema/SQLEnum.php +++ /dev/null @@ -1,11 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema; - -class SQLEnum -{ - public function __construct( - public readonly string $name, - public readonly array $values = [], - ) {} -} \ No newline at end of file diff --git a/src/Generator/Schema/SQLIndex.php b/src/Generator/Schema/SQLIndex.php deleted file mode 100644 index 5ad6561..0000000 --- a/src/Generator/Schema/SQLIndex.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema; - -class SQLIndex -{ - public function __construct( - public readonly ?string $name, - public readonly bool $isUnique, - public readonly array $columns, - public readonly array $sort = [], - ) {} -} \ No newline at end of file diff --git a/src/Generator/Schema/SQLSchema.php b/src/Generator/Schema/SQLSchema.php deleted file mode 100644 index ec41d92..0000000 --- a/src/Generator/Schema/SQLSchema.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator\Schema; - -use Composite\DB\Generator\Schema\Parsers\MySQLSchemaParser; -use Composite\DB\Generator\Schema\Parsers\PostgresSchemaParser; -use Composite\DB\Generator\Schema\Parsers\SQLiteSchemaParser; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Driver; - -class SQLSchema -{ - /** - * @param string $tableName - * @param SQLColumn[] $columns - * @param SQLEnum[] $enums - * @param SQLIndex[] $indexes - * @param string[] $primaryKeys - */ - public function __construct( - public readonly string $tableName, - public readonly array $columns, - public readonly array $enums, - public readonly array $primaryKeys, - public readonly array $indexes, - ) {} - - /** - * @throws \Exception - */ - public static function generate(Connection $connection, string $tableName): SQLSchema - { - $driver = $connection->getDriver(); - if ($driver instanceof Driver\AbstractSQLiteDriver) { - $parser = new SQLiteSchemaParser($connection, $tableName); - return $parser->getSchema(); - } elseif ($driver instanceof Driver\AbstractMySQLDriver) { - $parser = new MySQLSchemaParser($connection, $tableName); - return $parser->getSchema(); - } elseif ($driver instanceof Driver\AbstractPostgreSQLDriver) { - $parser = new PostgresSchemaParser($connection, $tableName); - return $parser->getSchema(); - } else { - throw new \Exception("Driver `" . $driver::class . "` is not yet supported"); - } - } - - public function isPrimaryKey(string $name): bool - { - return \in_array($name, $this->primaryKeys); - } -} \ No newline at end of file diff --git a/src/Generator/TableClassBuilder.php b/src/Generator/TableClassBuilder.php deleted file mode 100644 index ba840f8..0000000 --- a/src/Generator/TableClassBuilder.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Generator; - -use Composite\DB\AbstractTable; -use Composite\DB\TableConfig; -use Composite\Entity\Columns\AbstractColumn; -use Composite\DB\Helpers\ClassHelper; -use Nette\PhpGenerator\Method; - -class TableClassBuilder extends AbstractTableClassBuilder -{ - public function getParentNamespace(): string - { - return AbstractTable::class; - } - - public function generate(): void - { - $this->file - ->setStrictTypes() - ->addNamespace(ClassHelper::extractNamespace($this->tableClass)) - ->addUse(AbstractTable::class) - ->addUse(TableConfig::class) - ->addUse($this->schema->class) - ->addClass(ClassHelper::extractShortName($this->tableClass)) - ->setExtends(AbstractTable::class) - ->setMethods($this->getMethods()); - } - - private function getMethods(): array - { - return array_filter([ - $this->generateGetConfig(), - $this->generateFindOne(), - $this->generateFindAll(), - $this->generateCountAll(), - ]); - } - - protected function generateFindOne(): ?Method - { - $primaryColumns = array_map( - fn(string $key): AbstractColumn => $this->schema->getColumn($key) ?? throw new \Exception("Primary key column `$key` not found in entity."), - $this->tableConfig->primaryKeys - ); - if (count($this->tableConfig->primaryKeys) === 1) { - $body = 'return $this->createEntity($this->findByPkInternal(' . $this->buildVarsList($this->tableConfig->primaryKeys) . '));'; - } else { - $body = 'return $this->createEntity($this->findOneInternal(' . $this->buildVarsList($this->tableConfig->primaryKeys) . '));'; - } - $method = (new Method('findByPk')) - ->setPublic() - ->setReturnType($this->schema->class) - ->setReturnNullable() - ->setBody($body); - $this->addMethodParameters($method, $primaryColumns); - return $method; - } - - protected function generateFindAll(): Method - { - return (new Method('findAll')) - ->setPublic() - ->setComment('@return ' . $this->entityClassShortName . '[]') - ->setReturnType('array') - ->setBody('return $this->createEntities($this->findAllInternal());'); - } - - protected function generateCountAll(): Method - { - return (new Method('countAll')) - ->setPublic() - ->setReturnType('int') - ->setBody('return $this->countAllInternal();'); - } -} \ No newline at end of file diff --git a/src/Generator/Templates/EntityTemplate.php b/src/Generator/Templates/EntityTemplate.php deleted file mode 100644 index 9384d5b..0000000 --- a/src/Generator/Templates/EntityTemplate.php +++ /dev/null @@ -1,41 +0,0 @@ -<?= $phpOpener ?? '' ?> - - -namespace <?= $entityNamespace ?? '' ?>; - -<?php if (!empty($useAttributes)) : ?> -use Composite\DB\Attributes\{<?= implode(', ', $useAttributes) ?>}; -<?php endif; ?> -<?php foreach($useNamespaces ?? [] as $namespace) : ?> -use <?=$namespace?>; -<?php endforeach; ?> - -#[Table(connection: '<?= $connectionName ?? '' ?>', name: '<?= $tableName ?? '' ?>')] -<?php foreach($indexes ?? [] as $index) : ?> -<?=$index?> - -<?php endforeach; ?> -class <?=$entityClassShortname??''?> extends AbstractEntity -{ -<?php foreach($traits ?? [] as $trait) : ?> - use <?= $trait ?>; - -<?php endforeach; ?> -<?php foreach($properties ?? [] as $property) : ?> -<?php foreach($property['attributes'] as $attribute) : ?> - <?= $attribute ?> - -<?php endforeach; ?> - <?= $property['var'] ?>; - -<?php endforeach; ?> - public function __construct( -<?php foreach($constructorParams ?? [] as $param) : ?> -<?php foreach($param['attributes'] as $attribute) : ?> - <?= $attribute ?> - -<?php endforeach; ?> - <?= $param['var'] ?>, -<?php endforeach; ?> - ) {} -} diff --git a/src/Helpers/ClassHelper.php b/src/Helpers/ClassHelper.php deleted file mode 100644 index fa7b7f8..0000000 --- a/src/Helpers/ClassHelper.php +++ /dev/null @@ -1,18 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Helpers; - -class ClassHelper -{ - public static function extractNamespace(string $name): string - { - return ($pos = strrpos($name, '\\')) ? substr($name, 0, $pos) : ''; - } - - public static function extractShortName(string $name): string - { - return ($pos = strrpos($name, '\\')) === false - ? $name - : substr($name, $pos + 1); - } -} \ No newline at end of file diff --git a/src/Helpers/DatabaseSpecificTrait.php b/src/Helpers/DatabaseSpecificTrait.php new file mode 100644 index 0000000..7769175 --- /dev/null +++ b/src/Helpers/DatabaseSpecificTrait.php @@ -0,0 +1,64 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Helpers; + +use Composite\DB\Exceptions\DbException; +use Doctrine\DBAL\Driver; + +trait DatabaseSpecificTrait +{ + private ?bool $isPostgreSQL = null; + private ?bool $isMySQL = null; + private ?bool $isSQLite = null; + + private function identifyPlatform(): void + { + if ($this->isPostgreSQL !== null) { + return; + } + $driver = $this->getConnection()->getDriver(); + if ($driver instanceof Driver\AbstractPostgreSQLDriver) { + $this->isPostgreSQL = true; + $this->isMySQL = $this->isSQLite = false; + } elseif ($driver instanceof Driver\AbstractSQLiteDriver) { + $this->isSQLite = true; + $this->isPostgreSQL = $this->isMySQL = false; + } elseif ($driver instanceof Driver\AbstractMySQLDriver) { + $this->isMySQL = true; + $this->isPostgreSQL = $this->isSQLite = false; + } else { + // @codeCoverageIgnoreStart + throw new DbException('Unsupported driver ' . $driver::class); + // @codeCoverageIgnoreEnd + } + } + + /** + * @param array<string, mixed> $data + * @return array<string, mixed> + */ + private function prepareDataForSql(array $data): array + { + $this->identifyPlatform(); + foreach ($data as $columnName => $value) { + if (is_bool($value) && !$this->isPostgreSQL) { + $data[$columnName] = $value ? 1 : 0; + } + } + return $data; + } + + protected function escapeIdentifier(string $key): string + { + $this->identifyPlatform(); + if ($this->isMySQL) { + if (strpos($key, '.')) { + return implode('.', array_map(fn ($part) => "`$part`", explode('.', $key))); + } else { + return "`$key`"; + } + } else { + return '"' . $key . '"'; + } + } +} diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php deleted file mode 100644 index 59eea94..0000000 --- a/src/Helpers/DateTimeHelper.php +++ /dev/null @@ -1,37 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Helpers; - -class DateTimeHelper -{ - final public const DEFAULT_TIMESTAMP = '1970-01-01 00:00:01'; - final public const DEFAULT_TIMESTAMP_MICRO = '1970-01-01 00:00:01.000000'; - final public const DEFAULT_DATETIME = '1000-01-01 00:00:00'; - final public const DEFAULT_DATETIME_MICRO = '1000-01-01 00:00:00.000000'; - final public const DATETIME_FORMAT = 'Y-m-d H:i:s'; - final public const DATETIME_MICRO_FORMAT = 'Y-m-d H:i:s.u'; - - public static function getDefaultDateTimeImmutable() : \DateTimeImmutable - { - return new \DateTimeImmutable(self::DEFAULT_TIMESTAMP); - } - - public static function dateTimeToString(\DateTimeInterface $dateTime, bool $withMicro = true): string - { - return $dateTime->format($withMicro ? self::DATETIME_MICRO_FORMAT : self::DATETIME_FORMAT); - } - - public static function isDefault(mixed $value): bool - { - if (!$value) { - return true; - } - if ($value instanceof \DateTimeInterface) { - $value = self::dateTimeToString($value); - } - return in_array( - $value, - [self::DEFAULT_TIMESTAMP, self::DEFAULT_TIMESTAMP_MICRO, self::DEFAULT_DATETIME, self::DEFAULT_DATETIME_MICRO] - ); - } -} diff --git a/src/Helpers/SelectRawTrait.php b/src/Helpers/SelectRawTrait.php new file mode 100644 index 0000000..b02bb89 --- /dev/null +++ b/src/Helpers/SelectRawTrait.php @@ -0,0 +1,132 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Helpers; + +use Composite\DB\Where; +use Doctrine\DBAL\Query\QueryBuilder; + +trait SelectRawTrait +{ + /** @var string[] */ + private array $comparisonSigns = ['=', '!=', '>', '<', '>=', '<=', '<>']; + + private ?QueryBuilder $selectQuery = null; + + protected function select(string $select = '*'): QueryBuilder + { + if ($this->selectQuery === null) { + $this->selectQuery = $this->getConnection()->createQueryBuilder()->from($this->getTableName()); + } + return (clone $this->selectQuery)->select($select); + } + + /** + * @param array<string, mixed>|Where $where + * @param array<string, string>|string $orderBy + * @return array<string, mixed>|null + * @throws \Doctrine\DBAL\Exception + */ + private function _findOneRaw(array|Where $where, array|string $orderBy = []): ?array + { + $query = $this->select(); + $this->buildWhere($query, $where); + $this->applyOrderBy($query, $orderBy); + return $query->fetchAssociative() ?: null; + } + + /** + * @param array<string, mixed>|Where $where + * @param array<string, string>|string $orderBy + * @return list<array<string,mixed>> + * @throws \Doctrine\DBAL\Exception + */ + private function _findAllRaw( + array|Where $where = [], + array|string $orderBy = [], + ?int $limit = null, + ?int $offset = null, + ): array + { + $query = $this->select(); + $this->buildWhere($query, $where); + $this->applyOrderBy($query, $orderBy); + if ($limit > 0) { + $query->setMaxResults($limit); + } + if ($offset > 0) { + $query->setFirstResult($offset); + } + return $query->executeQuery()->fetchAllAssociative(); + } + + + /** + * @param array<string, mixed>|Where $where + */ + private function buildWhere(QueryBuilder $query, array|Where $where): void + { + if (is_array($where)) { + foreach ($where as $column => $value) { + if ($value instanceof \BackedEnum) { + $value = $value->value; + } elseif ($value instanceof \UnitEnum) { + $value = $value->name; + } + + if (is_null($value)) { + $query->andWhere($column . ' IS NULL'); + } elseif (is_array($value) && count($value) === 2 && \in_array($value[0], $this->comparisonSigns)) { + $comparisonSign = $value[0]; + $comparisonValue = $value[1]; + + // Handle special case of "!= null" + if ($comparisonSign === '!=' && is_null($comparisonValue)) { + $query->andWhere($column . ' IS NOT NULL'); + } else { + $query->andWhere($column . ' ' . $comparisonSign . ' :' . $column) + ->setParameter($column, $comparisonValue); + } + } elseif (is_array($value)) { + $placeholders = []; + foreach ($value as $index => $val) { + $placeholders[] = ':' . $column . $index; + $query->setParameter($column . $index, $val); + } + $query->andWhere($column . ' IN(' . implode(', ', $placeholders) . ')'); + } else { + $query->andWhere($column . ' = :' . $column) + ->setParameter($column, $value); + } + } + } else { + $query->where($where->condition); + foreach ($where->params as $param => $value) { + $query->setParameter($param, $value); + } + } + } + + /** + * @param array<string, string>|string $orderBy + */ + private function applyOrderBy(QueryBuilder $query, string|array $orderBy): void + { + if (!$orderBy) { + return; + } + if (is_array($orderBy)) { + foreach ($orderBy as $column => $direction) { + $query->addOrderBy($column, $direction); + } + } else { + foreach (explode(',', $orderBy) as $orderByPart) { + $orderByPart = trim($orderByPart); + if (preg_match('/(.+)\s(asc|desc)$/i', $orderByPart, $orderByPartMatch)) { + $query->addOrderBy($orderByPartMatch[1], $orderByPartMatch[2]); + } else { + $query->addOrderBy($orderByPart); + } + } + } + } +} \ No newline at end of file diff --git a/src/MultiQuery/MultiInsert.php b/src/MultiQuery/MultiInsert.php new file mode 100644 index 0000000..bfd3fe0 --- /dev/null +++ b/src/MultiQuery/MultiInsert.php @@ -0,0 +1,62 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\MultiQuery; + +use Composite\DB\Helpers\DatabaseSpecificTrait; +use Doctrine\DBAL\Connection; + +class MultiInsert +{ + use DatabaseSpecificTrait; + + private Connection $connection; + private string $sql = ''; + /** @var array<string, mixed> */ + private array $parameters = []; + + /** + * @param string $tableName + * @param list<array<string, mixed>> $rows + */ + public function __construct(Connection $connection, string $tableName, array $rows) { + if (!$rows) { + return; + } + $this->connection = $connection; + $firstRow = reset($rows); + $columnNames = array_map(fn ($columnName) => $this->escapeIdentifier($columnName), array_keys($firstRow)); + $this->sql = "INSERT INTO " . $this->escapeIdentifier($tableName) . " (" . implode(', ', $columnNames) . ") VALUES "; + $valuesSql = []; + + $index = 0; + foreach ($rows as $row) { + $valuePlaceholder = []; + foreach ($row as $column => $value) { + $valuePlaceholder[] = ":$column$index"; + $this->parameters["$column$index"] = $value; + } + $valuesSql[] = '(' . implode(', ', $valuePlaceholder) . ')'; + $index++; + } + + $this->sql .= implode(', ', $valuesSql) . ';'; + } + + public function getSql(): string + { + return $this->sql; + } + + /** + * @return array<string, mixed> + */ + public function getParameters(): array + { + return $this->parameters; + } + + private function getConnection(): Connection + { + return $this->connection; + } +} \ No newline at end of file diff --git a/src/MultiQuery/MultiSelect.php b/src/MultiQuery/MultiSelect.php new file mode 100644 index 0000000..c5171a7 --- /dev/null +++ b/src/MultiQuery/MultiSelect.php @@ -0,0 +1,68 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\MultiQuery; + +use Composite\DB\Exceptions\DbException; +use Composite\DB\TableConfig; +use Composite\Entity\AbstractEntity; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Query\QueryBuilder; + +class MultiSelect +{ + private readonly QueryBuilder $queryBuilder; + + /** + * @param array<string, mixed>|array<string|int> $condition + * @throws DbException + */ + public function __construct( + Connection $connection, + TableConfig $tableConfig, + array $condition, + ) { + $query = $connection->createQueryBuilder()->select('*')->from($tableConfig->tableName); + /** @var class-string<AbstractEntity> $class */ + $class = $tableConfig->entityClass; + + $pkColumns = []; + foreach ($tableConfig->primaryKeys as $primaryKeyName) { + $pkColumns[$primaryKeyName] = $class::schema()->getColumn($primaryKeyName); + } + + if (count($pkColumns) === 1) { + if (!array_is_list($condition)) { + throw new DbException('Input argument $pkList must be list'); + } + /** @var \Composite\Entity\Columns\AbstractColumn $pkColumn */ + $pkColumn = reset($pkColumns); + $preparedPkValues = array_map(fn ($pk) => (string)$pkColumn->uncast($pk), $condition); + $query->andWhere($query->expr()->in($pkColumn->name, $preparedPkValues)); + } else { + $expressions = []; + foreach ($condition as $i => $pkArray) { + if (!is_array($pkArray)) { + throw new DbException('For tables with composite keys, input array must consist associative arrays'); + } + $pkOrExpr = []; + foreach ($pkArray as $pkName => $pkValue) { + if (is_string($pkName) && isset($pkColumns[$pkName])) { + $preparedPkValue = $pkColumns[$pkName]->cast($pkValue); + $pkOrExpr[] = $query->expr()->eq($pkName, ':' . $pkName . $i); + $query->setParameter($pkName . $i, $preparedPkValue); + } + } + if ($pkOrExpr) { + $expressions[] = $query->expr()->and(...$pkOrExpr); + } + } + $query->where($query->expr()->or(...$expressions)); + } + $this->queryBuilder = $query; + } + + public function getQueryBuilder(): QueryBuilder + { + return $this->queryBuilder; + } +} \ No newline at end of file diff --git a/src/TableConfig.php b/src/TableConfig.php index c19c23e..a33b693 100644 --- a/src/TableConfig.php +++ b/src/TableConfig.php @@ -9,16 +9,23 @@ class TableConfig { + /** @var array<class-string, true> */ + private readonly array $entityTraits; + + /** + * @param class-string<AbstractEntity> $entityClass + * @param string[] $primaryKeys + */ public function __construct( public readonly string $connectionName, public readonly string $tableName, public readonly string $entityClass, public readonly array $primaryKeys, public readonly ?string $autoIncrementKey = null, - public readonly bool $isSoftDelete = false, - public readonly bool $isOptimisticLock = false, ) - {} + { + $this->entityTraits = array_fill_keys(class_uses($entityClass), true); + } /** * @throws EntityException @@ -26,12 +33,7 @@ public function __construct( public static function fromEntitySchema(Schema $schema): TableConfig { /** @var Attributes\Table|null $tableAttribute */ - $tableAttribute = null; - foreach ($schema->attributes as $attribute) { - if ($attribute instanceof Attributes\Table) { - $tableAttribute = $attribute; - } - } + $tableAttribute = $schema->getFirstAttributeByClass(Attributes\Table::class); if (!$tableAttribute) { throw new EntityException(sprintf( 'Attribute `%s` not found in Entity `%s`', @@ -40,7 +42,6 @@ public static function fromEntitySchema(Schema $schema): TableConfig } $primaryKeys = []; $autoIncrementKey = null; - $isSoftDelete = $isOptimisticLock = false; foreach ($schema->columns as $column) { foreach ($column->attributes as $attribute) { @@ -52,21 +53,12 @@ public static function fromEntitySchema(Schema $schema): TableConfig } } } - foreach (class_uses($schema->class) as $traitClass) { - if ($traitClass === Traits\SoftDelete::class) { - $isSoftDelete = true; - } elseif ($traitClass === Traits\OptimisticLock::class) { - $isOptimisticLock = true; - } - } return new TableConfig( connectionName: $tableAttribute->connection, tableName: $tableAttribute->name, entityClass: $schema->class, primaryKeys: $primaryKeys, autoIncrementKey: $autoIncrementKey, - isSoftDelete: $isSoftDelete, - isOptimisticLock: $isOptimisticLock, ); } @@ -82,4 +74,19 @@ public function checkEntity(AbstractEntity $entity): void ); } } + + public function hasSoftDelete(): bool + { + return !empty($this->entityTraits[Traits\SoftDelete::class]); + } + + public function hasOptimisticLock(): bool + { + return !empty($this->entityTraits[Traits\OptimisticLock::class]); + } + + public function hasUpdatedAt(): bool + { + return !empty($this->entityTraits[Traits\UpdatedAt::class]); + } } \ No newline at end of file diff --git a/src/Traits/OptimisticLock.php b/src/Traits/OptimisticLock.php index 150c328..73e2a9a 100644 --- a/src/Traits/OptimisticLock.php +++ b/src/Traits/OptimisticLock.php @@ -4,5 +4,15 @@ trait OptimisticLock { - public int $version = 1; + protected int $lock_version = 1; + + public function getVersion(): int + { + return $this->lock_version; + } + + public function incrementVersion(): void + { + $this->lock_version++; + } } diff --git a/src/Traits/UpdatedAt.php b/src/Traits/UpdatedAt.php new file mode 100644 index 0000000..1e8d878 --- /dev/null +++ b/src/Traits/UpdatedAt.php @@ -0,0 +1,8 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Traits; + +trait UpdatedAt +{ + public ?\DateTimeImmutable $updated_at = null; +} diff --git a/src/Where.php b/src/Where.php new file mode 100644 index 0000000..147b3aa --- /dev/null +++ b/src/Where.php @@ -0,0 +1,16 @@ +<?php declare(strict_types=1); + +namespace Composite\DB; + +class Where +{ + /** + * @param string $condition free format where string, example: "user_id = :user_id OR user_id > 0" + * @param array<string, mixed> $params params with placeholders, which used in $condition, example: ['user_id' => 123], + */ + public function __construct( + public readonly string $condition, + public readonly array $params, + ) { + } +} \ No newline at end of file diff --git a/tests/Attributes/PrimaryKeyAttributeTest.php b/tests/Attributes/PrimaryKeyAttributeTest.php index 26b6c55..1fd6c72 100644 --- a/tests/Attributes/PrimaryKeyAttributeTest.php +++ b/tests/Attributes/PrimaryKeyAttributeTest.php @@ -5,10 +5,11 @@ use Composite\DB\TableConfig; use Composite\Entity\AbstractEntity; use Composite\DB\Attributes; +use PHPUnit\Framework\Attributes\DataProvider; final class PrimaryKeyAttributeTest extends \PHPUnit\Framework\TestCase { - public function primaryKey_dataProvider(): array + public static function primaryKey_dataProvider(): array { return [ [ @@ -34,9 +35,7 @@ public function __construct( ]; } - /** - * @dataProvider primaryKey_dataProvider - */ + #[DataProvider('primaryKey_dataProvider')] public function test_primaryKey(AbstractEntity $entity, array $expected): void { $schema = $entity::schema(); diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php new file mode 100644 index 0000000..d17d625 --- /dev/null +++ b/tests/Connection/ConnectionManagerTest.php @@ -0,0 +1,70 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\Connection; + +use Composite\DB\ConnectionManager; +use Composite\DB\Exceptions\DbException; +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\Attributes\DataProvider; + +final class ConnectionManagerTest extends \PHPUnit\Framework\TestCase +{ + public function test_getConnection(): void + { + $connection = ConnectionManager::getConnection('sqlite'); + $this->assertInstanceOf(Connection::class, $connection); + } + + public static function invalidConfig_dataProvider(): array + { + $testStandConfigsBaseDir = __DIR__ . '/../TestStand/configs/'; + return [ + [ + '', + ], + [ + 'invalid/path', + ], + [ + $testStandConfigsBaseDir . 'empty_config.php', + ], + [ + $testStandConfigsBaseDir . 'wrong_content_config.php', + ], + [ + $testStandConfigsBaseDir . 'wrong_name_config.php', + ], + [ + $testStandConfigsBaseDir . 'wrong_params_config.php', + ], + [ + $testStandConfigsBaseDir . 'wrong_doctrine_config.php', + ], + ]; + } + + #[DataProvider('invalidConfig_dataProvider')] + public function test_invalidConfig(string $configPath): void + { + $reflection = new \ReflectionClass(ConnectionManager::class); + $reflection->setStaticPropertyValue('configs', null); + $currentPath = getenv('CONNECTIONS_CONFIG_FILE'); + putenv('CONNECTIONS_CONFIG_FILE=' . $configPath); + + try { + ConnectionManager::getConnection('db1'); + $this->fail('This line should not be reached'); + } catch (DbException) { + $this->assertTrue(true); + } finally { + putenv('CONNECTIONS_CONFIG_FILE=' . $currentPath); + $reflection->setStaticPropertyValue('configs', null); + } + } + + public function test_getConnectionWithMissingName(): void + { + $this->expectException(DbException::class); + ConnectionManager::getConnection('invalid_name'); + } +} \ No newline at end of file diff --git a/tests/Table/BaseTableTest.php b/tests/Helpers/CacheHelper.php similarity index 83% rename from tests/Table/BaseTableTest.php rename to tests/Helpers/CacheHelper.php index 2b811c7..a6ad2ec 100644 --- a/tests/Table/BaseTableTest.php +++ b/tests/Helpers/CacheHelper.php @@ -1,11 +1,11 @@ <?php declare(strict_types=1); -namespace Composite\DB\Tests\Table; +namespace Composite\DB\Tests\Helpers; use Kodus\Cache\FileCache; use Psr\SimpleCache\CacheInterface; -abstract class BaseTableTest extends \PHPUnit\Framework\TestCase +class CacheHelper { private static ?CacheInterface $cache = null; diff --git a/tests/Helpers/FalseCache.php b/tests/Helpers/FalseCache.php new file mode 100644 index 0000000..40da6d5 --- /dev/null +++ b/tests/Helpers/FalseCache.php @@ -0,0 +1,49 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\Helpers; + +use Psr\SimpleCache\CacheInterface; + +class FalseCache implements CacheInterface +{ + + public function get(string $key, mixed $default = null): mixed + { + return null; + } + + public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool + { + return false; + } + + public function delete(string $key): bool + { + return false; + } + + public function clear(): bool + { + return false; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + return []; + } + + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + return false; + } + + public function deleteMultiple(iterable $keys): bool + { + return false; + } + + public function has(string $key): bool + { + return false; + } +} diff --git a/tests/Helpers/StringHelper.php b/tests/Helpers/StringHelper.php new file mode 100644 index 0000000..f94fa3b --- /dev/null +++ b/tests/Helpers/StringHelper.php @@ -0,0 +1,11 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\Helpers; + +class StringHelper +{ + public static function getUniqueName(): string + { + return (new \DateTime())->format('U') . '_' . uniqid(); + } +} \ No newline at end of file diff --git a/tests/MultiQuery/MultiInsertTest.php b/tests/MultiQuery/MultiInsertTest.php new file mode 100644 index 0000000..69631a9 --- /dev/null +++ b/tests/MultiQuery/MultiInsertTest.php @@ -0,0 +1,58 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\MultiQuery; + +use Composite\DB\ConnectionManager; +use Composite\DB\MultiQuery\MultiInsert; +use PHPUnit\Framework\Attributes\DataProvider; + +class MultiInsertTest extends \PHPUnit\Framework\TestCase +{ + #[DataProvider('multiInsertQuery_dataProvider')] + public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expectedParameters) + { + $connection = ConnectionManager::getConnection('sqlite'); + $multiInserter = new MultiInsert($connection, $tableName, $rows); + + $this->assertEquals($expectedSql, $multiInserter->getSql()); + $this->assertEquals($expectedParameters, $multiInserter->getParameters()); + } + + public static function multiInsertQuery_dataProvider() + { + return [ + [ + 'testTable', + [], + '', + [] + ], + [ + 'testTable', + [ + ['a' => 'value1_1', 'b' => 'value2_1'], + ], + 'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0);', + ['a0' => 'value1_1', 'b0' => 'value2_1'] + ], + [ + 'testTable', + [ + ['a' => 'value1_1', 'b' => 'value2_1'], + ['a' => 'value1_2', 'b' => 'value2_2'] + ], + 'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0), (:a1, :b1);', + ['a0' => 'value1_1', 'b0' => 'value2_1', 'a1' => 'value1_2', 'b1' => 'value2_2'] + ], + [ + 'testTable', + [ + ['column1' => 'value1_1'], + ['column1' => 123] + ], + 'INSERT INTO "testTable" ("column1") VALUES (:column10), (:column11);', + ['column10' => 'value1_1', 'column11' => 123] + ] + ]; + } +} diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php index 544c09b..d9e706e 100644 --- a/tests/Table/AbstractCachedTableTest.php +++ b/tests/Table/AbstractCachedTableTest.php @@ -3,16 +3,21 @@ namespace Composite\DB\Tests\Table; use Composite\DB\AbstractCachedTable; -use Composite\DB\AbstractTable; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Tables; +use Composite\DB\Where; use Composite\Entity\AbstractEntity; +use Composite\DB\Tests\Helpers; +use PHPUnit\Framework\Attributes\DataProvider; +use Ramsey\Uuid\Uuid; -final class AbstractCachedTableTest extends BaseTableTest +final class AbstractCachedTableTest extends \PHPUnit\Framework\TestCase { - public function getOneCacheKey_dataProvider(): array + public static function getOneCacheKey_dataProvider(): array { - $cache = self::getCache(); + $cache = Helpers\CacheHelper::getCache(); + $uuid = Uuid::uuid4(); + $uuidCacheKey = str_replace('-', '_', (string)$uuid); return [ [ new Tables\TestAutoincrementCachedTable($cache), @@ -26,24 +31,19 @@ public function getOneCacheKey_dataProvider(): array ], [ new Tables\TestUniqueCachedTable($cache), - new Entities\TestUniqueEntity(id: '123abc', name: 'John'), - 'sqlite.TestUnique.v1.o.id_123abc', + new Entities\TestUniqueEntity(id: $uuid, name: 'John'), + 'sqlite.TestUnique.v1.o.id_' . $uuidCacheKey, ], [ - new Tables\TestUniqueCachedTable($cache), - new Entities\TestUniqueEntity( - id: implode('', array_fill(0, 100, 'a')), - name: 'John', - ), - 'ed66f06444d851a981a9ddcecbbf4d5860cd3131', + new Tables\TestCompositeCachedTable($cache), + new Entities\TestCompositeEntity(user_id: PHP_INT_MAX, post_id: PHP_INT_MAX, message: 'Text'), + '69b5bbf599d78f0274feb5cb0e6424f35cca0b57', ], ]; } - /** - * @dataProvider getOneCacheKey_dataProvider - */ - public function test_getOneCacheKey(AbstractTable $table, AbstractEntity $object, string $expected): void + #[DataProvider('getOneCacheKey_dataProvider')] + public function test_getOneCacheKey(AbstractCachedTable $table, AbstractEntity $object, string $expected): void { $reflectionMethod = new \ReflectionMethod($table, 'getOneCacheKey'); $actual = $reflectionMethod->invoke($table, $object); @@ -51,89 +51,102 @@ public function test_getOneCacheKey(AbstractTable $table, AbstractEntity $object } - public function getCountCacheKey_dataProvider(): array + public static function getCountCacheKey_dataProvider(): array { return [ [ - '', [], 'sqlite.TestAutoincrement.v1.c.all', ], [ - 'name = :name', - ['name' => 'John'], + new Where('name = :name', ['name' => 'John']), 'sqlite.TestAutoincrement.v1.c.name_eq_john', ], [ - ' name = :name ', ['name' => 'John'], + 'sqlite.TestAutoincrement.v1.c.name_john', + ], + [ + new Where(' name = :name ', ['name' => 'John']), 'sqlite.TestAutoincrement.v1.c.name_eq_john', ], [ - 'name=:name', - ['name' => 'John'], + new Where('name=:name', ['name' => 'John']), 'sqlite.TestAutoincrement.v1.c.name_eq_john', ], [ - 'name = :name AND id > :id', - ['name' => 'John', 'id' => 10], + new Where('name = :name AND id > :id', ['name' => 'John', 'id' => 10]), 'sqlite.TestAutoincrement.v1.c.name_eq_john_and_id_gt_10', ], + [ + ['name' => 'John', 'id' => ['>', 10]], + 'sqlite.TestAutoincrement.v1.c.name_john_id_gt_10', + ], ]; } - /** - * @dataProvider getCountCacheKey_dataProvider - */ - public function test_getCountCacheKey(string $whereString, array $whereParams, string $expected): void + #[DataProvider('getCountCacheKey_dataProvider')] + public function test_getCountCacheKey(array|Where $where, string $expected): void { - $table = new Tables\TestAutoincrementCachedTable(self::getCache()); + $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()); $reflectionMethod = new \ReflectionMethod($table, 'getCountCacheKey'); - $actual = $reflectionMethod->invoke($table, $whereString, $whereParams); + $actual = $reflectionMethod->invoke($table, $where); $this->assertEquals($expected, $actual); } - public function getListCacheKey_dataProvider(): array + public static function getListCacheKey_dataProvider(): array { return [ [ - '', [], [], null, 'sqlite.TestAutoincrement.v1.l.all', ], [ - '', [], [], 10, 'sqlite.TestAutoincrement.v1.l.all.limit_10', ], [ - '', [], ['id' => 'DESC'], 10, 'sqlite.TestAutoincrement.v1.l.all.ob_id_desc.limit_10', ], [ - 'name = :name', + new Where('name = :name', ['name' => 'John']), + [], + null, + 'sqlite.TestAutoincrement.v1.l.name_eq_john', + ], + [ ['name' => 'John'], [], null, + 'sqlite.TestAutoincrement.v1.l.name_john', + ], + [ + new Where('name = :name', ['name' => 'John']), + [], + null, 'sqlite.TestAutoincrement.v1.l.name_eq_john', ], [ - 'name = :name AND id > :id', - ['name' => 'John', 'id' => 10], + new Where('name = :name AND id > :id', ['name' => 'John', 'id' => 10]), [], null, 'sqlite.TestAutoincrement.v1.l.name_eq_john_and_id_gt_10', ], [ - 'name = :name AND id > :id', - ['name' => 'John', 'id' => 10], + ['name' => 'John', 'id' => ['>', 10]], + [], + null, + 'sqlite.TestAutoincrement.v1.l.name_john_id_gt_10', + ], + [ + new Where('name = :name AND id > :id', ['name' => 'John', 'id' => 10]), ['id' => 'ASC'], 20, 'bbcf331b765b682da02c4d21dbaa3342bf2c3f18', //sha1('sqlite.TestAutoincrement.v1.l.name_eq_john_and_id_gt_10.ob_id_asc.limit_20') @@ -141,19 +154,17 @@ public function getListCacheKey_dataProvider(): array ]; } - /** - * @dataProvider getListCacheKey_dataProvider - */ - public function test_getListCacheKey(string $whereString, array $whereArray, array $orderBy, ?int $limit, string $expected): void + #[DataProvider('getListCacheKey_dataProvider')] + public function test_getListCacheKey(array|Where $where, array $orderBy, ?int $limit, string $expected): void { - $table = new Tables\TestAutoincrementCachedTable(self::getCache()); + $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()); $reflectionMethod = new \ReflectionMethod($table, 'getListCacheKey'); - $actual = $reflectionMethod->invoke($table, $whereString, $whereArray, $orderBy, $limit); + $actual = $reflectionMethod->invoke($table, $where, $orderBy, $limit); $this->assertEquals($expected, $actual); } - public function getCustomCacheKey_dataProvider(): array + public static function getCustomCacheKey_dataProvider(): array { return [ [ @@ -179,23 +190,23 @@ public function getCustomCacheKey_dataProvider(): array ]; } - /** - * @dataProvider getCustomCacheKey_dataProvider - */ + #[DataProvider('getCustomCacheKey_dataProvider')] public function test_getCustomCacheKey(array $parts, string $expected): void { - $table = new Tables\TestAutoincrementCachedTable(self::getCache()); + $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()); $reflectionMethod = new \ReflectionMethod($table, 'buildCacheKey'); $actual = $reflectionMethod->invoke($table, ...$parts); $this->assertEquals($expected, $actual); } - public function collectCacheKeysByEntity_dataProvider(): array + public static function collectCacheKeysByEntity_dataProvider(): array { + $uuid = Uuid::uuid4(); + $uuidCacheKey = str_replace('-', '_', (string)$uuid); return [ [ new Entities\TestAutoincrementEntity(name: 'foo'), - new Tables\TestAutoincrementCachedTable(self::getCache()), + new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()), [ 'sqlite.TestAutoincrement.v1.o.name_foo', 'sqlite.TestAutoincrement.v1.l.name_eq_foo', @@ -204,7 +215,7 @@ public function collectCacheKeysByEntity_dataProvider(): array ], [ Entities\TestAutoincrementEntity::fromArray(['id' => 123, 'name' => 'bar']), - new Tables\TestAutoincrementCachedTable(self::getCache()), + new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()), [ 'sqlite.TestAutoincrement.v1.o.name_bar', 'sqlite.TestAutoincrement.v1.l.name_eq_bar', @@ -213,33 +224,54 @@ public function collectCacheKeysByEntity_dataProvider(): array ], ], [ - new Entities\TestUniqueEntity(id: '123abc', name: 'foo'), - new Tables\TestUniqueCachedTable(self::getCache()), + new Entities\TestUniqueEntity(id: $uuid, name: 'foo'), + new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()), [ 'sqlite.TestUnique.v1.l.name_eq_foo', 'sqlite.TestUnique.v1.c.name_eq_foo', - 'sqlite.TestUnique.v1.o.id_123abc', + 'sqlite.TestUnique.v1.o.id_' . $uuidCacheKey, ], ], [ - Entities\TestUniqueEntity::fromArray(['id' => '456def', 'name' => 'bar']), - new Tables\TestUniqueCachedTable(self::getCache()), + Entities\TestUniqueEntity::fromArray(['id' => $uuid, 'name' => 'bar']), + new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()), [ 'sqlite.TestUnique.v1.l.name_eq_bar', 'sqlite.TestUnique.v1.c.name_eq_bar', - 'sqlite.TestUnique.v1.o.id_456def', + 'sqlite.TestUnique.v1.o.id_' . $uuidCacheKey, ], ], ]; } - /** - * @dataProvider collectCacheKeysByEntity_dataProvider - */ + #[DataProvider('collectCacheKeysByEntity_dataProvider')] public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCachedTable $table, array $expected): void { $reflectionMethod = new \ReflectionMethod($table, 'collectCacheKeysByEntity'); $actual = $reflectionMethod->invoke($table, $entity); $this->assertEquals($expected, $actual); } + + public function test_findMulti(): void + { + $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()); + $e1 = new Entities\TestAutoincrementEntity('John'); + $e2 = new Entities\TestAutoincrementEntity('Constantine'); + + $table->save($e1); + $table->save($e2); + + $multi1 = $table->findMulti([$e1->id], 'id'); + $this->assertEquals($e1, $multi1[$e1->id]); + + $multi2 = $table->findMulti([$e1->id, $e2->id]); + $this->assertEquals($e1, $multi2[0]); + $this->assertEquals($e2, $multi2[1]); + + $e11 = $table->findByPk($e1->id); + $this->assertEquals($e1, $e11); + + $e111 = $table->findByPk($e1->id); + $this->assertEquals($e1, $e111); + } } \ No newline at end of file diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php index 0a7e78c..c14e58a 100644 --- a/tests/Table/AbstractTableTest.php +++ b/tests/Table/AbstractTableTest.php @@ -3,17 +3,19 @@ namespace Composite\DB\Tests\Table; use Composite\DB\AbstractTable; -use Composite\DB\ConnectionManager; -use Composite\DB\Exceptions\DbException; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Tables; use Composite\Entity\AbstractEntity; use Composite\Entity\Exceptions\EntityException; +use PHPUnit\Framework\Attributes\DataProvider; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; -final class AbstractTableTest extends BaseTableTest +final class AbstractTableTest extends \PHPUnit\Framework\TestCase { - public function getPkCondition_dataProvider(): array + public static function getPkCondition_dataProvider(): array { + $uuid = Uuid::uuid4(); return [ [ new Tables\TestAutoincrementTable(), @@ -37,156 +39,162 @@ public function getPkCondition_dataProvider(): array ], [ new Tables\TestUniqueTable(), - new Entities\TestUniqueEntity(id: '123abc', name: 'John'), - ['id' => '123abc'], + new Entities\TestUniqueEntity(id: $uuid, name: 'John'), + ['id' => $uuid->toString()], ], [ new Tables\TestUniqueTable(), - '123abc', - ['id' => '123abc'], + $uuid, + ['id' => $uuid->toString()], ], [ new Tables\TestAutoincrementSdTable(), Entities\TestAutoincrementSdEntity::fromArray(['id' => 123, 'name' => 'John']), ['id' => 123], ], - [ - new Tables\TestCompositeSdTable(), - new Entities\TestCompositeSdEntity(user_id: 123, post_id: 456, message: 'Text'), - ['user_id' => 123, 'post_id' => 456], - ], - [ - new Tables\TestUniqueSdTable(), - new Entities\TestUniqueSdEntity(id: '123abc', name: 'John'), - ['id' => '123abc'], - ], ]; } - /** - * @dataProvider getPkCondition_dataProvider - */ - public function test_getPkCondition(AbstractTable $table, int|string|array|AbstractEntity $object, array $expected): void + #[DataProvider('getPkCondition_dataProvider')] + public function test_getPkCondition(AbstractTable $table, int|string|array|AbstractEntity|UuidInterface $object, array $expected): void { $reflectionMethod = new \ReflectionMethod($table, 'getPkCondition'); $actual = $reflectionMethod->invoke($table, $object); $this->assertEquals($expected, $actual); } - public function enrichCondition_dataProvider(): array + public function test_illegalEntitySave(): void { - return [ - [ - new Tables\TestAutoincrementTable(), - ['id' => 123], - ['id' => 123], - ], - [ - new Tables\TestCompositeTable(), - ['user_id' => 123, 'post_id' => 456], - ['user_id' => 123, 'post_id' => 456], - ], - [ - new Tables\TestUniqueTable(), - ['id' => '123abc'], - ['id' => '123abc'], - ], - [ - new Tables\TestAutoincrementSdTable(), - ['id' => 123], - ['id' => 123, 'deleted_at' => null], - ], - [ - new Tables\TestCompositeSdTable(), - ['user_id' => 123, 'post_id' => 456], - ['user_id' => 123, 'post_id' => 456, 'deleted_at' => null], - ], - [ - new Tables\TestUniqueSdTable(), - ['id' => '123abc'], - ['id' => '123abc', 'deleted_at' => null], - ], - ]; - } + $entity = new Entities\TestAutoincrementEntity(name: 'Foo'); + $compositeTable = new Tables\TestUniqueTable(); - /** - * @dataProvider enrichCondition_dataProvider - */ - public function test_enrichCondition(AbstractTable $table, array $condition, array $expected): void - { - $reflectionMethod = new \ReflectionMethod($table, 'enrichCondition'); - $reflectionMethod->invokeArgs($table, [&$condition]); - $this->assertEquals($expected, $condition); + $this->expectException(EntityException::class); + $compositeTable->save($entity); } - public function test_illegalEntitySave(): void + public function test_illegalCreateEntity(): void { - $entity = new Entities\TestAutoincrementEntity(name: 'Foo'); - $compositeTable = new Tables\TestUniqueTable(); + $table = new Tables\TestStrictTable(); + $null = $table->buildEntity(['dti1' => 'abc']); + $this->assertNull($null); + + $empty = $table->buildEntities([['dti1' => 'abc']]); + $this->assertEmpty($empty); + + $empty = $table->buildEntities([]); + $this->assertEmpty($empty); - $exceptionCatch = false; - try { - $compositeTable->save($entity); - } catch (EntityException) { - $exceptionCatch = true; - } - $this->assertTrue($exceptionCatch); + $empty = $table->buildEntities(false); + $this->assertEmpty($empty); + + $empty = $table->buildEntities('abc'); + $this->assertEmpty($empty); + + $empty = $table->buildEntities(['abc']); + $this->assertEmpty($empty); } - public function test_optimisticLock(): void + #[DataProvider('buildWhere_dataProvider')] + public function test_buildWhere($where, $expectedSQL, $expectedParams): void { - //checking that problem exists - $aiEntity1 = new Entities\TestAutoincrementEntity(name: 'John'); - $aiTable1 = new Tables\TestAutoincrementTable(); - $aiTable2 = new Tables\TestAutoincrementTable(); + $table = new Tables\TestStrictTable(); - $aiTable1->save($aiEntity1); + $selectReflection = new \ReflectionMethod($table, 'select'); + $selectReflection->setAccessible(true); - $aiEntity2 = $aiTable2->findByPk($aiEntity1->id); + $queryBuilder = $selectReflection->invoke($table); - $db = ConnectionManager::getConnection($aiTable1->getConnectionName()); + $buildWhereReflection = new \ReflectionMethod($table, 'buildWhere'); + $buildWhereReflection->setAccessible(true); - $db->beginTransaction(); - $aiEntity1->name = 'John1'; - $aiTable1->save($aiEntity1); + $buildWhereReflection->invokeArgs($table, [$queryBuilder, $where]); - $aiEntity2->name = 'John2'; - $aiTable2->save($aiEntity2); + $this->assertEquals($expectedSQL, $queryBuilder->getSQL()); + $this->assertEquals($expectedParams, $queryBuilder->getParameters()); + } - $this->assertTrue($db->commit()); + public static function buildWhere_dataProvider(): array + { + return [ + // Scalar value + [ + ['column' => 1], + 'SELECT * FROM Strict WHERE column = :column', + ['column' => 1] + ], - $aiEntity3 = $aiTable1->findByPk($aiEntity1->id); - $this->assertEquals('John2', $aiEntity3->name); + // Null value + [ + ['column' => null], + 'SELECT * FROM Strict WHERE column IS NULL', + [] + ], - //Checking optimistic lock - $olEntity1 = new Entities\TestOptimisticLockEntity(name: 'John'); - $olTable1 = new Tables\TestOptimisticLockTable(); - $olTable2 = new Tables\TestOptimisticLockTable(); + // Greater than comparison + [ + ['column' => ['>', 0]], + 'SELECT * FROM Strict WHERE column > :column', + ['column' => 0] + ], - $olTable1->init(); + // Less than comparison + [ + ['column' => ['<', 5]], + 'SELECT * FROM Strict WHERE column < :column', + ['column' => 5] + ], + + // Greater than or equal to comparison + [ + ['column' => ['>=', 3]], + 'SELECT * FROM Strict WHERE column >= :column', + ['column' => 3] + ], - $olTable1->save($olEntity1); + // Less than or equal to comparison + [ + ['column' => ['<=', 7]], + 'SELECT * FROM Strict WHERE column <= :column', + ['column' => 7] + ], - $olEntity2 = $olTable2->findByPk($olEntity1->id); + // Not equal to comparison with scalar value + [ + ['column' => ['<>', 10]], + 'SELECT * FROM Strict WHERE column <> :column', + ['column' => 10] + ], - $db->beginTransaction(); - $olEntity1->name = 'John1'; - $olTable1->save($olEntity1); + // Not equal to comparison with null + [ + ['column' => ['!=', null]], + 'SELECT * FROM Strict WHERE column IS NOT NULL', + [] + ], - $olEntity2->name = 'John2'; + // IN condition + [ + ['column' => [1, 2, 3]], + 'SELECT * FROM Strict WHERE column IN(:column0, :column1, :column2)', + ['column0' => 1, 'column1' => 2, 'column2' => 3] + ], - $exceptionCaught = false; - try { - $olTable2->save($olEntity2); - } catch (DbException) { - $exceptionCaught = true; - } - $this->assertTrue($exceptionCaught); + // Multiple conditions + [ + ['column1' => 1, 'column2' => null, 'column3' => ['>', 5]], + 'SELECT * FROM Strict WHERE (column1 = :column1) AND (column2 IS NULL) AND (column3 > :column3)', + ['column1' => 1, 'column3' => 5] + ] + ]; + } - $this->assertTrue($db->rollBack()); + public function test_databaseSpecific(): void + { + $mySQLTable = new Tables\TestMySQLTable(); + $this->assertEquals('`column`', $mySQLTable->escapeIdentifierPub('column')); + $this->assertEquals('`Database`.`Table`', $mySQLTable->escapeIdentifierPub('Database.Table')); - $olEntity3 = $olTable1->findByPk($olEntity1->id); - $this->assertEquals(1, $olEntity3->version); - $this->assertEquals('John', $olEntity3->name); + $postgresTable = new Tables\TestPostgresTable(); + $this->assertEquals('"column"', $postgresTable->escapeIdentifierPub('column')); } } \ No newline at end of file diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php index c1a3535..03a7569 100644 --- a/tests/Table/AutoIncrementTableTest.php +++ b/tests/Table/AutoIncrementTableTest.php @@ -2,19 +2,18 @@ namespace Composite\DB\Tests\Table; +use Composite\DB\AbstractTable; +use Composite\DB\Exceptions\DbException; +use Composite\DB\TableConfig; +use Composite\DB\Tests\Helpers; use Composite\DB\Tests\TestStand\Tables; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Interfaces\IAutoincrementTable; +use PHPUnit\Framework\Attributes\DataProvider; -final class AutoIncrementTableTest extends BaseTableTest +final class AutoIncrementTableTest extends \PHPUnit\Framework\TestCase { - public static function setUpBeforeClass(): void - { - (new Tables\TestAutoincrementTable())->init(); - (new Tables\TestAutoincrementSdTable())->init(); - } - - public function crud_dataProvider(): array + public static function crud_dataProvider(): array { return [ [ @@ -26,25 +25,28 @@ public function crud_dataProvider(): array Entities\TestAutoincrementSdEntity::class, ], [ - new Tables\TestAutoincrementCachedTable(self::getCache()), + new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache()), Entities\TestAutoincrementEntity::class, ], [ - new Tables\TestAutoincrementSdCachedTable(self::getCache()), + new Tables\TestAutoincrementSdCachedTable(Helpers\CacheHelper::getCache()), Entities\TestAutoincrementSdEntity::class, ], ]; } /** - * @dataProvider crud_dataProvider + * @param class-string<Entities\TestAutoincrementEntity|Entities\TestAutoincrementSdEntity> $class */ - public function test_crud(IAutoincrementTable $table, string $class): void + #[DataProvider('crud_dataProvider')] + public function test_crud(AbstractTable&IAutoincrementTable $table, string $class): void { $table->truncate(); + $tableConfig = TableConfig::fromEntitySchema($class::schema()); $entity = new $class( - name: $this->getUniqueName(), + name: Helpers\StringHelper::getUniqueName(), + is_test: true, ); $this->assertEntityNotExists($table, PHP_INT_MAX, uniqid()); @@ -62,7 +64,81 @@ public function test_crud(IAutoincrementTable $table, string $class): void $this->assertEquals($newName, $foundEntity->name); $table->delete($entity); - $this->assertEntityNotExists($table, $entity->id, $entity->name); + if ($tableConfig->hasSoftDelete()) { + /** @var Entities\TestAutoincrementSdEntity $deletedEntity */ + $deletedEntity = $table->findByPk($entity->id); + $this->assertTrue($deletedEntity->isDeleted()); + } else { + $this->assertEntityNotExists($table, $entity->id, $entity->name); + } + + $e1 = new $class(Helpers\StringHelper::getUniqueName()); + $e2 = new $class(Helpers\StringHelper::getUniqueName()); + + $table->save($e1); + $table->save($e2); + $this->assertEntityExists($table, $e1); + $this->assertEntityExists($table, $e2); + + $recentEntities = $table->findRecent(2, 0); + $this->assertEquals($e2, $recentEntities[0]); + $this->assertEquals($e1, $recentEntities[1]); + $preLastEntity = $table->findRecent(1, 1); + $this->assertEquals($e1, $preLastEntity[0]); + + if ($tableConfig->hasSoftDelete()) { + $e1->name = 'Exception'; + $exceptionThrown = false; + try { + $table->deleteMany([$e1, $e2]); + } catch (\Exception) { + $exceptionThrown = true; + } + $this->assertTrue($exceptionThrown); + $e1->name = Helpers\StringHelper::getUniqueName(); + } + + $table->deleteMany([$e1, $e2]); + + if ($tableConfig->hasSoftDelete()) { + /** @var Entities\TestAutoincrementSdEntity $deletedEntity1 */ + $deletedEntity1 = $table->findByPk($e1->id); + $this->assertTrue($deletedEntity1->isDeleted()); + + /** @var Entities\TestAutoincrementSdEntity $deletedEntity2 */ + $deletedEntity2 = $table->findByPk($e2->id); + $this->assertTrue($deletedEntity2->isDeleted()); + } else { + $this->assertEntityNotExists($table, $e1->id, $e1->name); + $this->assertEntityNotExists($table, $e2->id, $e2->name); + } + } + + public function test_getMulti(): void + { + $table = new Tables\TestAutoincrementTable(); + + $e1 = new Entities\TestAutoincrementEntity('name1'); + $e2 = new Entities\TestAutoincrementEntity('name2'); + $e3 = new Entities\TestAutoincrementEntity('name3'); + + $table->save($e1); + $table->save($e2); + $table->save($e3); + + $multiResult = $table->findMulti([$e1->id, $e2->id, $e3->id]); + $this->assertEquals($e1, $multiResult[$e1->id]); + $this->assertEquals($e2, $multiResult[$e2->id]); + $this->assertEquals($e3, $multiResult[$e3->id]); + + $this->assertEmpty($table->findMulti([])); + } + + public function test_illegalGetMulti(): void + { + $table = new Tables\TestAutoincrementTable(); + $this->expectException(DbException::class); + $table->findMulti(['a' => 1]); } private function assertEntityExists(IAutoincrementTable $table, Entities\TestAutoincrementEntity $entity): void diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php index 7108179..f7ba7d9 100644 --- a/tests/Table/CombinedTransactionTest.php +++ b/tests/Table/CombinedTransactionTest.php @@ -6,8 +6,10 @@ use Composite\DB\Exceptions\DbException; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Tables; +use Composite\DB\Tests\Helpers; +use PHPUnit\Framework\Attributes\DataProvider; -final class CombinedTransactionTest extends BaseTableTest +final class CombinedTransactionTest extends \PHPUnit\Framework\TestCase { public function test_transactionCommit(): void { @@ -19,7 +21,7 @@ public function test_transactionCommit(): void $e1 = new Entities\TestAutoincrementEntity(name: 'Foo'); $saveTransaction->save($autoIncrementTable, $e1); - $e2 = new Entities\TestCompositeEntity(user_id: $e1->id, post_id: mt_rand(1, 1000), message: 'Bar'); + $e2 = new Entities\TestCompositeEntity(user_id: $e1->id, post_id: mt_rand(1, 1000000), message: 'Bar'); $saveTransaction->save($compositeTable, $e2); $saveTransaction->commit(); @@ -36,6 +38,32 @@ public function test_transactionCommit(): void $this->assertNull($compositeTable->findOne($e2->user_id, $e2->post_id)); } + public function test_saveDeleteMany(): void + { + $autoIncrementTable = new Tables\TestAutoincrementTable(); + $compositeTable = new Tables\TestCompositeTable(); + + $saveTransaction = new CombinedTransaction(); + + $e1 = new Entities\TestAutoincrementEntity(name: 'Foo'); + $saveTransaction->save($autoIncrementTable, $e1); + + $e2 = new Entities\TestCompositeEntity(user_id: $e1->id, post_id: mt_rand(1, 1000000), message: 'Foo'); + $e3 = new Entities\TestCompositeEntity(user_id: $e1->id, post_id: mt_rand(1, 1000000), message: 'Bar'); + $saveTransaction->saveMany($compositeTable, [$e2, $e3]); + + $saveTransaction->commit(); + + $this->assertNotNull($autoIncrementTable->findByPk($e1->id)); + $this->assertNotNull($compositeTable->findOne($e2->user_id, $e2->post_id)); + $this->assertNotNull($compositeTable->findOne($e3->user_id, $e3->post_id)); + + $deleteTransaction = new CombinedTransaction(); + $deleteTransaction->delete($autoIncrementTable, $e1); + $deleteTransaction->deleteMany($compositeTable, [$e2, $e3]); + $deleteTransaction->commit(); + } + public function test_transactionRollback(): void { $autoIncrementTable = new Tables\TestAutoincrementTable(); @@ -55,7 +83,7 @@ public function test_transactionRollback(): void $this->assertNull($compositeTable->findOne($e2->user_id, $e2->post_id)); } - public function test_transactionException(): void + public function test_failedSave(): void { $autoIncrementTable = new Tables\TestAutoincrementTable(); $compositeTable = new Tables\TestCompositeTable(); @@ -69,17 +97,69 @@ public function test_transactionException(): void try { $transaction->save($compositeTable, $e2); $transaction->commit(); - $this->assertFalse(true, 'This line should not be reached'); + $this->fail('This line should not be reached'); } catch (DbException) {} $this->assertNull($autoIncrementTable->findByPk($e1->id)); $this->assertNull($compositeTable->findOne($e2->user_id, $e2->post_id)); } + public function test_failedDelete(): void + { + $autoIncrementTable = new Tables\TestAutoincrementTable(); + $compositeTable = new Tables\TestCompositeTable(); + + $aiEntity = new Entities\TestAutoincrementEntity(name: 'Foo'); + $cEntity = new Entities\TestCompositeEntity(user_id: mt_rand(1, 1000), post_id: mt_rand(1, 1000), message: 'Bar');; + + $autoIncrementTable->save($aiEntity); + $compositeTable->save($cEntity); + + $transaction = new CombinedTransaction(); + try { + $aiEntity->name = 'Foo1'; + $cEntity->message = 'Exception'; + + $transaction->save($autoIncrementTable, $aiEntity); + $transaction->delete($compositeTable, $cEntity); + + $transaction->commit(); + $this->fail('This line should not be reached'); + } catch (DbException) {} + + $this->assertEquals('Foo', $autoIncrementTable->findByPk($aiEntity->id)->name); + $this->assertNotNull($compositeTable->findOne($cEntity->user_id, $cEntity->post_id)); + } + + public function test_try(): void + { + $compositeTable = new Tables\TestCompositeTable(); + $entity = new Entities\TestCompositeEntity(user_id: mt_rand(1, 1000), post_id: mt_rand(1, 1000), message: 'Bar');; + + try { + $transaction = new CombinedTransaction(); + $transaction->save($compositeTable, $entity); + $transaction->try(fn() => throw new \Exception('test')); + $transaction->commit(); + } catch (DbException) {} + $this->assertNull($compositeTable->findOne($entity->user_id, $entity->post_id)); + } + + public function test_lockFailed(): void + { + $cache = new Helpers\FalseCache(); + $keyParts = [uniqid()]; + $transaction = new CombinedTransaction(); + + $this->expectException(DbException::class); + $transaction->lock($cache, $keyParts); + } + public function test_lock(): void { - $cache = self::getCache(); + $cache = Helpers\CacheHelper::getCache(); $table = new Tables\TestAutoincrementTable(); + $e1 = new Entities\TestAutoincrementEntity(name: 'Foo'); $e2 = new Entities\TestAutoincrementEntity(name: 'Bar'); @@ -90,8 +170,10 @@ public function test_lock(): void $transaction2 = new CombinedTransaction(); try { $transaction2->lock($cache, $keyParts); - $this->assertFalse(false, 'Lock should not be free'); - } catch (DbException) {} + $this->fail('Lock should not be free'); + } catch (DbException) { + $this->assertTrue(true); + } $transaction1->save($table, $e1); $transaction1->commit(); @@ -103,4 +185,23 @@ public function test_lock(): void $this->assertNotEmpty($table->findByPk($e1->id)); $this->assertNotEmpty($table->findByPk($e2->id)); } + + #[DataProvider('buildLockKey_dataProvider')] + public function test_buildLockKey($keyParts, $expectedResult) + { + $reflection = new \ReflectionClass(CombinedTransaction::class); + $object = new CombinedTransaction(); + $result = $reflection->getMethod('buildLockKey')->invoke($object, $keyParts); + $this->assertEquals($expectedResult, $result); + } + + public static function buildLockKey_dataProvider() + { + return [ + 'empty array' => [[], 'composite.lock'], + 'one element' => [['element'], 'composite.lock.element'], + 'exact length' => [[str_repeat('a', 49)], 'composite.lock.' . str_repeat('a', 49)], + 'more than max length' => [[str_repeat('a', 55)], sha1('composite.lock.' . str_repeat('a', 55))], + ]; + } } \ No newline at end of file diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php index 87bc767..09f784b 100644 --- a/tests/Table/CompositeTableTest.php +++ b/tests/Table/CompositeTableTest.php @@ -2,19 +2,18 @@ namespace Composite\DB\Tests\Table; +use Composite\DB\AbstractTable; +use Composite\DB\Exceptions\DbException; +use Composite\DB\TableConfig; +use Composite\DB\Tests\Helpers; use Composite\DB\Tests\TestStand\Tables; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Interfaces\ICompositeTable; +use PHPUnit\Framework\Attributes\DataProvider; -final class CompositeTableTest extends BaseTableTest +final class CompositeTableTest extends \PHPUnit\Framework\TestCase { - public static function setUpBeforeClass(): void - { - (new Tables\TestCompositeTable())->init(); - (new Tables\TestCompositeSdTable())->init(); - } - - public function crud_dataProvider(): array + public static function crud_dataProvider(): array { return [ [ @@ -22,31 +21,25 @@ public function crud_dataProvider(): array Entities\TestCompositeEntity::class, ], [ - new Tables\TestCompositeSdTable(), - Entities\TestCompositeSdEntity::class, - ], - [ - new Tables\TestCompositeCachedTable(self::getCache()), + new Tables\TestCompositeCachedTable(Helpers\CacheHelper::getCache()), Entities\TestCompositeEntity::class, ], - [ - new Tables\TestCompositeSdCachedTable(self::getCache()), - Entities\TestCompositeSdEntity::class, - ], ]; } /** - * @dataProvider crud_dataProvider + * @param class-string<Entities\TestCompositeEntity> $class + * @throws \Throwable */ - public function test_crud(ICompositeTable $table, string $class): void + #[DataProvider('crud_dataProvider')] + public function test_crud(AbstractTable&ICompositeTable $table, string $class): void { $table->truncate(); $entity = new $class( user_id: mt_rand(1, 1000000), post_id: mt_rand(1, 1000000), - message: $this->getUniqueName(), + message: Helpers\StringHelper::getUniqueName(), ); $this->assertEntityNotExists($table, $entity); $table->save($entity); @@ -59,13 +52,74 @@ public function test_crud(ICompositeTable $table, string $class): void $table->delete($entity); $this->assertEntityNotExists($table, $entity); - $newEntity = new $entity( - user_id: $entity->user_id, - post_id: $entity->post_id, - message: 'Hello User', + $e1 = new $class( + user_id: mt_rand(1, 1000000), + post_id: mt_rand(1, 1000000), + message: Helpers\StringHelper::getUniqueName(), + ); + $e2 = new $class( + user_id: mt_rand(1, 1000000), + post_id: mt_rand(1, 1000000), + message: Helpers\StringHelper::getUniqueName(), + ); + + $table->saveMany([$e1, $e2]); + $e1->resetChangedColumns(); + $e2->resetChangedColumns(); + + $this->assertEntityExists($table, $e1); + $this->assertEntityExists($table, $e2); + + $table->deleteMany([$e1, $e2]); + + $this->assertEntityNotExists($table, $e1); + $this->assertEntityNotExists($table, $e2); + } + + public function test_getMulti(): void + { + $table = new Tables\TestCompositeTable(); + $userId = mt_rand(1, 1000000); + + $e1 = new Entities\TestCompositeEntity( + user_id: $userId, + post_id: mt_rand(1, 1000000), + message: Helpers\StringHelper::getUniqueName(), ); - $table->save($newEntity); - $this->assertEntityExists($table, $newEntity); + + $e2 = new Entities\TestCompositeEntity( + user_id: $userId, + post_id: mt_rand(1, 1000000), + message: Helpers\StringHelper::getUniqueName(), + ); + + $e3 = new Entities\TestCompositeEntity( + user_id: $userId, + post_id: mt_rand(1, 1000000), + message: Helpers\StringHelper::getUniqueName(), + ); + + $table->saveMany([$e1, $e2, $e3]); + + $e1->resetChangedColumns(); + $e2->resetChangedColumns(); + $e3->resetChangedColumns(); + + $multiResult = $table->findMulti([ + ['user_id' => $e1->user_id, 'post_id' => $e1->post_id], + ['user_id' => $e2->user_id, 'post_id' => $e2->post_id], + ['user_id' => $e3->user_id, 'post_id' => $e3->post_id], + ]); + $this->assertEquals($e1, $multiResult[$e1->post_id]); + $this->assertEquals($e2, $multiResult[$e2->post_id]); + $this->assertEquals($e3, $multiResult[$e3->post_id]); + } + + public function test_illegalGetMulti(): void + { + $table = new Tables\TestCompositeTable(); + $this->expectException(DbException::class); + $table->findMulti(['a']); } private function assertEntityExists(ICompositeTable $table, Entities\TestCompositeEntity $entity): void diff --git a/tests/Table/TableConfigTest.php b/tests/Table/TableConfigTest.php index b4c558e..e74f318 100644 --- a/tests/Table/TableConfigTest.php +++ b/tests/Table/TableConfigTest.php @@ -6,6 +6,7 @@ use Composite\DB\TableConfig; use Composite\DB\Traits; use Composite\Entity\AbstractEntity; +use Composite\Entity\Exceptions\EntityException; use Composite\Entity\Schema; final class TableConfigTest extends \PHPUnit\Framework\TestCase @@ -26,12 +27,28 @@ public function __construct( private \DateTimeImmutable $dt = new \DateTimeImmutable(), ) {} }; - $schema = Schema::build($class::class); + $schema = new Schema($class::class); $tableConfig = TableConfig::fromEntitySchema($schema); $this->assertNotEmpty($tableConfig->connectionName); $this->assertNotEmpty($tableConfig->tableName); - $this->assertTrue($tableConfig->isSoftDelete); + $this->assertTrue($tableConfig->hasSoftDelete()); $this->assertCount(1, $tableConfig->primaryKeys); $this->assertSame('id', $tableConfig->autoIncrementKey); } + + public function test_missingAttribute(): void + { + $class = new + class extends AbstractEntity { + #[Attributes\PrimaryKey(autoIncrement: true)] + public readonly int $id; + + public function __construct( + public string $str = 'abc', + ) {} + }; + $schema = new Schema($class::class); + $this->expectException(EntityException::class); + TableConfig::fromEntitySchema($schema); + } } \ No newline at end of file diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php index cb834c3..abeb9ff 100644 --- a/tests/Table/UniqueTableTest.php +++ b/tests/Table/UniqueTableTest.php @@ -2,19 +2,17 @@ namespace Composite\DB\Tests\Table; +use Composite\DB\AbstractTable; +use Composite\DB\Tests\Helpers; use Composite\DB\Tests\TestStand\Entities; use Composite\DB\Tests\TestStand\Tables; use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable; +use PHPUnit\Framework\Attributes\DataProvider; +use Ramsey\Uuid\Uuid; -final class UniqueTableTest extends BaseTableTest +final class UniqueTableTest extends \PHPUnit\Framework\TestCase { - public static function setUpBeforeClass(): void - { - (new Tables\TestUniqueTable())->init(); - (new Tables\TestUniqueSdTable())->init(); - } - - public function crud_dataProvider(): array + public static function crud_dataProvider(): array { return [ [ @@ -22,30 +20,23 @@ public function crud_dataProvider(): array Entities\TestUniqueEntity::class, ], [ - new Tables\TestUniqueSdTable(), - Entities\TestUniqueSdEntity::class, - ], - [ - new Tables\TestUniqueCachedTable(self::getCache()), + new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()), Entities\TestUniqueEntity::class, ], - [ - new Tables\TestUniqueSdCachedTable(self::getCache()), - Entities\TestUniqueSdEntity::class, - ], ]; } /** - * @dataProvider crud_dataProvider + * @param class-string<Entities\TestUniqueEntity> $class */ - public function test_crud(IUniqueTable $table, string $class): void + #[DataProvider('crud_dataProvider')] + public function test_crud(AbstractTable&IUniqueTable $table, string $class): void { $table->truncate(); $entity = new $class( - id: uniqid(), - name: $this->getUniqueName(), + id: Uuid::uuid4(), + name: Helpers\StringHelper::getUniqueName(), ); $this->assertEntityNotExists($table, $entity); $table->save($entity); @@ -57,16 +48,55 @@ public function test_crud(IUniqueTable $table, string $class): void $table->delete($entity); $this->assertEntityNotExists($table, $entity); + } - $newEntity = new $entity( - id: $entity->id, - name: $entity->name . ' new', + public function test_multiSave(): void + { + $e1 = new Entities\TestUniqueEntity( + id: Uuid::uuid4(), + name: Helpers\StringHelper::getUniqueName(), ); - $table->save($newEntity); - $this->assertEntityExists($table, $newEntity); + $e2 = new Entities\TestUniqueEntity( + id: Uuid::uuid4(), + name: Helpers\StringHelper::getUniqueName(), + ); + $e3 = new Entities\TestUniqueEntity( + id: Uuid::uuid4(), + name: Helpers\StringHelper::getUniqueName(), + ); + $e4 = new Entities\TestUniqueEntity( + id: Uuid::uuid4(), + name: Helpers\StringHelper::getUniqueName(), + ); + $table = new Tables\TestUniqueTable(); + $table->saveMany([$e1, $e2]); + + $this->assertEntityExists($table, $e1); + $this->assertEntityExists($table, $e2); + + $e1->resetChangedColumns(); + $e2->resetChangedColumns(); + + $e1->name = 'Exception'; + + $exceptionThrown = false; + try { + $table->saveMany([$e1, $e2, $e3, $e4]); + } catch (\Exception) { + $exceptionThrown = true; + } + $this->assertTrue($exceptionThrown); + $this->assertEntityNotExists($table, $e3); + $this->assertEntityNotExists($table, $e4); + + $e1->name = 'NonException'; + + $table->saveMany([$e1, $e2, $e3, $e4]); - $table->delete($newEntity); - $this->assertEntityNotExists($table, $newEntity); + $this->assertEntityExists($table, $e1); + $this->assertEntityExists($table, $e2); + $this->assertEntityExists($table, $e3); + $this->assertEntityExists($table, $e4); } private function assertEntityExists(IUniqueTable $table, Entities\TestUniqueEntity $entity): void diff --git a/tests/TestStand/Entities/Castable/TestCastableIntObject.php b/tests/TestStand/Entities/Castable/TestCastableIntObject.php deleted file mode 100644 index 56baddf..0000000 --- a/tests/TestStand/Entities/Castable/TestCastableIntObject.php +++ /dev/null @@ -1,32 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities\Castable; - -use Composite\DB\Helpers\DateTimeHelper; -use Composite\Entity\CastableInterface; - -class TestCastableIntObject extends \DateTime implements CastableInterface -{ - public function __construct(int $unixTime) - { - parent::__construct(date(DateTimeHelper::DATETIME_FORMAT, $unixTime)); - } - - public static function cast(mixed $dbValue): ?static - { - if (!$dbValue || !is_numeric($dbValue) || intval($dbValue) != $dbValue || $dbValue < 0) { - return null; - } - try { - return new static((int)$dbValue); - } catch (\Exception $e) { - return null; - } - } - - public function uncast(): ?int - { - $unixTime = (int)$this->format('U'); - return $unixTime === 0 ? null : $unixTime ; - } -} \ No newline at end of file diff --git a/tests/TestStand/Entities/Castable/TestCastableStringObject.php b/tests/TestStand/Entities/Castable/TestCastableStringObject.php deleted file mode 100644 index 2a09e03..0000000 --- a/tests/TestStand/Entities/Castable/TestCastableStringObject.php +++ /dev/null @@ -1,26 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities\Castable; - -use Composite\Entity\CastableInterface; - -class TestCastableStringObject implements CastableInterface -{ - public function __construct(private readonly ?string $value) {} - - public static function cast(mixed $dbValue): ?static - { - if (is_string($dbValue) || is_numeric($dbValue)) { - $dbValue = trim((string)$dbValue); - $dbValue = preg_replace('/(^_)|(_$)/', '', $dbValue); - } else { - $dbValue = null; - } - return new static($dbValue ?: null); - } - - public function uncast(): ?string - { - return $this->value ? '_' . $this->value . '_' : null; - } -} \ No newline at end of file diff --git a/tests/TestStand/Entities/Enums/TestBackedEnum.php b/tests/TestStand/Entities/Enums/TestBackedEnum.php new file mode 100644 index 0000000..107d79f --- /dev/null +++ b/tests/TestStand/Entities/Enums/TestBackedEnum.php @@ -0,0 +1,12 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Entities\Enums; + +use Composite\DB\Attributes\{PrimaryKey}; +use Composite\DB\Attributes\Table; + +enum TestBackedEnum: string +{ + case ACTIVE = 'Active'; + case DELETED = 'Deleted'; +} \ No newline at end of file diff --git a/tests/TestStand/Entities/Enums/TestBackedIntEnum.php b/tests/TestStand/Entities/Enums/TestBackedIntEnum.php deleted file mode 100644 index 8244fa3..0000000 --- a/tests/TestStand/Entities/Enums/TestBackedIntEnum.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities\Enums; - -enum TestBackedIntEnum: int -{ - case FooInt = 123; - case BarInt = 456; -} \ No newline at end of file diff --git a/tests/TestStand/Entities/Enums/TestBackedStringEnum.php b/tests/TestStand/Entities/Enums/TestBackedStringEnum.php deleted file mode 100644 index bdf0aa6..0000000 --- a/tests/TestStand/Entities/Enums/TestBackedStringEnum.php +++ /dev/null @@ -1,9 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities\Enums; - -enum TestBackedStringEnum: string -{ - case Foo = 'foo'; - case Bar = 'bar'; -} \ No newline at end of file diff --git a/tests/TestStand/Entities/Enums/TestSubEntity.php b/tests/TestStand/Entities/Enums/TestSubEntity.php deleted file mode 100644 index 7199082..0000000 --- a/tests/TestStand/Entities/Enums/TestSubEntity.php +++ /dev/null @@ -1,13 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities\Enums; - -use Composite\Entity\AbstractEntity; - -class TestSubEntity extends AbstractEntity -{ - public function __construct( - public string $str = 'foo', - public int $number = 123, - ) {} -} \ No newline at end of file diff --git a/tests/TestStand/Entities/Enums/TestUnitEnum.php b/tests/TestStand/Entities/Enums/TestUnitEnum.php index 652a6e6..a787709 100644 --- a/tests/TestStand/Entities/Enums/TestUnitEnum.php +++ b/tests/TestStand/Entities/Enums/TestUnitEnum.php @@ -2,8 +2,11 @@ namespace Composite\DB\Tests\TestStand\Entities\Enums; +use Composite\DB\Attributes\{PrimaryKey}; +use Composite\DB\Attributes\Table; + enum TestUnitEnum { - case Foo; - case Bar; + case ACTIVE; + case DELETED; } \ No newline at end of file diff --git a/tests/TestStand/Entities/TestAutoincrementEntity.php b/tests/TestStand/Entities/TestAutoincrementEntity.php index 1d80ec0..f97f1e7 100644 --- a/tests/TestStand/Entities/TestAutoincrementEntity.php +++ b/tests/TestStand/Entities/TestAutoincrementEntity.php @@ -13,6 +13,7 @@ class TestAutoincrementEntity extends \Composite\Entity\AbstractEntity public function __construct( public string $name, + public bool $is_test = false, public readonly \DateTimeImmutable $created_at = new \DateTimeImmutable(), ) {} } \ No newline at end of file diff --git a/tests/TestStand/Entities/TestCompositeEntity.php b/tests/TestStand/Entities/TestCompositeEntity.php index f624a50..3e1c765 100644 --- a/tests/TestStand/Entities/TestCompositeEntity.php +++ b/tests/TestStand/Entities/TestCompositeEntity.php @@ -14,6 +14,7 @@ public function __construct( #[PrimaryKey] public readonly int $post_id, public string $message, + public Enums\TestUnitEnum $status = Enums\TestUnitEnum::ACTIVE, public readonly \DateTimeImmutable $created_at = new \DateTimeImmutable(), ) {} } \ No newline at end of file diff --git a/tests/TestStand/Entities/TestCompositeSdEntity.php b/tests/TestStand/Entities/TestCompositeSdEntity.php deleted file mode 100644 index e6d8027..0000000 --- a/tests/TestStand/Entities/TestCompositeSdEntity.php +++ /dev/null @@ -1,12 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities; - -use Composite\DB\Attributes\Table; -use Composite\DB\Traits\SoftDelete; - -#[Table(connection: 'sqlite', name: 'TestCompositeSoftDelete')] -class TestCompositeSdEntity extends TestCompositeEntity -{ - use SoftDelete; -} \ No newline at end of file diff --git a/tests/TestStand/Entities/TestDiversityEntity.php b/tests/TestStand/Entities/TestDiversityEntity.php deleted file mode 100644 index 049277a..0000000 --- a/tests/TestStand/Entities/TestDiversityEntity.php +++ /dev/null @@ -1,111 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities; - -use Composite\DB\Tests\TestStand\Entities\Castable\TestCastableIntObject; -use Composite\DB\Tests\TestStand\Entities\Castable\TestCastableStringObject; -use Composite\DB\Tests\TestStand\Entities\Enums\TestBackedIntEnum; -use Composite\DB\Tests\TestStand\Entities\Enums\TestBackedStringEnum; -use Composite\DB\Tests\TestStand\Entities\Enums\TestSubEntity; -use Composite\DB\Tests\TestStand\Entities\Enums\TestUnitEnum; -use Composite\DB\Attributes; -use Composite\Entity\AbstractEntity; - -#[Attributes\Table(connection: 'sqlite', name: 'Diversity')] -class TestDiversityEntity extends AbstractEntity -{ - #[Attributes\PrimaryKey(autoIncrement: true)] - public readonly int $id; - - public string $str1; - public ?string $str2; - public string $str3 = 'str3 def'; - public ?string $str4 = ''; - public ?string $str5 = null; - - public int $int1; - public ?int $int2; - public int $int3 = 33; - public ?int $int4 = 44; - public ?int $int5 = null; - - public float $float1; - public ?float $float2; - public float $float3 = 3.9; - public ?float $float4 = 4.9; - public ?float $float5 = null; - - public bool $bool1; - public ?bool $bool2; - public bool $bool3 = true; - public ?bool $bool4 = false; - public ?bool $bool5 = null; - - public array $arr1; - public ?array $arr2; - public array $arr3 = [11, 22, 33]; - public ?array $arr4 = []; - public ?array $arr5 = null; - - public TestBackedStringEnum $benum_str1; - public ?TestBackedStringEnum $benum_str2; - public TestBackedStringEnum $benum_str3 = TestBackedStringEnum::Foo; - public ?TestBackedStringEnum $benum_str4 = TestBackedStringEnum::Bar; - public ?TestBackedStringEnum $benum_str5 = null; - - public TestBackedIntEnum $benum_int1; - public ?TestBackedIntEnum $benum_int2; - public TestBackedIntEnum $benum_int3 = TestBackedIntEnum::FooInt; - public ?TestBackedIntEnum $benum_int4 = TestBackedIntEnum::BarInt; - public ?TestBackedIntEnum $benum_int5 = null; - - public TestUnitEnum $uenum1; - public ?TestUnitEnum $uenum2; - public TestUnitEnum $uenum3 = TestUnitEnum::Foo; - public ?TestUnitEnum $uenum4 = TestUnitEnum::Bar; - public ?TestUnitEnum $uenum5 = null; - - public function __construct( - public \stdClass $obj1, - public ?\stdClass $obj2, - - public \DateTime $dt1, - public ?\DateTime $dt2, - - public \DateTimeImmutable $dti1, - public ?\DateTimeImmutable $dti2, - - public TestSubEntity $entity1, - public ?TestSubEntity $entity2, - - public TestCastableIntObject $castable_int1, - public ?TestCastableIntObject $castable_int2, - - public TestCastableStringObject $castable_str1, - public ?TestCastableStringObject $castable_str2, - - public \stdClass $obj3 = new \stdClass(), - public ?\stdClass $obj4 = new \stdClass(), - public ?\stdClass $obj5 = null, - - public \DateTime $dt3 = new \DateTime('2000-01-01 00:00:00'), - public ?\DateTime $dt4 = new \DateTime(), - public ?\DateTime $dt5 = null, - - public \DateTimeImmutable $dti3 = new \DateTimeImmutable('2000-01-01 00:00:00'), - public ?\DateTimeImmutable $dti4 = new \DateTimeImmutable(), - public ?\DateTimeImmutable $dti5 = null, - - public TestSubEntity $entity3 = new TestSubEntity(), - public ?TestSubEntity $entity4 = new TestSubEntity(number: 456), - public ?TestSubEntity $entity5 = null, - - public TestCastableIntObject $castable_int3 = new TestCastableIntObject(946684801), //2000-01-01 00:00:01, - public ?TestCastableIntObject $castable_int4 = new TestCastableIntObject(946684802), //2000-01-01 00:00:02, - public ?TestCastableIntObject $castable_int5 = null, - - public TestCastableStringObject $castable_str3 = new TestCastableStringObject('Hello'), - public ?TestCastableStringObject $castable_str4 = new TestCastableStringObject('World'), - public ?TestCastableStringObject $castable_str5 = new TestCastableStringObject(null), - ) {} -} \ No newline at end of file diff --git a/tests/TestStand/Entities/TestEntity.php b/tests/TestStand/Entities/TestEntity.php deleted file mode 100644 index d39ad72..0000000 --- a/tests/TestStand/Entities/TestEntity.php +++ /dev/null @@ -1,30 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities; - -use Composite\DB\Attributes; -use Composite\DB\Tests\TestStand\Entities\Castable\TestCastableIntObject; -use Composite\DB\Tests\TestStand\Entities\Enums\TestBackedStringEnum; -use Composite\DB\Tests\TestStand\Entities\Enums\TestSubEntity; -use Composite\DB\Tests\TestStand\Entities\Enums\TestUnitEnum; -use Composite\Entity\AbstractEntity; - -#[Attributes\Table(connection: 'sqlite', name: 'Test')] -class TestEntity extends AbstractEntity -{ - public function __construct( - #[Attributes\PrimaryKey] - public string $str = 'foo', - public int $int = 999, - public float $float = 9.99, - public bool $bool = true, - public array $arr = [], - public \stdClass $object = new \stdClass(), - public \DateTime $date_time = new \DateTime(), - public \DateTimeImmutable $date_time_immutable = new \DateTimeImmutable(), - public TestBackedStringEnum $backed_enum = TestBackedStringEnum::Foo, - public TestUnitEnum $unit_enum = TestUnitEnum::Bar, - public TestSubEntity $entity = new TestSubEntity(), - public TestCastableIntObject $castable = new TestCastableIntObject(946684801) //2000-01-01 00:00:01 - ) {} -} \ No newline at end of file diff --git a/tests/TestStand/Entities/TestStrictEntity.php b/tests/TestStand/Entities/TestStrictEntity.php new file mode 100644 index 0000000..8215aa2 --- /dev/null +++ b/tests/TestStand/Entities/TestStrictEntity.php @@ -0,0 +1,17 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Entities; + +use Composite\DB\Attributes; +use Composite\Entity\AbstractEntity; + +#[Attributes\Table(connection: 'sqlite', name: 'Strict')] +class TestStrictEntity extends AbstractEntity +{ + #[Attributes\PrimaryKey(autoIncrement: true)] + public readonly int $id; + + public function __construct( + public \DateTimeImmutable $dti1, + ) {} +} \ No newline at end of file diff --git a/tests/TestStand/Entities/TestUniqueEntity.php b/tests/TestStand/Entities/TestUniqueEntity.php index 7b676bd..cce8a0b 100644 --- a/tests/TestStand/Entities/TestUniqueEntity.php +++ b/tests/TestStand/Entities/TestUniqueEntity.php @@ -5,14 +5,16 @@ use Composite\DB\Attributes\{PrimaryKey}; use Composite\DB\Attributes\Table; use Composite\Entity\AbstractEntity; +use Ramsey\Uuid\UuidInterface; #[Table(connection: 'sqlite', name: 'TestUnique')] class TestUniqueEntity extends AbstractEntity { public function __construct( #[PrimaryKey] - public readonly string $id, + public readonly UuidInterface $id, public string $name, + public Enums\TestBackedEnum $status = Enums\TestBackedEnum::ACTIVE, public readonly \DateTimeImmutable $created_at = new \DateTimeImmutable(), ) {} } \ No newline at end of file diff --git a/tests/TestStand/Entities/TestUniqueSdEntity.php b/tests/TestStand/Entities/TestUniqueSdEntity.php deleted file mode 100644 index 1a95b5f..0000000 --- a/tests/TestStand/Entities/TestUniqueSdEntity.php +++ /dev/null @@ -1,12 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Entities; - -use Composite\DB\Attributes\Table; -use Composite\DB\Traits\SoftDelete; - -#[Table(connection: 'sqlite', name: 'TestUniqueSoftDelete')] -class TestUniqueSdEntity extends TestUniqueEntity -{ - use SoftDelete; -} \ No newline at end of file diff --git a/tests/TestStand/Entities/TestUpdatedAtEntity.php b/tests/TestStand/Entities/TestUpdatedAtEntity.php new file mode 100644 index 0000000..c47f652 --- /dev/null +++ b/tests/TestStand/Entities/TestUpdatedAtEntity.php @@ -0,0 +1,21 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Entities; + +use Composite\DB\Traits\UpdatedAt; +use Composite\DB\Attributes\{PrimaryKey}; +use Composite\DB\Attributes\Table; +use Composite\Entity\AbstractEntity; + +#[Table(connection: 'sqlite', name: 'TestUpdatedAt')] +class TestUpdatedAtEntity extends AbstractEntity +{ + use UpdatedAt; + #[PrimaryKey(autoIncrement: true)] + public readonly string $id; + + public function __construct( + public string $name, + public readonly \DateTimeImmutable $created_at = new \DateTimeImmutable(), + ) {} +} \ No newline at end of file diff --git a/tests/TestStand/Interfaces/IAutoincrementTable.php b/tests/TestStand/Interfaces/IAutoincrementTable.php index da6bf1a..e4afcc6 100644 --- a/tests/TestStand/Interfaces/IAutoincrementTable.php +++ b/tests/TestStand/Interfaces/IAutoincrementTable.php @@ -13,5 +13,6 @@ public function findOneByName(string $name): ?TestAutoincrementEntity; */ public function findAllByName(string $name): array; public function countAllByName(string $name): int; + public function findRecent(int $limit, int $offset): array; public function truncate(): void; } \ No newline at end of file diff --git a/tests/TestStand/Interfaces/IUniqueTable.php b/tests/TestStand/Interfaces/IUniqueTable.php index b0e09dd..197beaf 100644 --- a/tests/TestStand/Interfaces/IUniqueTable.php +++ b/tests/TestStand/Interfaces/IUniqueTable.php @@ -4,10 +4,11 @@ use Composite\DB\Tests\TestStand\Entities\TestCompositeEntity; use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity; +use Ramsey\Uuid\UuidInterface; interface IUniqueTable { - public function findByPk(string $id): ?TestUniqueEntity; + public function findByPk(UuidInterface $id): ?TestUniqueEntity; /** * @return TestCompositeEntity[] */ diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php index 9b65e5c..1fba645 100644 --- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php +++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php @@ -6,10 +6,17 @@ use Composite\DB\TableConfig; use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity; use Composite\DB\Tests\TestStand\Interfaces\IAutoincrementTable; +use Composite\DB\Where; use Composite\Entity\AbstractEntity; class TestAutoincrementCachedTable extends AbstractCachedTable implements IAutoincrementTable { + public function __construct(\Psr\SimpleCache\CacheInterface $cache) + { + parent::__construct($cache); + (new TestAutoincrementTable)->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestAutoincrementEntity::schema()); @@ -19,26 +26,34 @@ protected function getFlushCacheKeys(TestAutoincrementEntity|AbstractEntity $ent { $keys = [ $this->getOneCacheKey(['name' => $entity->name]), - $this->getListCacheKey('name = :name', ['name' => $entity->name]), - $this->getCountCacheKey('name = :name', ['name' => $entity->name]), + $this->getListCacheKey(new Where('name = :name', ['name' => $entity->name])), + $this->getCountCacheKey(new Where('name = :name', ['name' => $entity->name])), ]; $oldName = $entity->getOldValue('name'); if (!$entity->isNew() && $oldName !== $entity->name) { $keys[] = $this->getOneCacheKey(['name' => $oldName]); - $keys[] = $this->getListCacheKey('name = :name', ['name' => $oldName]); - $keys[] = $this->getCountCacheKey('name = :name', ['name' => $oldName]); + $keys[] = $this->getListCacheKey(new Where('name = :name', ['name' => $oldName])); + $keys[] = $this->getCountCacheKey(new Where('name = :name', ['name' => $oldName])); } return $keys; } public function findByPk(int $id): ?TestAutoincrementEntity { - return $this->createEntity($this->findByPkCachedInternal($id)); + return $this->_findByPkCached($id); } public function findOneByName(string $name): ?TestAutoincrementEntity { - return $this->createEntity($this->findOneCachedInternal(['name' => $name])); + return $this->_findOneCached(['name' => $name]); + } + + public function delete(TestAutoincrementEntity|AbstractEntity $entity): void + { + if ($entity->name === 'Exception') { + throw new \Exception('Test Exception'); + } + parent::delete($entity); } /** @@ -46,20 +61,34 @@ public function findOneByName(string $name): ?TestAutoincrementEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllCachedInternal( - 'name = :name', - ['name' => $name], - )); + return $this->_findAllCached(new Where('name = :name', ['name' => $name])); } - public function countAllByName(string $name): int + /** + * @return TestAutoincrementEntity[] + */ + public function findRecent(int $limit, int $offset): array { - return $this->countAllCachedInternal( - 'name = :name', - ['name' => $name], + return $this->_findAll( + orderBy: ['id' => 'DESC'], + limit: $limit, + offset: $offset, ); } + public function countAllByName(string $name): int + { + return $this->_countByAllCached(new Where('name = :name', ['name' => $name])); + } + + /** + * @return TestAutoincrementEntity[] + */ + public function findMulti(array $ids, ?string $keyColumnName = null): array + { + return $this->_findMultiCached(ids: $ids, keyColumnName: $keyColumnName); + } + public function truncate(): void { $this->getConnection()->executeStatement("DELETE FROM {$this->getTableName()} WHERE 1"); diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php index 007a187..07ba91d 100644 --- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php +++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php @@ -6,10 +6,17 @@ use Composite\DB\TableConfig; use Composite\DB\Tests\TestStand\Entities\TestAutoincrementSdEntity; use Composite\DB\Tests\TestStand\Interfaces\IAutoincrementTable; +use Composite\DB\Where; use Composite\Entity\AbstractEntity; class TestAutoincrementSdCachedTable extends AbstractCachedTable implements IAutoincrementTable { + public function __construct(\Psr\SimpleCache\CacheInterface $cache) + { + parent::__construct($cache); + (new TestAutoincrementSdTable)->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestAutoincrementSdEntity::schema()); @@ -19,26 +26,34 @@ protected function getFlushCacheKeys(TestAutoincrementSdEntity|AbstractEntity $e { $keys = [ $this->getOneCacheKey(['name' => $entity->name]), - $this->getListCacheKey('name = :name', ['name' => $entity->name]), - $this->getCountCacheKey('name = :name', ['name' => $entity->name]), + $this->getListCacheKey(new Where('name = :name', ['name' => $entity->name])), + $this->getCountCacheKey(new Where('name = :name', ['name' => $entity->name])), ]; $oldName = $entity->getOldValue('name'); if ($oldName !== null && $oldName !== $entity->name) { $keys[] = $this->getOneCacheKey(['name' => $oldName]); - $keys[] = $this->getListCacheKey('name = :name', ['name' => $oldName]); - $keys[] = $this->getCountCacheKey('name = :name', ['name' => $oldName]); + $keys[] = $this->getListCacheKey(new Where('name = :name', ['name' => $oldName])); + $keys[] = $this->getCountCacheKey(new Where('name = :name', ['name' => $oldName])); } return $keys; } public function findByPk(int $id): ?TestAutoincrementSdEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } public function findOneByName(string $name): ?TestAutoincrementSdEntity { - return $this->createEntity($this->findOneCachedInternal(['name' => $name])); + return $this->_findOneCached(['name' => $name]); + } + + public function delete(TestAutoincrementSdEntity|AbstractEntity $entity): void + { + if ($entity->name === 'Exception') { + throw new \Exception('Test Exception'); + } + parent::delete($entity); } /** @@ -46,18 +61,27 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllCachedInternal( - 'name = :name', - ['name' => $name], - )); + return $this->_findAllCached(new Where('name = :name', ['name' => $name, 'deleted_at' => null])); + } + + /** + * @return TestAutoincrementSdEntity[] + */ + public function findRecent(int $limit, int $offset): array + { + return $this->_findAll( + orderBy: 'id DESC', + limit: $limit, + offset: $offset, + ); } public function countAllByName(string $name): int { - return $this->countAllCachedInternal( + return $this->_countByAllCached(new Where( 'name = :name', ['name' => $name], - ); + )); } public function truncate(): void diff --git a/tests/TestStand/Tables/TestAutoincrementSdTable.php b/tests/TestStand/Tables/TestAutoincrementSdTable.php index 8f68332..f878db9 100644 --- a/tests/TestStand/Tables/TestAutoincrementSdTable.php +++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php @@ -4,9 +4,16 @@ use Composite\DB\TableConfig; use Composite\DB\Tests\TestStand\Entities\TestAutoincrementSdEntity; +use Composite\DB\Where; class TestAutoincrementSdTable extends TestAutoincrementTable { + public function __construct() + { + parent::__construct(); + $this->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestAutoincrementSdEntity::schema()); @@ -14,12 +21,12 @@ protected function getConfig(): TableConfig public function findByPk(int $id): ?TestAutoincrementSdEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } public function findOneByName(string $name): ?TestAutoincrementSdEntity { - return $this->createEntity($this->findOneInternal(['name' => $name])); + return $this->_findOne(['name' => $name, 'deleted_at' => null]); } /** @@ -27,10 +34,20 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllInternal( - 'name = :name', - ['name' => $name] - )); + return $this->_findAll(new Where('name = :name', ['name' => $name])); + } + + /** + * @return TestAutoincrementSdEntity[] + */ + public function findRecent(int $limit, int $offset): array + { + return $this->_findAll( + where: ['deleted_at' => null], + orderBy: 'id DESC', + limit: $limit, + offset: $offset, + ); } public function init(): bool @@ -41,6 +58,7 @@ public function init(): bool ( `id` INTEGER NOT NULL CONSTRAINT TestAutoincrementSd_pk PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255) NOT NULL, + `is_test` INTEGER NOT NULL DEFAULT 0, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, `deleted_at` TIMESTAMP NULL DEFAULT NULL ); diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php index 900e576..bef47de 100644 --- a/tests/TestStand/Tables/TestAutoincrementTable.php +++ b/tests/TestStand/Tables/TestAutoincrementTable.php @@ -6,9 +6,17 @@ use Composite\DB\TableConfig; use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity; use Composite\DB\Tests\TestStand\Interfaces\IAutoincrementTable; +use Composite\DB\Where; +use Composite\Entity\AbstractEntity; class TestAutoincrementTable extends AbstractTable implements IAutoincrementTable { + public function __construct() + { + parent::__construct(); + $this->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestAutoincrementEntity::schema()); @@ -16,12 +24,20 @@ protected function getConfig(): TableConfig public function findByPk(int $id): ?TestAutoincrementEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } public function findOneByName(string $name): ?TestAutoincrementEntity { - return $this->createEntity($this->findOneInternal(['name' => $name])); + return $this->_findOne(['name' => $name]); + } + + public function delete(AbstractEntity|TestAutoincrementEntity $entity): void + { + if ($entity->name === 'Exception') { + throw new \Exception('Test Exception'); + } + parent::delete($entity); } /** @@ -29,20 +45,39 @@ public function findOneByName(string $name): ?TestAutoincrementEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllInternal( - 'name = :name', - ['name' => $name], - )); + return $this->_findAll( + where: new Where('name = :name', ['name' => $name]), + orderBy: 'id', + ); } - public function countAllByName(string $name): int + /** + * @return TestAutoincrementEntity[] + */ + public function findRecent(int $limit, int $offset): array { - return $this->countAllInternal( - 'name = :name', - ['name' => $name] + return $this->_findAll( + orderBy: ['id' => 'DESC'], + limit: $limit, + offset: $offset, ); } + public function countAllByName(string $name): int + { + return $this->_countAll(new Where('name = :name', ['name' => $name])); + } + + /** + * @param int[] $ids + * @return TestAutoincrementEntity[] + * @throws \Composite\DB\Exceptions\DbException + */ + public function findMulti(array $ids): array + { + return $this->_findMulti($ids, 'id'); + } + public function init(): bool { $this->getConnection()->executeStatement( @@ -51,6 +86,7 @@ public function init(): bool ( `id` INTEGER NOT NULL CONSTRAINT TestAutoincrement_pk PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255) NOT NULL, + `is_test` INTEGER NOT NULL DEFAULT 0, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ); " diff --git a/tests/TestStand/Tables/TestCompositeCachedTable.php b/tests/TestStand/Tables/TestCompositeCachedTable.php index a2519da..6b5996a 100644 --- a/tests/TestStand/Tables/TestCompositeCachedTable.php +++ b/tests/TestStand/Tables/TestCompositeCachedTable.php @@ -9,6 +9,12 @@ class TestCompositeCachedTable extends \Composite\DB\AbstractCachedTable implements ICompositeTable { + public function __construct(\Psr\SimpleCache\CacheInterface $cache) + { + parent::__construct($cache); + (new TestCompositeTable())->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestCompositeEntity::schema()); @@ -17,17 +23,17 @@ protected function getConfig(): TableConfig protected function getFlushCacheKeys(TestCompositeEntity|AbstractEntity $entity): array { return [ - $this->getListCacheKey('user_id = :user_id', ['user_id' => $entity->user_id]), - $this->getCountCacheKey('user_id = :user_id', ['user_id' => $entity->user_id]), + $this->getListCacheKey(['user_id' => $entity->user_id]), + $this->getCountCacheKey(['user_id' => $entity->user_id]), ]; } public function findOne(int $user_id, int $post_id): ?TestCompositeEntity { - return $this->createEntity($this->findOneCachedInternal([ + return $this->_findOneCached([ 'user_id' => $user_id, 'post_id' => $post_id, - ])); + ]); } /** @@ -35,21 +41,12 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeEntity */ public function findAllByUser(int $userId): array { - return array_map( - fn (array $data) => TestCompositeEntity::fromArray($data), - $this->findAllCachedInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ) - ); + return $this->_findAllCached(['user_id' => $userId]); } public function countAllByUser(int $userId): int { - return $this->countAllCachedInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ); + return $this->_countByAllCached(['user_id' => $userId]); } public function truncate(): void diff --git a/tests/TestStand/Tables/TestCompositeSdCachedTable.php b/tests/TestStand/Tables/TestCompositeSdCachedTable.php deleted file mode 100644 index 97f8512..0000000 --- a/tests/TestStand/Tables/TestCompositeSdCachedTable.php +++ /dev/null @@ -1,59 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Tables; - -use Composite\DB\TableConfig; -use Composite\DB\Tests\TestStand\Entities\TestCompositeSdEntity; -use Composite\DB\Tests\TestStand\Interfaces\ICompositeTable; -use Composite\Entity\AbstractEntity; - -class TestCompositeSdCachedTable extends \Composite\DB\AbstractCachedTable implements ICompositeTable -{ - protected function getConfig(): TableConfig - { - return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema()); - } - - protected function getFlushCacheKeys(TestCompositeSdEntity|AbstractEntity $entity): array - { - return [ - $this->getListCacheKey('user_id = :user_id', ['user_id' => $entity->user_id]), - $this->getCountCacheKey('user_id = :user_id', ['user_id' => $entity->user_id]), - ]; - } - - public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity - { - return $this->createEntity($this->findOneCachedInternal([ - 'user_id' => $user_id, - 'post_id' => $post_id, - ])); - } - - /** - * @return TestCompositeSdEntity[] - */ - public function findAllByUser(int $userId): array - { - return array_map( - fn (array $data) => TestCompositeSdEntity::fromArray($data), - $this->findAllCachedInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ) - ); - } - - public function countAllByUser(int $userId): int - { - return $this->countAllCachedInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ); - } - - public function truncate(): void - { - $this->getConnection()->executeStatement("DELETE FROM {$this->getTableName()} WHERE 1"); - } -} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestCompositeSdTable.php b/tests/TestStand/Tables/TestCompositeSdTable.php deleted file mode 100644 index a35bd1e..0000000 --- a/tests/TestStand/Tables/TestCompositeSdTable.php +++ /dev/null @@ -1,59 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Tables; - -use Composite\DB\TableConfig; -use Composite\DB\Tests\TestStand\Entities\TestCompositeSdEntity; - -class TestCompositeSdTable extends TestCompositeTable -{ - protected function getConfig(): TableConfig - { - return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema()); - } - - public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity - { - return $this->createEntity($this->findOneInternal([ - 'user_id' => $user_id, - 'post_id' => $post_id, - ])); - } - - /** - * @return TestCompositeSdEntity[] - */ - public function findAllByUser(int $userId): array - { - return $this->createEntities($this->findAllInternal( - 'user_id = :user_id', - ['user_id' => $userId], - )); - } - - public function countAllByUser(int $userId): int - { - return $this->countAllInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ); - } - - public function init(): bool - { - $this->getConnection()->executeStatement( - " - CREATE TABLE IF NOT EXISTS {$this->getTableName()} - ( - `user_id` integer not null, - `post_id` integer not null, - `message` VARCHAR(255) DEFAULT '' NOT NULL, - `created_at` TIMESTAMP NOT NULL, - `deleted_at` TIMESTAMP NULL DEFAULT NULL, - CONSTRAINT TestCompositeSd PRIMARY KEY (`user_id`, `post_id`, `deleted_at`) - ); - " - ); - return true; - } -} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php index 931ff55..8a1e9da 100644 --- a/tests/TestStand/Tables/TestCompositeTable.php +++ b/tests/TestStand/Tables/TestCompositeTable.php @@ -3,6 +3,7 @@ namespace Composite\DB\Tests\TestStand\Tables; use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\Enums\TestUnitEnum; use Composite\DB\Tests\TestStand\Entities\TestCompositeEntity; use Composite\DB\Tests\TestStand\Interfaces\ICompositeTable; use Composite\Entity\AbstractEntity; @@ -14,7 +15,7 @@ protected function getConfig(): TableConfig return TableConfig::fromEntitySchema(TestCompositeEntity::schema()); } - public function save(AbstractEntity|TestCompositeEntity &$entity): void + public function save(AbstractEntity|TestCompositeEntity $entity): void { if ($entity->message === 'Exception') { throw new \Exception('Test Exception'); @@ -22,9 +23,17 @@ public function save(AbstractEntity|TestCompositeEntity &$entity): void parent::save($entity); } + public function delete(AbstractEntity|TestCompositeEntity $entity): void + { + if ($entity->message === 'Exception') { + throw new \Exception('Test Exception'); + } + parent::delete($entity); + } + public function findOne(int $user_id, int $post_id): ?TestCompositeEntity { - return $this->createEntity($this->findOneInternal(['user_id' => $user_id, 'post_id' => $post_id])); + return $this->_findOne(['user_id' => $user_id, 'post_id' => $post_id]); } /** @@ -32,29 +41,22 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeEntity */ public function findAllByUser(int $userId): array { - return $this->createEntities($this->findAllInternal( - 'user_id = :user_id', - ['user_id' => $userId], - )); + return $this->_findAll(['user_id' => $userId, 'status' => TestUnitEnum::ACTIVE]); } public function countAllByUser(int $userId): int { - return $this->countAllInternal( - 'user_id = :user_id', - ['user_id' => $userId], - ); + return $this->_countAll(['user_id' => $userId]); } - public function test(): array + /** + * @param array $ids + * @return TestCompositeEntity[] + * @throws \Composite\DB\Exceptions\DbException + */ + public function findMulti(array $ids): array { - $rows = $this - ->select() - ->where() - ->orWhere() - ->orderBy() - ->fetchAllAssociative(); - return $this->createEntities($rows); + return $this->_findMulti($ids, 'post_id'); } public function init(): bool @@ -66,6 +68,7 @@ public function init(): bool `user_id` integer not null, `post_id` integer not null, `message` VARCHAR(255) DEFAULT '' NOT NULL, + `status` VARCHAR(16) DEFAULT 'ACTIVE' NOT NULL, `created_at` TIMESTAMP NOT NULL, CONSTRAINT TestComposite PRIMARY KEY (`user_id`, `post_id`) ); diff --git a/tests/TestStand/Tables/TestMySQLTable.php b/tests/TestStand/Tables/TestMySQLTable.php new file mode 100644 index 0000000..e918355 --- /dev/null +++ b/tests/TestStand/Tables/TestMySQLTable.php @@ -0,0 +1,25 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Tables; + +use Composite\DB\AbstractTable; +use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity; + +class TestMySQLTable extends AbstractTable +{ + protected function getConfig(): TableConfig + { + return new TableConfig( + connectionName: 'mysql', + tableName: 'Fake', + entityClass: TestAutoincrementEntity::class, + primaryKeys: [], + ); + } + + public function escapeIdentifierPub(string $key): string + { + return $this->escapeIdentifier($key); + } +} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestOptimisticLockTable.php b/tests/TestStand/Tables/TestOptimisticLockTable.php index beb6123..1ede3bc 100644 --- a/tests/TestStand/Tables/TestOptimisticLockTable.php +++ b/tests/TestStand/Tables/TestOptimisticLockTable.php @@ -8,6 +8,12 @@ class TestOptimisticLockTable extends AbstractTable { + public function __construct() + { + parent::__construct(); + $this->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestOptimisticLockEntity::schema()); @@ -15,7 +21,7 @@ protected function getConfig(): TableConfig public function findByPk(int $id): ?TestOptimisticLockEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } public function init(): bool @@ -26,7 +32,7 @@ public function init(): bool ( `id` INTEGER NOT NULL CONSTRAINT TestAutoincrement_pk PRIMARY KEY AUTOINCREMENT, `name` VARCHAR(255) NOT NULL, - `version` INTEGER NOT NULL DEFAULT 1, + `lock_version` INTEGER NOT NULL DEFAULT 1, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL ); " diff --git a/tests/TestStand/Tables/TestPostgresTable.php b/tests/TestStand/Tables/TestPostgresTable.php new file mode 100644 index 0000000..94ab839 --- /dev/null +++ b/tests/TestStand/Tables/TestPostgresTable.php @@ -0,0 +1,25 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Tables; + +use Composite\DB\AbstractTable; +use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity; + +class TestPostgresTable extends AbstractTable +{ + protected function getConfig(): TableConfig + { + return new TableConfig( + connectionName: 'postgres', + tableName: 'Fake', + entityClass: TestAutoincrementEntity::class, + primaryKeys: [], + ); + } + + public function escapeIdentifierPub(string $key): string + { + return $this->escapeIdentifier($key); + } +} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestStrictTable.php b/tests/TestStand/Tables/TestStrictTable.php new file mode 100644 index 0000000..81a2c4b --- /dev/null +++ b/tests/TestStand/Tables/TestStrictTable.php @@ -0,0 +1,28 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Tables; + +use Composite\DB\AbstractTable; +use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\TestStrictEntity; + +class TestStrictTable extends AbstractTable +{ + protected function getConfig(): TableConfig + { + return TableConfig::fromEntitySchema(TestStrictEntity::schema()); + } + + public function buildEntity(array $data): ?TestStrictEntity + { + return $this->createEntity($data); + } + + /** + * @return TestStrictEntity[] + */ + public function buildEntities(mixed $data): array + { + return $this->createEntities($data); + } +} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php index f95d102..8fe714f 100644 --- a/tests/TestStand/Tables/TestUniqueCachedTable.php +++ b/tests/TestStand/Tables/TestUniqueCachedTable.php @@ -6,10 +6,18 @@ use Composite\DB\TableConfig; use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity; use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable; +use Composite\DB\Where; use Composite\Entity\AbstractEntity; +use Ramsey\Uuid\UuidInterface; class TestUniqueCachedTable extends AbstractCachedTable implements IUniqueTable { + public function __construct(\Psr\SimpleCache\CacheInterface $cache) + { + parent::__construct($cache); + (new TestUniqueTable())->init(); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestUniqueEntity::schema()); @@ -18,14 +26,14 @@ protected function getConfig(): TableConfig protected function getFlushCacheKeys(TestUniqueEntity|AbstractEntity $entity): array { return [ - $this->getListCacheKey('name = :name', ['name' => $entity->name]), - $this->getCountCacheKey('name = :name', ['name' => $entity->name]), + $this->getListCacheKey(new Where('name = :name', ['name' => $entity->name])), + $this->getCountCacheKey(new Where('name = :name', ['name' => $entity->name])), ]; } - public function findByPk(string $id): ?TestUniqueEntity + public function findByPk(UuidInterface $id): ?TestUniqueEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } /** @@ -33,18 +41,12 @@ public function findByPk(string $id): ?TestUniqueEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllCachedInternal( - 'name = :name', - ['name' => $name], - )); + return $this->_findAllCached(new Where('name = :name', ['name' => $name])); } public function countAllByName(string $name): int { - return $this->countAllCachedInternal( - 'name = :name', - ['name' => $name], - ); + return $this->_countByAllCached(new Where('name = :name', ['name' => $name])); } public function truncate(): void diff --git a/tests/TestStand/Tables/TestUniqueSdCachedTable.php b/tests/TestStand/Tables/TestUniqueSdCachedTable.php deleted file mode 100644 index 278cb37..0000000 --- a/tests/TestStand/Tables/TestUniqueSdCachedTable.php +++ /dev/null @@ -1,54 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Tables; - -use Composite\DB\AbstractCachedTable; -use Composite\DB\TableConfig; -use Composite\DB\Tests\TestStand\Entities\TestUniqueSdEntity; -use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable; -use Composite\Entity\AbstractEntity; - -class TestUniqueSdCachedTable extends AbstractCachedTable implements IUniqueTable -{ - protected function getConfig(): TableConfig - { - return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema()); - } - - protected function getFlushCacheKeys(TestUniqueSdEntity|AbstractEntity $entity): array - { - return [ - $this->getListCacheKey('name = :name', ['name' => $entity->name]), - $this->getCountCacheKey('name = :name', ['name' => $entity->name]), - ]; - } - - public function findByPk(string $id): ?TestUniqueSdEntity - { - return $this->createEntity($this->findByPkInternal($id)); - } - - /** - * @return TestUniqueSdEntity[] - */ - public function findAllByName(string $name): array - { - return $this->createEntities($this->findAllCachedInternal( - 'name = :name', - ['name' => $name], - )); - } - - public function countAllByName(string $name): int - { - return $this->countAllCachedInternal( - 'name = :name', - ['name' => $name], - ); - } - - public function truncate(): void - { - $this->getConnection()->executeStatement("DELETE FROM {$this->getTableName()} WHERE 1"); - } -} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestUniqueSdTable.php b/tests/TestStand/Tables/TestUniqueSdTable.php deleted file mode 100644 index 6bed15f..0000000 --- a/tests/TestStand/Tables/TestUniqueSdTable.php +++ /dev/null @@ -1,47 +0,0 @@ -<?php declare(strict_types=1); - -namespace Composite\DB\Tests\TestStand\Tables; - -use Composite\DB\TableConfig; -use Composite\DB\Tests\TestStand\Entities\TestUniqueSdEntity; - -class TestUniqueSdTable extends TestUniqueTable -{ - protected function getConfig(): TableConfig - { - return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema()); - } - - public function findByPk(string $id): ?TestUniqueSdEntity - { - return $this->createEntity($this->findByPkInternal($id)); - } - - /** - * @return TestUniqueSdEntity[] - */ - public function findAllByName(string $name): array - { - return $this->createEntities($this->findAllInternal( - 'name = :name', - ['name' => $name], - )); - } - - public function init(): bool - { - $this->getConnection()->executeStatement( - " - CREATE TABLE IF NOT EXISTS {$this->getTableName()} - ( - `id` VARCHAR(255) NOT NULL, - `name` VARCHAR(255) NOT NULL, - `created_at` TIMESTAMP NOT NULL, - `deleted_at` TIMESTAMP NULL DEFAULT NULL, - CONSTRAINT TestUniqueSd PRIMARY KEY (`id`, `deleted_at`) - ); - " - ); - return true; - } -} \ No newline at end of file diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php index 317985c..9387104 100644 --- a/tests/TestStand/Tables/TestUniqueTable.php +++ b/tests/TestStand/Tables/TestUniqueTable.php @@ -4,19 +4,37 @@ use Composite\DB\AbstractTable; use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\Enums\TestBackedEnum; use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity; use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable; +use Composite\DB\Where; +use Composite\Entity\AbstractEntity; +use Ramsey\Uuid\UuidInterface; class TestUniqueTable extends AbstractTable implements IUniqueTable { + public function __construct() + { + parent::__construct(); + $this->init(); + } + + public function save(AbstractEntity|TestUniqueEntity $entity): void + { + if ($entity->name === 'Exception') { + throw new \Exception('Test Exception'); + } + parent::save($entity); + } + protected function getConfig(): TableConfig { return TableConfig::fromEntitySchema(TestUniqueEntity::schema()); } - public function findByPk(string $id): ?TestUniqueEntity + public function findByPk(UuidInterface $id): ?TestUniqueEntity { - return $this->createEntity($this->findByPkInternal($id)); + return $this->_findByPk($id); } /** @@ -24,18 +42,12 @@ public function findByPk(string $id): ?TestUniqueEntity */ public function findAllByName(string $name): array { - return $this->createEntities($this->findAllInternal( - 'name = :name', - ['name' => $name], - )); + return $this->_findAll(['name' => $name, 'status' => TestBackedEnum::ACTIVE]); } public function countAllByName(string $name): int { - return $this->countAllInternal( - 'name = :name', - ['name' => $name], - ); + return $this->_countAll(new Where('name = :name', ['name' => $name])); } public function init(): bool @@ -46,6 +58,7 @@ public function init(): bool ( `id` VARCHAR(255) NOT NULL CONSTRAINT TestUnique_pk PRIMARY KEY, `name` VARCHAR(255) NOT NULL, + `status` VARCHAR(16) DEFAULT 'Active' NOT NULL, `created_at` TIMESTAMP NOT NULL ); " diff --git a/tests/TestStand/Tables/TestUpdateAtTable.php b/tests/TestStand/Tables/TestUpdateAtTable.php new file mode 100644 index 0000000..ea114bd --- /dev/null +++ b/tests/TestStand/Tables/TestUpdateAtTable.php @@ -0,0 +1,47 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\TestStand\Tables; + +use Composite\DB\AbstractTable; +use Composite\DB\TableConfig; +use Composite\DB\Tests\TestStand\Entities\TestUpdatedAtEntity; + +class TestUpdateAtTable extends AbstractTable +{ + public function __construct() + { + parent::__construct(); + $this->init(); + } + + protected function getConfig(): TableConfig + { + return TableConfig::fromEntitySchema(TestUpdatedAtEntity::schema()); + } + + public function findByPk(string $id): ?TestUpdatedAtEntity + { + return $this->_findByPk($id); + } + + public function init(): bool + { + $this->getConnection()->executeStatement( + " + CREATE TABLE IF NOT EXISTS {$this->getTableName()} + ( + `id` INTEGER NOT NULL CONSTRAINT TestAutoincrement_pk PRIMARY KEY AUTOINCREMENT, + `name` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP NOT NULL, + `updated_at` TIMESTAMP NOT NULL + ); + " + ); + return true; + } + + public function truncate(): void + { + $this->getConnection()->executeStatement("DELETE FROM {$this->getTableName()} WHERE 1"); + } +} \ No newline at end of file diff --git a/tests/TestStand/configs/empty_config.php b/tests/TestStand/configs/empty_config.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/TestStand/configs/wrong_content_config.php b/tests/TestStand/configs/wrong_content_config.php new file mode 100644 index 0000000..27cdc70 --- /dev/null +++ b/tests/TestStand/configs/wrong_content_config.php @@ -0,0 +1,2 @@ +<?php +return 'asd'; \ No newline at end of file diff --git a/tests/TestStand/configs/wrong_doctrine_config.php b/tests/TestStand/configs/wrong_doctrine_config.php new file mode 100644 index 0000000..a7a4b50 --- /dev/null +++ b/tests/TestStand/configs/wrong_doctrine_config.php @@ -0,0 +1,4 @@ +<?php +return [ + 'db1' => [], +]; \ No newline at end of file diff --git a/tests/TestStand/configs/wrong_name_config.php b/tests/TestStand/configs/wrong_name_config.php new file mode 100644 index 0000000..36b9da1 --- /dev/null +++ b/tests/TestStand/configs/wrong_name_config.php @@ -0,0 +1,4 @@ +<?php +return [ + 123 => [], +]; \ No newline at end of file diff --git a/tests/TestStand/configs/wrong_params_config.php b/tests/TestStand/configs/wrong_params_config.php new file mode 100644 index 0000000..cee852e --- /dev/null +++ b/tests/TestStand/configs/wrong_params_config.php @@ -0,0 +1,7 @@ +<?php +return [ + 'db1' => [ + 'driver' => 'pdo_nothing', + 'path' => __DIR__ . '/runtime/sqlite/database.db', + ], +]; \ No newline at end of file diff --git a/tests/Traits/OptimisticLockTest.php b/tests/Traits/OptimisticLockTest.php new file mode 100644 index 0000000..9856e25 --- /dev/null +++ b/tests/Traits/OptimisticLockTest.php @@ -0,0 +1,66 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\Traits; + +use Composite\DB\ConnectionManager; +use Composite\DB\Exceptions\DbException; +use Composite\DB\Tests\TestStand\Entities; +use Composite\DB\Tests\TestStand\Tables; + +final class OptimisticLockTest extends \PHPUnit\Framework\TestCase +{ + public function test_trait(): void + { + //checking that problem exists + $aiEntity1 = new Entities\TestAutoincrementEntity(name: 'John'); + $aiTable1 = new Tables\TestAutoincrementTable(); + $aiTable2 = new Tables\TestAutoincrementTable(); + + $aiTable1->save($aiEntity1); + + $aiEntity2 = $aiTable2->findByPk($aiEntity1->id); + + $db = ConnectionManager::getConnection($aiTable1->getConnectionName()); + + $db->beginTransaction(); + $aiEntity1->name = 'John1'; + $aiTable1->save($aiEntity1); + + $aiEntity2->name = 'John2'; + $aiTable2->save($aiEntity2); + + $this->assertTrue($db->commit()); + + $aiEntity3 = $aiTable1->findByPk($aiEntity1->id); + $this->assertEquals('John2', $aiEntity3->name); + + //Checking optimistic lock + $olEntity1 = new Entities\TestOptimisticLockEntity(name: 'John'); + $olTable1 = new Tables\TestOptimisticLockTable(); + $olTable2 = new Tables\TestOptimisticLockTable(); + + $olTable1->save($olEntity1); + + $olEntity2 = $olTable2->findByPk($olEntity1->id); + + $db->beginTransaction(); + $olEntity1->name = 'John1'; + $olTable1->save($olEntity1); + + $olEntity2->name = 'John2'; + + $exceptionCaught = false; + try { + $olTable2->save($olEntity2); + } catch (DbException) { + $exceptionCaught = true; + } + $this->assertTrue($exceptionCaught); + + $this->assertTrue($db->rollBack()); + + $olEntity3 = $olTable1->findByPk($olEntity1->id); + $this->assertEquals(1, $olEntity3->getVersion()); + $this->assertEquals('John', $olEntity3->name); + } +} \ No newline at end of file diff --git a/tests/Traits/UpdatedAtTest.php b/tests/Traits/UpdatedAtTest.php new file mode 100644 index 0000000..bb149c5 --- /dev/null +++ b/tests/Traits/UpdatedAtTest.php @@ -0,0 +1,36 @@ +<?php declare(strict_types=1); + +namespace Composite\DB\Tests\Traits; + +use Composite\DB\Tests\TestStand\Entities\TestUpdatedAtEntity; +use Composite\DB\Tests\TestStand\Tables\TestUpdateAtTable; + +final class UpdatedAtTest extends \PHPUnit\Framework\TestCase +{ + public function test_trait(): void + { + $entity = new TestUpdatedAtEntity('John'); + $this->assertNull($entity->updated_at); + + $table = new TestUpdateAtTable(); + $table->save($entity); + + $this->assertNotNull($entity->updated_at); + + $dbEntity = $table->findByPk($entity->id); + $this->assertNotNull($dbEntity); + + $this->assertEquals($entity->updated_at, $dbEntity->updated_at); + + + $entity->name = 'Richard'; + $table->save($entity); + + $this->assertNotEquals($entity->updated_at, $dbEntity->updated_at); + $lastUpdatedAt = $entity->updated_at; + + //should not update entity + $table->save($entity); + $this->assertEquals($lastUpdatedAt, $entity->updated_at); + } +} \ No newline at end of file