diff --git a/composer.json b/composer.json index ffdebd10..9ef40cda 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,6 @@ "nette/di": "^3.1", "nette/neon": "~3.0", "nette/tester": "~2.5", - "marc-mabe/php-enum": "~4.6", "mockery/mockery": ">=1.5.1", "phpstan/extension-installer": "1.2.0", "phpstan/phpstan": "1.10.9", @@ -41,7 +40,6 @@ "phpstan/phpstan-strict-rules": "1.4.5", "nextras/multi-query-parser": "~1.0", "nextras/orm-phpstan": "~1.0@dev", - "marc-mabe/php-enum-phpstan": "dev-master", "tracy/tracy": "~2.3" }, "autoload": { diff --git a/docs/entity.md b/docs/entity.md index cea5ec5e..7377e078 100644 --- a/docs/entity.md +++ b/docs/entity.md @@ -10,16 +10,22 @@ Data is accessible through properties. You have to annotate all properties that * @property string $name * @property DateTimeImmutable $born * @property string|null $web + * @property AdminLevel $adminLevel * @property-read int $age */ class Member extends Nextras\Orm\Entity\Entity { } + +enum AdminLevel: int { + case Full = 1; + case Moderator = 2; +} ``` Phpdoc property definition consists of its type and name. If you would like to use read-only property, define it with `@property-read` annotation; such annotation is useful to define properties which are based on values of other properties. Properties could be optional/nullable; to do that, just provide another type - `null` or you could use it by prefixing the type name with a question mark - `?string`. -If you put some value into the property, the value will be validated by property type annotation. Type casting is performed if it is possible and safe. Supported types are `null`, `string`, `int`, `float`, `array`, `mixed` and object types. Validation is provided on all properties, except for properties defined with property wrapper - in that case validation should do its property wrapper. +If you put some value into the property, the value will be validated by property type annotation. Type casting is performed if it is possible and safe. Supported types are `null`, `string`, `int`, `float`, `array`, `mixed`, enum (backed) types and object types. Validation is provided on all properties, except for properties defined with property wrapper - in that case validation is responsibility of the property wrapper. PHP 8.1 enums are validated and their backed value is used for the storage layer. Nextras Orm also provides enhanced support for date time handling. However, only "safe" `DateTimeImmutable` instances are supported as a property type. You may put a common `DateTime` instance as a value, but it will be automatically converted to DateTimeImmutable. Also, auto date string conversion is supported. @@ -91,7 +97,6 @@ Each property can be annotated with a modifier. Modifiers are optional and provi - `{virtual}` - marks property as "do not persist in storage"; - `{embeddable}` - encapsulates multiple properties into one wrapping object; - `{wrapper PropertyWrapperClassName}` - sets property wrapper; -- `{enum self::TYPE_*}` - enables extended validation against values enumeration; we recommend using object enum types instead of scalar enum types; - `{1:m TargetEntity::$property}` - see [relationships](relationships). - `{m:1 TargetEntity::$property}` - see [relationships](relationships). - `{m:m TargetEntity::$property}` - see [relationships](relationships). @@ -231,23 +236,6 @@ class JsonWrapper extends ImmutableValuePropertyWrapper } ``` -#### `{enum}` - -You can easily validate passed value by value enumeration. To set the enumeration validation, use `enum` modifier with the list of constants (separated by a space); or pass a constant name with a wildcard. - -```php -/** - * ... - * @property int $type {enum self::TYPE_*} - */ -class Event extends Nextras\Orm\Entity\Entity -{ - const TYPE_PUBLIC = 0; - const TYPE_PRIVATE = 1; - const TYPE_ANOTHER = 2; -} -``` - ### Entity dependencies Your entity can require some dependency to work. Orm comes with `Nextras\Orm\Repository\IDependencyProvider` interface, which takes care about injecting needed dependencies. If you use `OrmExtension` for `Nette\DI`, it will automatically call standard DI injections (injection methods and `@inject` annotation). Dependencies are injected when an entity is attached to the repository. diff --git a/src/Entity/AbstractEntity.php b/src/Entity/AbstractEntity.php index 65916ee9..89ea23df 100644 --- a/src/Entity/AbstractEntity.php +++ b/src/Entity/AbstractEntity.php @@ -199,7 +199,7 @@ public function getRawValues(bool $modifiedOnly = false): array } else { $out[$name] = $this->getProperty($name)->getRawValue(); if ($out[$name] === null && !$propertyMetadata->isNullable) { - throw new NullValueException($this, $propertyMetadata); + throw new NullValueException($propertyMetadata); } } } diff --git a/src/Entity/Embeddable/Embeddable.php b/src/Entity/Embeddable/Embeddable.php index 11726aa6..270cf7b1 100644 --- a/src/Entity/Embeddable/Embeddable.php +++ b/src/Entity/Embeddable/Embeddable.php @@ -144,7 +144,7 @@ private function createPropertyWrapper(PropertyMetadata $metadata): IProperty if ($wrapper instanceof IEntityAwareProperty) { if ($this->parentEntity === null) { - throw new InvalidStateException(""); + throw new InvalidStateException("Embeddable cannot contain a property having IEntityAwareProperty wrapper because embeddable is instanced before setting/attaching to its entity."); } else { $wrapper->onEntityAttach($this->parentEntity); } diff --git a/src/Entity/Embeddable/EmbeddableContainer.php b/src/Entity/Embeddable/EmbeddableContainer.php index e7541cc8..281069a8 100644 --- a/src/Entity/Embeddable/EmbeddableContainer.php +++ b/src/Entity/Embeddable/EmbeddableContainer.php @@ -121,7 +121,7 @@ public function setInjectedValue($value): bool if ($value !== null && !$value instanceof $this->instanceType) { throw new InvalidArgumentException("Value has to be instance of {$this->instanceType}" . ($this->metadata->isNullable ? ' or a null.' : '.')); } elseif ($value === null && !$this->metadata->isNullable) { - throw new NullValueException($this->entity, $this->metadata); + throw new NullValueException($this->metadata); } if ($value !== null) { diff --git a/src/Entity/PropertyWrapper/BackedEnumWrapper.php b/src/Entity/PropertyWrapper/BackedEnumWrapper.php new file mode 100644 index 00000000..ec6dff90 --- /dev/null +++ b/src/Entity/PropertyWrapper/BackedEnumWrapper.php @@ -0,0 +1,50 @@ +propertyMetadata->isNullable) { + throw new NullValueException($this->propertyMetadata); + } + + return parent::setInjectedValue($value); + } + + + public function convertToRawValue(mixed $value): mixed + { + if ($value === null) return null; + $type = array_key_first($this->propertyMetadata->types); + assert($value instanceof $type); + assert($value instanceof BackedEnum); + return $value->value; + } + + + public function convertFromRawValue(mixed $value): ?BackedEnum + { + if ($value === null) { + if ($this->propertyMetadata->isNullable) return null; + throw new NullValueException($this->propertyMetadata); + } + + assert(is_int($value) || is_string($value)); + $type = array_key_first($this->propertyMetadata->types); + assert(is_subclass_of($type, BackedEnum::class)); + return $type::from($value); + } +} diff --git a/src/Entity/Reflection/MetadataParser.php b/src/Entity/Reflection/MetadataParser.php index 6e524967..b3b6ae6d 100644 --- a/src/Entity/Reflection/MetadataParser.php +++ b/src/Entity/Reflection/MetadataParser.php @@ -3,6 +3,7 @@ namespace Nextras\Orm\Entity\Reflection; +use BackedEnum; use DateTime; use Nette\Utils\Reflection; use Nextras\Orm\Collection\ICollection; @@ -10,6 +11,7 @@ use Nextras\Orm\Entity\Embeddable\IEmbeddable; use Nextras\Orm\Entity\IEntity; use Nextras\Orm\Entity\IProperty; +use Nextras\Orm\Entity\PropertyWrapper\BackedEnumWrapper; use Nextras\Orm\Exception\InvalidStateException; use Nextras\Orm\Exception\NotSupportedException; use Nextras\Orm\Relationships\HasMany; @@ -174,7 +176,7 @@ protected function parseAnnotations(ReflectionClass $reflection, array $methods) { preg_match_all( '~^[ \t*]* @property(|-read|-write)[ \t]+([^\s$]+)[ \t]+\$(\w+)(.*)$~um', - (string) $reflection->getDocComment(), $matches, PREG_SET_ORDER + (string) $reflection->getDocComment(), $matches, PREG_SET_ORDER, ); $properties = []; @@ -183,6 +185,7 @@ protected function parseAnnotations(ReflectionClass $reflection, array $methods) $property = new PropertyMetadata(); $property->name = $variable; + $property->containerClassname = $reflection->getName(); $property->isReadonly = $isReadonly; $this->parseAnnotationTypes($property, $type); @@ -228,7 +231,7 @@ protected function parseAnnotationTypes(PropertyMetadata $property, string $type $typeLower = substr($typeLower, 1); $type = substr($type, 1); } - if (strpos($type, '[') !== false) { // string[] + if (str_contains($type, '[')) { // string[] $type = 'array'; } elseif (isset($types[$typeLower])) { $type = $typeLower; @@ -240,12 +243,18 @@ protected function parseAnnotationTypes(PropertyMetadata $property, string $type if ($type === DateTime::class || is_subclass_of($type, DateTime::class)) { throw new NotSupportedException("Type '{$type}' in {$this->currentReflection->name}::\${$property->name} property is not supported anymore. Use \DateTimeImmutable or \Nextras\Dbal\Utils\DateTimeImmutable type."); } + if (is_subclass_of($type, BackedEnum::class)) { + $property->wrapper = BackedEnumWrapper::class; + } } $parsedTypes[$type] = true; } $property->isNullable = $isNullable || isset($parsedTypes['null']) || isset($parsedTypes['NULL']) || isset($parsedTypes['mixed']); unset($parsedTypes['null'], $parsedTypes['NULL']); + if (count($parsedTypes) < 1) { + throw new NotSupportedException("Property {$this->currentReflection->name}::\${$property->name} without a type definition is not supported."); + } $property->types = $parsedTypes; } diff --git a/src/Entity/Reflection/PropertyMetadata.php b/src/Entity/Reflection/PropertyMetadata.php index 3310c79a..1a625fdd 100644 --- a/src/Entity/Reflection/PropertyMetadata.php +++ b/src/Entity/Reflection/PropertyMetadata.php @@ -24,47 +24,35 @@ class PropertyMetadata use SmartObject; - /** @var string property name */ - public $name = ''; + public string $name = ''; + public string $containerClassname = ''; + public ?string $wrapper = null; - /** @var string|null */ - public $wrapper; + public ?string $hasGetter = null; + public ?string $hasSetter = null; - /** @var string|null */ - public $hasGetter; + /** @var non-empty-array of allowed types defined as keys */ + public array $types; - /** @var string|null */ - public $hasSetter; - - /** @var array of allowed types defined as keys */ - public $types = []; - - /** @var bool */ - public $isPrimary = false; - - /** @var bool */ - public $isNullable = false; - - /** @var bool */ - public $isReadonly = false; - - /** @var bool */ - public $isVirtual = false; - - /** @var mixed */ - public $defaultValue; - - /** @var PropertyRelationshipMetadata|null */ - public $relationship; + public bool $isPrimary = false; + public bool $isNullable = false; + public bool $isReadonly = false; + public bool $isVirtual = false; + public mixed $defaultValue = null; + public ?PropertyRelationshipMetadata $relationship = null; /** @var array|null */ - public $args; + public ?array $args = null; /** @var array|null array of allowed values */ - public $enum; + public ?array $enum = null; + + private ?IProperty $wrapperPrototype = null; + - /** @var IProperty|null */ - private $wrapperPrototype; + public function __construct() + { + } public function getWrapperPrototype(): IProperty @@ -85,6 +73,7 @@ public function __sleep() // we skip wrapperPrototype which may not be serializable and is created lazily return [ 'name', + 'containerClassname', 'wrapper', 'hasGetter', 'hasSetter', diff --git a/src/Exception/NullValueException.php b/src/Exception/NullValueException.php index 4fc81c66..f1482a9d 100644 --- a/src/Exception/NullValueException.php +++ b/src/Exception/NullValueException.php @@ -3,15 +3,13 @@ namespace Nextras\Orm\Exception; -use Nextras\Orm\Entity\IEntity; use Nextras\Orm\Entity\Reflection\PropertyMetadata; class NullValueException extends InvalidArgumentException { - public function __construct(IEntity $entity, PropertyMetadata $propertyMetadata) + public function __construct(PropertyMetadata $propertyMetadata) { - $class = get_class($entity); - parent::__construct("Property {$class}::\${$propertyMetadata->name} is not nullable."); + parent::__construct("Property {$propertyMetadata->containerClassname}::\${$propertyMetadata->name} is not nullable."); } } diff --git a/src/Relationships/HasOne.php b/src/Relationships/HasOne.php index cc847cd3..eddb23d6 100644 --- a/src/Relationships/HasOne.php +++ b/src/Relationships/HasOne.php @@ -189,7 +189,7 @@ public function getEntity(): ?IEntity if ($value === null && !$this->metadata->isNullable) { assert($this->parent !== null); - throw new NullValueException($this->parent, $this->metadata); + throw new NullValueException($this->metadata); } return $value; @@ -313,7 +313,7 @@ protected function createEntity($entity, bool $allowNull): ?IEntity } elseif ($entity === null) { if (!$this->metadata->isNullable && !$allowNull) { - throw new NullValueException($this->getParentEntity(), $this->metadata); + throw new NullValueException($this->metadata); } return null; diff --git a/tests/cases/integration/Collection/collection.embeddables.phpt b/tests/cases/integration/Collection/collection.embeddables.phpt index e53d8797..7b7dd40a 100644 --- a/tests/cases/integration/Collection/collection.embeddables.phpt +++ b/tests/cases/integration/Collection/collection.embeddables.phpt @@ -30,7 +30,7 @@ class CollectionEmbeddablesTest extends DataTestCase Assert::same(0, $books1->countStored()); $book = $this->orm->books->getByIdChecked(1); - $book->price = new Money(1000, Currency::CZK()); + $book->price = new Money(1000, Currency::CZK); $this->orm->persistAndFlush($book); $books2 = $this->orm->books->findBy(['price->cents>=' => 1000]); diff --git a/tests/cases/integration/Collection/collection.where.phpt b/tests/cases/integration/Collection/collection.where.phpt index 91e34446..ce95a0f9 100644 --- a/tests/cases/integration/Collection/collection.where.phpt +++ b/tests/cases/integration/Collection/collection.where.phpt @@ -98,25 +98,25 @@ class CollectionWhereTest extends DataTestCase public function testFilterByPropertyWrapper(): void { - $ean8 = new Ean(EanType::EAN8()); + $ean8 = new Ean(EanType::EAN8); $ean8->code = '123'; $ean8->book = $this->orm->books->getByIdChecked(1); $this->orm->persist($ean8); - $ean13 = new Ean(EanType::EAN13()); + $ean13 = new Ean(EanType::EAN13); $ean13->code = '456'; $ean13->book = $this->orm->books->getByIdChecked(2); $this->orm->persistAndFlush($ean13); Assert::count(2, $this->orm->eans->findAll()); - $eans = $this->orm->eans->findBy(['type' => EanType::EAN8()]); + $eans = $this->orm->eans->findBy(['type' => EanType::EAN8]); Assert::count(1, $eans); $fetched = $eans->fetch(); Assert::notNull($fetched); Assert::equal('123', $fetched->code); - $eans = $this->orm->eans->findBy(['type' => EanType::EAN13()]); + $eans = $this->orm->eans->findBy(['type' => EanType::EAN13]); Assert::count(1, $eans); $fetched = $eans->fetch(); Assert::notNull($fetched); diff --git a/tests/cases/integration/Entity/entity.embeddable.phpt b/tests/cases/integration/Entity/entity.embeddable.phpt index 3a807ed0..40b8e6ef 100644 --- a/tests/cases/integration/Entity/entity.embeddable.phpt +++ b/tests/cases/integration/Entity/entity.embeddable.phpt @@ -25,9 +25,9 @@ class EntityEmbeddableTest extends DataTestCase public function testBasic(): void { $book = $this->orm->books->getByIdChecked(1); - $book->price = new Money(1000, Currency::CZK()); + $book->price = new Money(1000, Currency::CZK); Assert::same(1000, $book->price->cents); - Assert::same(Currency::CZK(), $book->price->currency); + Assert::same(Currency::CZK, $book->price->currency); $this->orm->persistAndFlush($book); $this->orm->clear(); @@ -36,7 +36,7 @@ class EntityEmbeddableTest extends DataTestCase Assert::notNull($book->price); Assert::same(1000, $book->price->cents); - Assert::same(Currency::CZK(), $book->price->currency); + Assert::same(Currency::CZK, $book->price->currency); $book->price = null; $this->orm->persistAndFlush($book); @@ -50,8 +50,8 @@ class EntityEmbeddableTest extends DataTestCase public function testMultiple(): void { $book = $this->orm->books->getByIdChecked(1); - $book->price = new Money(1000, Currency::CZK()); - $book->origPrice = new Money(330, Currency::EUR()); + $book->price = new Money(1000, Currency::CZK); + $book->origPrice = new Money(330, Currency::EUR); $this->orm->persistAndFlush($book); $this->orm->clear(); @@ -73,7 +73,7 @@ class EntityEmbeddableTest extends DataTestCase Assert::throws(function (): void { $book = new Book(); // @phpstan-ignore-next-line - $book->price = (object) ['price' => 100, 'currency' => Currency::CZK()]; + $book->price = (object) ['price' => 100, 'currency' => Currency::CZK]; }, InvalidArgumentException::class); } @@ -82,7 +82,7 @@ class EntityEmbeddableTest extends DataTestCase { $book = $this->orm->books->getByIdChecked(1); - $book->price = new Money(1000, Currency::CZK()); + $book->price = new Money(1000, Currency::CZK); Assert::same(1000, $book->price->cents); $book->price = null; diff --git a/tests/cases/integration/Entity/entity.nullValidation.phpt b/tests/cases/integration/Entity/entity.nullValidation.phpt index 4360bab4..be72a794 100644 --- a/tests/cases/integration/Entity/entity.nullValidation.phpt +++ b/tests/cases/integration/Entity/entity.nullValidation.phpt @@ -11,6 +11,7 @@ use Nextras\Orm\Exception\InvalidArgumentException; use Nextras\Orm\Exception\InvalidStateException; use Nextras\Orm\Exception\NullValueException; use NextrasTests\Orm\Book; +use NextrasTests\Orm\Ean; use NextrasTests\Orm\TestCase; use Tester\Assert; @@ -32,6 +33,11 @@ class EntityNullValidationTest extends TestCase $book->author = null; // @phpstan-ignore-line }, NullValueException::class, 'Property NextrasTests\Orm\Book::$author is not nullable.'); + Assert::throws(function (): void { + $ean = new Ean(); + $ean->type = null; // @phpstan-ignore-line + }, NullValueException::class, 'Property NextrasTests\Orm\Ean::$type is not nullable.'); + $book = new Book(); $book->translator = null; } diff --git a/tests/cases/unit/Collection/FetchPairsHelperTest.phpt b/tests/cases/unit/Collection/FetchPairsHelperTest.phpt index bc4ec155..3c046044 100644 --- a/tests/cases/unit/Collection/FetchPairsHelperTest.phpt +++ b/tests/cases/unit/Collection/FetchPairsHelperTest.phpt @@ -175,11 +175,11 @@ class FetchPairsHelperTest extends TestCase $data = new ArrayIterator([ $this->e( Book::class, - ['price' => new Money(100, Currency::CZK())] + ['price' => new Money(100, Currency::CZK)] ), $this->e( Book::class, - ['price' => new Money(200, Currency::CZK())] + ['price' => new Money(200, Currency::CZK)] ), ]); Assert::same( diff --git a/tests/db/array-data.php b/tests/db/array-data.php index 8db63bc2..bf3c0b08 100644 --- a/tests/db/array-data.php +++ b/tests/db/array-data.php @@ -44,7 +44,7 @@ $book1->translator = $author1; $book1->publisher = $publisher1; $book1->publishedAt = new \DateTimeImmutable('2021-12-14 21:10:04'); -$book1->price = new Money(50, Currency::CZK()); +$book1->price = new Money(50, Currency::CZK); $book1->tags->set([$tag1, $tag2]); $orm->books->persist($book1); @@ -53,7 +53,7 @@ $book2->author = $author1; $book2->publisher = $publisher2; $book2->publishedAt = new \DateTimeImmutable('2021-12-14 21:10:02'); -$book2->price = new Money(150, Currency::CZK()); +$book2->price = new Money(150, Currency::CZK); $book2->tags->set([$tag2, $tag3]); $orm->books->persist($book2); @@ -63,7 +63,7 @@ $book3->translator = $author2; $book3->publisher = $publisher3; $book3->publishedAt = new \DateTimeImmutable('2021-12-14 21:10:03'); -$book3->price = new Money(20, Currency::CZK()); +$book3->price = new Money(20, Currency::CZK); $book3->tags->set([$tag3]); $orm->books->persist($book3); @@ -74,7 +74,7 @@ $book4->publisher = $publisher1; $book4->nextPart = $book3; $book4->publishedAt = new \DateTimeImmutable('2021-12-14 21:10:01'); -$book4->price = new Money(220, Currency::CZK()); +$book4->price = new Money(220, Currency::CZK); $orm->books->persist($book4); $tagFollower1 = new TagFollower(); diff --git a/tests/inc/Currency.php b/tests/inc/Currency.php index b5f5a1b0..39ff7072 100644 --- a/tests/inc/Currency.php +++ b/tests/inc/Currency.php @@ -3,13 +3,10 @@ namespace NextrasTests\Orm; -use MabeEnum\Enum; - - -class Currency extends Enum +enum Currency: string { - const CZK = 'CZK'; - const EUR = 'EUR'; - const GBP = 'GBP'; - const USD = 'USD'; + case CZK = 'CZK'; + case EUR = 'EUR'; + case GBP = 'GBP'; + case USD = 'USD'; } diff --git a/tests/inc/Money.php b/tests/inc/Money.php index af075fb3..33f49919 100644 --- a/tests/inc/Money.php +++ b/tests/inc/Money.php @@ -8,7 +8,7 @@ /** * @property-read int $cents - * @property-read Currency $currency {wrapper TestEnumPropertyWrapper} + * @property-read Currency $currency */ class Money extends Embeddable { diff --git a/tests/inc/TestEnumPropertyWrapper.php b/tests/inc/TestEnumPropertyWrapper.php deleted file mode 100644 index 34875d54..00000000 --- a/tests/inc/TestEnumPropertyWrapper.php +++ /dev/null @@ -1,38 +0,0 @@ -types) === 1); - $this->enumClass = key($propertyMetadata->types); - assert(class_exists($this->enumClass)); - } - - - public function convertToRawValue($value) - { - assert($value instanceof Enum); - return $value->getValue(); - } - - - public function convertFromRawValue($value) - { - $enumClass = $this->enumClass; - return $enumClass::byValue($value); - } -} diff --git a/tests/inc/model/ean/Ean.php b/tests/inc/model/ean/Ean.php index c5955ca5..e8dbc886 100644 --- a/tests/inc/model/ean/Ean.php +++ b/tests/inc/model/ean/Ean.php @@ -10,13 +10,13 @@ * @property int|null $id {primary} * @property string $code * @property Book $book {1:1 Book::$ean} - * @property EanType $type {wrapper TestEnumPropertyWrapper} + * @property EanType $type */ class Ean extends Entity { - public function __construct(EanType $type = null) + public function __construct(EanType $type = EanType::EAN8) { parent::__construct(); - $this->type = $type ?? EanType::EAN8(); + $this->type = $type; } } diff --git a/tests/inc/model/ean/EanType.php b/tests/inc/model/ean/EanType.php index 63fef079..1eef8f51 100644 --- a/tests/inc/model/ean/EanType.php +++ b/tests/inc/model/ean/EanType.php @@ -3,12 +3,9 @@ namespace NextrasTests\Orm; -use MabeEnum\Enum; - - -class EanType extends Enum +enum EanType: int { - const EAN13 = 1; - const EAN8 = 2; - const CODE39 = 3; + case EAN13 = 1; + case EAN8 = 2; + case CODE39 = 3; }