diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec2436..117a4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) princip ## 1.3.0 - DATE ### Added +- TOML support! Provided by the [`vanodevium/toml`](https://github.com/vanodevium/toml) library (sold separately). As with YAML, just require that library and Serde will pick it up and use it. Slava Ukraine! - Null values may now be excluded when serializing. See the `omitNullFields` and `omitIfNull` flags in the README. - We now require AttributeUtils 1.2, which lets us use closures rather than method name strings for subAttribute callbacks. (Internal improvement.) - When `strict` is false on a sequence or dictionary, numeric strings will get cast to an int or float as appropriate. Previously the list values were processed in strict mode regardless of what the field was set to. diff --git a/README.md b/README.md index 100e7f7..669d14f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Serde (pronounced "seer-dee") is a fast, flexible, powerful, and easy to use serialization and deserialization library for PHP that supports a number of standard formats. It draws inspiration from both Rust's Serde crate and Symfony Serializer, although it is not directly based on either. -At this time, Serde supports serializing PHP objects to and from PHP arrays, JSON, YAML, and CSV files. It also supports serializing to JSON or CSV via a stream. Further support is planned, but by design can also be extended by anyone. +At this time, Serde supports serializing PHP objects to and from PHP arrays, JSON, YAML, TOML, and CSV files. It also supports serializing to JSON or CSV via a stream. Further support is planned, but by design can also be extended by anyone. ## Install @@ -47,6 +47,7 @@ Serde can serialize to: * JSON (`json`) * Streaming JSON (`json-stream`) * YAML (`yaml`) +* TOML (`toml`) * CSV (`csv`) * Streaming CSV (`csv-stream`) @@ -55,9 +56,14 @@ Serde can deserialize from: * PHP arrays (`array`) * JSON (`json`) * YAML (`yaml`) +* TOML (`toml`) * CSV (`csv`) -(YAML support requires the [`Symfony/Yaml`](https://github.com/symfony/yaml) library.) XML support is in progress. +YAML support requires the [`Symfony/Yaml`](https://github.com/symfony/yaml) library. + +TOML support requires the [`Vanodevium/Toml`](https://github.com/vanodevium/toml) library. + +XML support is in progress. ### Robust object support diff --git a/composer.json b/composer.json index c019971..85016ff 100644 --- a/composer.json +++ b/composer.json @@ -22,12 +22,14 @@ "crell/fp": "~1.0" }, "require-dev": { + "devium/toml": "^1.0.5", "phpbench/phpbench": "^1.3.0", "phpstan/phpstan": "^1.11", "phpunit/phpunit": "~10.5", "symfony/yaml": "^5.4" }, "suggest": { + "devium/toml": "Enables serializing to/from TOML files.", "symfony/yaml": "Enables serializing to/from YAML files." }, "autoload": { diff --git a/src/Formatter/ArrayBasedDeformatter.php b/src/Formatter/ArrayBasedDeformatter.php index c0d08a8..d03fdc3 100644 --- a/src/Formatter/ArrayBasedDeformatter.php +++ b/src/Formatter/ArrayBasedDeformatter.php @@ -150,7 +150,7 @@ public function deserializeSequence(mixed $decoded, Field $field, Deserializer $ if ($class->assert($data)) { return $data; } else { - throw TypeMismatch::create($field->serializedName, "array($class->name)", "array(" . \get_debug_type($data[0] . ')')); + throw TypeMismatch::create($field->serializedName, "array($class->name)", "array(" . \get_debug_type($data[0]) . ')'); } } else if (class_exists($class) || interface_exists($class)) { diff --git a/src/Formatter/TomlFormatter.php b/src/Formatter/TomlFormatter.php new file mode 100644 index 0000000..ae2017e --- /dev/null +++ b/src/Formatter/TomlFormatter.php @@ -0,0 +1,110 @@ + + */ + public function serializeInitialize(ClassSettings $classDef, Field $rootField): array + { + return ['root' => []]; + } + + /** + * @throws TomlError + */ + public function serializeFinalize(mixed $runningValue, ClassSettings $classDef): string + { + return Toml::encode($runningValue['root']); + } + + /** + * @param array $runningValue + * @param Field $field + * @param Sequence $next + * @return array + */ + 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); + } + + /** + * @param array $runningValue + * @param Field $field + * @param Dict $next + * @return array + */ + 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 + * @param Field $rootField + * @param Deserializer $deserializer + * @return array + * @throws TomlError + */ + public function deserializeInitialize( + mixed $serialized, + ClassSettings $classDef, + Field $rootField, + Deserializer $deserializer + ): array + { + return ['root' => Toml::decode($serialized ?: '', true, true)]; + } + + /** + * 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 deserializeFinalize(mixed $decoded): void + { + + } +} diff --git a/src/PropertyHandler/MixedExporter.php b/src/PropertyHandler/MixedExporter.php index fc4af69..eee9a57 100644 --- a/src/PropertyHandler/MixedExporter.php +++ b/src/PropertyHandler/MixedExporter.php @@ -20,7 +20,7 @@ * * This class makes a good-faith attempt to detect the type of a given field by its value. * It currently does not work for objects, and on import it works only on array-based - * formats (JSON, YAML, etc.) + * formats (JSON, YAML, TOML, etc.) */ class MixedExporter implements Importer, Exporter { @@ -55,6 +55,6 @@ public function canImport(Field $field, string $format): bool // We can only import if we know that the $source will be an array so that it // can be introspected. If it's not, then this class has no way to tell what // type to tell the Deformatter to read. - return $field->typeCategory === TypeCategory::Mixed && in_array($format, ['json', 'yaml', 'array']); + return $field->typeCategory === TypeCategory::Mixed && in_array($format, ['json', 'yaml', 'array', 'toml']); } } diff --git a/src/SerdeCommon.php b/src/SerdeCommon.php index 5787cc3..97ac1c8 100644 --- a/src/SerdeCommon.php +++ b/src/SerdeCommon.php @@ -11,6 +11,7 @@ use Crell\Serde\Formatter\Deformatter; use Crell\Serde\Formatter\Formatter; use Crell\Serde\Formatter\JsonFormatter; +use Crell\Serde\Formatter\TomlFormatter; use Crell\Serde\Formatter\YamlFormatter; use Crell\Serde\PropertyHandler\DateTimeExporter; use Crell\Serde\PropertyHandler\DateTimeZoneExporter; @@ -26,6 +27,7 @@ use Crell\Serde\PropertyHandler\ScalarExporter; use Crell\Serde\PropertyHandler\SequenceExporter; use Crell\Serde\PropertyHandler\UnixTimeExporter; +use Devium\Toml\Toml; use Symfony\Component\Yaml\Yaml; use function Crell\fp\afilter; use function Crell\fp\indexBy; @@ -94,6 +96,9 @@ public function __construct( if (class_exists(Yaml::class)) { $formatters[] = new YamlFormatter(); } + if (class_exists(Toml::class)) { + $formatters[] = new TomlFormatter(); + } // These lines by definition filter the array to the correct type, but // PHPStan doesn't know that. diff --git a/tests/TomlFormatterTest.php b/tests/TomlFormatterTest.php new file mode 100644 index 0000000..a6a6770 --- /dev/null +++ b/tests/TomlFormatterTest.php @@ -0,0 +1,172 @@ +formatters = [new TomlFormatter()]; + $this->format = 'toml'; + $this->emptyData = ''; + + $this->aliasedData = Toml::encode([ + 'un' => 1, + 'dos' => 'dos', + 'dot' => [ + 'x' => 1, + 'y' => 2, + 'z' => 3, + ] + ]); + + $this->invalidDictStringKey = Toml::encode([ + 'stringKey' => ['a' => 'A', 2 => 'B'], + // The 'd' key here is invalid and won't deserialize. + 'intKey' => [5 => 'C', 'd' => 'D'], + ]); + + $this->invalidDictIntKey = Toml::encode([ + // The 2 key here is invalid and won't deserialize. + 'stringKey' => ['a' => 'A', 2 => 'B'], + 'intKey' => [5 => 'C', 10 => 'D'], + ]); + + $this->missingOptionalData = Toml::encode(['a' => 'A']); + + $this->dictsInSequenceShouldFail = Toml::encode([ + 'strict' => ['a' => 'A', 'b' => 'B'], + 'nonstrict' => ['a' => 'A', 'b' => 'B'], + ]); + + $this->dictsInSequenceShouldPass = Toml::encode([ + 'strict' => ['A', 'B'], + 'nonstrict' => ['a' => 'A', 'b' => 'B'], + ]); + + $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 ($name === 'empty_values') { + /** @var EmptyData $data */ + $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); + + // 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); + + } elseif ($name === 'array_of_null_serializes_cleanly') { + /** @var NullArrays $data */ + $s = new SerdeCommon(formatters: $this->formatters); + + $serialized = $s->serialize($data, $this->format); + + $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 + } + } + + #[Test] + public function toml_float_strings_are_safe_in_strict(): void + { + $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); + + self::assertEquals('beep', $result->name); + self::assertEquals('3.14', $result->price); + } + + protected function empty_values_validate(mixed $serialized): void + { + $toTest = $this->arrayify($serialized); + + self::assertEquals('narf', $toTest['nonConstructorDefault']); + self::assertEquals('beep', $toTest['required']); + self::assertArrayNotHasKey('requiredNullable', $toTest); + self::assertEquals('boop', $toTest['withDefault']); + self::assertArrayNotHasKey('nullableUninitialized', $toTest); + self::assertArrayNotHasKey('uninitialized', $toTest); + 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); + } + + public static function non_strict_properties_examples(): iterable + { + foreach (self::non_strict_properties_examples_data() as $k => $v) { + $v['serialized'] = Toml::encode($v['serialized']); + yield $k => $v; + } + } + + 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; + } + } +}