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 +[](https://packagist.org/packages/compositephp/db) +[](https://github.com/compositephp/db/actions) +[](https://codecov.io/gh/compositephp/db/) Composite DB is lightweight and fast PHP ORM, DataMapper and Table Gateway which allows you to represent your SQL tables 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 @@ [](https://github.com/compositephp/db/actions) [](https://codecov.io/gh/compositephp/db/) -Composite DB is lightweight and fast PHP ORM, DataMapper and Table Gateway which allows you to represent your SQL tables +Composite DB is lightweight and fast PHP DataMapper and Table Gateway which allows you to represent your SQL tables scheme in OOP style using full power of PHP 8.1+ class syntax. It also gives you CRUD, query builder and automatic caching out of the box, so you can start to work with your database from php code in a minutes! Overview: -* [Mission](#mission) +* [Features](#features) * [Requirements](#requirements) * [Installation](#installation) * [Quick example](#quick-example) * [Documentation](doc/README.md) -## Mission -You probably may ask, why do you need another ORM if there are already popular Doctrine, CycleORM, etc.? - -Composite DB solves multiple problems: +## Features * **Lightweight** - easier entity schema, no getters and setters, you don't need attributes for each column definition, just use native php class syntax. -* **Speed** - it's 1.5x faster in pure SQL queries mode and many times faster in automatic caching mode. -* **Easy caching** - gives you CRUD operations caching out of the box and in general its much easier to work with cached "selects". +* **Speed** - it's 1.5x faster in pure SQL queries mode and many times faster in automatic caching mode (see [benchmark](https://github.com/compositephp/php-orm-benchmark)). +* **Easy caching** - gives you CRUD operations caching out of the box and in general it's much easier to work with cached "selects". * **Strict types** - Composite DB forces you to be more strict typed and makes your IDE happy. * **Hydration** - you can serialize your Entities to plain array or json and deserialize them back. -* **Flexibility** - gives you more freedom to extend Repositories, for example its easier to build sharding tables. +* **Flexibility** - gives you more freedom to extend Repositories, for example it's easier to build sharding tables. * **Code generation** - you can generate Entity and Repository classes from your SQL tables. -* **Division of responsibility** - there is no "god" entity manager, every Entity has its own Repository class and its the only entry point to make queries to your table. +* **Division of responsibility** - every Entity has its own Repository class, and it's the only entry point to make queries to your table. It also has many popular features such as: * **Query Builder** - build your queries with constructor, based on [doctrine/dbal](https://github.com/doctrine/dbal) * **Migrations** - 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();