Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support renaming properties in flattened sub-objects #48

Merged
merged 5 commits into from
Jan 20, 2024
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
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,98 @@ When collecting, only the lexically last flattened array will get any data, and

In this case, the `$other` property has two keys, `foo` and `bar`, with values `beep` and `boop`, respectively. The same JSON will deserialize back to the same object as before.

#### Value objects

Flattening can also be used in conjunction with renaming to silently translate value objects. Consider:

```php
class Person
{
public function __construct(
public string $name,
#[Field(flatten: true)]
public Age $age,
#[Field(flatten: true)]
public Email $email,
) {}
}

readonly class Email
{
public function __construct(
#[Field(serializedName: 'email')] public string $value,
) {}
}

readonly class Age
{
public function __construct(
#[Field(serializedName: 'age')] public int $value
) {
$this->validate();
}

#[PostLoad]
private function validate(): void
{
if ($this->value < 0) {
throw new \InvalidArgumentException('Age cannot be negative.');
}
}
}
```

In this example, `Email` and `Age` are value objects, in the latter case with extra validation. However, both are marked `flatten: true`, so their properties will be moved up a level to `Person` when serializing. However, they both use the same property name, so both have a custom serialization name specified. The above object will serialize to (and deserialize from) something like this:

```json
{
"name": "Larry",
"age": 21,
"email": "me@example.com"
}
```

Note that because deserialization bypasses the constructor, the extra validation in `Age` must be placed in a separate method that is called from the constructor and flagged to run automatically after deserialization.

It is also possible to specify a prefix for a flattened value, which will also be applied recursively. For example, assuming the same Age class above:

```php
readonly class JobDescription
{
public function __construct(
#[Field(flatten: true, flattenPrefix: 'min_')]
public Age $minAge,
#[Field(flatten: true, flattenPrefix: 'max_')]
public Age $maxAge,
) {}
}

class JobEntry
{
public function __construct(
#[Field(flatten: true, flattenPrefix: 'desc_')]
public JobDescription $description,
) {}
}
```

In this case, serializing `JobEntry` will first flatten the `$description` property, with `desc_` as a prefix. Then, `JobDescription` will flatten both of its age fields, giving each a separate prefix. That will result in a serialized output something like this:

```json
{
"desc_min_age": 18,
"desc_max_age": 65,
}
```

And it will deserialize back to the same original 3-layer-object structure.

### `flattenPrefix` (string, default '')

When an object or array property is flattened, by default its properties will be flattened using their existing name (or `serializedName`, if specified). That may cause issues if the same class is included in a parent class twice, or if there is some other name collission. Instead, flattened fields may be given a `flattenPrefix` value. That string will be prepended to the name of the property when serializing.

If set on a non-flattened field, this value is meaningless and has no effect.

### Sequences and Dictionaries

In most languages, and many serialization formats, there is a difference between a sequential list of values (called variously an array, sequence, or list) and a map of arbitrary size of arbitrary values to other arbitrary values (called a dictionary or map). PHP does not make a distinction, and shoves both data types into a single associative array variable type.
Expand Down
7 changes: 6 additions & 1 deletion src/Attributes/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
* True to use the default value on deserialization. False to skip setting it entirely.
* @param bool $flatten
* True to flatten an array on serialization and collect into it when deserializing.
* @param string $flattenPrefix
* If the field is flattened, this string will be prepended to the name of every field in the sub-value.
* If not flattened, this field is ignored.
* @param bool $exclude
* Set true to exclude this field from serialization entirely.
* @param string[] $alias
Expand Down Expand Up @@ -148,6 +151,7 @@ public function __construct(
mixed $default = PropValue::None,
protected readonly bool $useDefault = true,
public readonly bool $flatten = false,
public readonly string $flattenPrefix = '',
public readonly bool $exclude = false,
public readonly array $alias = [],
public readonly bool $strict = true,
Expand Down Expand Up @@ -298,11 +302,12 @@ public static function create(
?string $phpType = null,
array $extraProperties = [],
TypeField $typeField = null,
string $phpName = null,
): self
{
$new = new self();
$new->serializedName = $serializedName;
$new->phpName = $serializedName;
$new->phpName = $phpName ?? $serializedName;
if ($phpType) {
$new->phpType = $phpType;
}
Expand Down
15 changes: 13 additions & 2 deletions src/PropertyHandler/ObjectExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ protected function flattenValue(Dict $dict, Field $field, callable $propReader,
$subPropReader = (fn (string $prop): mixed
=> array_key_exists($prop, get_object_vars($this)) ? $this->$prop : DeformatterResult::Missing)->bindTo($value, $value);
// This really wants to be explicit partial application. :-(
$c = fn (Dict $dict, Field $prop) => $this->reduceObjectProperty($dict, $prop, $subPropReader, $serializer);
$c = fn (Dict $dict, Field $prop) => $this->reduceObjectProperty($dict, $prop, $subPropReader, $field, $serializer);
$properties = $serializer->propertiesFor($value::class);
$dict = reduce($dict, $c)($properties);
if ($map = $serializer->typeMapper->typeMapForField($field)) {
Expand All @@ -115,8 +115,19 @@ protected function reduceArrayElement(Dict $dict, mixed $val, string|int $key, ?
return $dict->add(new CollectionItem(field: $f, value: $val));
}

protected function reduceObjectProperty(Dict $dict, Field $prop, callable $subPropReader, Serializer $serializer): Dict
protected function reduceObjectProperty(Dict $dict, Field $prop, callable $subPropReader, Field $parentProperty, Serializer $serializer): Dict
{
// If there is a prefix provided by the parent field being flattened, we need to create a new, alternate
// field definition for the property. The serializedName field will be used only on the final property,
// so while this will produce weird strings it along the way for the intermediary fields, that doesn't matter.
if ($parentProperty->flattenPrefix) {
/** @var Field $prop */
$prop = $prop->with(
serializedName: $parentProperty->flattenPrefix . $prop->serializedName,
flattenPrefix: $parentProperty->flattenPrefix . $prop->flattenPrefix
);
}

if ($prop->flatten) {
return $this->flattenValue($dict, $prop, $subPropReader, $serializer);
}
Expand Down
20 changes: 14 additions & 6 deletions src/PropertyHandler/ObjectImporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,29 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou
* @param Deserializer $deserializer
* @return array{object, mixed[]}
*/
protected function populateObject(array $dict, string $class, Deserializer $deserializer): array
protected function populateObject(array $dict, string $class, Deserializer $deserializer, ?Field $parentField = null): array
{
$classDef = $deserializer->analyzer->analyze($class, ClassSettings::class, scopes: $deserializer->scopes);

$props = [];
$usedNames = [];
$seenNames = [];
$collectingArray = null;
/** @var Field[] $collectingObjects */
$collectingObjects = [];

/** @var Field $propField */
foreach ($classDef->properties as $propField) {
$usedNames[] = $propField->serializedName;
// This extra indirection is to allow for parent-prefixed properties when flattening.
$dictName = ($parentField->flattenPrefix ?? '') . $propField->serializedName;
$seenNames[] = $dictName;
if ($propField->flatten && $propField->typeCategory === TypeCategory::Array) {
$collectingArray = $propField;
} elseif ($propField->flatten && $propField->typeCategory === TypeCategory::Object) {
$collectingObjects[] = $propField;
} else {
$value = $dict[$propField->serializedName];
$usedNames[] = $dictName;
$value = $dict[$dictName];
if ($value !== DeformatterResult::Missing && !$propField->validate($value)) {
throw InvalidArrayKeyType::create($propField, 'invalid');
}
Expand Down Expand Up @@ -92,18 +96,22 @@ protected function populateObject(array $dict, string $class, Deserializer $dese
// It's possible there will be a class map but no mapping field in
// the data. In that case, either set a default or just ignore the field.
if ($targetClass = $deserializer->typeMapper->getTargetClass($collectingField, $dict)) {
[$object, $remaining] = $this->populateObject($remaining, $targetClass, $deserializer);
// Pass the collecting field definition through for context, such as a flattening prefix.
// If there's already a parent field, slip its flattenPrefix in along the way.
[$object, $remaining] = $this->populateObject($remaining, $targetClass, $deserializer, $collectingField->with(flattenPrefix: $parentField?->flattenPrefix . $collectingField->flattenPrefix));
$props[$collectingField->phpName] = $object;
if ($map = $deserializer->typeMapper->typeMapForField($collectingField)) {
$usedNames[] = $map->keyField();
$keyField = $map->keyField();
$usedNames[] = $keyField;
$seenNames[] = $keyField;
}
} elseif ($collectingField->shouldUseDefault) {
$props[$collectingField->phpName] = $collectingField->defaultValue;
}
}

// Any remaining data gets passed to a collecting array, if defined.
$remaining = $deserializer->deformatter->getRemainingData($remaining, $usedNames);
$remaining = $deserializer->deformatter->getRemainingData($remaining, $seenNames);
if ($collectingArray) {
$props[$collectingArray->phpName] = $remaining;
$remaining = [];
Expand Down
42 changes: 42 additions & 0 deletions tests/ArrayBasedFormatterTestCases.php
Original file line number Diff line number Diff line change
Expand Up @@ -445,4 +445,46 @@ public function non_sequence_arrays_are_normalized_to_sequences_validate(mixed $
self::assertIsList($toTest['strict']);
self::assertIsList($toTest['nonstrict']);
}

public function value_objects_with_similar_property_names_work_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertSame('Larry', $toTest['name']);
self::assertSame(21, $toTest['age']);
self::assertSame('me@example.com', $toTest['email']);
}

public function multiple_same_class_value_objects_work_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertSame(18, $toTest['min_age']);
self::assertSame(65, $toTest['max_age']);
}


public function multiple_same_class_value_objects_work_when_nested_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertSame(18, $toTest['description']['min_age']);
self::assertSame(65, $toTest['description']['max_age']);
}

public function multiple_same_class_value_objects_work_when_nested_and_flattened_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertSame(18, $toTest['min_age']);
self::assertSame(65, $toTest['max_age']);
}

public function multiple_same_class_value_objects_work_when_nested_and_flattened_with_prefix_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertSame(18, $toTest['desc_min_age']);
self::assertSame(65, $toTest['desc_max_age']);
}
}
17 changes: 17 additions & 0 deletions tests/Records/ValueObjects/Age.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class Age
{
public function __construct(#[Field(serializedName: 'age')] public int $value)
{
if ($this->value < 0) {
throw new \InvalidArgumentException('Age cannot be negative.');
}
}
}
12 changes: 12 additions & 0 deletions tests/Records/ValueObjects/Email.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class Email
{
public function __construct(#[Field(serializedName: 'email')] public string $value) {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way it (also applicable for Age ) forces properties to have a specific name, which depending on the context may be ambiguous, or even worse, it may be necessary to have, let's imagine, 2 email objects.

Ideally the property name would be dictated by the parent object, People in this case, or as a fallback, the serializedName to be infered there.

Another way to put it, is to consider this scenario:

<?php

declare(strict_types=1);

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class Invite
{
    public function __construct(
        #[Field(flatten: true)]
        public Email $invitingEmail,
        #[Field(flatten: true)]
        public Email $invitedEmail,
    ) {}
}

Forcing the serializedName on the value object makes this object impossible to deserialize/serialize, as far as I understand.

Does my perspective on this make sense?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yes, it does break down if the value object is used twice. However, I'm not really sure how to have an alternate name injected from the parent.

Part of the challenge is that there are certain approaches that would only work for a single-property value object, but there's nothing inherent in value objects that makes them single-value only. (You could have a phone number class that stores the area code separately internally, for whatever reason.) So any solution would need to handle that, too.

Which suggests, maybe using renameWith: new Prefix(...) on the parent class. But then the question is how that interacts with the names in the value object. I'm not sure what interaction there makes sense without some completely separate pathway.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm delighted to see this PR.

But as already noted before, setting the field's name into the child object might be troublesome. You can't say how the value object will be used.

For example, considering the type Age, a field of that type called age could exist, but also a minimumAge or whatever. Also, multiple fields could use the same type, so different names are needed.

It is the parent class, then, that should determine the name.

What about a new attribute, as I put on the issue?

#[Attribute(Attribute::TARGET_CLASS)]
readonly class ValueObject
{
    public function __construct(public string $field = 'value') {}
}

In this way, Serde knows how to serialize/hydrate. But this adds an extra dependency, and I prefer Vanilla PHP when possible.

As an alternative, why not use a flag like simple?

readonly class Person
{
    public function __construct(
        #[Field(flatten: true, simple: true)]
        public Name $name,
        #[Field(flatten: true, simple: 'age')]
        public Age $age
    ) {}
}

I imagine two possible values:

  • true when the child value object uses the default field value
  • string when the child uses a custom field name

Or better, why not merge flatten and simple behaviours in a single property called simplify?

readonly class Person
{
    public function __construct(
        #[Field(simplify: true)]
        public Name $name,
        #[Field(simplify: 'age')]
        public Age $age
    ) {}
}

The field name or the serializedName might be used as a name.

}
15 changes: 15 additions & 0 deletions tests/Records/ValueObjects/JobDescription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class JobDescription
{
public function __construct(
#[Field(flatten: true, flattenPrefix: 'min_')]
public Age $minAge,
#[Field(flatten: true, flattenPrefix: 'max_')]
public Age $maxAge,
) {}
}
10 changes: 10 additions & 0 deletions tests/Records/ValueObjects/JobEntry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Crell\Serde\Records\ValueObjects;

class JobEntry
{
public function __construct(
public JobDescription $description,
) {}
}
13 changes: 13 additions & 0 deletions tests/Records/ValueObjects/JobEntryFlattened.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class JobEntryFlattened
{
public function __construct(
#[Field(flatten: true)]
public JobDescription $description,
) {}
}
13 changes: 13 additions & 0 deletions tests/Records/ValueObjects/JobEntryFlattenedPrefixed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class JobEntryFlattenedPrefixed
{
public function __construct(
#[Field(flatten: true, flattenPrefix: 'desc_')]
public JobDescription $description,
) {}
}
18 changes: 18 additions & 0 deletions tests/Records/ValueObjects/Person.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records\ValueObjects;

use Crell\Serde\Attributes\Field;

class Person
{
public function __construct(
public string $name,
#[Field(flatten: true)]
public Age $age,
#[Field(flatten: true)]
public Email $email,
) {}
}
Loading
Loading