From 43f90a821613b8de1d5cab91236863db56206f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Mi=C5=A1o=20=C4=8Cerve=C5=88=C3=A1k?= Date: Mon, 12 Oct 2026 20:22:09 +0200 Subject: [PATCH 1/5] fix tests --- src/Model/Model.php | 23 +++--- src/ModelRelationsParser.php | 44 +++++----- src/Service/Service.php | 28 +++++-- src/Validator/CheckBuildIn.php | 34 ++++++++ src/Validator/CheckClass.php | 38 +++++++++ src/Validator/CheckEnum.php | 62 ++++++++++++++ src/Validator/CheckNull.php | 32 +++++++ src/Validator/Validator.php | 138 +++++++++++++++++++++++++++++++ src/Validator/ValidatorCheck.php | 26 ++++++ src/ValidatorException.php | 15 ++++ 10 files changed, 398 insertions(+), 42 deletions(-) create mode 100644 src/Validator/CheckBuildIn.php create mode 100644 src/Validator/CheckClass.php create mode 100644 src/Validator/CheckEnum.php create mode 100644 src/Validator/CheckNull.php create mode 100644 src/Validator/Validator.php create mode 100644 src/Validator/ValidatorCheck.php create mode 100644 src/ValidatorException.php diff --git a/src/Model/Model.php b/src/Model/Model.php index 7e64209..492b920 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -53,17 +53,16 @@ public function &__get(string $key): mixed //phpcs:ignore if (!is_null($value) && isset($docs[$key])) { $item = $docs[$key]; if ($item['type']->isClass()) { - $returnType = $item['reflection']; - if (isset($returnType)) { - if ($value instanceof ActiveRow) { - if ($returnType->isSubclassOf(self::class)) { - $value = $returnType->newInstance($value->toArray(), $value->getTable()); - } - } elseif ($returnType->isSubclassOf(\BackedEnum::class)) { - $value = $returnType->getMethod('tryFrom')->invoke($returnType, $value); - } elseif ($returnType->name === WGS84Point::class) { - $value = $returnType->getMethod('fromBytes')->invoke($returnType, $value); + /** @phpstan-ignore argument.type */ + $returnType = new \ReflectionClass($item['type']->getSingleName()); + if ($value instanceof ActiveRow) { + if ($returnType->isSubclassOf(self::class)) { + $value = $returnType->newInstance($value->toArray(), $value->getTable()); } + } elseif ($returnType->isSubclassOf(\BackedEnum::class)) { + $value = $returnType->getMethod('tryFrom')->invoke($returnType, $value); + } elseif ($returnType->name === WGS84Point::class) { + $value = $returnType->getMethod('fromBytes')->invoke($returnType, $value); } } } @@ -92,9 +91,9 @@ public function getReferencedModel(string $requestedModel): ?self if ($path !== null) { foreach ($path as $item) { $nextModel = $item['type'] === 'property' - /** @phpstan-ignore property.dynamicName */ + /** @phpstan-ignore property.dynamicName */ ? $newModel->{$item['accessor']} - /** @phpstan-ignore method.dynamicName */ + /** @phpstan-ignore method.dynamicName */ : $newModel->{$item['accessor']}(); if (is_null($nextModel)) { if ($item['nullable']) { diff --git a/src/ModelRelationsParser.php b/src/ModelRelationsParser.php index 0aea722..0c4de18 100644 --- a/src/ModelRelationsParser.php +++ b/src/ModelRelationsParser.php @@ -12,17 +12,18 @@ use ReflectionException; use ReflectionNamedType; +/** + * @phpstan-type ColumnDoc array{ + * type:Type, + * property:string + * } + */ class ModelRelationsParser { /** * @template TModel of Model * @phpstan-param ReflectionClass $modelReflection - * @phpstan-return array|null, - * property:string - * }>|null - * @throws ReflectionException + * @phpstan-return array|null */ public static function parseModelDoc(ReflectionClass $modelReflection): ?array { @@ -40,19 +41,21 @@ public static function parseModelDoc(ReflectionClass $modelReflection): ?array if ($m !== false && $m !== 0) { [, $returnString, $property] = $matches; $returnType = Type::fromString($returnString); + if (!$returnType->isBuiltin() && $returnType->isSimple()) { + if ($returnType->allows('null')) { + $returnType = Type::fromString( + /** @phpstan-ignore argument.type,argument.type */ + '?' . Reflection::expandClassName($returnType->getSingleName(), $modelReflection) + ); + } else { + $returnType = Type::fromString( + /** @phpstan-ignore argument.type,argument.type */ + Reflection::expandClassName($returnType->getSingleName(), $modelReflection) + ); + } + } $properties[$property] = [ 'type' => $returnType, - 'reflection' => $returnType->isClass() - ? new ReflectionClass( - /** @phpstan-ignore argument.type */ - Reflection::expandClassName( - /** @phpstan-ignore argument.type */ - $returnType->getSingleName(), - /** @phpstan-ignore argument.type */ - $modelReflection - ) - ) - : null, 'property' => $property, ]; } @@ -143,10 +146,8 @@ public static function resolveReferencedProperties(ReflectionClass $model): arra if (!$type->isClass()) { continue; } - $itemReflection = new ReflectionClass( - /** @phpstan-ignore argument.type,argument.type,argument.type */ - Reflection::expandClassName($type->getSingleName(), $model) - ); + /** @phpstan-ignore argument.type */ + $itemReflection = new ReflectionClass($type->getSingleName()); if ($itemReflection->isSubclassOf(Model::class)) { $items[$itemReflection->name] = [ 'type' => 'property', @@ -183,7 +184,6 @@ public static function getPath( self::resolveReferencedProperties($model), self::resolveReferencedMethods($model) ); - if (isset($items[$requestedModel->getName()])) { return [$items[$requestedModel->getName()]]; } diff --git a/src/Service/Service.php b/src/Service/Service.php index b778448..e1d8abb 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -13,10 +13,22 @@ /** * @template TModel of Model + * @phpstan-type ColumnMeta array{ + * name: string, + * table: string, + * nativetype: string, + * size: ?int, + * nullable: bool, + * default: mixed, + * autoincrement: bool, + * primary: bool, + * comment: ?string, + * vendor: array + * } */ abstract class Service { - /** @phpstan-var array> */ + /** @phpstan-var list */ private array $columns; /** @@ -30,7 +42,7 @@ final public function __construct( } /** - * @phpstan-return TModel|null + * @phpstan-return Model|null */ public function findByPrimary(string|int|null $key): ?Model { @@ -38,7 +50,7 @@ public function findByPrimary(string|int|null $key): ?Model } /** - * @phpstan-param TModel $model + * @phpstan-param Model $model * @throws \PDOException */ public function disposeModel(Model $model): void @@ -63,9 +75,9 @@ final public function getTable(): TypedSelection } /** - * @phpstan-param TModel|null $model + * @phpstan-param Model|null $model * @phpstan-param array $data - * @phpstan-return TModel + * @phpstan-return Model * @throws \PDOException */ public function storeModel(array $data, ?Model $model = null): Model @@ -89,7 +101,7 @@ final public function getModelClassName(): string /** * @throws \InvalidArgumentException - * @phpstan-param TModel $model + * @phpstan-param Model $model */ protected function checkType(Model $model): void { @@ -124,9 +136,9 @@ protected function filterData(array $data): array } /** - * @phpstan-return array> + * @return list */ - protected function getColumnMetadata(): array + public function getColumnMetadata(): array { if (!isset($this->columns)) { $this->columns = $this->explorer->getConnection()->getDriver()->getColumns($this->tableName); diff --git a/src/Validator/CheckBuildIn.php b/src/Validator/CheckBuildIn.php new file mode 100644 index 0000000..c627f96 --- /dev/null +++ b/src/Validator/CheckBuildIn.php @@ -0,0 +1,34 @@ +isBuiltin()) { + if ($dbType->getSingleName() !== $docType->getSingleName()) { + $errors[] = new ValidatorException( + $table, + $column, + sprintf( + 'Database type `%s` does not match PHPDoc type `%s`', + $dbType, + $docType, + ) + ); + } + } + } +} diff --git a/src/Validator/CheckClass.php b/src/Validator/CheckClass.php new file mode 100644 index 0000000..98d1453 --- /dev/null +++ b/src/Validator/CheckClass.php @@ -0,0 +1,38 @@ +isBuiltin()) { + if ( + $docType->getSingleName() !== $dbType->getSingleName() && + /** @phpstan-ignore argument.type,argument.type */ + !new \ReflectionClass($docType->getSingleName())->isSubclassOf($dbType->getSingleName()) + ) { + $errors[] = new ValidatorException( + $table, + $column, + sprintf( + 'Database type `%s` does not accept PHPDoc type `%s`', + $dbType, + $docType, + ) + ); + } + } + } +} diff --git a/src/Validator/CheckEnum.php b/src/Validator/CheckEnum.php new file mode 100644 index 0000000..e42a2bc --- /dev/null +++ b/src/Validator/CheckEnum.php @@ -0,0 +1,62 @@ +getSingleName()); + } catch (\ReflectionException $exception) { + $errors[] = new ValidatorException( + $table, + $column, + $exception->getMessage() + ); + return; + } + + /** @var class-string<\BackedEnum> $enumClassName */ + $enumClassName = $docReflection->getName(); + $enumCases = $enumClassName::cases(); + /** @phpstan-ignore argument.type */ + foreach (explode(',', substr($metadata['vendor']['type'], 5, -1)) as $raw) { + $trimmedCase = substr($raw, 1, -1); + try { + $case = $enumClassName::from($trimmedCase); + $enumCases = array_filter($enumCases, fn($c) => $c === $case); + } catch (\ValueError $e) { + $errors[] = new ValidatorException( + $metadata['table'], + $metadata['name'], + $e->getMessage(), + ); + } + } + foreach ($enumCases as $missing) { + $errors[] = new ValidatorException( + $metadata['table'], + $metadata['name'], + sprintf( + 'Case `%s` is missing in DB.', + $missing->value, + ) + ); + } + } + } +} diff --git a/src/Validator/CheckNull.php b/src/Validator/CheckNull.php new file mode 100644 index 0000000..701fbc1 --- /dev/null +++ b/src/Validator/CheckNull.php @@ -0,0 +1,32 @@ +allows('null') xor $docType->allows('null')) { + $errors[] = new ValidatorException( + $table, + $column, + sprintf( + 'Database %s allows NULL but PHPDoc %s allow NULL', + $dbType->allows('null') ? 'does' : 'doesnt', + $docType->allows('null') ? 'does' : 'doesnt', + ) + ); + } + } +} diff --git a/src/Validator/Validator.php b/src/Validator/Validator.php new file mode 100644 index 0000000..d14bdbd --- /dev/null +++ b/src/Validator/Validator.php @@ -0,0 +1,138 @@ + $service + * @return ValidatorException[] + * @throws \ReflectionException + * @throws \Exception + */ + public function __invoke(Service $service): array + { + $doc = ModelRelationsParser::parseModelDoc(new \ReflectionClass($service->getModelClassName())); + $metaData = $service->getColumnMetadata(); + $exceptions = []; + foreach ($metaData as $metaDatum) { + try { + $this->validateColumn($metaDatum, $doc[$metaDatum['name']] ?? null); + } catch (ValidatorException $exception) { + $exceptions[] = $exception; + } + } + return $exceptions; + } + + /** + * @phpstan-param ColumnMeta $metadata + * @phpstan-param ColumnDoc|null $doc + * @throws \Exception + */ + private function validateColumn(array $metadata, array|null $doc): void + { + $name = $metadata['name']; + if (!isset($doc)) { + throw new ValidatorException( + $metadata['table'], + $metadata['name'], + \sprintf( + 'Missing columns `%s`', + $name, + ) + ); + } + $dbType = $this->matchType($metadata); + $errors = []; + new CheckNull()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckBuildIn()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckClass()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckEnum()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + } + + /** + * @phpstan-param ColumnMeta $metadata + */ + private function matchType(array $metadata): Type + { + if ($metadata['nativetype'] === 'BOOL') { + return $metadata['nullable'] ? Type::fromString('?bool') : Type::fromString('bool'); + } + + if ( + in_array( + $metadata['nativetype'], + ['DECIMAL', 'NUMERIC', 'FLOAT', 'DOUBLE', 'REAL', 'DOUBLE PRECISION'], + true + ) + ) { + return $metadata['nullable'] ? Type::fromString('?float') : Type::fromString('float'); + } + + if (in_array($metadata['nativetype'], ['VARCHAR', 'CHAR', 'TEXT', 'LONGTEXT'], true)) { + return $metadata['nullable'] ? Type::fromString('?string') : Type::fromString('string'); + } + if (in_array($metadata['nativetype'], ['TIMESTAMP', 'DATETIME', 'DATE'], true)) { + return $metadata['nullable'] ? + Type::fromString('?' . \DateTimeInterface::class) : + Type::fromString(\DateTimeInterface::class); + } + if ($metadata['nativetype'] === 'ENUM') { + return $metadata['nullable'] ? + Type::fromString('?' . \BackedEnum::class) : + Type::fromString(\BackedEnum::class); + } + if ($metadata['nativetype'] === 'TINYINT' && $metadata['size'] === 1) { + return $metadata['nullable'] ? Type::fromString('?bool') : Type::fromString('bool'); + } + if ($metadata['nativetype'] === 'POINT') { + return $metadata['nullable'] ? + Type::fromString('?' . WGS84Point::class) : + Type::fromString(WGS84Point::class); + } + if ( + in_array( + $metadata['nativetype'], + [ + 'TINYINT', + 'TINYINT UNSIGNED', + 'SMALLINT', + 'SMALLINT UNSIGNED', + 'MEDIUMINT', + 'MEDIUMINT UNSIGNED', + 'INT', + 'INT UNSIGNED', + 'BIGINT', + 'BIGINT UNSIGNED' + ], + true + ) + ) { + return $metadata['nullable'] ? Type::fromString('?int') : Type::fromString('int'); + } + + throw new ValidatorException( + $metadata['table'], + $metadata['name'], + sprintf( + 'Validator does not support type `%s`.', + $metadata['nativetype'], + ) + ); + } +} diff --git a/src/Validator/ValidatorCheck.php b/src/Validator/ValidatorCheck.php new file mode 100644 index 0000000..c5c632e --- /dev/null +++ b/src/Validator/ValidatorCheck.php @@ -0,0 +1,26 @@ + Date: Mon, 12 Oct 2026 20:26:50 +0200 Subject: [PATCH 2/5] update composer --- composer.lock | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/composer.lock b/composer.lock index 75bd049..6bb80bb 100644 --- a/composer.lock +++ b/composer.lock @@ -700,11 +700,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.16", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", - "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -760,20 +760,20 @@ "type": "github" } ], - "time": "2026-09-25T09:31:51+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpstan/phpstan-nette", - "version": "2.0.12", + "version": "2.0.15", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-nette.git", - "reference": "2c104f121b9db65407112069ef4fd43799c9ba0c" + "reference": "b108419a50b08b35ae7a2bf06a4570f29963179b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-nette/zipball/2c104f121b9db65407112069ef4fd43799c9ba0c", - "reference": "2c104f121b9db65407112069ef4fd43799c9ba0c", + "url": "https://api.github.com/repos/phpstan/phpstan-nette/zipball/b108419a50b08b35ae7a2bf06a4570f29963179b", + "reference": "b108419a50b08b35ae7a2bf06a4570f29963179b", "shasum": "" }, "require": { @@ -821,27 +821,27 @@ "description": "Nette Framework class reflection extension for PHPStan", "support": { "issues": "https://github.com/phpstan/phpstan-nette/issues", - "source": "https://github.com/phpstan/phpstan-nette/tree/2.0.12" + "source": "https://github.com/phpstan/phpstan-nette/tree/2.0.15" }, - "time": "2026-07-02T10:28:46+00:00" + "time": "2026-10-12T07:46:34+00:00" }, { "name": "phpstan/phpstan-strict-rules", - "version": "2.0.12", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-strict-rules.git", - "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b" + "reference": "24ec51589f896f048d0d36de373d993b5cf71786" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/2bc5ae19ae965663b62ac907ee6342c3903ec93b", - "reference": "2bc5ae19ae965663b62ac907ee6342c3903ec93b", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/24ec51589f896f048d0d36de373d993b5cf71786", + "reference": "24ec51589f896f048d0d36de373d993b5cf71786", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.1.52" + "phpstan/phpstan": "^2.3" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", @@ -873,9 +873,9 @@ ], "support": { "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", - "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.12" + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.1.0" }, - "time": "2026-07-19T07:24:06+00:00" + "time": "2026-10-01T21:30:37+00:00" }, { "name": "squizlabs/php_codesniffer", From 252048c8b46cb1289f7cf633458603e451b6b540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Mi=C5=A1o=20=C4=8Cerve=C5=88=C3=A1k?= Date: Mon, 12 Oct 2026 20:44:25 +0200 Subject: [PATCH 3/5] fix validation --- src/Validator/CheckBuildIn.php | 2 +- src/Validator/CheckClass.php | 2 +- src/Validator/CheckEnum.php | 2 +- src/Validator/CheckNull.php | 6 +++--- src/Validator/Validator.php | 16 ++++++++-------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Validator/CheckBuildIn.php b/src/Validator/CheckBuildIn.php index c627f96..defa36b 100644 --- a/src/Validator/CheckBuildIn.php +++ b/src/Validator/CheckBuildIn.php @@ -17,7 +17,7 @@ public function validate( string $column, array &$errors ): void { - if ($dbType->isBuiltin()) { + if ($dbType->isBuiltin() || $docType->isBuiltin()) { if ($dbType->getSingleName() !== $docType->getSingleName()) { $errors[] = new ValidatorException( $table, diff --git a/src/Validator/CheckClass.php b/src/Validator/CheckClass.php index 98d1453..c733a78 100644 --- a/src/Validator/CheckClass.php +++ b/src/Validator/CheckClass.php @@ -17,7 +17,7 @@ public function validate( string $column, array &$errors ): void { - if (!$dbType->isBuiltin()) { + if (!$dbType->isBuiltin() && !$docType->isBuiltin()) { if ( $docType->getSingleName() !== $dbType->getSingleName() && /** @phpstan-ignore argument.type,argument.type */ diff --git a/src/Validator/CheckEnum.php b/src/Validator/CheckEnum.php index e42a2bc..2b2a87d 100644 --- a/src/Validator/CheckEnum.php +++ b/src/Validator/CheckEnum.php @@ -20,7 +20,7 @@ public function validate( if ($metadata['nativetype'] === 'ENUM') { try { /** @phpstan-ignore argument.type */ - $docReflection = new \ReflectionEnum($dbType->getSingleName()); + $docReflection = new \ReflectionEnum($docType->getSingleName()); } catch (\ReflectionException $exception) { $errors[] = new ValidatorException( $table, diff --git a/src/Validator/CheckNull.php b/src/Validator/CheckNull.php index 701fbc1..9d32920 100644 --- a/src/Validator/CheckNull.php +++ b/src/Validator/CheckNull.php @@ -22,9 +22,9 @@ public function validate( $table, $column, sprintf( - 'Database %s allows NULL but PHPDoc %s allow NULL', - $dbType->allows('null') ? 'does' : 'doesnt', - $docType->allows('null') ? 'does' : 'doesnt', + 'Database %s NULL but PHPDoc %s NULL', + $dbType->allows('null') ? 'allows' : 'doesnt allow', + $docType->allows('null') ? 'allows' : 'doesnt allow', ) ); } diff --git a/src/Validator/Validator.php b/src/Validator/Validator.php index d14bdbd..952e5bd 100644 --- a/src/Validator/Validator.php +++ b/src/Validator/Validator.php @@ -30,11 +30,7 @@ public function __invoke(Service $service): array $metaData = $service->getColumnMetadata(); $exceptions = []; foreach ($metaData as $metaDatum) { - try { - $this->validateColumn($metaDatum, $doc[$metaDatum['name']] ?? null); - } catch (ValidatorException $exception) { - $exceptions[] = $exception; - } + $exceptions += $this->validateColumn($metaDatum, $doc[$metaDatum['name']] ?? null); } return $exceptions; } @@ -42,13 +38,15 @@ public function __invoke(Service $service): array /** * @phpstan-param ColumnMeta $metadata * @phpstan-param ColumnDoc|null $doc + * @return ValidatorException[] * @throws \Exception */ - private function validateColumn(array $metadata, array|null $doc): void + private function validateColumn(array $metadata, array|null $doc): array { + $errors = []; $name = $metadata['name']; if (!isset($doc)) { - throw new ValidatorException( + $errors[] = new ValidatorException( $metadata['table'], $metadata['name'], \sprintf( @@ -56,13 +54,15 @@ private function validateColumn(array $metadata, array|null $doc): void $name, ) ); + return $errors; } $dbType = $this->matchType($metadata); - $errors = []; + new CheckNull()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); new CheckBuildIn()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); new CheckClass()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); new CheckEnum()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + return $errors; } /** From ae18b0c947142037cb19e6f5ef3b2758ae4e6043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Mi=C5=A1o=20=C4=8Cerve=C5=88=C3=A1k?= Date: Mon, 12 Oct 2026 22:31:48 +0200 Subject: [PATCH 4/5] fix merge errors --- src/Validator/Validator.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Validator/Validator.php b/src/Validator/Validator.php index 952e5bd..a033d2f 100644 --- a/src/Validator/Validator.php +++ b/src/Validator/Validator.php @@ -30,7 +30,10 @@ public function __invoke(Service $service): array $metaData = $service->getColumnMetadata(); $exceptions = []; foreach ($metaData as $metaDatum) { - $exceptions += $this->validateColumn($metaDatum, $doc[$metaDatum['name']] ?? null); + $exceptions = [ + ...$exceptions, + ...$this->validateColumn($metaDatum, $doc[$metaDatum['name']] ?? null) + ]; } return $exceptions; } From 64a943063e01f07fa60dd0521a16b83f725aafbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Mi=C5=A1o=20=C4=8Cerve=C5=88=C3=A1k?= Date: Fri, 16 Oct 2026 22:29:40 +0200 Subject: [PATCH 5/5] Validation options --- src/Extension.php | 21 ++++++- src/Mapper.php | 5 ++ src/Service/Service.php | 10 +++ src/Validator/CheckBuildIn.php | 3 +- src/Validator/CheckClass.php | 3 +- src/Validator/CheckEnum.php | 7 +-- src/Validator/CheckNull.php | 3 +- src/Validator/Validator.php | 26 ++++---- src/Validator/ValidatorCheck.php | 3 +- src/Validator/ValidatorError.php | 20 ++++++ src/Validator/ValidatorException.php | 19 ++++++ src/ValidatorException.php | 15 ----- tests/ORM/EventModel.php | 5 +- tests/ORM/ParticipantModel.php | 3 + tests/Tests/ReferencedAccessTest.phpt | 25 +++++--- tests/Tests/ServiceOperationTest.phpt | 89 ++++++++++++--------------- tests/Tests/TableSelectionTest.phpt | 28 +++++---- tests/Tests/TestCase.php | 1 + tests/config.neon | 14 +++-- tests/resource/schema.sql | 2 +- 20 files changed, 184 insertions(+), 118 deletions(-) create mode 100644 src/Validator/ValidatorError.php create mode 100644 src/Validator/ValidatorException.php delete mode 100644 src/ValidatorException.php diff --git a/src/Extension.php b/src/Extension.php index 6cbfb6d..dc2a9c5 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -11,6 +11,8 @@ use Nette\DI\CompilerExtension; use Nette\DI\Definitions\ServiceDefinition; use Nette\NotImplementedException; +use Nette\Schema\Expect; +use Nette\Schema\Schema; class Extension extends CompilerExtension { @@ -20,14 +22,29 @@ class Extension extends CompilerExtension public function loadConfiguration(): void { $mapper = $this->getContainerBuilder()->addDefinition($this->prefix('mapper')); - $mapper->setFactory(Mapper::class); + $mapper->setFactory(Mapper::class, [$this->config['validation']]); + /** @phpstan-ignore foreach.nonIterable */ - foreach ($this->config as $tableName => $fieldDefinitions) { + foreach ($this->config['tables'] as $tableName => $fieldDefinitions) { /** @phpstan-ignore argument.type */ $this->registerORMService($tableName, $fieldDefinitions, $mapper); } } + public function getConfigSchema(): Schema + { + return Expect::structure([ + 'validation' => Expect::bool(false), + 'tables' => Expect::arrayOf( + Expect::structure([ + 'model' => Expect::string(), + 'service' => Expect::string(), + ])->castTo('array'), + 'string' + )->castTo('array') + ])->otherItems('mixed')->castTo('array'); + } + /** * @template TModel of Model * @phpstan-param array{ diff --git a/src/Mapper.php b/src/Mapper.php index 9fd2500..b6d437e 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -22,6 +22,11 @@ class Mapper */ private array $map = []; + public function __construct( + public readonly bool $validate + ) { + } + /** * @throws Throwable * @phpstan-param TKey $table diff --git a/src/Service/Service.php b/src/Service/Service.php index e1d8abb..871bd78 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -8,6 +8,8 @@ use Fykosak\NetteORM\Model\Model; use Fykosak\NetteORM\Selection\TypedSelection; use Fykosak\NetteORM\Types\WGS84Point; +use Fykosak\NetteORM\Validator\Validator; +use Fykosak\NetteORM\Validator\ValidatorException; use Nette\Database\Explorer; use Nette\InvalidStateException; @@ -33,12 +35,20 @@ abstract class Service /** * @param Mapper $mapper + * @throws \ReflectionException */ final public function __construct( private readonly string $tableName, public readonly Explorer $explorer, private readonly Mapper $mapper ) { + if ($this->mapper->validate) { + $validator = new Validator(); + $errors = $validator($this); + if (count($errors) > 0) { + throw new ValidatorException($errors); + } + } } /** diff --git a/src/Validator/CheckBuildIn.php b/src/Validator/CheckBuildIn.php index defa36b..60a0ab3 100644 --- a/src/Validator/CheckBuildIn.php +++ b/src/Validator/CheckBuildIn.php @@ -4,7 +4,6 @@ namespace Fykosak\NetteORM\Validator; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; class CheckBuildIn implements ValidatorCheck @@ -19,7 +18,7 @@ public function validate( ): void { if ($dbType->isBuiltin() || $docType->isBuiltin()) { if ($dbType->getSingleName() !== $docType->getSingleName()) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $table, $column, sprintf( diff --git a/src/Validator/CheckClass.php b/src/Validator/CheckClass.php index c733a78..512002d 100644 --- a/src/Validator/CheckClass.php +++ b/src/Validator/CheckClass.php @@ -4,7 +4,6 @@ namespace Fykosak\NetteORM\Validator; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; class CheckClass implements ValidatorCheck @@ -23,7 +22,7 @@ public function validate( /** @phpstan-ignore argument.type,argument.type */ !new \ReflectionClass($docType->getSingleName())->isSubclassOf($dbType->getSingleName()) ) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $table, $column, sprintf( diff --git a/src/Validator/CheckEnum.php b/src/Validator/CheckEnum.php index 2b2a87d..f7d8f26 100644 --- a/src/Validator/CheckEnum.php +++ b/src/Validator/CheckEnum.php @@ -4,7 +4,6 @@ namespace Fykosak\NetteORM\Validator; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; class CheckEnum implements ValidatorCheck @@ -22,7 +21,7 @@ public function validate( /** @phpstan-ignore argument.type */ $docReflection = new \ReflectionEnum($docType->getSingleName()); } catch (\ReflectionException $exception) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $table, $column, $exception->getMessage() @@ -40,7 +39,7 @@ public function validate( $case = $enumClassName::from($trimmedCase); $enumCases = array_filter($enumCases, fn($c) => $c === $case); } catch (\ValueError $e) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $metadata['table'], $metadata['name'], $e->getMessage(), @@ -48,7 +47,7 @@ public function validate( } } foreach ($enumCases as $missing) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $metadata['table'], $metadata['name'], sprintf( diff --git a/src/Validator/CheckNull.php b/src/Validator/CheckNull.php index 9d32920..39b54ef 100644 --- a/src/Validator/CheckNull.php +++ b/src/Validator/CheckNull.php @@ -4,7 +4,6 @@ namespace Fykosak\NetteORM\Validator; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; class CheckNull implements ValidatorCheck @@ -18,7 +17,7 @@ public function validate( array &$errors ): void { if ($dbType->allows('null') xor $docType->allows('null')) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $table, $column, sprintf( diff --git a/src/Validator/Validator.php b/src/Validator/Validator.php index a033d2f..23068a5 100644 --- a/src/Validator/Validator.php +++ b/src/Validator/Validator.php @@ -8,7 +8,6 @@ use Fykosak\NetteORM\ModelRelationsParser; use Fykosak\NetteORM\Service\Service; use Fykosak\NetteORM\Types\WGS84Point; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; /** @@ -20,7 +19,7 @@ class Validator /** * @template M of Model * @param Service $service - * @return ValidatorException[] + * @return ValidatorError[] * @throws \ReflectionException * @throws \Exception */ @@ -41,7 +40,7 @@ public function __invoke(Service $service): array /** * @phpstan-param ColumnMeta $metadata * @phpstan-param ColumnDoc|null $doc - * @return ValidatorException[] + * @return ValidatorError[] * @throws \Exception */ private function validateColumn(array $metadata, array|null $doc): array @@ -49,7 +48,7 @@ private function validateColumn(array $metadata, array|null $doc): array $errors = []; $name = $metadata['name']; if (!isset($doc)) { - $errors[] = new ValidatorException( + $errors[] = new ValidatorError( $metadata['table'], $metadata['name'], \sprintf( @@ -59,19 +58,21 @@ private function validateColumn(array $metadata, array|null $doc): array ); return $errors; } - $dbType = $this->matchType($metadata); - - new CheckNull()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); - new CheckBuildIn()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); - new CheckClass()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); - new CheckEnum()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + $dbType = $this->matchType($metadata, $errors); + if (isset($dbType)) { + new CheckNull()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckBuildIn()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckClass()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + new CheckEnum()->validate($dbType, $doc['type'], $metadata, $metadata['table'], $metadata['name'], $errors); + } return $errors; } /** * @phpstan-param ColumnMeta $metadata + * @phpstan-param ValidatorError[] $errors */ - private function matchType(array $metadata): Type + private function matchType(array $metadata, array &$errors): ?Type { if ($metadata['nativetype'] === 'BOOL') { return $metadata['nullable'] ? Type::fromString('?bool') : Type::fromString('bool'); @@ -129,7 +130,7 @@ private function matchType(array $metadata): Type return $metadata['nullable'] ? Type::fromString('?int') : Type::fromString('int'); } - throw new ValidatorException( + $errors[] = new ValidatorError( $metadata['table'], $metadata['name'], sprintf( @@ -137,5 +138,6 @@ private function matchType(array $metadata): Type $metadata['nativetype'], ) ); + return null; } } diff --git a/src/Validator/ValidatorCheck.php b/src/Validator/ValidatorCheck.php index c5c632e..76f7612 100644 --- a/src/Validator/ValidatorCheck.php +++ b/src/Validator/ValidatorCheck.php @@ -3,7 +3,6 @@ namespace Fykosak\NetteORM\Validator; use Fykosak\NetteORM\Service\Service; -use Fykosak\NetteORM\ValidatorException; use Nette\Utils\Type; /** @@ -13,7 +12,7 @@ interface ValidatorCheck { /** * @phpstan-param ColumnMeta $metadata - * @phpstan-param ValidatorException[] $errors + * @phpstan-param ValidatorError[] $errors */ public function validate( Type $dbType, diff --git a/src/Validator/ValidatorError.php b/src/Validator/ValidatorError.php new file mode 100644 index 0000000..2d85fb3 --- /dev/null +++ b/src/Validator/ValidatorError.php @@ -0,0 +1,20 @@ +table, $this->column, $this->message); + } +} diff --git a/src/Validator/ValidatorException.php b/src/Validator/ValidatorException.php new file mode 100644 index 0000000..cce0eb9 --- /dev/null +++ b/src/Validator/ValidatorException.php @@ -0,0 +1,19 @@ +eventService = $eventService; + } + + public function injectParticipantService(ParticipantService $participantService): void + { + $this->participantService = $participantService; + } + public function testSuccess(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); /** @var ParticipantModel $participant */ - $participant = $serviceParticipant->getTable()->fetch(); + $participant = $this->participantService->getTable()->fetch(); $modelEvent = $participant->getReferencedModel(EventModel::class); Assert::type(EventModel::class, $modelEvent); } public function testSame(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); /** @var ParticipantModel $participant */ - $participant = $serviceParticipant->getTable()->fetch(); + $participant = $this->participantService->getTable()->fetch(); $newModel = $participant->getReferencedModel(ParticipantModel::class); Assert::same($participant, $newModel); } public function testNoCandidate(): void { - /** @var EventService $service */ - $service = $this->container->getByType(EventService::class); /** @var EventModel $event */ - $event = $service->getTable()->fetch(); + $event = $this->eventService->getTable()->fetch(); Assert::exception(function () use ($event) { $event->getReferencedModel(ParticipantModel::class); }, CannotAccessModelException::class); diff --git a/tests/Tests/ServiceOperationTest.phpt b/tests/Tests/ServiceOperationTest.phpt index 803168b..fb31124 100644 --- a/tests/Tests/ServiceOperationTest.phpt +++ b/tests/Tests/ServiceOperationTest.phpt @@ -14,15 +14,25 @@ require_once __DIR__ . '/TestCase.php'; class ServiceOperationTest extends TestCase { + private EventService $eventService; + private ParticipantService $participantService; + + public function injectEventService(EventService $eventService): void + { + $this->eventService = $eventService; + } + + public function injectParticipantService(ParticipantService $participantService): void + { + $this->participantService = $participantService; + } public function testCreateSuccess(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $countBefore = $serviceParticipant->getTable()->count('*'); + $countBefore = $this->participantService->getTable()->count('*'); - $newEvent = $serviceParticipant->storeModel(['event_id' => 1, 'name' => 'Igor']); - $countAfter = $serviceParticipant->getTable()->count('*'); + $newEvent = $this->participantService->storeModel(['event_id' => 1, 'name' => 'Igor']); + $countAfter = $this->participantService->getTable()->count('*'); Assert::same($countBefore + 1, $countAfter); Assert::type(ParticipantModel::class, $newEvent); @@ -31,68 +41,56 @@ class ServiceOperationTest extends TestCase public function testCreateError(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $countBefore = $serviceParticipant->getTable()->count('*'); - Assert::exception(function () use ($serviceParticipant) { - $serviceParticipant->storeModel(['event_id' => 4, 'name' => 'Igor']); + $countBefore = $this->participantService->getTable()->count('*'); + Assert::exception(function () { + $this->participantService->storeModel(['event_id' => 4, 'name' => 'Igor']); }, ForeignKeyConstraintViolationException::class); - $countAfter = $serviceParticipant->getTable()->count('*'); + $countAfter = $this->participantService->getTable()->count('*'); Assert::same($countBefore, $countAfter); } public function testFindByPrimary(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(1); + $participant = $this->participantService->findByPrimary(1); Assert::same('Adam', $participant->name); Assert::type(ParticipantModel::class, $participant); - $nullParticipant = $serviceParticipant->findByPrimary(10); + $nullParticipant = $this->participantService->findByPrimary(10); Assert::null($nullParticipant); } public function testFindByPrimaryProtection(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(null); + $participant = $this->participantService->findByPrimary(null); Assert::null($participant); } public function testUpdateSuccess(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(2); - $serviceParticipant->storeModel(['name' => 'Betka'], $participant); + $participant = $this->participantService->findByPrimary(2); + $this->participantService->storeModel(['name' => 'Betka'], $participant); Assert::same('Betka', $participant->name); Assert::type(ParticipantModel::class, $participant); } public function testUpdateError(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(2); + $participant = $this->participantService->findByPrimary(2); - Assert::exception(function () use ($participant, $serviceParticipant) { - $serviceParticipant->storeModel(['event_id' => 4], $participant); + Assert::exception(function () use ($participant) { + $this->participantService->storeModel(['event_id' => 4], $participant); }, ForeignKeyConstraintViolationException::class); Assert::same('Bára', $participant->name); } public function testStoreExists(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(2); - $newParticipant = $serviceParticipant->storeModel(['name' => 'Betka'], $participant); + $participant = $this->participantService->findByPrimary(2); + $newParticipant = $this->participantService->storeModel(['name' => 'Betka'], $participant); Assert::same($participant, $newParticipant);// must be a same obj Assert::same('Betka', $participant->name); Assert::type(ParticipantModel::class, $participant); @@ -100,40 +98,33 @@ class ServiceOperationTest extends TestCase public function testStoreNew(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $newParticipant = $serviceParticipant->storeModel(['event_id' => 1, 'name' => 'Igor'], null); + $newParticipant = $this->participantService->storeModel(['event_id' => 1, 'name' => 'Igor'], null); Assert::same('Igor', $newParticipant->name); Assert::type(ParticipantModel::class, $newParticipant); } public function testDelete(): void { - /** @var ParticipantService $serviceParticipant */ - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $participant = $serviceParticipant->findByPrimary(2); - $countBefore = $serviceParticipant->getTable()->count('*'); - $serviceParticipant->disposeModel($participant); - $countAfter = $serviceParticipant->getTable()->count('*'); + $participant = $this->participantService->findByPrimary(2); + $countBefore = $this->participantService->getTable()->count('*'); + $this->participantService->disposeModel($participant); + $countAfter = $this->participantService->getTable()->count('*'); Assert::same($countBefore - 1, $countAfter); } public function testType(): void { - $serviceParticipant = $this->container->getByType(ParticipantService::class); - $serviceEvent = $this->container->getByType(EventService::class); - $event = $serviceEvent->getTable()->fetch(); - Assert::exception(function () use ($event, $serviceParticipant) { - $serviceParticipant->disposeModel($event); + $event = $this->eventService->getTable()->fetch(); + Assert::exception(function () use ($event) { + $this->participantService->disposeModel($event); }, \TypeError::class); } public function testDeleteError(): void { - $serviceEvent = $this->container->getByType(EventService::class); - $event = $serviceEvent->getTable()->fetch(); - Assert::exception(function () use ($event, $serviceEvent) { - $serviceEvent->disposeModel($event); + $event = $this->eventService->getTable()->fetch(); + Assert::exception(function () use ($event) { + $this->eventService->disposeModel($event); }, ForeignKeyConstraintViolationException::class); } } diff --git a/tests/Tests/TableSelectionTest.phpt b/tests/Tests/TableSelectionTest.phpt index 351e1ed..6a478e7 100644 --- a/tests/Tests/TableSelectionTest.phpt +++ b/tests/Tests/TableSelectionTest.phpt @@ -4,11 +4,11 @@ declare(strict_types=1); namespace Fykosak\NetteORM\Tests\Tests; +use Fykosak\NetteORM\Selection\TypedSelection; use Fykosak\NetteORM\Tests\ORM\EventModel; -use Fykosak\NetteORM\Tests\ORM\ParticipantModel; use Fykosak\NetteORM\Tests\ORM\EventService; +use Fykosak\NetteORM\Tests\ORM\ParticipantModel; use Fykosak\NetteORM\Tests\ORM\ParticipantService; -use Fykosak\NetteORM\Selection\TypedSelection; use Nette\Database\Table\ActiveRow; use Nette\Database\Table\Selection; use Tester\Assert; @@ -17,12 +17,22 @@ require_once __DIR__ . '/TestCase.php'; class TableSelectionTest extends TestCase { + private EventService $eventService; + private ParticipantService $participantService; + + public function injectEventService(EventService $eventService): void + { + $this->eventService = $eventService; + } + + public function injectParticipantService(ParticipantService $participantService): void + { + $this->participantService = $participantService; + } public function testType(): void { - /** @var EventService $serviceEvent */ - $serviceEvent = $this->container->getByType(EventService::class); - $selection = $serviceEvent->getTable(); + $selection = $this->eventService->getTable(); Assert::type(TypedSelection::class, $selection); Assert::type(Selection::class, $selection); $event = $selection->fetch(); @@ -32,9 +42,7 @@ class TableSelectionTest extends TestCase public function testReferenced(): void { - /** @var ParticipantService $serviceEvent */ - $serviceEvent = $this->container->getByType(ParticipantService::class); - $participant = $serviceEvent->getTable()->fetch(); + $participant = $this->participantService->getTable()->fetch(); Assert::type(ParticipantModel::class, $participant); $event = $participant->event; @@ -43,9 +51,7 @@ class TableSelectionTest extends TestCase public function testRelated(): void { - /** @var ParticipantService $serviceEvent */ - $serviceEvent = $this->container->getByType(EventService::class); - $event = $serviceEvent->getTable()->fetch(); + $event = $this->eventService->getTable()->fetch(); $participant = $event->related('participant')->fetch(); Assert::type(ParticipantModel::class, $participant); } diff --git a/tests/Tests/TestCase.php b/tests/Tests/TestCase.php index 64ee3c6..fe0042e 100644 --- a/tests/Tests/TestCase.php +++ b/tests/Tests/TestCase.php @@ -36,6 +36,7 @@ public function __construct() }); $this->container = new $class(); + $this->container->callInjects($this); } protected function setUp(): void diff --git a/tests/config.neon b/tests/config.neon index 66dafa8..05f1210 100644 --- a/tests/config.neon +++ b/tests/config.neon @@ -1,12 +1,14 @@ services: - Nette\Caching\Storages\FileStorage(/tmp) orm: - event: - model: Fykosak\NetteORM\Tests\ORM\EventModel - service: Fykosak\NetteORM\Tests\ORM\EventService - participant: - model: Fykosak\NetteORM\Tests\ORM\ParticipantModel - service: Fykosak\NetteORM\Tests\ORM\ParticipantService + validation: true + tables: + event: + model: Fykosak\NetteORM\Tests\ORM\EventModel + service: Fykosak\NetteORM\Tests\ORM\EventService + participant: + model: Fykosak\NetteORM\Tests\ORM\ParticipantModel + service: Fykosak\NetteORM\Tests\ORM\ParticipantService includes: - config.local.neon diff --git a/tests/resource/schema.sql b/tests/resource/schema.sql index efa122c..7895c13 100644 --- a/tests/resource/schema.sql +++ b/tests/resource/schema.sql @@ -16,7 +16,7 @@ CREATE TABLE `participant` `participant_id` INT PRIMARY KEY AUTO_INCREMENT NOT NULL, `event_id` INT NOT NULL, `status` ENUM ('applied','cancelled') NOT NULL DEFAULT 'applied', - `name` VARCHAR(64), + `name` VARCHAR(64) NOT NULL, CONSTRAINT `fk_participant_event` FOREIGN KEY (`event_id`) REFERENCES `event` (`event_id`)