diff --git a/README.md b/README.md index 68f29eee..dc02927e 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ class User * through=TagMap::class, * load="lazy", * collection="Collection\BaseCollection" - * -* ) */ + * ) + */ protected $tags; ... diff --git a/composer.json b/composer.json index 693d0ba7..7ddcf897 100644 --- a/composer.json +++ b/composer.json @@ -7,16 +7,16 @@ "prefer-stable": true, "require": { "php": ">=8.0", - "spiral/tokenizer": "^2.8", + "spiral/tokenizer": "^2.8 || ^3.0", "cycle/orm": "^2.0.0", "cycle/schema-builder": "^2.1.0", "doctrine/annotations": "^1.13", - "spiral/attributes": "^2.8", + "spiral/attributes": "^2.8 || ^3.0", "doctrine/inflector": "^2.0" }, "require-dev": { "phpunit/phpunit": "^9.5", - "vimeo/psalm": "^4.18" + "vimeo/psalm": "5.21 || ^6.8" }, "autoload": { "psr-4": { diff --git a/src/Annotation/Column.php b/src/Annotation/Column.php index 7e534c20..5344c44b 100644 --- a/src/Annotation/Column.php +++ b/src/Annotation/Column.php @@ -29,7 +29,7 @@ final class Column * @param bool $primary Explicitly set column as a primary key. * @param bool $nullable Set column as nullable. * @param mixed|null $default Default column value. - * @param non-empty-string|null $typecast Typecast rule name. + * @param callable|non-empty-string|null $typecast Typecast rule name. * Regarding the default Typecast handler {@see Typecast} the value can be `callable` or * one of ("int"|"float"|"bool"|"datetime") based on column type. * If you want to use another rule you should add in the `typecast` argument of the {@see Entity} attribute @@ -39,7 +39,7 @@ final class Column public function __construct( #[ExpectedValues(values: ['primary', 'bigPrimary', 'enum', 'boolean', 'integer', 'tinyInteger', 'bigInteger', 'string', 'text', 'tinyText', 'longText', 'double', 'float', 'decimal', 'datetime', 'date', 'time', - 'timestamp', 'binary', 'tinyBinary', 'longBinary', 'json', + 'timestamp', 'binary', 'tinyBinary', 'longBinary', 'json', 'uuid', ])] private string $type, private ?string $name = null, diff --git a/src/Annotation/Obsolete.php b/src/Annotation/Obsolete.php new file mode 100644 index 00000000..8ecb80a1 --- /dev/null +++ b/src/Annotation/Obsolete.php @@ -0,0 +1,20 @@ +getProperties() as $property) { + foreach ($this->getActualProperties($class) as $property) { try { $column = $this->reader->firstPropertyMetadata($property, Column::class); } catch (Exception $e) { @@ -109,7 +110,7 @@ public function initFields(EntitySchema $entity, \ReflectionClass $class, string public function initRelations(EntitySchema $entity, \ReflectionClass $class): void { - foreach ($class->getProperties() as $property) { + foreach ($this->getActualProperties($class) as $property) { try { $metadata = $this->reader->getPropertyMetadata($property, RelationAnnotation\RelationInterface::class); } catch (Exception $e) { @@ -251,40 +252,63 @@ public function initField(string $name, Column $column, \ReflectionClass $class, */ public function resolveName(?string $name, \ReflectionClass $class): ?string { - if ($name === null || class_exists($name, true) || interface_exists($name, true)) { + if ($name === null || $this->exists($name)) { return $name; } - $resolved = sprintf( + $resolved = \sprintf( '%s\\%s', $class->getNamespaceName(), - ltrim(str_replace('/', '\\', $name), '\\') + \ltrim(\str_replace('/', '\\', $name), '\\') ); - if (class_exists($resolved, true) || interface_exists($resolved, true)) { - return ltrim($resolved, '\\'); + if ($this->exists($resolved)) { + return \ltrim($resolved, '\\'); } return $name; } + private function exists(string $name): bool + { + return \class_exists($name, true) || \interface_exists($name, true); + } + private function resolveTypecast(mixed $typecast, \ReflectionClass $class): mixed { - if (is_string($typecast) && strpos($typecast, '::') !== false) { + if (\is_string($typecast) && \str_contains($typecast, '::')) { // short definition - $typecast = explode('::', $typecast); + $typecast = \explode('::', $typecast); // resolve class name $typecast[0] = $this->resolveName($typecast[0], $class); } - if (is_string($typecast)) { + if (\is_string($typecast)) { $typecast = $this->resolveName($typecast, $class); - if (class_exists($typecast)) { + if (\class_exists($typecast) && \method_exists($typecast, 'typecast')) { $typecast = [$typecast, 'typecast']; } } return $typecast; } + + /** + * @return \Generator<\ReflectionProperty> + */ + private function getActualProperties(\ReflectionClass $class): \Generator + { + foreach ($class->getProperties() as $property) { + // Obsolete property must not be included in the scheme. + $metadata = \iterator_to_array( + $this->reader->getPropertyMetadata($property, Obsolete::class), + ); + if ([] !== $metadata) { + continue; + } + + yield $property; + } + } } diff --git a/src/Entities.php b/src/Entities.php index 767b29ac..0f36753f 100644 --- a/src/Entities.php +++ b/src/Entities.php @@ -32,7 +32,7 @@ final class Entities implements GeneratorInterface public function __construct( private ClassesInterface $locator, - DoctrineReader|ReaderInterface $reader = null, + DoctrineReader|ReaderInterface|null $reader = null, int $tableNamingStrategy = self::TABLE_NAMING_PLURAL ) { $this->reader = ReaderFactory::create($reader); diff --git a/src/ReaderFactory.php b/src/ReaderFactory.php index 372eaeff..27beafa0 100644 --- a/src/ReaderFactory.php +++ b/src/ReaderFactory.php @@ -12,7 +12,7 @@ final class ReaderFactory { - public static function create(DoctrineReader|ReaderInterface $reader = null): ReaderInterface + public static function create(DoctrineReader|ReaderInterface|null $reader = null): ReaderInterface { return match (true) { $reader instanceof ReaderInterface => $reader, diff --git a/tests/Annotated/Fixtures/Fixtures19/BackedEnum.php b/tests/Annotated/Fixtures/Fixtures19/BackedEnum.php new file mode 100644 index 00000000..051f52fb --- /dev/null +++ b/tests/Annotated/Fixtures/Fixtures19/BackedEnum.php @@ -0,0 +1,11 @@ +expectException(AnnotationException::class); - $this->expectErrorMessage( - 'Some of required arguments [`type`] is missed on `Cycle\Annotated\Tests\Fixtures\Fixtures4\User.id.`' - ); + // $this->expectErrorMessage( + // 'Some of required arguments [`type`] is missed on `Cycle\Annotated\Tests\Fixtures\Fixtures4\User.id.`' + // ); $tokenizer = new Tokenizer(new TokenizerConfig([ 'directories' => [__DIR__ . '/../../../Fixtures/Fixtures4'], diff --git a/tests/Annotated/Functional/Driver/Common/ObsoleteTest.php b/tests/Annotated/Functional/Driver/Common/ObsoleteTest.php new file mode 100644 index 00000000..ed81e497 --- /dev/null +++ b/tests/Annotated/Functional/Driver/Common/ObsoleteTest.php @@ -0,0 +1,44 @@ + [__DIR__ . '/../../../Fixtures/Fixtures7'], + 'exclude' => [], + ]) + ); + + $locator = $tokenizer->classLocator(); + + $r = new Registry($this->dbal); + + $schema = (new Compiler())->compile($r, [ + new Entities($locator, $reader), + new RenderTables(), + new SyncTables(), + ]); + + $this->assertArrayNotHasKey('skype', $schema['post'][SchemaInterface::COLUMNS]); + $this->assertArrayNotHasKey('skype', $schema['post'][SchemaInterface::TYPECAST]); + } +} diff --git a/tests/Annotated/Functional/Driver/Common/TypecastTest.php b/tests/Annotated/Functional/Driver/Common/TypecastTest.php index 1691d538..54b03155 100644 --- a/tests/Annotated/Functional/Driver/Common/TypecastTest.php +++ b/tests/Annotated/Functional/Driver/Common/TypecastTest.php @@ -4,14 +4,27 @@ namespace Cycle\Annotated\Tests\Functional\Driver\Common; +use Cycle\Annotated\Embeddings; +use Cycle\Annotated\MergeIndexes; use Cycle\Annotated\Tests\Fixtures\Fixtures1\Typecast\Typecaster; use Cycle\Annotated\Tests\Fixtures\Fixtures1\Typecast\UuidTypecaster; +use Cycle\Annotated\Tests\Fixtures\Fixtures19\BackedEnumWrapper; use Cycle\ORM\Schema; +use Cycle\ORM\SchemaInterface; use Cycle\Schema\Compiler; +use Cycle\Schema\Generator\GenerateRelations; +use Cycle\Schema\Generator\GenerateTypecast; +use Cycle\Schema\Generator\RenderRelations; +use Cycle\Schema\Generator\RenderTables; +use Cycle\Schema\Generator\ResetTables; +use Cycle\Schema\Generator\SyncTables; use Cycle\Schema\Registry; use Cycle\Annotated\Entities; use Cycle\Annotated\MergeColumns; use Spiral\Attributes\ReaderInterface; +use Spiral\Tokenizer\Config\TokenizerConfig; +use Spiral\Tokenizer\Tokenizer; +use Cycle\Annotated\Tests\Fixtures\Fixtures19\BackedEnum; abstract class TypecastTest extends BaseTest { @@ -54,4 +67,42 @@ public function testEntityWithDefinedTypecastAsArray(ReaderInterface $reader) $schema['tag'][Schema::TYPECAST_HANDLER] ); } + + /** + * @dataProvider allReadersProvider + * @requires PHP >= 8.1 + */ + public function testBackedEnum(ReaderInterface $reader) + { + $tokenizer = new Tokenizer(new TokenizerConfig([ + 'directories' => [__DIR__ . '/../../../Fixtures/Fixtures19'], + 'exclude' => [], + ])); + + $locator = $tokenizer->classLocator(); + + $r = new Registry($this->dbal); + + $schema = (new Compiler())->compile($r, [ + new Embeddings($locator, $reader), + new Entities($locator, $reader), + new ResetTables(), + new MergeColumns($reader), + new GenerateRelations(), + new RenderTables(), + new RenderRelations(), + new MergeIndexes($reader), + new SyncTables(), + new GenerateTypecast(), + ]); + + $this->assertSame( + [ + 'bid' => 'int', + 'be' => BackedEnum::class, + 'bew' => [BackedEnumWrapper::class, 'typecast'], + ], + $schema['booking'][SchemaInterface::TYPECAST] + ); + } } diff --git a/tests/Annotated/Functional/Driver/MySQL/ObsoleteTest.php b/tests/Annotated/Functional/Driver/MySQL/ObsoleteTest.php new file mode 100644 index 00000000..8d4675ee --- /dev/null +++ b/tests/Annotated/Functional/Driver/MySQL/ObsoleteTest.php @@ -0,0 +1,17 @@ +getDatabase()->table($table)->getSchema(); diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 5ee2054f..555eee61 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: sqlserver: image: mcr.microsoft.com/mssql/server:2019-latest diff --git a/tests/generate.php b/tests/generate.php index 368bf6f4..a972201f 100644 --- a/tests/generate.php +++ b/tests/generate.php @@ -1,7 +1,31 @@