Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@

- [UniqueConstraint](unique-constraint)
- [Migration to 5.0](migrate_5.0)
- [Migration to 6.0](migrate_6.0)
13 changes: 13 additions & 0 deletions docs/migrate_6.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Migration Guide for 6.0

### BC Breaks

- **`IProperty::setRawValue()` must not validate the value** - the method now only stores the passed raw value; it must neither convert it to the runtime representation nor validate it. Both the conversion and any validation are deferred to read time (`getInjectedValue()` / `getRawValue()`).

This fixes premature initialization of non-nullable wrapped properties — reading such a property via `IEntity::getProperty()`, or creating an entity through `EntityCreator`, no longer triggers validation before the actual value is set.

If you maintain a **custom property wrapper**:
- Any validation previously performed in `setRawValue()` must be moved to read time — `setRawValue()` may be called with a value that is not yet known to be valid (or that is about to be overwritten before it is ever read).
- `Nextras\Orm\Entity\ImmutableValuePropertyWrapper` already implements this: it keeps the raw value, converts it lazily via `convertFromRawValue()` on read, and validates nullability centrally. Your `convertFromRawValue()` is free to validate the (converted) value as before — it is simply invoked lazily now.

As a consequence, an invalid raw value now surfaces its exception when the value is **read** rather than when it is **set** (i.e. during hydration / `setRawValue()`).
17 changes: 13 additions & 4 deletions src/Entity/IProperty.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,28 @@ public function convertToRawValue($value);

/**
* Sets a fetched raw value from a storage.
* Calling this method directly may BREAK things.
* Implementation must not require entity instance.
*
* Expectations:
* - Calling this method directly may BREAK things.
* - Implementation must not require entity instance.
* - Implementation must not validate the value (e.g. nullability of a non-nullable property); a raw value is set
* during property instance creation and the missing value reflected as null is passed. Any validation is
* therefore deferred to read time ({@see getRawValue()} / value read).
*
* This method is not symmetric to {@see getRawValue()}.
* @param mixed $value
*
* @internal
* @param mixed $value
*/
public function setRawValue($value): void;


/**
* Returns raw value.
* Raw value is a normalized value which is suitable for storing.
*
* Raw value is a normalized and validated value which is suitable for storing.
* This method is not symmetric to {@see setRawValue()}.
*
* @return mixed
*/
public function getRawValue();
Expand Down
39 changes: 34 additions & 5 deletions src/Entity/ImmutableValuePropertyWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@


use Nextras\Orm\Entity\Reflection\PropertyMetadata;
use Nextras\Orm\Exception\NullValueException;


abstract class ImmutableValuePropertyWrapper implements IPropertyContainer
{
/** @var mixed */
/** @var mixed converted runtime value; meaningful only when {@see $validated} is true */
protected $value;

/** @var mixed raw value pending conversion; meaningful only when {@see $validated} is false */
private $rawValue;

/** whether the currently held value has already been converted and validated */
private bool $validated = true;


public function __construct(
protected readonly PropertyMetadata $propertyMetadata,
Expand All @@ -19,40 +26,62 @@ public function __construct(
}


/**
* Sets a fetched raw value.
*
* The raw value is stored as-is; it is neither converted nor validated here. Both the conversion to the runtime
* representation and the nullability validation are deferred to read time ({@see getInjectedValue()} /
* {@see getRawValue()}), therefore a raw value may be set even before it is known whether it is valid.
*/
public function setRawValue($value): void
{
$this->value = $this->convertFromRawValue($value);
$this->rawValue = $value;
$this->validated = false;
}


public function getRawValue()
{
return $this->convertToRawValue($this->value);
return $this->convertToRawValue($this->getInjectedValue());
}


public function setInjectedValue($value): bool
{
$this->value = $value;
$this->rawValue = null;
$this->validated = true;
return true;
}


public function hasInjectedValue(): bool
{
return $this->value !== null;
return ($this->validated ? $this->value : $this->rawValue) !== null;
}


public function &getInjectedValue()
{
if (!$this->validated) {
$this->value = $this->convertFromRawValue($this->rawValue);
$this->rawValue = null;
$this->validated = true;
}
if ($this->value === null && !$this->propertyMetadata->isNullable) {
throw new NullValueException($this->propertyMetadata);
}
return $this->value;
}


/**
* Converts passed value from raw value suitable for storing to runtime representation.
* Implementation must not require entity instance.
*
* - Implementation must not require entity instance.
* - The conversion is performed lazily on read; it must return null for a null input (nullability is validated
* centrally) but is allowed to throw for an otherwise invalid value.
*
* @param mixed $value
* @return mixed
* @internal
Expand Down
3 changes: 1 addition & 2 deletions src/Entity/PropertyWrapper/BackedEnumWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ public function convertToRawValue(mixed $value): mixed
public function convertFromRawValue(mixed $value): ?BackedEnum
{
if ($value === null) {
if ($this->propertyMetadata->isNullable) return null;
throw new NullValueException($this->propertyMetadata);
return null;
}

$type = array_key_first($this->propertyMetadata->types);
Expand Down
4 changes: 0 additions & 4 deletions src/Entity/PropertyWrapper/DateTimeWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ public function convertToRawValue(mixed $value): mixed

public function convertFromRawValue($value)
{
if ($value === null && !$this->propertyMetadata->isNullable) {
throw new NullValueException($this->propertyMetadata);
}

// The string conversion from raw values is used when using {default} modifier in property definition.
// This string value is considered to be a raw value.
if (is_string($value)) {
Expand Down
4 changes: 0 additions & 4 deletions src/Entity/PropertyWrapper/DateWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ public function convertToRawValue(mixed $value): mixed

public function convertFromRawValue($value)
{
if ($value === null && !$this->propertyMetadata->isNullable) {
throw new NullValueException($this->propertyMetadata);
}

// The string conversion from raw values is used when using {default} modifier in property definition.
// This string value is considered to be a raw value.
if (is_string($value)) {
Expand Down
45 changes: 45 additions & 0 deletions tests/cases/integration/TestHelper/entityCreator.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types = 1);

/**
* @testCase
*/

namespace NextrasTests\Orm\Integration\TestHelper;


use DateTimeImmutable;
use Nextras\Orm\TestHelper\EntityCreator;
use NextrasTests\Orm\Comment;
use NextrasTests\Orm\TestCase;
use NextrasTests\Orm\Thread;
use Tester\Assert;


require_once __DIR__ . '/../../../bootstrap.php';


class EntityCreatorTest extends TestCase
{
/**
* A non-nullable property backed by a value wrapper (here \DateTimeImmutable) must be created with the value
* passed in params, without the wrapper being prematurely initialized with a null and throwing NullValueException.
* @see https://github.com/nextras/orm/pull/811
*/
public function testNonNullableWrappedProperty(): void
{
$creator = $this->container->getByType(EntityCreator::class);

$repliedAt = new DateTimeImmutable('2020-01-01 18:00:00');
$comment = $creator->create(Comment::class, [
'thread' => new Thread(),
'repliedAt' => $repliedAt,
]);

Assert::type(Comment::class, $comment);
Assert::equal($repliedAt, $comment->repliedAt);
}
}


$test = new EntityCreatorTest();
$test->run();
97 changes: 97 additions & 0 deletions tests/cases/unit/Entity/ImmutableValuePropertyWrapper.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php declare(strict_types = 1);

/**
* @testCase
*/

namespace NextrasTests\Orm\Entity;


use Mockery;
use Nextras\Dbal\Utils\DateTimeImmutable;
use Nextras\Orm\Entity\PropertyWrapper\DateTimeWrapper;
use Nextras\Orm\Entity\Reflection\PropertyMetadata;
use Nextras\Orm\Exception\InvalidPropertyValueException;
use Nextras\Orm\Exception\NullValueException;
use NextrasTests\Orm\TestCase;
use Tester\Assert;


require_once __DIR__ . '/../../../bootstrap.php';


class ImmutableValuePropertyWrapperTest extends TestCase
{
/**
* Setting a raw null must not validate; the actual value may not be known yet (e.g. when the wrapper is
* instantiated by {@see IEntity::getProperty()} before the value from the user is applied).
*/
public function testSetRawValueDoesNotValidateNull(): void
{
$wrapper = new DateTimeWrapper($this->createMetadata(isNullable: false));

Assert::noError(function () use ($wrapper): void {
$wrapper->setRawValue(null);
});
}


/**
* Nullability of a non-nullable property is validated lazily, on read.
*/
public function testReadValidatesNullOnNonNullable(): void
{
$wrapper = new DateTimeWrapper($this->createMetadata(isNullable: false));
$wrapper->setRawValue(null);

Assert::throws(function () use ($wrapper): void {
$wrapper->getRawValue();
}, NullValueException::class);

Assert::throws(function () use ($wrapper): void {
$wrapper->getInjectedValue();
}, NullValueException::class);
}


public function testNullableAllowsNull(): void
{
$wrapper = new DateTimeWrapper($this->createMetadata(isNullable: true));
$wrapper->setRawValue(null);

Assert::null($wrapper->getRawValue());
Assert::null($wrapper->getInjectedValue());
}


/**
* The conversion of the raw value is deferred to read time; a malformed raw value therefore does not throw when
* set, only when it is actually read.
*/
public function testConversionIsDeferredToRead(): void
{
$wrapper = new DateTimeWrapper($this->createMetadata(isNullable: true));

Assert::noError(function () use ($wrapper): void {
$wrapper->setRawValue('');
});

Assert::throws(function () use ($wrapper): void {
$wrapper->getInjectedValue();
}, InvalidPropertyValueException::class);
}


private function createMetadata(bool $isNullable): PropertyMetadata
{
$metadata = Mockery::mock(PropertyMetadata::class);
$metadata->name = 'createdAt';
$metadata->isNullable = $isNullable;
$metadata->types = [DateTimeImmutable::class => true];
return $metadata;
}
}


$test = new ImmutableValuePropertyWrapperTest();
$test->run();