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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.travis.yml export-ignore
/.github export-ignore
/.editorconfig export-ignore
/default-.env export-ignore
/Taskfile export-ignore
/phpstan.neon.dist export-ignore
/phpunit.xml.dist export-ignore
/.scrutinizer.yml export-ignore
/tests export-ignore
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/quality-assurance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.1', '8.2', '8.3', '8.4' ]
php: [ '8.2', '8.3', '8.4' ]
composer-flags: [ '' ]
phpunit-flags: [ '--coverage-text' ]
steps:
Expand All @@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.1', '8.2', '8.3', '8.4' ]
php: [ '8.2', '8.3', '8.4' ]
composer-flags: [ '' ]
steps:
- uses: actions/checkout@v2
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to `Serde` will be documented in this file.

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

## 1.5.0 - 2025-07-15

### Added
- Union, Intersection, and Compound types are now supported. They work by falling back to `mixed`, and then relying on the Deformatter to derive the type. Not all Deformatters will have that ability, but the most common bundled ones do. (`json`, `yaml`, `toml`, and `array`.) Additionally, Union Types may specify `TypeField`s that apply only when specific types are used.

### Deprecated
- Version 1.5 and later requires at least PHP 8.2. PHP 8.1 is no longer supported.

### Fixed
- Nothing

### Removed
- Nothing

### Security
- Nothing

## 1.4.0 - 2025-06-19

### Added
Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ Note that the permissible range of milliseconds and microseconds is considerably

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.

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.)
On deserialization, Serde will attempt to derive the type by making educated guesses. If the Deformatter in use implements `SupportsTypeIntrospection` (`json`, `yaml`, `toml`, and `array` already do), then the formatter will be asked what the type should be. 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.)

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.

Expand All @@ -631,6 +631,28 @@ class Message

If you are only ever serializing an object with a `mixed` property, these concerns should not apply and no additional effort should be required.

### Union and Compound types

Most Serde behavior assumes a single type for a given field. If the field has a compound type (a union, intersection, or a combination of the two), Serde will internally treat it as if it were `mixed` and will behave like a `mixed` field above.

That does mean that, in practice, union and compound types are only supported when using a `SupportsTypeIntrospection` formatter. That includes the most common formats, however, so most of the time it will work. The `MixedField` attribute may be applied to a compound type, and will work as described above.

Alternatively, if the field is a Union type specifically, you may also use the `UnionField` type field attribute. It works the same as `MixedField`, but has an additional array parameter that lets you specify a separate TypeField for each type in the union. That is especially useful if, for instance, one of the subtypes is an array, and you want to mark it as a sequence or dictionary so that a list of objects will deserialize correctly.

The example below, for instance, specifies that `$values` could be a `string` or an array of `Point` objects, keyed by a string. If the system cannot otherwise figure out the type, it will default to just `array`.

```php
class UnionTypeSubTypeField
{
public function __construct(
#[UnionField('array', [
'array' => new DictionaryField(Point::class, KeyType::String)]
)]
public string|array $values,
) {}
}
```

### Generators, Iterables, and Traversables

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.
Expand Down Expand Up @@ -1378,6 +1400,8 @@ If you discover any security related issues, please use the [GitHub security rep

Initial development of this library was sponsored by [TYPO3 GmbH](https://typo3.com/).

Additional development sponsored in part by [MakersHub](https://makershub.ai/).

## License

The Lesser GPL version 3 or later. Please see [License File](LICENSE.md) for more information.
Expand Down
8 changes: 6 additions & 2 deletions Taskfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

set -euo pipefail

project="serde"
dirname=${PWD##*/} # Get the current dir name, without full path
dirname=${dirname:-/} # to correct for the case where PWD is / (root)
dirname=`echo $dirname | tr '[:upper:]' '[:lower:]'` # Convert the dirname to lowercase.

project=${dirname}
appContainer="php-fpm"

function build {
Expand All @@ -24,7 +28,7 @@ function restart {

function shell {
start
docker compose exec -it -u $(id -u):$(id -g) ${appContainer} bash
docker compose exec -u $(id -u):$(id -g) ${appContainer} bash
}

function default {
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
}
],
"require": {
"php": "~8.1",
"crell/attributeutils": "~1.2",
"php": "~8.2",
"crell/attributeutils": "~1.3",
"crell/fp": "~1.0"
},
"require-dev": {
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ parameters:
- tests/Records/*
ignoreErrors:
- identifier: missingType.generics
reportUnmatched: false
# PHPStan whines about every match without a default, even if logically it's still complete.
- '#Match expression does not handle remaining value#'
# As far as I can tell, PHPStan is just buggy on explode(). It gives this error
Expand Down
42 changes: 24 additions & 18 deletions src/Attributes/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@
use Crell\AttributeUtils\HasSubAttributes;
use Crell\AttributeUtils\ReadsClass;
use Crell\AttributeUtils\SupportsScopes;
use Crell\AttributeUtils\TypeDef;
use Crell\fp\Evolvable;
use Crell\Serde\FieldTypeIncompatible;
use Crell\Serde\IntersectionTypesNotSupported;
use Crell\Serde\PropValue;
use Crell\Serde\Renaming\LiteralName;
use Crell\Serde\Renaming\RenamingStrategy;
use Crell\Serde\TypeCategory;
use Crell\Serde\TypeField;
use Crell\Serde\TypeMap;
use Crell\Serde\UnionTypesNotSupported;
use Crell\Serde\UnsupportedType;

use function Crell\fp\indexBy;
use function Crell\fp\method;
use function Crell\fp\pipe;
Expand Down Expand Up @@ -100,6 +100,13 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
*/
public readonly bool $omitIfNull;

/**
* For more complex cases, the full type definition of the property.
*
* The phpType field contains the "preferred type" in those complex cases,
* so even a complex type can "look like" a simple type in most cases.
*/
public readonly TypeDef $typeDef;

/**
* Additional key/value pairs to be included with an object.
Expand All @@ -112,6 +119,7 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
* @var array<string, mixed>
*/
public readonly array $extraProperties;

public const TYPE_NOT_SPECIFIED = '__NO_TYPE__';

/**
Expand Down Expand Up @@ -194,7 +202,14 @@ public function scopes(): array
public function fromReflection(\ReflectionProperty $subject): void
{
$this->phpName = $subject->name;
$this->phpType ??= $this->getNativeType($subject);

$this->typeDef = new TypeDef($subject->getType());

// If it's a simple type, we can derive it now.
$type = $this->typeDef->getSimpleType();
if ($type) {
$this->phpType = $type;
}

// An untyped property is equivalent to mixed, which is nullable.
// Damnit, PHP.
Expand Down Expand Up @@ -247,6 +262,12 @@ protected function getDefaultValueFromConstructor(\ReflectionProperty $subject):

public function finalize(): void
{
$this->phpType ??= 'mixed';

if ($this->typeField && !$this->typeField->acceptsType($this->phpType) && $this->typeDef->accepts($this->phpType)) {
throw FieldTypeIncompatible::create($this->typeField::class, $this->phpType);
}

// We cannot compute these until we have the PHP type,
// but they can still be determined entirely at analysis time
// and cached.
Expand Down Expand Up @@ -283,9 +304,6 @@ public function subAttributes(): array

protected function fromTypeField(?TypeField $typeField): void
{
if ($typeField && !$typeField->acceptsType($this->phpType)) {
throw FieldTypeIncompatible::create($typeField::class, $this->phpType);
}
// This may assign to null, which is OK as that will
// evaluate to false when we need it to.
$this->typeField = $typeField;
Expand Down Expand Up @@ -339,18 +357,6 @@ protected function deriveTypeCategory(): TypeCategory
};
}

protected function getNativeType(\ReflectionProperty $property): string
{
// @todo Support easy unions, like int|float.
$rType = $property->getType();
return match(true) {
$rType instanceof \ReflectionUnionType => throw UnionTypesNotSupported::create($property),
$rType instanceof \ReflectionIntersectionType => throw IntersectionTypesNotSupported::create($property),
$rType instanceof \ReflectionNamedType => $rType->getName(),
default => static::TYPE_NOT_SPECIFIED,
};
}

public function deriveSerializedName(): string
{
return $this->rename?->convert($this->phpName)
Expand Down
8 changes: 7 additions & 1 deletion src/Attributes/MixedField.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Attribute;
use Crell\AttributeUtils\SupportsScopes;
use Crell\Serde\CompoundType;
use Crell\Serde\TypeField;

/**
Expand All @@ -15,7 +16,7 @@
* value should be deserialized.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class MixedField implements TypeField, SupportsScopes
class MixedField implements TypeField, SupportsScopes, CompoundType
{
/**
* @param string $suggestedType
Expand All @@ -29,6 +30,11 @@ public function __construct(
protected readonly array $scopes = [null],
) {}

public function suggestedType(): string
{
return $this->suggestedType;
}

public function scopes(): array
{
return $this->scopes;
Expand Down
50 changes: 50 additions & 0 deletions src/Attributes/UnionField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Attributes;

use Crell\AttributeUtils\FromReflectionProperty;
use Crell\AttributeUtils\TypeDef;
use Crell\Serde\TypeField;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UnionField extends MixedField implements FromReflectionProperty
{
// @todo This is also stored on Field; it would be nice to read from there,
// but TypeFields don't get a reference back to the Field. That may be
// something to improve in 2.0.
public readonly TypeDef $typeDef;

/**
* @param string $primaryType
* @param array<string, TypeField> $typeFields
* A list of TypeFields that should apply only if the specified value is
* one of the types in the union. The key is the type, the value is a TypeField
* instance.
* @param array<string|null> $scopes
* The scopes in which this attribute should apply.
*/
public function __construct(
string $primaryType,
public readonly array $typeFields = [],
array $scopes = [null],
) {
parent::__construct($primaryType, $scopes);
}

public function fromReflection(\ReflectionProperty $subject): void
{
$this->typeDef ??= new TypeDef($subject->getType());
}

public function acceptsType(string $type): bool
{
return $this->typeDef->accepts($type);
}

public function validate(mixed $value): bool
{
return $this->typeDef->accepts(get_debug_type($value));
}
}
19 changes: 19 additions & 0 deletions src/CompoundType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Crell\Serde;

/**
* A compound type is a type that may carry more than one sub-type, like a Union type.
*/
interface CompoundType
{
/**
* Returns the primary type for this compound type.
*
* Because most of Serde assumes a single type, any compound type
* needs to declare what single type it falls back to in most cases.
*/
public function suggestedType(): string;
}
5 changes: 5 additions & 0 deletions src/Formatter/ArrayBasedDeformatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -325,4 +325,9 @@ public function getRemainingData(mixed $source, array $used): array
{
return array_diff_key($source, array_flip($used));
}

public function getType(mixed $decoded, Field $field): string
{
return \get_debug_type($decoded[$field->serializedName]);
}
}
2 changes: 1 addition & 1 deletion src/Formatter/ArrayFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Crell\Serde\Attributes\Field;
use Crell\Serde\Deserializer;

class ArrayFormatter implements Formatter, Deformatter, SupportsCollecting
class ArrayFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection
{
use ArrayBasedFormatter;
use ArrayBasedDeformatter;
Expand Down
2 changes: 1 addition & 1 deletion src/Formatter/JsonFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Crell\Serde\Attributes\Field;
use Crell\Serde\Deserializer;

class JsonFormatter implements Formatter, Deformatter, SupportsCollecting
class JsonFormatter implements Formatter, Deformatter, SupportsCollecting, SupportsTypeIntrospection
{
use ArrayBasedFormatter;
use ArrayBasedDeformatter;
Expand Down
5 changes: 3 additions & 2 deletions src/Formatter/SupportsCollecting.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

namespace Crell\Serde\Formatter;

/**
* Indicates a Deformatter that supports reading "the rest of the data" into a collected value.
*/
interface SupportsCollecting
{
/**
*
*
* @param mixed $source
* The deformatter-specific source value being passed around.
* @param string[] $used
Expand Down
Loading