Skip to content

Commit

Permalink
Merge pull request Crell#22 from Crell/uninitialized
Browse files Browse the repository at this point in the history
Allow fields to require a value be provided on deserialiation.
  • Loading branch information
Crell authored Mar 23, 2023
2 parents f27d82d + eafd0e5 commit 2fe4bc7
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 5 deletions.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ class Person

Which you do is mostly a matter of preference, although if you are mixing Serde attributes with attributes from other libraries then the namespaced approach is advisable.

There is also a `ClassSettings` attribute that may be placed on classes to be serialized. At this time it has only one argument, `includeFieldsByDefault`, which defaults to `true`. If set to false, a property with no `#[Field]` attribute will be ignored. It is equivalent to setting `exclude: true` on all properties implicitly.
There is also a `ClassSettings` attribute that may be placed on classes to be serialized. At this time it has three arguments:

* `includeFieldsByDefault`, which defaults to `true`. If set to false, a property with no `#[Field]` attribute will be ignored. It is equivalent to setting `exclude: true` on all properties implicitly.
* `requireValues`, which defaults to `false`. If set to true, then when deserializing any field that is not provided in the incoming data will result in an exception. This may also be turned on or off on a per-field level. (See `requireValue` below.) The class-level setting applies to any field that does not specify its behavior.
* `scopes`, which sets the scope of a given class definition attribute. See the section on Scopes below.

### `exclude` (bool, default false)

Expand Down Expand Up @@ -278,6 +282,12 @@ This key only applies on deserialization. If set to `true`, a type mismatch in

The exact handling of this setting may vary slightly depending on the incoming format, as some formats handle their own types differently. (For instance, everything is a string in XML.)

### `requireValue` (bool, default false)

This key only applies on deserialization. If set to `true`, if the incoming data does not include a value for this field and there is no default specified, a `MissingRequiredValueWhenDeserializing` exception will be thrown. If not set, and there is no default value, then the property will be left uninitialized.

If a field has a default value, then the default value will always be used for missing data and this setting has no effect.

### `flatten` (bool, default false)

The `flatten` keyword can only be applied on an array or object property. A property that is "flattened" will have all of its properties injected into the parent directly on serialization, and will have values from the parent "collected" into it on deserialization.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
],
"require": {
"php": "~8.1",
"crell/attributeutils": "~0.8.0",
"crell/attributeutils": "~0.8.2",
"crell/fp": ">= 0.3.3"
},
"require-dev": {
Expand Down
13 changes: 13 additions & 0 deletions src/Attributes/ClassSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,24 @@ class ClassSettings implements FromReflectionClass, ParseProperties, HasSubAttri
public readonly array $postLoadCallacks;

/**
* @param bool $includeFieldsByDefault
* If true, all fields will be included when serializing and deserializing unless
* the individual field opts-out with #[Field(exclude: true)]. If false, all fields
* will be ignored unless they have a #[Field] directive.
* @param array<string|null> $scopes
* If specified, this ClassSettings entry will be included only when operating in
* the specified scopes. To also be included in the default "unscoped" case,
* include an array element of `null`, or include a non-scoped copy of the
* Field.
* @param bool $requireValues
* If true, all fields will be required when deserializing into this object.
* If false, fields will not be required and unset fields will be left uninitialized.
* this may be overridden on a per-field basis with #[Field(requireValue: true)]
*/
public function __construct(
public readonly bool $includeFieldsByDefault = true,
public readonly array $scopes = [null],
public readonly bool $requireValues = false,
) {}

public function fromReflection(\ReflectionClass $subject): void
Expand Down
32 changes: 29 additions & 3 deletions src/Attributes/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use Attribute;
use Crell\AttributeUtils\Excludable;
use Crell\AttributeUtils\Finalizable;
use Crell\AttributeUtils\FromReflectionProperty;
use Crell\AttributeUtils\HasSubAttributes;
use Crell\AttributeUtils\ReadsClass;
use Crell\AttributeUtils\SupportsScopes;
use Crell\fp\Evolvable;
use Crell\Serde\FieldTypeIncompatible;
Expand All @@ -25,7 +27,7 @@
use function Crell\fp\pipe;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
class Field implements FromReflectionProperty, HasSubAttributes, Excludable, SupportsScopes
class Field implements FromReflectionProperty, HasSubAttributes, Excludable, SupportsScopes, ReadsClass, Finalizable
{
use Evolvable;

Expand Down Expand Up @@ -73,6 +75,11 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
*/
public readonly bool $shouldUseDefault;

/**
* Whether or not to require a value when deserializaing into this field.
*/
public readonly bool $requireValue;

/**
* The renaming mechanism used for this field.
*
Expand Down Expand Up @@ -114,6 +121,13 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
* On deserialization, set to true to require incoming data to be of the
* correct type. If false, the system will attempt to cast values to
* the correct type.
* @param bool $requireValue
* On deserialization, set to true to require incoming data to have a value.
* If it does not, and incoming data is missing a value for this field, and
* no default is set for the property, then an exception will be thrown. Set
* to false to disable this check, in which case the value may be uninitialized
* after deserialization. If a property has a default value, this directive
* has no effect.
* @param array<string|null> $scopes
* If specified, this Field entry will be included only when operating in
* the specified scopes. To also be included in the default "unscoped" case,
Expand All @@ -129,11 +143,16 @@ public function __construct(
public readonly bool $exclude = false,
public readonly array $alias = [],
public readonly bool $strict = true,
?bool $requireValue = null,
protected readonly array $scopes = [null],
) {
if ($default) {
$this->defaultValue = $default;
}
// Null means we want to accept a default value later from the class.
if ($requireValue !== null) {
$this->requireValue = $requireValue;
}
// Upcast the literal serialized name to a converter if appropriate.
$this->rename ??=
$renameWith
Expand Down Expand Up @@ -166,8 +185,15 @@ public function fromReflection(\ReflectionProperty $subject): void
?? $constructorDefault
;
}
}

$this->finalize();
/**
* @param ClassSettings $class
*/
public function fromClassAttribute(object $class): void
{
// If there is no requireValue flag set, inherit it from the class attribute.
$this->requireValue ??= $class->requireValues;
}

protected function getDefaultValueFromConstructor(\ReflectionProperty $subject): mixed
Expand All @@ -189,7 +215,7 @@ protected function getDefaultValueFromConstructor(\ReflectionProperty $subject):

}

protected function finalize(): void
public function finalize(): void
{
// We cannot compute these until we have the PHP type,
// but they can still be determined entirely at analysis time
Expand Down
24 changes: 24 additions & 0 deletions src/MissingRequiredValueWhenDeserializing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Crell\Serde;

class MissingRequiredValueWhenDeserializing extends \InvalidArgumentException
{
public readonly string $name;
public readonly string $class;
public readonly string $format;

public static function create(string $name, string $class, string $format): self
{
$new = new self();
$new->name = $name;
$new->class = $class;
$new->format = $format;

$new->message = sprintf('No data found for required field "%s" on class %s when deserializing from %s.', $name, $class, $format);

return $new;
}
}
8 changes: 8 additions & 0 deletions src/PropertyHandler/ObjectImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Crell\Serde\Deserializer;
use Crell\Serde\Formatter\SupportsCollecting;
use Crell\Serde\InvalidArrayKeyType;
use Crell\Serde\MissingRequiredValueWhenDeserializing;
use Crell\Serde\SerdeError;
use Crell\Serde\TypeCategory;

Expand Down Expand Up @@ -52,6 +53,7 @@ protected function populateObject(array $dict, string $class, Deserializer $dese
/** @var Field[] $collectingObjects */
$collectingObjects = [];

/** @var Field $propField */
foreach ($classDef->properties as $propField) {
$usedNames[] = $propField->serializedName;
if ($propField->flatten && $propField->typeCategory === TypeCategory::Array) {
Expand All @@ -66,6 +68,12 @@ protected function populateObject(array $dict, string $class, Deserializer $dese
if ($value === SerdeError::Missing) {
if ($propField->shouldUseDefault) {
$props[$propField->phpName] = $propField->defaultValue;
} elseif ($propField->requireValue) {
throw MissingRequiredValueWhenDeserializing::create(
$propField->phpName,
$classDef->phpType,
$deserializer->deformatter->format(),
);
}
} else {
$props[$propField->phpName] = $value;
Expand Down
2 changes: 2 additions & 0 deletions tests/ArrayFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public function setUp(): void
'stringKey' => ['a' => 'A', 2 => 'B'],
'intKey' => [5 => 'C', 10 => 'D'],
];

$this->missingOptionalData = ['a' => 'A'];
}

protected function arrayify(mixed $serialized): array
Expand Down
2 changes: 2 additions & 0 deletions tests/JsonFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public function setUp(): void
$this->invalidDictStringKey = '{"stringKey": {"a": "A", "2": "B"}, "intKey": {"5": "C", "d": "D"}}';

$this->invalidDictIntKey = '{"stringKey": {"a": "A", "2": "B"}, "intKey": {"5": "C", "10": "D"}}';

$this->missingOptionalData = '{"a": "A"}';
}

protected function arrayify(mixed $serialized): array
Expand Down
20 changes: 20 additions & 0 deletions tests/Records/RequiresFieldValues.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\ClassSettings;
use Crell\Serde\Attributes\Field;

#[ClassSettings]
class RequiresFieldValues
{
public function __construct(
#[Field(requireValue: true)]
public string $a,
// This field has a default, so it being missing should not be an error.
#[Field(requireValue: true)]
public string $b = 'B',
) {}
}
18 changes: 18 additions & 0 deletions tests/Records/RequiresFieldValuesClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\ClassSettings;
use Crell\Serde\Attributes\Field;

#[ClassSettings(requireValues: true)]
class RequiresFieldValuesClass
{
public function __construct(
public string $a,
// This field has a default, so it being missing should not be an error.
public string $b = 'B',
) {}
}
63 changes: 63 additions & 0 deletions tests/SerdeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
use Crell\Serde\Records\Pagination\ProductType;
use Crell\Serde\Records\Pagination\Results;
use Crell\Serde\Records\Point;
use Crell\Serde\Records\RequiresFieldValues;
use Crell\Serde\Records\RequiresFieldValuesClass;
use Crell\Serde\Records\RootMap\Type;
use Crell\Serde\Records\RootMap\TypeB;
use Crell\Serde\Records\Shapes\Box;
Expand Down Expand Up @@ -114,6 +116,13 @@ abstract class SerdeTest extends TestCase
*/
protected mixed $invalidDictIntKey;

/**
* Data that is missing a required field for which a default is provided.
*
* @see missing_required_value_with_default_does_not_throw()
*/
protected mixed $missingOptionalData;

/**
* @test
*/
Expand Down Expand Up @@ -1404,6 +1413,60 @@ public function traversable_object_not_iterated_validate(mixed $serialized): voi

}

/**
* @test
*/
public function missing_required_value_throws(): void
{
$this->expectException(MissingRequiredValueWhenDeserializing::class);

$s = new SerdeCommon(formatters: $this->formatters);

$result = $s->deserialize($this->emptyData, $this->format, RequiresFieldValues::class);
}

/**
* @test
*/
public function missing_required_value_with_default_does_not_throw(): void
{
$s = new SerdeCommon(formatters: $this->formatters);

/** @var RequiresFieldValues $result */
$result = $s->deserialize($this->missingOptionalData, $this->format, RequiresFieldValues::class);

self::assertEquals('A', $result->a);
// This isn't in the incoming data, and is required, but has a default so it's fine.
self::assertEquals('B', $result->b);
}

/**
* @test
*/
public function missing_required_value_for_class_throws(): void
{
$this->expectException(MissingRequiredValueWhenDeserializing::class);

$s = new SerdeCommon(formatters: $this->formatters);

$result = $s->deserialize($this->emptyData, $this->format, RequiresFieldValuesClass::class);
}

/**
* @test
*/
public function missing_required_value_for_class_with_default_does_not_throw(): void
{
$s = new SerdeCommon(formatters: $this->formatters);

/** @var RequiresFieldValuesClass $result */
$result = $s->deserialize($this->missingOptionalData, $this->format, RequiresFieldValuesClass::class);

self::assertEquals('A', $result->a);
// This isn't in the incoming data, and is required, but has a default so it's fine.
self::assertEquals('B', $result->b);
}

/**
* @test
* @dataProvider scopes_examples()
Expand Down
2 changes: 2 additions & 0 deletions tests/YamlFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public function setUp(): void
'stringKey' => ['a' => 'A', 2 => 'B'],
'intKey' => [5 => 'C', 10 => 'D'],
]);

$this->missingOptionalData = YAML::dump(['a' => 'A']);
}

protected function arrayify(mixed $serialized): array
Expand Down

0 comments on commit 2fe4bc7

Please sign in to comment.