Skip to content

Commit e3eebb3

Browse files
authored
Merge pull request #87 from Crell/mixed-serialize
Allow serialization/unserialization of objects on mixed properties
2 parents 12d0a20 + 2013518 commit e3eebb3

File tree

8 files changed

+153
-21
lines changed

8 files changed

+153
-21
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to `Serde` will be documented in this file.
44

55
Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
66

7+
## 1.4.0 - DATE
8+
9+
### Added
10+
- A new `MixedField` type field is available for use on `mixed` properties. If specified, it allows specifying a preferred object type that an array-ish value will be cast into when deserializing. If the object deserialiation fails, the whole deserialization will fail.
11+
12+
### Deprecated
13+
- Nothing
14+
15+
### Fixed
16+
- Serializing an object that is assigned to a `mixed` field will no longer generate a circular reference error.
17+
18+
### Removed
19+
- Nothing
20+
21+
### Security
22+
- Nothing
23+
724
## 1.3.2 - 2024-12-06
825

926
### Added

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,27 @@ The serialized integer should be read as "this many seconds since the epoc" or "
696696

697697
Note that the permissible range of milliseconds and microseconds is considerably smaller than that for seconds, since there is a limit on the size of an integer that we can represent. For timestamps in the early 21st century there should be no issue, but trying to record the microseconds since the epoc for the setting of Dune (somewhere in the 10,000s) won't work.
698698

699+
### Mixed values
700+
701+
`mixed` values pose an interesting challenge, as their data type is by definition not specified.
702+
703+
On serialization, Serde will make a good faith effort to derive the type to serialize to from the value itself. So if a `mixed` property has a value of `"beep"`, it will try to serialize as a string. If it has a value `[1, 2, 3]`, it will try to serialize as an array.
704+
705+
On deserialization, primitive types (`int`, `float`, `string`) will be read successfully and written to the property. If no additional information is provided, then sequences and dictionaries will also be read into the property as an `array`, but objects are not supported. (They'll be treated like a dictionary.)
706+
707+
Alternatively, you may specify the field as a `#[MixedField(Point::class)]`, which has one required argument, `suggestedType`. If that is specified, any incoming array-ish value will be deserialized to the specified class. If the value is not compatible with that class, an exception will be thrown. That means it is not possible to support both array deserialization and object deserialization at the same time.
708+
709+
```php
710+
class Message
711+
{
712+
public string $message;
713+
#[MixedField(SomeClass::class)]
714+
public mixed $result;
715+
}
716+
```
717+
718+
If you are only ever serializing an object with a `mixed` property, these concerns should not apply and no additional effort should be required.
719+
699720
### Generators, Iterables, and Traversables
700721

701722
PHP has a number of "lazy list" options. Generally, they are all objects that implement the `\Traversable` interface. However, there are several syntax options available with their own subtleties. Serde supports them in different ways.

src/Attributes/Field.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,9 @@ public function validate(mixed $value): bool
371371

372372
if ($this->phpType === $valueType) {
373373
$valid = true;
374+
} elseif ($this->phpType === 'mixed') {
375+
// From a type perspective, mixed accepts anything.
376+
$valid = true;
374377
} elseif ($this->nullable && $valueType === 'null') {
375378
$valid = true;
376379
} elseif (is_object($value) || class_exists($this->phpType) || interface_exists($this->phpType)) {

src/Attributes/MixedField.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Crell\Serde\Attributes;
6+
7+
use Attribute;
8+
use Crell\AttributeUtils\SupportsScopes;
9+
use Crell\Serde\TypeField;
10+
11+
/**
12+
* Specifies how a mixed field should be deserialized.
13+
*
14+
* In particular, it allows specifying a class type to which an array-ish
15+
* value should be deserialized.
16+
*/
17+
#[Attribute(Attribute::TARGET_PROPERTY)]
18+
class MixedField implements TypeField, SupportsScopes
19+
{
20+
/**
21+
* @param string $suggestedType
22+
* The class name that an array-ish value should be deserialized into.
23+
* Primitive values will be left as is when deserializing.
24+
* @param array<string|null> $scopes
25+
* The scopes in which this attribute should apply.
26+
*/
27+
public function __construct(
28+
public readonly string $suggestedType,
29+
protected readonly array $scopes = [null],
30+
) {}
31+
32+
public function scopes(): array
33+
{
34+
return $this->scopes;
35+
}
36+
37+
public function acceptsType(string $type): bool
38+
{
39+
return $type === 'mixed';
40+
}
41+
42+
public function validate(mixed $value): bool
43+
{
44+
return true;
45+
}
46+
}

src/PropertyHandler/MixedExporter.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Crell\Serde\Attributes\DictionaryField;
88
use Crell\Serde\Attributes\Field;
9+
use Crell\Serde\Attributes\MixedField;
910
use Crell\Serde\CollectionItem;
1011
use Crell\Serde\Deserializer;
1112
use Crell\Serde\Dict;
@@ -19,14 +20,19 @@
1920
* Exporter/importer for `mixed` properties.
2021
*
2122
* This class makes a good-faith attempt to detect the type of a given field by its value.
22-
* It currently does not work for objects, and on import it works only on array-based
23-
* formats (JSON, YAML, TOML, etc.)
23+
* On import, it currently works only on array-based formats (JSON, YAML, TOML, etc.)
24+
*
25+
* To deserialize into an object, the property must have the MixedField attribute.
26+
*
27+
* @see MixedField
2428
*/
2529
class MixedExporter implements Importer, Exporter
2630
{
2731
public function exportValue(Serializer $serializer, Field $field, mixed $value, mixed $runningValue): mixed
2832
{
29-
return $serializer->serialize($value, $runningValue, Field::create(
33+
// We need to bypass the circular reference check in Serializer::serialize(),
34+
// or else an object would always fail here.
35+
return $serializer->doSerialize($value, $runningValue, Field::create(
3036
serializedName: $field->serializedName,
3137
phpType: \get_debug_type($value),
3238
));
@@ -39,6 +45,12 @@ public function importValue(Deserializer $deserializer, Field $field, mixed $sou
3945
// it directly.
4046
$type = \get_debug_type($source[$field->serializedName]);
4147

48+
/** @var MixedField|null $typeField */
49+
$typeField = $field->typeField;
50+
if ($typeField && class_exists($typeField->suggestedType) && $type === 'array') {
51+
$type = $typeField->suggestedType;
52+
}
53+
4254
return $deserializer->deserialize($source, Field::create(
4355
serializedName: $field->serializedName,
4456
phpType: $type,

src/Serializer.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ public function serialize(mixed $value, mixed $runningValue, Field $field): mixe
4444
$this->seenObjects[] = $value;
4545
}
4646

47-
$reader = $this->findExporter($field, $value);
48-
$result = $reader->exportValue($this, $field, $value, $runningValue);
47+
$result = $this->doSerialize($value, $runningValue, $field);
4948

5049
if (is_object($value)) {
5150
array_pop($this->seenObjects);
@@ -54,6 +53,21 @@ public function serialize(mixed $value, mixed $runningValue, Field $field): mixe
5453
return $result;
5554
}
5655

56+
/**
57+
* @internal
58+
*
59+
* There's one case where we need the circular detection disabled, and that's when
60+
* dealing with mixed fields. A mixed field throws its value back through the serializer,
61+
* which is normally fine but would trigger the circular reference checks in serialize().
62+
* Instead, we split it out to a separate method that no one should use except MixedExporter.
63+
* That means if you're reading this, you should walk away now because you're not supposed
64+
* to use this method.
65+
*/
66+
public function doSerialize(mixed $value, mixed $runningValue, Field $field): mixed
67+
{
68+
return $this->findExporter($field, $value)->exportValue($this, $field, $value, $runningValue);
69+
}
70+
5771
protected function findExporter(Field $field, mixed $value): Exporter
5872
{
5973
$format = $this->formatter->format();

tests/Records/MixedValObject.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Crell\Serde\Records;
6+
7+
use Crell\Serde\Attributes\MixedField;
8+
9+
class MixedValObject
10+
{
11+
public function __construct(
12+
#[MixedField(Point::class)]
13+
public mixed $val
14+
) {}
15+
}

tests/SerdeTestCases.php

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use Crell\Serde\Records\MappedCollected\ThingC;
5050
use Crell\Serde\Records\MappedCollected\ThingList;
5151
use Crell\Serde\Records\MixedVal;
52+
use Crell\Serde\Records\MixedValObject;
5253
use Crell\Serde\Records\MultiCollect\ThingOneA;
5354
use Crell\Serde\Records\MultiCollect\ThingTwoC;
5455
use Crell\Serde\Records\MultiCollect\Wrapper;
@@ -1062,27 +1063,30 @@ public static function mixed_val_property_examples(): iterable
10621063
yield 'dict' => [new MixedVal(['a' => 'A', 'b' => 'B', 'c' => 'C'])];
10631064
}
10641065

1065-
public function mixed_val_property_validate(mixed $serialized, mixed $data): void
1066+
#[Test, DataProvider('mixed_val_property_object_examples')]
1067+
public function mixed_val_property_object(mixed $data): void
10661068
{
1067-
}
1069+
$s = new SerdeCommon(formatters: $this->formatters);
10681070

1069-
/**
1070-
* This isn't a desired feature; it's just confirmation for the future why it is how it is.
1071-
*/
1072-
#[Test]
1073-
public function mixed_val_object_does_not_serialize(): void
1074-
{
1075-
// MixedExporter sends the property value back through the Serialize pipeline
1076-
// a second time with a new Field definition. However, that trips the circular
1077-
// reference detection. Ideally we will fix that somehow, but I'm not sure how.
1078-
// Importing an object to mixed will never work correctly.
1079-
$this->expectException(CircularReferenceDetected::class);
1071+
$serialized = $s->serialize($data, $this->format);
1072+
1073+
$this->mixed_val_property_validate($serialized, $data);
10801074

1081-
$data = new MixedVal(new Point(3, 4, 5));
1075+
$result = $s->deserialize($serialized, from: $this->format, to: MixedValObject::class);
10821076

1083-
$s = new SerdeCommon(formatters: $this->formatters);
1077+
self::assertEquals($data, $result);
1078+
}
10841079

1085-
$serialized = $s->serialize($data, $this->format);
1080+
public static function mixed_val_property_object_examples(): iterable
1081+
{
1082+
yield 'string' => [new MixedValObject('hello')];
1083+
yield 'int' => [new MixedValObject(5)];
1084+
yield 'float' => [new MixedValObject(3.14)];
1085+
yield 'object' => [new MixedValObject(new Point(1, 2, 3))];
1086+
}
1087+
1088+
public function mixed_val_property_validate(mixed $serialized, mixed $data): void
1089+
{
10861090
}
10871091

10881092
#[Test]

0 commit comments

Comments
 (0)