Skip to content

Commit

Permalink
Merge pull request #62 from Crell/exclude-null
Browse files Browse the repository at this point in the history
Allow excluding null fields on serialization
  • Loading branch information
Crell committed Jul 10, 2024
2 parents 106476d + 3cff304 commit 97f480d
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 2 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ This release includes a small *breaking change*. The deformatter methods all no
### Security
- Nothing

## 1.3.0 - DATE

### Added
- Null values may now be excluded when serializing. See the `omitNullFields` and `omitIfNull` flags in the README.

### Deprecated
- Nothing

### Fixed
- Nothing

### Removed
- Nothing

### Security
- Nothing

## 1.1.0 - 2024-01-20

The main change in this release is better support for flattening value objects. See the additional section in the README for more details.
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ There is also a `ClassSettings` attribute that may be placed on classes to be se
* `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.
* `renameWith`. If set, the specified renaming strategy will be used for all properties of the class, unless a property specifies its own. (See `renameWith` below.) The class-level setting applies to any field that does not specify its behavior.
* `omitNullFields`, which defaults to false. If set to true, any property on the class that is null will be omitted when serializing. It has on effect on deserialization. This may also be turned on or off on a per-field level. (See `omitIfNull` below.)
* `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 @@ -240,6 +241,10 @@ All three of the following JSON strings would be read into an identical object:

This is mainly useful when an API key has changed, and legacy incoming data may still have an old key name.

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

This key only applies on serialization. If set to true, and the value of this property is null when an object is serialized, it will be omitted from the output entirely. If false, a `null` will be written to the output, however that looks for the particular format.

### `useDefault` (bool, default true)

This key only applies on deserialization. If a property of a class is not found in the incoming data, and this property is true, then a default value will be assigned instead. If false, the value will be skipped entirely. Whether the deserialized object is now in an invalid state depends on the object.
Expand Down
7 changes: 6 additions & 1 deletion src/Attributes/ClassSettings.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ class ClassSettings implements FromReflectionClass, ParseProperties, HasSubAttri
* 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)]
*/
* @param bool $omitNullFields
* When serializing, if a property is set to null, exclude it from the output
* entirely. Default false, meaning a "null" will be written to the output format.
* This may be overridden on a per-field basis.
*/
public function __construct(
public readonly bool $includeFieldsByDefault = true,
public readonly array $scopes = [null],
public readonly bool $requireValues = false,
public readonly ?RenamingStrategy $renameWith = null,
public readonly bool $omitNullFields = false,
) {}

public function fromReflection(\ReflectionClass $subject): void
Expand Down
15 changes: 14 additions & 1 deletion src/Attributes/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
*/
protected ?RenamingStrategy $rename;

/**
* Whether or not to omit values when serializing if they are null.
*/
public readonly bool $omitIfNull;


/**
* Additional key/value pairs to be included with an object.
*
Expand All @@ -107,7 +113,6 @@ 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 @@ -139,6 +144,9 @@ class Field implements FromReflectionProperty, HasSubAttributes, Excludable, Sup
* 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 bool $omitIfNull
* When serializing, if a property is set to null, exclude it from the output
* entirely. Default false, meaning a "null" will be written to the output format.
* @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 @@ -156,6 +164,7 @@ public function __construct(
public readonly array $alias = [],
public readonly bool $strict = true,
?bool $requireValue = null,
?bool $omitIfNull = null,
protected readonly array $scopes = [null],
) {
if ($default !== PropValue::None) {
Expand All @@ -166,6 +175,9 @@ public function __construct(
if ($requireValue !== null) {
$this->requireValue = $requireValue;
}
if ($omitIfNull !== null) {
$this->omitIfNull = $omitIfNull;
}
// Upcast the literal serialized name to a converter if appropriate.
$this->rename ??=
$renameWith
Expand Down Expand Up @@ -213,6 +225,7 @@ public function fromClassAttribute(object $class): void
// If there is no requireValue flag set, inherit it from the class attribute.
$this->requireValue ??= $class->requireValues;
$this->rename ??= $class->renameWith ?? null;
$this->omitIfNull ??= $class->omitNullFields ?? false;
}

protected function getDefaultValueFromConstructor(\ReflectionProperty $subject): mixed
Expand Down
4 changes: 4 additions & 0 deletions src/PropertyHandler/ObjectExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ protected function flattenValue(Dict $dict, Field $field, callable $propReader,
return $dict;
}

if ($field->omitIfNull && is_null($value)) {
return $dict;
}

if (!$field->flatten) {
return $dict->add(new CollectionItem(field: $field, value: $value));
}
Expand Down
16 changes: 16 additions & 0 deletions tests/ArrayBasedFormatterTestCases.php
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,20 @@ public function multiple_same_class_value_objects_work_when_nested_and_flattened
self::assertSame(65, $toTest['desc_max_age']);
}

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

self::assertEquals('A', $toTest['name']);
self::assertArrayNotHasKey('age', $toTest);
}

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

self::assertEquals('A', $toTest['name']);
self::assertArrayNotHasKey('age', $toTest);
}

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

declare(strict_types=1);

namespace Crell\Serde\Records;

use Crell\Serde\Attributes\Field;

class ExcludeNullFields
{
public function __construct(
public string $name,
#[Field(omitIfNull: true)]
public ?int $age = null,
) {}
}
17 changes: 17 additions & 0 deletions tests/Records/ExcludeNullFieldsClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Crell\Serde\Records;

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

#[ClassSettings(omitNullFields: true)]
class ExcludeNullFieldsClass
{
public function __construct(
public string $name,
public ?int $age = null,
) {}
}
46 changes: 46 additions & 0 deletions tests/SerdeTestCases.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
use Crell\Serde\Records\Drupal\StringItem;
use Crell\Serde\Records\Drupal\TextItem;
use Crell\Serde\Records\EmptyData;
use Crell\Serde\Records\ExcludeNullFields;
use Crell\Serde\Records\ExcludeNullFieldsClass;
use Crell\Serde\Records\Exclusions;
use Crell\Serde\Records\ExplicitDefaults;
use Crell\Serde\Records\FlatMapNested\HostObject;
Expand Down Expand Up @@ -1622,6 +1624,50 @@ public function nullable_properties_flattened_validate(mixed $serialized): void

}

#[Test]
public function null_properties_may_be_excluded(): void
{
$s = new SerdeCommon(formatters: $this->formatters);

$data = new ExcludeNullFields('A');

$serialized = $s->serialize($data, $this->format);

$this->null_properties_may_be_excluded_validate($serialized);

/** @var ExcludeNullFields $result */
$result = $s->deserialize($serialized, from: $this->format, to: $data::class);

self::assertEquals($data, $result);
}

public function null_properties_may_be_excluded_validate(mixed $serialized): void
{

}

#[Test]
public function null_properties_may_be_excluded_class_level(): void
{
$s = new SerdeCommon(formatters: $this->formatters);

$data = new ExcludeNullFieldsClass('A');

$serialized = $s->serialize($data, $this->format);

$this->null_properties_may_be_excluded_class_level_validate($serialized);

/** @var ExcludeNullFields $result */
$result = $s->deserialize($serialized, from: $this->format, to: $data::class);

self::assertEquals($data, $result);
}

public function null_properties_may_be_excluded_class_level_validate(mixed $serialized): void
{

}

#[Test]
public function non_sequence_arrays_are_normalized_to_sequences(): void
{
Expand Down

0 comments on commit 97f480d

Please sign in to comment.