From 8c26f490eed4273fc633afe5747fd71fb17ba6e5 Mon Sep 17 00:00:00 2001 From: ActualFab Date: Sun, 24 Mar 2024 18:54:20 +0100 Subject: [PATCH] Initial upload --- .gitattributes | 108 ++++++++++ .gitignore | 3 + LICENSE | 21 ++ README.md | 114 +++++++++++ composer.json | 58 ++++++ phpunit.xml | 17 ++ pint.json | 77 +++++++ src/Attribute/Cast.php | 51 +++++ src/Attribute/Casts.php | 52 +++++ src/Caster/CasterInterface.php | 15 ++ src/Caster/DateTimeCaster.php | 73 +++++++ src/Caster/DateTimeFormatCaster.php | 80 ++++++++ src/Caster/Dt0Caster.php | 32 +++ src/Caster/MathCaster.php | 40 ++++ src/Caster/NoOpCaster.php | 18 ++ src/Dt0.php | 237 ++++++++++++++++++++++ src/Exception/Dt0Exception.php | 37 ++++ src/Format.php | 18 ++ src/Property/Properties.php | 137 +++++++++++++ src/Property/Property.php | 142 +++++++++++++ src/Type/Type.php | 32 +++ src/Type/Types.php | 157 ++++++++++++++ tests/Artifacts/DefaultDt0.php | 41 ++++ tests/Artifacts/Dt0Dt0.php | 29 +++ tests/Artifacts/Enum/IntBackedEnum.php | 17 ++ tests/Artifacts/Enum/StringBackedEnum.php | 17 ++ tests/Artifacts/Enum/UnitEnum.php | 17 ++ tests/Artifacts/EnumDt0.php | 31 +++ tests/Artifacts/SimpleDefaultDt0.php | 25 +++ tests/DefaultTest.php | 138 +++++++++++++ tests/Dt0Test.php | 70 +++++++ tests/EnumTest.php | 104 ++++++++++ tests/TestCase.php | 38 ++++ 33 files changed, 2046 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 src/Attribute/Cast.php create mode 100644 src/Attribute/Casts.php create mode 100644 src/Caster/CasterInterface.php create mode 100644 src/Caster/DateTimeCaster.php create mode 100644 src/Caster/DateTimeFormatCaster.php create mode 100644 src/Caster/Dt0Caster.php create mode 100644 src/Caster/MathCaster.php create mode 100644 src/Caster/NoOpCaster.php create mode 100644 src/Dt0.php create mode 100644 src/Exception/Dt0Exception.php create mode 100644 src/Format.php create mode 100644 src/Property/Properties.php create mode 100644 src/Property/Property.php create mode 100644 src/Type/Type.php create mode 100644 src/Type/Types.php create mode 100644 tests/Artifacts/DefaultDt0.php create mode 100644 tests/Artifacts/Dt0Dt0.php create mode 100644 tests/Artifacts/Enum/IntBackedEnum.php create mode 100644 tests/Artifacts/Enum/StringBackedEnum.php create mode 100644 tests/Artifacts/Enum/UnitEnum.php create mode 100644 tests/Artifacts/EnumDt0.php create mode 100644 tests/Artifacts/SimpleDefaultDt0.php create mode 100644 tests/DefaultTest.php create mode 100644 tests/Dt0Test.php create mode 100644 tests/EnumTest.php create mode 100644 tests/TestCase.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2116ae2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,108 @@ +# laravel default +*.css linguist-vendored +*.less linguist-vendored + +# These settings are for any web project + +# Handle line endings automatically for files detected as text +# and leave all files detected as binary untouched. +* text=auto + +# +# The above will handle all files NOT found below +# + +# +## These files are text and should be normalized (Convert crlf => lf) +# + +# source code +*.php text +*.css text +*.sass text +*.scss text +*.less text +*.styl text +*.js text +*.coffee text +*.json text +*.htm text +*.html text +*.xml text +*.svg text +*.txt text +*.ini text +*.inc text +*.pl text +*.rb text +*.py text +*.scm text +*.sql text +*.sh text +*.bat text + +# templates +*.ejs text +*.hbt text +*.jade text +*.haml text +*.hbs text +*.dot text +*.tmpl text +*.phtml text + +# server config +.htaccess text + +# git config +.gitattributes text +.gitignore text + +# code analysis config +.jshintrc text +.jscsrc text +.jshintignore text +.csslintrc text + +# misc config +*.yaml text +*.yml text +.editorconfig text + +# build config +*.npmignore text +*.bowerrc text + +# Heroku +Procfile text +.slugignore text + +# Documentation +*.md text +LICENSE text +AUTHORS text + +# +## These files are binary and should be left untouched +# + +# (binary is a macro for -text -diff) +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.mov binary +*.mp4 binary +*.mp3 binary +*.flv binary +*.fla binary +*.swf binary +*.gz binary +*.zip binary +*.7z binary +*.ttf binary +*.eot binary +*.woff binary +*.pyc binary +*.pdf binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbebd00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor +.*.cache +composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d312f8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 fab2s + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac43318 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Dt0 + +`Dt0` (_DeeTO_ or _DeTZerO_) is a DTO (_Data-Transport-Object_) PHP implementation than can both secure mutability and implement convenient ways to take control over input and output in various format. + +## Installation + +`Dt0` can be installed using composer: + +```shell +composer require "fab2s/dt0" +``` + +Once done, you can start playing : + +```php + +use fab2s\Dt0\Dt0; + +// works if all public props have defaults +$dt0 = new SomeDt0(); + +// set at least props without default +$dt0 = new SomeDt0(readOnlyProp: $someValue /*, ... */); // <= argument order does not matter + // unless SomeDt0 has a constructor + +// same as +$dt0 = SomeDt0::make(readOnlyProp: $someValue /*, ... */); // <= argument order never matter + +$value = $dt0->readOnlyProp; // $someValue + +/** @var array|string|SomeDt0|Dt0|null $wannaBeDt0 */ +$dt0 = SomeDt0::tryFrom($wannaBeDt0); + +/** @var Dt0 $dt0 */ + +// recursively applied among Dt0 members +$array = $dt0->toArray(); + +// toArray with call to jsonSerialize on compatible members +$jsonArray = $dt0->toJsonArray(); +// same as +$jsonArray = $dt0->jsonSerialize(); + +// toJson +$json = $dt0->toJson(); +// same as +$json = json_decode($dt0); + +// serializable +$serialized = serialize($dt0); +$dt0 = unserialize($serialized); + +``` + +## Casting + +`Dt0` comes with two `Attributes` : `Casts` and `Cast` + +`Cast` is used to define how to handle a property as a **property attribute** and `Casts` is used to set many `Cast` at once as a **class attribute**. + +````php + +use fab2s\Dt0\Dt0; + +#[Casts( + new Cast(default: 'defaultFromCast', propName: 'propClassCasted'), + // same as + propClassCasted: new Cast(default: 'defaultFromCast'), +)] +class MyDt0 extends Dt0 +{ + public readonly string $propClassCasted; + + #[Cast(default: null)] + public readonly ?string $propCasted; +} + +$dt0 = MyDt0::make(); +/** +[ + 'propClassCasted' => 'defaultFromCast', + 'propCasted' => null, +] +*/ + +$dt0 = MyDt0::make(propCasted:'Oh Yeah'); +/** +[ + 'propClassCasted' => 'defaultFromCast', + 'propCasted' => 'Oh Yeah', +] +*/ + +$dt0 = MyDt0::fromArray(['propCasted' => 'Oh', 'propClassCasted' => 'Ho']); +/** +[ + 'propClassCasted' => 'Oh', + 'propCasted' => 'Ho', +] +*/ + +```` + +## Requirements + +`Dt0` is tested against php 8.1 and 8.2 + +## Contributing + +Contributions are welcome, do not hesitate to open issues and submit pull requests. + +## License + +`Dt0` is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f398ebf --- /dev/null +++ b/composer.json @@ -0,0 +1,58 @@ +{ + "name": "fab2s/dt0", + "description": "Dt0, a DTO PHP implementation than can both secure mutability and implement convenient ways to take control over input and output in various format", + "type": "library", + "authors": [{ + "name": "Fabrice de Stefanis" + }], + "support": { + "issues": "https://github.com/fab2s/Dt0/issues", + "source": "https://github.com/fab2s/Dt0" + }, + "keywords": [ + "Data-Transport-Object", + "DTO", + "DT0", + "symfony", + "laravel", + "PHP", + "Serializable", + "immutable", + "JSON", + "Data-Processing" + ], + "license": [ + "MIT" + ], + "require": { + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "laravel/pint": "^1.10", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "fab2s/math": "*" + }, + "suggest": { + "laravel/laravel": "To use Laravel (The awesome) implementations" + }, + "autoload": { + "psr-4": { + "fab2s\\Dt0\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "fab2s\\Dt0\\Tests\\": "tests" + } + }, + "scripts": { + "post-update-cmd": [ + "rm -rf .*.cache" + ], + "post-install-cmd": [ + "rm -rf .*.cache" + ], + "fix": "@php vendor/bin/pint --config pint.json" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..8e81f97 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + + ./tests + + + + + + + + src/ + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..5963fc0 --- /dev/null +++ b/pint.json @@ -0,0 +1,77 @@ +{ + "preset": "laravel", + "rules": { + "header_comment": { + "header": "This file is part of fab2s/Dt0.\n(c) Fabrice de Stefanis / https://github.com/fab2s/Dt0\nThis source file is licensed under the MIT license which you will\nfind in the LICENSE file or at https://opensource.org/licenses/MIT" + }, + "assign_null_coalescing_to_coalesce_equal": true, + "binary_operator_spaces": { + "default": "align_single_space_minimal" + }, + "class_definition": true, + "blank_lines_before_namespace": true, + "align_multiline_comment": true, + "class_attributes_separation": { + "elements": { + "method": "one", + "const": "only_if_meta", + "property": "only_if_meta", + "trait_import": "only_if_meta" + } + }, + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_functions": false, + "import_constants": false + }, + "no_superfluous_phpdoc_tags": true, + "method_argument_space": { + "on_multiline": "ensure_fully_multiline" + }, + "method_chaining_indentation": true, + "multiline_whitespace_before_semicolons": { + "strategy": "new_line_for_chained_calls" + }, + "operator_linebreak": { + "position": "beginning" + }, + "ordered_class_elements": { + "order": [ + "use_trait", + "constant", + "case", + "property", + "method" + ] + }, + "phpdoc_align": true, + "phpdoc_separation": { + "groups": [ + [ + "param" + ], + [ + "return" + ] + ] + }, + "php_unit_method_casing": { + "case": "snake_case" + }, + "return_type_declaration": true, + "simplified_null_return": true, + "single_trait_insert_per_statement": true, + "trailing_comma_in_multiline": { + "elements": [ + "parameters", + "arguments" + ] + } + }, + "exclude": [ + "vendor" + ] +} diff --git a/src/Attribute/Cast.php b/src/Attribute/Cast.php new file mode 100644 index 0000000..74feee0 --- /dev/null +++ b/src/Attribute/Cast.php @@ -0,0 +1,51 @@ +in = $in instanceof CasterInterface ? $in : ($in ? new $in : null); + $this->out = $out instanceof CasterInterface ? $out : ($out ? new $out : null); + $this->hasDefault = $this->default !== Dt0::DT0_NIL; + } + + public static function make( + CasterInterface|string|null $in = null, + CasterInterface|string|null $out = null, + mixed $default = Dt0::DT0_NIL, + string|array|null $renameFrom = null, + ?string $renameTo = null, + ): static { + return new static( + in: $in, + out: $out, + default: $default, + renameFrom: $renameFrom, + renameTo: $renameTo, + ); + } +} diff --git a/src/Attribute/Casts.php b/src/Attribute/Casts.php new file mode 100644 index 0000000..2be3809 --- /dev/null +++ b/src/Attribute/Casts.php @@ -0,0 +1,52 @@ + + */ + protected array $casters = []; + + public function __construct( + Cast ...$casters, + ) { + foreach ($casters as $name => $caster) { + if (is_int($name)) { + if (! $caster->propName) { + continue; + } + + $name = $caster->propName; + } + + $this->casters[$name] = $caster; + } + } + + public function hasCast($name): bool + { + return isset($this->casters[$name]); + } + + public function getCast($name): ?Cast + { + return $this->casters[$name] ?? null; + } + + public function getCasters(): array + { + return $this->casters; + } +} diff --git a/src/Caster/CasterInterface.php b/src/Caster/CasterInterface.php new file mode 100644 index 0000000..2920df4 --- /dev/null +++ b/src/Caster/CasterInterface.php @@ -0,0 +1,15 @@ +timeZone = $timeZone instanceof DateTimeZone ? $timeZone : ($timeZone ? new DateTimeZone($timeZone) : null); + $this->dateTimeClass = $immutable ? DateTimeImmutable::class : DateTime::class; + } + + /** + * @throws Exception + */ + public function cast(mixed $value): ?DateTimeInterface + { + $instance = match (true) { + $value instanceof DateTimeInterface => $this->dateTimeClass::createFromInterface($value), + $this->nullable && $value === null => null, + is_array($value) => $this->fromArray($value), + is_string($value) => new $this->dateTimeClass($value), + is_int($value) => (new $this->dateTimeClass)->setTimestamp($value), + default => throw new InvalidArgumentException('Unsupported type'), + }; + + if ($instance && $this->timeZone) { + $instance = $instance->setTimezone($this->timeZone); + } + + return $instance; + } + + protected function fromArray(array $date): ?DateTimeInterface + { + $result = null; + if (! empty($date['date'])) { + $result = new $this->dateTimeClass($date['date']); + if (! empty($date['timezone'])) { + $result->setTimeZone($date['timezone'] instanceof DateTimeZone ? $date['timezone'] : new DateTimeZone($date['timezone'])); + } + } + + if (! $this->nullable && $result === null) { + throw new InvalidArgumentException('This Date is not nullable'); + } + + return $result; + } +} diff --git a/src/Caster/DateTimeFormatCaster.php b/src/Caster/DateTimeFormatCaster.php new file mode 100644 index 0000000..b0c44ab --- /dev/null +++ b/src/Caster/DateTimeFormatCaster.php @@ -0,0 +1,80 @@ +timeZone = $timeZone instanceof DateTimeZone ? $timeZone : ($timeZone ? new DateTimeZone($timeZone) : null); + } + + /** + * @throws Exception + */ + public function cast(mixed $value): ?string + { + $instance = match (true) { + $value instanceof DateTimeInterface => DateTimeImmutable::createFromInterface($value), + $this->nullable && $value === null => null, + is_array($value) => $this->fromArray($value), + is_string($value) => new DateTimeImmutable($value), + is_int($value) => (new DateTimeImmutable)->setTimestamp($value), + default => throw new InvalidArgumentException('Unsupported type'), + }; + + if ($instance) { + if ($this->timeZone) { + $instance = $instance->setTimezone($this->timeZone); + } + + return $instance->format($this->format); + } + + if (! $this->nullable) { + throw new InvalidArgumentException('Value is not a DateTime'); + } + + return null; + + } + + protected function fromArray(array $date): ?DateTimeInterface + { + $result = null; + if (! empty($date['date'])) { + $result = new $this->dateTimeClass($date['date']); + if (! empty($date['timezone'])) { + $result->setTimeZone($date['timezone'] instanceof DateTimeZone ? $date['timezone'] : new DateTimeZone($date['timezone'])); + } + } + + if (! $this->nullable && $result === null) { + throw new InvalidArgumentException('This Date is not nullable'); + } + + return $result; + } +} diff --git a/src/Caster/Dt0Caster.php b/src/Caster/Dt0Caster.php new file mode 100644 index 0000000..f653971 --- /dev/null +++ b/src/Caster/Dt0Caster.php @@ -0,0 +1,32 @@ +dt0Fqn::tryFrom($value); + } +} diff --git a/src/Caster/MathCaster.php b/src/Caster/MathCaster.php new file mode 100644 index 0000000..2988bfb --- /dev/null +++ b/src/Caster/MathCaster.php @@ -0,0 +1,40 @@ +precision = max(0, $precision); + } + + public function cast(mixed $value): ?Math + { + if ($this->nullable && $value === null) { + return null; + } + + $isNumber = Math::isNumber($value); + if (! $this->nullable && ! $isNumber) { + throw new InvalidArgumentException('Value is not a number'); + } + + return $isNumber ? Math::number($value) : null; + } +} diff --git a/src/Caster/NoOpCaster.php b/src/Caster/NoOpCaster.php new file mode 100644 index 0000000..fabb1c9 --- /dev/null +++ b/src/Caster/NoOpCaster.php @@ -0,0 +1,18 @@ + + */ + protected Properties $dt0Properties; + protected array $dt0Output = []; + protected static array $dt0Cache = []; + + /** + * @throws Dt0Exception + * @throws JsonException + */ + public function __construct(mixed ...$args) + { + $this->dt0Properties = static::compile(); + $args = static::initializeRenameFrom($this->dt0Properties, $args); + + foreach ($this->dt0Properties->toArray() as $name => $property) { + if (! $property->property->isInitialized($this)) { + if (static::initializeValue($property, $args, $value)) { + $property->property->setValue($this, $value); + } else { + throw new Dt0Exception("Missing required property $name in " . static::class); + } + } + } + } + + /** + * @throws Dt0Exception + * @throws JsonException + */ + public static function make(mixed ...$args): static + { + $properties = static::compile(); + $args = static::initializeRenameFrom($properties, $args); + + foreach ($properties->toArray() as $name => $property) { + if (static::initializeValue($property, $args, $value)) { + $args[$name] = $value; + } + } + + if ($properties->constructorParameters) { + $inputConstruct = array_intersect_key($args, $properties->constructorParameters); + $inputArgs = array_diff_key($args, $properties->constructorParameters); + + return new static(...$inputConstruct, ...$inputArgs); + } + + return new static(...$args); + } + + public function clone(array $update = []): static + { + return static::fromArray(array_replace($this->toArray(), $update)); + } + + public function toArray(): array + { + if (isset($this->dt0Output[Format::ARRAY->value])) { + return $this->dt0Output[Format::ARRAY->value]; + } + + $result = []; + foreach ($this->dt0Properties->toArray() as $name => $property) { + $key = $property->cast?->renameTo ?? $name; + if ($property->out) { + $value = $property->out->cast($this->$name); + } else { + $value = $this->$name; + } + + $result[$key] = match (true) { + $value instanceof Dt0 => $value->toArray(), + $value instanceof UnitEnum => $value->value ?? $value->name, + default => $value, + }; + } + + return $this->dt0Output[Format::ARRAY->value] = $result; + } + + public function jsonSerialize(): array + { + if (isset($this->dt0Output[Format::JSON_SERIALISED->value])) { + return $this->dt0Output[Format::JSON_SERIALISED->value]; + } + + $result = $this->toArray(); + foreach ($result as $name => $value) { + if ($value instanceof JsonSerializable) { + $result[$name] = $value->jsonSerialize(); + } + } + + return $this->dt0Output[Format::JSON_SERIALISED->value] = $result; + } + + /** + * @throws JsonException + */ + public function toJson(int $flags = 0, int $depth = 512): string + { + return $this->dt0Output[Format::JSON->value] ??= json_encode($this, JSON_THROW_ON_ERROR & $flags, $depth); + } + + public function __toString(): string + { + return $this->toString(); + } + + public function toString(): string + { + return $this->toJson(); + } + + public function toJsonArray(): array + { + return $this->jsonSerialize(); + } + + public static function fromArray(array $input): static + { + return static::make(...$input); + } + + /** + * @throws JsonException + */ + public static function fromJson(string $json, int $depth = 512): static + { + return static::make(...json_decode($json, true, $depth, JSON_THROW_ON_ERROR)); + } + + /** + * @throws JsonException + */ + public static function fromString(string $string): static + { + return static::fromJson($string); + } + + /** + * @throws JsonException + */ + public static function tryFrom(mixed $input): ?static + { + return match (true) { + is_string($input) => static::fromString($input), + is_array($input) => static::fromArray($input), + $input instanceof static => $input, + $input instanceof self => static::fromArray($input->toArray()), + default => null, + }; + } + + /** + * @throws JsonException + */ + protected static function initializeValue(Property $property, array $input, mixed &$value = null): bool + { + $hasValue = false; + if (array_key_exists($property->name, $input)) { + $value = $input[$property->name]; + $hasValue = true; + } elseif ($property->hasDefault()) { + $value = $property->getDefault(); + $hasValue = true; + } + + if ($hasValue) { + $value = $property->cast($value); + } + + return $hasValue; + } + + protected static function initializeRenameFrom(Properties $properties, array $parameters): array + { + foreach ($properties->getRenameFrom() as $from => $to) { + if ( + ! array_key_exists($to, $parameters) + && array_key_exists($from, $parameters) + ) { + $parameters[$to] = $parameters[$from]; + unset($parameters[$from]); + } + } + + return $parameters; + } + + /** + * @return Properties + */ + final protected static function compile(): Properties + { + if (isset(self::$dt0Cache[static::class])) { + return self::$dt0Cache[static::class]; + } + + return self::$dt0Cache[static::class] = new Properties(static::class); + } + + public function __sleep(): array + { + return array_keys($this->dt0Properties->toArray()); + } + + public function __wakeup(): void + { + $this->dt0Properties = static::compile(); + } +} diff --git a/src/Exception/Dt0Exception.php b/src/Exception/Dt0Exception.php new file mode 100644 index 0000000..f99b432 --- /dev/null +++ b/src/Exception/Dt0Exception.php @@ -0,0 +1,37 @@ + + */ + protected array $properties = []; + + /** + * @var array + */ + public readonly array $constructorParameters; + protected array $renameFrom = []; + protected array $renameTo = []; + + /** + * @throws ReflectionException + */ + public function __construct(public readonly object|string $objectOrClass) + { + $reflection = new ReflectionClass($this->objectOrClass); + $constructorParameters = []; + $constructor = $reflection->getConstructor(); + if ($constructor->getDeclaringClass()->getName() !== Dt0::class) { + foreach ($constructor->getParameters() as $parameter) { + $constructorParameters[$parameter->getName()] = $parameter; + } + } + + $this->constructorParameters = $constructorParameters; + + $reflectionProperties = $reflection->getProperties(ReflectionProperty::IS_PUBLIC); + $classCaster = $reflection->getAttributes(Casts::class)[0] ?? null; + /** @var ?Casts $classCast */ + $classCast = $classCaster ? $classCaster->newInstance() : null; + foreach ($reflectionProperties as $reflectionProperty) { + $name = $reflectionProperty->getName(); + if ($classCast?->hasCast($name)) { + $this->registerProp($reflectionProperty, $classCast->getCast($name)); + + continue; + } + + $this->registerProp($reflectionProperty); + } + } + + /** + * @throws ReflectionException + */ + public static function make(object|string $objectOrClass): static + { + return new static($objectOrClass); + } + + protected function registerProp(ReflectionProperty $property, ?Cast $cast = null): static + { + $name = $property->getName(); + $prop = Property::make($property, $cast); + if (! $prop->hasDefault()) { + if ($param = $this->constructorParameters[$name] ?? null) { + /** @var ReflectionParameter $param */ + //@todo check $param->isPromoted() ? + if ($param->isDefaultValueAvailable()) { + $prop->setDefault($param->getDefaultValue()); + } + } + } + + $this->properties[$name] = $prop; + + if ($prop->cast?->renameFrom) { + if (is_array($prop->cast->renameFrom)) { + foreach ($prop->cast->renameFrom as $from) { + $this->renameFrom[$from] = $name; + } + } else { + $this->renameFrom[$prop->cast->renameFrom] = $name; + } + } + + if ($prop->cast?->renameTo) { + $this->renameTo[$name] = $prop->cast->renameTo; + $this->renameFrom[$prop->cast->renameTo] = $name; + } + + return $this; + } + + public function push(Property|array ...$properties): static + { + foreach ($properties as $property) { + $property = $property instanceof Property ? $property : Property::make(...$property); + $this->properties[$property->name] = $property; + } + + return $this; + } + + public function get(string $name): ?Property + { + return $this->properties[$name] ?? null; + } + + public function toArray(): array + { + return $this->properties; + } + + public function getRenameFrom(): array + { + return $this->renameFrom; + } + + public function getRenameTo(): array + { + return $this->renameTo; + } +} diff --git a/src/Property/Property.php b/src/Property/Property.php new file mode 100644 index 0000000..af124ac --- /dev/null +++ b/src/Property/Property.php @@ -0,0 +1,142 @@ +property->getAttributes(Cast::class)[0] ?? null; + + $this->cast = $cast ?? $attribute?->newInstance(); + + $this->in = $this->cast?->in; + $this->out = $this->cast?->out; + + $this->name = $this->property->getName(); + $this->types = Types::make($this->property); + $this->isDt0 = ! empty($this->types->getDt0Fqns()); + $this->isEnum = ! empty($this->types->getEnumFqns()); + foreach (['cast', 'types'] as $prop) { + if ($this->$prop?->hasDefault) { + $this->setDefault($this->$prop->default); + break; + } + } + } + + public static function make(ReflectionProperty $property, ?Cast $cast = null): static + { + return new static($property, $cast); + } + + public function hasDefault(): bool + { + return $this->hasDefault; + } + + public function getDefault(): mixed + { + return $this->default; + } + + public function setDefault(mixed $default): static + { + $this->hasDefault = true; + $this->default = $default; + + return $this; + } + + /** + * @throws JsonException + */ + public function cast(mixed $value): mixed + { + if ($this->in) { + // gives the opportunity to enforce more + // things on objects in the caster + // eg timezone for Datetimes + return $this->in->cast($value); + } + + if (is_object($value)) { + return $value; + } + + if ($this->isDt0 && ! empty($value)) { + + foreach ($this->types->getDt0Fqns() as $dt0Fqn) { + /** @var Dt0 $dt0Fqn */ + if ($dt0 = $dt0Fqn::tryFrom($value)) { + $value = $dt0; + break; + } + } + } + + if ($this->isEnum && (is_string($value) || is_int($value))) { + foreach ($this->types->getEnumFqns() as $enumFqn) { + if ($case = static::tryEnum($enumFqn, $value)) { + $value = $case; + break; + } + } + } + + return $value; + } + + public static function tryEnum(?string $enumFqn, string|int|null $value): UnitEnum|BackedEnum|null + { + if (! $enumFqn) { + return null; + } + + if (is_subclass_of($enumFqn, BackedEnum::class)) { + return $enumFqn::tryFrom($value); + } + + return static::tryEnumFromName($enumFqn, $value); + } + + public static function tryEnumFromName(string $enumFqn, ?string $name): UnitEnum|BackedEnum|null + { + if ($name && is_subclass_of($enumFqn, UnitEnum::class)) { + foreach ($enumFqn::cases() as $case) { + if ($case->name === $name) { + return $case; + } + } + } + + return null; + } +} diff --git a/src/Type/Type.php b/src/Type/Type.php new file mode 100644 index 0000000..3d60aea --- /dev/null +++ b/src/Type/Type.php @@ -0,0 +1,32 @@ + + */ + protected array $types = []; + public readonly bool $isReadOnly; + public readonly bool $hasDefault; + public readonly bool $isDefault; + public readonly mixed $default; + public readonly bool $isNullable; + public readonly bool $isUnion; + public readonly bool $isIntersection; + + /** + * @var array + */ + public readonly array $trackInstanceTypes; + protected array $enumFqns = []; + protected array $dt0Fqns = []; + + public function __construct(public readonly ReflectionProperty $property) + { + [ + 'isReadOnly' => $this->isReadOnly, + 'hasDefault' => $this->hasDefault, + 'isDefault' => $this->isDefault, + 'default' => $this->default, + 'isNullable' => $this->isNullable, + 'isUnion' => $this->isUnion, + 'isIntersection' => $this->isIntersection + ] = $this->registerType(); + } + + public static function make(ReflectionProperty $property): static + { + return new static($property); + } + + protected function registerType(): array + { + $isNullable = $isUnion = $isIntersection = false; + foreach ($this->getTypes() as $type) { + $this->types[$type->name] = $type; + $isNullable = $isNullable || $type->allowsNull; + $isUnion = $isUnion || $type->isUnion; + $isIntersection = $isIntersection || $type->isIntersection; + + if (is_subclass_of($type->name, UnitEnum::class)) { + $this->enumFqns[$type->name] = $type->name; + } + + if (is_subclass_of($type->name, Dt0::class)) { + $this->dt0Fqns[$type->name] = $type->name; + } + } + + return [ + 'isReadOnly' => $this->property->isReadOnly(), + 'hasDefault' => $this->property->hasDefaultValue(), + 'isDefault' => $this->property->isDefault(), + 'default' => $this->property->getDefaultValue(), + 'isNullable' => $isNullable, + 'isUnion' => $isUnion, + 'isIntersection' => $isIntersection, + ]; + } + + /** + * @return array + */ + protected function getTypes(): array + { + $type = $this->property->getType(); + $types = []; + $isIntersectionType = false; + switch (true) { + case $type instanceof ReflectionNamedType: + $types[] = Type::make( + name: $type->getName(), + allowsNull: $type->allowsNull(), + isBuiltin: $type->isBuiltin(), + ); + break; + case $type instanceof ReflectionIntersectionType: + $isIntersectionType = true; + case $type instanceof ReflectionUnionType: + foreach ($type->getTypes() as $multiType) { + $types[] = Type::make( + name: $multiType->getName(), + allowsNull: $multiType->allowsNull(), + isBuiltin: $multiType->isBuiltin(), + isUnion: ! $isIntersectionType, + isIntersection: $isIntersectionType, + ); + } + break; + default: + if ($type === null) { + $types[] = Type::make( + name: 'mixed', + allowsNull: true, + isBuiltin: true, + ); + break; + } + + throw new LogicException('Received unknown type from ReflectionProperty::getType'); + } + + return $types; + } + + public function get(string $name): ?Type + { + return $this->types[$name] ?? null; + } + + public function has(string $name): bool + { + return isset($this->types[$name]); + } + + public function toArray(): array + { + return $this->types; + } + + public function getEnumFqns(): array + { + return $this->enumFqns; + } + + public function getDt0Fqns(): array + { + return $this->dt0Fqns; + } +} diff --git a/tests/Artifacts/DefaultDt0.php b/tests/Artifacts/DefaultDt0.php new file mode 100644 index 0000000..aba069a --- /dev/null +++ b/tests/Artifacts/DefaultDt0.php @@ -0,0 +1,41 @@ + 'assigned', 'stringCast' => 'assigned'])] + public readonly DefaultDt0 $defaultDt0Default; + + public function __construct( + public readonly DefaultDt0 $defaultDt0, + ...$args, + ) { + parent::__construct(...$args); + } +} diff --git a/tests/Artifacts/Enum/IntBackedEnum.php b/tests/Artifacts/Enum/IntBackedEnum.php new file mode 100644 index 0000000..0aaa4a8 --- /dev/null +++ b/tests/Artifacts/Enum/IntBackedEnum.php @@ -0,0 +1,17 @@ + $value) { + if ($value === null) { + $expected[$key] = 'default'; + } + } + + $this->assertSame($expected, $dt0->toArray()); + } + + public static function simpleDefaultProvider(): array + { + return [ + [ + [ + 'stringNoCast' => 'assigned', + 'stringCast' => null, + 'stringCastDefault' => null, + ], + ], + [ + [ + 'stringNoCast' => 'assigned', + 'stringCast' => 'assigned', + 'stringCastDefault' => null, + ], + ], + [ + [ + 'stringNoCast' => 'assigned', + 'stringCast' => 'assigned', + 'stringCastDefault' => 'assigned', + ], + ], + ]; + } + + /** + * @throws JsonException + */ + #[DataProvider('defaultProvider')] + public function test_default_dt0( + array $args, + array $expected, + ): void { + $keys = array_keys($args); + shuffle($keys); + $keys = array_flip($keys); + $args = array_filter( + array_replace($keys, $args), + ); + + foreach ($args as $name => $value) { + if ($value === 'null') { + $args[$name] = null; + } + } + + $dt0 = DefaultDt0::fromArray($args); + + $this->assertSame($expected, $dt0->toArray()); + $this->dt0Assertions($dt0); + } + + public static function defaultProvider(): array + { + $cases = []; + $props = [ + 'stringNoCast' => ['assigned'], + 'stringCast' => ['assigned'], + 'stringCastDefault' => [null, 'assigned'], + 'stringCastDefaultNull' => [null, 'null', 'assigned'], + 'stringNoCastDefault' => [null, 'assigned'], + 'stringDefaultCastDefault' => [null, 'assigned'], + 'stringDefaultNullCastDefault' => [null, 'null', 'assigned'], + 'stringDefaultCastDefaultNull' => [null, 'null', 'assigned'], + ]; + + $expected = [ + 'stringNoCast' => ['assigned'], + 'stringCast' => ['assigned'], + 'stringCastDefault' => ['casted', 'assigned'], + 'stringCastDefaultNull' => [null, null, 'assigned'], + 'stringNoCastDefault' => ['default', 'assigned'], + 'stringDefaultCastDefault' => ['casted', 'assigned'], + 'stringDefaultNullCastDefault' => ['casted', null, 'assigned'], + 'stringDefaultCastDefaultNull' => [null, null, 'assigned'], + ]; + + $cases = []; + $defaultArgs = []; + foreach ($props as $prop => $values) { + $defaultArgs[$prop] = $values[0]; + } + + $defaultExpected = []; + foreach ($expected as $prop => $values) { + $defaultExpected[$prop] = $values[0]; + } + + foreach ($props as $prop => $values) { + $case = [ + 'args' => $defaultArgs, + 'expected' => $defaultExpected, + ]; + + foreach ($values as $idx => $value) { + $case['args'][$prop] = $value; + $case['expected'][$prop] = $expected[$prop][$idx]; + $cases[] = $case; + } + } + + return $cases; + } +} diff --git a/tests/Dt0Test.php b/tests/Dt0Test.php new file mode 100644 index 0000000..b74ebaf --- /dev/null +++ b/tests/Dt0Test.php @@ -0,0 +1,70 @@ + $enumDt0, + 'defaultDt0Default' => $defaultDt0Default, + 'defaultDt0' => $defaultDt0, + ])); + + $defaultDt0Default ??= ['stringNoCast' => 'assigned', 'stringCast' => 'assigned']; + + $this->assertSame([ + 'enumDt0' => EnumDt0::tryFrom($enumDt0)->toArray(), + 'defaultDt0Default' => DefaultDt0::tryFrom($defaultDt0Default)->toArray(), + 'defaultDt0' => DefaultDt0::tryFrom($defaultDt0)->toArray(), + + ], $dto->toArray()); + + $this->dt0Assertions($dto); + } + + public static function dt0Provider(): array + { + $defaultDt0 = DefaultDt0::make(stringNoCast: 'assigned', stringCast: 'assigned'); + $enumtDt0 = EnumDt0::make(unitEnum: UnitEnum::ONE, stringBackedEnum: StringBackedEnum::ONE, intBackedEnum: IntBackedEnum::ONE); + + return [ + 'dt0' => [ + 'enumDt0' => $enumtDt0, + 'defaultDt0Default' => null, + 'defaultDt0' => $defaultDt0, + ], + 'string' => [ + 'enumDt0' => (string) $enumtDt0, + 'defaultDt0Default' => null, + 'defaultDt0' => (string) $defaultDt0, + ], + 'array' => [ + 'enumDt0' => $enumtDt0->toArray(), + 'defaultDt0Default' => null, + 'defaultDt0' => $defaultDt0->toArray(), + ], + ]; + } +} diff --git a/tests/EnumTest.php b/tests/EnumTest.php new file mode 100644 index 0000000..9d8d110 --- /dev/null +++ b/tests/EnumTest.php @@ -0,0 +1,104 @@ + $value) { + $this->assertSame($value, $dt0->$prop); + $toArrayExpected[$prop] = $value->value ?? $value->name; + } + + $this->assertSame($toArrayExpected, $dt0->toArray()); + + $this->dt0Assertions($dt0); + } + + public static function enumProvider(): array + { + $cases = []; + $props = [ + 'unitEnum' => UnitEnum::class, + 'stringBackedEnum' => StringBackedEnum::class, + 'intBackedEnum' => IntBackedEnum::class, + ]; + + foreach (StringBackedEnum::cases() as $case) { + $args = [ + 'unitEnum' => Property::tryEnumFromName(UnitEnum::class, $case->name), + 'stringBackedEnum' => $case, + 'intBackedEnum' => Property::tryEnumFromName(IntBackedEnum::class, $case->name), + ]; + + $expected = $args; + foreach ($props as $propName => $enumFqn) { + $withDefaultProp = $propName . 'WithDefault'; + $args[$withDefaultProp] = null; + $expected[$withDefaultProp] = Property::tryEnumFromName($enumFqn, 'ONE'); + } + + $caseName = $case->name . '_instance_default'; + $cases[$caseName] = [ + 'args' => $args, + 'expected' => $expected, + ]; + + foreach ($props as $propName => $enumFqn) { + $withDefaultProp = $propName . 'WithDefault'; + $args[$propName] = $args[$propName]->value ?? $args[$propName]->name; + $args[$withDefaultProp] = $args[$propName]; + $expected[$withDefaultProp] = $expected[$propName]; + } + + $caseName = $case->name . '_strings_string'; + $cases[$caseName] = [ + 'args' => $args, + 'expected' => $expected, + ]; + + foreach ($props as $propName => $enumFqn) { + $withDefaultProp = $propName . 'WithDefault'; + $args[$withDefaultProp] = Property::tryEnum($enumFqn, $args[$propName]); + } + + $caseName = $case->name . '_strings_instance'; + $cases[$caseName] = [ + 'args' => $args, + 'expected' => $expected, + ]; + } + + return $cases; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..83a3ed9 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,38 @@ +assertSame($dt0->toArray(), $dt0->clone()->toArray()); + $this->assertSame($dt0->toArray(), $dt0::tryFrom($dt0->toArray())->toArray()); + $this->assertSame($dt0->toJson(), (string) $dt0); + $this->assertSame($dt0->toArray(), $dt0::tryFrom($dt0->toJson())->toArray()); + $this->assertSame($dt0->toArray(), unserialize(serialize($dt0))->toArray()); + + return $this; + } +}