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

TOML support #69

Merged
merged 13 commits into from
Sep 20, 2024
Prev Previous commit
Next Next commit
Fix TOML null and float handling and update test exceptions accordingly.
  • Loading branch information
Crell committed Sep 6, 2024
commit 7dcdff6f6cfe7aecfecb5cb05843a059d4ad1206
43 changes: 34 additions & 9 deletions src/Formatter/TomlFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@

use Crell\Serde\Attributes\ClassSettings;
use Crell\Serde\Attributes\Field;
use Crell\Serde\CollectionItem;
use Crell\Serde\DeformatterResult;
use Crell\Serde\Deserializer;
use Crell\Serde\Dict;
use Crell\Serde\Sequence;
use Crell\Serde\Serializer;
use Devium\Toml\Toml;
use Devium\Toml\TomlError;

use function Crell\fp\collect;

class TomlFormatter implements Formatter, Deformatter, SupportsCollecting
{
use ArrayBasedFormatter;
use ArrayBasedDeformatter;
use ArrayBasedFormatter {
ArrayBasedFormatter::serializeSequence as serializeArraySequence;
ArrayBasedFormatter::serializeDictionary as serializeArrayDictionary;
}
use ArrayBasedDeformatter {
ArrayBasedDeformatter::deserializeFloat as deserializeArrayFloat;
}

public function format(): string
{
Expand All @@ -39,6 +50,18 @@ public function serializeFinalize(mixed $runningValue, ClassSettings $classDef):
return Toml::encode($runningValue['root']);
}

public function serializeSequence(mixed $runningValue, Field $field, Sequence $next, Serializer $serializer): array
{
$next->items = array_filter(collect($next->items), static fn(CollectionItem $i) => !is_null($i->value));
return $this->serializeArraySequence($runningValue, $field, $next, $serializer);
}

public function serializeDictionary(mixed $runningValue, Field $field, Dict $next, Serializer $serializer): array
{
$next->items = array_filter(collect($next->items), static fn(CollectionItem $i) => !is_null($i->value));
return $this->serializeArrayDictionary($runningValue, $field, $next, $serializer);
}

/**
* @param mixed $serialized
* @param ClassSettings $classDef
Expand All @@ -57,17 +80,19 @@ public function deserializeInitialize(
return ['root' => Toml::decode($serialized ?: '', true, true)];
}

public function deserializeFinalize(mixed $decoded): void
/**
* TOML in particular frequently uses strings to represent floats, so in that case, cast it like weak mode, always.
*/
public function deserializeFloat(mixed $decoded, Field $field): float|DeformatterResult|null
{

if ($field->phpType === 'float' && is_string($decoded[$field->serializedName]) && is_numeric($decoded[$field->serializedName])) {
$decoded[$field->serializedName] = (float)$decoded[$field->serializedName];
}
return $this->deserializeArrayFloat($decoded, $field);
}

public function deserializeFloat(mixed $decoded, Field $field): float|DeformatterResult|null
public function deserializeFinalize(mixed $decoded): void
{
if (!array_key_exists($field->serializedName, $decoded)) {
return DeformatterResult::Missing;
}

return (float)($decoded[$field->serializedName]);
}
}
96 changes: 67 additions & 29 deletions tests/TomlFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

namespace Crell\Serde;

use Crell\Serde\Records\EmptyData;
use Crell\Serde\Records\NullArrays;
use Crell\Serde\Records\Pagination\Product;
use Devium\Toml\Toml;
use Crell\Serde\Formatter\TomlFormatter;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use stdClass;

class TomlFormatterTest extends ArrayBasedFormatterTestCases
{
Expand Down Expand Up @@ -53,49 +55,71 @@ public function setUp(): void
'strict' => ['A', 'B'],
'nonstrict' => ['a' => 'A', 'b' => 'B'],
]);
}

#[Test, DataProvider('strict_mode_throws_examples')]
public function strict_mode_throws_correct_exception(mixed $serialized, string $errorField, string $expectedType, string $foundType): void
{
if ($expectedType === 'float' && $foundType === 'string') {
$this->markTestSkipped("it's normal for TOML");
}

parent::strict_mode_throws_correct_exception($serialized, $errorField, $expectedType, $foundType);
$this->weakModeLists = Toml::encode([
'seq' => [1, '2', 3],
'dict' => ['a' => 1, 'b' => '2'],
]);
}

#[Test, DataProvider('round_trip_examples')]
public function round_trip(object $data, string $name): void
{
if (in_array($name, [
'array_of_null_serializes_cleanly',
'arrays_with_valid_scalar_values',
], true)) {
$this->markTestSkipped("it's normal for TOML");
}
if ($name === 'empty_values') {
/** @var EmptyData $data */
$s = new SerdeCommon(formatters: $this->formatters);

$s = new SerdeCommon(formatters: $this->formatters);
$serialized = $s->serialize($data, $this->format);

$this->validateSerialized($serialized, $name);

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

$serialized = $s->serialize($data, $this->format);
// Manually assert the fields that can transfer.
// requiredNullable will be uninitialized for TOML, and
// many others are supposed to be uninitialized, so don't check for them.
self::assertEquals($data->required, $result->required);
self::assertEquals($data->nonConstructorDefault, $result->nonConstructorDefault);
self::assertEquals($data->nullable, $result->nullable);
self::assertEquals($data->withDefault, $result->withDefault);

$this->validateSerialized($serialized, $name);
} elseif ($name === 'array_of_null_serializes_cleanly') {
/** @var NullArrays $data */
$s = new SerdeCommon(formatters: $this->formatters);

$result = $s->deserialize($serialized, from: $this->format, to: $data::class);
$serialized = $s->serialize($data, $this->format);

self::assertEquals($this->clearNullKeys($data), $this->clearNullKeys($result));
$this->validateSerialized($serialized, $name);

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

// TOML can't handle null values in arrays. So in this case,
// we allow it to be empty. In most cases this is good enough.
// In the rare case where the null has significance, it's probably
// a sign of a design flaw in the object to begin with.
self::assertEmpty($result->arr);
} else {
parent::round_trip($data, $name); // TODO: Change the autogenerated stub
}
}

protected function clearNullKeys(object $data): object
#[Test]
public function toml_float_strings_are_safe_in_strict(): void
{
$obj = new stdClass();
foreach (array_keys(get_object_vars($data)) as $key) {
if ($data->{$key} !== null) {
$obj->{$key} = $data->{$key};
}
}
$s = new SerdeCommon(formatters: $this->formatters);

$serialized = Toml::encode([
'name' => 'beep',
'price' => "3.14",
]);

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

return $obj;
self::assertEquals('beep', $result->name);
self::assertEquals('3.14', $result->price);
}

protected function empty_values_validate(mixed $serialized): void
Expand All @@ -111,6 +135,16 @@ protected function empty_values_validate(mixed $serialized): void
self::assertArrayNotHasKey('roNullable', $toTest);
}

/**
* On TOML, the array won't have nulls but will just be empty.
*/
public function array_of_null_serializes_cleanly_validate(mixed $serialized): void
{
$toTest = $this->arrayify($serialized);

self::assertEmpty($toTest['arr']);
}

protected function arrayify(mixed $serialized): array
{
return (array) Toml::decode($serialized, true, true);
Expand All @@ -127,6 +161,10 @@ public static function non_strict_properties_examples(): iterable
public static function strict_mode_throws_examples(): iterable
{
foreach (self::strict_mode_throws_examples_data() as $k => $v) {
// This should NOT throw on TOML.
if ($v['serialized'] === ['afloat' => '3.14']) {
continue;
}
$v['serialized'] = Toml::encode($v['serialized']);
yield $k => $v;
}
Expand Down