From 3ffda2b0b282f8f3ee9dde1edd052629c2b41d76 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 26 Dec 2022 19:20:27 +0000
Subject: [PATCH 01/68] Fix env CONNECTIONS_CONFIG_ENV_VAR fetching

---
 src/ConnectionManager.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php
index 5ff27db..8f7c4c6 100644
--- a/src/ConnectionManager.php
+++ b/src/ConnectionManager.php
@@ -40,7 +40,7 @@ public static function getConnection(string $name, ?Configuration $config = null
     private static function getConnectionParams(string $name): array
     {
         if (self::$configs === null) {
-            $configFile = getenv(self::CONNECTIONS_CONFIG_ENV_VAR, true);
+            $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 call ConnectionManager::configure() method or setup putenv(\'%s=/path/to/config/file.php\') variable',

From 9fb532384ff254804d92357cdb3ec1fe40344d6b Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 6 May 2023 09:54:27 +0100
Subject: [PATCH 02/68] Soft delete improvements

---
 src/AbstractCachedTable.php       |  5 ++++-
 src/AbstractTable.php             | 31 +++++++++++++++++++++++++++----
 src/TableConfig.php               |  3 +++
 tests/Table/AbstractTableTest.php |  6 +++---
 tests/Table/TableConfigTest.php   |  2 +-
 5 files changed, 38 insertions(+), 9 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 9d3064e..1f0906f 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -77,7 +77,7 @@ public function delete(AbstractEntity &$entity): void
      */
     public function deleteMany(array $entities): bool
     {
-        return $this->getConnection()->transactional(function() use ($entities) {
+        return (bool)$this->getConnection()->transactional(function() use ($entities) {
             $cacheKeys = [];
             foreach ($entities as $entity) {
                 $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
@@ -244,6 +244,9 @@ protected function buildCacheKey(mixed ...$parts): string
             $formattedParts = [];
             foreach ($parts as $part) {
                 if (is_array($part)) {
+                    if ($this->config->isSoftDelete && array_key_exists('deleted_at', $part)) {
+                        unset($part['deleted_at']);
+                    }
                     $string = json_encode($part);
                 } else {
                     $string = strval($part);
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 4d465f1..4a4c1c4 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -2,10 +2,12 @@
 
 namespace Composite\DB;
 
+use Composite\DB\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Exceptions\DbException;
 use Composite\Entity\Exceptions\EntityException;
 use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
 use Doctrine\DBAL\Query\QueryBuilder;
 
 abstract class AbstractTable
@@ -45,7 +47,7 @@ public function save(AbstractEntity &$entity): void
         $this->config->checkEntity($entity);
         if ($entity->isNew()) {
             $connection = $this->getConnection();
-            $insertData = $entity->toArray();
+            $insertData = $this->formatData($entity->toArray());
             $this->getConnection()->insert($this->getTableName(), $insertData);
 
             if ($this->config->autoIncrementKey) {
@@ -118,8 +120,13 @@ public function delete(AbstractEntity &$entity): void
         $this->config->checkEntity($entity);
         if ($this->config->isSoftDelete) {
             if (method_exists($entity, 'delete')) {
+                $condition = $this->getPkCondition($entity);
+                $this->getConnection()->update(
+                    $this->getTableName(),
+                    ['deleted_at' => DateTimeHelper::dateTimeToString(new \DateTime())],
+                    $condition,
+                );
                 $entity->delete();
-                $this->save($entity);
             }
         } else {
             $where = $this->getPkCondition($entity);
@@ -133,7 +140,7 @@ public function delete(AbstractEntity &$entity): void
      */
     public function deleteMany(array $entities): bool
     {
-        return $this->getConnection()->transactional(function() use ($entities) {
+        return (bool)$this->getConnection()->transactional(function() use ($entities) {
             foreach ($entities as $entity) {
                 $this->delete($entity);
             }
@@ -250,7 +257,11 @@ protected function getPkCondition(int|string|array|AbstractEntity $data): array
             }
         } else {
             foreach ($this->config->primaryKeys as $key) {
-                $condition[$key] = $data;
+                if ($this->config->isSoftDelete && $key === 'deleted_at') {
+                    $condition['deleted_at'] = null;
+                } else {
+                    $condition[$key] = $data;
+                }
             }
         }
         return $condition;
@@ -288,4 +299,16 @@ private function buildWhere(QueryBuilder $query, array $where): void
             }
         }
     }
+
+    private function formatData(array $data): array
+    {
+        foreach ($data as $columnName => $value) {
+            if ($this->getConnection()->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
+                if (is_bool($value)) {
+                    $data[$columnName] = $value ? 1 : 0;
+                }
+            }
+        }
+        return $data;
+    }
 }
diff --git a/src/TableConfig.php b/src/TableConfig.php
index c19c23e..ef2bacb 100644
--- a/src/TableConfig.php
+++ b/src/TableConfig.php
@@ -55,6 +55,9 @@ public static function fromEntitySchema(Schema $schema): TableConfig
         foreach (class_uses($schema->class) as $traitClass) {
             if ($traitClass === Traits\SoftDelete::class) {
                 $isSoftDelete = true;
+                if (!\in_array('deleted_at', $primaryKeys)) {
+                    $primaryKeys[] = 'deleted_at';
+                }
             } elseif ($traitClass === Traits\OptimisticLock::class) {
                 $isOptimisticLock = true;
             }
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index 0a7e78c..5cbedcd 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -48,17 +48,17 @@ public function getPkCondition_dataProvider(): array
             [
                 new Tables\TestAutoincrementSdTable(),
                 Entities\TestAutoincrementSdEntity::fromArray(['id' => 123, 'name' => 'John']),
-                ['id' => 123],
+                ['id' => 123, 'deleted_at' => null],
             ],
             [
                 new Tables\TestCompositeSdTable(),
                 new Entities\TestCompositeSdEntity(user_id: 123, post_id: 456, message: 'Text'),
-                ['user_id' => 123, 'post_id' => 456],
+                ['user_id' => 123, 'post_id' => 456, 'deleted_at' => null],
             ],
             [
                 new Tables\TestUniqueSdTable(),
                 new Entities\TestUniqueSdEntity(id: '123abc', name: 'John'),
-                ['id' => '123abc'],
+                ['id' => '123abc', 'deleted_at' => null],
             ],
         ];
     }
diff --git a/tests/Table/TableConfigTest.php b/tests/Table/TableConfigTest.php
index b4c558e..528a835 100644
--- a/tests/Table/TableConfigTest.php
+++ b/tests/Table/TableConfigTest.php
@@ -31,7 +31,7 @@ public function __construct(
         $this->assertNotEmpty($tableConfig->connectionName);
         $this->assertNotEmpty($tableConfig->tableName);
         $this->assertTrue($tableConfig->isSoftDelete);
-        $this->assertCount(1, $tableConfig->primaryKeys);
+        $this->assertCount(2, $tableConfig->primaryKeys);
         $this->assertSame('id', $tableConfig->autoIncrementKey);
     }
 }
\ No newline at end of file

From 317462f8527fba12b5747fdf608d5d1301d2d9a2 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 6 May 2023 09:57:03 +0100
Subject: [PATCH 03/68] Move code generation to compositephp/sync repository
 and make db repo more clean and independent

---
 composer.json                                 |   9 +-
 src/Attributes/Column.php                     |  15 -
 src/Attributes/Index.php                      |  13 -
 src/Attributes/Strict.php                     |   6 -
 src/Commands/CommandHelperTrait.php           | 108 -------
 src/Commands/GenerateEntityCommand.php        |  90 ------
 src/Commands/GenerateTableCommand.php         |  95 -------
 src/Generator/AbstractTableClassBuilder.php   |  68 -----
 src/Generator/CachedTableClassBuilder.php     |  97 -------
 src/Generator/EntityClassBuilder.php          | 264 ------------------
 src/Generator/EnumClassBuilder.php            |  32 ---
 src/Generator/Schema/ColumnType.php           |  15 -
 .../Schema/Parsers/MySQLSchemaParser.php      | 122 --------
 .../Schema/Parsers/PostgresSchemaParser.php   | 221 ---------------
 .../Schema/Parsers/SQLiteSchemaParser.php     | 247 ----------------
 src/Generator/Schema/SQLColumn.php            |  45 ---
 src/Generator/Schema/SQLEnum.php              |  11 -
 src/Generator/Schema/SQLIndex.php             |  13 -
 src/Generator/Schema/SQLSchema.php            |  52 ----
 src/Generator/TableClassBuilder.php           |  77 -----
 src/Generator/Templates/EntityTemplate.php    |  41 ---
 src/Helpers/ClassHelper.php                   |  18 --
 .../Entities/TestDiversityEntity.php          |   1 -
 tests/TestStand/Entities/TestEntity.php       |   1 -
 .../Entities/{Enums => }/TestSubEntity.php    |   2 +-
 25 files changed, 4 insertions(+), 1659 deletions(-)
 delete mode 100644 src/Attributes/Column.php
 delete mode 100644 src/Attributes/Index.php
 delete mode 100644 src/Attributes/Strict.php
 delete mode 100644 src/Commands/CommandHelperTrait.php
 delete mode 100644 src/Commands/GenerateEntityCommand.php
 delete mode 100644 src/Commands/GenerateTableCommand.php
 delete mode 100644 src/Generator/AbstractTableClassBuilder.php
 delete mode 100644 src/Generator/CachedTableClassBuilder.php
 delete mode 100644 src/Generator/EntityClassBuilder.php
 delete mode 100644 src/Generator/EnumClassBuilder.php
 delete mode 100644 src/Generator/Schema/ColumnType.php
 delete mode 100644 src/Generator/Schema/Parsers/MySQLSchemaParser.php
 delete mode 100644 src/Generator/Schema/Parsers/PostgresSchemaParser.php
 delete mode 100644 src/Generator/Schema/Parsers/SQLiteSchemaParser.php
 delete mode 100644 src/Generator/Schema/SQLColumn.php
 delete mode 100644 src/Generator/Schema/SQLEnum.php
 delete mode 100644 src/Generator/Schema/SQLIndex.php
 delete mode 100644 src/Generator/Schema/SQLSchema.php
 delete mode 100644 src/Generator/TableClassBuilder.php
 delete mode 100644 src/Generator/Templates/EntityTemplate.php
 delete mode 100644 src/Helpers/ClassHelper.php
 rename tests/TestStand/Entities/{Enums => }/TestSubEntity.php (80%)

diff --git a/composer.json b/composer.json
index bc13c29..5d8f909 100644
--- a/composer.json
+++ b/composer.json
@@ -13,13 +13,10 @@
     ],
     "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": "dev-master",
+        "doctrine/dbal": "^3.5"
     },
     "require-dev": {
         "kodus/file-cache": "^2.0",
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/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/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/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/tests/TestStand/Entities/TestDiversityEntity.php b/tests/TestStand/Entities/TestDiversityEntity.php
index 049277a..5d3153f 100644
--- a/tests/TestStand/Entities/TestDiversityEntity.php
+++ b/tests/TestStand/Entities/TestDiversityEntity.php
@@ -6,7 +6,6 @@
 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;
diff --git a/tests/TestStand/Entities/TestEntity.php b/tests/TestStand/Entities/TestEntity.php
index d39ad72..b90dae1 100644
--- a/tests/TestStand/Entities/TestEntity.php
+++ b/tests/TestStand/Entities/TestEntity.php
@@ -5,7 +5,6 @@
 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;
 
diff --git a/tests/TestStand/Entities/Enums/TestSubEntity.php b/tests/TestStand/Entities/TestSubEntity.php
similarity index 80%
rename from tests/TestStand/Entities/Enums/TestSubEntity.php
rename to tests/TestStand/Entities/TestSubEntity.php
index 7199082..f59eec4 100644
--- a/tests/TestStand/Entities/Enums/TestSubEntity.php
+++ b/tests/TestStand/Entities/TestSubEntity.php
@@ -1,6 +1,6 @@
 <?php declare(strict_types=1);
 
-namespace Composite\DB\Tests\TestStand\Entities\Enums;
+namespace Composite\DB\Tests\TestStand\Entities;
 
 use Composite\Entity\AbstractEntity;
 

From 6638a44c92c634d834e45dfb68848830479b2f55 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 6 May 2023 10:02:03 +0100
Subject: [PATCH 04/68] Update Entity version dependency to ^0.1.4

---
 composer.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index 5d8f909..77acd90 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "dev-master",
+        "compositephp/entity": "^0.1.4",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {

From 5c4f41b58e8541d573307d0f44d0e0ee0c93fc90 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 13 May 2023 11:23:02 +0100
Subject: [PATCH 05/68] Change configure method to loadConfigs that returns
 array of configs

---
 src/ConnectionManager.php | 51 ++++++++++++++++++++-------------------
 1 file changed, 26 insertions(+), 25 deletions(-)

diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php
index 8f7c4c6..dfed177 100644
--- a/src/ConnectionManager.php
+++ b/src/ConnectionManager.php
@@ -40,27 +40,7 @@ public static function getConnection(string $name, ?Configuration $config = null
     private static function getConnectionParams(string $name): array
     {
         if (self::$configs === null) {
-            $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 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");
     }
@@ -68,17 +48,38 @@ private static function getConnectionParams(string $name): array
     /**
      * @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_once $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

From c9dafe8d34cc216d085b78d543cce9a99d3c9e24 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 13 May 2023 11:46:30 +0100
Subject: [PATCH 06/68] Minor optimizations and adapt to phpstan level 8

---
 src/AbstractCachedTable.php   | 29 ++++++++++++++++++++++----
 src/AbstractTable.php         | 39 +++++++++++++++++++++++++++++++++++
 src/Attributes/PrimaryKey.php |  2 +-
 src/Attributes/Table.php      |  2 +-
 src/CombinedTransaction.php   |  1 +
 src/ConnectionManager.php     |  4 ++++
 src/TableConfig.php           | 11 +++++-----
 7 files changed, 76 insertions(+), 12 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 1f0906f..e6370e4 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -37,7 +37,7 @@ public function save(AbstractEntity &$entity): void
 
     /**
      * @param AbstractEntity[] $entities
-     * @return  AbstractEntity[]
+     * @return AbstractEntity[]
      * @throws \Throwable
      */
     public function saveMany(array $entities): array
@@ -128,6 +128,7 @@ protected function findOneCachedInternal(array $condition, null|int|\DateInterva
     }
 
     /**
+     * @param array<string, mixed> $whereParams
      * @param array<string, string>|string $orderBy
      * @return array<string, mixed>[]
      */
@@ -175,6 +176,12 @@ protected function getCached(string $cacheKey, callable $dataCallback, null|int|
         return $data;
     }
 
+    /**
+     * @param mixed[] $ids
+     * @param int|\DateInterval|null $ttl
+     * @return array<array<string, mixed>>
+     * @throws \Psr\SimpleCache\InvalidArgumentException
+     */
     protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $ttl = null): array
     {
         $result = $cacheKeys = $foundIds = [];
@@ -199,6 +206,10 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
         return $result;
     }
 
+    /**
+     * @param string|int|array<string, mixed>|AbstractEntity $keyOrEntity
+     * @throws \Composite\Entity\Exceptions\EntityException
+     */
     protected function getOneCacheKey(string|int|array|AbstractEntity $keyOrEntity): string
     {
         if (!is_array($keyOrEntity)) {
@@ -209,6 +220,10 @@ protected function getOneCacheKey(string|int|array|AbstractEntity $keyOrEntity):
         return $this->buildCacheKey('o', $condition ?: 'one');
     }
 
+    /**
+     * @param array<string, mixed> $whereParams
+     * @param array<string, string>|string $orderBy
+     */
     protected function getListCacheKey(
         string $whereString = '',
         array $whereParams = [],
@@ -225,6 +240,9 @@ protected function getListCacheKey(
         );
     }
 
+    /**
+     * @param array<string, mixed> $whereParams
+     */
     protected function getCountCacheKey(
         string $whereString = '',
         array $whereParams = [],
@@ -247,7 +265,7 @@ protected function buildCacheKey(mixed ...$parts): string
                     if ($this->config->isSoftDelete && array_key_exists('deleted_at', $part)) {
                         unset($part['deleted_at']);
                     }
-                    $string = json_encode($part);
+                    $string = json_encode($part, JSON_THROW_ON_ERROR);
                 } else {
                     $string = strval($part);
                 }
@@ -273,10 +291,13 @@ private function formatStringForCacheKey(string $string): string
     {
         $string = mb_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), '_');
     }
 
+    /**
+     * @param array<string, mixed> $whereParams
+     */
     private function prepareWhereKey(string $whereString, array $whereParams): ?string
     {
         if (!$whereString) {
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 4a4c1c4..6d72b16 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -148,6 +148,10 @@ public function deleteMany(array $entities): bool
         });
     }
 
+    /**
+     * @param array<string, mixed> $whereParams
+     * @throws \Doctrine\DBAL\Exception
+     */
     protected function countAllInternal(string $whereString = '', array $whereParams = []): int
     {
         $query = $this->select('COUNT(*)');
@@ -161,12 +165,22 @@ protected function countAllInternal(string $whereString = '', array $whereParams
         return intval($query->executeQuery()->fetchOne());
     }
 
+    /**
+     * @return array<string, mixed>|null
+     * @throws EntityException
+     * @throws \Doctrine\DBAL\Exception
+     */
     protected function findByPkInternal(mixed $pk): ?array
     {
         $where = $this->getPkCondition($pk);
         return $this->findOneInternal($where);
     }
 
+    /**
+     * @param array<string, mixed> $where
+     * @return array<string, mixed>|null
+     * @throws \Doctrine\DBAL\Exception
+     */
     protected function findOneInternal(array $where): ?array
     {
         $query = $this->select();
@@ -175,6 +189,12 @@ protected function findOneInternal(array $where): ?array
         return $query->fetchAssociative() ?: null;
     }
 
+    /**
+     * @param array<string, mixed> $whereParams
+     * @param array<string, string>|string $orderBy
+     * @return array<string, mixed>
+     * @throws \Doctrine\DBAL\Exception
+     */
     protected function findAllInternal(
         string $whereString = '',
         array $whereParams = [],
@@ -224,6 +244,9 @@ final protected function createEntity(mixed $data): mixed
         }
     }
 
+    /**
+     * @return AbstractEntity[]
+     */
     final protected function createEntities(mixed $data): array
     {
         if (!is_array($data)) {
@@ -245,6 +268,11 @@ final protected function createEntities(mixed $data): array
         return $result;
     }
 
+    /**
+     * @param int|string|array<string, mixed>|AbstractEntity $data
+     * @return array<string, mixed>
+     * @throws EntityException
+     */
     protected function getPkCondition(int|string|array|AbstractEntity $data): array
     {
         $condition = [];
@@ -267,6 +295,9 @@ protected function getPkCondition(int|string|array|AbstractEntity $data): array
         return $condition;
     }
 
+    /**
+     * @param array<string, mixed>|QueryBuilder $query
+     */
     protected function enrichCondition(array|QueryBuilder &$query): void
     {
         if ($this->config->isSoftDelete) {
@@ -288,6 +319,9 @@ protected function select(string $select = '*'): QueryBuilder
         return (clone $this->selectQuery)->select($select);
     }
 
+    /**
+     * @param array<string, mixed> $where
+     */
     private function buildWhere(QueryBuilder $query, array $where): void
     {
         foreach ($where as $column => $value) {
@@ -300,6 +334,11 @@ private function buildWhere(QueryBuilder $query, array $where): void
         }
     }
 
+    /**
+     * @param array<string, mixed> $data
+     * @return array<string, mixed>
+     * @throws \Doctrine\DBAL\Exception
+     */
     private function formatData(array $data): array
     {
         foreach ($data as $columnName => $value) {
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/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..ec2598e 100644
--- a/src/CombinedTransaction.php
+++ b/src/CombinedTransaction.php
@@ -80,6 +80,7 @@ public function commit(): void
 
     /**
      * Pessimistic lock
+     * @param string[] $keyParts
      * @throws DbException
      */
     public function lock(CacheInterface $cache, array $keyParts, int $duration = 10): void
diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php
index dfed177..aeb463f 100644
--- a/src/ConnectionManager.php
+++ b/src/ConnectionManager.php
@@ -12,7 +12,9 @@
 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 = [];
 
     /**
@@ -35,6 +37,7 @@ public static function getConnection(string $name, ?Configuration $config = null
     }
 
     /**
+     * @return array<string, mixed>
      * @throws DbException
      */
     private static function getConnectionParams(string $name): array
@@ -46,6 +49,7 @@ private static function getConnectionParams(string $name): array
     }
 
     /**
+     * @return array<string, array<string, mixed>>
      * @throws DbException
      */
     private static function loadConfigs(): array
diff --git a/src/TableConfig.php b/src/TableConfig.php
index ef2bacb..a145ffb 100644
--- a/src/TableConfig.php
+++ b/src/TableConfig.php
@@ -9,6 +9,10 @@
 
 class TableConfig
 {
+    /**
+     * @param class-string<AbstractEntity> $entityClass
+     * @param string[] $primaryKeys
+     */
     public function __construct(
         public readonly string $connectionName,
         public readonly string $tableName,
@@ -26,12 +30,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`',

From a047a0bc2b073b3456a00aa20f318220744a7856 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 14 May 2023 23:52:58 +0100
Subject: [PATCH 07/68] Update documentation

---
 README.md                 |   2 +-
 doc/code-generators.md    |  99 ++++++++++++++++++-----
 doc/migrations.md         | 164 ++++++++++++++++++--------------------
 doc/sync_illustration.png | Bin 0 -> 62723 bytes
 4 files changed, 161 insertions(+), 104 deletions(-)
 create mode 100644 doc/sync_illustration.png

diff --git a/README.md b/README.md
index c4f3f59..a3802fd 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ just use native php class syntax.
 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)
+* **Migrations** - synchronise your php entities with database tables
 
 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
diff --git a/doc/code-generators.md b/doc/code-generators.md
index 276f294..865c987 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.
+Composite Sync is a powerful and flexible PHP library designed to streamline and automate the synchronization process between SQL database table structures and PHP entity classes.
+By providing a set of easy-to-use tools, Composite Sync eliminates the need for manual synchronization and helps you maintain the consistency of your application's data model.
 
-## 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/migrations.md b/doc/migrations.md
index da0fe68..b3247ae 100644
--- a/doc/migrations.md
+++ b/doc/migrations.md
@@ -2,96 +2,90 @@
 
 > **_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:
+Code generation is a key feature 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.
+
+## Supported Databases
+- MySQL
+- Postgres (Coming soon)
+- SQLite (Coming soon)
+
+## 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\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';
+//may be changed with .env file
+putenv('CONNECTIONS_CONFIG_FILE=/path/to/your/connections/config.php');
 
-$connection = DriverManager::getConnection([
-    'driver' => 'pdo_mysql',
-    'dbname' => 'test',
-    'user' => 'test',
-    'password' => 'test',
-    'host' => '127.0.0.1',
+$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.
+
+```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  | 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.
+
+```shell
+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:
 
-$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   | Description                                |
+|----------|--------------------------------------------|
+| --cached | Generate cached version of PHP Table class |
+| --force  | 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 0000000000000000000000000000000000000000..94c451a362331b53657587942b825015c6f2e841
GIT binary patch
literal 62723
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
NqN#f!|D4s`{{ypaC9ePg

literal 0
HcmV?d00001


From 6c04f620bca895e63af87f1bd1366508e19498e8 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 15 May 2023 00:12:30 +0100
Subject: [PATCH 08/68] Update documentation

---
 doc/code-generators.md | 4 ++--
 doc/migrations.md      | 8 +++++---
 2 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/doc/code-generators.md b/doc/code-generators.md
index 865c987..ce53614 100644
--- a/doc/code-generators.md
+++ b/doc/code-generators.md
@@ -1,7 +1,7 @@
 # Code generators
 
-Composite Sync is a powerful and flexible PHP library designed to streamline and automate the synchronization process between SQL database table structures and PHP entity classes.
-By providing a set of easy-to-use tools, Composite Sync eliminates the need for manual synchronization and helps you maintain the consistency of your application's data model.
+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.
 
 ## Supported Databases
 - MySQL
diff --git a/doc/migrations.md b/doc/migrations.md
index b3247ae..d6180e4 100644
--- a/doc/migrations.md
+++ b/doc/migrations.md
@@ -2,8 +2,10 @@
 
 > **_NOTE:_**  This is experimental feature
 
-Code generation is a key feature 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.
+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
@@ -12,7 +14,7 @@ This enables you to generate Entity classes directly from SQL tables, thereby en
 
 ## Getting Started
 
-To begin using Composite Sync in your project, follow these steps:
+To begin using migrations you need to add Composite Sync package into your project and configure it:
 
 ### 1. Install package via composer:
  ```shell

From 6fb07662bb03d5a97e73206519f8db94e56e5f66 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 15 May 2023 00:13:25 +0100
Subject: [PATCH 09/68] Update entity version in composer.json

---
 composer.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index 77acd90..5f435c3 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "^0.1.4",
+        "compositephp/entity": "^0.1.5",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {

From 31917ddd8666c0f6cd31fb96617f771ea0059417 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 20 May 2023 15:02:27 +0100
Subject: [PATCH 10/68] Add UpdatedAt trait, remove hidden logic behind
 SoftDelete trait

---
 src/AbstractCachedTable.php                   |  3 --
 src/AbstractTable.php                         | 51 ++++++------------
 src/Helpers/DateTimeHelper.php                |  2 +
 src/TableConfig.php                           | 41 +++++++++------
 src/Traits/UpdatedAt.php                      |  8 +++
 tests/Table/AbstractTableTest.php             | 52 ++-----------------
 tests/Table/AutoIncrementTableTest.php        | 14 ++++-
 tests/Table/CompositeTableTest.php            | 21 ++++----
 tests/Table/TableConfigTest.php               |  4 +-
 tests/Table/UniqueTableTest.php               | 23 ++++----
 .../Entities/TestUpdatedAtEntity.php          | 21 ++++++++
 tests/TestStand/Tables/TestCompositeTable.php |  2 +-
 tests/TestStand/Tables/TestUpdateAtTable.php  | 41 +++++++++++++++
 tests/Traits/UpdatedAtTest.php                | 26 ++++++++++
 14 files changed, 181 insertions(+), 128 deletions(-)
 create mode 100644 src/Traits/UpdatedAt.php
 create mode 100644 tests/TestStand/Entities/TestUpdatedAtEntity.php
 create mode 100644 tests/TestStand/Tables/TestUpdateAtTable.php
 create mode 100644 tests/Traits/UpdatedAtTest.php

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index e6370e4..92d124d 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -262,9 +262,6 @@ protected function buildCacheKey(mixed ...$parts): string
             $formattedParts = [];
             foreach ($parts as $part) {
                 if (is_array($part)) {
-                    if ($this->config->isSoftDelete && array_key_exists('deleted_at', $part)) {
-                        unset($part['deleted_at']);
-                    }
                     $string = json_encode($part, JSON_THROW_ON_ERROR);
                 } else {
                     $string = strval($part);
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 6d72b16..ad54d38 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -47,6 +47,9 @@ public function save(AbstractEntity &$entity): void
         $this->config->checkEntity($entity);
         if ($entity->isNew()) {
             $connection = $this->getConnection();
+            if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at') && $entity->updated_at === null) {
+                $entity->updated_at = new \DateTimeImmutable();
+            }
             $insertData = $this->formatData($entity->toArray());
             $this->getConnection()->insert($this->getTableName(), $insertData);
 
@@ -62,9 +65,13 @@ public function save(AbstractEntity &$entity): void
             }
             $connection = $this->getConnection();
             $where = $this->getPkCondition($entity);
-            $this->enrichCondition($where);
 
-            if ($this->config->isOptimisticLock && isset($entity->version)) {
+            if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at')) {
+                $entity->updated_at = new \DateTimeImmutable();
+                $changedColumns['updated_at'] = DateTimeHelper::dateTimeToString($entity->updated_at);
+            }
+
+            if ($this->config->hasOptimisticLock() && isset($entity->version)) {
                 $currentVersion = $entity->version;
                 try {
                     $connection->beginTransaction();
@@ -118,19 +125,13 @@ public function saveMany(array $entities): array
     public function delete(AbstractEntity &$entity): void
     {
         $this->config->checkEntity($entity);
-        if ($this->config->isSoftDelete) {
+        if ($this->config->hasSoftDelete()) {
             if (method_exists($entity, 'delete')) {
-                $condition = $this->getPkCondition($entity);
-                $this->getConnection()->update(
-                    $this->getTableName(),
-                    ['deleted_at' => DateTimeHelper::dateTimeToString(new \DateTime())],
-                    $condition,
-                );
                 $entity->delete();
+                $this->save($entity);
             }
         } else {
             $where = $this->getPkCondition($entity);
-            $this->enrichCondition($where);
             $this->getConnection()->delete($this->getTableName(), $where);
         }
     }
@@ -161,7 +162,6 @@ protected function countAllInternal(string $whereString = '', array $whereParams
                 $query->setParameter($param, $value);
             }
         }
-        $this->enrichCondition($query);
         return intval($query->executeQuery()->fetchOne());
     }
 
@@ -184,7 +184,6 @@ protected function findByPkInternal(mixed $pk): ?array
     protected function findOneInternal(array $where): ?array
     {
         $query = $this->select();
-        $this->enrichCondition($where);
         $this->buildWhere($query, $where);
         return $query->fetchAssociative() ?: null;
     }
@@ -210,8 +209,6 @@ protected function findAllInternal(
                 $query->setParameter($param, $value);
             }
         }
-        $this->enrichCondition($query);
-
         if ($orderBy) {
             if (is_array($orderBy)) {
                 foreach ($orderBy as $column => $direction) {
@@ -285,32 +282,12 @@ protected function getPkCondition(int|string|array|AbstractEntity $data): array
             }
         } else {
             foreach ($this->config->primaryKeys as $key) {
-                if ($this->config->isSoftDelete && $key === 'deleted_at') {
-                    $condition['deleted_at'] = null;
-                } else {
-                    $condition[$key] = $data;
-                }
+                $condition[$key] = $data;
             }
         }
         return $condition;
     }
 
-    /**
-     * @param array<string, mixed>|QueryBuilder $query
-     */
-    protected function enrichCondition(array|QueryBuilder &$query): 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) {
@@ -342,6 +319,10 @@ private function buildWhere(QueryBuilder $query, array $where): void
     private function formatData(array $data): array
     {
         foreach ($data as $columnName => $value) {
+            if ($value === null && $this->config->isPrimaryKey($columnName)) {
+                unset($data[$columnName]);
+                continue;
+            }
             if ($this->getConnection()->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
                 if (is_bool($value)) {
                     $data[$columnName] = $value ? 1 : 0;
diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php
index 59eea94..ae7220c 100644
--- a/src/Helpers/DateTimeHelper.php
+++ b/src/Helpers/DateTimeHelper.php
@@ -2,6 +2,8 @@
 
 namespace Composite\DB\Helpers;
 
+use Doctrine\DBAL\Platforms;
+
 class DateTimeHelper
 {
     final public const DEFAULT_TIMESTAMP = '1970-01-01 00:00:01';
diff --git a/src/TableConfig.php b/src/TableConfig.php
index a145ffb..ce1ace5 100644
--- a/src/TableConfig.php
+++ b/src/TableConfig.php
@@ -9,6 +9,8 @@
 
 class TableConfig
 {
+    private readonly array $entityTraits;
+
     /**
      * @param class-string<AbstractEntity> $entityClass
      * @param string[] $primaryKeys
@@ -19,10 +21,10 @@ public function __construct(
         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
@@ -39,7 +41,6 @@ public static function fromEntitySchema(Schema $schema): TableConfig
         }
         $primaryKeys = [];
         $autoIncrementKey = null;
-        $isSoftDelete = $isOptimisticLock = false;
 
         foreach ($schema->columns as $column) {
             foreach ($column->attributes as $attribute) {
@@ -51,24 +52,12 @@ public static function fromEntitySchema(Schema $schema): TableConfig
                 }
             }
         }
-        foreach (class_uses($schema->class) as $traitClass) {
-            if ($traitClass === Traits\SoftDelete::class) {
-                $isSoftDelete = true;
-                if (!\in_array('deleted_at', $primaryKeys)) {
-                    $primaryKeys[] = 'deleted_at';
-                }
-            } 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,
         );
     }
 
@@ -84,4 +73,24 @@ public function checkEntity(AbstractEntity $entity): void
             );
         }
     }
+
+    public function isPrimaryKey(string $columnName): bool
+    {
+        return \in_array($columnName, $this->primaryKeys);
+    }
+
+    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/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/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index 5cbedcd..a0a0dad 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -48,17 +48,17 @@ public function getPkCondition_dataProvider(): array
             [
                 new Tables\TestAutoincrementSdTable(),
                 Entities\TestAutoincrementSdEntity::fromArray(['id' => 123, 'name' => 'John']),
-                ['id' => 123, 'deleted_at' => null],
+                ['id' => 123],
             ],
             [
                 new Tables\TestCompositeSdTable(),
                 new Entities\TestCompositeSdEntity(user_id: 123, post_id: 456, message: 'Text'),
-                ['user_id' => 123, 'post_id' => 456, 'deleted_at' => null],
+                ['user_id' => 123, 'post_id' => 456],
             ],
             [
                 new Tables\TestUniqueSdTable(),
                 new Entities\TestUniqueSdEntity(id: '123abc', name: 'John'),
-                ['id' => '123abc', 'deleted_at' => null],
+                ['id' => '123abc'],
             ],
         ];
     }
@@ -73,52 +73,6 @@ public function test_getPkCondition(AbstractTable $table, int|string|array|Abstr
         $this->assertEquals($expected, $actual);
     }
 
-    public function enrichCondition_dataProvider(): array
-    {
-        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],
-            ],
-        ];
-    }
-
-    /**
-     * @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);
-    }
-
     public function test_illegalEntitySave(): void
     {
         $entity = new Entities\TestAutoincrementEntity(name: 'Foo');
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index c1a3535..9065d49 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -2,6 +2,8 @@
 
 namespace Composite\DB\Tests\Table;
 
+use Composite\DB\AbstractTable;
+use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Interfaces\IAutoincrementTable;
@@ -37,11 +39,13 @@ public function crud_dataProvider(): array
     }
 
     /**
+     * @param class-string<Entities\TestAutoincrementEntity|Entities\TestAutoincrementSdEntity> $class
      * @dataProvider crud_dataProvider
      */
-    public function test_crud(IAutoincrementTable $table, string $class): void
+    public function test_crud(AbstractTable&IAutoincrementTable $table, string $class): void
     {
         $table->truncate();
+        $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
             name: $this->getUniqueName(),
@@ -62,7 +66,13 @@ 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);
+        }
     }
 
     private function assertEntityExists(IAutoincrementTable $table, Entities\TestAutoincrementEntity $entity): void
diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php
index 87bc767..57dc064 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -2,6 +2,8 @@
 
 namespace Composite\DB\Tests\Table;
 
+use Composite\DB\AbstractTable;
+use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Interfaces\ICompositeTable;
@@ -37,11 +39,13 @@ public function crud_dataProvider(): array
     }
 
     /**
+     * @param class-string<Entities\TestCompositeEntity|Entities\TestCompositeSdEntity> $class
      * @dataProvider crud_dataProvider
      */
-    public function test_crud(ICompositeTable $table, string $class): void
+    public function test_crud(AbstractTable&ICompositeTable $table, string $class): void
     {
         $table->truncate();
+        $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
             user_id: mt_rand(1, 1000000),
@@ -57,15 +61,14 @@ public function test_crud(ICompositeTable $table, string $class): void
         $this->assertEntityExists($table, $entity);
 
         $table->delete($entity);
-        $this->assertEntityNotExists($table, $entity);
 
-        $newEntity = new $entity(
-            user_id: $entity->user_id,
-            post_id: $entity->post_id,
-            message: 'Hello User',
-        );
-        $table->save($newEntity);
-        $this->assertEntityExists($table, $newEntity);
+        if ($tableConfig->hasSoftDelete()) {
+            /** @var Entities\TestCompositeSdEntity $deletedEntity */
+            $deletedEntity = $table->findOne(user_id: $entity->user_id, post_id: $entity->post_id);
+            $this->assertTrue($deletedEntity->isDeleted());
+        } else {
+            $this->assertEntityNotExists($table, $entity);
+        }
     }
 
     private function assertEntityExists(ICompositeTable $table, Entities\TestCompositeEntity $entity): void
diff --git a/tests/Table/TableConfigTest.php b/tests/Table/TableConfigTest.php
index 528a835..5b712e0 100644
--- a/tests/Table/TableConfigTest.php
+++ b/tests/Table/TableConfigTest.php
@@ -30,8 +30,8 @@ public function __construct(
         $tableConfig = TableConfig::fromEntitySchema($schema);
         $this->assertNotEmpty($tableConfig->connectionName);
         $this->assertNotEmpty($tableConfig->tableName);
-        $this->assertTrue($tableConfig->isSoftDelete);
-        $this->assertCount(2, $tableConfig->primaryKeys);
+        $this->assertTrue($tableConfig->hasSoftDelete());
+        $this->assertCount(1, $tableConfig->primaryKeys);
         $this->assertSame('id', $tableConfig->autoIncrementKey);
     }
 }
\ No newline at end of file
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index cb834c3..e2f0a1e 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -2,6 +2,8 @@
 
 namespace Composite\DB\Tests\Table;
 
+use Composite\DB\AbstractTable;
+use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
@@ -37,11 +39,13 @@ public function crud_dataProvider(): array
     }
 
     /**
+     * @param class-string<Entities\TestUniqueEntity|Entities\TestUniqueSdEntity> $class
      * @dataProvider crud_dataProvider
      */
-    public function test_crud(IUniqueTable $table, string $class): void
+    public function test_crud(AbstractTable&IUniqueTable $table, string $class): void
     {
         $table->truncate();
+        $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
             id: uniqid(),
@@ -56,17 +60,14 @@ public function test_crud(IUniqueTable $table, string $class): void
         $this->assertEntityExists($table, $entity);
 
         $table->delete($entity);
-        $this->assertEntityNotExists($table, $entity);
-
-        $newEntity = new $entity(
-            id: $entity->id,
-            name: $entity->name . ' new',
-        );
-        $table->save($newEntity);
-        $this->assertEntityExists($table, $newEntity);
 
-        $table->delete($newEntity);
-        $this->assertEntityNotExists($table, $newEntity);
+        if ($tableConfig->hasSoftDelete()) {
+            /** @var Entities\TestUniqueSdEntity $deletedEntity */
+            $deletedEntity = $table->findByPk($entity->id);
+            $this->assertTrue($deletedEntity->isDeleted());
+        } else {
+            $this->assertEntityNotExists($table, $entity);
+        }
     }
 
     private function assertEntityExists(IUniqueTable $table, Entities\TestUniqueEntity $entity): void
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/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index 931ff55..a6b9bba 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -42,7 +42,7 @@ public function countAllByUser(int $userId): int
     {
         return $this->countAllInternal(
             'user_id = :user_id',
-            ['user_id' => $userId],
+            ['user_id' => $userId, 'deleted_at' => null],
         );
     }
 
diff --git a/tests/TestStand/Tables/TestUpdateAtTable.php b/tests/TestStand/Tables/TestUpdateAtTable.php
new file mode 100644
index 0000000..9ecc449
--- /dev/null
+++ b/tests/TestStand/Tables/TestUpdateAtTable.php
@@ -0,0 +1,41 @@
+<?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
+{
+    protected function getConfig(): TableConfig
+    {
+        return TableConfig::fromEntitySchema(TestUpdatedAtEntity::schema());
+    }
+
+    public function findByPk(string $id): ?TestUpdatedAtEntity
+    {
+        return $this->createEntity($this->findByPkInternal($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/Traits/UpdatedAtTest.php b/tests/Traits/UpdatedAtTest.php
new file mode 100644
index 0000000..a3a3712
--- /dev/null
+++ b/tests/Traits/UpdatedAtTest.php
@@ -0,0 +1,26 @@
+<?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->init();
+        $table->save($entity);
+
+        $this->assertNotNull($entity->updated_at);
+
+        $dbEntity = $table->findByPk($entity->id);
+        $this->assertNotNull($dbEntity);
+
+        $this->assertEquals($entity->updated_at, $dbEntity->updated_at);
+    }
+}
\ No newline at end of file

From d041bf33c04a5f2d46ff73f8c19a539a4de4a349 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 20 May 2023 15:02:53 +0100
Subject: [PATCH 11/68] Add .gitattributes

---
 .gitattributes | 5 +++++
 1 file changed, 5 insertions(+)
 create mode 100644 .gitattributes

diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..af43e6b
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,5 @@
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/doc export-ignore
+/phpunit.xml export-ignore
+/tests export-ignore

From 57ce3ece14db631f5c29cbc4f0bc941462a1cec3 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 20 May 2023 15:11:15 +0100
Subject: [PATCH 12/68] Update compositephp/entity version in composer.json

---
 composer.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index 5f435c3..2167628 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "^0.1.5",
+        "compositephp/entity": "^0.1.7",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {

From 34fae1b693fbc9ea38514c3d7cbcf31a4c86cc51 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 27 May 2023 10:04:32 +0100
Subject: [PATCH 13/68] Move optimistic lock test from AbstractTableTest to
 separate test file

---
 tests/Table/AbstractTableTest.php   | 57 ------------------------
 tests/Traits/OptimisticLockTest.php | 68 +++++++++++++++++++++++++++++
 2 files changed, 68 insertions(+), 57 deletions(-)
 create mode 100644 tests/Traits/OptimisticLockTest.php

diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index a0a0dad..aaf68c3 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -86,61 +86,4 @@ public function test_illegalEntitySave(): void
         }
         $this->assertTrue($exceptionCatch);
     }
-
-    public function test_optimisticLock(): 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->init();
-
-        $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->version);
-        $this->assertEquals('John', $olEntity3->name);
-    }
 }
\ No newline at end of file
diff --git a/tests/Traits/OptimisticLockTest.php b/tests/Traits/OptimisticLockTest.php
new file mode 100644
index 0000000..737e279
--- /dev/null
+++ b/tests/Traits/OptimisticLockTest.php
@@ -0,0 +1,68 @@
+<?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->init();
+
+        $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->version);
+        $this->assertEquals('John', $olEntity3->name);
+    }
+}
\ No newline at end of file

From ba7be35f264bfb9127fc097f665786ad1d755184 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 27 May 2023 10:12:19 +0100
Subject: [PATCH 14/68] Update transactions doc

---
 doc/table.md | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/doc/table.md b/doc/table.md
index 326f095..c80f0d8 100644
--- a/doc/table.md
+++ b/doc/table.md
@@ -93,27 +93,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 

From f2798fa061dbec00ecb5dbb5aecff4758431ce10 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 09:43:15 +0100
Subject: [PATCH 15/68] Add github actions and codecov

---
 .github/workflows/main.yml | 45 ++++++++++++++++++++++++++++++++++++++
 .gitignore                 |  2 ++
 phpunit.xml                | 34 +++++++++++++++++++++++-----
 3 files changed, 75 insertions(+), 6 deletions(-)
 create mode 100644 .github/workflows/main.yml

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..544a7d2
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,45 @@
+name: PHP Composer
+
+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: 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..49f2327 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
 .idea
 .phpunit.result.cache
+.phpunit.cache
+coverage.clover
 vendor/
 composer.lock
 tests/runtime/
diff --git a/phpunit.xml b/phpunit.xml
index 23ced23..f39f444 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,6 +1,28 @@
-<?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/9.5/phpunit.xsd"
+         bootstrap="tests/bootstrap.php"
+         cacheResultFile=".phpunit.cache/test-results"
+         executionOrder="depends,defects"
+         forceCoversAnnotation="false"
+         beStrictAboutCoversAnnotation="true"
+         beStrictAboutOutputDuringTests="true"
+         beStrictAboutTodoAnnotatedTests="true"
+         convertDeprecationsToExceptions="true"
+         failOnRisky="true"
+         failOnWarning="true"
+         verbose="true"
+         colors="true">
+    <testsuites>
+        <testsuite name="default">
+            <directory>tests</directory>
+        </testsuite>
+    </testsuites>
+
+    <coverage cacheDirectory=".phpunit.cache/code-coverage"
+              processUncoveredFiles="true">
+        <include>
+            <directory suffix=".php">src</directory>
+        </include>
+    </coverage>
+</phpunit>

From c566dbfe624c432debaf15e5224dfd608ed3c884 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 09:55:43 +0100
Subject: [PATCH 16/68] Update github workflow

---
 .github/workflows/main.yml | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 544a7d2..4f98a1a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -32,6 +32,12 @@ jobs:
     - name: Install dependencies
       run: composer install --prefer-dist --no-progress
 
+    - name: Create runtime cache folder
+      run: mkdir -p -m 777 runtime/cache
+
+    - name: Create sqlite folder
+      run: mkdir -p -m 777 runtime/sqlite && touch runtime/sqlite/database.db
+
     - name: Execute Tests
       run: vendor/bin/phpunit tests --colors=always --coverage-clover=coverage.clover
       env:

From ccb5f3245ed2607757601c9f23e0a024c490c4bd Mon Sep 17 00:00:00 2001
From: compositephp <38870693+compositephp@users.noreply.github.com>
Date: Sun, 28 May 2023 09:58:30 +0100
Subject: [PATCH 17/68] Update main.yml

---
 .github/workflows/main.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 4f98a1a..a6cfd81 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -33,10 +33,10 @@ jobs:
       run: composer install --prefer-dist --no-progress
 
     - name: Create runtime cache folder
-      run: mkdir -p -m 777 runtime/cache
+      run: mkdir -v -p -m 777 runtime/cache
 
     - name: Create sqlite folder
-      run: mkdir -p -m 777 runtime/sqlite && touch runtime/sqlite/database.db
+      run: mkdir -v -p -m 777 runtime/sqlite && touch runtime/sqlite/database.db
 
     - name: Execute Tests
       run: vendor/bin/phpunit tests --colors=always --coverage-clover=coverage.clover

From 98fac68b69241f944630eab857a101477fd1cc26 Mon Sep 17 00:00:00 2001
From: compositephp <38870693+compositephp@users.noreply.github.com>
Date: Sun, 28 May 2023 10:00:10 +0100
Subject: [PATCH 18/68] Update main.yml

---
 .github/workflows/main.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a6cfd81..b17c9ef 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -33,10 +33,10 @@ jobs:
       run: composer install --prefer-dist --no-progress
 
     - name: Create runtime cache folder
-      run: mkdir -v -p -m 777 runtime/cache
+      run: mkdir -v -p -m 777 tests/runtime/cache
 
     - name: Create sqlite folder
-      run: mkdir -v -p -m 777 runtime/sqlite && touch runtime/sqlite/database.db
+      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

From bcb7b703ac334f79ec9578d4b6283804934c93c6 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 10:06:27 +0100
Subject: [PATCH 19/68] Fix some test sequence

---
 tests/Table/AbstractTableTest.php             |  2 --
 tests/Table/CombinedTransactionTest.php       | 11 +++++++++++
 tests/TestStand/Tables/TestCompositeTable.php | 11 -----------
 3 files changed, 11 insertions(+), 13 deletions(-)

diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index aaf68c3..ce6f402 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -3,8 +3,6 @@
 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;
diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php
index 7108179..4ee8865 100644
--- a/tests/Table/CombinedTransactionTest.php
+++ b/tests/Table/CombinedTransactionTest.php
@@ -12,7 +12,10 @@ final class CombinedTransactionTest extends BaseTableTest
     public function test_transactionCommit(): void
     {
         $autoIncrementTable = new Tables\TestAutoincrementTable();
+        $autoIncrementTable->init();
+
         $compositeTable = new Tables\TestCompositeTable();
+        $compositeTable->init();
 
         $saveTransaction = new CombinedTransaction();
 
@@ -39,7 +42,10 @@ public function test_transactionCommit(): void
     public function test_transactionRollback(): void
     {
         $autoIncrementTable = new Tables\TestAutoincrementTable();
+        $autoIncrementTable->init();
+
         $compositeTable = new Tables\TestCompositeTable();
+        $compositeTable->init();
 
         $transaction = new CombinedTransaction();
 
@@ -58,7 +64,10 @@ public function test_transactionRollback(): void
     public function test_transactionException(): void
     {
         $autoIncrementTable = new Tables\TestAutoincrementTable();
+        $autoIncrementTable->init();
+
         $compositeTable = new Tables\TestCompositeTable();
+        $compositeTable->init();
 
         $transaction = new CombinedTransaction();
 
@@ -80,6 +89,8 @@ public function test_lock(): void
     {
         $cache = self::getCache();
         $table = new Tables\TestAutoincrementTable();
+        $table->init();
+
         $e1 = new Entities\TestAutoincrementEntity(name: 'Foo');
         $e2 = new Entities\TestAutoincrementEntity(name: 'Bar');
 
diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index a6b9bba..f69c00b 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -46,17 +46,6 @@ public function countAllByUser(int $userId): int
         );
     }
 
-    public function test(): array
-    {
-        $rows = $this
-            ->select()
-            ->where()
-            ->orWhere()
-            ->orderBy()
-            ->fetchAllAssociative();
-        return $this->createEntities($rows);
-    }
-
     public function init(): bool
     {
         $this->getConnection()->executeStatement(

From ffab09bccf4b264bcc6d1b4444f292ba625598cf Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 10:13:44 +0100
Subject: [PATCH 20/68] Delete useless Helper

---
 src/AbstractTable.php                         |  2 +-
 src/Helpers/DateTimeHelper.php                | 39 -------------------
 .../Castable/TestCastableIntObject.php        |  2 +-
 3 files changed, 2 insertions(+), 41 deletions(-)
 delete mode 100644 src/Helpers/DateTimeHelper.php

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index ad54d38..7434c5d 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -2,7 +2,7 @@
 
 namespace Composite\DB;
 
-use Composite\DB\Helpers\DateTimeHelper;
+use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Exceptions\DbException;
 use Composite\Entity\Exceptions\EntityException;
diff --git a/src/Helpers/DateTimeHelper.php b/src/Helpers/DateTimeHelper.php
deleted file mode 100644
index ae7220c..0000000
--- a/src/Helpers/DateTimeHelper.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Composite\DB\Helpers;
-
-use Doctrine\DBAL\Platforms;
-
-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/tests/TestStand/Entities/Castable/TestCastableIntObject.php b/tests/TestStand/Entities/Castable/TestCastableIntObject.php
index 56baddf..221da00 100644
--- a/tests/TestStand/Entities/Castable/TestCastableIntObject.php
+++ b/tests/TestStand/Entities/Castable/TestCastableIntObject.php
@@ -2,7 +2,7 @@
 
 namespace Composite\DB\Tests\TestStand\Entities\Castable;
 
-use Composite\DB\Helpers\DateTimeHelper;
+use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\CastableInterface;
 
 class TestCastableIntObject extends \DateTime implements CastableInterface

From 8bec53e2f9f0113f267f4665739bc753fd9cc491 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 10:58:39 +0100
Subject: [PATCH 21/68] Add
 \Composite\DB\Tests\Connection\ConnectionManagerTest

---
 tests/Connection/ConnectionManagerTest.php | 31 ++++++++++++++++++++++
 1 file changed, 31 insertions(+)
 create mode 100644 tests/Connection/ConnectionManagerTest.php

diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php
new file mode 100644
index 0000000..f1e63ee
--- /dev/null
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -0,0 +1,31 @@
+<?php declare(strict_types=1);
+
+namespace Composite\DB\Tests\Connection;
+
+use Composite\DB\ConnectionManager;
+use Composite\DB\Exceptions\DbException;
+use Doctrine\DBAL\Connection;
+
+final class ConnectionManagerTest extends \PHPUnit\Framework\TestCase
+{
+    public function test_getConnection(): void
+    {
+        $connection = ConnectionManager::getConnection('sqlite');
+        $this->assertInstanceOf(Connection::class, $connection);
+    }
+
+    public function test_getConnectionWithInvalidConfig(): void
+    {
+        putenv('CONNECTIONS_CONFIG_FILE=invalid/path');
+        $this->expectException(DbException::class);
+
+        ConnectionManager::getConnection('db1');
+    }
+
+    public function test_getConnectionWithMissingName(): void
+    {
+        $this->expectException(DbException::class);
+
+        ConnectionManager::getConnection('missing_db');
+    }
+}
\ No newline at end of file

From d14f57d305a630dcfa54984962b4f8f619a36ead Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 11:32:06 +0100
Subject: [PATCH 22/68] Update saveMany adn deleteMany test cases

---
 src/AbstractCachedTable.php            |  6 +-----
 src/AbstractTable.php                  |  3 +--
 tests/Table/AutoIncrementTableTest.php | 21 +++++++++++++++++++++
 3 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 92d124d..65f1e01 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -44,10 +44,8 @@ public function saveMany(array $entities): array
     {
         return $this->getConnection()->transactional(function() use ($entities) {
             $cacheKeys = [];
-            foreach ($entities as $entity) {
+            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))) {
@@ -81,8 +79,6 @@ public function deleteMany(array $entities): bool
             $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))) {
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 7434c5d..929d35c 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -106,13 +106,12 @@ public function save(AbstractEntity &$entity): void
 
     /**
      * @param AbstractEntity[] $entities
-     * @return AbstractEntity[] $entities
      * @throws \Throwable
      */
     public function saveMany(array $entities): array
     {
         return $this->getConnection()->transactional(function() use ($entities) {
-            foreach ($entities as $entity) {
+            foreach ($entities as &$entity) {
                 $this->save($entity);
             }
             return $entities;
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index 9065d49..2d34292 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -73,6 +73,27 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         } else {
             $this->assertEntityNotExists($table, $entity->id, $entity->name);
         }
+
+        $e1 = new $class($this->getUniqueName());
+        $e2 = new $class($this->getUniqueName());
+
+        [$e1, $e2] = $table->saveMany([$e1, $e2]);
+        $this->assertEntityExists($table, $e1);
+        $this->assertEntityExists($table, $e2);
+        $this->assertTrue($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);
+        }
     }
 
     private function assertEntityExists(IAutoincrementTable $table, Entities\TestAutoincrementEntity $entity): void

From 2071365b467cd7fe7cb37f831c034de8f6fc7e63 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 11:56:47 +0100
Subject: [PATCH 23/68] More tests

---
 tests/Connection/ConnectionManagerTest.php    | 48 ++++++++++++++++---
 tests/TestStand/configs/empty_config.php      |  0
 .../configs/wrong_content_config.php          |  2 +
 tests/TestStand/configs/wrong_name_config.php |  4 ++
 .../TestStand/configs/wrong_params_config.php |  4 ++
 5 files changed, 51 insertions(+), 7 deletions(-)
 create mode 100644 tests/TestStand/configs/empty_config.php
 create mode 100644 tests/TestStand/configs/wrong_content_config.php
 create mode 100644 tests/TestStand/configs/wrong_name_config.php
 create mode 100644 tests/TestStand/configs/wrong_params_config.php

diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php
index f1e63ee..31c7b90 100644
--- a/tests/Connection/ConnectionManagerTest.php
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -14,18 +14,52 @@ public function test_getConnection(): void
         $this->assertInstanceOf(Connection::class, $connection);
     }
 
-    public function test_getConnectionWithInvalidConfig(): void
+    public function invalidConfig_dataProvider(): array
     {
-        putenv('CONNECTIONS_CONFIG_FILE=invalid/path');
-        $this->expectException(DbException::class);
+        $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',
+            ],
+        ];
+    }
 
-        ConnectionManager::getConnection('db1');
+    /**
+     * @dataProvider invalidConfig_dataProvider
+     */
+    public function test_invalidConfig(string $configPath): void
+    {
+        $currentPath = getenv('CONNECTIONS_CONFIG_FILE');
+        putenv('CONNECTIONS_CONFIG_FILE=' . $configPath);
+        try {
+            ConnectionManager::getConnection('db1');
+            $this->assertTrue(false);
+        } catch (DbException) {
+            $this->assertTrue(true);
+        } finally {
+            putenv('CONNECTIONS_CONFIG_FILE=' . $currentPath);
+        }
     }
 
     public function test_getConnectionWithMissingName(): void
     {
-        $this->expectException(DbException::class);
-
-        ConnectionManager::getConnection('missing_db');
+        try {
+            ConnectionManager::getConnection('invalid_name');
+            $this->assertTrue(false);
+        } catch (DbException) {
+            $this->assertTrue(true);
+        }
     }
 }
\ 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_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..7993161
--- /dev/null
+++ b/tests/TestStand/configs/wrong_params_config.php
@@ -0,0 +1,4 @@
+<?php
+return [
+    'db1' => 123,
+];
\ No newline at end of file

From e18ae2de7d409e64614b3ed69a99ca75407a8a19 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 12:16:09 +0100
Subject: [PATCH 24/68] Update tests

---
 tests/Connection/ConnectionManagerTest.php | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php
index 31c7b90..b820380 100644
--- a/tests/Connection/ConnectionManagerTest.php
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -16,8 +16,11 @@ public function test_getConnection(): void
 
     public function invalidConfig_dataProvider(): array
     {
-        $testStandConfigsBaseDir = __DIR__ . '../TestStand/configs/';
+        $testStandConfigsBaseDir = __DIR__ . '/../TestStand/configs/';
         return [
+            [
+                '',
+            ],
             [
                 'invalid/path',
             ],
@@ -41,8 +44,11 @@ public function invalidConfig_dataProvider(): array
      */
     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->assertTrue(false);
@@ -50,6 +56,7 @@ public function test_invalidConfig(string $configPath): void
             $this->assertTrue(true);
         } finally {
             putenv('CONNECTIONS_CONFIG_FILE=' . $currentPath);
+            $reflection->setStaticPropertyValue('configs', null);
         }
     }
 

From 4d7cdf8b5cd1ac58ea496add26acf6376ec80c8d Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 28 May 2023 12:23:45 +0100
Subject: [PATCH 25/68] Update tests

---
 tests/Connection/ConnectionManagerTest.php      | 11 +++++------
 tests/Table/TableConfigTest.php                 | 17 +++++++++++++++++
 .../TestStand/configs/wrong_doctrine_config.php |  4 ++++
 3 files changed, 26 insertions(+), 6 deletions(-)
 create mode 100644 tests/TestStand/configs/wrong_doctrine_config.php

diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php
index b820380..4ee5b3e 100644
--- a/tests/Connection/ConnectionManagerTest.php
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -36,6 +36,9 @@ public function invalidConfig_dataProvider(): array
             [
                 $testStandConfigsBaseDir . 'wrong_params_config.php',
             ],
+            [
+                $testStandConfigsBaseDir . 'wrong_doctrine_config.php',
+            ],
         ];
     }
 
@@ -62,11 +65,7 @@ public function test_invalidConfig(string $configPath): void
 
     public function test_getConnectionWithMissingName(): void
     {
-        try {
-            ConnectionManager::getConnection('invalid_name');
-            $this->assertTrue(false);
-        } catch (DbException) {
-            $this->assertTrue(true);
-        }
+        $this->expectException(DbException::class);
+        ConnectionManager::getConnection('invalid_name');
     }
 }
\ No newline at end of file
diff --git a/tests/Table/TableConfigTest.php b/tests/Table/TableConfigTest.php
index 5b712e0..63c5983 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
@@ -34,4 +35,20 @@ public function __construct(
         $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 = Schema::build($class::class);
+        $this->expectException(EntityException::class);
+        TableConfig::fromEntitySchema($schema);
+    }
 }
\ 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

From dfcbb3ab6aba08488730953b4c3538144bb5a907 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 29 May 2023 23:17:28 +0100
Subject: [PATCH 26/68] Fix orderBy in AbstractTable

---
 src/AbstractTable.php | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 929d35c..8048796 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -214,7 +214,14 @@ protected function findAllInternal(
                     $query->addOrderBy($column, $direction);
                 }
             } else {
-                $query->orderBy($orderBy);
+                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);
+                    }
+                }
             }
         }
         if ($limit > 0) {

From 30a78e5270b06ac234d14e522f20907e9cd6bc13 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 29 May 2023 23:39:28 +0100
Subject: [PATCH 27/68] Update formatData method and how it works with booleans

---
 src/AbstractTable.php | 13 ++++---------
 1 file changed, 4 insertions(+), 9 deletions(-)

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 8048796..798cf31 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -7,7 +7,7 @@
 use Composite\DB\Exceptions\DbException;
 use Composite\Entity\Exceptions\EntityException;
 use Doctrine\DBAL\Connection;
-use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
 use Doctrine\DBAL\Query\QueryBuilder;
 
 abstract class AbstractTable
@@ -324,15 +324,10 @@ private function buildWhere(QueryBuilder $query, array $where): void
      */
     private function formatData(array $data): array
     {
+        $supportsBoolean = $this->getConnection()->getDatabasePlatform() instanceof PostgreSQLPlatform;
         foreach ($data as $columnName => $value) {
-            if ($value === null && $this->config->isPrimaryKey($columnName)) {
-                unset($data[$columnName]);
-                continue;
-            }
-            if ($this->getConnection()->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
-                if (is_bool($value)) {
-                    $data[$columnName] = $value ? 1 : 0;
-                }
+            if (is_bool($value) && !$supportsBoolean) {
+                $data[$columnName] = $value ? 1 : 0;
             }
         }
         return $data;

From c9a125875a763a3422746996e7a889500e08c16e Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 29 May 2023 23:40:24 +0100
Subject: [PATCH 28/68] Tests cleanup and coverage improvements

---
 .gitignore                                    |   1 +
 tests/Table/AbstractTableTest.php             |  31 +++--
 tests/Table/AutoIncrementTableTest.php        |   8 ++
 .../Castable/TestCastableIntObject.php        |  32 -----
 .../Castable/TestCastableStringObject.php     |  26 -----
 .../Entities/Enums/TestBackedIntEnum.php      |   9 --
 .../Entities/Enums/TestBackedStringEnum.php   |   9 --
 .../TestStand/Entities/Enums/TestUnitEnum.php |   9 --
 .../Entities/TestAutoincrementEntity.php      |   1 +
 .../Entities/TestDiversityEntity.php          | 110 ------------------
 tests/TestStand/Entities/TestEntity.php       |  29 -----
 tests/TestStand/Entities/TestStrictEntity.php |  17 +++
 tests/TestStand/Entities/TestSubEntity.php    |  13 ---
 .../Interfaces/IAutoincrementTable.php        |   1 +
 .../Tables/TestAutoincrementCachedTable.php   |  12 ++
 .../Tables/TestAutoincrementSdCachedTable.php |  14 ++-
 .../Tables/TestAutoincrementSdTable.php       |  15 ++-
 .../Tables/TestAutoincrementTable.php         |  18 ++-
 tests/TestStand/Tables/TestStrictTable.php    |  28 +++++
 tests/Traits/UpdatedAtTest.php                |  11 ++
 20 files changed, 146 insertions(+), 248 deletions(-)
 delete mode 100644 tests/TestStand/Entities/Castable/TestCastableIntObject.php
 delete mode 100644 tests/TestStand/Entities/Castable/TestCastableStringObject.php
 delete mode 100644 tests/TestStand/Entities/Enums/TestBackedIntEnum.php
 delete mode 100644 tests/TestStand/Entities/Enums/TestBackedStringEnum.php
 delete mode 100644 tests/TestStand/Entities/Enums/TestUnitEnum.php
 delete mode 100644 tests/TestStand/Entities/TestDiversityEntity.php
 delete mode 100644 tests/TestStand/Entities/TestEntity.php
 create mode 100644 tests/TestStand/Entities/TestStrictEntity.php
 delete mode 100644 tests/TestStand/Entities/TestSubEntity.php
 create mode 100644 tests/TestStand/Tables/TestStrictTable.php

diff --git a/.gitignore b/.gitignore
index 49f2327..caa2c69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
 .phpunit.result.cache
 .phpunit.cache
 coverage.clover
+coverage.txt
 vendor/
 composer.lock
 tests/runtime/
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index ce6f402..6adb264 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -76,12 +76,29 @@ public function test_illegalEntitySave(): void
         $entity = new Entities\TestAutoincrementEntity(name: 'Foo');
         $compositeTable = new Tables\TestUniqueTable();
 
-        $exceptionCatch = false;
-        try {
-            $compositeTable->save($entity);
-        } catch (EntityException) {
-            $exceptionCatch = true;
-        }
-        $this->assertTrue($exceptionCatch);
+        $this->expectException(EntityException::class);
+        $compositeTable->save($entity);
+    }
+
+    public function test_illegalCreateEntity(): void
+    {
+        $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);
+
+        $empty = $table->buildEntities(false);
+        $this->assertEmpty($empty);
+
+        $empty = $table->buildEntities('abc');
+        $this->assertEmpty($empty);
+
+        $empty = $table->buildEntities(['abc']);
+        $this->assertEmpty($empty);
     }
 }
\ No newline at end of file
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index 2d34292..83fd800 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -49,6 +49,7 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
 
         $entity = new $class(
             name: $this->getUniqueName(),
+            is_test: true,
         );
         $this->assertEntityNotExists($table, PHP_INT_MAX, uniqid());
 
@@ -80,6 +81,13 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         [$e1, $e2] = $table->saveMany([$e1, $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]);
+
         $this->assertTrue($table->deleteMany([$e1, $e2]));
 
         if ($tableConfig->hasSoftDelete()) {
diff --git a/tests/TestStand/Entities/Castable/TestCastableIntObject.php b/tests/TestStand/Entities/Castable/TestCastableIntObject.php
deleted file mode 100644
index 221da00..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\Entity\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/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/TestUnitEnum.php b/tests/TestStand/Entities/Enums/TestUnitEnum.php
deleted file mode 100644
index 652a6e6..0000000
--- a/tests/TestStand/Entities/Enums/TestUnitEnum.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Composite\DB\Tests\TestStand\Entities\Enums;
-
-enum TestUnitEnum
-{
-    case Foo;
-    case Bar;
-}
\ 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/TestDiversityEntity.php b/tests/TestStand/Entities/TestDiversityEntity.php
deleted file mode 100644
index 5d3153f..0000000
--- a/tests/TestStand/Entities/TestDiversityEntity.php
+++ /dev/null
@@ -1,110 +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\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 b90dae1..0000000
--- a/tests/TestStand/Entities/TestEntity.php
+++ /dev/null
@@ -1,29 +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\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/TestSubEntity.php b/tests/TestStand/Entities/TestSubEntity.php
deleted file mode 100644
index f59eec4..0000000
--- a/tests/TestStand/Entities/TestSubEntity.php
+++ /dev/null
@@ -1,13 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Composite\DB\Tests\TestStand\Entities;
-
-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/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/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index 9b65e5c..90368ce 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -52,6 +52,18 @@ public function findAllByName(string $name): array
         ));
     }
 
+    /**
+     * @return TestAutoincrementEntity[]
+     */
+    public function findRecent(int $limit, int $offset): array
+    {
+        return $this->createEntities($this->findAllInternal(
+            orderBy: ['id' => 'DESC'],
+            limit: $limit,
+            offset: $offset,
+        ));
+    }
+
     public function countAllByName(string $name): int
     {
         return $this->countAllCachedInternal(
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index 007a187..ebc74da 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -48,7 +48,19 @@ public function findAllByName(string $name): array
     {
         return $this->createEntities($this->findAllCachedInternal(
             'name = :name',
-            ['name' => $name],
+            ['name' => $name, 'deleted_at' => null],
+        ));
+    }
+
+    /**
+     * @return TestAutoincrementSdEntity[]
+     */
+    public function findRecent(int $limit, int $offset): array
+    {
+        return $this->createEntities($this->findAllInternal(
+            orderBy: 'id DESC',
+            limit: $limit,
+            offset: $offset,
         ));
     }
 
diff --git a/tests/TestStand/Tables/TestAutoincrementSdTable.php b/tests/TestStand/Tables/TestAutoincrementSdTable.php
index 8f68332..136d32b 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php
@@ -19,7 +19,7 @@ public function findByPk(int $id): ?TestAutoincrementSdEntity
 
     public function findOneByName(string $name): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->findOneInternal(['name' => $name]));
+        return $this->createEntity($this->findOneInternal(['name' => $name, 'deleted_at' => null]));
     }
 
     /**
@@ -33,6 +33,18 @@ public function findAllByName(string $name): array
         ));
     }
 
+    /**
+     * @return TestAutoincrementSdEntity[]
+     */
+    public function findRecent(int $limit, int $offset): array
+    {
+        return $this->createEntities($this->findAllInternal(
+            orderBy: 'id DESC',
+            limit: $limit,
+            offset: $offset,
+        ));
+    }
+
     public function init(): bool
     {
         $this->getConnection()->executeStatement(
@@ -41,6 +53,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..9dfa129 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -30,8 +30,21 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->findAllInternal(
-            'name = :name',
-            ['name' => $name],
+            whereString: 'name = :name',
+            whereParams: ['name' => $name],
+            orderBy: 'id',
+        ));
+    }
+
+    /**
+     * @return TestAutoincrementEntity[]
+     */
+    public function findRecent(int $limit, int $offset): array
+    {
+        return $this->createEntities($this->findAllInternal(
+            orderBy: ['id' => 'DESC'],
+            limit: $limit,
+            offset: $offset,
         ));
     }
 
@@ -51,6 +64,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/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/Traits/UpdatedAtTest.php b/tests/Traits/UpdatedAtTest.php
index a3a3712..5a32b09 100644
--- a/tests/Traits/UpdatedAtTest.php
+++ b/tests/Traits/UpdatedAtTest.php
@@ -22,5 +22,16 @@ public function test_trait(): void
         $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

From b0d04ae74b848a4c1398cb37002001f9b97442ec Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Mon, 29 May 2023 23:46:39 +0100
Subject: [PATCH 29/68] TableConfig cleanup

---
 src/TableConfig.php | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/src/TableConfig.php b/src/TableConfig.php
index ce1ace5..d11ea63 100644
--- a/src/TableConfig.php
+++ b/src/TableConfig.php
@@ -74,11 +74,6 @@ public function checkEntity(AbstractEntity $entity): void
         }
     }
 
-    public function isPrimaryKey(string $columnName): bool
-    {
-        return \in_array($columnName, $this->primaryKeys);
-    }
-
     public function hasSoftDelete(): bool
     {
         return !empty($this->entityTraits[Traits\SoftDelete::class]);

From dc48c36de833f0544b93e223abcf8d4fc3949a9b Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Tue, 30 May 2023 00:41:39 +0100
Subject: [PATCH 30/68] Fix
 \Composite\DB\AbstractCachedTable::findMultiCachedInternal

---
 src/AbstractCachedTable.php | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 65f1e01..7d56924 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -187,6 +187,9 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
         }
         $cache = $this->cache->getMultiple(array_keys($cacheKeys));
         foreach ($cache as $cacheKey => $cachedRow) {
+            if ($cachedRow === null) {
+                continue;
+            }
             $result[] = $cachedRow;
             if (empty($cacheKeys[$cacheKey])) {
                 continue;
@@ -195,7 +198,7 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
         }
         $ids = array_diff($ids, $foundIds);
         foreach ($ids as $id) {
-            if ($row = $this->findOneCachedInternal($id, $ttl)) {
+            if ($row = $this->findByPkCachedInternal($id, $ttl)) {
                 $result[] = $row;
             }
         }

From 8bf96bab52df1194adad4392edd3b2f3cb421aba Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Tue, 30 May 2023 00:42:01 +0100
Subject: [PATCH 31/68] More tests

---
 tests/Table/AbstractCachedTableTest.php          | 16 ++++++++++++++++
 .../Tables/TestAutoincrementCachedTable.php      |  8 ++++++++
 2 files changed, 24 insertions(+)

diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 544c09b..9dd59d4 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -242,4 +242,20 @@ public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCa
         $actual = $reflectionMethod->invoke($table, $entity);
         $this->assertEquals($expected, $actual);
     }
+
+    public function test_findMulti(): void
+    {
+        $table = new Tables\TestAutoincrementCachedTable($this->getCache());
+        $e1 = new Entities\TestAutoincrementEntity('John');
+        $e2 = new Entities\TestAutoincrementEntity('Constantine');
+
+        [$e1, $e2] = $table->saveMany([$e1, $e2]);
+
+        $multi1 = $table->findMulti([$e1->id]);
+        $this->assertEquals($e1, $multi1[0]);
+
+        $multi2 = $table->findMulti([$e1->id, $e2->id]);
+        $this->assertEquals($e1, $multi2[0]);
+        $this->assertEquals($e2, $multi2[1]);
+    }
 }
\ No newline at end of file
diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index 90368ce..eecba0d 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -72,6 +72,14 @@ public function countAllByName(string $name): int
         );
     }
 
+    /**
+     * @return TestAutoincrementEntity[]
+     */
+    public function findMulti(array $ids): array
+    {
+        return $this->createEntities($this->findMultiCachedInternal($ids));
+    }
+
     public function truncate(): void
     {
         $this->getConnection()->executeStatement("DELETE FROM {$this->getTableName()} WHERE 1");

From 22a71741280ad53cb7931b3715a2ae790d7e5a90 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Tue, 30 May 2023 00:48:19 +0100
Subject: [PATCH 32/68] Fix test

---
 tests/Table/AbstractCachedTableTest.php | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 9dd59d4..03bbed3 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -245,6 +245,8 @@ public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCa
 
     public function test_findMulti(): void
     {
+        (new Tables\TestAutoincrementTable())->init();
+        
         $table = new Tables\TestAutoincrementCachedTable($this->getCache());
         $e1 = new Entities\TestAutoincrementEntity('John');
         $e2 = new Entities\TestAutoincrementEntity('Constantine');

From 9d7251f2fecc291f48adfb47e91e5f474509c05a Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Tue, 30 May 2023 00:59:13 +0100
Subject: [PATCH 33/68] Update tests

---
 src/AbstractCachedTable.php             | 7 +++----
 tests/Table/AbstractCachedTableTest.php | 8 +++++++-
 2 files changed, 10 insertions(+), 5 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 7d56924..bac38aa 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -190,11 +190,10 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
             if ($cachedRow === null) {
                 continue;
             }
-            $result[] = $cachedRow;
-            if (empty($cacheKeys[$cacheKey])) {
-                continue;
+            if (isset($cacheKeys[$cacheKey])) {
+                $result[] = $cachedRow;
+                $foundIds[] = $cacheKeys[$cacheKey];
             }
-            $foundIds[] = $cacheKeys[$cacheKey];
         }
         $ids = array_diff($ids, $foundIds);
         foreach ($ids as $id) {
diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 03bbed3..b8af512 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -246,7 +246,7 @@ public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCa
     public function test_findMulti(): void
     {
         (new Tables\TestAutoincrementTable())->init();
-        
+
         $table = new Tables\TestAutoincrementCachedTable($this->getCache());
         $e1 = new Entities\TestAutoincrementEntity('John');
         $e2 = new Entities\TestAutoincrementEntity('Constantine');
@@ -259,5 +259,11 @@ public function test_findMulti(): void
         $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

From 9c484dac80690340c2f156b47136f0ee276b9dfe Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 4 Jun 2023 10:11:13 +0100
Subject: [PATCH 34/68] 1. Add \Composite\DB\AbstractTable::findMultiInternal
 2. Improve code coverage 3. Migrate to phpunit 10

---
 composer.json                                 |  5 +-
 phpunit.xml                                   | 28 +++----
 src/AbstractCachedTable.php                   | 60 +++++++-------
 src/AbstractTable.php                         | 70 ++++++++++++++--
 src/CombinedTransaction.php                   | 39 +++++----
 tests/Attributes/PrimaryKeyAttributeTest.php  |  2 +-
 tests/Connection/ConnectionManagerTest.php    |  4 +-
 .../CacheHelper.php}                          |  4 +-
 tests/Helpers/FalseCache.php                  | 49 +++++++++++
 tests/Helpers/StringHelper.php                | 11 +++
 tests/Table/AbstractCachedTableTest.php       | 34 ++++----
 tests/Table/AbstractTableTest.php             |  4 +-
 tests/Table/AutoIncrementTableTest.php        | 47 ++++++++---
 tests/Table/CombinedTransactionTest.php       | 83 +++++++++++++++----
 tests/Table/CompositeTableTest.php            | 60 +++++++++++---
 tests/Table/UniqueTableTest.php               | 17 ++--
 .../Tables/TestAutoincrementCachedTable.php   |  6 ++
 .../Tables/TestAutoincrementSdCachedTable.php |  6 ++
 .../Tables/TestAutoincrementSdTable.php       |  6 ++
 .../Tables/TestAutoincrementTable.php         | 16 ++++
 .../Tables/TestCompositeCachedTable.php       |  6 ++
 .../Tables/TestCompositeSdCachedTable.php     |  6 ++
 .../TestStand/Tables/TestCompositeSdTable.php |  6 ++
 tests/TestStand/Tables/TestCompositeTable.php | 18 ++++
 .../Tables/TestOptimisticLockTable.php        |  6 ++
 .../Tables/TestUniqueCachedTable.php          |  6 ++
 .../Tables/TestUniqueSdCachedTable.php        |  6 ++
 tests/TestStand/Tables/TestUniqueSdTable.php  |  6 ++
 tests/TestStand/Tables/TestUniqueTable.php    |  6 ++
 tests/TestStand/Tables/TestUpdateAtTable.php  |  6 ++
 .../TestStand/configs/wrong_params_config.php |  5 +-
 tests/Traits/OptimisticLockTest.php           |  2 -
 tests/Traits/UpdatedAtTest.php                |  1 -
 33 files changed, 483 insertions(+), 148 deletions(-)
 rename tests/{Table/BaseTableTest.php => Helpers/CacheHelper.php} (83%)
 create mode 100644 tests/Helpers/FalseCache.php
 create mode 100644 tests/Helpers/StringHelper.php

diff --git a/composer.json b/composer.json
index 2167628..31fe04e 100644
--- a/composer.json
+++ b/composer.json
@@ -20,8 +20,9 @@
     },
     "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/phpunit.xml b/phpunit.xml
index f39f444..f68b1fa 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,28 +1,22 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
-         bootstrap="tests/bootstrap.php"
-         cacheResultFile=".phpunit.cache/test-results"
-         executionOrder="depends,defects"
-         forceCoversAnnotation="false"
-         beStrictAboutCoversAnnotation="true"
-         beStrictAboutOutputDuringTests="true"
-         beStrictAboutTodoAnnotatedTests="true"
-         convertDeprecationsToExceptions="true"
-         failOnRisky="true"
-         failOnWarning="true"
-         verbose="true"
-         colors="true">
+         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"
+         beStrictAboutCoverageMetadata="true">
     <testsuites>
         <testsuite name="default">
             <directory>tests</directory>
         </testsuite>
     </testsuites>
-
-    <coverage cacheDirectory=".phpunit.cache/code-coverage"
-              processUncoveredFiles="true">
+    <coverage>
+        <report>
+            <html outputDirectory=".phpunit.cache"/>
+        </report>
+    </coverage>
+    <source>
         <include>
             <directory suffix=".php">src</directory>
         </include>
-    </coverage>
+    </source>
 </phpunit>
diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index bac38aa..247ce28 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -26,13 +26,11 @@ abstract protected function getFlushCacheKeys(AbstractEntity $entity): array;
      */
     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);
+        }
     }
 
     /**
@@ -42,17 +40,20 @@ public function save(AbstractEntity &$entity): void
      */
     public function saveMany(array $entities): array
     {
-        return $this->getConnection()->transactional(function() use ($entities) {
-            $cacheKeys = [];
+        $cacheKeys = [];
+        foreach ($entities as $entity) {
+            $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
+        }
+        $result = $this->getConnection()->transactional(function() use ($entities, $cacheKeys) {
             foreach ($entities as &$entity) {
-                $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
                 parent::save($entity);
             }
-            if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) {
-                throw new DbException('Failed to flush cache keys');
-            }
             return $entities;
         });
+        if ($cacheKeys) {
+            $this->cache->deleteMultiple(array_unique($cacheKeys));
+        }
+        return $result;
     }
 
     /**
@@ -60,13 +61,11 @@ public function saveMany(array $entities): array
      */
     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);
+        }
     }
 
     /**
@@ -75,17 +74,21 @@ public function delete(AbstractEntity &$entity): void
      */
     public function deleteMany(array $entities): bool
     {
-        return (bool)$this->getConnection()->transactional(function() use ($entities) {
-            $cacheKeys = [];
+        $cacheKeys = [];
+        foreach ($entities as $entity) {
+            $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
+            parent::delete($entity);
+        }
+        $result = (bool)$this->getConnection()->transactional(function() use ($entities) {
             foreach ($entities as $entity) {
-                $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
                 parent::delete($entity);
             }
-            if ($cacheKeys && !$this->cache->deleteMultiple(array_unique($cacheKeys))) {
-                throw new DbException('Failed to flush cache keys');
-            }
             return true;
         });
+        if ($cacheKeys) {
+            $this->cache->deleteMultiple($cacheKeys);
+        }
+        return $result;
     }
 
     /**
@@ -98,7 +101,7 @@ private function collectCacheKeysByEntity(AbstractEntity $entity): array
         if (!$entity->isNew() || !$this->getConfig()->autoIncrementKey) {
             $keys[] = $this->getOneCacheKey($entity);
         }
-        return $keys;
+        return array_unique($keys);
     }
 
     /**
@@ -298,9 +301,6 @@ private function prepareWhereKey(string $whereString, array $whereParams): ?stri
         if (!$whereString) {
             return null;
         }
-        if (!$whereParams) {
-            return $whereString;
-        }
         return str_replace(
             array_map(fn (string $key): string => ':' . $key, array_keys($whereParams)),
             array_values($whereParams),
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 798cf31..7b1c3d5 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -187,6 +187,58 @@ protected function findOneInternal(array $where): ?array
         return $query->fetchAssociative() ?: null;
     }
 
+    /**
+     * @param array<int|string|array<string,mixed>> $pkList
+     * @return array
+     * @throws DbException
+     * @throws EntityException
+     * @throws \Doctrine\DBAL\Exception
+     */
+    protected function findMultiInternal(array $pkList): array
+    {
+        if (!$pkList) {
+            return [];
+        }
+        /** @var class-string<AbstractEntity> $class */
+        $class = $this->config->entityClass;
+
+        $pkColumns = [];
+        foreach ($this->config->primaryKeys as $primaryKeyName) {
+            $pkColumns[$primaryKeyName] = $class::schema()->getColumn($primaryKeyName);
+        }
+        if (count($pkColumns) === 1) {
+            if (!array_is_list($pkList)) {
+                throw new DbException('Input argument $pkList must be list');
+            }
+            /** @var \Composite\Entity\Columns\AbstractColumn $pkColumn */
+            $pkColumn = reset($pkColumns);
+            $preparedPkValues = array_map(fn ($pk) => $pkColumn->uncast($pk), $pkList);
+            $query = $this->select();
+            $this->buildWhere($query, [$pkColumn->name => $preparedPkValues]);
+        } else {
+            $query = $this->select();
+            $expressions = [];
+            foreach ($pkList as $i => $pkArray) {
+                if (!is_array($pkArray) || array_is_list($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));
+        }
+        return $query->executeQuery()->fetchAllAssociative();
+    }
+
     /**
      * @param array<string, mixed> $whereParams
      * @param array<string, string>|string $orderBy
@@ -239,7 +291,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) {
@@ -250,7 +302,7 @@ final protected function createEntity(mixed $data): mixed
     /**
      * @return AbstractEntity[]
      */
-    final protected function createEntities(mixed $data): array
+    final protected function createEntities(mixed $data, ?string $keyColumnName = null): array
     {
         if (!is_array($data)) {
             return [];
@@ -263,7 +315,11 @@ final protected function createEntities(mixed $data): array
                 if (!is_array($datum)) {
                     continue;
                 }
-                $result[] = $entityClass::fromArray($datum);
+                if ($keyColumnName && isset($datum[$keyColumnName])) {
+                    $result[$datum[$keyColumnName]] = $entityClass::fromArray($datum);
+                } else {
+                    $result[] = $entityClass::fromArray($datum);
+                }
             }
         } catch (\Throwable) {
             return [];
@@ -310,9 +366,13 @@ private function buildWhere(QueryBuilder $query, array $where): void
         foreach ($where as $column => $value) {
             if ($value === null) {
                 $query->andWhere("$column IS NULL");
+            } elseif (is_array($value)) {
+                $query
+                    ->andWhere($query->expr()->in($column, $value));
             } else {
-                $query->andWhere("$column = :" . $column);
-                $query->setParameter($column, $value);
+                $query
+                    ->andWhere("$column = :" . $column)
+                    ->setParameter($column, $value);
             }
         }
     }
diff --git a/src/CombinedTransaction.php b/src/CombinedTransaction.php
index ec2598e..1b53586 100644
--- a/src/CombinedTransaction.php
+++ b/src/CombinedTransaction.php
@@ -70,10 +70,13 @@ 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();
     }
@@ -82,23 +85,17 @@ public function commit(): void
      * 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}`");
         }
     }
 
@@ -107,9 +104,21 @@ public function releaseLock(): void
         if (!$this->cache || !$this->lockKey) {
             return;
         }
-        try {
-            $this->cache->delete($this->lockKey);
-        } catch (InvalidArgumentException) {}
+        $this->cache->delete($this->lockKey);
+    }
+
+    /**
+     * @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/tests/Attributes/PrimaryKeyAttributeTest.php b/tests/Attributes/PrimaryKeyAttributeTest.php
index 26b6c55..750cd6a 100644
--- a/tests/Attributes/PrimaryKeyAttributeTest.php
+++ b/tests/Attributes/PrimaryKeyAttributeTest.php
@@ -8,7 +8,7 @@
 
 final class PrimaryKeyAttributeTest extends \PHPUnit\Framework\TestCase
 {
-    public function primaryKey_dataProvider(): array
+    public static function primaryKey_dataProvider(): array
     {
         return [
             [
diff --git a/tests/Connection/ConnectionManagerTest.php b/tests/Connection/ConnectionManagerTest.php
index 4ee5b3e..7d634ff 100644
--- a/tests/Connection/ConnectionManagerTest.php
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -14,7 +14,7 @@ public function test_getConnection(): void
         $this->assertInstanceOf(Connection::class, $connection);
     }
 
-    public function invalidConfig_dataProvider(): array
+    public static function invalidConfig_dataProvider(): array
     {
         $testStandConfigsBaseDir = __DIR__ . '/../TestStand/configs/';
         return [
@@ -54,7 +54,7 @@ public function test_invalidConfig(string $configPath): void
 
         try {
             ConnectionManager::getConnection('db1');
-            $this->assertTrue(false);
+            $this->fail('This line should not be reached');
         } catch (DbException) {
             $this->assertTrue(true);
         } finally {
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/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index b8af512..2cf2252 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -4,15 +4,17 @@
 
 use Composite\DB\AbstractCachedTable;
 use Composite\DB\AbstractTable;
+use Composite\DB\Exceptions\DbException;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\Entity\AbstractEntity;
+use Composite\DB\Tests\Helpers;
 
-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();
         return [
             [
                 new Tables\TestAutoincrementCachedTable($cache),
@@ -51,7 +53,7 @@ public function test_getOneCacheKey(AbstractTable $table, AbstractEntity $object
     }
 
 
-    public function getCountCacheKey_dataProvider(): array
+    public static function getCountCacheKey_dataProvider(): array
     {
         return [
             [
@@ -87,13 +89,13 @@ public function getCountCacheKey_dataProvider(): array
      */
     public function test_getCountCacheKey(string $whereString, array $whereParams, 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);
         $this->assertEquals($expected, $actual);
     }
 
-    public function getListCacheKey_dataProvider(): array
+    public static function getListCacheKey_dataProvider(): array
     {
         return [
             [
@@ -146,14 +148,14 @@ public function getListCacheKey_dataProvider(): array
      */
     public function test_getListCacheKey(string $whereString, array $whereArray, 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);
         $this->assertEquals($expected, $actual);
     }
 
 
-    public function getCustomCacheKey_dataProvider(): array
+    public static function getCustomCacheKey_dataProvider(): array
     {
         return [
             [
@@ -184,18 +186,18 @@ public function getCustomCacheKey_dataProvider(): array
      */
     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
     {
         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 +206,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',
@@ -214,7 +216,7 @@ public function collectCacheKeysByEntity_dataProvider(): array
             ],
             [
                 new Entities\TestUniqueEntity(id: '123abc', name: 'foo'),
-                new Tables\TestUniqueCachedTable(self::getCache()),
+                new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()),
                 [
                     'sqlite.TestUnique.v1.l.name_eq_foo',
                     'sqlite.TestUnique.v1.c.name_eq_foo',
@@ -223,7 +225,7 @@ public function collectCacheKeysByEntity_dataProvider(): array
             ],
             [
                 Entities\TestUniqueEntity::fromArray(['id' => '456def', 'name' => 'bar']),
-                new Tables\TestUniqueCachedTable(self::getCache()),
+                new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()),
                 [
                     'sqlite.TestUnique.v1.l.name_eq_bar',
                     'sqlite.TestUnique.v1.c.name_eq_bar',
@@ -245,9 +247,7 @@ public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCa
 
     public function test_findMulti(): void
     {
-        (new Tables\TestAutoincrementTable())->init();
-
-        $table = new Tables\TestAutoincrementCachedTable($this->getCache());
+        $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
         $e1 = new Entities\TestAutoincrementEntity('John');
         $e2 = new Entities\TestAutoincrementEntity('Constantine');
 
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index 6adb264..6923fdb 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -8,9 +8,9 @@
 use Composite\Entity\AbstractEntity;
 use Composite\Entity\Exceptions\EntityException;
 
-final class AbstractTableTest extends BaseTableTest
+final class AbstractTableTest extends \PHPUnit\Framework\TestCase
 {
-    public function getPkCondition_dataProvider(): array
+    public static function getPkCondition_dataProvider(): array
     {
         return [
             [
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index 83fd800..c8e22a1 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -3,20 +3,16 @@
 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;
 
-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 [
             [
@@ -28,11 +24,11 @@ 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,
             ],
         ];
@@ -48,7 +44,7 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         $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());
@@ -75,8 +71,8 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
             $this->assertEntityNotExists($table, $entity->id, $entity->name);
         }
 
-        $e1 = new $class($this->getUniqueName());
-        $e2 = new $class($this->getUniqueName());
+        $e1 = new $class(Helpers\StringHelper::getUniqueName());
+        $e2 = new $class(Helpers\StringHelper::getUniqueName());
 
         [$e1, $e2] = $table->saveMany([$e1, $e2]);
         $this->assertEntityExists($table, $e1);
@@ -104,6 +100,31 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         }
     }
 
+    public function test_getMulti(): void
+    {
+        $table = new Tables\TestAutoincrementTable();
+
+        $e1 = new Entities\TestAutoincrementEntity('name1');
+        $e2 = new Entities\TestAutoincrementEntity('name2');
+        $e3 = new Entities\TestAutoincrementEntity('name3');
+
+        [$e1, $e2, $e3] = $table->saveMany([$e1, $e2, $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
     {
         $this->assertNotNull($table->findByPk($entity->id));
diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php
index 4ee8865..63ffff5 100644
--- a/tests/Table/CombinedTransactionTest.php
+++ b/tests/Table/CombinedTransactionTest.php
@@ -6,16 +6,14 @@
 use Composite\DB\Exceptions\DbException;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;
+use Composite\DB\Tests\Helpers;
 
-final class CombinedTransactionTest extends BaseTableTest
+final class CombinedTransactionTest extends \PHPUnit\Framework\TestCase
 {
     public function test_transactionCommit(): void
     {
         $autoIncrementTable = new Tables\TestAutoincrementTable();
-        $autoIncrementTable->init();
-
         $compositeTable = new Tables\TestCompositeTable();
-        $compositeTable->init();
 
         $saveTransaction = new CombinedTransaction();
 
@@ -42,10 +40,7 @@ public function test_transactionCommit(): void
     public function test_transactionRollback(): void
     {
         $autoIncrementTable = new Tables\TestAutoincrementTable();
-        $autoIncrementTable->init();
-
         $compositeTable = new Tables\TestCompositeTable();
-        $compositeTable->init();
 
         $transaction = new CombinedTransaction();
 
@@ -61,13 +56,10 @@ 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();
-        $autoIncrementTable->init();
-
         $compositeTable = new Tables\TestCompositeTable();
-        $compositeTable->init();
 
         $transaction = new CombinedTransaction();
 
@@ -78,18 +70,54 @@ 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_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();
-        $table->init();
 
         $e1 = new Entities\TestAutoincrementEntity(name: 'Foo');
         $e2 = new Entities\TestAutoincrementEntity(name: 'Bar');
@@ -101,8 +129,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();
@@ -114,4 +144,25 @@ 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 57dc064..7359e56 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -3,20 +3,16 @@
 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;
 
-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 [
             [
@@ -28,11 +24,11 @@ public function crud_dataProvider(): array
                 Entities\TestCompositeSdEntity::class,
             ],
             [
-                new Tables\TestCompositeCachedTable(self::getCache()),
+                new Tables\TestCompositeCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestCompositeEntity::class,
             ],
             [
-                new Tables\TestCompositeSdCachedTable(self::getCache()),
+                new Tables\TestCompositeSdCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestCompositeSdEntity::class,
             ],
         ];
@@ -50,7 +46,7 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         $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);
@@ -71,6 +67,48 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         }
     }
 
+    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(),
+        );
+
+        $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(),
+        );
+
+        [$e1, $e2, $e3] = $table->saveMany([$e1, $e2, $e3]);
+
+        $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
     {
         $this->assertNotNull($table->findOne(user_id: $entity->user_id, post_id: $entity->post_id));
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index e2f0a1e..72b277a 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -4,19 +4,14 @@
 
 use Composite\DB\AbstractTable;
 use Composite\DB\TableConfig;
+use Composite\DB\Tests\Helpers;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
 
-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 [
             [
@@ -28,11 +23,11 @@ public function crud_dataProvider(): array
                 Entities\TestUniqueSdEntity::class,
             ],
             [
-                new Tables\TestUniqueCachedTable(self::getCache()),
+                new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestUniqueEntity::class,
             ],
             [
-                new Tables\TestUniqueSdCachedTable(self::getCache()),
+                new Tables\TestUniqueSdCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestUniqueSdEntity::class,
             ],
         ];
@@ -49,7 +44,7 @@ public function test_crud(AbstractTable&IUniqueTable $table, string $class): voi
 
         $entity = new $class(
             id: uniqid(),
-            name: $this->getUniqueName(),
+            name: Helpers\StringHelper::getUniqueName(),
         );
         $this->assertEntityNotExists($table, $entity);
         $table->save($entity);
diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index eecba0d..5776f92 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -10,6 +10,12 @@
 
 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());
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index ebc74da..66645f4 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -10,6 +10,12 @@
 
 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());
diff --git a/tests/TestStand/Tables/TestAutoincrementSdTable.php b/tests/TestStand/Tables/TestAutoincrementSdTable.php
index 136d32b..b9ce555 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php
@@ -7,6 +7,12 @@
 
 class TestAutoincrementSdTable extends TestAutoincrementTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestAutoincrementSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php
index 9dfa129..c0768e6 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -9,6 +9,12 @@
 
 class TestAutoincrementTable extends AbstractTable implements IAutoincrementTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestAutoincrementEntity::schema());
@@ -56,6 +62,16 @@ public function countAllByName(string $name): int
         );
     }
 
+    /**
+     * @param int[] $ids
+     * @return TestAutoincrementEntity[]
+     * @throws \Composite\DB\Exceptions\DbException
+     */
+    public function findMulti(array $ids): array
+    {
+        return $this->createEntities($this->findMultiInternal($ids), 'id');
+    }
+
     public function init(): bool
     {
         $this->getConnection()->executeStatement(
diff --git a/tests/TestStand/Tables/TestCompositeCachedTable.php b/tests/TestStand/Tables/TestCompositeCachedTable.php
index a2519da..936d0a9 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());
diff --git a/tests/TestStand/Tables/TestCompositeSdCachedTable.php b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
index 97f8512..46846dc 100644
--- a/tests/TestStand/Tables/TestCompositeSdCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
@@ -9,6 +9,12 @@
 
 class TestCompositeSdCachedTable extends \Composite\DB\AbstractCachedTable implements ICompositeTable
 {
+    public function __construct(\Psr\SimpleCache\CacheInterface $cache)
+    {
+        parent::__construct($cache);
+        (new TestCompositeSdTable())->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestCompositeSdTable.php b/tests/TestStand/Tables/TestCompositeSdTable.php
index a35bd1e..28d3123 100644
--- a/tests/TestStand/Tables/TestCompositeSdTable.php
+++ b/tests/TestStand/Tables/TestCompositeSdTable.php
@@ -7,6 +7,12 @@
 
 class TestCompositeSdTable extends TestCompositeTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index f69c00b..359e450 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -22,6 +22,14 @@ 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]));
@@ -46,6 +54,16 @@ public function countAllByUser(int $userId): int
         );
     }
 
+    /**
+     * @param array $ids
+     * @return TestCompositeEntity[]
+     * @throws \Composite\DB\Exceptions\DbException
+     */
+    public function findMulti(array $ids): array
+    {
+        return $this->createEntities($this->findMultiInternal($ids), 'post_id');
+    }
+
     public function init(): bool
     {
         $this->getConnection()->executeStatement(
diff --git a/tests/TestStand/Tables/TestOptimisticLockTable.php b/tests/TestStand/Tables/TestOptimisticLockTable.php
index beb6123..cf318fd 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());
diff --git a/tests/TestStand/Tables/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php
index f95d102..46c5e55 100644
--- a/tests/TestStand/Tables/TestUniqueCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueCachedTable.php
@@ -10,6 +10,12 @@
 
 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());
diff --git a/tests/TestStand/Tables/TestUniqueSdCachedTable.php b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
index 278cb37..8c60a0c 100644
--- a/tests/TestStand/Tables/TestUniqueSdCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
@@ -10,6 +10,12 @@
 
 class TestUniqueSdCachedTable extends AbstractCachedTable implements IUniqueTable
 {
+    public function __construct(\Psr\SimpleCache\CacheInterface $cache)
+    {
+        parent::__construct($cache);
+        (new TestUniqueSdTable())->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestUniqueSdTable.php b/tests/TestStand/Tables/TestUniqueSdTable.php
index 6bed15f..33cbf62 100644
--- a/tests/TestStand/Tables/TestUniqueSdTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdTable.php
@@ -7,6 +7,12 @@
 
 class TestUniqueSdTable extends TestUniqueTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 317985c..137a24a 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -9,6 +9,12 @@
 
 class TestUniqueTable extends AbstractTable implements IUniqueTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestUniqueEntity::schema());
diff --git a/tests/TestStand/Tables/TestUpdateAtTable.php b/tests/TestStand/Tables/TestUpdateAtTable.php
index 9ecc449..2fb74c4 100644
--- a/tests/TestStand/Tables/TestUpdateAtTable.php
+++ b/tests/TestStand/Tables/TestUpdateAtTable.php
@@ -8,6 +8,12 @@
 
 class TestUpdateAtTable extends AbstractTable
 {
+    public function __construct()
+    {
+        parent::__construct();
+        $this->init();
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestUpdatedAtEntity::schema());
diff --git a/tests/TestStand/configs/wrong_params_config.php b/tests/TestStand/configs/wrong_params_config.php
index 7993161..cee852e 100644
--- a/tests/TestStand/configs/wrong_params_config.php
+++ b/tests/TestStand/configs/wrong_params_config.php
@@ -1,4 +1,7 @@
 <?php
 return [
-    'db1' => 123,
+    '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
index 737e279..b64e973 100644
--- a/tests/Traits/OptimisticLockTest.php
+++ b/tests/Traits/OptimisticLockTest.php
@@ -39,8 +39,6 @@ public function test_trait(): void
         $olTable1 = new Tables\TestOptimisticLockTable();
         $olTable2 = new Tables\TestOptimisticLockTable();
 
-        $olTable1->init();
-
         $olTable1->save($olEntity1);
 
         $olEntity2 = $olTable2->findByPk($olEntity1->id);
diff --git a/tests/Traits/UpdatedAtTest.php b/tests/Traits/UpdatedAtTest.php
index 5a32b09..bb149c5 100644
--- a/tests/Traits/UpdatedAtTest.php
+++ b/tests/Traits/UpdatedAtTest.php
@@ -13,7 +13,6 @@ public function test_trait(): void
         $this->assertNull($entity->updated_at);
 
         $table = new TestUpdateAtTable();
-        $table->init();
         $table->save($entity);
 
         $this->assertNotNull($entity->updated_at);

From 268c3e853e1d727b8a1ef67961f18061559ad4db Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 4 Jun 2023 10:16:14 +0100
Subject: [PATCH 35/68] Add badges

---
 README.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/README.md b/README.md
index a3802fd..0385896 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,7 @@
 # What is Composite DB
+[![Latest Stable Version](https://poser.pugx.org/compositephp/db/v/stable)](https://packagist.org/packages/compositephp/db)
+[![Build Status](https://github.com/compositephp/db/actions/workflows/main.yml/badge.svg)](https://github.com/compositephp/db/actions)
+[![Codecov](https://codecov.io/gh/compositephp/db/branch/master/graph/badge.svg)](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 
 scheme in OOP style using full power of PHP 8.1+ class syntax. 

From 986e8052b33865f5d68645164f5d13502cfd8629 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 4 Jun 2023 11:11:19 +0100
Subject: [PATCH 36/68] Add scrunitizer

---
 .scrutinizer.yml | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)
 create mode 100644 .scrutinizer.yml

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

From bc902ed64d76209d551825345725db6a33b50320 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 4 Jun 2023 11:12:31 +0100
Subject: [PATCH 37/68] Change saveMany signature to return bool

---
 src/AbstractCachedTable.php             |  8 +++----
 src/AbstractTable.php                   |  6 ++---
 tests/Table/AbstractCachedTableTest.php |  3 ++-
 tests/Table/AutoIncrementTableTest.php  |  7 ++++--
 tests/Table/CompositeTableTest.php      | 32 ++++++++++++++++++++++++-
 5 files changed, 44 insertions(+), 12 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 247ce28..73a56d6 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -35,25 +35,23 @@ public function save(AbstractEntity &$entity): void
 
     /**
      * @param AbstractEntity[] $entities
-     * @return AbstractEntity[]
      * @throws \Throwable
      */
-    public function saveMany(array $entities): array
+    public function saveMany(array $entities): bool
     {
         $cacheKeys = [];
         foreach ($entities as $entity) {
             $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
         }
-        $result = $this->getConnection()->transactional(function() use ($entities, $cacheKeys) {
+        $this->getConnection()->transactional(function() use ($entities, $cacheKeys) {
             foreach ($entities as &$entity) {
                 parent::save($entity);
             }
-            return $entities;
         });
         if ($cacheKeys) {
             $this->cache->deleteMultiple(array_unique($cacheKeys));
         }
-        return $result;
+        return true;
     }
 
     /**
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 7b1c3d5..c8af2b9 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -108,10 +108,10 @@ public function save(AbstractEntity &$entity): void
      * @param AbstractEntity[] $entities
      * @throws \Throwable
      */
-    public function saveMany(array $entities): array
+    public function saveMany(array $entities): bool
     {
-        return $this->getConnection()->transactional(function() use ($entities) {
-            foreach ($entities as &$entity) {
+        return (bool)$this->getConnection()->transactional(function() use ($entities) {
+            foreach ($entities as $entity) {
                 $this->save($entity);
             }
             return $entities;
diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 2cf2252..576d663 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -251,7 +251,8 @@ public function test_findMulti(): void
         $e1 = new Entities\TestAutoincrementEntity('John');
         $e2 = new Entities\TestAutoincrementEntity('Constantine');
 
-        [$e1, $e2] = $table->saveMany([$e1, $e2]);
+        $table->save($e1);
+        $table->save($e2);
 
         $multi1 = $table->findMulti([$e1->id]);
         $this->assertEquals($e1, $multi1[0]);
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index c8e22a1..f71394e 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -74,7 +74,8 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         $e1 = new $class(Helpers\StringHelper::getUniqueName());
         $e2 = new $class(Helpers\StringHelper::getUniqueName());
 
-        [$e1, $e2] = $table->saveMany([$e1, $e2]);
+        $table->save($e1);
+        $table->save($e2);
         $this->assertEntityExists($table, $e1);
         $this->assertEntityExists($table, $e2);
 
@@ -108,7 +109,9 @@ public function test_getMulti(): void
         $e2 = new Entities\TestAutoincrementEntity('name2');
         $e3 = new Entities\TestAutoincrementEntity('name3');
 
-        [$e1, $e2, $e3] = $table->saveMany([$e1, $e2, $e3]);
+        $table->save($e1);
+        $table->save($e2);
+        $table->save($e3);
 
         $multiResult = $table->findMulti([$e1->id, $e2->id, $e3->id]);
         $this->assertEquals($e1, $multiResult[$e1->id]);
diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php
index 7359e56..ee4a62b 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -65,6 +65,36 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         } else {
             $this->assertEntityNotExists($table, $entity);
         }
+
+        $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]);
+        $this->assertEntityExists($table, $e1);
+        $this->assertEntityExists($table, $e2);
+
+        $this->assertTrue($table->deleteMany([$e1, $e2]));
+
+        if ($tableConfig->hasSoftDelete()) {
+            /** @var Entities\TestCompositeSdEntity $deletedEntity1 */
+            $deletedEntity1 = $table->findOne(user_id: $e1->user_id, post_id: $e1->post_id);
+            $this->assertTrue($deletedEntity1->isDeleted());
+
+            /** @var Entities\TestCompositeSdEntity $deletedEntity2 */
+            $deletedEntity2 = $table->findOne(user_id: $e2->user_id, post_id: $e2->post_id);
+            $this->assertTrue($deletedEntity2->isDeleted());
+        } else {
+            $this->assertEntityNotExists($table, $e1);
+            $this->assertEntityNotExists($table, $e2);
+        }
     }
 
     public function test_getMulti(): void
@@ -90,7 +120,7 @@ public function test_getMulti(): void
             message: Helpers\StringHelper::getUniqueName(),
         );
 
-        [$e1, $e2, $e3] = $table->saveMany([$e1, $e2, $e3]);
+        $table->saveMany([$e1, $e2, $e3]);
 
         $multiResult = $table->findMulti([
             ['user_id' => $e1->user_id, 'post_id' => $e1->post_id],

From ed49b0cc0dcd885e9490ec53228fca560faf7b4f Mon Sep 17 00:00:00 2001
From: Composite PHP <38870693+compositephp@users.noreply.github.com>
Date: Sat, 17 Jun 2023 09:51:46 +0100
Subject: [PATCH 38/68] Update main.yml

---
 .github/workflows/main.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b17c9ef..206c8af 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,4 +1,4 @@
-name: PHP Composer
+name: build
 
 on:
   push:

From a897c09f9c9789718200b3a14e2ea1ede30476f0 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 17 Jun 2023 09:55:34 +0100
Subject: [PATCH 39/68] update compositephp/entity dependency version

---
 composer.json                   | 2 +-
 tests/Table/TableConfigTest.php | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/composer.json b/composer.json
index 31fe04e..1cea962 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "^0.1.7",
+        "compositephp/entity": "^0.1.8",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {
diff --git a/tests/Table/TableConfigTest.php b/tests/Table/TableConfigTest.php
index 63c5983..e74f318 100644
--- a/tests/Table/TableConfigTest.php
+++ b/tests/Table/TableConfigTest.php
@@ -27,7 +27,7 @@ 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);
@@ -47,7 +47,7 @@ public function __construct(
                 public string $str = 'abc',
             ) {}
         };
-        $schema = Schema::build($class::class);
+        $schema = new Schema($class::class);
         $this->expectException(EntityException::class);
         TableConfig::fromEntitySchema($schema);
     }

From e2264b66bdc17b9a9f1db5e362171fb2874563fc Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 18 Jun 2023 12:21:35 +0100
Subject: [PATCH 40/68] Add multi insert

---
 src/AbstractCachedTable.php                   | 19 ++----
 src/AbstractTable.php                         | 65 +++++++++++++++----
 src/MultiQuery/MultiInsert.php                | 36 ++++++++++
 tests/MultiQuery/MultiInsertTest.php          | 57 ++++++++++++++++
 tests/Table/AutoIncrementTableTest.php        |  2 +-
 tests/Table/CompositeTableTest.php            | 21 +++++-
 tests/Table/UniqueTableTest.php               | 49 ++++++++++++++
 .../Tables/TestCompositeSdCachedTable.php     |  8 +++
 tests/TestStand/Tables/TestUniqueTable.php    |  9 +++
 9 files changed, 237 insertions(+), 29 deletions(-)
 create mode 100644 src/MultiQuery/MultiInsert.php
 create mode 100644 tests/MultiQuery/MultiInsertTest.php

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 73a56d6..44e0936 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -37,21 +37,16 @@ public function save(AbstractEntity &$entity): void
      * @param AbstractEntity[] $entities
      * @throws \Throwable
      */
-    public function saveMany(array $entities): bool
+    public function saveMany(array $entities): void
     {
         $cacheKeys = [];
         foreach ($entities as $entity) {
             $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
         }
-        $this->getConnection()->transactional(function() use ($entities, $cacheKeys) {
-            foreach ($entities as &$entity) {
-                parent::save($entity);
-            }
-        });
+        parent::saveMany($entities);
         if ($cacheKeys) {
             $this->cache->deleteMultiple(array_unique($cacheKeys));
         }
-        return true;
     }
 
     /**
@@ -70,23 +65,17 @@ public function delete(AbstractEntity &$entity): void
      * @param AbstractEntity[] $entities
      * @throws \Throwable
      */
-    public function deleteMany(array $entities): bool
+    public function deleteMany(array $entities): void
     {
         $cacheKeys = [];
         foreach ($entities as $entity) {
             $cacheKeys = array_merge($cacheKeys, $this->collectCacheKeysByEntity($entity));
             parent::delete($entity);
         }
-        $result = (bool)$this->getConnection()->transactional(function() use ($entities) {
-            foreach ($entities as $entity) {
-                parent::delete($entity);
-            }
-            return true;
-        });
+        parent::deleteMany($entities);
         if ($cacheKeys) {
             $this->cache->deleteMultiple($cacheKeys);
         }
-        return $result;
     }
 
     /**
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index c8af2b9..9a63d2b 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -2,6 +2,7 @@
 
 namespace Composite\DB;
 
+use Composite\DB\MultiQuery\MultiInsert;
 use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Exceptions\DbException;
@@ -47,9 +48,8 @@ public function save(AbstractEntity &$entity): void
         $this->config->checkEntity($entity);
         if ($entity->isNew()) {
             $connection = $this->getConnection();
-            if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at') && $entity->updated_at === null) {
-                $entity->updated_at = new \DateTimeImmutable();
-            }
+            $this->checkUpdatedAt($entity);
+
             $insertData = $this->formatData($entity->toArray());
             $this->getConnection()->insert($this->getTableName(), $insertData);
 
@@ -108,18 +108,46 @@ public function save(AbstractEntity &$entity): void
      * @param AbstractEntity[] $entities
      * @throws \Throwable
      */
-    public function saveMany(array $entities): bool
+    public function saveMany(array $entities): void
     {
-        return (bool)$this->getConnection()->transactional(function() use ($entities) {
+        $rowsToInsert = [];
+        foreach ($entities as $i => $entity) {
+            if ($entity->isNew()) {
+                $this->config->checkEntity($entity);
+                $this->checkUpdatedAt($entity);
+                $rowsToInsert[] = $this->formatData($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);
+                foreach ($chunks as $chunk) {
+                    $multiInsert = new MultiInsert(
+                        tableName: $this->getTableName(),
+                        rows: $chunk,
+                    );
+                    if ($multiInsert->sql) {
+                        $stmt = $this->getConnection()->prepare($multiInsert->sql);
+                        $stmt->executeQuery($multiInsert->parameters);
+                    }
+                }
+            }
+            $connection->commit();
+        } catch (\Throwable $e) {
+            $connection->rollBack();
+            throw $e;
+        }
     }
 
     /**
-     * @throws EntityException
+     * @param AbstractEntity $entity
+     * @throws \Throwable
      */
     public function delete(AbstractEntity &$entity): void
     {
@@ -137,15 +165,21 @@ public function delete(AbstractEntity &$entity): void
 
     /**
      * @param AbstractEntity[] $entities
+     * @throws \Throwable
      */
-    public function deleteMany(array $entities): bool
+    public function deleteMany(array $entities): void
     {
-        return (bool)$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;
+        }
     }
 
     /**
@@ -377,6 +411,13 @@ private function buildWhere(QueryBuilder $query, array $where): void
         }
     }
 
+    private function checkUpdatedAt(AbstractEntity $entity): void
+    {
+        if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at') && $entity->updated_at === null) {
+            $entity->updated_at = new \DateTimeImmutable();
+        }
+    }
+
     /**
      * @param array<string, mixed> $data
      * @return array<string, mixed>
diff --git a/src/MultiQuery/MultiInsert.php b/src/MultiQuery/MultiInsert.php
new file mode 100644
index 0000000..b2138e4
--- /dev/null
+++ b/src/MultiQuery/MultiInsert.php
@@ -0,0 +1,36 @@
+<?php declare(strict_types=1);
+
+namespace Composite\DB\MultiQuery;
+
+class MultiInsert
+{
+    public readonly string $sql;
+    public readonly array $parameters;
+
+    public function __construct(string $tableName, array $rows) {
+        if (!$rows) {
+            $this->sql = '';
+            $this->parameters = [];
+            return;
+        }
+        $firstRow = reset($rows);
+        $columnNames = array_map(fn ($columnName) => "`$columnName`", array_keys($firstRow));
+        $sql = "INSERT INTO `$tableName` (" . implode(', ', $columnNames) . ") VALUES ";
+        $valuesSql = $parameters = [];
+
+        $index = 0;
+        foreach ($rows as $row) {
+            $valuePlaceholder = [];
+            foreach ($row as $column => $value) {
+                $valuePlaceholder[] = ":$column$index";
+                $parameters["$column$index"] = $value;
+            }
+            $valuesSql[] = '(' . implode(', ', $valuePlaceholder) . ')';
+            $index++;
+        }
+
+        $sql .= implode(', ', $valuesSql);
+        $this->sql = $sql . ';';
+        $this->parameters = $parameters;
+    }
+}
\ No newline at end of file
diff --git a/tests/MultiQuery/MultiInsertTest.php b/tests/MultiQuery/MultiInsertTest.php
new file mode 100644
index 0000000..012736f
--- /dev/null
+++ b/tests/MultiQuery/MultiInsertTest.php
@@ -0,0 +1,57 @@
+<?php declare(strict_types=1);
+
+namespace Composite\DB\Tests\MultiQuery;
+
+use Composite\DB\MultiQuery\MultiInsert;
+
+class MultiInsertTest extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * @dataProvider multiInsertQuery_dataProvider
+     */
+    public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expectedParameters)
+    {
+        $multiInserter = new MultiInsert($tableName, $rows);
+
+        $this->assertEquals($expectedSql, $multiInserter->sql);
+        $this->assertEquals($expectedParameters, $multiInserter->parameters);
+    }
+
+    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/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index f71394e..ec85178 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -85,7 +85,7 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         $preLastEntity = $table->findRecent(1, 1);
         $this->assertEquals($e1, $preLastEntity[0]);
 
-        $this->assertTrue($table->deleteMany([$e1, $e2]));
+        $table->deleteMany([$e1, $e2]);
 
         if ($tableConfig->hasSoftDelete()) {
             /** @var Entities\TestAutoincrementSdEntity $deletedEntity1 */
diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php
index ee4a62b..afc9f97 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -78,10 +78,25 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         );
 
         $table->saveMany([$e1, $e2]);
+        $e1->resetChangedColumns();
+        $e2->resetChangedColumns();
+
         $this->assertEntityExists($table, $e1);
         $this->assertEntityExists($table, $e2);
 
-        $this->assertTrue($table->deleteMany([$e1, $e2]));
+        if ($tableConfig->hasSoftDelete()) {
+            $e1->message = 'Exception';
+            $exceptionThrown = false;
+            try {
+                $table->deleteMany([$e1, $e2]);
+            } catch (\Exception) {
+                $exceptionThrown = true;
+            }
+            $this->assertTrue($exceptionThrown);
+            $e1->message = Helpers\StringHelper::getUniqueName();
+        }
+
+        $table->deleteMany([$e1, $e2]);
 
         if ($tableConfig->hasSoftDelete()) {
             /** @var Entities\TestCompositeSdEntity $deletedEntity1 */
@@ -122,6 +137,10 @@ public function test_getMulti(): void
 
         $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],
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index 72b277a..87b954f 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -65,6 +65,55 @@ public function test_crud(AbstractTable&IUniqueTable $table, string $class): voi
         }
     }
 
+    public function test_multiSave(): void
+    {
+        $e1 = new Entities\TestUniqueEntity(
+            id: uniqid(),
+            name: Helpers\StringHelper::getUniqueName(),
+        );
+        $e2 = new Entities\TestUniqueEntity(
+            id: uniqid(),
+            name: Helpers\StringHelper::getUniqueName(),
+        );
+        $e3 = new Entities\TestUniqueEntity(
+            id: uniqid(),
+            name: Helpers\StringHelper::getUniqueName(),
+        );
+        $e4 = new Entities\TestUniqueEntity(
+            id: uniqid(),
+            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]);
+
+        $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
     {
         $this->assertNotNull($table->findByPk($entity->id));
diff --git a/tests/TestStand/Tables/TestCompositeSdCachedTable.php b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
index 46846dc..f4938f1 100644
--- a/tests/TestStand/Tables/TestCompositeSdCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
@@ -15,6 +15,14 @@ public function __construct(\Psr\SimpleCache\CacheInterface $cache)
         (new TestCompositeSdTable())->init();
     }
 
+    public function save(AbstractEntity|TestCompositeSdEntity &$entity): void
+    {
+        if ($entity->message === 'Exception') {
+            throw new \Exception('Test Exception');
+        }
+        parent::save($entity);
+    }
+
     protected function getConfig(): TableConfig
     {
         return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema());
diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 137a24a..7d26fca 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -6,6 +6,7 @@
 use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
+use Composite\Entity\AbstractEntity;
 
 class TestUniqueTable extends AbstractTable implements IUniqueTable
 {
@@ -15,6 +16,14 @@ public function __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());

From e4543eb348371a94575caec9522515032153db89 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sun, 18 Jun 2023 12:49:21 +0100
Subject: [PATCH 41/68] PHPStan fixes

---
 src/AbstractTable.php                | 12 +++++-----
 src/MultiQuery/MultiInsert.php       | 36 +++++++++++++++++++---------
 src/TableConfig.php                  |  1 +
 tests/MultiQuery/MultiInsertTest.php |  4 ++--
 4 files changed, 34 insertions(+), 19 deletions(-)

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 9a63d2b..22b2720 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -132,9 +132,9 @@ public function saveMany(array $entities): void
                         tableName: $this->getTableName(),
                         rows: $chunk,
                     );
-                    if ($multiInsert->sql) {
-                        $stmt = $this->getConnection()->prepare($multiInsert->sql);
-                        $stmt->executeQuery($multiInsert->parameters);
+                    if ($multiInsert->getSql()) {
+                        $stmt = $this->getConnection()->prepare($multiInsert->getSql());
+                        $stmt->executeQuery($multiInsert->getParameters());
                     }
                 }
             }
@@ -223,7 +223,7 @@ protected function findOneInternal(array $where): ?array
 
     /**
      * @param array<int|string|array<string,mixed>> $pkList
-     * @return array
+     * @return array<array<string, mixed>>
      * @throws DbException
      * @throws EntityException
      * @throws \Doctrine\DBAL\Exception
@@ -253,7 +253,7 @@ protected function findMultiInternal(array $pkList): array
             $query = $this->select();
             $expressions = [];
             foreach ($pkList as $i => $pkArray) {
-                if (!is_array($pkArray) || array_is_list($pkArray)) {
+                if (!is_array($pkArray)) {
                     throw new DbException('For tables with composite keys, input array must consist associative arrays');
                 }
                 $pkOrExpr = [];
@@ -276,7 +276,7 @@ protected function findMultiInternal(array $pkList): array
     /**
      * @param array<string, mixed> $whereParams
      * @param array<string, string>|string $orderBy
-     * @return array<string, mixed>
+     * @return list<array<string,mixed>>
      * @throws \Doctrine\DBAL\Exception
      */
     protected function findAllInternal(
diff --git a/src/MultiQuery/MultiInsert.php b/src/MultiQuery/MultiInsert.php
index b2138e4..04de6a8 100644
--- a/src/MultiQuery/MultiInsert.php
+++ b/src/MultiQuery/MultiInsert.php
@@ -4,33 +4,47 @@
 
 class MultiInsert
 {
-    public readonly string $sql;
-    public readonly array $parameters;
+    private string $sql = '';
+    /** @var array<string, mixed> */
+    private array $parameters = [];
 
+    /**
+     * @param string $tableName
+     * @param list<array<string, mixed>> $rows
+     */
     public function __construct(string $tableName, array $rows) {
         if (!$rows) {
-            $this->sql = '';
-            $this->parameters = [];
             return;
         }
         $firstRow = reset($rows);
-        $columnNames = array_map(fn ($columnName) => "`$columnName`", array_keys($firstRow));
-        $sql = "INSERT INTO `$tableName` (" . implode(', ', $columnNames) . ") VALUES ";
-        $valuesSql = $parameters = [];
+        $columnNames = array_map(fn($columnName) => "`$columnName`", array_keys($firstRow));
+        $this->sql = "INSERT INTO `$tableName` (" . implode(', ', $columnNames) . ") VALUES ";
+        $valuesSql = [];
 
         $index = 0;
         foreach ($rows as $row) {
             $valuePlaceholder = [];
             foreach ($row as $column => $value) {
                 $valuePlaceholder[] = ":$column$index";
-                $parameters["$column$index"] = $value;
+                $this->parameters["$column$index"] = $value;
             }
             $valuesSql[] = '(' . implode(', ', $valuePlaceholder) . ')';
             $index++;
         }
 
-        $sql .= implode(', ', $valuesSql);
-        $this->sql = $sql . ';';
-        $this->parameters = $parameters;
+        $this->sql .= implode(', ', $valuesSql) . ';';
+    }
+
+    public function getSql(): string
+    {
+        return $this->sql;
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function getParameters(): array
+    {
+        return $this->parameters;
     }
 }
\ No newline at end of file
diff --git a/src/TableConfig.php b/src/TableConfig.php
index d11ea63..a33b693 100644
--- a/src/TableConfig.php
+++ b/src/TableConfig.php
@@ -9,6 +9,7 @@
 
 class TableConfig
 {
+    /** @var array<class-string, true> */
     private readonly array $entityTraits;
 
     /**
diff --git a/tests/MultiQuery/MultiInsertTest.php b/tests/MultiQuery/MultiInsertTest.php
index 012736f..e2c1237 100644
--- a/tests/MultiQuery/MultiInsertTest.php
+++ b/tests/MultiQuery/MultiInsertTest.php
@@ -13,8 +13,8 @@ public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expected
     {
         $multiInserter = new MultiInsert($tableName, $rows);
 
-        $this->assertEquals($expectedSql, $multiInserter->sql);
-        $this->assertEquals($expectedParameters, $multiInserter->parameters);
+        $this->assertEquals($expectedSql, $multiInserter->getSql());
+        $this->assertEquals($expectedParameters, $multiInserter->getParameters());
     }
 
     public static function multiInsertQuery_dataProvider()

From 946da1640c1a8081c00aaf91008ce248e1ea5d19 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 1 Jul 2023 22:15:53 +0100
Subject: [PATCH 42/68] Move multiselect to separate class MultiSelecy

---
 src/AbstractTable.php             | 44 ++-------------------
 src/MultiQuery/MultiSelect.php    | 64 +++++++++++++++++++++++++++++++
 tests/Table/AbstractTableTest.php | 44 +++++++++++++++++++++
 3 files changed, 112 insertions(+), 40 deletions(-)
 create mode 100644 src/MultiQuery/MultiSelect.php

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 22b2720..cb9b850 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -3,6 +3,7 @@
 namespace Composite\DB;
 
 use Composite\DB\MultiQuery\MultiInsert;
+use Composite\DB\MultiQuery\MultiSelect;
 use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Exceptions\DbException;
@@ -225,7 +226,6 @@ protected function findOneInternal(array $where): ?array
      * @param array<int|string|array<string,mixed>> $pkList
      * @return array<array<string, mixed>>
      * @throws DbException
-     * @throws EntityException
      * @throws \Doctrine\DBAL\Exception
      */
     protected function findMultiInternal(array $pkList): array
@@ -233,44 +233,8 @@ protected function findMultiInternal(array $pkList): array
         if (!$pkList) {
             return [];
         }
-        /** @var class-string<AbstractEntity> $class */
-        $class = $this->config->entityClass;
-
-        $pkColumns = [];
-        foreach ($this->config->primaryKeys as $primaryKeyName) {
-            $pkColumns[$primaryKeyName] = $class::schema()->getColumn($primaryKeyName);
-        }
-        if (count($pkColumns) === 1) {
-            if (!array_is_list($pkList)) {
-                throw new DbException('Input argument $pkList must be list');
-            }
-            /** @var \Composite\Entity\Columns\AbstractColumn $pkColumn */
-            $pkColumn = reset($pkColumns);
-            $preparedPkValues = array_map(fn ($pk) => $pkColumn->uncast($pk), $pkList);
-            $query = $this->select();
-            $this->buildWhere($query, [$pkColumn->name => $preparedPkValues]);
-        } else {
-            $query = $this->select();
-            $expressions = [];
-            foreach ($pkList 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));
-        }
-        return $query->executeQuery()->fetchAllAssociative();
+        $multiSelect = new MultiSelect($this->getConnection(), $this->config, $pkList);
+        return $multiSelect->getQueryBuilder()->executeQuery()->fetchAllAssociative();
     }
 
     /**
@@ -395,7 +359,7 @@ protected function select(string $select = '*'): QueryBuilder
     /**
      * @param array<string, mixed> $where
      */
-    private function buildWhere(QueryBuilder $query, array $where): void
+    private function buildWhere(\Doctrine\DBAL\Query\QueryBuilder $query, array $where): void
     {
         foreach ($where as $column => $value) {
             if ($value === null) {
diff --git a/src/MultiQuery/MultiSelect.php b/src/MultiQuery/MultiSelect.php
new file mode 100644
index 0000000..59459eb
--- /dev/null
+++ b/src/MultiQuery/MultiSelect.php
@@ -0,0 +1,64 @@
+<?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;
+
+    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) => $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/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index 6923fdb..c426c56 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -101,4 +101,48 @@ public function test_illegalCreateEntity(): void
         $empty = $table->buildEntities(['abc']);
         $this->assertEmpty($empty);
     }
+
+    /**
+     * @dataProvider buildWhere_dataProvider
+     */
+    public function test_buildWhere($where, $expectedSQL, $expectedParams)
+    {
+        $table = new Tables\TestStrictTable();
+
+        $selectReflection = new \ReflectionMethod($table, 'select');
+        $selectReflection->setAccessible(true);
+
+        $queryBuilder = $selectReflection->invoke($table);
+
+        $buildWhereReflection = new \ReflectionMethod($table, 'buildWhere');
+        $buildWhereReflection->setAccessible(true);
+
+        $buildWhereReflection->invokeArgs($table, [$queryBuilder, $where]);
+
+        $this->assertEquals($expectedSQL, $queryBuilder->getSQL());
+    }
+
+    public static function buildWhere_dataProvider(): array
+    {
+        return [
+            // Test when value is null
+            [
+                ['column1' => null],
+                'SELECT * FROM Strict WHERE column1 IS NULL',
+                []
+            ],
+            // Test when value is an array
+            [
+                ['column1' => [1, 2, 3]],
+                'SELECT * FROM Strict WHERE column1 IN (1, 2, 3)',
+                [1, 2, 3]
+            ],
+            // Test when value is a single value
+            [
+                ['column1' => 'value1'],
+                'SELECT * FROM Strict WHERE column1 = :column1',
+                ['value1']
+            ],
+        ];
+    }
 }
\ No newline at end of file

From 06cd5570eb60cc424c5230e8d214e5d146f9fb7d Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 14 Oct 2023 12:40:16 +0100
Subject: [PATCH 43/68] Add .github folder to export-ignore

---
 .gitattributes | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitattributes b/.gitattributes
index af43e6b..1989899 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,6 @@
 /.gitattributes export-ignore
 /.gitignore export-ignore
+/.github export-ignore
 /doc export-ignore
 /phpunit.xml export-ignore
 /tests export-ignore

From 6e5824dd119593ce64db31032b6475a93bb12ecc Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 14 Oct 2023 14:03:02 +0100
Subject: [PATCH 44/68] Add UUID support

---
 composer.json                                 |  2 +-
 src/AbstractCachedTable.php                   |  4 +--
 src/AbstractTable.php                         |  3 +-
 tests/Table/AbstractCachedTableTest.php       | 28 ++++++++++---------
 tests/Table/AbstractTableTest.php             | 17 ++++++-----
 tests/Table/UniqueTableTest.php               | 11 ++++----
 tests/TestStand/Entities/TestUniqueEntity.php |  3 +-
 tests/TestStand/Interfaces/IUniqueTable.php   |  3 +-
 .../Tables/TestUniqueCachedTable.php          |  3 +-
 .../Tables/TestUniqueSdCachedTable.php        |  3 +-
 tests/TestStand/Tables/TestUniqueSdTable.php  |  5 ++--
 tests/TestStand/Tables/TestUniqueTable.php    |  3 +-
 12 files changed, 49 insertions(+), 36 deletions(-)

diff --git a/composer.json b/composer.json
index 1cea962..5bfced2 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "^0.1.8",
+        "compositephp/entity": "^0.1.9",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {
diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 44e0936..857f9b9 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -5,6 +5,7 @@
 use Composite\DB\Exceptions\DbException;
 use Composite\Entity\AbstractEntity;
 use Psr\SimpleCache\CacheInterface;
+use Ramsey\Uuid\UuidInterface;
 
 abstract class AbstractCachedTable extends AbstractTable
 {
@@ -196,9 +197,8 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
 
     /**
      * @param string|int|array<string, mixed>|AbstractEntity $keyOrEntity
-     * @throws \Composite\Entity\Exceptions\EntityException
      */
-    protected function getOneCacheKey(string|int|array|AbstractEntity $keyOrEntity): string
+    protected function getOneCacheKey(string|int|array|AbstractEntity|UuidInterface $keyOrEntity): string
     {
         if (!is_array($keyOrEntity)) {
             $condition = $this->getPkCondition($keyOrEntity);
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index cb9b850..822249b 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -11,6 +11,7 @@
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
 use Doctrine\DBAL\Query\QueryBuilder;
+use Ramsey\Uuid\UuidInterface;
 
 abstract class AbstractTable
 {
@@ -330,7 +331,7 @@ final protected function createEntities(mixed $data, ?string $keyColumnName = nu
      * @return array<string, mixed>
      * @throws EntityException
      */
-    protected function getPkCondition(int|string|array|AbstractEntity $data): array
+    protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface $data): array
     {
         $condition = [];
         if ($data instanceof AbstractEntity) {
diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 576d663..6c65846 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -9,12 +9,15 @@
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Tests\Helpers;
+use Ramsey\Uuid\Uuid;
 
 final class AbstractCachedTableTest extends \PHPUnit\Framework\TestCase
 {
     public static function getOneCacheKey_dataProvider(): array
     {
         $cache = Helpers\CacheHelper::getCache();
+        $uuid = Uuid::uuid4();
+        $uuidCacheKey = str_replace('-', '_', (string)$uuid);
         return [
             [
                 new Tables\TestAutoincrementCachedTable($cache),
@@ -28,16 +31,13 @@ public static 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',
             ],
         ];
     }
@@ -45,7 +45,7 @@ public static function getOneCacheKey_dataProvider(): array
     /**
      * @dataProvider getOneCacheKey_dataProvider
      */
-    public function test_getOneCacheKey(AbstractTable $table, AbstractEntity $object, string $expected): void
+    public function test_getOneCacheKey(AbstractCachedTable $table, AbstractEntity $object, string $expected): void
     {
         $reflectionMethod = new \ReflectionMethod($table, 'getOneCacheKey');
         $actual = $reflectionMethod->invoke($table, $object);
@@ -194,6 +194,8 @@ public function test_getCustomCacheKey(array $parts, string $expected): void
 
     public static function collectCacheKeysByEntity_dataProvider(): array
     {
+        $uuid = Uuid::uuid4();
+        $uuidCacheKey = str_replace('-', '_', (string)$uuid);
         return [
             [
                 new Entities\TestAutoincrementEntity(name: 'foo'),
@@ -215,21 +217,21 @@ public static function collectCacheKeysByEntity_dataProvider(): array
                 ],
             ],
             [
-                new Entities\TestUniqueEntity(id: '123abc', name: 'foo'),
+                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']),
+                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,
                 ],
             ],
         ];
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index c426c56..5c45eca 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -7,11 +7,14 @@
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\Entity\AbstractEntity;
 use Composite\Entity\Exceptions\EntityException;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
 
 final class AbstractTableTest extends \PHPUnit\Framework\TestCase
 {
     public static function getPkCondition_dataProvider(): array
     {
+        $uuid = Uuid::uuid4();
         return [
             [
                 new Tables\TestAutoincrementTable(),
@@ -35,13 +38,13 @@ public static 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(),
@@ -55,8 +58,8 @@ public static function getPkCondition_dataProvider(): array
             ],
             [
                 new Tables\TestUniqueSdTable(),
-                new Entities\TestUniqueSdEntity(id: '123abc', name: 'John'),
-                ['id' => '123abc'],
+                new Entities\TestUniqueSdEntity(id: $uuid, name: 'John'),
+                ['id' => $uuid->toString()],
             ],
         ];
     }
@@ -64,7 +67,7 @@ public static function getPkCondition_dataProvider(): array
     /**
      * @dataProvider getPkCondition_dataProvider
      */
-    public function test_getPkCondition(AbstractTable $table, int|string|array|AbstractEntity $object, array $expected): void
+    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);
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index 87b954f..e9cd434 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -8,6 +8,7 @@
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
+use Ramsey\Uuid\Uuid;
 
 final class UniqueTableTest extends \PHPUnit\Framework\TestCase
 {
@@ -43,7 +44,7 @@ public function test_crud(AbstractTable&IUniqueTable $table, string $class): voi
         $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
-            id: uniqid(),
+            id: Uuid::uuid4(),
             name: Helpers\StringHelper::getUniqueName(),
         );
         $this->assertEntityNotExists($table, $entity);
@@ -68,19 +69,19 @@ public function test_crud(AbstractTable&IUniqueTable $table, string $class): voi
     public function test_multiSave(): void
     {
         $e1 = new Entities\TestUniqueEntity(
-            id: uniqid(),
+            id: Uuid::uuid4(),
             name: Helpers\StringHelper::getUniqueName(),
         );
         $e2 = new Entities\TestUniqueEntity(
-            id: uniqid(),
+            id: Uuid::uuid4(),
             name: Helpers\StringHelper::getUniqueName(),
         );
         $e3 = new Entities\TestUniqueEntity(
-            id: uniqid(),
+            id: Uuid::uuid4(),
             name: Helpers\StringHelper::getUniqueName(),
         );
         $e4 = new Entities\TestUniqueEntity(
-            id: uniqid(),
+            id: Uuid::uuid4(),
             name: Helpers\StringHelper::getUniqueName(),
         );
         $table = new Tables\TestUniqueTable();
diff --git a/tests/TestStand/Entities/TestUniqueEntity.php b/tests/TestStand/Entities/TestUniqueEntity.php
index 7b676bd..2a6138b 100644
--- a/tests/TestStand/Entities/TestUniqueEntity.php
+++ b/tests/TestStand/Entities/TestUniqueEntity.php
@@ -5,13 +5,14 @@
 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 readonly \DateTimeImmutable $created_at = new \DateTimeImmutable(),
     ) {}
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/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php
index 46c5e55..7cb7fa3 100644
--- a/tests/TestStand/Tables/TestUniqueCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueCachedTable.php
@@ -7,6 +7,7 @@
 use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
 use Composite\Entity\AbstractEntity;
+use Ramsey\Uuid\UuidInterface;
 
 class TestUniqueCachedTable extends AbstractCachedTable implements IUniqueTable
 {
@@ -29,7 +30,7 @@ protected function getFlushCacheKeys(TestUniqueEntity|AbstractEntity $entity): a
         ];
     }
 
-    public function findByPk(string $id): ?TestUniqueEntity
+    public function findByPk(UuidInterface $id): ?TestUniqueEntity
     {
         return $this->createEntity($this->findByPkInternal($id));
     }
diff --git a/tests/TestStand/Tables/TestUniqueSdCachedTable.php b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
index 8c60a0c..016133d 100644
--- a/tests/TestStand/Tables/TestUniqueSdCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
@@ -7,6 +7,7 @@
 use Composite\DB\Tests\TestStand\Entities\TestUniqueSdEntity;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
 use Composite\Entity\AbstractEntity;
+use Ramsey\Uuid\UuidInterface;
 
 class TestUniqueSdCachedTable extends AbstractCachedTable implements IUniqueTable
 {
@@ -29,7 +30,7 @@ protected function getFlushCacheKeys(TestUniqueSdEntity|AbstractEntity $entity):
         ];
     }
 
-    public function findByPk(string $id): ?TestUniqueSdEntity
+    public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
     {
         return $this->createEntity($this->findByPkInternal($id));
     }
diff --git a/tests/TestStand/Tables/TestUniqueSdTable.php b/tests/TestStand/Tables/TestUniqueSdTable.php
index 33cbf62..df078b9 100644
--- a/tests/TestStand/Tables/TestUniqueSdTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdTable.php
@@ -4,6 +4,7 @@
 
 use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Entities\TestUniqueSdEntity;
+use Ramsey\Uuid\UuidInterface;
 
 class TestUniqueSdTable extends TestUniqueTable
 {
@@ -18,7 +19,7 @@ protected function getConfig(): TableConfig
         return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema());
     }
 
-    public function findByPk(string $id): ?TestUniqueSdEntity
+    public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
     {
         return $this->createEntity($this->findByPkInternal($id));
     }
@@ -40,7 +41,7 @@ public function init(): bool
             "
             CREATE TABLE IF NOT EXISTS {$this->getTableName()}
             (
-                `id` VARCHAR(255) NOT NULL,
+                `id` VARCHAR(32) NOT NULL,
                 `name` VARCHAR(255) NOT NULL,
                 `created_at` TIMESTAMP NOT NULL,
                 `deleted_at` TIMESTAMP NULL DEFAULT NULL,
diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 7d26fca..7e223d9 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -7,6 +7,7 @@
 use Composite\DB\Tests\TestStand\Entities\TestUniqueEntity;
 use Composite\DB\Tests\TestStand\Interfaces\IUniqueTable;
 use Composite\Entity\AbstractEntity;
+use Ramsey\Uuid\UuidInterface;
 
 class TestUniqueTable extends AbstractTable implements IUniqueTable
 {
@@ -29,7 +30,7 @@ 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));
     }

From 0667a416e4eb7c87f1978fec7f04ff51e30b5f0b Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 21 Oct 2023 14:07:41 +0100
Subject: [PATCH 45/68] - Improve OptimisticLock to work with 1 query - Add
 orderBy param to findOneInternal method

---
 src/AbstractTable.php                         | 64 +++++++++++--------
 src/Exceptions/LockException.php              |  7 ++
 src/Traits/OptimisticLock.php                 | 12 +++-
 .../Tables/TestOptimisticLockTable.php        |  2 +-
 tests/Traits/OptimisticLockTest.php           |  2 +-
 5 files changed, 58 insertions(+), 29 deletions(-)
 create mode 100644 src/Exceptions/LockException.php

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 822249b..5901139 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -73,22 +73,23 @@ public function save(AbstractEntity &$entity): void
                 $changedColumns['updated_at'] = DateTimeHelper::dateTimeToString($entity->updated_at);
             }
 
-            if ($this->config->hasOptimisticLock() && isset($entity->version)) {
-                $currentVersion = $entity->version;
+
+            if ($this->config->hasOptimisticLock()
+                && method_exists($entity, 'getVersion')
+                && method_exists($entity, 'incrementVersion')) {
+                $where['lock_version'] = $entity->getVersion();
+                $entity->incrementVersion();
+                $changedColumns['lock_version'] = $entity->getVersion();
+
                 try {
                     $connection->beginTransaction();
-                    $connection->update(
+                    $versionUpdated = $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.');
+                        throw new Exceptions\LockException('Failed to update entity version, concurrency modification, rolling back.');
                     }
                     $connection->commit();
                 } catch (\Throwable $e) {
@@ -213,13 +214,15 @@ protected function findByPkInternal(mixed $pk): ?array
 
     /**
      * @param array<string, mixed> $where
+     * @param array<string, string>|string $orderBy
      * @return array<string, mixed>|null
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function findOneInternal(array $where): ?array
+    protected function findOneInternal(array $where, array|string $orderBy = []): ?array
     {
         $query = $this->select();
         $this->buildWhere($query, $where);
+        $this->applyOrderBy($query, $orderBy);
         return $query->fetchAssociative() ?: null;
     }
 
@@ -259,22 +262,7 @@ protected function findAllInternal(
                 $query->setParameter($param, $value);
             }
         }
-        if ($orderBy) {
-            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);
-                    }
-                }
-            }
-        }
+        $this->applyOrderBy($query, $orderBy);
         if ($limit > 0) {
             $query->setMaxResults($limit);
         }
@@ -398,4 +386,28 @@ private function formatData(array $data): array
         }
         return $data;
     }
+
+    /**
+     * @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);
+                }
+            }
+        }
+    }
 }
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/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/tests/TestStand/Tables/TestOptimisticLockTable.php b/tests/TestStand/Tables/TestOptimisticLockTable.php
index cf318fd..8303514 100644
--- a/tests/TestStand/Tables/TestOptimisticLockTable.php
+++ b/tests/TestStand/Tables/TestOptimisticLockTable.php
@@ -32,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/Traits/OptimisticLockTest.php b/tests/Traits/OptimisticLockTest.php
index b64e973..9856e25 100644
--- a/tests/Traits/OptimisticLockTest.php
+++ b/tests/Traits/OptimisticLockTest.php
@@ -60,7 +60,7 @@ public function test_trait(): void
         $this->assertTrue($db->rollBack());
 
         $olEntity3 = $olTable1->findByPk($olEntity1->id);
-        $this->assertEquals(1, $olEntity3->version);
+        $this->assertEquals(1, $olEntity3->getVersion());
         $this->assertEquals('John', $olEntity3->name);
     }
 }
\ No newline at end of file

From 63416c6b48d07478efb916a6b9b57e9f603e92c4 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 21 Oct 2023 23:07:23 +0100
Subject: [PATCH 46/68] Optimize save method

---
 src/AbstractTable.php | 32 ++++++++------------------------
 1 file changed, 8 insertions(+), 24 deletions(-)

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 5901139..63da7fe 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -72,36 +72,20 @@ public function save(AbstractEntity &$entity): void
                 $entity->updated_at = new \DateTimeImmutable();
                 $changedColumns['updated_at'] = DateTimeHelper::dateTimeToString($entity->updated_at);
             }
-
-
             if ($this->config->hasOptimisticLock()
                 && method_exists($entity, 'getVersion')
                 && method_exists($entity, 'incrementVersion')) {
                 $where['lock_version'] = $entity->getVersion();
                 $entity->incrementVersion();
                 $changedColumns['lock_version'] = $entity->getVersion();
-
-                try {
-                    $connection->beginTransaction();
-                    $versionUpdated = $connection->update(
-                        $this->getTableName(),
-                        $changedColumns,
-                        $where
-                    );
-                    if (!$versionUpdated) {
-                        throw new Exceptions\LockException('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 = $connection->update(
+                table: $this->getTableName(),
+                data: $changedColumns,
+                criteria: $where,
+            );
+            if ($this->config->hasOptimisticLock() && !$entityUpdated) {
+                throw new Exceptions\LockException('Failed to update entity version, concurrency modification, rolling back.');
             }
             $entity->resetChangedColumns();
         }

From 384610ff107d2971fd377d85c854dd69ad4a7774 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 11:55:42 +0100
Subject: [PATCH 47/68] Add CombinedTransaction saveMany and deleteMany

---
 src/CombinedTransaction.php             | 62 ++++++++++++++++---------
 tests/Table/CombinedTransactionTest.php | 28 ++++++++++-
 2 files changed, 67 insertions(+), 23 deletions(-)

diff --git a/src/CombinedTransaction.php b/src/CombinedTransaction.php
index 1b53586..bb8a4a5 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,18 +49,9 @@ 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);
-        } catch (\Throwable $e) {
-            $this->rollback();
-            throw new Exceptions\DbException($e->getMessage(), 500, $e);
-        }
+        });
     }
 
     public function rollback(): void
@@ -107,6 +109,22 @@ public function releaseLock(): void
         $this->cache->delete($this->lockKey);
     }
 
+    private function doInTransaction(AbstractTable $table, callable $callback): void
+    {
+        try {
+            $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
diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php
index 63ffff5..1e67a2c 100644
--- a/tests/Table/CombinedTransactionTest.php
+++ b/tests/Table/CombinedTransactionTest.php
@@ -20,7 +20,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();
@@ -37,6 +37,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();

From 5e9e64294e87daa83a7eeaedcecafb1eaf6454ed Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 12:11:19 +0100
Subject: [PATCH 48/68] Get rid of "Internal" word in find...() and count...()
 AbstractTable protected methods

---
 doc/cache.md                                  |  8 ++++----
 doc/example.md                                |  6 +++---
 doc/table.md                                  |  8 ++++----
 src/AbstractCachedTable.php                   | 20 +++++++++----------
 src/AbstractTable.php                         | 14 ++++++-------
 .../Tables/TestAutoincrementCachedTable.php   | 12 +++++------
 .../Tables/TestAutoincrementSdCachedTable.php | 10 +++++-----
 .../Tables/TestAutoincrementSdTable.php       |  8 ++++----
 .../Tables/TestAutoincrementTable.php         | 12 +++++------
 .../Tables/TestCompositeCachedTable.php       |  6 +++---
 .../Tables/TestCompositeSdCachedTable.php     |  6 +++---
 .../TestStand/Tables/TestCompositeSdTable.php |  6 +++---
 tests/TestStand/Tables/TestCompositeTable.php |  8 ++++----
 .../Tables/TestOptimisticLockTable.php        |  2 +-
 .../Tables/TestUniqueCachedTable.php          |  6 +++---
 .../Tables/TestUniqueSdCachedTable.php        |  6 +++---
 tests/TestStand/Tables/TestUniqueSdTable.php  |  4 ++--
 tests/TestStand/Tables/TestUniqueTable.php    |  6 +++---
 tests/TestStand/Tables/TestUpdateAtTable.php  |  2 +-
 19 files changed, 75 insertions(+), 75 deletions(-)

diff --git a/doc/cache.md b/doc/cache.md
index e3ca6ab..7b64d1f 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->createEntity($this->_findByPkCached($id));
     }
     
     /**
@@ -54,7 +54,7 @@ class PostsTable extends AbstractCachedTable
     */
     public function findAllFeatured(): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'is_featured = :is_featured',
             ['is_featured' => true],
         ));
@@ -62,7 +62,7 @@ class PostsTable extends AbstractCachedTable
     
     public function countAllFeatured(): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'is_featured = :is_featured',
             ['is_featured' => true],
         );
diff --git a/doc/example.md b/doc/example.md
index 096ea6f..22a63fc 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->createEntity($this->_findByPk($id));
     }
 
     /**
@@ -51,7 +51,7 @@ class UsersTable extends \Composite\DB\AbstractTable
      */
     public function findAllActive(): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'status = :status',
             ['status' => Status::ACTIVE->name],
         ));
@@ -59,7 +59,7 @@ class UsersTable extends \Composite\DB\AbstractTable
 
     public function countAllActive(): int
     {
-        return $this->countAllInternal(
+        return $this->_countAll(
             'status = :status',
             ['status' => Status::ACTIVE->name],
         );
diff --git a/doc/table.md b/doc/table.md
index c80f0d8..fbd7d1b 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->createEntity($this->_findOne($id));
     }
 
     /**
@@ -46,12 +46,12 @@ class UsersTable extends AbstractTable
      */
     public function findAll(): array
     {
-        return $this->createEntities($this->findAllInternal());
+        return $this->createEntities($this->_findAll());
     }
 
     public function countAll(): int
     {
-        return $this->countAllInternal();
+        return $this->_countAll();
     }
 }
 ```
@@ -67,7 +67,7 @@ Example with internal helper:
  */
 public function findAllActiveAdults(): array
 {
-    $rows = $this->findAllInternal(
+    $rows = $this->_findAll(
         'age > :age AND status = :status',
         ['age' => 18, 'status' => Status::ACTIVE->name],
     );
diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 857f9b9..cca50ad 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -95,9 +95,9 @@ private function collectCacheKeysByEntity(AbstractEntity $entity): array
     /**
      * @return array<string, mixed>|null
      */
-    protected function findByPkCachedInternal(mixed $pk, null|int|\DateInterval $ttl = null): ?array
+    protected function _findByPkCached(mixed $pk, null|int|\DateInterval $ttl = null): ?array
     {
-        return $this->findOneCachedInternal($this->getPkCondition($pk), $ttl);
+        return $this->_findOneCached($this->getPkCondition($pk), $ttl);
     }
 
     /**
@@ -105,11 +105,11 @@ protected function findByPkCachedInternal(mixed $pk, null|int|\DateInterval $ttl
      * @param int|\DateInterval|null $ttl
      * @return array<string, mixed>|null
      */
-    protected function findOneCachedInternal(array $condition, null|int|\DateInterval $ttl = null): ?array
+    protected function _findOneCached(array $condition, null|int|\DateInterval $ttl = null): ?array
     {
         return $this->getCached(
             $this->getOneCacheKey($condition),
-            fn() => $this->findOneInternal($condition),
+            fn() => $this->_findOne($condition),
             $ttl,
         ) ?: null;
     }
@@ -119,7 +119,7 @@ protected function findOneCachedInternal(array $condition, null|int|\DateInterva
      * @param array<string, string>|string $orderBy
      * @return array<string, mixed>[]
      */
-    protected function findAllCachedInternal(
+    protected function _findAllCached(
         string $whereString = '',
         array $whereParams = [],
         array|string $orderBy = [],
@@ -129,7 +129,7 @@ protected function findAllCachedInternal(
     {
         return $this->getCached(
             $this->getListCacheKey($whereString, $whereParams, $orderBy, $limit),
-            fn() => $this->findAllInternal(whereString: $whereString, whereParams: $whereParams, orderBy: $orderBy, limit: $limit),
+            fn() => $this->_findAll(whereString: $whereString, whereParams: $whereParams, orderBy: $orderBy, limit: $limit),
             $ttl,
         );
     }
@@ -137,7 +137,7 @@ protected function findAllCachedInternal(
     /**
      * @param array<string, mixed> $whereParams
      */
-    protected function countAllCachedInternal(
+    protected function _countAllCached(
         string $whereString = '',
         array $whereParams = [],
         null|int|\DateInterval $ttl = null,
@@ -145,7 +145,7 @@ protected function countAllCachedInternal(
     {
         return (int)$this->getCached(
             $this->getCountCacheKey($whereString, $whereParams),
-            fn() => $this->countAllInternal(whereString: $whereString, whereParams: $whereParams),
+            fn() => $this->_countAll(whereString: $whereString, whereParams: $whereParams),
             $ttl,
         );
     }
@@ -169,7 +169,7 @@ protected function getCached(string $cacheKey, callable $dataCallback, null|int|
      * @return array<array<string, mixed>>
      * @throws \Psr\SimpleCache\InvalidArgumentException
      */
-    protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $ttl = null): array
+    protected function _findMultiCached(array $ids, null|int|\DateInterval $ttl = null): array
     {
         $result = $cacheKeys = $foundIds = [];
         foreach ($ids as $id) {
@@ -188,7 +188,7 @@ protected function findMultiCachedInternal(array $ids, null|int|\DateInterval $t
         }
         $ids = array_diff($ids, $foundIds);
         foreach ($ids as $id) {
-            if ($row = $this->findByPkCachedInternal($id, $ttl)) {
+            if ($row = $this->_findByPkCached($id, $ttl)) {
                 $result[] = $row;
             }
         }
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 63da7fe..4362f42 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -173,7 +173,7 @@ public function deleteMany(array $entities): void
      * @param array<string, mixed> $whereParams
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function countAllInternal(string $whereString = '', array $whereParams = []): int
+    protected function _countAll(string $whereString = '', array $whereParams = []): int
     {
         $query = $this->select('COUNT(*)');
         if ($whereString) {
@@ -190,10 +190,10 @@ protected function countAllInternal(string $whereString = '', array $whereParams
      * @throws EntityException
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function findByPkInternal(mixed $pk): ?array
+    protected function _findByPk(mixed $pk): ?array
     {
         $where = $this->getPkCondition($pk);
-        return $this->findOneInternal($where);
+        return $this->_findOne($where);
     }
 
     /**
@@ -202,7 +202,7 @@ protected function findByPkInternal(mixed $pk): ?array
      * @return array<string, mixed>|null
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function findOneInternal(array $where, array|string $orderBy = []): ?array
+    protected function _findOne(array $where, array|string $orderBy = []): ?array
     {
         $query = $this->select();
         $this->buildWhere($query, $where);
@@ -216,7 +216,7 @@ protected function findOneInternal(array $where, array|string $orderBy = []): ?a
      * @throws DbException
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function findMultiInternal(array $pkList): array
+    protected function _findMulti(array $pkList): array
     {
         if (!$pkList) {
             return [];
@@ -231,7 +231,7 @@ protected function findMultiInternal(array $pkList): array
      * @return list<array<string,mixed>>
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function findAllInternal(
+    protected function _findAll(
         string $whereString = '',
         array $whereParams = [],
         array|string $orderBy = [],
@@ -332,7 +332,7 @@ protected function select(string $select = '*'): QueryBuilder
     /**
      * @param array<string, mixed> $where
      */
-    private function buildWhere(\Doctrine\DBAL\Query\QueryBuilder $query, array $where): void
+    private function buildWhere(QueryBuilder $query, array $where): void
     {
         foreach ($where as $column => $value) {
             if ($value === null) {
diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index 5776f92..90f4386 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -39,12 +39,12 @@ protected function getFlushCacheKeys(TestAutoincrementEntity|AbstractEntity $ent
 
     public function findByPk(int $id): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->findByPkCachedInternal($id));
+        return $this->createEntity($this->_findByPkCached($id));
     }
 
     public function findOneByName(string $name): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->findOneCachedInternal(['name' => $name]));
+        return $this->createEntity($this->_findOneCached(['name' => $name]));
     }
 
     /**
@@ -52,7 +52,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllCachedInternal(
+        return $this->createEntities($this->_findAllCached(
             'name = :name',
             ['name' => $name],
         ));
@@ -63,7 +63,7 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             orderBy: ['id' => 'DESC'],
             limit: $limit,
             offset: $offset,
@@ -72,7 +72,7 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'name = :name',
             ['name' => $name],
         );
@@ -83,7 +83,7 @@ public function countAllByName(string $name): int
      */
     public function findMulti(array $ids): array
     {
-        return $this->createEntities($this->findMultiCachedInternal($ids));
+        return $this->createEntities($this->_findMultiCached($ids));
     }
 
     public function truncate(): void
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index 66645f4..c646e9a 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -39,12 +39,12 @@ protected function getFlushCacheKeys(TestAutoincrementSdEntity|AbstractEntity $e
 
     public function findByPk(int $id): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     public function findOneByName(string $name): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->findOneCachedInternal(['name' => $name]));
+        return $this->createEntity($this->_findOneCached(['name' => $name]));
     }
 
     /**
@@ -52,7 +52,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllCachedInternal(
+        return $this->createEntities($this->_findAllCached(
             'name = :name',
             ['name' => $name, 'deleted_at' => null],
         ));
@@ -63,7 +63,7 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             orderBy: 'id DESC',
             limit: $limit,
             offset: $offset,
@@ -72,7 +72,7 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'name = :name',
             ['name' => $name],
         );
diff --git a/tests/TestStand/Tables/TestAutoincrementSdTable.php b/tests/TestStand/Tables/TestAutoincrementSdTable.php
index b9ce555..c45e826 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php
@@ -20,12 +20,12 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     public function findOneByName(string $name): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->findOneInternal(['name' => $name, 'deleted_at' => null]));
+        return $this->createEntity($this->_findOne(['name' => $name, 'deleted_at' => null]));
     }
 
     /**
@@ -33,7 +33,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'name = :name',
             ['name' => $name]
         ));
@@ -44,7 +44,7 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             orderBy: 'id DESC',
             limit: $limit,
             offset: $offset,
diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php
index c0768e6..86c9a50 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -22,12 +22,12 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     public function findOneByName(string $name): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->findOneInternal(['name' => $name]));
+        return $this->createEntity($this->_findOne(['name' => $name]));
     }
 
     /**
@@ -35,7 +35,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             whereString: 'name = :name',
             whereParams: ['name' => $name],
             orderBy: 'id',
@@ -47,7 +47,7 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             orderBy: ['id' => 'DESC'],
             limit: $limit,
             offset: $offset,
@@ -56,7 +56,7 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllInternal(
+        return $this->_countAll(
             'name = :name',
             ['name' => $name]
         );
@@ -69,7 +69,7 @@ public function countAllByName(string $name): int
      */
     public function findMulti(array $ids): array
     {
-        return $this->createEntities($this->findMultiInternal($ids), 'id');
+        return $this->createEntities($this->_findMulti($ids), 'id');
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestCompositeCachedTable.php b/tests/TestStand/Tables/TestCompositeCachedTable.php
index 936d0a9..532294f 100644
--- a/tests/TestStand/Tables/TestCompositeCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeCachedTable.php
@@ -30,7 +30,7 @@ protected function getFlushCacheKeys(TestCompositeEntity|AbstractEntity $entity)
 
     public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
     {
-        return $this->createEntity($this->findOneCachedInternal([
+        return $this->createEntity($this->_findOneCached([
             'user_id' => $user_id,
             'post_id' => $post_id,
         ]));
@@ -43,7 +43,7 @@ public function findAllByUser(int $userId): array
     {
         return array_map(
             fn (array $data) => TestCompositeEntity::fromArray($data),
-            $this->findAllCachedInternal(
+            $this->_findAllCached(
                 'user_id = :user_id',
                 ['user_id' => $userId],
             )
@@ -52,7 +52,7 @@ public function findAllByUser(int $userId): array
 
     public function countAllByUser(int $userId): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'user_id = :user_id',
             ['user_id' => $userId],
         );
diff --git a/tests/TestStand/Tables/TestCompositeSdCachedTable.php b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
index f4938f1..aefee62 100644
--- a/tests/TestStand/Tables/TestCompositeSdCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeSdCachedTable.php
@@ -38,7 +38,7 @@ protected function getFlushCacheKeys(TestCompositeSdEntity|AbstractEntity $entit
 
     public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity
     {
-        return $this->createEntity($this->findOneCachedInternal([
+        return $this->createEntity($this->_findOneCached([
             'user_id' => $user_id,
             'post_id' => $post_id,
         ]));
@@ -51,7 +51,7 @@ public function findAllByUser(int $userId): array
     {
         return array_map(
             fn (array $data) => TestCompositeSdEntity::fromArray($data),
-            $this->findAllCachedInternal(
+            $this->_findAllCached(
                 'user_id = :user_id',
                 ['user_id' => $userId],
             )
@@ -60,7 +60,7 @@ public function findAllByUser(int $userId): array
 
     public function countAllByUser(int $userId): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'user_id = :user_id',
             ['user_id' => $userId],
         );
diff --git a/tests/TestStand/Tables/TestCompositeSdTable.php b/tests/TestStand/Tables/TestCompositeSdTable.php
index 28d3123..3217f73 100644
--- a/tests/TestStand/Tables/TestCompositeSdTable.php
+++ b/tests/TestStand/Tables/TestCompositeSdTable.php
@@ -20,7 +20,7 @@ protected function getConfig(): TableConfig
 
     public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity
     {
-        return $this->createEntity($this->findOneInternal([
+        return $this->createEntity($this->_findOne([
             'user_id' => $user_id,
             'post_id' => $post_id,
         ]));
@@ -31,7 +31,7 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity
      */
     public function findAllByUser(int $userId): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'user_id = :user_id',
             ['user_id' => $userId],
         ));
@@ -39,7 +39,7 @@ public function findAllByUser(int $userId): array
 
     public function countAllByUser(int $userId): int
     {
-        return $this->countAllInternal(
+        return $this->_countAll(
             'user_id = :user_id',
             ['user_id' => $userId],
         );
diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index 359e450..50639be 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -32,7 +32,7 @@ public function delete(AbstractEntity|TestCompositeEntity &$entity): void
 
     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->createEntity($this->_findOne(['user_id' => $user_id, 'post_id' => $post_id]));
     }
 
     /**
@@ -40,7 +40,7 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
      */
     public function findAllByUser(int $userId): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'user_id = :user_id',
             ['user_id' => $userId],
         ));
@@ -48,7 +48,7 @@ public function findAllByUser(int $userId): array
 
     public function countAllByUser(int $userId): int
     {
-        return $this->countAllInternal(
+        return $this->_countAll(
             'user_id = :user_id',
             ['user_id' => $userId, 'deleted_at' => null],
         );
@@ -61,7 +61,7 @@ public function countAllByUser(int $userId): int
      */
     public function findMulti(array $ids): array
     {
-        return $this->createEntities($this->findMultiInternal($ids), 'post_id');
+        return $this->createEntities($this->_findMulti($ids), 'post_id');
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestOptimisticLockTable.php b/tests/TestStand/Tables/TestOptimisticLockTable.php
index 8303514..375a61a 100644
--- a/tests/TestStand/Tables/TestOptimisticLockTable.php
+++ b/tests/TestStand/Tables/TestOptimisticLockTable.php
@@ -21,7 +21,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestOptimisticLockEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php
index 7cb7fa3..cdcaccb 100644
--- a/tests/TestStand/Tables/TestUniqueCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueCachedTable.php
@@ -32,7 +32,7 @@ protected function getFlushCacheKeys(TestUniqueEntity|AbstractEntity $entity): a
 
     public function findByPk(UuidInterface $id): ?TestUniqueEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     /**
@@ -40,7 +40,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllCachedInternal(
+        return $this->createEntities($this->_findAllCached(
             'name = :name',
             ['name' => $name],
         ));
@@ -48,7 +48,7 @@ public function findAllByName(string $name): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'name = :name',
             ['name' => $name],
         );
diff --git a/tests/TestStand/Tables/TestUniqueSdCachedTable.php b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
index 016133d..f5877ec 100644
--- a/tests/TestStand/Tables/TestUniqueSdCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdCachedTable.php
@@ -32,7 +32,7 @@ protected function getFlushCacheKeys(TestUniqueSdEntity|AbstractEntity $entity):
 
     public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     /**
@@ -40,7 +40,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllCachedInternal(
+        return $this->createEntities($this->_findAllCached(
             'name = :name',
             ['name' => $name],
         ));
@@ -48,7 +48,7 @@ public function findAllByName(string $name): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllCachedInternal(
+        return $this->_countAllCached(
             'name = :name',
             ['name' => $name],
         );
diff --git a/tests/TestStand/Tables/TestUniqueSdTable.php b/tests/TestStand/Tables/TestUniqueSdTable.php
index df078b9..5f61e0d 100644
--- a/tests/TestStand/Tables/TestUniqueSdTable.php
+++ b/tests/TestStand/Tables/TestUniqueSdTable.php
@@ -21,7 +21,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     /**
@@ -29,7 +29,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'name = :name',
             ['name' => $name],
         ));
diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 7e223d9..befb866 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -32,7 +32,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(UuidInterface $id): ?TestUniqueEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     /**
@@ -40,7 +40,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->findAllInternal(
+        return $this->createEntities($this->_findAll(
             'name = :name',
             ['name' => $name],
         ));
@@ -48,7 +48,7 @@ public function findAllByName(string $name): array
 
     public function countAllByName(string $name): int
     {
-        return $this->countAllInternal(
+        return $this->_countAll(
             'name = :name',
             ['name' => $name],
         );
diff --git a/tests/TestStand/Tables/TestUpdateAtTable.php b/tests/TestStand/Tables/TestUpdateAtTable.php
index 2fb74c4..48c7c2a 100644
--- a/tests/TestStand/Tables/TestUpdateAtTable.php
+++ b/tests/TestStand/Tables/TestUpdateAtTable.php
@@ -21,7 +21,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(string $id): ?TestUpdatedAtEntity
     {
-        return $this->createEntity($this->findByPkInternal($id));
+        return $this->createEntity($this->_findByPk($id));
     }
 
     public function init(): bool

From e68dd36ea67e7c9513da091764597229b3c08aa3 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 18:26:20 +0100
Subject: [PATCH 49/68] Rework $where param, add flexibility to use simple
 assoc array or flexible Where class

---
 src/AbstractCachedTable.php                   | 53 +++++-------
 src/AbstractTable.php                         | 62 +++++++++-----
 src/Where.php                                 | 16 ++++
 tests/Table/AbstractCachedTableTest.php       |  5 +-
 tests/Table/AbstractTableTest.php             | 84 ++++++++++++++-----
 tests/Table/AutoIncrementTableTest.php        | 12 +++
 tests/Table/CompositeTableTest.php            | 46 +---------
 tests/Table/UniqueTableTest.php               | 20 +----
 .../Entities/Enums/TestBackedEnum.php         | 12 +++
 .../TestStand/Entities/Enums/TestUnitEnum.php | 12 +++
 .../Entities/TestCompositeEntity.php          |  1 +
 .../Entities/TestCompositeSdEntity.php        | 12 ---
 tests/TestStand/Entities/TestUniqueEntity.php |  1 +
 .../TestStand/Entities/TestUniqueSdEntity.php | 12 ---
 .../Tables/TestAutoincrementCachedTable.php   | 25 +++---
 .../Tables/TestAutoincrementSdCachedTable.php | 24 ++++--
 .../Tables/TestAutoincrementSdTable.php       |  5 +-
 .../Tables/TestAutoincrementTable.php         | 18 ++--
 .../Tables/TestCompositeCachedTable.php       | 14 +---
 .../Tables/TestCompositeSdCachedTable.php     | 73 ----------------
 .../TestStand/Tables/TestCompositeSdTable.php | 65 --------------
 tests/TestStand/Tables/TestCompositeTable.php | 12 +--
 .../Tables/TestUniqueCachedTable.php          | 13 ++-
 .../Tables/TestUniqueSdCachedTable.php        | 61 --------------
 tests/TestStand/Tables/TestUniqueSdTable.php  | 54 ------------
 tests/TestStand/Tables/TestUniqueTable.php    | 13 ++-
 26 files changed, 254 insertions(+), 471 deletions(-)
 create mode 100644 src/Where.php
 create mode 100644 tests/TestStand/Entities/Enums/TestBackedEnum.php
 create mode 100644 tests/TestStand/Entities/Enums/TestUnitEnum.php
 delete mode 100644 tests/TestStand/Entities/TestCompositeSdEntity.php
 delete mode 100644 tests/TestStand/Entities/TestUniqueSdEntity.php
 delete mode 100644 tests/TestStand/Tables/TestCompositeSdCachedTable.php
 delete mode 100644 tests/TestStand/Tables/TestCompositeSdTable.php
 delete mode 100644 tests/TestStand/Tables/TestUniqueSdCachedTable.php
 delete mode 100644 tests/TestStand/Tables/TestUniqueSdTable.php

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index cca50ad..dd3a2b7 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -3,6 +3,7 @@
 namespace Composite\DB;
 
 use Composite\DB\Exceptions\DbException;
+use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity;
 use Composite\Entity\AbstractEntity;
 use Psr\SimpleCache\CacheInterface;
 use Ramsey\Uuid\UuidInterface;
@@ -115,37 +116,35 @@ protected function _findOneCached(array $condition, null|int|\DateInterval $ttl
     }
 
     /**
-     * @param array<string, mixed> $whereParams
+     * @param array<string, mixed>|Where $where
      * @param array<string, string>|string $orderBy
      * @return array<string, mixed>[]
      */
     protected function _findAllCached(
-        string $whereString = '',
-        array $whereParams = [],
+        array|Where $where = [],
         array|string $orderBy = [],
         ?int $limit = null,
         null|int|\DateInterval $ttl = null,
     ): array
     {
         return $this->getCached(
-            $this->getListCacheKey($whereString, $whereParams, $orderBy, $limit),
-            fn() => $this->_findAll(whereString: $whereString, whereParams: $whereParams, orderBy: $orderBy, limit: $limit),
+            $this->getListCacheKey($where, $orderBy, $limit),
+            fn() => $this->_findAll(where: $where, orderBy: $orderBy, limit: $limit),
             $ttl,
         );
     }
 
     /**
-     * @param array<string, mixed> $whereParams
+     * @param array<string, mixed>|Where $where
      */
-    protected function _countAllCached(
-        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->_countAll(whereString: $whereString, whereParams: $whereParams),
+            $this->getCountCacheKey($where),
+            fn() => $this->_countAll(where: $where),
             $ttl,
         );
     }
@@ -209,37 +208,35 @@ protected function getOneCacheKey(string|int|array|AbstractEntity|UuidInterface
     }
 
     /**
-     * @param array<string, mixed> $whereParams
+     * @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> $whereParams
+     * @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',
         );
     }
 
@@ -280,18 +277,12 @@ private function formatStringForCacheKey(string $string): string
         return trim((string)preg_replace('/_+/', '_', $string), '_');
     }
 
-    /**
-     * @param array<string, mixed> $whereParams
-     */
-    private function prepareWhereKey(string $whereString, array $whereParams): ?string
+    private function prepareWhereKey(Where $where): string
     {
-        if (!$whereString) {
-            return null;
-        }
         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->string,
         );
     }
 }
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 4362f42..59e89e3 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -7,7 +7,6 @@
 use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Exceptions\DbException;
-use Composite\Entity\Exceptions\EntityException;
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
 use Doctrine\DBAL\Query\QueryBuilder;
@@ -15,6 +14,8 @@
 
 abstract class AbstractTable
 {
+    private const COMPARISON_SIGNS = ['=', '!=', '>', '<', '>=', '<=', '<>'];
+
     protected readonly TableConfig $config;
     private ?QueryBuilder $selectQuery = null;
 
@@ -170,15 +171,17 @@ public function deleteMany(array $entities): void
     }
 
     /**
-     * @param array<string, mixed> $whereParams
+     * @param array<string, mixed>|Where $where
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function _countAll(string $whereString = '', array $whereParams = []): int
+    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->string);
+            foreach ($where->params as $param => $value) {
                 $query->setParameter($param, $value);
             }
         }
@@ -187,7 +190,6 @@ protected function _countAll(string $whereString = '', array $whereParams = []):
 
     /**
      * @return array<string, mixed>|null
-     * @throws EntityException
      * @throws \Doctrine\DBAL\Exception
      */
     protected function _findByPk(mixed $pk): ?array
@@ -226,23 +228,24 @@ protected function _findMulti(array $pkList): array
     }
 
     /**
-     * @param array<string, mixed> $whereParams
+     * @param array<string, mixed>|Where $where
      * @param array<string, string>|string $orderBy
      * @return list<array<string,mixed>>
      * @throws \Doctrine\DBAL\Exception
      */
     protected function _findAll(
-        string $whereString = '',
-        array $whereParams = [],
+        array|Where $where = [],
         array|string $orderBy = [],
         ?int $limit = null,
         ?int $offset = null,
     ): array
     {
         $query = $this->select();
-        if ($whereString) {
-            $query->where($whereString);
-            foreach ($whereParams as $param => $value) {
+        if (is_array($where)) {
+            $this->buildWhere($query, $where);
+        } else {
+            $query->where($where->string);
+            foreach ($where->params as $param => $value) {
                 $query->setParameter($param, $value);
             }
         }
@@ -301,7 +304,6 @@ final protected function createEntities(mixed $data, ?string $keyColumnName = nu
     /**
      * @param int|string|array<string, mixed>|AbstractEntity $data
      * @return array<string, mixed>
-     * @throws EntityException
      */
     protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface $data): array
     {
@@ -335,14 +337,34 @@ protected function select(string $select = '*'): QueryBuilder
     private function buildWhere(QueryBuilder $query, array $where): void
     {
         foreach ($where as $column => $value) {
-            if ($value === null) {
-                $query->andWhere("$column IS NULL");
+            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], self::COMPARISON_SIGNS)) {
+                $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)) {
-                $query
-                    ->andWhere($query->expr()->in($column, $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)
+                $query->andWhere($column . ' = :' . $column)
                     ->setParameter($column, $value);
             }
         }
diff --git a/src/Where.php b/src/Where.php
new file mode 100644
index 0000000..41b545f
--- /dev/null
+++ b/src/Where.php
@@ -0,0 +1,16 @@
+<?php declare(strict_types=1);
+
+namespace Composite\DB;
+
+class Where
+{
+    /**
+     * @param string $string free format where string, example: "user_id = :user_id OR user_id > 0"
+     * @param array<string, mixed> $params params with placeholders, which used in $string, example: ['user_id' => 123],
+     */
+    public function __construct(
+        public readonly string $string,
+        public readonly array $params,
+    ) {
+    }
+}
\ No newline at end of file
diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 6c65846..8ca3877 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -7,6 +7,7 @@
 use Composite\DB\Exceptions\DbException;
 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 Ramsey\Uuid\Uuid;
@@ -91,7 +92,7 @@ public function test_getCountCacheKey(string $whereString, array $whereParams, s
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
         $reflectionMethod = new \ReflectionMethod($table, 'getCountCacheKey');
-        $actual = $reflectionMethod->invoke($table, $whereString, $whereParams);
+        $actual = $reflectionMethod->invoke($table, new Where($whereString, $whereParams));
         $this->assertEquals($expected, $actual);
     }
 
@@ -150,7 +151,7 @@ public function test_getListCacheKey(string $whereString, array $whereArray, arr
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
         $reflectionMethod = new \ReflectionMethod($table, 'getListCacheKey');
-        $actual = $reflectionMethod->invoke($table, $whereString, $whereArray, $orderBy, $limit);
+        $actual = $reflectionMethod->invoke($table, new Where($whereString, $whereArray), $orderBy, $limit);
         $this->assertEquals($expected, $actual);
     }
 
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index 5c45eca..cb48987 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -51,16 +51,6 @@ public static function getPkCondition_dataProvider(): array
                 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: $uuid, name: 'John'),
-                ['id' => $uuid->toString()],
-            ],
         ];
     }
 
@@ -123,29 +113,81 @@ public function test_buildWhere($where, $expectedSQL, $expectedParams)
         $buildWhereReflection->invokeArgs($table, [$queryBuilder, $where]);
 
         $this->assertEquals($expectedSQL, $queryBuilder->getSQL());
+        $this->assertEquals($expectedParams, $queryBuilder->getParameters());
     }
 
     public static function buildWhere_dataProvider(): array
     {
         return [
-            // Test when value is null
+            // Scalar value
             [
-                ['column1' => null],
-                'SELECT * FROM Strict WHERE column1 IS NULL',
+                ['column' => 1],
+                'SELECT * FROM Strict WHERE column = :column',
+                ['column' => 1]
+            ],
+
+            // Null value
+            [
+                ['column' => null],
+                'SELECT * FROM Strict WHERE column IS NULL',
                 []
             ],
-            // Test when value is an array
+
+            // Greater than comparison
             [
-                ['column1' => [1, 2, 3]],
-                'SELECT * FROM Strict WHERE column1 IN (1, 2, 3)',
-                [1, 2, 3]
+                ['column' => ['>', 0]],
+                'SELECT * FROM Strict WHERE column > :column',
+                ['column' => 0]
             ],
-            // Test when value is a single value
+
+            // Less than comparison
             [
-                ['column1' => 'value1'],
-                'SELECT * FROM Strict WHERE column1 = :column1',
-                ['value1']
+                ['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]
+            ],
+
+            // Less than or equal to comparison
+            [
+                ['column' => ['<=', 7]],
+                'SELECT * FROM Strict WHERE column <= :column',
+                ['column' => 7]
+            ],
+
+            // Not equal to comparison with scalar value
+            [
+                ['column' => ['<>', 10]],
+                'SELECT * FROM Strict WHERE column <> :column',
+                ['column' => 10]
+            ],
+
+            // Not equal to comparison with null
+            [
+                ['column' => ['!=', null]],
+                'SELECT * FROM Strict WHERE column IS NOT NULL',
+                []
+            ],
+
+            // IN condition
+            [
+                ['column' => [1, 2, 3]],
+                'SELECT * FROM Strict WHERE column IN(:column0, :column1, :column2)',
+                ['column0' => 1, 'column1' => 2, 'column2' => 3]
+            ],
+
+            // 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]
+            ]
         ];
     }
 }
\ No newline at end of file
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index ec85178..98690f2 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -85,6 +85,18 @@ public function test_crud(AbstractTable&IAutoincrementTable $table, string $clas
         $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()) {
diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php
index afc9f97..224e604 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -19,29 +19,20 @@ public static function crud_dataProvider(): array
                 new Tables\TestCompositeTable(),
                 Entities\TestCompositeEntity::class,
             ],
-            [
-                new Tables\TestCompositeSdTable(),
-                Entities\TestCompositeSdEntity::class,
-            ],
             [
                 new Tables\TestCompositeCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestCompositeEntity::class,
             ],
-            [
-                new Tables\TestCompositeSdCachedTable(Helpers\CacheHelper::getCache()),
-                Entities\TestCompositeSdEntity::class,
-            ],
         ];
     }
 
     /**
-     * @param class-string<Entities\TestCompositeEntity|Entities\TestCompositeSdEntity> $class
+     * @param class-string<Entities\TestCompositeEntity> $class
      * @dataProvider crud_dataProvider
      */
     public function test_crud(AbstractTable&ICompositeTable $table, string $class): void
     {
         $table->truncate();
-        $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
             user_id: mt_rand(1, 1000000),
@@ -57,14 +48,7 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         $this->assertEntityExists($table, $entity);
 
         $table->delete($entity);
-
-        if ($tableConfig->hasSoftDelete()) {
-            /** @var Entities\TestCompositeSdEntity $deletedEntity */
-            $deletedEntity = $table->findOne(user_id: $entity->user_id, post_id: $entity->post_id);
-            $this->assertTrue($deletedEntity->isDeleted());
-        } else {
-            $this->assertEntityNotExists($table, $entity);
-        }
+        $this->assertEntityNotExists($table, $entity);
 
         $e1 = new $class(
             user_id: mt_rand(1, 1000000),
@@ -84,32 +68,10 @@ public function test_crud(AbstractTable&ICompositeTable $table, string $class):
         $this->assertEntityExists($table, $e1);
         $this->assertEntityExists($table, $e2);
 
-        if ($tableConfig->hasSoftDelete()) {
-            $e1->message = 'Exception';
-            $exceptionThrown = false;
-            try {
-                $table->deleteMany([$e1, $e2]);
-            } catch (\Exception) {
-                $exceptionThrown = true;
-            }
-            $this->assertTrue($exceptionThrown);
-            $e1->message = Helpers\StringHelper::getUniqueName();
-        }
-
         $table->deleteMany([$e1, $e2]);
 
-        if ($tableConfig->hasSoftDelete()) {
-            /** @var Entities\TestCompositeSdEntity $deletedEntity1 */
-            $deletedEntity1 = $table->findOne(user_id: $e1->user_id, post_id: $e1->post_id);
-            $this->assertTrue($deletedEntity1->isDeleted());
-
-            /** @var Entities\TestCompositeSdEntity $deletedEntity2 */
-            $deletedEntity2 = $table->findOne(user_id: $e2->user_id, post_id: $e2->post_id);
-            $this->assertTrue($deletedEntity2->isDeleted());
-        } else {
-            $this->assertEntityNotExists($table, $e1);
-            $this->assertEntityNotExists($table, $e2);
-        }
+        $this->assertEntityNotExists($table, $e1);
+        $this->assertEntityNotExists($table, $e2);
     }
 
     public function test_getMulti(): void
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index e9cd434..d4073fd 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -19,29 +19,20 @@ public static function crud_dataProvider(): array
                 new Tables\TestUniqueTable(),
                 Entities\TestUniqueEntity::class,
             ],
-            [
-                new Tables\TestUniqueSdTable(),
-                Entities\TestUniqueSdEntity::class,
-            ],
             [
                 new Tables\TestUniqueCachedTable(Helpers\CacheHelper::getCache()),
                 Entities\TestUniqueEntity::class,
             ],
-            [
-                new Tables\TestUniqueSdCachedTable(Helpers\CacheHelper::getCache()),
-                Entities\TestUniqueSdEntity::class,
-            ],
         ];
     }
 
     /**
-     * @param class-string<Entities\TestUniqueEntity|Entities\TestUniqueSdEntity> $class
+     * @param class-string<Entities\TestUniqueEntity> $class
      * @dataProvider crud_dataProvider
      */
     public function test_crud(AbstractTable&IUniqueTable $table, string $class): void
     {
         $table->truncate();
-        $tableConfig = TableConfig::fromEntitySchema($class::schema());
 
         $entity = new $class(
             id: Uuid::uuid4(),
@@ -56,14 +47,7 @@ public function test_crud(AbstractTable&IUniqueTable $table, string $class): voi
         $this->assertEntityExists($table, $entity);
 
         $table->delete($entity);
-
-        if ($tableConfig->hasSoftDelete()) {
-            /** @var Entities\TestUniqueSdEntity $deletedEntity */
-            $deletedEntity = $table->findByPk($entity->id);
-            $this->assertTrue($deletedEntity->isDeleted());
-        } else {
-            $this->assertEntityNotExists($table, $entity);
-        }
+        $this->assertEntityNotExists($table, $entity);
     }
 
     public function test_multiSave(): void
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/TestUnitEnum.php b/tests/TestStand/Entities/Enums/TestUnitEnum.php
new file mode 100644
index 0000000..a787709
--- /dev/null
+++ b/tests/TestStand/Entities/Enums/TestUnitEnum.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 TestUnitEnum
+{
+    case ACTIVE;
+    case DELETED;
+}
\ 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/TestUniqueEntity.php b/tests/TestStand/Entities/TestUniqueEntity.php
index 2a6138b..cce8a0b 100644
--- a/tests/TestStand/Entities/TestUniqueEntity.php
+++ b/tests/TestStand/Entities/TestUniqueEntity.php
@@ -14,6 +14,7 @@ public function __construct(
         #[PrimaryKey]
         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/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index 90f4386..ebb3d96 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -6,6 +6,7 @@
 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
@@ -25,14 +26,14 @@ 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;
     }
@@ -47,14 +48,21 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
         return $this->createEntity($this->_findOneCached(['name' => $name]));
     }
 
+    public function delete(TestAutoincrementEntity|AbstractEntity &$entity): void
+    {
+        if ($entity->name === 'Exception') {
+            throw new \Exception('Test Exception');
+        }
+        parent::delete($entity);
+    }
+
     /**
      * @return TestAutoincrementEntity[]
      */
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->_findAllCached(
-            'name = :name',
-            ['name' => $name],
+            new Where('name = :name', ['name' => $name])
         ));
     }
 
@@ -72,10 +80,7 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->_countAllCached(
-            'name = :name',
-            ['name' => $name],
-        );
+        return $this->_countByAllCached(new Where('name = :name', ['name' => $name]));
     }
 
     /**
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index c646e9a..4f432b7 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -6,6 +6,7 @@
 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
@@ -25,14 +26,14 @@ 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;
     }
@@ -47,14 +48,21 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
         return $this->createEntity($this->_findOneCached(['name' => $name]));
     }
 
+    public function delete(TestAutoincrementSdEntity|AbstractEntity &$entity): void
+    {
+        if ($entity->name === 'Exception') {
+            throw new \Exception('Test Exception');
+        }
+        parent::delete($entity);
+    }
+
     /**
      * @return TestAutoincrementSdEntity[]
      */
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->_findAllCached(
-            'name = :name',
-            ['name' => $name, 'deleted_at' => null],
+            new Where('name = :name', ['name' => $name, 'deleted_at' => null]),
         ));
     }
 
@@ -72,10 +80,10 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->_countAllCached(
+        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 c45e826..519f063 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php
@@ -4,6 +4,7 @@
 
 use Composite\DB\TableConfig;
 use Composite\DB\Tests\TestStand\Entities\TestAutoincrementSdEntity;
+use Composite\DB\Where;
 
 class TestAutoincrementSdTable extends TestAutoincrementTable
 {
@@ -34,8 +35,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->_findAll(
-            'name = :name',
-            ['name' => $name]
+            new Where('name = :name', ['name' => $name])
         ));
     }
 
@@ -45,6 +45,7 @@ public function findAllByName(string $name): array
     public function findRecent(int $limit, int $offset): array
     {
         return $this->createEntities($this->_findAll(
+            where: ['deleted_at' => null],
             orderBy: 'id DESC',
             limit: $limit,
             offset: $offset,
diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php
index 86c9a50..de0e994 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -6,6 +6,8 @@
 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
 {
@@ -30,14 +32,21 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
         return $this->createEntity($this->_findOne(['name' => $name]));
     }
 
+    public function delete(AbstractEntity|TestAutoincrementEntity &$entity): void
+    {
+        if ($entity->name === 'Exception') {
+            throw new \Exception('Test Exception');
+        }
+        parent::delete($entity);
+    }
+
     /**
      * @return TestAutoincrementEntity[]
      */
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->_findAll(
-            whereString: 'name = :name',
-            whereParams: ['name' => $name],
+            where: new Where('name = :name', ['name' => $name]),
             orderBy: 'id',
         ));
     }
@@ -56,10 +65,7 @@ public function findRecent(int $limit, int $offset): array
 
     public function countAllByName(string $name): int
     {
-        return $this->_countAll(
-            'name = :name',
-            ['name' => $name]
-        );
+        return $this->_countAll(new Where('name = :name', ['name' => $name]));
     }
 
     /**
diff --git a/tests/TestStand/Tables/TestCompositeCachedTable.php b/tests/TestStand/Tables/TestCompositeCachedTable.php
index 532294f..57f3203 100644
--- a/tests/TestStand/Tables/TestCompositeCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeCachedTable.php
@@ -23,8 +23,8 @@ 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]),
         ];
     }
 
@@ -43,19 +43,13 @@ public function findAllByUser(int $userId): array
     {
         return array_map(
             fn (array $data) => TestCompositeEntity::fromArray($data),
-            $this->_findAllCached(
-                'user_id = :user_id',
-                ['user_id' => $userId],
-            )
+            $this->_findAllCached(['user_id' => $userId])
         );
     }
 
     public function countAllByUser(int $userId): int
     {
-        return $this->_countAllCached(
-            '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 aefee62..0000000
--- a/tests/TestStand/Tables/TestCompositeSdCachedTable.php
+++ /dev/null
@@ -1,73 +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
-{
-    public function __construct(\Psr\SimpleCache\CacheInterface $cache)
-    {
-        parent::__construct($cache);
-        (new TestCompositeSdTable())->init();
-    }
-
-    public function save(AbstractEntity|TestCompositeSdEntity &$entity): void
-    {
-        if ($entity->message === 'Exception') {
-            throw new \Exception('Test Exception');
-        }
-        parent::save($entity);
-    }
-
-    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->_findOneCached([
-            '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->_findAllCached(
-                'user_id = :user_id',
-                ['user_id' => $userId],
-            )
-        );
-    }
-
-    public function countAllByUser(int $userId): int
-    {
-        return $this->_countAllCached(
-            '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 3217f73..0000000
--- a/tests/TestStand/Tables/TestCompositeSdTable.php
+++ /dev/null
@@ -1,65 +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
-{
-    public function __construct()
-    {
-        parent::__construct();
-        $this->init();
-    }
-
-    protected function getConfig(): TableConfig
-    {
-        return TableConfig::fromEntitySchema(TestCompositeSdEntity::schema());
-    }
-
-    public function findOne(int $user_id, int $post_id): ?TestCompositeSdEntity
-    {
-        return $this->createEntity($this->_findOne([
-            'user_id' => $user_id,
-            'post_id' => $post_id,
-        ]));
-    }
-
-    /**
-     * @return TestCompositeSdEntity[]
-     */
-    public function findAllByUser(int $userId): array
-    {
-        return $this->createEntities($this->_findAll(
-            'user_id = :user_id',
-            ['user_id' => $userId],
-        ));
-    }
-
-    public function countAllByUser(int $userId): int
-    {
-        return $this->_countAll(
-            '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 50639be..2fe6910 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;
@@ -40,18 +41,12 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
      */
     public function findAllByUser(int $userId): array
     {
-        return $this->createEntities($this->_findAll(
-            'user_id = :user_id',
-            ['user_id' => $userId],
-        ));
+        return $this->createEntities($this->_findAll(['user_id' => $userId, 'status' => TestUnitEnum::ACTIVE]));
     }
 
     public function countAllByUser(int $userId): int
     {
-        return $this->_countAll(
-            'user_id = :user_id',
-            ['user_id' => $userId, 'deleted_at' => null],
-        );
+        return $this->_countAll(['user_id' => $userId]);
     }
 
     /**
@@ -73,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/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php
index cdcaccb..cd7c901 100644
--- a/tests/TestStand/Tables/TestUniqueCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueCachedTable.php
@@ -6,6 +6,7 @@
 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;
 
@@ -25,8 +26,8 @@ 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])),
         ];
     }
 
@@ -41,17 +42,13 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
     public function findAllByName(string $name): array
     {
         return $this->createEntities($this->_findAllCached(
-            'name = :name',
-            ['name' => $name],
+            new Where('name = :name', ['name' => $name])
         ));
     }
 
     public function countAllByName(string $name): int
     {
-        return $this->_countAllCached(
-            '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 f5877ec..0000000
--- a/tests/TestStand/Tables/TestUniqueSdCachedTable.php
+++ /dev/null
@@ -1,61 +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;
-use Ramsey\Uuid\UuidInterface;
-
-class TestUniqueSdCachedTable extends AbstractCachedTable implements IUniqueTable
-{
-    public function __construct(\Psr\SimpleCache\CacheInterface $cache)
-    {
-        parent::__construct($cache);
-        (new TestUniqueSdTable())->init();
-    }
-
-    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(UuidInterface $id): ?TestUniqueSdEntity
-    {
-        return $this->createEntity($this->_findByPk($id));
-    }
-
-    /**
-     * @return TestUniqueSdEntity[]
-     */
-    public function findAllByName(string $name): array
-    {
-        return $this->createEntities($this->_findAllCached(
-            'name = :name',
-            ['name' => $name],
-        ));
-    }
-
-    public function countAllByName(string $name): int
-    {
-        return $this->_countAllCached(
-            '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 5f61e0d..0000000
--- a/tests/TestStand/Tables/TestUniqueSdTable.php
+++ /dev/null
@@ -1,54 +0,0 @@
-<?php declare(strict_types=1);
-
-namespace Composite\DB\Tests\TestStand\Tables;
-
-use Composite\DB\TableConfig;
-use Composite\DB\Tests\TestStand\Entities\TestUniqueSdEntity;
-use Ramsey\Uuid\UuidInterface;
-
-class TestUniqueSdTable extends TestUniqueTable
-{
-    public function __construct()
-    {
-        parent::__construct();
-        $this->init();
-    }
-
-    protected function getConfig(): TableConfig
-    {
-        return TableConfig::fromEntitySchema(TestUniqueSdEntity::schema());
-    }
-
-    public function findByPk(UuidInterface $id): ?TestUniqueSdEntity
-    {
-        return $this->createEntity($this->_findByPk($id));
-    }
-
-    /**
-     * @return TestUniqueSdEntity[]
-     */
-    public function findAllByName(string $name): array
-    {
-        return $this->createEntities($this->_findAll(
-            'name = :name',
-            ['name' => $name],
-        ));
-    }
-
-    public function init(): bool
-    {
-        $this->getConnection()->executeStatement(
-            "
-            CREATE TABLE IF NOT EXISTS {$this->getTableName()}
-            (
-                `id` VARCHAR(32) 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 befb866..0ad6264 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -4,8 +4,10 @@
 
 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;
 
@@ -40,18 +42,12 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAll(
-            'name = :name',
-            ['name' => $name],
-        ));
+        return $this->createEntities($this->_findAll(['name' => $name, 'status' => TestBackedEnum::ACTIVE]));
     }
 
     public function countAllByName(string $name): int
     {
-        return $this->_countAll(
-            'name = :name',
-            ['name' => $name],
-        );
+        return $this->_countAll(new Where('name = :name', ['name' => $name]));
     }
 
     public function init(): bool
@@ -62,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
             );
             "

From c81df32ae1ce7bfc709ffa9188bc25c3d95353da Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 18:26:39 +0100
Subject: [PATCH 50/68] Display warnings in unit tests

---
 phpunit.xml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/phpunit.xml b/phpunit.xml
index f68b1fa..9b42ad0 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -3,6 +3,7 @@
          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"
+         displayDetailsOnTestsThatTriggerWarnings="true"
          beStrictAboutCoverageMetadata="true">
     <testsuites>
         <testsuite name="default">

From 93cc75b9c059d8c1f71e20ed3795a27061258f79 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 18:26:59 +0100
Subject: [PATCH 51/68] Update documentation

---
 doc/cache.md   |  1 -
 doc/example.md |  6 ++----
 doc/table.md   | 23 ++++++++++++++++++++---
 3 files changed, 22 insertions(+), 8 deletions(-)

diff --git a/doc/cache.md b/doc/cache.md
index 7b64d1f..84b123d 100644
--- a/doc/cache.md
+++ b/doc/cache.md
@@ -55,7 +55,6 @@ class PostsTable extends AbstractCachedTable
     public function findAllFeatured(): array
     {
         return $this->createEntities($this->_findAll(
-            'is_featured = :is_featured',
             ['is_featured' => true],
         ));
     }
diff --git a/doc/example.md b/doc/example.md
index 22a63fc..8709821 100644
--- a/doc/example.md
+++ b/doc/example.md
@@ -52,16 +52,14 @@ class UsersTable extends \Composite\DB\AbstractTable
     public function findAllActive(): array
     {
         return $this->createEntities($this->_findAll(
-            'status = :status',
-            ['status' => Status::ACTIVE->name],
+            ['status' => Status::ACTIVE],
         ));
     }
 
     public function countAllActive(): int
     {
         return $this->_countAll(
-            'status = :status',
-            ['status' => Status::ACTIVE->name],
+            ['status' => Status::ACTIVE],
         );
     }
 
diff --git a/doc/table.md b/doc/table.md
index fbd7d1b..e6aec2e 100644
--- a/doc/table.md
+++ b/doc/table.md
@@ -68,14 +68,31 @@ Example with internal helper:
 public function findAllActiveAdults(): array
 {
     $rows = $this->_findAll(
-        'age > :age AND status = :status',
-        ['age' => 18, 'status' => Status::ACTIVE->name],
+        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
+{
+    $rows = $this->_findAll([
+        'age' => ['>', 18],
+        'status' => Status:ACTIVE,
+    ]);
+    return $this->createEntities($rows);
+}
+```
+
+Or you can use standard Doctrine QueryBuilder
 ```php
 /**
  * @return User[]

From ccc8ca5688040fe0a18a092e928806f4b948911a Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 19:20:08 +0100
Subject: [PATCH 52/68] Minor improve AbstractCachedTableTest

---
 tests/Table/AbstractCachedTableTest.php | 55 ++++++++++++++++---------
 1 file changed, 35 insertions(+), 20 deletions(-)

diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 8ca3877..f73c4ec 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -58,41 +58,44 @@ 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
+    public function test_getCountCacheKey(array|Where $where, string $expected): void
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
         $reflectionMethod = new \ReflectionMethod($table, 'getCountCacheKey');
-        $actual = $reflectionMethod->invoke($table, new Where($whereString, $whereParams));
+        $actual = $reflectionMethod->invoke($table, $where);
         $this->assertEquals($expected, $actual);
     }
 
@@ -100,43 +103,55 @@ 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')
@@ -147,11 +162,11 @@ public static function getListCacheKey_dataProvider(): array
     /**
      * @dataProvider getListCacheKey_dataProvider
      */
-    public function test_getListCacheKey(string $whereString, array $whereArray, array $orderBy, ?int $limit, string $expected): void
+    public function test_getListCacheKey(array|Where $where, array $orderBy, ?int $limit, string $expected): void
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
         $reflectionMethod = new \ReflectionMethod($table, 'getListCacheKey');
-        $actual = $reflectionMethod->invoke($table, new Where($whereString, $whereArray), $orderBy, $limit);
+        $actual = $reflectionMethod->invoke($table, $where, $orderBy, $limit);
         $this->assertEquals($expected, $actual);
     }
 

From 9a26bbe4dff1d3f10dea1e6d5fa4637232e9142c Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 19:37:20 +0100
Subject: [PATCH 53/68] Add .scrutinizer.yml to export-ignore

---
 .gitattributes | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.gitattributes b/.gitattributes
index 1989899..d09a5f7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,6 +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

From 1faf615424bcf6f4638a1f69ffd35cf296cacd7a Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 20:04:13 +0100
Subject: [PATCH 54/68] Add possibility to use Where class in _findOne internal
 method

---
 src/AbstractTable.php | 76 +++++++++++++++++++++----------------------
 1 file changed, 38 insertions(+), 38 deletions(-)

diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 59e89e3..49f225b 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -199,12 +199,12 @@ protected function _findByPk(mixed $pk): ?array
     }
 
     /**
-     * @param array<string, mixed> $where
+     * @param array<string, mixed>|Where $where
      * @param array<string, string>|string $orderBy
      * @return array<string, mixed>|null
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function _findOne(array $where, array|string $orderBy = []): ?array
+    protected function _findOne(array|Where $where, array|string $orderBy = []): ?array
     {
         $query = $this->select();
         $this->buildWhere($query, $where);
@@ -241,14 +241,7 @@ protected function _findAll(
     ): array
     {
         $query = $this->select();
-        if (is_array($where)) {
-            $this->buildWhere($query, $where);
-        } else {
-            $query->where($where->string);
-            foreach ($where->params as $param => $value) {
-                $query->setParameter($param, $value);
-            }
-        }
+        $this->buildWhere($query, $where);
         $this->applyOrderBy($query, $orderBy);
         if ($limit > 0) {
             $query->setMaxResults($limit);
@@ -332,40 +325,47 @@ protected function select(string $select = '*'): QueryBuilder
     }
 
     /**
-     * @param array<string, mixed> $where
+     * @param array<string, mixed>|Where $where
      */
-    private function buildWhere(QueryBuilder $query, array $where): void
+    private function buildWhere(QueryBuilder $query, array|Where $where): void
     {
-        foreach ($where as $column => $value) {
-            if ($value instanceof \BackedEnum) {
-                $value = $value->value;
-            } elseif ($value instanceof \UnitEnum) {
-                $value = $value->name;
-            }
+        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], self::COMPARISON_SIGNS)) {
-                $comparisonSign = $value[0];
-                $comparisonValue = $value[1];
+                if (is_null($value)) {
+                    $query->andWhere($column . ' IS NULL');
+                } elseif (is_array($value) && count($value) === 2 && \in_array($value[0], self::COMPARISON_SIGNS)) {
+                    $comparisonSign = $value[0];
+                    $comparisonValue = $value[1];
 
-                // Handle special case of "!= null"
-                if ($comparisonSign === '!=' && is_null($comparisonValue)) {
-                    $query->andWhere($column . ' IS NOT NULL');
+                    // 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 . ' ' . $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 . ' = :' . $column)
+                        ->setParameter($column, $value);
                 }
-                $query->andWhere($column . ' IN(' . implode(', ', $placeholders) . ')');
-            } else {
-                $query->andWhere($column . ' = :' . $column)
-                    ->setParameter($column, $value);
+            }
+        } else {
+            $query->where($where->string);
+            foreach ($where->params as $param => $value) {
+                $query->setParameter($param, $value);
             }
         }
     }

From 7a6a66ad6c57b0131f88bfeb4db942c105905823 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 28 Oct 2023 20:10:02 +0100
Subject: [PATCH 55/68] Rename Where.string to Where.condition

---
 src/AbstractCachedTable.php | 2 +-
 src/AbstractTable.php       | 4 ++--
 src/Where.php               | 6 +++---
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index dd3a2b7..6a6022c 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -282,7 +282,7 @@ private function prepareWhereKey(Where $where): string
         return str_replace(
             array_map(fn (string $key): string => ':' . $key, array_keys($where->params)),
             array_values($where->params),
-            $where->string,
+            $where->condition,
         );
     }
 }
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index 49f225b..dad1381 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -180,7 +180,7 @@ protected function _countAll(array|Where $where = []): int
         if (is_array($where)) {
             $this->buildWhere($query, $where);
         } else {
-            $query->where($where->string);
+            $query->where($where->condition);
             foreach ($where->params as $param => $value) {
                 $query->setParameter($param, $value);
             }
@@ -363,7 +363,7 @@ private function buildWhere(QueryBuilder $query, array|Where $where): void
                 }
             }
         } else {
-            $query->where($where->string);
+            $query->where($where->condition);
             foreach ($where->params as $param => $value) {
                 $query->setParameter($param, $value);
             }
diff --git a/src/Where.php b/src/Where.php
index 41b545f..147b3aa 100644
--- a/src/Where.php
+++ b/src/Where.php
@@ -5,11 +5,11 @@
 class Where
 {
     /**
-     * @param string $string free format where string, example: "user_id = :user_id OR user_id > 0"
-     * @param array<string, mixed> $params params with placeholders, which used in $string, example: ['user_id' => 123],
+     * @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 $string,
+        public readonly string $condition,
         public readonly array $params,
     ) {
     }

From e0446683211efe7b43abf63ffac3a265b0c68791 Mon Sep 17 00:00:00 2001
From: Composite PHP <compositephp@gmail.com>
Date: Sat, 11 Nov 2023 21:52:38 +0000
Subject: [PATCH 56/68] phpstan fixes

---
 src/MultiQuery/MultiSelect.php | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/MultiQuery/MultiSelect.php b/src/MultiQuery/MultiSelect.php
index 59459eb..c5171a7 100644
--- a/src/MultiQuery/MultiSelect.php
+++ b/src/MultiQuery/MultiSelect.php
@@ -12,6 +12,10 @@ class MultiSelect
 {
     private readonly QueryBuilder $queryBuilder;
 
+    /**
+     * @param array<string, mixed>|array<string|int> $condition
+     * @throws DbException
+     */
     public function __construct(
         Connection $connection,
         TableConfig $tableConfig,
@@ -32,7 +36,7 @@ public function __construct(
             }
             /** @var \Composite\Entity\Columns\AbstractColumn $pkColumn */
             $pkColumn = reset($pkColumns);
-            $preparedPkValues = array_map(fn ($pk) => $pkColumn->uncast($pk), $condition);
+            $preparedPkValues = array_map(fn ($pk) => (string)$pkColumn->uncast($pk), $condition);
             $query->andWhere($query->expr()->in($pkColumn->name, $preparedPkValues));
         } else {
             $expressions = [];

From 019af835d163bf5357b2381ba28ddff136805156 Mon Sep 17 00:00:00 2001
From: Ivan Vasilkov <i.vasilkov@eyelinkmedia.com>
Date: Sat, 18 Nov 2023 23:15:29 +0000
Subject: [PATCH 57/68] remove redundant use

---
 tests/Table/UniqueTableTest.php | 1 -
 1 file changed, 1 deletion(-)

diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index d4073fd..186ff97 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -3,7 +3,6 @@
 namespace Composite\DB\Tests\Table;
 
 use Composite\DB\AbstractTable;
-use Composite\DB\TableConfig;
 use Composite\DB\Tests\Helpers;
 use Composite\DB\Tests\TestStand\Entities;
 use Composite\DB\Tests\TestStand\Tables;

From b477a3a795dfdc8707499daac22a6bc0b8d43e0d Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sun, 19 Nov 2023 10:03:06 +0000
Subject: [PATCH 58/68] Move raw selects to separate trait and build entities
 by default

---
 doc/cache.md                                  |   6 +-
 doc/example.md                                |   6 +-
 doc/table.md                                  |  10 +-
 src/AbstractCachedTable.php                   |  43 +++---
 src/AbstractTable.php                         | 146 +++++-------------
 src/SelectRawTrait.php                        | 131 ++++++++++++++++
 tests/Table/AbstractCachedTableTest.php       |   4 +-
 .../Tables/TestAutoincrementCachedTable.php   |  16 +-
 .../Tables/TestAutoincrementSdCachedTable.php |  12 +-
 .../Tables/TestAutoincrementSdTable.php       |  12 +-
 .../Tables/TestAutoincrementTable.php         |  14 +-
 .../Tables/TestCompositeCachedTable.php       |   9 +-
 tests/TestStand/Tables/TestCompositeTable.php |   6 +-
 .../Tables/TestOptimisticLockTable.php        |   2 +-
 .../Tables/TestUniqueCachedTable.php          |   6 +-
 tests/TestStand/Tables/TestUniqueTable.php    |   4 +-
 tests/TestStand/Tables/TestUpdateAtTable.php  |   2 +-
 17 files changed, 237 insertions(+), 192 deletions(-)
 create mode 100644 src/SelectRawTrait.php

diff --git a/doc/cache.md b/doc/cache.md
index 84b123d..a5d6340 100644
--- a/doc/cache.md
+++ b/doc/cache.md
@@ -46,7 +46,7 @@ class PostsTable extends AbstractCachedTable
 
     public function findByPk(int $id): ?Post
     {
-        return $this->createEntity($this->_findByPkCached($id));
+        return $this->_findByPkCached($id);
     }
     
     /**
@@ -54,9 +54,7 @@ class PostsTable extends AbstractCachedTable
     */
     public function findAllFeatured(): array
     {
-        return $this->createEntities($this->_findAll(
-            ['is_featured' => true],
-        ));
+        return $this->_findAll(['is_featured' => true]);
     }
     
     public function countAllFeatured(): int
diff --git a/doc/example.md b/doc/example.md
index 8709821..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->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     /**
@@ -51,9 +51,7 @@ class UsersTable extends \Composite\DB\AbstractTable
      */
     public function findAllActive(): array
     {
-        return $this->createEntities($this->_findAll(
-            ['status' => Status::ACTIVE],
-        ));
+        return $this->_findAll(['status' => Status::ACTIVE]);
     }
 
     public function countAllActive(): int
diff --git a/doc/table.md b/doc/table.md
index e6aec2e..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->_findOne($id));
+        return $this->_findByPk($id);
     }
 
     /**
@@ -46,7 +46,7 @@ class UsersTable extends AbstractTable
      */
     public function findAll(): array
     {
-        return $this->createEntities($this->_findAll());
+        return $this->_findAll();
     }
 
     public function countAll(): int
@@ -67,13 +67,12 @@ Example with internal helper:
  */
 public function findAllActiveAdults(): array
 {
-    $rows = $this->_findAll(
+    return $this->_findAll(
         new Where(
             'age > :age AND status = :status',
             ['age' => 18, 'status' => Status::ACTIVE->name],
         )
     );
-    return $this->createEntities($rows);
 }
 ```
 
@@ -84,11 +83,10 @@ Or it might be simplified to:
  */
 public function findAllActiveAdults(): array
 {
-    $rows = $this->_findAll([
+    return $this->_findAll([
         'age' => ['>', 18],
         'status' => Status:ACTIVE,
     ]);
-    return $this->createEntities($rows);
 }
 ```
 
diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 6a6022c..590403e 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -2,14 +2,14 @@
 
 namespace Composite\DB;
 
-use Composite\DB\Exceptions\DbException;
-use Composite\DB\Tests\TestStand\Entities\TestAutoincrementEntity;
 use Composite\Entity\AbstractEntity;
 use Psr\SimpleCache\CacheInterface;
 use Ramsey\Uuid\UuidInterface;
 
 abstract class AbstractCachedTable extends AbstractTable
 {
+    use SelectRawTrait;
+
     protected const CACHE_VERSION = 1;
 
     public function __construct(
@@ -94,44 +94,47 @@ private function collectCacheKeysByEntity(AbstractEntity $entity): array
     }
 
     /**
-     * @return array<string, mixed>|null
+     * @return AbstractEntity|null
      */
-    protected function _findByPkCached(mixed $pk, null|int|\DateInterval $ttl = null): ?array
+    protected function _findByPkCached(mixed $pk, null|int|\DateInterval $ttl = null): mixed
     {
         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 _findOneCached(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->_findOne($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 _findAllCached(
         array|Where $where = [],
         array|string $orderBy = [],
         ?int $limit = null,
         null|int|\DateInterval $ttl = null,
+        ?string $keyColumnName = null,
     ): array
     {
-        return $this->getCached(
+        $rows = $this->getCached(
             $this->getListCacheKey($where, $orderBy, $limit),
-            fn() => $this->_findAll(where: $where, orderBy: $orderBy, limit: $limit),
+            fn() => $this->_findAllRaw(where: $where, orderBy: $orderBy, limit: $limit),
             $ttl,
         );
+        return $this->createEntities($rows, $keyColumnName);
     }
 
     /**
@@ -165,10 +168,14 @@ protected function getCached(string $cacheKey, callable $dataCallback, null|int|
     /**
      * @param mixed[] $ids
      * @param int|\DateInterval|null $ttl
-     * @return array<array<string, mixed>>
+     * @return array<AbstractEntity>|array<array-key, AbstractEntity>
      * @throws \Psr\SimpleCache\InvalidArgumentException
      */
-    protected function _findMultiCached(array $ids, null|int|\DateInterval $ttl = null): array
+    protected function _findMultiCached(
+        array $ids,
+        null|int|\DateInterval $ttl = null,
+        ?string $keyColumnName = null,
+    ): array
     {
         $result = $cacheKeys = $foundIds = [];
         foreach ($ids as $id) {
@@ -191,7 +198,7 @@ protected function _findMultiCached(array $ids, null|int|\DateInterval $ttl = nu
                 $result[] = $row;
             }
         }
-        return $result;
+        return $this->createEntities($result, $keyColumnName);
     }
 
     /**
@@ -271,7 +278,7 @@ 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 =  (string)preg_replace('/\W/', '_', $string);
         return trim((string)preg_replace('/_+/', '_', $string), '_');
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index dad1381..df058a1 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -9,15 +9,13 @@
 use Composite\DB\Exceptions\DbException;
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
-use Doctrine\DBAL\Query\QueryBuilder;
 use Ramsey\Uuid\UuidInterface;
 
 abstract class AbstractTable
 {
-    private const COMPARISON_SIGNS = ['=', '!=', '>', '<', '>=', '<=', '<>'];
+    use SelectRawTrait;
 
     protected readonly TableConfig $config;
-    private ?QueryBuilder $selectQuery = null;
 
     abstract protected function getConfig(): TableConfig;
 
@@ -189,10 +187,10 @@ protected function _countAll(array|Where $where = []): int
     }
 
     /**
-     * @return array<string, mixed>|null
      * @throws \Doctrine\DBAL\Exception
+     * @return AbstractEntity|null
      */
-    protected function _findByPk(mixed $pk): ?array
+    protected function _findByPk(mixed $pk): mixed
     {
         $where = $this->getPkCondition($pk);
         return $this->_findOne($where);
@@ -201,55 +199,54 @@ protected function _findByPk(mixed $pk): ?array
     /**
      * @param array<string, mixed>|Where $where
      * @param array<string, string>|string $orderBy
-     * @return array<string, mixed>|null
+     * @return AbstractEntity|null
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function _findOne(array|Where $where, array|string $orderBy = []): ?array
+    protected function _findOne(array|Where $where, array|string $orderBy = []): mixed
     {
-        $query = $this->select();
-        $this->buildWhere($query, $where);
-        $this->applyOrderBy($query, $orderBy);
-        return $query->fetchAssociative() ?: null;
+        return $this->createEntity($this->_findOneRaw($where, $orderBy));
     }
 
     /**
      * @param array<int|string|array<string,mixed>> $pkList
-     * @return array<array<string, mixed>>
+     * @return array<AbstractEntity>| array<array-key, AbstractEntity>
      * @throws DbException
      * @throws \Doctrine\DBAL\Exception
      */
-    protected function _findMulti(array $pkList): array
+    protected function _findMulti(array $pkList, ?string $keyColumnName = null): array
     {
         if (!$pkList) {
             return [];
         }
         $multiSelect = new MultiSelect($this->getConnection(), $this->config, $pkList);
-        return $multiSelect->getQueryBuilder()->executeQuery()->fetchAllAssociative();
+        return $this->createEntities(
+            $multiSelect->getQueryBuilder()->executeQuery()->fetchAllAssociative(),
+            $keyColumnName,
+        );
     }
 
     /**
      * @param array<string, mixed>|Where $where
      * @param array<string, string>|string $orderBy
-     * @return list<array<string,mixed>>
-     * @throws \Doctrine\DBAL\Exception
+     * @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();
-        $this->buildWhere($query, $where);
-        $this->applyOrderBy($query, $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
@@ -279,13 +276,18 @@ final protected function createEntities(mixed $data, ?string $keyColumnName = nu
             $entityClass = $this->config->entityClass;
             $result = [];
             foreach ($data as $datum) {
-                if (!is_array($datum)) {
-                    continue;
-                }
-                if ($keyColumnName && isset($datum[$keyColumnName])) {
-                    $result[$datum[$keyColumnName]] = $entityClass::fromArray($datum);
-                } else {
-                    $result[] = $entityClass::fromArray($datum);
+                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;
+                    }
                 }
             }
         } catch (\Throwable) {
@@ -316,60 +318,6 @@ protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface
         return $condition;
     }
 
-    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
-     */
-    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], self::COMPARISON_SIGNS)) {
-                    $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);
-            }
-        }
-    }
-
     private function checkUpdatedAt(AbstractEntity $entity): void
     {
         if ($this->config->hasUpdatedAt() && property_exists($entity, 'updated_at') && $entity->updated_at === null) {
@@ -392,28 +340,4 @@ private function formatData(array $data): array
         }
         return $data;
     }
-
-    /**
-     * @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);
-                }
-            }
-        }
-    }
 }
diff --git a/src/SelectRawTrait.php b/src/SelectRawTrait.php
new file mode 100644
index 0000000..e1db259
--- /dev/null
+++ b/src/SelectRawTrait.php
@@ -0,0 +1,131 @@
+<?php declare(strict_types=1);
+
+namespace Composite\DB;
+
+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/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index f73c4ec..826ede0 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -272,8 +272,8 @@ public function test_findMulti(): void
         $table->save($e1);
         $table->save($e2);
 
-        $multi1 = $table->findMulti([$e1->id]);
-        $this->assertEquals($e1, $multi1[0]);
+        $multi1 = $table->findMulti([$e1->id], 'id');
+        $this->assertEquals($e1, $multi1[$e1->id]);
 
         $multi2 = $table->findMulti([$e1->id, $e2->id]);
         $this->assertEquals($e1, $multi2[0]);
diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index ebb3d96..fb05d76 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -40,12 +40,12 @@ protected function getFlushCacheKeys(TestAutoincrementEntity|AbstractEntity $ent
 
     public function findByPk(int $id): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->_findByPkCached($id));
+        return $this->_findByPkCached($id);
     }
 
     public function findOneByName(string $name): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->_findOneCached(['name' => $name]));
+        return $this->_findOneCached(['name' => $name]);
     }
 
     public function delete(TestAutoincrementEntity|AbstractEntity &$entity): void
@@ -61,9 +61,7 @@ public function delete(TestAutoincrementEntity|AbstractEntity &$entity): void
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAllCached(
-            new Where('name = :name', ['name' => $name])
-        ));
+        return $this->_findAllCached(new Where('name = :name', ['name' => $name]));
     }
 
     /**
@@ -71,11 +69,11 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->_findAll(
+        return $this->_findAll(
             orderBy: ['id' => 'DESC'],
             limit: $limit,
             offset: $offset,
-        ));
+        );
     }
 
     public function countAllByName(string $name): int
@@ -86,9 +84,9 @@ public function countAllByName(string $name): int
     /**
      * @return TestAutoincrementEntity[]
      */
-    public function findMulti(array $ids): array
+    public function findMulti(array $ids, ?string $keyColumnName = null): array
     {
-        return $this->createEntities($this->_findMultiCached($ids));
+        return $this->_findMultiCached(ids: $ids, keyColumnName: $keyColumnName);
     }
 
     public function truncate(): void
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index 4f432b7..c1134f0 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -40,12 +40,12 @@ protected function getFlushCacheKeys(TestAutoincrementSdEntity|AbstractEntity $e
 
     public function findByPk(int $id): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     public function findOneByName(string $name): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->_findOneCached(['name' => $name]));
+        return $this->_findOneCached(['name' => $name]);
     }
 
     public function delete(TestAutoincrementSdEntity|AbstractEntity &$entity): void
@@ -61,9 +61,7 @@ public function delete(TestAutoincrementSdEntity|AbstractEntity &$entity): void
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAllCached(
-            new Where('name = :name', ['name' => $name, 'deleted_at' => null]),
-        ));
+        return $this->_findAllCached(new Where('name = :name', ['name' => $name, 'deleted_at' => null]));
     }
 
     /**
@@ -71,11 +69,11 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->_findAll(
+        return $this->_findAll(
             orderBy: 'id DESC',
             limit: $limit,
             offset: $offset,
-        ));
+        );
     }
 
     public function countAllByName(string $name): int
diff --git a/tests/TestStand/Tables/TestAutoincrementSdTable.php b/tests/TestStand/Tables/TestAutoincrementSdTable.php
index 519f063..f878db9 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdTable.php
@@ -21,12 +21,12 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     public function findOneByName(string $name): ?TestAutoincrementSdEntity
     {
-        return $this->createEntity($this->_findOne(['name' => $name, 'deleted_at' => null]));
+        return $this->_findOne(['name' => $name, 'deleted_at' => null]);
     }
 
     /**
@@ -34,9 +34,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAll(
-            new Where('name = :name', ['name' => $name])
-        ));
+        return $this->_findAll(new Where('name = :name', ['name' => $name]));
     }
 
     /**
@@ -44,12 +42,12 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->_findAll(
+        return $this->_findAll(
             where: ['deleted_at' => null],
             orderBy: 'id DESC',
             limit: $limit,
             offset: $offset,
-        ));
+        );
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php
index de0e994..7b6ac40 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -24,12 +24,12 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     public function findOneByName(string $name): ?TestAutoincrementEntity
     {
-        return $this->createEntity($this->_findOne(['name' => $name]));
+        return $this->_findOne(['name' => $name]);
     }
 
     public function delete(AbstractEntity|TestAutoincrementEntity &$entity): void
@@ -45,10 +45,10 @@ public function delete(AbstractEntity|TestAutoincrementEntity &$entity): void
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAll(
+        return $this->_findAll(
             where: new Where('name = :name', ['name' => $name]),
             orderBy: 'id',
-        ));
+        );
     }
 
     /**
@@ -56,11 +56,11 @@ public function findAllByName(string $name): array
      */
     public function findRecent(int $limit, int $offset): array
     {
-        return $this->createEntities($this->_findAll(
+        return $this->_findAll(
             orderBy: ['id' => 'DESC'],
             limit: $limit,
             offset: $offset,
-        ));
+        );
     }
 
     public function countAllByName(string $name): int
@@ -75,7 +75,7 @@ public function countAllByName(string $name): int
      */
     public function findMulti(array $ids): array
     {
-        return $this->createEntities($this->_findMulti($ids), 'id');
+        return $this->_findMulti($ids, 'id');
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestCompositeCachedTable.php b/tests/TestStand/Tables/TestCompositeCachedTable.php
index 57f3203..6b5996a 100644
--- a/tests/TestStand/Tables/TestCompositeCachedTable.php
+++ b/tests/TestStand/Tables/TestCompositeCachedTable.php
@@ -30,10 +30,10 @@ protected function getFlushCacheKeys(TestCompositeEntity|AbstractEntity $entity)
 
     public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
     {
-        return $this->createEntity($this->_findOneCached([
+        return $this->_findOneCached([
             'user_id' => $user_id,
             'post_id' => $post_id,
-        ]));
+        ]);
     }
 
     /**
@@ -41,10 +41,7 @@ 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->_findAllCached(['user_id' => $userId])
-        );
+        return $this->_findAllCached(['user_id' => $userId]);
     }
 
     public function countAllByUser(int $userId): int
diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index 2fe6910..64e90d4 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -33,7 +33,7 @@ public function delete(AbstractEntity|TestCompositeEntity &$entity): void
 
     public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
     {
-        return $this->createEntity($this->_findOne(['user_id' => $user_id, 'post_id' => $post_id]));
+        return $this->_findOne(['user_id' => $user_id, 'post_id' => $post_id]);
     }
 
     /**
@@ -41,7 +41,7 @@ public function findOne(int $user_id, int $post_id): ?TestCompositeEntity
      */
     public function findAllByUser(int $userId): array
     {
-        return $this->createEntities($this->_findAll(['user_id' => $userId, 'status' => TestUnitEnum::ACTIVE]));
+        return $this->_findAll(['user_id' => $userId, 'status' => TestUnitEnum::ACTIVE]);
     }
 
     public function countAllByUser(int $userId): int
@@ -56,7 +56,7 @@ public function countAllByUser(int $userId): int
      */
     public function findMulti(array $ids): array
     {
-        return $this->createEntities($this->_findMulti($ids), 'post_id');
+        return $this->_findMulti($ids, 'post_id');
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestOptimisticLockTable.php b/tests/TestStand/Tables/TestOptimisticLockTable.php
index 375a61a..1ede3bc 100644
--- a/tests/TestStand/Tables/TestOptimisticLockTable.php
+++ b/tests/TestStand/Tables/TestOptimisticLockTable.php
@@ -21,7 +21,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(int $id): ?TestOptimisticLockEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     public function init(): bool
diff --git a/tests/TestStand/Tables/TestUniqueCachedTable.php b/tests/TestStand/Tables/TestUniqueCachedTable.php
index cd7c901..8fe714f 100644
--- a/tests/TestStand/Tables/TestUniqueCachedTable.php
+++ b/tests/TestStand/Tables/TestUniqueCachedTable.php
@@ -33,7 +33,7 @@ protected function getFlushCacheKeys(TestUniqueEntity|AbstractEntity $entity): a
 
     public function findByPk(UuidInterface $id): ?TestUniqueEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     /**
@@ -41,9 +41,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAllCached(
-            new Where('name = :name', ['name' => $name])
-        ));
+        return $this->_findAllCached(new Where('name = :name', ['name' => $name]));
     }
 
     public function countAllByName(string $name): int
diff --git a/tests/TestStand/Tables/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 0ad6264..8353194 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -34,7 +34,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(UuidInterface $id): ?TestUniqueEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     /**
@@ -42,7 +42,7 @@ public function findByPk(UuidInterface $id): ?TestUniqueEntity
      */
     public function findAllByName(string $name): array
     {
-        return $this->createEntities($this->_findAll(['name' => $name, 'status' => TestBackedEnum::ACTIVE]));
+        return $this->_findAll(['name' => $name, 'status' => TestBackedEnum::ACTIVE]);
     }
 
     public function countAllByName(string $name): int
diff --git a/tests/TestStand/Tables/TestUpdateAtTable.php b/tests/TestStand/Tables/TestUpdateAtTable.php
index 48c7c2a..ea114bd 100644
--- a/tests/TestStand/Tables/TestUpdateAtTable.php
+++ b/tests/TestStand/Tables/TestUpdateAtTable.php
@@ -21,7 +21,7 @@ protected function getConfig(): TableConfig
 
     public function findByPk(string $id): ?TestUpdatedAtEntity
     {
-        return $this->createEntity($this->_findByPk($id));
+        return $this->_findByPk($id);
     }
 
     public function init(): bool

From 04d24a2fe69236818538d9ca4be8483681d603f6 Mon Sep 17 00:00:00 2001
From: Composite PHP <38870693+compositephp@users.noreply.github.com>
Date: Wed, 22 Nov 2023 10:31:04 +0000
Subject: [PATCH 59/68] Fix composite:generate-table example

---
 doc/migrations.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/doc/migrations.md b/doc/migrations.md
index d6180e4..a5c65f5 100644
--- a/doc/migrations.md
+++ b/doc/migrations.md
@@ -77,7 +77,7 @@ The command examines the specific Entity and generates a [Table](https://github.
 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 connection_name TableName 'App\Models\EntityName'
+php cli.php composite:generate-table 'App\Models\EntityName'
 ```
 
 | Argument  | Required | Description                                   |
@@ -90,4 +90,4 @@ Options:
 | Option   | Description                                |
 |----------|--------------------------------------------|
 | --cached | Generate cached version of PHP Table class |
-| --force  | Overwrite existing file                    |
\ No newline at end of file
+| --force  | Overwrite existing file                    |

From f107e5cc402fc68ac7747c6ed5a1a6cc5d366e75 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sat, 9 Dec 2023 16:57:41 +0000
Subject: [PATCH 60/68] Add migration commands description to documentation

---
 doc/migrations.md | 85 +++++++++++++++++++++++++++++++++++++++++------
 1 file changed, 75 insertions(+), 10 deletions(-)

diff --git a/doc/migrations.md b/doc/migrations.md
index a5c65f5..f772d25 100644
--- a/doc/migrations.md
+++ b/doc/migrations.md
@@ -2,9 +2,9 @@
 
 > **_NOTE:_**  This is experimental feature
 
-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. 
+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
@@ -26,6 +26,9 @@ You need to configure ConnectionManager, see instructions [here](configuration.m
 ### 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
 
@@ -40,9 +43,15 @@ use Symfony\Component\Console\Application;
 
 //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(),
 ]);
@@ -50,6 +59,62 @@ $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.
@@ -67,9 +132,9 @@ php cli.php composite:generate-entity connection_name TableName 'App\Models\Enti
 
 Options:
 
-| Option  | Description             |
-|---------|-------------------------|
-| --force | Overwrite existing file |
+| Option  | Short | Description             |
+|---------|-------|-------------------------|
+| --force | -f    | Overwrite existing file |
 
 * ### composite:generate-table
 
@@ -87,7 +152,7 @@ php cli.php composite:generate-table 'App\Models\EntityName'
 
 Options:
 
-| Option   | Description                                |
-|----------|--------------------------------------------|
-| --cached | Generate cached version of PHP Table class |
-| --force  | Overwrite existing file                    |
+| Option   | Short | Description                                |
+|----------|-------|--------------------------------------------|
+| --cached | -c    | Generate cached version of PHP Table class |
+| --force  | -f    | Overwrite existing file                    |
\ No newline at end of file

From 1ae4801a62d93ab74ac66e0abd2322b4b9c46821 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sat, 23 Dec 2023 14:30:49 +0000
Subject: [PATCH 61/68] Bump composite/entity dependency version

---
 composer.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/composer.json b/composer.json
index 5bfced2..ef84c75 100644
--- a/composer.json
+++ b/composer.json
@@ -15,7 +15,7 @@
         "php": "^8.1",
         "ext-pdo": "*",
         "psr/simple-cache":  "1 - 3",
-        "compositephp/entity": "^0.1.9",
+        "compositephp/entity": "^v0.1.11",
         "doctrine/dbal": "^3.5"
     },
     "require-dev": {

From ba500606bece7c413715473d95e9e219f2fc54c8 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sat, 23 Dec 2023 14:42:39 +0000
Subject: [PATCH 62/68] Performance optimizations

---
 src/AbstractCachedTable.php                   |  6 +-
 src/AbstractTable.php                         | 96 ++++++++++---------
 src/Helpers/DatabaseSpecificTrait.php         | 60 ++++++++++++
 src/{ => Helpers}/SelectRawTrait.php          |  3 +-
 src/MultiQuery/MultiInsert.php                | 18 +++-
 tests/MultiQuery/MultiInsertTest.php          | 10 +-
 tests/Table/AbstractTableTest.php             | 12 ++-
 .../Tables/TestAutoincrementCachedTable.php   |  2 +-
 .../Tables/TestAutoincrementSdCachedTable.php |  2 +-
 .../Tables/TestAutoincrementTable.php         |  2 +-
 tests/TestStand/Tables/TestCompositeTable.php |  4 +-
 tests/TestStand/Tables/TestMySQLTable.php     | 25 +++++
 tests/TestStand/Tables/TestPostgresTable.php  | 25 +++++
 tests/TestStand/Tables/TestUniqueTable.php    |  2 +-
 14 files changed, 204 insertions(+), 63 deletions(-)
 create mode 100644 src/Helpers/DatabaseSpecificTrait.php
 rename src/{ => Helpers}/SelectRawTrait.php (98%)
 create mode 100644 tests/TestStand/Tables/TestMySQLTable.php
 create mode 100644 tests/TestStand/Tables/TestPostgresTable.php

diff --git a/src/AbstractCachedTable.php b/src/AbstractCachedTable.php
index 590403e..24ff5f9 100644
--- a/src/AbstractCachedTable.php
+++ b/src/AbstractCachedTable.php
@@ -8,7 +8,7 @@
 
 abstract class AbstractCachedTable extends AbstractTable
 {
-    use SelectRawTrait;
+    use Helpers\SelectRawTrait;
 
     protected const CACHE_VERSION = 1;
 
@@ -26,7 +26,7 @@ abstract protected function getFlushCacheKeys(AbstractEntity $entity): array;
     /**
      * @throws \Throwable
      */
-    public function save(AbstractEntity &$entity): void
+    public function save(AbstractEntity $entity): void
     {
         $cacheKeys = $this->collectCacheKeysByEntity($entity);
         parent::save($entity);
@@ -54,7 +54,7 @@ public function saveMany(array $entities): void
     /**
      * @throws \Throwable
      */
-    public function delete(AbstractEntity &$entity): void
+    public function delete(AbstractEntity $entity): void
     {
         $cacheKeys = $this->collectCacheKeysByEntity($entity);
         parent::delete($entity);
diff --git a/src/AbstractTable.php b/src/AbstractTable.php
index df058a1..e144cd3 100644
--- a/src/AbstractTable.php
+++ b/src/AbstractTable.php
@@ -2,21 +2,22 @@
 
 namespace Composite\DB;
 
+use Composite\DB\Exceptions\DbException;
 use Composite\DB\MultiQuery\MultiInsert;
 use Composite\DB\MultiQuery\MultiSelect;
-use Composite\Entity\Helpers\DateTimeHelper;
 use Composite\Entity\AbstractEntity;
-use Composite\DB\Exceptions\DbException;
+use Composite\Entity\Helpers\DateTimeHelper;
 use Doctrine\DBAL\Connection;
-use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
 use Ramsey\Uuid\UuidInterface;
 
 abstract class AbstractTable
 {
-    use SelectRawTrait;
+    use Helpers\SelectRawTrait;
+    use Helpers\DatabaseSpecificTrait;
 
     protected readonly TableConfig $config;
 
+
     abstract protected function getConfig(): TableConfig;
 
     public function __construct()
@@ -44,49 +45,51 @@ 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();
             $this->checkUpdatedAt($entity);
 
-            $insertData = $this->formatData($entity->toArray());
+            $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);
-
+            $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')) {
-                $where['lock_version'] = $entity->getVersion();
+                $whereParams['lock_version'] = $entity->getVersion();
                 $entity->incrementVersion();
                 $changedColumns['lock_version'] = $entity->getVersion();
             }
-            $entityUpdated = $connection->update(
-                table: $this->getTableName(),
-                data: $changedColumns,
-                criteria: $where,
+            $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)));
+
+            $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);
         }
     }
 
@@ -101,7 +104,7 @@ public function saveMany(array $entities): void
             if ($entity->isNew()) {
                 $this->config->checkEntity($entity);
                 $this->checkUpdatedAt($entity);
-                $rowsToInsert[] = $this->formatData($entity->toArray());
+                $rowsToInsert[] = $this->prepareDataForSql($entity->toArray());
                 unset($entities[$i]);
             }
         }
@@ -113,14 +116,15 @@ public function saveMany(array $entities): void
             }
             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()) {
-                        $stmt = $this->getConnection()->prepare($multiInsert->getSql());
-                        $stmt->executeQuery($multiInsert->getParameters());
+                        $connection->executeStatement($multiInsert->getSql(), $multiInsert->getParameters());
                     }
                 }
             }
@@ -135,7 +139,7 @@ public function saveMany(array $entities): void
      * @param AbstractEntity $entity
      * @throws \Throwable
      */
-    public function delete(AbstractEntity &$entity): void
+    public function delete(AbstractEntity $entity): void
     {
         $this->config->checkEntity($entity);
         if ($this->config->hasSoftDelete()) {
@@ -144,8 +148,12 @@ public function delete(AbstractEntity &$entity): void
                 $this->save($entity);
             }
         } else {
-            $where = $this->getPkCondition($entity);
-            $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),
+            );
         }
     }
 
@@ -192,8 +200,15 @@ protected function _countAll(array|Where $where = []): int
      */
     protected function _findByPk(mixed $pk): mixed
     {
-        $where = $this->getPkCondition($pk);
-        return $this->_findOne($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);
     }
 
     /**
@@ -304,7 +319,14 @@ protected function getPkCondition(int|string|array|AbstractEntity|UuidInterface
     {
         $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) {
@@ -324,20 +346,4 @@ private function checkUpdatedAt(AbstractEntity $entity): void
             $entity->updated_at = new \DateTimeImmutable();
         }
     }
-
-    /**
-     * @param array<string, mixed> $data
-     * @return array<string, mixed>
-     * @throws \Doctrine\DBAL\Exception
-     */
-    private function formatData(array $data): array
-    {
-        $supportsBoolean = $this->getConnection()->getDatabasePlatform() instanceof PostgreSQLPlatform;
-        foreach ($data as $columnName => $value) {
-            if (is_bool($value) && !$supportsBoolean) {
-                $data[$columnName] = $value ? 1 : 0;
-            }
-        }
-        return $data;
-    }
 }
diff --git a/src/Helpers/DatabaseSpecificTrait.php b/src/Helpers/DatabaseSpecificTrait.php
new file mode 100644
index 0000000..14a72ff
--- /dev/null
+++ b/src/Helpers/DatabaseSpecificTrait.php
@@ -0,0 +1,60 @@
+<?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) {
+            return implode('.', array_map(fn ($part) => "`$part`", explode('.', $key)));
+        } else {
+            return '"' . $key . '"';
+        }
+    }
+}
diff --git a/src/SelectRawTrait.php b/src/Helpers/SelectRawTrait.php
similarity index 98%
rename from src/SelectRawTrait.php
rename to src/Helpers/SelectRawTrait.php
index e1db259..b02bb89 100644
--- a/src/SelectRawTrait.php
+++ b/src/Helpers/SelectRawTrait.php
@@ -1,7 +1,8 @@
 <?php declare(strict_types=1);
 
-namespace Composite\DB;
+namespace Composite\DB\Helpers;
 
+use Composite\DB\Where;
 use Doctrine\DBAL\Query\QueryBuilder;
 
 trait SelectRawTrait
diff --git a/src/MultiQuery/MultiInsert.php b/src/MultiQuery/MultiInsert.php
index 04de6a8..bfd3fe0 100644
--- a/src/MultiQuery/MultiInsert.php
+++ b/src/MultiQuery/MultiInsert.php
@@ -2,8 +2,14 @@
 
 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 = [];
@@ -12,13 +18,14 @@ class MultiInsert
      * @param string $tableName
      * @param list<array<string, mixed>> $rows
      */
-    public function __construct(string $tableName, array $rows) {
+    public function __construct(Connection $connection, string $tableName, array $rows) {
         if (!$rows) {
             return;
         }
+        $this->connection = $connection;
         $firstRow = reset($rows);
-        $columnNames = array_map(fn($columnName) => "`$columnName`", array_keys($firstRow));
-        $this->sql = "INSERT INTO `$tableName` (" . implode(', ', $columnNames) . ") VALUES ";
+        $columnNames = array_map(fn ($columnName) => $this->escapeIdentifier($columnName), array_keys($firstRow));
+        $this->sql = "INSERT INTO " . $this->escapeIdentifier($tableName)  . " (" . implode(', ', $columnNames) . ") VALUES ";
         $valuesSql = [];
 
         $index = 0;
@@ -47,4 +54,9 @@ public function getParameters(): array
     {
         return $this->parameters;
     }
+
+    private function getConnection(): Connection
+    {
+        return $this->connection;
+    }
 }
\ No newline at end of file
diff --git a/tests/MultiQuery/MultiInsertTest.php b/tests/MultiQuery/MultiInsertTest.php
index e2c1237..1cf09e4 100644
--- a/tests/MultiQuery/MultiInsertTest.php
+++ b/tests/MultiQuery/MultiInsertTest.php
@@ -2,6 +2,7 @@
 
 namespace Composite\DB\Tests\MultiQuery;
 
+use Composite\DB\ConnectionManager;
 use Composite\DB\MultiQuery\MultiInsert;
 
 class MultiInsertTest extends \PHPUnit\Framework\TestCase
@@ -11,7 +12,8 @@ class MultiInsertTest extends \PHPUnit\Framework\TestCase
      */
     public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expectedParameters)
     {
-        $multiInserter = new MultiInsert($tableName, $rows);
+        $connection = ConnectionManager::getConnection('sqlite');
+        $multiInserter = new MultiInsert($connection, $tableName, $rows);
 
         $this->assertEquals($expectedSql, $multiInserter->getSql());
         $this->assertEquals($expectedParameters, $multiInserter->getParameters());
@@ -31,7 +33,7 @@ public static function multiInsertQuery_dataProvider()
                 [
                     ['a' => 'value1_1', 'b' => 'value2_1'],
                 ],
-                "INSERT INTO `testTable` (`a`, `b`) VALUES (:a0, :b0);",
+                'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0);',
                 ['a0' => 'value1_1', 'b0' => 'value2_1']
             ],
             [
@@ -40,7 +42,7 @@ public static function multiInsertQuery_dataProvider()
                     ['a' => 'value1_1', 'b' => 'value2_1'],
                     ['a' => 'value1_2', 'b' => 'value2_2']
                 ],
-                "INSERT INTO `testTable` (`a`, `b`) VALUES (:a0, :b0), (:a1, :b1);",
+                'INSERT INTO "testTable" ("a", "b") VALUES (:a0, :b0), (:a1, :b1);',
                 ['a0' => 'value1_1', 'b0' => 'value2_1', 'a1' => 'value1_2', 'b1' => 'value2_2']
             ],
             [
@@ -49,7 +51,7 @@ public static function multiInsertQuery_dataProvider()
                     ['column1' => 'value1_1'],
                     ['column1' => 123]
                 ],
-                "INSERT INTO `testTable` (`column1`) VALUES (:column10), (:column11);",
+                'INSERT INTO "testTable" ("column1") VALUES (:column10), (:column11);',
                 ['column10' => 'value1_1', 'column11' => 123]
             ]
         ];
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index cb48987..f5e77ce 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -98,7 +98,7 @@ public function test_illegalCreateEntity(): void
     /**
      * @dataProvider buildWhere_dataProvider
      */
-    public function test_buildWhere($where, $expectedSQL, $expectedParams)
+    public function test_buildWhere($where, $expectedSQL, $expectedParams): void
     {
         $table = new Tables\TestStrictTable();
 
@@ -190,4 +190,14 @@ public static function buildWhere_dataProvider(): array
             ]
         ];
     }
+
+    public function test_databaseSpecific(): void
+    {
+        $mySQLTable = new Tables\TestMySQLTable();
+        $this->assertEquals('`column`', $mySQLTable->escapeIdentifierPub('column'));
+        $this->assertEquals('`Database`.`Table`', $mySQLTable->escapeIdentifierPub('Database.Table'));
+
+        $postgresTable = new Tables\TestPostgresTable();
+        $this->assertEquals('"column"', $postgresTable->escapeIdentifierPub('column'));
+    }
 }
\ No newline at end of file
diff --git a/tests/TestStand/Tables/TestAutoincrementCachedTable.php b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
index fb05d76..1fba645 100644
--- a/tests/TestStand/Tables/TestAutoincrementCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementCachedTable.php
@@ -48,7 +48,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
         return $this->_findOneCached(['name' => $name]);
     }
 
-    public function delete(TestAutoincrementEntity|AbstractEntity &$entity): void
+    public function delete(TestAutoincrementEntity|AbstractEntity $entity): void
     {
         if ($entity->name === 'Exception') {
             throw new \Exception('Test Exception');
diff --git a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
index c1134f0..07ba91d 100644
--- a/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementSdCachedTable.php
@@ -48,7 +48,7 @@ public function findOneByName(string $name): ?TestAutoincrementSdEntity
         return $this->_findOneCached(['name' => $name]);
     }
 
-    public function delete(TestAutoincrementSdEntity|AbstractEntity &$entity): void
+    public function delete(TestAutoincrementSdEntity|AbstractEntity $entity): void
     {
         if ($entity->name === 'Exception') {
             throw new \Exception('Test Exception');
diff --git a/tests/TestStand/Tables/TestAutoincrementTable.php b/tests/TestStand/Tables/TestAutoincrementTable.php
index 7b6ac40..bef47de 100644
--- a/tests/TestStand/Tables/TestAutoincrementTable.php
+++ b/tests/TestStand/Tables/TestAutoincrementTable.php
@@ -32,7 +32,7 @@ public function findOneByName(string $name): ?TestAutoincrementEntity
         return $this->_findOne(['name' => $name]);
     }
 
-    public function delete(AbstractEntity|TestAutoincrementEntity &$entity): void
+    public function delete(AbstractEntity|TestAutoincrementEntity $entity): void
     {
         if ($entity->name === 'Exception') {
             throw new \Exception('Test Exception');
diff --git a/tests/TestStand/Tables/TestCompositeTable.php b/tests/TestStand/Tables/TestCompositeTable.php
index 64e90d4..8a1e9da 100644
--- a/tests/TestStand/Tables/TestCompositeTable.php
+++ b/tests/TestStand/Tables/TestCompositeTable.php
@@ -15,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');
@@ -23,7 +23,7 @@ public function save(AbstractEntity|TestCompositeEntity &$entity): void
         parent::save($entity);
     }
 
-    public function delete(AbstractEntity|TestCompositeEntity &$entity): void
+    public function delete(AbstractEntity|TestCompositeEntity $entity): void
     {
         if ($entity->message === 'Exception') {
             throw new \Exception('Test Exception');
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/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/TestUniqueTable.php b/tests/TestStand/Tables/TestUniqueTable.php
index 8353194..9387104 100644
--- a/tests/TestStand/Tables/TestUniqueTable.php
+++ b/tests/TestStand/Tables/TestUniqueTable.php
@@ -19,7 +19,7 @@ public function __construct()
         $this->init();
     }
 
-    public function save(AbstractEntity|TestUniqueEntity &$entity): void
+    public function save(AbstractEntity|TestUniqueEntity $entity): void
     {
         if ($entity->name === 'Exception') {
             throw new \Exception('Test Exception');

From 18fba46f0093415c3d6fa4bcefc0909a0f581750 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sat, 23 Dec 2023 19:45:41 +0000
Subject: [PATCH 63/68] Minor fix in ConnectionManager::loadConfigs

---
 src/ConnectionManager.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php
index aeb463f..f79bcad 100644
--- a/src/ConnectionManager.php
+++ b/src/ConnectionManager.php
@@ -67,7 +67,7 @@ private static function loadConfigs(): array
                 $configFile
             ));
         }
-        $configFileContent = require_once $configFile;
+        $configFileContent = require $configFile;
         if (empty($configFileContent) || !is_array($configFileContent)) {
             throw new DbException(sprintf(
                 'Connections config file `%s` should return array of connection params',

From 7efbe27d9bfa4cae31abd8f5c67412cf429745d3 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Mon, 25 Dec 2023 10:22:12 +0000
Subject: [PATCH 64/68] Optimize DatabaseSpecificTrait::escapeIdentifier

---
 src/Helpers/DatabaseSpecificTrait.php | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/Helpers/DatabaseSpecificTrait.php b/src/Helpers/DatabaseSpecificTrait.php
index 14a72ff..7769175 100644
--- a/src/Helpers/DatabaseSpecificTrait.php
+++ b/src/Helpers/DatabaseSpecificTrait.php
@@ -52,7 +52,11 @@ protected function escapeIdentifier(string $key): string
     {
         $this->identifyPlatform();
         if ($this->isMySQL) {
-            return implode('.', array_map(fn ($part) => "`$part`", explode('.', $key)));
+            if (strpos($key, '.')) {
+                return implode('.', array_map(fn ($part) => "`$part`", explode('.', $key)));
+            } else {
+                return "`$key`";
+            }
         } else {
             return '"' . $key . '"';
         }

From 5830b1ef542845e1b49aea09c257efb644090694 Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Wed, 27 Dec 2023 12:01:17 +0000
Subject: [PATCH 65/68] Remove ORM mentions from the README

---
 README.md     | 29 ++++++++---------------------
 composer.json |  2 +-
 2 files changed, 9 insertions(+), 22 deletions(-)

diff --git a/README.md b/README.md
index 0385896..256a728 100644
--- a/README.md
+++ b/README.md
@@ -3,49 +3,36 @@
 [![Build Status](https://github.com/compositephp/db/actions/workflows/main.yml/badge.svg)](https://github.com/compositephp/db/actions)
 [![Codecov](https://codecov.io/gh/compositephp/db/branch/master/graph/badge.svg)](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** - synchronise your php entities with database tables
 
-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
-
 ## Requirements
 
 * PHP 8.1+
@@ -180,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 ef84c75..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",

From 0945254f53f4450905c925e206ad92746c6e978b Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sat, 13 Jul 2024 14:46:06 +0100
Subject: [PATCH 66/68] Add CombinedTransaction::try()

---
 src/CombinedTransaction.php             | 13 +++++++++++++
 tests/Table/CombinedTransactionTest.php | 14 ++++++++++++++
 2 files changed, 27 insertions(+)

diff --git a/src/CombinedTransaction.php b/src/CombinedTransaction.php
index bb8a4a5..bb5a590 100644
--- a/src/CombinedTransaction.php
+++ b/src/CombinedTransaction.php
@@ -54,6 +54,19 @@ public function delete(AbstractTable $table, AbstractEntity &$entity): void
         });
     }
 
+    /**
+     * @throws Exceptions\DbException
+     */
+    public function try(callable $callback): void
+    {
+        try {
+            $callback();
+        } catch (\Throwable $e) {
+            $this->rollback();
+            throw new Exceptions\DbException($e->getMessage(), 500, $e);
+        }
+    }
+
     public function rollback(): void
     {
         foreach ($this->transactions as $connection) {
diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php
index 1e67a2c..78f3ef2 100644
--- a/tests/Table/CombinedTransactionTest.php
+++ b/tests/Table/CombinedTransactionTest.php
@@ -130,6 +130,20 @@ public function test_failedDelete(): void
         $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();

From a869f6be4537951cd9865a522e6b4519016e367e Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Sun, 3 Nov 2024 22:47:44 +0000
Subject: [PATCH 67/68] Fix Docrtine DBAL deprecation notice, initialize Config
 and set DefaultSchemaManagerFactory

---
 src/ConnectionManager.php | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php
index f79bcad..7854ed6 100644
--- a/src/ConnectionManager.php
+++ b/src/ConnectionManager.php
@@ -8,6 +8,7 @@
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\DriverManager;
 use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory;
 
 class ConnectionManager
 {
@@ -24,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,

From 20a1fc502d676e23ab378ad86b59363115a3c39d Mon Sep 17 00:00:00 2001
From: Composite PHP <comositephp@gmail.com>
Date: Mon, 23 Dec 2024 22:43:26 +0000
Subject: [PATCH 68/68] Adapt tests to php 8.4

---
 phpunit.xml                                  |  4 ++++
 tests/Attributes/PrimaryKeyAttributeTest.php |  5 ++---
 tests/Connection/ConnectionManagerTest.php   |  5 ++---
 tests/MultiQuery/MultiInsertTest.php         |  5 ++---
 tests/Table/AbstractCachedTableTest.php      | 23 +++++---------------
 tests/Table/AbstractTableTest.php            |  9 +++-----
 tests/Table/AutoIncrementTableTest.php       |  3 ++-
 tests/Table/CombinedTransactionTest.php      |  5 ++---
 tests/Table/CompositeTableTest.php           |  4 +++-
 tests/Table/UniqueTableTest.php              |  3 ++-
 10 files changed, 28 insertions(+), 38 deletions(-)

diff --git a/phpunit.xml b/phpunit.xml
index 9b42ad0..1389feb 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -3,6 +3,10 @@
          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>
diff --git a/tests/Attributes/PrimaryKeyAttributeTest.php b/tests/Attributes/PrimaryKeyAttributeTest.php
index 750cd6a..1fd6c72 100644
--- a/tests/Attributes/PrimaryKeyAttributeTest.php
+++ b/tests/Attributes/PrimaryKeyAttributeTest.php
@@ -5,6 +5,7 @@
 use Composite\DB\TableConfig;
 use Composite\Entity\AbstractEntity;
 use Composite\DB\Attributes;
+use PHPUnit\Framework\Attributes\DataProvider;
 
 final class PrimaryKeyAttributeTest extends \PHPUnit\Framework\TestCase
 {
@@ -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
index 7d634ff..d17d625 100644
--- a/tests/Connection/ConnectionManagerTest.php
+++ b/tests/Connection/ConnectionManagerTest.php
@@ -5,6 +5,7 @@
 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
 {
@@ -42,9 +43,7 @@ public static function invalidConfig_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider invalidConfig_dataProvider
-     */
+    #[DataProvider('invalidConfig_dataProvider')]
     public function test_invalidConfig(string $configPath): void
     {
         $reflection = new \ReflectionClass(ConnectionManager::class);
diff --git a/tests/MultiQuery/MultiInsertTest.php b/tests/MultiQuery/MultiInsertTest.php
index 1cf09e4..69631a9 100644
--- a/tests/MultiQuery/MultiInsertTest.php
+++ b/tests/MultiQuery/MultiInsertTest.php
@@ -4,12 +4,11 @@
 
 use Composite\DB\ConnectionManager;
 use Composite\DB\MultiQuery\MultiInsert;
+use PHPUnit\Framework\Attributes\DataProvider;
 
 class MultiInsertTest extends \PHPUnit\Framework\TestCase
 {
-    /**
-     * @dataProvider multiInsertQuery_dataProvider
-     */
+    #[DataProvider('multiInsertQuery_dataProvider')]
     public function test_multiInsertQuery($tableName, $rows, $expectedSql, $expectedParameters)
     {
         $connection = ConnectionManager::getConnection('sqlite');
diff --git a/tests/Table/AbstractCachedTableTest.php b/tests/Table/AbstractCachedTableTest.php
index 826ede0..d9e706e 100644
--- a/tests/Table/AbstractCachedTableTest.php
+++ b/tests/Table/AbstractCachedTableTest.php
@@ -3,13 +3,12 @@
 namespace Composite\DB\Tests\Table;
 
 use Composite\DB\AbstractCachedTable;
-use Composite\DB\AbstractTable;
-use Composite\DB\Exceptions\DbException;
 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 \PHPUnit\Framework\TestCase
@@ -43,9 +42,7 @@ public static function getOneCacheKey_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider getOneCacheKey_dataProvider
-     */
+    #[DataProvider('getOneCacheKey_dataProvider')]
     public function test_getOneCacheKey(AbstractCachedTable $table, AbstractEntity $object, string $expected): void
     {
         $reflectionMethod = new \ReflectionMethod($table, 'getOneCacheKey');
@@ -88,9 +85,7 @@ public static function getCountCacheKey_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider getCountCacheKey_dataProvider
-     */
+    #[DataProvider('getCountCacheKey_dataProvider')]
     public function test_getCountCacheKey(array|Where $where, string $expected): void
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
@@ -159,9 +154,7 @@ public static function getListCacheKey_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider getListCacheKey_dataProvider
-     */
+    #[DataProvider('getListCacheKey_dataProvider')]
     public function test_getListCacheKey(array|Where $where, array $orderBy, ?int $limit, string $expected): void
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
@@ -197,9 +190,7 @@ public static function getCustomCacheKey_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider getCustomCacheKey_dataProvider
-     */
+    #[DataProvider('getCustomCacheKey_dataProvider')]
     public function test_getCustomCacheKey(array $parts, string $expected): void
     {
         $table = new Tables\TestAutoincrementCachedTable(Helpers\CacheHelper::getCache());
@@ -253,9 +244,7 @@ public static function collectCacheKeysByEntity_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider collectCacheKeysByEntity_dataProvider
-     */
+    #[DataProvider('collectCacheKeysByEntity_dataProvider')]
     public function test_collectCacheKeysByEntity(AbstractEntity $entity, AbstractCachedTable $table, array $expected): void
     {
         $reflectionMethod = new \ReflectionMethod($table, 'collectCacheKeysByEntity');
diff --git a/tests/Table/AbstractTableTest.php b/tests/Table/AbstractTableTest.php
index f5e77ce..c14e58a 100644
--- a/tests/Table/AbstractTableTest.php
+++ b/tests/Table/AbstractTableTest.php
@@ -7,6 +7,7 @@
 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;
 
@@ -54,9 +55,7 @@ public static function getPkCondition_dataProvider(): array
         ];
     }
 
-    /**
-     * @dataProvider getPkCondition_dataProvider
-     */
+    #[DataProvider('getPkCondition_dataProvider')]
     public function test_getPkCondition(AbstractTable $table, int|string|array|AbstractEntity|UuidInterface $object, array $expected): void
     {
         $reflectionMethod = new \ReflectionMethod($table, 'getPkCondition');
@@ -95,9 +94,7 @@ public function test_illegalCreateEntity(): void
         $this->assertEmpty($empty);
     }
 
-    /**
-     * @dataProvider buildWhere_dataProvider
-     */
+    #[DataProvider('buildWhere_dataProvider')]
     public function test_buildWhere($where, $expectedSQL, $expectedParams): void
     {
         $table = new Tables\TestStrictTable();
diff --git a/tests/Table/AutoIncrementTableTest.php b/tests/Table/AutoIncrementTableTest.php
index 98690f2..03a7569 100644
--- a/tests/Table/AutoIncrementTableTest.php
+++ b/tests/Table/AutoIncrementTableTest.php
@@ -9,6 +9,7 @@
 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 \PHPUnit\Framework\TestCase
 {
@@ -36,8 +37,8 @@ public static function crud_dataProvider(): array
 
     /**
      * @param class-string<Entities\TestAutoincrementEntity|Entities\TestAutoincrementSdEntity> $class
-     * @dataProvider crud_dataProvider
      */
+    #[DataProvider('crud_dataProvider')]
     public function test_crud(AbstractTable&IAutoincrementTable $table, string $class): void
     {
         $table->truncate();
diff --git a/tests/Table/CombinedTransactionTest.php b/tests/Table/CombinedTransactionTest.php
index 78f3ef2..f7ba7d9 100644
--- a/tests/Table/CombinedTransactionTest.php
+++ b/tests/Table/CombinedTransactionTest.php
@@ -7,6 +7,7 @@
 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 \PHPUnit\Framework\TestCase
 {
@@ -185,9 +186,7 @@ public function test_lock(): void
         $this->assertNotEmpty($table->findByPk($e2->id));
     }
 
-    /**
-     * @dataProvider buildLockKey_dataProvider
-     */
+    #[DataProvider('buildLockKey_dataProvider')]
     public function test_buildLockKey($keyParts, $expectedResult)
     {
         $reflection = new \ReflectionClass(CombinedTransaction::class);
diff --git a/tests/Table/CompositeTableTest.php b/tests/Table/CompositeTableTest.php
index 224e604..09f784b 100644
--- a/tests/Table/CompositeTableTest.php
+++ b/tests/Table/CompositeTableTest.php
@@ -9,6 +9,7 @@
 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 \PHPUnit\Framework\TestCase
 {
@@ -28,8 +29,9 @@ public static function crud_dataProvider(): array
 
     /**
      * @param class-string<Entities\TestCompositeEntity> $class
-     * @dataProvider crud_dataProvider
+     * @throws \Throwable
      */
+    #[DataProvider('crud_dataProvider')]
     public function test_crud(AbstractTable&ICompositeTable $table, string $class): void
     {
         $table->truncate();
diff --git a/tests/Table/UniqueTableTest.php b/tests/Table/UniqueTableTest.php
index 186ff97..abeb9ff 100644
--- a/tests/Table/UniqueTableTest.php
+++ b/tests/Table/UniqueTableTest.php
@@ -7,6 +7,7 @@
 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 \PHPUnit\Framework\TestCase
@@ -27,8 +28,8 @@ public static function crud_dataProvider(): array
 
     /**
      * @param class-string<Entities\TestUniqueEntity> $class
-     * @dataProvider crud_dataProvider
      */
+    #[DataProvider('crud_dataProvider')]
     public function test_crud(AbstractTable&IUniqueTable $table, string $class): void
     {
         $table->truncate();