From 4ead5e76095e9f75a85fdd0af2fb33406add3f70 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Wed, 30 Apr 2025 01:29:46 +0200 Subject: [PATCH 01/11] doc: adjust contribution guide --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4e3346..4f2f641 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Contributors are always welcome! We want to make contributing to this project as Pull requests are the best way to propose changes to the codebase. The ideal way to create a PR is: -1. Fork the repo and create your branch from the main version branch (e.g. `v1.x`). +1. Fork the repo and create your branch from the main version branch. 2. If you've added code that should be tested, add tests. 3. Ensure the test suite passes. 4. Make sure your code lints. From ee112f8d5c5b0c446c3044ad1a6b44caa0b39239 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Wed, 30 Apr 2025 01:30:35 +0200 Subject: [PATCH 02/11] chore: ignore exporting of development or ci files --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index 3e48d48..65e28b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,6 @@ +/.docker export-ignore /.github export-ignore +/.hooks export-ignore /coverage export-ignore /tests export-ignore .gitattributes export-ignore From f314d197a02241100152338aa732abcf444f537f Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Fri, 2 May 2025 20:53:33 +0200 Subject: [PATCH 03/11] feat: implement base logic --- composer.json | 14 +- composer.lock | 1876 +++++++++++++++++++- phpcs.xml.dist | 29 +- src/Attribute/Attribute.php | 14 + src/Attribute/Entity.php | 22 + src/Attribute/InvalidArgumentException.php | 9 + src/Attribute/KeyInterface.php | 19 + src/Attribute/PartitionKey.php | 41 + src/Attribute/SortKey.php | 41 + src/Metadata/ClassMetadata.php | 50 + src/Metadata/EntityMetadata.php | 42 + src/Metadata/MetadataException.php | 9 + src/Metadata/MetadataLoader.php | 127 ++ src/ODM/EntityManager.php | 110 ++ src/ODM/EntityManagerException.php | 9 + src/Serializer/EntitySerializer.php | 376 ++++ src/Serializer/InvalidEntityException.php | 9 + src/Serializer/InvalidFieldException.php | 9 + 18 files changed, 2794 insertions(+), 12 deletions(-) create mode 100644 src/Attribute/Attribute.php create mode 100644 src/Attribute/Entity.php create mode 100644 src/Attribute/InvalidArgumentException.php create mode 100644 src/Attribute/KeyInterface.php create mode 100644 src/Attribute/PartitionKey.php create mode 100644 src/Attribute/SortKey.php create mode 100644 src/Metadata/ClassMetadata.php create mode 100644 src/Metadata/EntityMetadata.php create mode 100644 src/Metadata/MetadataException.php create mode 100644 src/Metadata/MetadataLoader.php create mode 100644 src/ODM/EntityManager.php create mode 100644 src/ODM/EntityManagerException.php create mode 100644 src/Serializer/EntitySerializer.php create mode 100644 src/Serializer/InvalidEntityException.php create mode 100644 src/Serializer/InvalidFieldException.php diff --git a/composer.json b/composer.json index 8bb26da..861f030 100644 --- a/composer.json +++ b/composer.json @@ -9,12 +9,17 @@ } ], "require": { - "php": "^8.3" + "php": "^8.3", + "aws/aws-sdk-php": "^3.343", + "symfony/serializer": "^7.2", + "symfony/property-access": "^7.2" }, "require-dev": { "squizlabs/php_codesniffer": "^3.12", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^12.1" + "phpunit/phpunit": "^12.1", + "slevomat/coding-standard": "^7.2", + "symfony/var-dumper": "^7.2" }, "autoload": { "psr-4": { @@ -25,5 +30,10 @@ "psr-4": { "EduardoMarques\\DynamoPHP\\Tests\\": "tests/" } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/composer.lock b/composer.lock index 50ca577..6be4537 100644 --- a/composer.lock +++ b/composer.lock @@ -4,9 +4,1690 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3339ae1956dfa4314e21791fb9324d68", - "packages": [], + "content-hash": "98bcedbb6ddfd76b4d6259343ef79b53", + "packages": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e", + "reference": "d71d9906c7bb63a28295447ba12e74723bd3730e", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7" + }, + "time": "2024-10-18T22:15:13+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.343.2", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "95d43e71d3395622394b36079f2fb2289d3284b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/95d43e71d3395622394b36079f2fb2289d3284b3", + "reference": "95d43e71d3395622394b36079f2fb2289d3284b3", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/promises": "^2.0", + "guzzlehttp/psr7": "^2.4.5", + "mtdowling/jmespath.php": "^2.8.0", + "php": ">=8.1", + "psr/http-message": "^2.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^2.7.8", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "psr/cache": "^2.0 || ^3.0", + "psr/simple-cache": "^2.0 || ^3.0", + "sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0", + "symfony/filesystem": "^v6.4.0 || ^v7.1.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + }, + "exclude-from-classmap": [ + "src/data/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://github.com/aws/aws-sdk-php/discussions", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.343.2" + }, + "time": "2025-05-01T18:05:02+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, + { + "name": "mtdowling/jmespath.php", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "reference": "a2a865e05d5f420b50cc2f85bb78d565db12a6bc", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.8-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.8.0" + }, + "time": "2024-09-04T18:46:31+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.5-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/property-access", + "version": "v7.2.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "b28732e315d81fbec787f838034de7d6c9b2b902" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b28732e315d81fbec787f838034de7d6c9b2b902", + "reference": "b28732e315d81fbec787f838034de7d6c9b2b902", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4|^7.0" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.2.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-17T10:56:55+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "f00fd9685ecdbabe82ca25c7b739ce7bba99302c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/f00fd9685ecdbabe82ca25c7b739ce7bba99302c", + "reference": "f00fd9685ecdbabe82ca25c7b739ce7bba99302c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/string": "^6.4|^7.0", + "symfony/type-info": "~7.1.9|^7.2.2" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-06T16:27:19+00:00" + }, + { + "name": "symfony/serializer", + "version": "v7.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "d8b75b2c8144c29ac43b235738411f7cca6d584d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/d8b75b2c8144c29ac43b235738411f7cca6d584d", + "reference": "d8b75b2c8144c29ac43b235738411f7cca6d584d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^7.2", + "symfony/error-handler": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.1", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-24T12:37:32+00:00" + }, + { + "name": "symfony/string", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T13:31:26+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/c4824a6b658294c828e609d3d8dbb4e87f6a375d", + "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.0|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-24T09:03:36+00:00" + } + ], "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v0.7.2", + "source": { + "type": "git", + "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "*", + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^9.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "franck.nijhof@dealerdirect.com", + "homepage": "http://www.frenck.nl", + "role": "Developer / IT Manager" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "homepage": "http://www.dealerdirect.com", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", + "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" + }, + "time": "2022-02-04T12:51:07+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.13.1", @@ -243,6 +1924,53 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "1.33.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^4.15", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" + }, + "time": "2024-10-13T11:25:22+00:00" + }, { "name": "phpstan/phpstan", "version": "2.1.13", @@ -1541,6 +3269,67 @@ ], "time": "2025-02-07T05:00:38+00:00" }, + { + "name": "slevomat/coding-standard", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/slevomat/coding-standard.git", + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7", + "php": "^7.2 || ^8.0", + "phpstan/phpdoc-parser": "^1.5.1", + "squizlabs/php_codesniffer": "^3.6.2" + }, + "require-dev": { + "phing/phing": "2.17.3", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpstan/phpstan": "1.4.10|1.7.1", + "phpstan/phpstan-deprecation-rules": "1.0.0", + "phpstan/phpstan-phpunit": "1.0.0|1.1.1", + "phpstan/phpstan-strict-rules": "1.2.3", + "phpunit/phpunit": "7.5.20|8.5.21|9.5.20" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "SlevomatCodingStandard\\": "SlevomatCodingStandard" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "support": { + "issues": "https://github.com/slevomat/coding-standard/issues", + "source": "https://github.com/slevomat/coding-standard/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/kukulich", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", + "type": "tidelift" + } + ], + "time": "2022-05-25T10:58:12+00:00" + }, { "name": "squizlabs/php_codesniffer", "version": "3.12.2", @@ -1677,6 +3466,89 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/var-dumper", + "version": "v7.2.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9c46038cd4ed68952166cf7001b54eb539184ccb", + "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.2.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-09T08:14:01+00:00" + }, { "name": "theseer/tokenizer", "version": "1.2.3", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 47cc539..773e3bd 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -3,15 +3,28 @@ - - - - + + + + - + src/ + tests/ - src/ - tests/Unit - tests/Integration + + + + + + + + + + + + + + + diff --git a/src/Attribute/Attribute.php b/src/Attribute/Attribute.php new file mode 100644 index 0000000..c759e62 --- /dev/null +++ b/src/Attribute/Attribute.php @@ -0,0 +1,14 @@ +table) { + throw new InvalidArgumentException( + sprintf('Attribute argument %s::table must not be empty', $this::class) + ); + } + } +} diff --git a/src/Attribute/InvalidArgumentException.php b/src/Attribute/InvalidArgumentException.php new file mode 100644 index 0000000..8d3beb4 --- /dev/null +++ b/src/Attribute/InvalidArgumentException.php @@ -0,0 +1,9 @@ + + */ + public function getFields(): array; + + public function getName(): string; + + public function getDelimiter(): string; + + public function getPrefix(): ?string; +} diff --git a/src/Attribute/PartitionKey.php b/src/Attribute/PartitionKey.php new file mode 100644 index 0000000..2f9f936 --- /dev/null +++ b/src/Attribute/PartitionKey.php @@ -0,0 +1,41 @@ + */ + protected array $fields, + protected string $name = 'PK', + protected string $delimiter = '#', + protected ?string $prefix = null, + ) { + $this->fields = array_values(array_unique($this->fields)); + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + public function getName(): string + { + return $this->name; + } + + public function getDelimiter(): string + { + return $this->delimiter; + } + + public function getPrefix(): ?string + { + return $this->prefix; + } +} diff --git a/src/Attribute/SortKey.php b/src/Attribute/SortKey.php new file mode 100644 index 0000000..893b6d7 --- /dev/null +++ b/src/Attribute/SortKey.php @@ -0,0 +1,41 @@ +fields = array_values(array_unique($this->fields)); + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + public function getName(): string + { + return $this->name; + } + + public function getDelimiter(): string + { + return $this->delimiter; + } + + public function getPrefix(): ?string + { + return $this->prefix; + } +} diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php new file mode 100644 index 0000000..c103451 --- /dev/null +++ b/src/Metadata/ClassMetadata.php @@ -0,0 +1,50 @@ + */ + protected array $properties, + ) { + } + + /** + * @param string $offset + * + * @return bool + */ + public function offsetExists(mixed $offset): bool + { + return isset($this->properties[$offset]); + } + + /** + * @param string $offset + */ + public function offsetGet(mixed $offset): ?\ReflectionProperty + { + return $this->properties[$offset] ?? null; + } + + /** + * @param string $offset + * @param \ReflectionProperty $value + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->properties[$offset] = $value; + } + + /** + * @param string $offset + */ + public function offsetUnset(mixed $offset): void + { + unset($this->properties[$offset]); + } + +} diff --git a/src/Metadata/EntityMetadata.php b/src/Metadata/EntityMetadata.php new file mode 100644 index 0000000..f007cc5 --- /dev/null +++ b/src/Metadata/EntityMetadata.php @@ -0,0 +1,42 @@ + */ + protected array $propertyAttributes, + ) { + } + + public function getTable(): string + { + return $this->entityAttribute->table; + } + + public function getPartitionKey(): KeyInterface + { + return $this->entityAttribute->partitionKey; + } + + public function getSortKey(): ?KeyInterface + { + return $this->entityAttribute->sortKey; + } + + /** + * @return array + */ + public function getPropertyAttributes(): array + { + return $this->propertyAttributes; + } +} diff --git a/src/Metadata/MetadataException.php b/src/Metadata/MetadataException.php new file mode 100644 index 0000000..8543997 --- /dev/null +++ b/src/Metadata/MetadataException.php @@ -0,0 +1,9 @@ +> + */ + private array $cache = []; + + /** + * @param class-string $class + * + * @throws \ReflectionException + */ + public function getClassMetadata(string $class): ClassMetadata + { + if (isset($this->cache[__METHOD__][$class])) { + return $this->cache[__METHOD__][$class]; + } + + $reflection = new \ReflectionClass($class); + + $classProperties = $this->getClassProperties($reflection); + + $metadata = new ClassMetadata($classProperties); + + $this->cache[__METHOD__][$class] = $metadata; + + return $metadata; + } + + /** + * @param class-string $class + * + * @throws \ReflectionException + * @throws MetadataException + */ + public function getEntityMetadata(string $class): EntityMetadata + { + if (isset($this->cache[__METHOD__][$class])) { + return $this->cache[__METHOD__][$class]; + } + + $reflection = new \ReflectionClass($class); + + $classAttributes = $this->getClassAttributes($reflection); + $entityAttribute = $classAttributes[Entity::class] ?? null; + + if (!($entityAttribute instanceof Entity)) { + throw new MetadataException(sprintf('No %s attribute declared for class "%s"', Entity::class, $class)); + } + + $propertyAttributes = $this->getPropertyAttributes($reflection); + $attributes = []; + + foreach ($propertyAttributes as $prop => $attrs) { + foreach ($attrs as $attr) { + if ($attr instanceof Attribute) { + $attributes[$prop] = $attr; + } + } + } + + $metadata = new EntityMetadata($entityAttribute, $attributes); + + $this->cache[__METHOD__][$class] = $metadata; + + return $metadata; + } + + /** + * @return array + */ + private function getClassProperties(\ReflectionClass $reflection): array + { + $properties = []; + + foreach ($reflection->getProperties() as $property) { + $properties[$property->getName()] = $property; + } + + return $properties; + } + + /** + * @return array + */ + private function getClassAttributes(\ReflectionClass $reflection): array + { + $attributes = []; + + foreach ($reflection->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + $attributes[$instance::class] = $instance; + } + + return $attributes; + } + + /** + * @return array> + */ + private function getPropertyAttributes(\ReflectionClass $reflection): array + { + $attributes = []; + + foreach ($reflection->getProperties() as $property) { + $propertyAttributes = $property->getAttributes(); + + if (!empty($propertyAttributes)) { + $attributes[$property->getName()] = array_map( + fn(\ReflectionAttribute $attribute): object => $attribute->newInstance(), + $propertyAttributes, + ); + } + } + + return $attributes; + } +} diff --git a/src/ODM/EntityManager.php b/src/ODM/EntityManager.php new file mode 100644 index 0000000..3feaec2 --- /dev/null +++ b/src/ODM/EntityManager.php @@ -0,0 +1,110 @@ + $keyFieldValues + * + * @throws EntityManagerException + */ + public function find(string $class, array $keyFieldValues): ?object + { + try { + $key = $this->entitySerializer->normalizePrimaryKey($class, $keyFieldValues); + + if (2 > count($key)) { + throw new EntityManagerException('Fields of both Partition and Sort keys must be provided'); + } + + $rawKey = $this->dynamoDbMarshaler->marshalItem($key); + $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + + $result = $this->dynamoDbClient->getItem( + [ + 'TableName' => $table, + 'Key' => $rawKey, + ] + ); + + $rawItem = $result['Item'] ?? null; + + if (null === $rawItem) { + return null; + } + + $item = $this->dynamoDbMarshaler->unmarshalItem($rawItem); + + return $this->entitySerializer->denormalize($item, $class); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + } + + /** + * @throws EntityManagerException + */ + public function save(object $entity): void + { + try { + $item = $this->entitySerializer->normalize($entity); + $rawItem = $this->dynamoDbMarshaler->marshalItem($item); + $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); + + $this->dynamoDbClient->putItem( + [ + 'TableName' => $table, + 'Item' => $rawItem, + ] + ); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + } + + /** + * @throws EntityManagerException + */ + public function remove(object $entity): void + { + try { + $key = $this->entitySerializer->normalizePrimaryKey($entity); + $rawKey = $this->dynamoDbMarshaler->marshalItem($key); + $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); + + $this->dynamoDbClient->deleteItem( + [ + 'TableName' => $table, + 'Key' => $rawKey, + ] + ); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + } + + /** + * @throws EntityManagerException + */ + private function wrapException(\Throwable $exception): void + { + throw new EntityManagerException($exception->getMessage()); + } +} diff --git a/src/ODM/EntityManagerException.php b/src/ODM/EntityManagerException.php new file mode 100644 index 0000000..e4b8b6d --- /dev/null +++ b/src/ODM/EntityManagerException.php @@ -0,0 +1,9 @@ +> + * @throws ExceptionInterface + * @throws \ReflectionException + * @throws MetadataException + */ + public function normalize(object $entity, bool $includePrimaryKey = true): array + { + $primaryKey = $includePrimaryKey ? $this->normalizePrimaryKey($entity) : []; + + return [ + ...$primaryKey, + ...$this->normalizeAttributes($entity), + ]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * + * @return array + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + public function normalizePrimaryKey(object|string $entity, array $keyFieldValues = []): array + { + return [ + ...$this->normalizePartitionKey($entity, $keyFieldValues), + ...$this->normalizeSortKey($entity, $keyFieldValues), + ]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * + * @return array + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + public function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array + { + $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $isClassString = is_string($entity); + $class = $isClassString ? $entity : $entity::class; + + $partitionKeyName = $this->normalizePartitionKeyName($class); + $partitionKeyValue = $isClassString + ? $this->normalizePartitionKeyValueFromArray($class, $keyFieldValues) + : $this->normalizePartitionKeyValueFromEntity($entity); + + return [$partitionKeyName => $partitionKeyValue]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * + * @return array + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + public function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array + { + $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $isClassString = is_string($entity); + $class = $isClassString ? $entity : $entity::class; + $sortKeyName = $this->normalizeSortKeyName($class); + + $sortKeyValue = $isClassString + ? $this->normalizeSortKeyValueFromArray($class, $keyFieldValues) + : $this->normalizeSortKeyValueFromEntity($entity); + + return null === $sortKeyName || null === $sortKeyValue + ? [] + : [$sortKeyName => $sortKeyValue]; + } + + /** + * @param $item array> + * + * @throws ExceptionInterface + * @throws \ReflectionException + * @throws MetadataException + */ + public function denormalize(array $item, string $class): object + { + return $this->denormalizeAttributes($item, $class); + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + */ + private function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void + { + $isClassString = is_string($entity); + + if ($isClassString && false === class_exists($entity)) { + throw new InvalidEntityException(sprintf('Entity class "%s" does not exist', $entity)); + } + + if ($isClassString && empty($keyFieldValues)) { + throw new InvalidEntityException('When entity class is provided, fields also need to be.'); + } + } + + /** + * @param class-string $class + * + * @throws \ReflectionException + * @throws MetadataException + */ + private function normalizePartitionKeyName(string $class): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + + return $entityMetadata->getPartitionKey()->getName(); + } + + /** + * @param class-string $class + * + * @throws \ReflectionException + * @throws MetadataException + */ + private function normalizeSortKeyName(string $class): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + + return $entityMetadata->getSortKey()?->getName(); + } + + /** + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function normalizePartitionKeyValueFromEntity(object $entity): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $key = $entityMetadata->getPartitionKey(); + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $finalValue = $prefix; + + foreach ($definedFields as $field) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf( + 'Field "%s" defined in Partition Key is invalid. Are you sure it exists in the entity class?', + $field + ) + ); + } + + $reflectionProperty = $classMetadata->offsetGet($field); + $propertyValue = $reflectionProperty->getValue($entity); + $currentFieldValue = $this->serializer->normalize($propertyValue); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function normalizeSortKeyValueFromEntity(object $entity): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $key = $entityMetadata->getSortKey(); + + if (null === $key) { + return null; + } + + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $finalValue = $prefix; + + foreach ($definedFields as $field) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf( + 'Field "%s" defined in Sort Key is invalid. Are you sure it exists in the entity class?', + $field + ) + ); + } + + $reflectionProperty = $classMetadata->offsetGet($field); + $propertyValue = $reflectionProperty->getValue($entity); + $currentFieldValue = $this->serializer->normalize($propertyValue); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @param class-string $class + * @param array $valuesByField + * + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $key = $entityMetadata->getPartitionKey(); + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $valuesByFieldSorted = []; + + foreach ($definedFields as $field) { + if (isset($valuesByField[$field])) { + $valuesByFieldSorted[$field] = $valuesByField[$field]; + } + } + + $allFieldsProvided = empty(array_diff_key($valuesByFieldSorted, array_flip($definedFields))); + + if (false === $allFieldsProvided) { + throw new InvalidFieldException( + 'Provided Partition Key fields do not match the ones defined in the entity' + ); + } + + $classMetadata = $this->metadataLoader->getClassMetadata($class); + $finalValue = $prefix; + + foreach ($valuesByFieldSorted as $field => $value) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) + ); + } + + if (empty($value)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure its value is provided?', $field) + ); + } + + $currentFieldValue = $this->serializer->normalize($value); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @param class-string $class + * @param array $valuesByField + * + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function normalizeSortKeyValueFromArray(string $class, array $valuesByField): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $key = $entityMetadata->getSortKey(); + + if (null === $key) { + return null; + } + + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $valuesByFieldSorted = []; + + foreach ($definedFields as $field) { + if (isset($valuesByField[$field])) { + $valuesByFieldSorted[$field] = $valuesByField[$field]; + } + } + + $classMetadata = $this->metadataLoader->getClassMetadata($class); + $finalValue = $prefix; + + foreach ($valuesByFieldSorted as $field => $value) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) + ); + } + + $currentFieldValue = $this->serializer->normalize($value); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function normalizeAttributes(object $entity): array + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $propertyAttributes = $entityMetadata->getPropertyAttributes(); + + $attributes = []; + + foreach ($propertyAttributes as $prop => $attr) { + $reflectionProperty = $classMetadata->offsetGet($prop); + $propertyValue = $reflectionProperty?->getValue($entity); + $attributes[$attr->name ?: $prop] = $this->serializer->normalize($propertyValue); + } + + return $attributes; + } + + /** + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + private function denormalizeAttributes(array $item, string $class): object + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $propertyAttributes = $entityMetadata->getPropertyAttributes(); + + $normalizedItem = []; + + foreach ($propertyAttributes as $prop => $attr) { + $normalizedItem[$prop] = $item[$attr->name ?: $prop] ?? null; + } + + return $this->serializer->denormalize($normalizedItem, $class); + } +} diff --git a/src/Serializer/InvalidEntityException.php b/src/Serializer/InvalidEntityException.php new file mode 100644 index 0000000..cdf9a86 --- /dev/null +++ b/src/Serializer/InvalidEntityException.php @@ -0,0 +1,9 @@ + Date: Sat, 3 May 2025 14:08:31 +0200 Subject: [PATCH 04/11] feat: implement base logic (wip) --- src/ODM/AbstractBuilder.php | 115 ++++++++++++++++++++++ src/ODM/BuilderException.php | 9 ++ src/ODM/EntityManager.php | 143 +++++++++++++++++++++++++--- src/ODM/QueryBuilder.php | 42 ++++++++ src/ODM/ResultStream.php | 18 ++++ src/ODM/ScanBuilder.php | 30 ++++++ src/Serializer/EntitySerializer.php | 93 +++++++++++++----- 7 files changed, 412 insertions(+), 38 deletions(-) create mode 100644 src/ODM/AbstractBuilder.php create mode 100644 src/ODM/BuilderException.php create mode 100644 src/ODM/QueryBuilder.php create mode 100644 src/ODM/ResultStream.php create mode 100644 src/ODM/ScanBuilder.php diff --git a/src/ODM/AbstractBuilder.php b/src/ODM/AbstractBuilder.php new file mode 100644 index 0000000..01d3e4c --- /dev/null +++ b/src/ODM/AbstractBuilder.php @@ -0,0 +1,115 @@ + */ + protected array $parameters = []; + + public function __construct() + { + $this->marshaler = new Marshaler(); + } + + public function filterExpression(string $expression): self + { + $this->parameters['FilterExpression'] = $expression; + + return $this; + } + + public function projectionExpression(string $expression): self + { + $this->parameters['ProjectionExpression'] = $expression; + + return $this; + } + + /** + * @param array $names + */ + public function expressionAttributeNames(array $names): self + { + $this->parameters['ExpressionAttributeNames'] = $names; + + return $this; + } + + /** + * @param array $values + */ + public function expressionAttributeValues(array $values): self + { + $this->parameters['ExpressionAttributeValues'] = $this->serialize($values); + + return $this; + } + + public function limit(int $limit): self + { + if (0 < $limit) { + $this->parameters['Limit'] = $limit; + } + + return $this; + } + + public function select(string $select): self + { + $this->parameters['Select'] = $select; + + return $this; + } + + public function consistentRead(bool $value = true): self + { + $this->parameters['ConsistentRead'] = $value; + + return $this; + } + + public function returnConsumedCapacity(string $value): self + { + $this->parameters['ReturnConsumedCapacity'] = $value; + + return $this; + } + + /** + * @param array $startKey + */ + public function exclusiveStartKey(array $startKey): self + { + $this->parameters['ExclusiveStartKey'] = $startKey; + + return $this; + } + + /** + * @return array + */ + abstract public function build(): array; + + /** + * @param array $values + * + * @return array> + */ + protected function serialize(array $values): array + { + $marshaled = []; + + foreach ($values as $key => $value) { + $marshaled[$key] = $this->marshaler->marshalValue($value); + } + + return $marshaled; + } +} diff --git a/src/ODM/BuilderException.php b/src/ODM/BuilderException.php new file mode 100644 index 0000000..b9b4b6f --- /dev/null +++ b/src/ODM/BuilderException.php @@ -0,0 +1,9 @@ +entitySerializer->normalizePrimaryKey($class, $keyFieldValues); + $key = $this->entitySerializer->serializePrimaryKey($class, $keyFieldValues); if (2 > count($key)) { throw new EntityManagerException('Fields of both Partition and Sort keys must be provided'); } - $rawKey = $this->dynamoDbMarshaler->marshalItem($key); $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); $result = $this->dynamoDbClient->getItem( [ 'TableName' => $table, - 'Key' => $rawKey, + 'Key' => $key, ] ); @@ -50,28 +47,124 @@ public function find(string $class, array $keyFieldValues): ?object return null; } - $item = $this->dynamoDbMarshaler->unmarshalItem($rawItem); - - return $this->entitySerializer->denormalize($item, $class); + return $this->entitySerializer->deserialize($rawItem, $class); } catch (\Throwable $exception) { $this->wrapException($exception); } } + /** + * @param class-string $class + * + * @throws EntityManagerException + */ + public function queryOne(string $class, QueryBuilder $queryBuilder): ?object + { + $queryBuilder->limit(1); + $result = $this->query($class, $queryBuilder); + + return $result->getItems(true)[0] ?? null; + } + + /** + * @param class-string $class + * + * @throws EntityManagerException + */ + public function query(string $class, QueryBuilder $queryBuilder): ResultStream + { + $items = (function () use ($class, $queryBuilder): \Generator { + try { + $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + + $params = $queryBuilder->build(); + $params['TableName'] = $table; + $remainingLimit = $params['Limit'] ?? null; + + do { + if (null !== $remainingLimit) { + $params['Limit'] = $remainingLimit; + } + + $result = $this->dynamoDbClient->query($params); + + foreach ($result->get('Items') ?? [] as $item) { + yield $this->entitySerializer->deserialize($item, $class); + + if (null === $remainingLimit) { + continue; + } + + if (0 >= --$remainingLimit) { + return; + } + } + + $params['ExclusiveStartKey'] = $result->get('LastEvaluatedKey') ?? null; + } while (!empty($params['ExclusiveStartKey'])); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + })(); + + return new ResultStream($items); + } + + /** + * @throws EntityManagerException + */ + public function scan(string $class, ScanBuilder $scanBuilder): ResultStream + { + $items = (function () use ($class, $scanBuilder): \Generator { + try { + $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + + $params = $scanBuilder->build(); + $params['TableName'] = $table; + $remainingLimit = $params['Limit'] ?? null; + + do { + if (null !== $remainingLimit) { + $params['Limit'] = $remainingLimit; + } + + $result = $this->dynamoDbClient->scan($params); + + foreach ($result->get('Items') ?? [] as $item) { + yield $this->entitySerializer->deserialize($item, $class); + + if (null === $remainingLimit) { + continue; + } + + if (0 >= --$remainingLimit) { + return; + } + } + + $params['ExclusiveStartKey'] = $result->get('LastEvaluatedKey') ?? null; + } while (!empty($params['ExclusiveStartKey'])); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + })(); + + return new ResultStream($items); + } + /** * @throws EntityManagerException */ public function save(object $entity): void { try { - $item = $this->entitySerializer->normalize($entity); - $rawItem = $this->dynamoDbMarshaler->marshalItem($item); $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); + $item = $this->entitySerializer->serialize($entity); $this->dynamoDbClient->putItem( [ 'TableName' => $table, - 'Item' => $rawItem, + 'Item' => $item, ] ); } catch (\Throwable $exception) { @@ -85,14 +178,13 @@ public function save(object $entity): void public function remove(object $entity): void { try { - $key = $this->entitySerializer->normalizePrimaryKey($entity); - $rawKey = $this->dynamoDbMarshaler->marshalItem($key); $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); + $key = $this->entitySerializer->serializePrimaryKey($entity); $this->dynamoDbClient->deleteItem( [ 'TableName' => $table, - 'Key' => $rawKey, + 'Key' => $key, ] ); } catch (\Throwable $exception) { @@ -100,11 +192,32 @@ public function remove(object $entity): void } } + /** + * @param class-string $class + * + * @throws EntityManagerException + */ + public function describe(string $class): ResultStream + { + $result = (function () use ($class): \Generator { + try { + $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + yield from $this->dynamoDbClient->describeTable(['TableName' => $table]); + } catch (\Throwable $exception) { + $this->wrapException($exception); + } + })(); + + return new ResultStream($result); + } + /** * @throws EntityManagerException */ private function wrapException(\Throwable $exception): void { - throw new EntityManagerException($exception->getMessage()); + throw new EntityManagerException( + sprintf('An error occurred. %s: %s', $exception::class, $exception->getMessage()) + ); } } diff --git a/src/ODM/QueryBuilder.php b/src/ODM/QueryBuilder.php new file mode 100644 index 0000000..e52ee3b --- /dev/null +++ b/src/ODM/QueryBuilder.php @@ -0,0 +1,42 @@ +parameters['IndexName'] = $index; + + return $this; + } + + public function keyConditionExpression(string $expression): self + { + $this->parameters['KeyConditionExpression'] = $expression; + + return $this; + } + + public function scanIndexForward(bool $asc = true): self + { + $this->parameters['ScanIndexForward'] = $asc; + + return $this; + } + + /** + * @inheritdoc + * @throws BuilderException + */ + public function build(): array + { + if (!isset($this->parameters['KeyConditionExpression'])) { + throw new BuilderException('KeyConditionExpression is required for query operations.'); + } + + return $this->parameters; + } +} diff --git a/src/ODM/ResultStream.php b/src/ODM/ResultStream.php new file mode 100644 index 0000000..0258808 --- /dev/null +++ b/src/ODM/ResultStream.php @@ -0,0 +1,18 @@ +items) : $this->items; + } +} diff --git a/src/ODM/ScanBuilder.php b/src/ODM/ScanBuilder.php new file mode 100644 index 0000000..3121c8b --- /dev/null +++ b/src/ODM/ScanBuilder.php @@ -0,0 +1,30 @@ +parameters['Segment'] = $segment; + + return $this; + } + + public function totalSegments(int $totalSegments): self + { + $this->parameters['TotalSegments'] = $totalSegments; + + return $this; + } + + /** + * @inheritdoc + */ + public function build(): array + { + return $this->parameters; + } +} diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php index 6d3a325..23033fa 100644 --- a/src/Serializer/EntitySerializer.php +++ b/src/Serializer/EntitySerializer.php @@ -4,6 +4,7 @@ namespace EduardoMarques\DynamoPHP\Serializer; +use Aws\DynamoDb\Marshaler; use EduardoMarques\DynamoPHP\Metadata\MetadataException; use EduardoMarques\DynamoPHP\Metadata\MetadataLoader; use Symfony\Component\Serializer\Exception\ExceptionInterface; @@ -11,10 +12,56 @@ class EntitySerializer { + protected Marshaler $marshaler; + public function __construct( protected MetadataLoader $metadataLoader, protected SerializerInterface $serializer, ) { + $this->marshaler = new Marshaler(); + } + + /** + * @return array> + * @throws ExceptionInterface + * @throws MetadataException + * @throws \ReflectionException + */ + public function serialize(object $entity, bool $includePrimaryKey = true): array + { + $normalized = $this->normalize($entity, $includePrimaryKey); + + return $this->marshaler->marshalItem($normalized); + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * + * @return array + * @throws \ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + public function serializePrimaryKey(object|string $entity, array $keyFieldValues = []): array + { + $normalized = $this->normalizePrimaryKey($entity, $keyFieldValues); + + return $this->marshaler->marshalItem($normalized); + } + + /** + * @param $item array> + * + * @throws ExceptionInterface + * @throws \ReflectionException + * @throws MetadataException + */ + public function deserialize(array $item, string $class): object + { + $normalized = $this->marshaler->unmarshalItem($item); + + return $this->denormalize($normalized, $class); } /** @@ -50,6 +97,18 @@ public function normalizePrimaryKey(object|string $entity, array $keyFieldValues ]; } + /** + * @param $item array> + * + * @throws ExceptionInterface + * @throws \ReflectionException + * @throws MetadataException + */ + public function denormalize(array $item, string $class): object + { + return $this->denormalizeAttributes($item, $class); + } + /** * @param object|class-string $entity * @param array $keyFieldValues @@ -59,7 +118,7 @@ public function normalizePrimaryKey(object|string $entity, array $keyFieldValues * @throws ExceptionInterface * @throws MetadataException */ - public function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array + protected function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array { $this->validatePrimaryKeyArguments($entity, $keyFieldValues); $isClassString = is_string($entity); @@ -82,7 +141,7 @@ public function normalizePartitionKey(object|string $entity, array $keyFieldValu * @throws ExceptionInterface * @throws MetadataException */ - public function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array + protected function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array { $this->validatePrimaryKeyArguments($entity, $keyFieldValues); $isClassString = is_string($entity); @@ -98,23 +157,11 @@ public function normalizeSortKey(object|string $entity, array $keyFieldValues = : [$sortKeyName => $sortKeyValue]; } - /** - * @param $item array> - * - * @throws ExceptionInterface - * @throws \ReflectionException - * @throws MetadataException - */ - public function denormalize(array $item, string $class): object - { - return $this->denormalizeAttributes($item, $class); - } - /** * @param object|class-string $entity * @param array $keyFieldValues */ - private function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void + protected function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void { $isClassString = is_string($entity); @@ -133,7 +180,7 @@ private function validatePrimaryKeyArguments(object|string $entity, array $keyFi * @throws \ReflectionException * @throws MetadataException */ - private function normalizePartitionKeyName(string $class): ?string + protected function normalizePartitionKeyName(string $class): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); @@ -146,7 +193,7 @@ private function normalizePartitionKeyName(string $class): ?string * @throws \ReflectionException * @throws MetadataException */ - private function normalizeSortKeyName(string $class): ?string + protected function normalizeSortKeyName(string $class): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); @@ -158,7 +205,7 @@ private function normalizeSortKeyName(string $class): ?string * @throws ExceptionInterface * @throws MetadataException */ - private function normalizePartitionKeyValueFromEntity(object $entity): ?string + protected function normalizePartitionKeyValueFromEntity(object $entity): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); $key = $entityMetadata->getPartitionKey(); @@ -194,7 +241,7 @@ private function normalizePartitionKeyValueFromEntity(object $entity): ?string * @throws ExceptionInterface * @throws MetadataException */ - private function normalizeSortKeyValueFromEntity(object $entity): ?string + protected function normalizeSortKeyValueFromEntity(object $entity): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); $key = $entityMetadata->getSortKey(); @@ -238,7 +285,7 @@ private function normalizeSortKeyValueFromEntity(object $entity): ?string * @throws ExceptionInterface * @throws MetadataException */ - private function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): ?string + protected function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); $key = $entityMetadata->getPartitionKey(); @@ -294,7 +341,7 @@ private function normalizePartitionKeyValueFromArray(string $class, array $value * @throws ExceptionInterface * @throws MetadataException */ - private function normalizeSortKeyValueFromArray(string $class, array $valuesByField): ?string + protected function normalizeSortKeyValueFromArray(string $class, array $valuesByField): ?string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); $key = $entityMetadata->getSortKey(); @@ -338,7 +385,7 @@ private function normalizeSortKeyValueFromArray(string $class, array $valuesByFi * @throws ExceptionInterface * @throws MetadataException */ - private function normalizeAttributes(object $entity): array + protected function normalizeAttributes(object $entity): array { $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); @@ -360,7 +407,7 @@ private function normalizeAttributes(object $entity): array * @throws ExceptionInterface * @throws MetadataException */ - private function denormalizeAttributes(array $item, string $class): object + protected function denormalizeAttributes(array $item, string $class): object { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); $propertyAttributes = $entityMetadata->getPropertyAttributes(); From 371ee56fef0b40e39deacfe845384b2923c17d97 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Sat, 3 May 2025 18:09:50 +0200 Subject: [PATCH 05/11] feat: implement base logic (wip) --- .gitignore | 8 +- composer.json | 2 +- composer.lock | 210 +++++++++++---------- phpcs.xml | 40 ++++ phpcs.xml.dist | 30 --- phpstan.neon.dist => phpstan.neon | 0 phpunit.xml.dist => phpunit.xml | 0 src/Attribute/Attribute.php | 4 +- src/Attribute/Entity.php | 5 +- src/Attribute/InvalidArgumentException.php | 4 +- src/Metadata/ClassMetadata.php | 17 +- src/Metadata/MetadataException.php | 4 +- src/Metadata/MetadataLoader.php | 31 +-- src/ODM/AbstractBuilder.php | 1 - src/ODM/BuilderException.php | 4 +- src/ODM/EntityManager.php | 33 ++-- src/ODM/EntityManagerException.php | 4 +- src/ODM/ResultStream.php | 7 +- src/Serializer/EntitySerializer.php | 114 +++++++---- src/Serializer/InvalidEntityException.php | 4 +- src/Serializer/InvalidFieldException.php | 4 +- 21 files changed, 309 insertions(+), 217 deletions(-) create mode 100644 phpcs.xml delete mode 100644 phpcs.xml.dist rename phpstan.neon.dist => phpstan.neon (100%) rename phpunit.xml.dist => phpunit.xml (100%) diff --git a/.gitignore b/.gitignore index 4551043..1df431a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,15 +8,9 @@ vendor ###> phpunit/phpunit ### coverage -phpunit.xml .phpunit.result.cache ###< phpunit/phpunit ### ###> squizlabs/php_codesniffer ### -phpcs.xml -.phpcs-cache +.phpcs.cache ###< squizlabs/php_codesniffer ### - -###> phpstan/phpstan ### -phpstan.neon -###< phpstan/phpstan ### diff --git a/composer.json b/composer.json index 861f030..4d8c22e 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "squizlabs/php_codesniffer": "^3.12", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.1", - "slevomat/coding-standard": "^7.2", + "slevomat/coding-standard": "^8.18", "symfony/var-dumper": "^7.2" }, "autoload": { diff --git a/composer.lock b/composer.lock index 6be4537..07b88e6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98bcedbb6ddfd76b4d6259343ef79b53", + "content-hash": "ff6ed7fbeda99e86f2aa6600614ba47c", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.343.2", + "version": "3.343.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "95d43e71d3395622394b36079f2fb2289d3284b3" + "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/95d43e71d3395622394b36079f2fb2289d3284b3", - "reference": "95d43e71d3395622394b36079f2fb2289d3284b3", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979", + "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.343.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.343.3" }, - "time": "2025-05-01T18:05:02+00:00" + "time": "2025-05-02T18:04:58+00:00" }, { "name": "guzzlehttp/guzzle", @@ -874,7 +874,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -933,7 +933,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -953,7 +953,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -1011,7 +1011,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -1031,7 +1031,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -1092,7 +1092,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -1112,19 +1112,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -1172,7 +1173,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -1188,7 +1189,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/property-access", @@ -1353,16 +1354,16 @@ }, { "name": "symfony/serializer", - "version": "v7.2.5", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "d8b75b2c8144c29ac43b235738411f7cca6d584d" + "reference": "be549655b034edc1a16ed23d8164aa04318c5ec1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/d8b75b2c8144c29ac43b235738411f7cca6d584d", - "reference": "d8b75b2c8144c29ac43b235738411f7cca6d584d", + "url": "https://api.github.com/repos/symfony/serializer/zipball/be549655b034edc1a16ed23d8164aa04318c5ec1", + "reference": "be549655b034edc1a16ed23d8164aa04318c5ec1", "shasum": "" }, "require": { @@ -1431,7 +1432,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.2.5" + "source": "https://github.com/symfony/serializer/tree/v7.2.6" }, "funding": [ { @@ -1447,20 +1448,20 @@ "type": "tidelift" } ], - "time": "2025-03-24T12:37:32+00:00" + "time": "2025-04-27T13:34:41+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.2.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931", + "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931", "shasum": "" }, "require": { @@ -1518,7 +1519,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.2.6" }, "funding": [ { @@ -1534,7 +1535,7 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2025-04-20T20:18:16+00:00" }, { "name": "symfony/type-info", @@ -1615,35 +1616,38 @@ "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v0.7.2", + "version": "v1.0.0", "source": { "type": "git", - "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", - "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db" + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "4be43904336affa5c2f70744a348312336afd0da" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", - "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da", + "reference": "4be43904336affa5c2f70744a348312336afd0da", "shasum": "" }, "require": { "composer-plugin-api": "^1.0 || ^2.0", - "php": ">=5.3", + "php": ">=5.4", "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" }, "require-dev": { "composer/composer": "*", + "ext-json": "*", + "ext-zip": "*", "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpcompatibility/php-compatibility": "^9.0" + "phpcompatibility/php-compatibility": "^9.0", + "yoast/phpunit-polyfills": "^1.0" }, "type": "composer-plugin", "extra": { - "class": "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" }, "autoload": { "psr-4": { - "Dealerdirect\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1659,7 +1663,7 @@ }, { "name": "Contributors", - "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors" + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" } ], "description": "PHP_CodeSniffer Standards Composer Installer Plugin", @@ -1683,10 +1687,10 @@ "tests" ], "support": { - "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", - "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "source": "https://github.com/PHPCSStandards/composer-installer" }, - "time": "2022-02-04T12:51:07+00:00" + "time": "2023-01-05T11:28:13+00:00" }, { "name": "myclabs/deep-copy", @@ -1926,30 +1930,30 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.33.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^5.3.0", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", "symfony/process": "^5.2" }, "type": "library", @@ -1967,22 +1971,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" }, - "time": "2024-10-13T11:25:22+00:00" + "time": "2025-02-19T13:28:12+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.13", + "version": "2.1.14", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "e55e03e6d4ac49cd1240907e5b08e5cd378572a9" + "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e55e03e6d4ac49cd1240907e5b08e5cd378572a9", - "reference": "e55e03e6d4ac49cd1240907e5b08e5cd378572a9", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2", + "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2", "shasum": "" }, "require": { @@ -2027,20 +2031,20 @@ "type": "github" } ], - "time": "2025-04-27T12:28:25+00:00" + "time": "2025-05-02T15:32:28+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.1.2", + "version": "12.2.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "05c33d01a856f9f62488d144bafddc3d7b7a4ebb" + "reference": "4c7133dbade8423ef124ae62c39a075ba591cb3f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/05c33d01a856f9f62488d144bafddc3d7b7a4ebb", - "reference": "05c33d01a856f9f62488d144bafddc3d7b7a4ebb", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4c7133dbade8423ef124ae62c39a075ba591cb3f", + "reference": "4c7133dbade8423ef124ae62c39a075ba591cb3f", "shasum": "" }, "require": { @@ -2058,7 +2062,7 @@ "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -2067,7 +2071,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.1.x-dev" + "dev-main": "12.2.x-dev" } }, "autoload": { @@ -2096,15 +2100,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.1.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.2.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2025-04-03T14:34:39+00:00" + "time": "2025-05-03T07:27:30+00:00" }, { "name": "phpunit/php-file-iterator", @@ -2353,16 +2369,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.1.3", + "version": "12.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "72ca50e817dd7d65356c16772c30f06c01a6fae2" + "reference": "5ee57ad690bda2c487594577600931a99053436c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/72ca50e817dd7d65356c16772c30f06c01a6fae2", - "reference": "72ca50e817dd7d65356c16772c30f06c01a6fae2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5ee57ad690bda2c487594577600931a99053436c", + "reference": "5ee57ad690bda2c487594577600931a99053436c", "shasum": "" }, "require": { @@ -2372,7 +2388,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.0", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", @@ -2430,7 +2446,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.1.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.1.4" }, "funding": [ { @@ -2454,7 +2470,7 @@ "type": "tidelift" } ], - "time": "2025-04-22T06:11:09+00:00" + "time": "2025-05-02T07:01:56+00:00" }, { "name": "sebastian/cli-parser", @@ -3271,42 +3287,42 @@ }, { "name": "slevomat/coding-standard", - "version": "7.2.1", + "version": "8.18.0", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90" + "reference": "f3b23cb9b26301b8c3c7bb03035a1bee23974593" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/aff06ae7a84e4534bf6f821dc982a93a5d477c90", - "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/f3b23cb9b26301b8c3c7bb03035a1bee23974593", + "reference": "f3b23cb9b26301b8c3c7bb03035a1bee23974593", "shasum": "" }, "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.5.1", - "squizlabs/php_codesniffer": "^3.6.2" + "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", + "php": "^7.4 || ^8.0", + "phpstan/phpdoc-parser": "^2.1.0", + "squizlabs/php_codesniffer": "^3.12.2" }, "require-dev": { - "phing/phing": "2.17.3", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.4.10|1.7.1", - "phpstan/phpstan-deprecation-rules": "1.0.0", - "phpstan/phpstan-phpunit": "1.0.0|1.1.1", - "phpstan/phpstan-strict-rules": "1.2.3", - "phpunit/phpunit": "7.5.20|8.5.21|9.5.20" + "phing/phing": "3.0.1", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/phpstan": "2.1.13", + "phpstan/phpstan-deprecation-rules": "2.0.2", + "phpstan/phpstan-phpunit": "2.0.6", + "phpstan/phpstan-strict-rules": "2.0.4", + "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.17|12.1.3" }, "type": "phpcodesniffer-standard", "extra": { "branch-alias": { - "dev-master": "7.x-dev" + "dev-master": "8.x-dev" } }, "autoload": { "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard" + "SlevomatCodingStandard\\": "SlevomatCodingStandard/" } }, "notification-url": "https://packagist.org/downloads/", @@ -3314,9 +3330,13 @@ "MIT" ], "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", + "keywords": [ + "dev", + "phpcs" + ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/7.2.1" + "source": "https://github.com/slevomat/coding-standard/tree/8.18.0" }, "funding": [ { @@ -3328,7 +3348,7 @@ "type": "tidelift" } ], - "time": "2022-05-25T10:58:12+00:00" + "time": "2025-05-01T09:40:50+00:00" }, { "name": "squizlabs/php_codesniffer", diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..9126437 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,40 @@ + + + + + + + + + + src/ + tests/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 773e3bd..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - src/ - tests/ - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon similarity index 100% rename from phpstan.neon.dist rename to phpstan.neon diff --git a/phpunit.xml.dist b/phpunit.xml similarity index 100% rename from phpunit.xml.dist rename to phpunit.xml diff --git a/src/Attribute/Attribute.php b/src/Attribute/Attribute.php index c759e62..d49db17 100644 --- a/src/Attribute/Attribute.php +++ b/src/Attribute/Attribute.php @@ -4,7 +4,9 @@ namespace EduardoMarques\DynamoPHP\Attribute; -#[\Attribute(\Attribute::TARGET_PROPERTY)] +use Attribute as PHPAttribute; + +#[PHPAttribute(PHPAttribute::TARGET_PROPERTY)] class Attribute { public function __construct( diff --git a/src/Attribute/Entity.php b/src/Attribute/Entity.php index cb103af..6f36cb2 100644 --- a/src/Attribute/Entity.php +++ b/src/Attribute/Entity.php @@ -4,11 +4,12 @@ namespace EduardoMarques\DynamoPHP\Attribute; -#[\Attribute(\Attribute::TARGET_CLASS)] +use Attribute as PHPAttribute; + +#[PHPAttribute(PHPAttribute::TARGET_CLASS)] class Entity { public function __construct( - /** @var non-empty-string */ public string $table, public KeyInterface $partitionKey, public ?KeyInterface $sortKey = null, diff --git a/src/Attribute/InvalidArgumentException.php b/src/Attribute/InvalidArgumentException.php index 8d3beb4..1efc776 100644 --- a/src/Attribute/InvalidArgumentException.php +++ b/src/Attribute/InvalidArgumentException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\Attribute; -class InvalidArgumentException extends \InvalidArgumentException +use InvalidArgumentException as PHPInvalidArgumentException; + +class InvalidArgumentException extends PHPInvalidArgumentException { } diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index c103451..cc0d3e7 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -4,18 +4,22 @@ namespace EduardoMarques\DynamoPHP\Metadata; -class ClassMetadata implements \ArrayAccess +use ArrayAccess; +use ReflectionProperty; + +/** + * @implements ArrayAccess + */ +class ClassMetadata implements ArrayAccess { public function __construct( - /** @var array */ + /** @var array */ protected array $properties, ) { } /** * @param string $offset - * - * @return bool */ public function offsetExists(mixed $offset): bool { @@ -25,14 +29,14 @@ public function offsetExists(mixed $offset): bool /** * @param string $offset */ - public function offsetGet(mixed $offset): ?\ReflectionProperty + public function offsetGet(mixed $offset): ?ReflectionProperty { return $this->properties[$offset] ?? null; } /** * @param string $offset - * @param \ReflectionProperty $value + * @param ReflectionProperty $value */ public function offsetSet(mixed $offset, mixed $value): void { @@ -46,5 +50,4 @@ public function offsetUnset(mixed $offset): void { unset($this->properties[$offset]); } - } diff --git a/src/Metadata/MetadataException.php b/src/Metadata/MetadataException.php index 8543997..d961807 100644 --- a/src/Metadata/MetadataException.php +++ b/src/Metadata/MetadataException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\Metadata; -class MetadataException extends \Exception +use Exception; + +class MetadataException extends Exception { } diff --git a/src/Metadata/MetadataLoader.php b/src/Metadata/MetadataLoader.php index 16948d5..c911789 100644 --- a/src/Metadata/MetadataLoader.php +++ b/src/Metadata/MetadataLoader.php @@ -6,18 +6,19 @@ use EduardoMarques\DynamoPHP\Attribute\Attribute; use EduardoMarques\DynamoPHP\Attribute\Entity; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionException; +use ReflectionProperty; class MetadataLoader { - /** - * @var array> - */ + /** @var array> */ private array $cache = []; /** * @param class-string $class - * - * @throws \ReflectionException + * @throws ReflectionException */ public function getClassMetadata(string $class): ClassMetadata { @@ -25,7 +26,7 @@ public function getClassMetadata(string $class): ClassMetadata return $this->cache[__METHOD__][$class]; } - $reflection = new \ReflectionClass($class); + $reflection = new ReflectionClass($class); $classProperties = $this->getClassProperties($reflection); @@ -38,8 +39,7 @@ public function getClassMetadata(string $class): ClassMetadata /** * @param class-string $class - * - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ public function getEntityMetadata(string $class): EntityMetadata @@ -48,7 +48,7 @@ public function getEntityMetadata(string $class): EntityMetadata return $this->cache[__METHOD__][$class]; } - $reflection = new \ReflectionClass($class); + $reflection = new ReflectionClass($class); $classAttributes = $this->getClassAttributes($reflection); $entityAttribute = $classAttributes[Entity::class] ?? null; @@ -76,9 +76,10 @@ public function getEntityMetadata(string $class): EntityMetadata } /** - * @return array + * @param ReflectionClass $reflection + * @return array */ - private function getClassProperties(\ReflectionClass $reflection): array + private function getClassProperties(ReflectionClass $reflection): array { $properties = []; @@ -90,9 +91,10 @@ private function getClassProperties(\ReflectionClass $reflection): array } /** + * @param ReflectionClass $reflection * @return array */ - private function getClassAttributes(\ReflectionClass $reflection): array + private function getClassAttributes(ReflectionClass $reflection): array { $attributes = []; @@ -105,9 +107,10 @@ private function getClassAttributes(\ReflectionClass $reflection): array } /** + * @param ReflectionClass $reflection * @return array> */ - private function getPropertyAttributes(\ReflectionClass $reflection): array + private function getPropertyAttributes(ReflectionClass $reflection): array { $attributes = []; @@ -116,7 +119,7 @@ private function getPropertyAttributes(\ReflectionClass $reflection): array if (!empty($propertyAttributes)) { $attributes[$property->getName()] = array_map( - fn(\ReflectionAttribute $attribute): object => $attribute->newInstance(), + static fn(ReflectionAttribute $attribute): object => $attribute->newInstance(), $propertyAttributes, ); } diff --git a/src/ODM/AbstractBuilder.php b/src/ODM/AbstractBuilder.php index 01d3e4c..48daec9 100644 --- a/src/ODM/AbstractBuilder.php +++ b/src/ODM/AbstractBuilder.php @@ -99,7 +99,6 @@ abstract public function build(): array; /** * @param array $values - * * @return array> */ protected function serialize(array $values): array diff --git a/src/ODM/BuilderException.php b/src/ODM/BuilderException.php index b9b4b6f..bcd3726 100644 --- a/src/ODM/BuilderException.php +++ b/src/ODM/BuilderException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\ODM; -class BuilderException extends \Exception +use Exception; + +class BuilderException extends Exception { } diff --git a/src/ODM/EntityManager.php b/src/ODM/EntityManager.php index 74d5ecf..2717b21 100644 --- a/src/ODM/EntityManager.php +++ b/src/ODM/EntityManager.php @@ -7,6 +7,8 @@ use Aws\DynamoDb\DynamoDbClient; use EduardoMarques\DynamoPHP\Metadata\MetadataLoader; use EduardoMarques\DynamoPHP\Serializer\EntitySerializer; +use Generator; +use Throwable; class EntityManager { @@ -20,7 +22,6 @@ public function __construct( /** * @param class-string $class * @param array $keyFieldValues - * * @throws EntityManagerException */ public function find(string $class, array $keyFieldValues): ?object @@ -48,32 +49,32 @@ public function find(string $class, array $keyFieldValues): ?object } return $this->entitySerializer->deserialize($rawItem, $class); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } } /** * @param class-string $class - * * @throws EntityManagerException */ public function queryOne(string $class, QueryBuilder $queryBuilder): ?object { $queryBuilder->limit(1); - $result = $this->query($class, $queryBuilder); - return $result->getItems(true)[0] ?? null; + /** @var array $result */ + $result = $this->query($class, $queryBuilder)->getItems(true); + + return $result[0] ?? null; } /** * @param class-string $class - * * @throws EntityManagerException */ public function query(string $class, QueryBuilder $queryBuilder): ResultStream { - $items = (function () use ($class, $queryBuilder): \Generator { + $items = (function () use ($class, $queryBuilder): Generator { try { $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); @@ -102,7 +103,7 @@ public function query(string $class, QueryBuilder $queryBuilder): ResultStream $params['ExclusiveStartKey'] = $result->get('LastEvaluatedKey') ?? null; } while (!empty($params['ExclusiveStartKey'])); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } })(); @@ -111,11 +112,12 @@ public function query(string $class, QueryBuilder $queryBuilder): ResultStream } /** + * @param class-string $class * @throws EntityManagerException */ public function scan(string $class, ScanBuilder $scanBuilder): ResultStream { - $items = (function () use ($class, $scanBuilder): \Generator { + $items = (function () use ($class, $scanBuilder): Generator { try { $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); @@ -144,7 +146,7 @@ public function scan(string $class, ScanBuilder $scanBuilder): ResultStream $params['ExclusiveStartKey'] = $result->get('LastEvaluatedKey') ?? null; } while (!empty($params['ExclusiveStartKey'])); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } })(); @@ -167,7 +169,7 @@ public function save(object $entity): void 'Item' => $item, ] ); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } } @@ -187,23 +189,22 @@ public function remove(object $entity): void 'Key' => $key, ] ); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } } /** * @param class-string $class - * * @throws EntityManagerException */ public function describe(string $class): ResultStream { - $result = (function () use ($class): \Generator { + $result = (function () use ($class): Generator { try { $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); yield from $this->dynamoDbClient->describeTable(['TableName' => $table]); - } catch (\Throwable $exception) { + } catch (Throwable $exception) { $this->wrapException($exception); } })(); @@ -214,7 +215,7 @@ public function describe(string $class): ResultStream /** * @throws EntityManagerException */ - private function wrapException(\Throwable $exception): void + private function wrapException(Throwable $exception): never { throw new EntityManagerException( sprintf('An error occurred. %s: %s', $exception::class, $exception->getMessage()) diff --git a/src/ODM/EntityManagerException.php b/src/ODM/EntityManagerException.php index e4b8b6d..9f6ff33 100644 --- a/src/ODM/EntityManagerException.php +++ b/src/ODM/EntityManagerException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\ODM; -class EntityManagerException extends \Exception +use Exception; + +class EntityManagerException extends Exception { } diff --git a/src/ODM/ResultStream.php b/src/ODM/ResultStream.php index 0258808..fc7a725 100644 --- a/src/ODM/ResultStream.php +++ b/src/ODM/ResultStream.php @@ -4,13 +4,18 @@ namespace EduardoMarques\DynamoPHP\ODM; +use Generator; + class ResultStream { public function __construct( - protected \Generator $items, + protected Generator $items, ) { } + /** + * @return Generator|array + */ public function getItems(bool $asArray = false): iterable { return $asArray ? iterator_to_array($this->items) : $this->items; diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php index 23033fa..e7b9aa5 100644 --- a/src/Serializer/EntitySerializer.php +++ b/src/Serializer/EntitySerializer.php @@ -7,8 +7,10 @@ use Aws\DynamoDb\Marshaler; use EduardoMarques\DynamoPHP\Metadata\MetadataException; use EduardoMarques\DynamoPHP\Metadata\MetadataLoader; +use ReflectionException; +use ReflectionProperty; use Symfony\Component\Serializer\Exception\ExceptionInterface; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Serializer; class EntitySerializer { @@ -16,7 +18,7 @@ class EntitySerializer public function __construct( protected MetadataLoader $metadataLoader, - protected SerializerInterface $serializer, + protected Serializer $serializer, ) { $this->marshaler = new Marshaler(); } @@ -25,7 +27,7 @@ public function __construct( * @return array> * @throws ExceptionInterface * @throws MetadataException - * @throws \ReflectionException + * @throws ReflectionException */ public function serialize(object $entity, bool $includePrimaryKey = true): array { @@ -37,9 +39,8 @@ public function serialize(object $entity, bool $includePrimaryKey = true): array /** * @param object|class-string $entity * @param array $keyFieldValues - * * @return array - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -51,14 +52,15 @@ public function serializePrimaryKey(object|string $entity, array $keyFieldValues } /** - * @param $item array> - * + * @param array> $item + * @param class-string $class * @throws ExceptionInterface - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ public function deserialize(array $item, string $class): object { + /** @var array $normalized */ $normalized = $this->marshaler->unmarshalItem($item); return $this->denormalize($normalized, $class); @@ -67,7 +69,7 @@ public function deserialize(array $item, string $class): object /** * @return array> * @throws ExceptionInterface - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ public function normalize(object $entity, bool $includePrimaryKey = true): array @@ -83,9 +85,8 @@ public function normalize(object $entity, bool $includePrimaryKey = true): array /** * @param object|class-string $entity * @param array $keyFieldValues - * * @return array - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -98,10 +99,10 @@ public function normalizePrimaryKey(object|string $entity, array $keyFieldValues } /** - * @param $item array> - * + * @param array> $item + * @param class-string $class * @throws ExceptionInterface - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ public function denormalize(array $item, string $class): object @@ -112,9 +113,8 @@ public function denormalize(array $item, string $class): object /** * @param object|class-string $entity * @param array $keyFieldValues - * * @return array - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -135,9 +135,8 @@ protected function normalizePartitionKey(object|string $entity, array $keyFieldV /** * @param object|class-string $entity * @param array $keyFieldValues - * * @return array - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -176,11 +175,10 @@ protected function validatePrimaryKeyArguments(object|string $entity, array $key /** * @param class-string $class - * - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ - protected function normalizePartitionKeyName(string $class): ?string + protected function normalizePartitionKeyName(string $class): string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); @@ -189,8 +187,7 @@ protected function normalizePartitionKeyName(string $class): ?string /** * @param class-string $class - * - * @throws \ReflectionException + * @throws ReflectionException * @throws MetadataException */ protected function normalizeSortKeyName(string $class): ?string @@ -201,11 +198,11 @@ protected function normalizeSortKeyName(string $class): ?string } /** - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ - protected function normalizePartitionKeyValueFromEntity(object $entity): ?string + protected function normalizePartitionKeyValueFromEntity(object $entity): string { $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); $key = $entityMetadata->getPartitionKey(); @@ -214,7 +211,7 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): ?string $prefix = $key->getPrefix(); $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); - $finalValue = $prefix; + $finalValue = $prefix ?? ''; foreach ($definedFields as $field) { if (false === $classMetadata->offsetExists($field)) { @@ -226,8 +223,20 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): ?string ); } + /** @var ReflectionProperty $reflectionProperty */ $reflectionProperty = $classMetadata->offsetGet($field); $propertyValue = $reflectionProperty->getValue($entity); + + if (false === is_scalar($propertyValue)) { + throw new InvalidFieldException( + sprintf( + 'Values of Partition Key fields need to be scalar. Please check it for the field "%s".', + $field + ) + ); + } + + /** @var scalar $currentFieldValue */ $currentFieldValue = $this->serializer->normalize($propertyValue); $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; @@ -237,7 +246,7 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): ?string } /** - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -267,8 +276,20 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string ); } + /** @var ReflectionProperty $reflectionProperty */ $reflectionProperty = $classMetadata->offsetGet($field); $propertyValue = $reflectionProperty->getValue($entity); + + if (false === is_scalar($propertyValue)) { + throw new InvalidFieldException( + sprintf( + 'Values of Sort Key fields need to be scalar. Please check it for the field "%s".', + $field + ) + ); + } + + /** @var scalar $currentFieldValue */ $currentFieldValue = $this->serializer->normalize($propertyValue); $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; @@ -280,12 +301,11 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string /** * @param class-string $class * @param array $valuesByField - * - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ - protected function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): ?string + protected function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): string { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); $key = $entityMetadata->getPartitionKey(); @@ -310,7 +330,7 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val } $classMetadata = $this->metadataLoader->getClassMetadata($class); - $finalValue = $prefix; + $finalValue = $prefix ?? ''; foreach ($valuesByFieldSorted as $field => $value) { if (false === $classMetadata->offsetExists($field)) { @@ -325,6 +345,16 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val ); } + if (false === is_scalar($value)) { + throw new InvalidFieldException( + sprintf( + 'Values of Partition Key fields need to be scalar. Please check it for the field "%s".', + $field + ) + ); + } + + /** @var scalar $currentFieldValue */ $currentFieldValue = $this->serializer->normalize($value); $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; @@ -336,8 +366,7 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val /** * @param class-string $class * @param array $valuesByField - * - * @throws \ReflectionException + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -363,7 +392,7 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy } $classMetadata = $this->metadataLoader->getClassMetadata($class); - $finalValue = $prefix; + $finalValue = $prefix ?? ''; foreach ($valuesByFieldSorted as $field => $value) { if (false === $classMetadata->offsetExists($field)) { @@ -372,6 +401,16 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy ); } + if (false === is_scalar($value)) { + throw new InvalidFieldException( + sprintf( + 'Values of Sort Key fields need to be scalar. Please check it for the field "%s".', + $field + ) + ); + } + + /** @var scalar $currentFieldValue */ $currentFieldValue = $this->serializer->normalize($value); $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; @@ -381,7 +420,8 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy } /** - * @throws \ReflectionException + * @return array + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ @@ -403,7 +443,9 @@ protected function normalizeAttributes(object $entity): array } /** - * @throws \ReflectionException + * @param array $item + * @param class-string $class + * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException */ diff --git a/src/Serializer/InvalidEntityException.php b/src/Serializer/InvalidEntityException.php index cdf9a86..9263e0b 100644 --- a/src/Serializer/InvalidEntityException.php +++ b/src/Serializer/InvalidEntityException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\Serializer; -class InvalidEntityException extends \InvalidArgumentException +use InvalidArgumentException; + +class InvalidEntityException extends InvalidArgumentException { } diff --git a/src/Serializer/InvalidFieldException.php b/src/Serializer/InvalidFieldException.php index 67b9624..fb588e1 100644 --- a/src/Serializer/InvalidFieldException.php +++ b/src/Serializer/InvalidFieldException.php @@ -4,6 +4,8 @@ namespace EduardoMarques\DynamoPHP\Serializer; -class InvalidFieldException extends \InvalidArgumentException +use InvalidArgumentException; + +class InvalidFieldException extends InvalidArgumentException { } From 3e33c58608568ea796f2593899122633cddf9faf Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Sun, 4 May 2025 17:27:05 +0200 Subject: [PATCH 06/11] feat: implement base logic (wip) --- src/ODM/EntityManagerFactory.php | 41 +++ src/Serializer/EntityDenormalizer.php | 53 ++++ src/Serializer/EntityNormalizer.php | 351 ++++++++++++++++++++++ src/Serializer/EntitySerializer.php | 410 +------------------------- 4 files changed, 450 insertions(+), 405 deletions(-) create mode 100644 src/ODM/EntityManagerFactory.php create mode 100644 src/Serializer/EntityDenormalizer.php create mode 100644 src/Serializer/EntityNormalizer.php diff --git a/src/ODM/EntityManagerFactory.php b/src/ODM/EntityManagerFactory.php new file mode 100644 index 0000000..94046e6 --- /dev/null +++ b/src/ODM/EntityManagerFactory.php @@ -0,0 +1,41 @@ + $dbClientOptions + * @param array $options + */ + public static function create(array $dbClientOptions, array $options = []): EntityManager + { + $datetimeFormat = $options[EntityNormalizer::DATETIME_FORMAT_KEY] ?? 'Y-m-d\TH:i:s.v\Z'; + + $dynamoDbClient = new DynamoDbClient($dbClientOptions); + $metadataLoader = new MetadataLoader(); + $serializer = new Serializer([ + new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => $datetimeFormat]), + new BackedEnumNormalizer(), + new ObjectNormalizer(), + ]); + + $normalizer = new EntityNormalizer($metadataLoader, $serializer); + $denormalizer = new EntityDenormalizer($metadataLoader, $serializer); + $entitySerializer = new EntitySerializer($normalizer, $denormalizer); + + return new EntityManager($dynamoDbClient, $metadataLoader, $entitySerializer); + } +} diff --git a/src/Serializer/EntityDenormalizer.php b/src/Serializer/EntityDenormalizer.php new file mode 100644 index 0000000..d6a602a --- /dev/null +++ b/src/Serializer/EntityDenormalizer.php @@ -0,0 +1,53 @@ +> $item + * @param class-string $class + * @throws ExceptionInterface + * @throws ReflectionException + * @throws MetadataException + */ + public function denormalize(array $item, string $class): object + { + return $this->denormalizeAttributes($item, $class); + } + + /** + * @param array $item + * @param class-string $class + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function denormalizeAttributes(array $item, string $class): object + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $propertyAttributes = $entityMetadata->getPropertyAttributes(); + + $normalizedItem = []; + + foreach ($propertyAttributes as $prop => $attr) { + $normalizedItem[$prop] = $item[$attr->name ?: $prop] ?? null; + } + + return $this->denormalizer->denormalize($normalizedItem, $class); + } +} diff --git a/src/Serializer/EntityNormalizer.php b/src/Serializer/EntityNormalizer.php new file mode 100644 index 0000000..b73b00c --- /dev/null +++ b/src/Serializer/EntityNormalizer.php @@ -0,0 +1,351 @@ +> + * @throws ExceptionInterface + * @throws ReflectionException + * @throws MetadataException + */ + public function normalize(object $entity, bool $includePrimaryKey = true): array + { + $primaryKey = $includePrimaryKey ? $this->normalizePrimaryKey($entity) : []; + + return [ + ...$primaryKey, + ...$this->normalizeAttributes($entity), + ]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * @return array + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + public function normalizePrimaryKey(object|string $entity, array $keyFieldValues = []): array + { + return [ + ...$this->normalizePartitionKey($entity, $keyFieldValues), + ...$this->normalizeSortKey($entity, $keyFieldValues), + ]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * @return array + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array + { + $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $isClassString = is_string($entity); + $class = $isClassString ? $entity : $entity::class; + + $partitionKeyName = $this->normalizePartitionKeyName($class); + $partitionKeyValue = $isClassString + ? $this->normalizePartitionKeyValueFromArray($class, $keyFieldValues) + : $this->normalizePartitionKeyValueFromEntity($entity); + + return [$partitionKeyName => $partitionKeyValue]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + * @return array + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array + { + $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $isClassString = is_string($entity); + $class = $isClassString ? $entity : $entity::class; + $sortKeyName = $this->normalizeSortKeyName($class); + + $sortKeyValue = $isClassString + ? $this->normalizeSortKeyValueFromArray($class, $keyFieldValues) + : $this->normalizeSortKeyValueFromEntity($entity); + + return null === $sortKeyName || null === $sortKeyValue + ? [] + : [$sortKeyName => $sortKeyValue]; + } + + /** + * @param object|class-string $entity + * @param array $keyFieldValues + */ + protected function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void + { + $isClassString = is_string($entity); + + if ($isClassString && false === class_exists($entity)) { + throw new InvalidEntityException(sprintf('Entity class "%s" does not exist', $entity)); + } + + if ($isClassString && empty($keyFieldValues)) { + throw new InvalidEntityException('When entity class is provided, fields also need to be.'); + } + } + + /** + * @param class-string $class + * @throws ReflectionException + * @throws MetadataException + */ + protected function normalizePartitionKeyName(string $class): string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + + return $entityMetadata->getPartitionKey()->getName(); + } + + /** + * @param class-string $class + * @throws ReflectionException + * @throws MetadataException + */ + protected function normalizeSortKeyName(string $class): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + + return $entityMetadata->getSortKey()?->getName(); + } + + /** + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizePartitionKeyValueFromEntity(object $entity): string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $key = $entityMetadata->getPartitionKey(); + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $finalValue = $prefix ?? ''; + + foreach ($definedFields as $field) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf( + 'Field "%s" defined in Partition Key is invalid. Are you sure it exists in the entity class?', + $field + ) + ); + } + + /** @var ReflectionProperty $reflectionProperty */ + $reflectionProperty = $classMetadata->offsetGet($field); + $propertyValue = $reflectionProperty->getValue($entity); + + /** @var scalar $currentFieldValue */ + $currentFieldValue = $this->normalizer->normalize($propertyValue); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizeSortKeyValueFromEntity(object $entity): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $key = $entityMetadata->getSortKey(); + + if (null === $key) { + return null; + } + + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $finalValue = $prefix; + + foreach ($definedFields as $field) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf( + 'Field "%s" defined in Sort Key is invalid. Are you sure it exists in the entity class?', + $field + ) + ); + } + + /** @var ReflectionProperty $reflectionProperty */ + $reflectionProperty = $classMetadata->offsetGet($field); + $propertyValue = $reflectionProperty->getValue($entity); + + /** @var scalar $currentFieldValue */ + $currentFieldValue = $this->normalizer->normalize($propertyValue); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @param class-string $class + * @param array $valuesByField + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $key = $entityMetadata->getPartitionKey(); + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $valuesByFieldSorted = []; + + foreach ($definedFields as $field) { + if (isset($valuesByField[$field])) { + $valuesByFieldSorted[$field] = $valuesByField[$field]; + } + } + + $allFieldsProvided = empty(array_diff_key($valuesByFieldSorted, array_flip($definedFields))); + + if (false === $allFieldsProvided) { + throw new InvalidFieldException( + 'Provided Partition Key fields do not match the ones defined in the entity' + ); + } + + $classMetadata = $this->metadataLoader->getClassMetadata($class); + $finalValue = $prefix ?? ''; + + foreach ($valuesByFieldSorted as $field => $value) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) + ); + } + + if (empty($value)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure its value is provided?', $field) + ); + } + + /** @var scalar $currentFieldValue */ + $currentFieldValue = $this->normalizer->normalize($value); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @param class-string $class + * @param array $valuesByField + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizeSortKeyValueFromArray(string $class, array $valuesByField): ?string + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($class); + $key = $entityMetadata->getSortKey(); + + if (null === $key) { + return null; + } + + $definedFields = $key->getFields(); + $delimiter = $key->getDelimiter(); + $prefix = $key->getPrefix(); + + $valuesByFieldSorted = []; + + foreach ($definedFields as $field) { + if (isset($valuesByField[$field])) { + $valuesByFieldSorted[$field] = $valuesByField[$field]; + } + } + + $classMetadata = $this->metadataLoader->getClassMetadata($class); + $finalValue = $prefix ?? ''; + + foreach ($valuesByFieldSorted as $field => $value) { + if (false === $classMetadata->offsetExists($field)) { + throw new InvalidFieldException( + sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) + ); + } + + /** @var scalar $currentFieldValue */ + $currentFieldValue = $this->normalizer->normalize($value); + + $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; + } + + return $finalValue; + } + + /** + * @return array + * @throws ReflectionException + * @throws ExceptionInterface + * @throws MetadataException + */ + protected function normalizeAttributes(object $entity): array + { + $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); + $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); + $propertyAttributes = $entityMetadata->getPropertyAttributes(); + + $attributes = []; + + foreach ($propertyAttributes as $prop => $attr) { + $reflectionProperty = $classMetadata->offsetGet($prop); + $propertyValue = $reflectionProperty?->getValue($entity); + $attributes[$attr->name ?: $prop] = $this->normalizer->normalize($propertyValue); + } + + return $attributes; + } +} diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php index e7b9aa5..c575644 100644 --- a/src/Serializer/EntitySerializer.php +++ b/src/Serializer/EntitySerializer.php @@ -6,19 +6,16 @@ use Aws\DynamoDb\Marshaler; use EduardoMarques\DynamoPHP\Metadata\MetadataException; -use EduardoMarques\DynamoPHP\Metadata\MetadataLoader; use ReflectionException; -use ReflectionProperty; use Symfony\Component\Serializer\Exception\ExceptionInterface; -use Symfony\Component\Serializer\Serializer; class EntitySerializer { protected Marshaler $marshaler; public function __construct( - protected MetadataLoader $metadataLoader, - protected Serializer $serializer, + protected EntityNormalizer $entityNormalizer, + protected EntityDenormalizer $entityDenormalizer, ) { $this->marshaler = new Marshaler(); } @@ -31,7 +28,7 @@ public function __construct( */ public function serialize(object $entity, bool $includePrimaryKey = true): array { - $normalized = $this->normalize($entity, $includePrimaryKey); + $normalized = $this->entityNormalizer->normalize($entity, $includePrimaryKey); return $this->marshaler->marshalItem($normalized); } @@ -46,7 +43,7 @@ public function serialize(object $entity, bool $includePrimaryKey = true): array */ public function serializePrimaryKey(object|string $entity, array $keyFieldValues = []): array { - $normalized = $this->normalizePrimaryKey($entity, $keyFieldValues); + $normalized = $this->entityNormalizer->normalizePrimaryKey($entity, $keyFieldValues); return $this->marshaler->marshalItem($normalized); } @@ -63,403 +60,6 @@ public function deserialize(array $item, string $class): object /** @var array $normalized */ $normalized = $this->marshaler->unmarshalItem($item); - return $this->denormalize($normalized, $class); - } - - /** - * @return array> - * @throws ExceptionInterface - * @throws ReflectionException - * @throws MetadataException - */ - public function normalize(object $entity, bool $includePrimaryKey = true): array - { - $primaryKey = $includePrimaryKey ? $this->normalizePrimaryKey($entity) : []; - - return [ - ...$primaryKey, - ...$this->normalizeAttributes($entity), - ]; - } - - /** - * @param object|class-string $entity - * @param array $keyFieldValues - * @return array - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - public function normalizePrimaryKey(object|string $entity, array $keyFieldValues = []): array - { - return [ - ...$this->normalizePartitionKey($entity, $keyFieldValues), - ...$this->normalizeSortKey($entity, $keyFieldValues), - ]; - } - - /** - * @param array> $item - * @param class-string $class - * @throws ExceptionInterface - * @throws ReflectionException - * @throws MetadataException - */ - public function denormalize(array $item, string $class): object - { - return $this->denormalizeAttributes($item, $class); - } - - /** - * @param object|class-string $entity - * @param array $keyFieldValues - * @return array - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array - { - $this->validatePrimaryKeyArguments($entity, $keyFieldValues); - $isClassString = is_string($entity); - $class = $isClassString ? $entity : $entity::class; - - $partitionKeyName = $this->normalizePartitionKeyName($class); - $partitionKeyValue = $isClassString - ? $this->normalizePartitionKeyValueFromArray($class, $keyFieldValues) - : $this->normalizePartitionKeyValueFromEntity($entity); - - return [$partitionKeyName => $partitionKeyValue]; - } - - /** - * @param object|class-string $entity - * @param array $keyFieldValues - * @return array - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array - { - $this->validatePrimaryKeyArguments($entity, $keyFieldValues); - $isClassString = is_string($entity); - $class = $isClassString ? $entity : $entity::class; - $sortKeyName = $this->normalizeSortKeyName($class); - - $sortKeyValue = $isClassString - ? $this->normalizeSortKeyValueFromArray($class, $keyFieldValues) - : $this->normalizeSortKeyValueFromEntity($entity); - - return null === $sortKeyName || null === $sortKeyValue - ? [] - : [$sortKeyName => $sortKeyValue]; - } - - /** - * @param object|class-string $entity - * @param array $keyFieldValues - */ - protected function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void - { - $isClassString = is_string($entity); - - if ($isClassString && false === class_exists($entity)) { - throw new InvalidEntityException(sprintf('Entity class "%s" does not exist', $entity)); - } - - if ($isClassString && empty($keyFieldValues)) { - throw new InvalidEntityException('When entity class is provided, fields also need to be.'); - } - } - - /** - * @param class-string $class - * @throws ReflectionException - * @throws MetadataException - */ - protected function normalizePartitionKeyName(string $class): string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($class); - - return $entityMetadata->getPartitionKey()->getName(); - } - - /** - * @param class-string $class - * @throws ReflectionException - * @throws MetadataException - */ - protected function normalizeSortKeyName(string $class): ?string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($class); - - return $entityMetadata->getSortKey()?->getName(); - } - - /** - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizePartitionKeyValueFromEntity(object $entity): string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); - $key = $entityMetadata->getPartitionKey(); - $definedFields = $key->getFields(); - $delimiter = $key->getDelimiter(); - $prefix = $key->getPrefix(); - - $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); - $finalValue = $prefix ?? ''; - - foreach ($definedFields as $field) { - if (false === $classMetadata->offsetExists($field)) { - throw new InvalidFieldException( - sprintf( - 'Field "%s" defined in Partition Key is invalid. Are you sure it exists in the entity class?', - $field - ) - ); - } - - /** @var ReflectionProperty $reflectionProperty */ - $reflectionProperty = $classMetadata->offsetGet($field); - $propertyValue = $reflectionProperty->getValue($entity); - - if (false === is_scalar($propertyValue)) { - throw new InvalidFieldException( - sprintf( - 'Values of Partition Key fields need to be scalar. Please check it for the field "%s".', - $field - ) - ); - } - - /** @var scalar $currentFieldValue */ - $currentFieldValue = $this->serializer->normalize($propertyValue); - - $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; - } - - return $finalValue; - } - - /** - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizeSortKeyValueFromEntity(object $entity): ?string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); - $key = $entityMetadata->getSortKey(); - - if (null === $key) { - return null; - } - - $definedFields = $key->getFields(); - $delimiter = $key->getDelimiter(); - $prefix = $key->getPrefix(); - - $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); - $finalValue = $prefix; - - foreach ($definedFields as $field) { - if (false === $classMetadata->offsetExists($field)) { - throw new InvalidFieldException( - sprintf( - 'Field "%s" defined in Sort Key is invalid. Are you sure it exists in the entity class?', - $field - ) - ); - } - - /** @var ReflectionProperty $reflectionProperty */ - $reflectionProperty = $classMetadata->offsetGet($field); - $propertyValue = $reflectionProperty->getValue($entity); - - if (false === is_scalar($propertyValue)) { - throw new InvalidFieldException( - sprintf( - 'Values of Sort Key fields need to be scalar. Please check it for the field "%s".', - $field - ) - ); - } - - /** @var scalar $currentFieldValue */ - $currentFieldValue = $this->serializer->normalize($propertyValue); - - $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; - } - - return $finalValue; - } - - /** - * @param class-string $class - * @param array $valuesByField - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizePartitionKeyValueFromArray(string $class, array $valuesByField): string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($class); - $key = $entityMetadata->getPartitionKey(); - $definedFields = $key->getFields(); - $delimiter = $key->getDelimiter(); - $prefix = $key->getPrefix(); - - $valuesByFieldSorted = []; - - foreach ($definedFields as $field) { - if (isset($valuesByField[$field])) { - $valuesByFieldSorted[$field] = $valuesByField[$field]; - } - } - - $allFieldsProvided = empty(array_diff_key($valuesByFieldSorted, array_flip($definedFields))); - - if (false === $allFieldsProvided) { - throw new InvalidFieldException( - 'Provided Partition Key fields do not match the ones defined in the entity' - ); - } - - $classMetadata = $this->metadataLoader->getClassMetadata($class); - $finalValue = $prefix ?? ''; - - foreach ($valuesByFieldSorted as $field => $value) { - if (false === $classMetadata->offsetExists($field)) { - throw new InvalidFieldException( - sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) - ); - } - - if (empty($value)) { - throw new InvalidFieldException( - sprintf('Field "%s" is invalid. Are you sure its value is provided?', $field) - ); - } - - if (false === is_scalar($value)) { - throw new InvalidFieldException( - sprintf( - 'Values of Partition Key fields need to be scalar. Please check it for the field "%s".', - $field - ) - ); - } - - /** @var scalar $currentFieldValue */ - $currentFieldValue = $this->serializer->normalize($value); - - $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; - } - - return $finalValue; - } - - /** - * @param class-string $class - * @param array $valuesByField - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizeSortKeyValueFromArray(string $class, array $valuesByField): ?string - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($class); - $key = $entityMetadata->getSortKey(); - - if (null === $key) { - return null; - } - - $definedFields = $key->getFields(); - $delimiter = $key->getDelimiter(); - $prefix = $key->getPrefix(); - - $valuesByFieldSorted = []; - - foreach ($definedFields as $field) { - if (isset($valuesByField[$field])) { - $valuesByFieldSorted[$field] = $valuesByField[$field]; - } - } - - $classMetadata = $this->metadataLoader->getClassMetadata($class); - $finalValue = $prefix ?? ''; - - foreach ($valuesByFieldSorted as $field => $value) { - if (false === $classMetadata->offsetExists($field)) { - throw new InvalidFieldException( - sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) - ); - } - - if (false === is_scalar($value)) { - throw new InvalidFieldException( - sprintf( - 'Values of Sort Key fields need to be scalar. Please check it for the field "%s".', - $field - ) - ); - } - - /** @var scalar $currentFieldValue */ - $currentFieldValue = $this->serializer->normalize($value); - - $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue; - } - - return $finalValue; - } - - /** - * @return array - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function normalizeAttributes(object $entity): array - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($entity::class); - $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); - $propertyAttributes = $entityMetadata->getPropertyAttributes(); - - $attributes = []; - - foreach ($propertyAttributes as $prop => $attr) { - $reflectionProperty = $classMetadata->offsetGet($prop); - $propertyValue = $reflectionProperty?->getValue($entity); - $attributes[$attr->name ?: $prop] = $this->serializer->normalize($propertyValue); - } - - return $attributes; - } - - /** - * @param array $item - * @param class-string $class - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function denormalizeAttributes(array $item, string $class): object - { - $entityMetadata = $this->metadataLoader->getEntityMetadata($class); - $propertyAttributes = $entityMetadata->getPropertyAttributes(); - - $normalizedItem = []; - - foreach ($propertyAttributes as $prop => $attr) { - $normalizedItem[$prop] = $item[$attr->name ?: $prop] ?? null; - } - - return $this->serializer->denormalize($normalizedItem, $class); + return $this->entityDenormalizer->denormalize($normalized, $class); } } From 5847cd9cba421148a567653472560137f60a6a1e Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Mon, 5 May 2025 03:11:31 +0200 Subject: [PATCH 07/11] feat: implement unit tests --- composer.lock | 12 ++-- phpunit.xml | 4 +- src/Attribute/AbstractKey.php | 47 +++++++++++++ src/Attribute/Entity.php | 4 +- src/Attribute/KeyInterface.php | 19 ----- src/Attribute/PartitionKey.php | 39 +++-------- src/Attribute/SortKey.php | 39 +++-------- src/Metadata/ClassMetadata.php | 37 ++-------- src/Metadata/EntityMetadata.php | 8 +-- src/Metadata/MetadataLoader.php | 17 +++-- src/ODM/EntityManager.php | 64 ++++++++--------- src/ODM/EntityManagerFactory.php | 12 ++-- src/ODM/ResultStream.php | 10 ++- src/Serializer/EntityDenormalizer.php | 30 ++++---- src/Serializer/EntityNormalizer.php | 70 +++++++++++++------ src/Serializer/EntitySerializer.php | 28 ++++---- tests/Unit/Attribute/EntityTest.php | 23 ++++++ tests/Unit/Attribute/PartitionKeyTest.php | 30 ++++++++ tests/Unit/Attribute/SortKeyTest.php | 30 ++++++++ tests/Unit/Metadata/MetadataLoaderTest.php | 81 ++++++++++++++++++++++ tests/Unit/Stubs/ClassA.php | 12 ++++ tests/Unit/Stubs/EntityA.php | 45 ++++++++++++ 22 files changed, 440 insertions(+), 221 deletions(-) create mode 100644 src/Attribute/AbstractKey.php delete mode 100644 src/Attribute/KeyInterface.php create mode 100644 tests/Unit/Attribute/EntityTest.php create mode 100644 tests/Unit/Attribute/PartitionKeyTest.php create mode 100644 tests/Unit/Attribute/SortKeyTest.php create mode 100644 tests/Unit/Metadata/MetadataLoaderTest.php create mode 100644 tests/Unit/Stubs/ClassA.php create mode 100644 tests/Unit/Stubs/EntityA.php diff --git a/composer.lock b/composer.lock index 07b88e6..e8130f0 100644 --- a/composer.lock +++ b/composer.lock @@ -2035,16 +2035,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.2.0", + "version": "12.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4c7133dbade8423ef124ae62c39a075ba591cb3f" + "reference": "448f2c504d86dbff3949dcd02c95aa85db2c7617" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4c7133dbade8423ef124ae62c39a075ba591cb3f", - "reference": "4c7133dbade8423ef124ae62c39a075ba591cb3f", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/448f2c504d86dbff3949dcd02c95aa85db2c7617", + "reference": "448f2c504d86dbff3949dcd02c95aa85db2c7617", "shasum": "" }, "require": { @@ -2100,7 +2100,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.2.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.2.1" }, "funding": [ { @@ -2120,7 +2120,7 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:27:30+00:00" + "time": "2025-05-04T05:25:05+00:00" }, { "name": "phpunit/php-file-iterator", diff --git a/phpunit.xml b/phpunit.xml index 80c506e..2f0301e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,10 +13,10 @@ - tests/unit + tests/Unit - tests/integration + tests/Integration diff --git a/src/Attribute/AbstractKey.php b/src/Attribute/AbstractKey.php new file mode 100644 index 0000000..0ce8cd1 --- /dev/null +++ b/src/Attribute/AbstractKey.php @@ -0,0 +1,47 @@ + */ + protected array $fields, + protected string $name, + protected string $delimiter, + protected ?string $prefix = null, + ) { + if (empty($this->fields)) { + throw new InvalidArgumentException( + sprintf('Attribute argument %s::fields must not be empty', $this::class) + ); + } + + $this->fields = array_values(array_unique($this->fields)); + } + + /** + * @return array + */ + public function getFields(): array + { + return $this->fields; + } + + public function getName(): string + { + return $this->name; + } + + public function getDelimiter(): string + { + return $this->delimiter; + } + + public function getPrefix(): ?string + { + return $this->prefix; + } +} diff --git a/src/Attribute/Entity.php b/src/Attribute/Entity.php index 6f36cb2..ca431b2 100644 --- a/src/Attribute/Entity.php +++ b/src/Attribute/Entity.php @@ -11,8 +11,8 @@ class Entity { public function __construct( public string $table, - public KeyInterface $partitionKey, - public ?KeyInterface $sortKey = null, + public AbstractKey $partitionKey, + public ?AbstractKey $sortKey = null, ) { if ('' === $this->table) { throw new InvalidArgumentException( diff --git a/src/Attribute/KeyInterface.php b/src/Attribute/KeyInterface.php deleted file mode 100644 index 2f2c932..0000000 --- a/src/Attribute/KeyInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - - */ - public function getFields(): array; - - public function getName(): string; - - public function getDelimiter(): string; - - public function getPrefix(): ?string; -} diff --git a/src/Attribute/PartitionKey.php b/src/Attribute/PartitionKey.php index 2f9f936..940f578 100644 --- a/src/Attribute/PartitionKey.php +++ b/src/Attribute/PartitionKey.php @@ -4,38 +4,17 @@ namespace EduardoMarques\DynamoPHP\Attribute; -class PartitionKey implements KeyInterface +final class PartitionKey extends AbstractKey { - public function __construct( - /** @var array */ - protected array $fields, - protected string $name = 'PK', - protected string $delimiter = '#', - protected ?string $prefix = null, - ) { - $this->fields = array_values(array_unique($this->fields)); - } - /** - * @return array + * @param array $fields */ - public function getFields(): array - { - return $this->fields; - } - - public function getName(): string - { - return $this->name; - } - - public function getDelimiter(): string - { - return $this->delimiter; - } - - public function getPrefix(): ?string - { - return $this->prefix; + public function __construct( + array $fields, + string $name = 'PK', + string $delimiter = '#', + ?string $prefix = null, + ) { + parent::__construct($fields, $name, $delimiter, $prefix); } } diff --git a/src/Attribute/SortKey.php b/src/Attribute/SortKey.php index 893b6d7..e80667d 100644 --- a/src/Attribute/SortKey.php +++ b/src/Attribute/SortKey.php @@ -4,38 +4,17 @@ namespace EduardoMarques\DynamoPHP\Attribute; -class SortKey implements KeyInterface +final class SortKey extends AbstractKey { - public function __construct( - /** @var string[] */ - protected array $fields, - protected string $name = 'SK', - protected string $delimiter = '#', - protected ?string $prefix = null, - ) { - $this->fields = array_values(array_unique($this->fields)); - } - /** - * @return array + * @param array $fields */ - public function getFields(): array - { - return $this->fields; - } - - public function getName(): string - { - return $this->name; - } - - public function getDelimiter(): string - { - return $this->delimiter; - } - - public function getPrefix(): ?string - { - return $this->prefix; + public function __construct( + array $fields, + string $name = 'SK', + string $delimiter = '#', + ?string $prefix = null, + ) { + parent::__construct($fields, $name, $delimiter, $prefix); } } diff --git a/src/Metadata/ClassMetadata.php b/src/Metadata/ClassMetadata.php index cc0d3e7..243c57e 100644 --- a/src/Metadata/ClassMetadata.php +++ b/src/Metadata/ClassMetadata.php @@ -4,13 +4,9 @@ namespace EduardoMarques\DynamoPHP\Metadata; -use ArrayAccess; use ReflectionProperty; -/** - * @implements ArrayAccess - */ -class ClassMetadata implements ArrayAccess +final readonly class ClassMetadata { public function __construct( /** @var array */ @@ -18,36 +14,13 @@ public function __construct( ) { } - /** - * @param string $offset - */ - public function offsetExists(mixed $offset): bool + public function has(string $property): bool { - return isset($this->properties[$offset]); + return isset($this->properties[$property]); } - /** - * @param string $offset - */ - public function offsetGet(mixed $offset): ?ReflectionProperty + public function get(string $property): ?ReflectionProperty { - return $this->properties[$offset] ?? null; - } - - /** - * @param string $offset - * @param ReflectionProperty $value - */ - public function offsetSet(mixed $offset, mixed $value): void - { - $this->properties[$offset] = $value; - } - - /** - * @param string $offset - */ - public function offsetUnset(mixed $offset): void - { - unset($this->properties[$offset]); + return $this->properties[$property] ?? null; } } diff --git a/src/Metadata/EntityMetadata.php b/src/Metadata/EntityMetadata.php index f007cc5..dd5ced7 100644 --- a/src/Metadata/EntityMetadata.php +++ b/src/Metadata/EntityMetadata.php @@ -4,11 +4,11 @@ namespace EduardoMarques\DynamoPHP\Metadata; +use EduardoMarques\DynamoPHP\Attribute\AbstractKey; use EduardoMarques\DynamoPHP\Attribute\Attribute; use EduardoMarques\DynamoPHP\Attribute\Entity; -use EduardoMarques\DynamoPHP\Attribute\KeyInterface; -class EntityMetadata +final readonly class EntityMetadata { public function __construct( protected Entity $entityAttribute, @@ -22,12 +22,12 @@ public function getTable(): string return $this->entityAttribute->table; } - public function getPartitionKey(): KeyInterface + public function getPartitionKey(): AbstractKey { return $this->entityAttribute->partitionKey; } - public function getSortKey(): ?KeyInterface + public function getSortKey(): ?AbstractKey { return $this->entityAttribute->sortKey; } diff --git a/src/Metadata/MetadataLoader.php b/src/Metadata/MetadataLoader.php index c911789..38ee425 100644 --- a/src/Metadata/MetadataLoader.php +++ b/src/Metadata/MetadataLoader.php @@ -11,13 +11,14 @@ use ReflectionException; use ReflectionProperty; -class MetadataLoader +final class MetadataLoader { /** @var array> */ private array $cache = []; /** - * @param class-string $class + * @template T of object + * @param class-string $class * @throws ReflectionException */ public function getClassMetadata(string $class): ClassMetadata @@ -38,7 +39,8 @@ public function getClassMetadata(string $class): ClassMetadata } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @throws ReflectionException * @throws MetadataException */ @@ -76,7 +78,8 @@ public function getEntityMetadata(string $class): EntityMetadata } /** - * @param ReflectionClass $reflection + * @template T of object + * @param ReflectionClass $reflection * @return array */ private function getClassProperties(ReflectionClass $reflection): array @@ -91,7 +94,8 @@ private function getClassProperties(ReflectionClass $reflection): array } /** - * @param ReflectionClass $reflection + * @template T of object + * @param ReflectionClass $reflection * @return array */ private function getClassAttributes(ReflectionClass $reflection): array @@ -107,7 +111,8 @@ private function getClassAttributes(ReflectionClass $reflection): array } /** - * @param ReflectionClass $reflection + * @template T of object + * @param ReflectionClass $reflection * @return array> */ private function getPropertyAttributes(ReflectionClass $reflection): array diff --git a/src/ODM/EntityManager.php b/src/ODM/EntityManager.php index 2717b21..cc15205 100644 --- a/src/ODM/EntityManager.php +++ b/src/ODM/EntityManager.php @@ -10,7 +10,7 @@ use Generator; use Throwable; -class EntityManager +readonly class EntityManager { public function __construct( protected DynamoDbClient $dynamoDbClient, @@ -20,27 +20,22 @@ public function __construct( } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @param array $keyFieldValues + * @return T|null * @throws EntityManagerException */ - public function find(string $class, array $keyFieldValues): ?object + public function get(string $class, array $keyFieldValues): ?object { try { $key = $this->entitySerializer->serializePrimaryKey($class, $keyFieldValues); - - if (2 > count($key)) { - throw new EntityManagerException('Fields of both Partition and Sort keys must be provided'); - } - $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); - $result = $this->dynamoDbClient->getItem( - [ - 'TableName' => $table, - 'Key' => $key, - ] - ); + $result = $this->dynamoDbClient->getItem([ + 'TableName' => $table, + 'Key' => $key, + ]); $rawItem = $result['Item'] ?? null; @@ -55,7 +50,9 @@ public function find(string $class, array $keyFieldValues): ?object } /** - * @param class-string $class + * @template T of object + * @param class-string $class + * @return T|null * @throws EntityManagerException */ public function queryOne(string $class, QueryBuilder $queryBuilder): ?object @@ -69,7 +66,9 @@ public function queryOne(string $class, QueryBuilder $queryBuilder): ?object } /** - * @param class-string $class + * @template T of object + * @param class-string $class + * @return ResultStream * @throws EntityManagerException */ public function query(string $class, QueryBuilder $queryBuilder): ResultStream @@ -112,7 +111,9 @@ public function query(string $class, QueryBuilder $queryBuilder): ResultStream } /** - * @param class-string $class + * @template T of object + * @param class-string $class + * @return ResultStream * @throws EntityManagerException */ public function scan(string $class, ScanBuilder $scanBuilder): ResultStream @@ -155,40 +156,40 @@ public function scan(string $class, ScanBuilder $scanBuilder): ResultStream } /** + * @template T of object + * @param T $entity * @throws EntityManagerException */ - public function save(object $entity): void + public function put(object $entity): void { try { $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); $item = $this->entitySerializer->serialize($entity); - $this->dynamoDbClient->putItem( - [ - 'TableName' => $table, - 'Item' => $item, - ] - ); + $this->dynamoDbClient->putItem([ + 'TableName' => $table, + 'Item' => $item, + ]); } catch (Throwable $exception) { $this->wrapException($exception); } } /** + * @template T of object + * @param T $entity * @throws EntityManagerException */ - public function remove(object $entity): void + public function delete(object $entity): void { try { $table = $this->metadataLoader->getEntityMetadata($entity::class)->getTable(); $key = $this->entitySerializer->serializePrimaryKey($entity); - $this->dynamoDbClient->deleteItem( - [ - 'TableName' => $table, - 'Key' => $key, - ] - ); + $this->dynamoDbClient->deleteItem([ + 'TableName' => $table, + 'Key' => $key, + ]); } catch (Throwable $exception) { $this->wrapException($exception); } @@ -196,6 +197,7 @@ public function remove(object $entity): void /** * @param class-string $class + * @return ResultStream * @throws EntityManagerException */ public function describe(string $class): ResultStream diff --git a/src/ODM/EntityManagerFactory.php b/src/ODM/EntityManagerFactory.php index 94046e6..c6325eb 100644 --- a/src/ODM/EntityManagerFactory.php +++ b/src/ODM/EntityManagerFactory.php @@ -9,12 +9,14 @@ use EduardoMarques\DynamoPHP\Serializer\EntityDenormalizer; use EduardoMarques\DynamoPHP\Serializer\EntityNormalizer; use EduardoMarques\DynamoPHP\Serializer\EntitySerializer; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; -class EntityManagerFactory +readonly class EntityManagerFactory { /** * @param array $dbClientOptions @@ -22,14 +24,16 @@ class EntityManagerFactory */ public static function create(array $dbClientOptions, array $options = []): EntityManager { - $datetimeFormat = $options[EntityNormalizer::DATETIME_FORMAT_KEY] ?? 'Y-m-d\TH:i:s.v\Z'; + $datetimeFormat = $options[EntityNormalizer::DATETIME_FORMAT_KEY] ?? 'Y-m-d\TH:i:s.u\Z'; $dynamoDbClient = new DynamoDbClient($dbClientOptions); $metadataLoader = new MetadataLoader(); $serializer = new Serializer([ - new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => $datetimeFormat]), new BackedEnumNormalizer(), - new ObjectNormalizer(), + new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => $datetimeFormat]), + new ObjectNormalizer( + propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new ReflectionExtractor()]), + ), ]); $normalizer = new EntityNormalizer($metadataLoader, $serializer); diff --git a/src/ODM/ResultStream.php b/src/ODM/ResultStream.php index fc7a725..cde1cdd 100644 --- a/src/ODM/ResultStream.php +++ b/src/ODM/ResultStream.php @@ -6,15 +6,21 @@ use Generator; -class ResultStream +/** + * @template T + */ +readonly class ResultStream { + /** + * @param Generator $items + */ public function __construct( protected Generator $items, ) { } /** - * @return Generator|array + * @return Generator|array */ public function getItems(bool $asArray = false): iterable { diff --git a/src/Serializer/EntityDenormalizer.php b/src/Serializer/EntityDenormalizer.php index d6a602a..3d5b213 100644 --- a/src/Serializer/EntityDenormalizer.php +++ b/src/Serializer/EntityDenormalizer.php @@ -10,7 +10,7 @@ use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; -class EntityDenormalizer +final readonly class EntityDenormalizer { public function __construct( protected MetadataLoader $metadataLoader, @@ -19,35 +19,29 @@ public function __construct( } /** - * @param array> $item + * @template T of object + * @param array $data * @param class-string $class + * @return T * @throws ExceptionInterface * @throws ReflectionException * @throws MetadataException */ - public function denormalize(array $item, string $class): object - { - return $this->denormalizeAttributes($item, $class); - } - - /** - * @param array $item - * @param class-string $class - * @throws ReflectionException - * @throws ExceptionInterface - * @throws MetadataException - */ - protected function denormalizeAttributes(array $item, string $class): object + public function denormalize(array $data, string $class): object { $entityMetadata = $this->metadataLoader->getEntityMetadata($class); $propertyAttributes = $entityMetadata->getPropertyAttributes(); - $normalizedItem = []; + $normalizedData = []; foreach ($propertyAttributes as $prop => $attr) { - $normalizedItem[$prop] = $item[$attr->name ?: $prop] ?? null; + $value = $data[$attr->name ?: $prop] ?? null; + + if (null !== $value) { + $normalizedData[$prop] = $value; + } } - return $this->denormalizer->denormalize($normalizedItem, $class); + return $this->denormalizer->denormalize($normalizedData, $class); } } diff --git a/src/Serializer/EntityNormalizer.php b/src/Serializer/EntityNormalizer.php index b73b00c..c181ca8 100644 --- a/src/Serializer/EntityNormalizer.php +++ b/src/Serializer/EntityNormalizer.php @@ -11,7 +11,7 @@ use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -class EntityNormalizer +final readonly class EntityNormalizer { public const string DATETIME_FORMAT_KEY = EntityNormalizer::class . '_datetime_format'; @@ -22,7 +22,9 @@ public function __construct( } /** - * @return array> + * @template T of object + * @param T $entity + * @return array * @throws ExceptionInterface * @throws ReflectionException * @throws MetadataException @@ -38,7 +40,8 @@ public function normalize(object $entity, bool $includePrimaryKey = true): array } /** - * @param object|class-string $entity + * @template T of object + * @param T|class-string $entity * @param array $keyFieldValues * @return array * @throws ReflectionException @@ -54,7 +57,8 @@ public function normalizePrimaryKey(object|string $entity, array $keyFieldValues } /** - * @param object|class-string $entity + * @template T of object + * @param T|class-string $entity * @param array $keyFieldValues * @return array * @throws ReflectionException @@ -63,7 +67,7 @@ public function normalizePrimaryKey(object|string $entity, array $keyFieldValues */ protected function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array { - $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $this->validateKeyArguments($entity, $keyFieldValues); $isClassString = is_string($entity); $class = $isClassString ? $entity : $entity::class; @@ -76,7 +80,8 @@ protected function normalizePartitionKey(object|string $entity, array $keyFieldV } /** - * @param object|class-string $entity + * @template T of object + * @param T|class-string $entity * @param array $keyFieldValues * @return array * @throws ReflectionException @@ -85,7 +90,7 @@ protected function normalizePartitionKey(object|string $entity, array $keyFieldV */ protected function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array { - $this->validatePrimaryKeyArguments($entity, $keyFieldValues); + $this->validateKeyArguments($entity, $keyFieldValues); $isClassString = is_string($entity); $class = $isClassString ? $entity : $entity::class; $sortKeyName = $this->normalizeSortKeyName($class); @@ -94,16 +99,17 @@ protected function normalizeSortKey(object|string $entity, array $keyFieldValues ? $this->normalizeSortKeyValueFromArray($class, $keyFieldValues) : $this->normalizeSortKeyValueFromEntity($entity); - return null === $sortKeyName || null === $sortKeyValue + return empty($sortKeyName) || empty($sortKeyValue) ? [] : [$sortKeyName => $sortKeyValue]; } /** - * @param object|class-string $entity + * @template T of object + * @param T|class-string $entity * @param array $keyFieldValues */ - protected function validatePrimaryKeyArguments(object|string $entity, array $keyFieldValues = []): void + protected function validateKeyArguments(object|string $entity, array $keyFieldValues = []): void { $isClassString = is_string($entity); @@ -117,7 +123,8 @@ protected function validatePrimaryKeyArguments(object|string $entity, array $key } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @throws ReflectionException * @throws MetadataException */ @@ -129,7 +136,8 @@ protected function normalizePartitionKeyName(string $class): string } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @throws ReflectionException * @throws MetadataException */ @@ -141,6 +149,8 @@ protected function normalizeSortKeyName(string $class): ?string } /** + * @template T of object + * @param T $entity * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException @@ -157,7 +167,7 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): string $finalValue = $prefix ?? ''; foreach ($definedFields as $field) { - if (false === $classMetadata->offsetExists($field)) { + if (false === $classMetadata->has($field)) { throw new InvalidFieldException( sprintf( 'Field "%s" defined in Partition Key is invalid. Are you sure it exists in the entity class?', @@ -167,7 +177,7 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): string } /** @var ReflectionProperty $reflectionProperty */ - $reflectionProperty = $classMetadata->offsetGet($field); + $reflectionProperty = $classMetadata->get($field); $propertyValue = $reflectionProperty->getValue($entity); /** @var scalar $currentFieldValue */ @@ -180,6 +190,8 @@ protected function normalizePartitionKeyValueFromEntity(object $entity): string } /** + * @template T of object + * @param T $entity * @throws ReflectionException * @throws ExceptionInterface * @throws MetadataException @@ -198,10 +210,10 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string $prefix = $key->getPrefix(); $classMetadata = $this->metadataLoader->getClassMetadata($entity::class); - $finalValue = $prefix; + $finalValue = $prefix ?? ''; foreach ($definedFields as $field) { - if (false === $classMetadata->offsetExists($field)) { + if (false === $classMetadata->has($field)) { throw new InvalidFieldException( sprintf( 'Field "%s" defined in Sort Key is invalid. Are you sure it exists in the entity class?', @@ -211,7 +223,7 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string } /** @var ReflectionProperty $reflectionProperty */ - $reflectionProperty = $classMetadata->offsetGet($field); + $reflectionProperty = $classMetadata->get($field); $propertyValue = $reflectionProperty->getValue($entity); /** @var scalar $currentFieldValue */ @@ -224,7 +236,8 @@ protected function normalizeSortKeyValueFromEntity(object $entity): ?string } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @param array $valuesByField * @throws ReflectionException * @throws ExceptionInterface @@ -246,7 +259,7 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val } } - $allFieldsProvided = empty(array_diff_key($valuesByFieldSorted, array_flip($definedFields))); + $allFieldsProvided = empty(array_diff_key(array_flip($definedFields), $valuesByFieldSorted)); if (false === $allFieldsProvided) { throw new InvalidFieldException( @@ -258,7 +271,7 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val $finalValue = $prefix ?? ''; foreach ($valuesByFieldSorted as $field => $value) { - if (false === $classMetadata->offsetExists($field)) { + if (false === $classMetadata->has($field)) { throw new InvalidFieldException( sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) ); @@ -280,7 +293,8 @@ protected function normalizePartitionKeyValueFromArray(string $class, array $val } /** - * @param class-string $class + * @template T of object + * @param class-string $class * @param array $valuesByField * @throws ReflectionException * @throws ExceptionInterface @@ -307,11 +321,19 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy } } + $allFieldsProvided = empty(array_diff_key(array_flip($definedFields), $valuesByFieldSorted)); + + if (false === $allFieldsProvided) { + throw new InvalidFieldException( + 'Provided Sort Key fields do not match the ones defined in the entity' + ); + } + $classMetadata = $this->metadataLoader->getClassMetadata($class); $finalValue = $prefix ?? ''; foreach ($valuesByFieldSorted as $field => $value) { - if (false === $classMetadata->offsetExists($field)) { + if (false === $classMetadata->has($field)) { throw new InvalidFieldException( sprintf('Field "%s" is invalid. Are you sure it exists in the entity class?', $field) ); @@ -327,6 +349,8 @@ protected function normalizeSortKeyValueFromArray(string $class, array $valuesBy } /** + * @template T of object + * @param T $entity * @return array * @throws ReflectionException * @throws ExceptionInterface @@ -341,7 +365,7 @@ protected function normalizeAttributes(object $entity): array $attributes = []; foreach ($propertyAttributes as $prop => $attr) { - $reflectionProperty = $classMetadata->offsetGet($prop); + $reflectionProperty = $classMetadata->get($prop); $propertyValue = $reflectionProperty?->getValue($entity); $attributes[$attr->name ?: $prop] = $this->normalizer->normalize($propertyValue); } diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php index c575644..9f56e82 100644 --- a/src/Serializer/EntitySerializer.php +++ b/src/Serializer/EntitySerializer.php @@ -9,7 +9,7 @@ use ReflectionException; use Symfony\Component\Serializer\Exception\ExceptionInterface; -class EntitySerializer +readonly class EntitySerializer { protected Marshaler $marshaler; @@ -21,6 +21,8 @@ public function __construct( } /** + * @template T of object + * @param T $entity * @return array> * @throws ExceptionInterface * @throws MetadataException @@ -28,13 +30,14 @@ public function __construct( */ public function serialize(object $entity, bool $includePrimaryKey = true): array { - $normalized = $this->entityNormalizer->normalize($entity, $includePrimaryKey); + $normalizedEntity = $this->entityNormalizer->normalize($entity, $includePrimaryKey); - return $this->marshaler->marshalItem($normalized); + return $this->marshaler->marshalItem($normalizedEntity); } /** - * @param object|class-string $entity + * @template T of object + * @param T|class-string $entity * @param array $keyFieldValues * @return array * @throws ReflectionException @@ -43,23 +46,24 @@ public function serialize(object $entity, bool $includePrimaryKey = true): array */ public function serializePrimaryKey(object|string $entity, array $keyFieldValues = []): array { - $normalized = $this->entityNormalizer->normalizePrimaryKey($entity, $keyFieldValues); + $normalizedEntity = $this->entityNormalizer->normalizePrimaryKey($entity, $keyFieldValues); - return $this->marshaler->marshalItem($normalized); + return $this->marshaler->marshalItem($normalizedEntity); } /** - * @param array> $item - * @param class-string $class + * @template T of object + * @param array> $data + * @param class-string $class * @throws ExceptionInterface * @throws ReflectionException * @throws MetadataException */ - public function deserialize(array $item, string $class): object + public function deserialize(array $data, string $class): object { - /** @var array $normalized */ - $normalized = $this->marshaler->unmarshalItem($item); + /** @var array $normalizedData */ + $normalizedData = $this->marshaler->unmarshalItem($data); - return $this->entityDenormalizer->denormalize($normalized, $class); + return $this->entityDenormalizer->denormalize($normalizedData, $class); } } diff --git a/tests/Unit/Attribute/EntityTest.php b/tests/Unit/Attribute/EntityTest.php new file mode 100644 index 0000000..7977694 --- /dev/null +++ b/tests/Unit/Attribute/EntityTest.php @@ -0,0 +1,23 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/^Attribute argument .*Entity::table must not be empty/'); + + new Entity('', new PartitionKey(['id'])); + } +} diff --git a/tests/Unit/Attribute/PartitionKeyTest.php b/tests/Unit/Attribute/PartitionKeyTest.php new file mode 100644 index 0000000..b66f2be --- /dev/null +++ b/tests/Unit/Attribute/PartitionKeyTest.php @@ -0,0 +1,30 @@ + 'id', 'id', 'name']); + + $this->assertSame(['id', 'name'], $partitionKey->getFields()); + } + + #[Test] + public function itThrowsExceptionWhenNoFieldsAreProvided(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/^Attribute argument .*PartitionKey::fields must not be empty/'); + + new PartitionKey([]); + } +} diff --git a/tests/Unit/Attribute/SortKeyTest.php b/tests/Unit/Attribute/SortKeyTest.php new file mode 100644 index 0000000..69793ae --- /dev/null +++ b/tests/Unit/Attribute/SortKeyTest.php @@ -0,0 +1,30 @@ + 'id', 'id', 'name']); + + $this->assertSame(['id', 'name'], $partitionKey->getFields()); + } + + #[Test] + public function itThrowsExceptionWhenNoFieldsAreProvided(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/^Attribute argument .*SortKey::fields must not be empty/'); + + new SortKey([]); + } +} diff --git a/tests/Unit/Metadata/MetadataLoaderTest.php b/tests/Unit/Metadata/MetadataLoaderTest.php new file mode 100644 index 0000000..239016f --- /dev/null +++ b/tests/Unit/Metadata/MetadataLoaderTest.php @@ -0,0 +1,81 @@ +metadataLoader->getClassMetadata(ClassA::class); + + $this->assertTrue($metadata->has('id')); + $this->assertInstanceOf(ReflectionProperty::class, $metadata->get('id')); + + $this->assertTrue($metadata->has('name')); + $this->assertInstanceOf(ReflectionProperty::class, $metadata->get('name')); + } + + #[Test] + public function itReturnsEntityMetadata(): void + { + $metadata = $this->metadataLoader->getEntityMetadata(EntityA::class); + + $this->assertSame('tests', $metadata->getTable()); + $this->assertSame(['id'], $metadata->getPartitionKey()->getFields()); + $this->assertSame(['creationDate'], $metadata->getSortKey()->getFields()); + + $propertyAttributes = $metadata->getPropertyAttributes(); + $properties = array_keys($propertyAttributes); + $attributes = array_values($propertyAttributes); + + $this->assertSame(['id', 'name', 'creationDate', 'cardNumber'], $properties); + $this->assertInstanceOf(Attribute::class, $attributes[0]); + $this->assertInstanceOf(Attribute::class, $attributes[1]); + $this->assertInstanceOf(Attribute::class, $attributes[2]); + $this->assertInstanceOf(Attribute::class, $attributes[3]); + } + + #[Test] + public function itThrowsExceptionWhenEntityAttributeIsMissing(): void + { + $this->expectException(MetadataException::class); + $this->expectExceptionMessageMatches('/^No .*Entity attribute declared for class .*/'); + + $this->metadataLoader->getEntityMetadata(ClassA::class); + } + + #[Test] + public function itCachesMetadata(): void + { + $classMetadata1 = $this->metadataLoader->getClassMetadata(ClassA::class); + $classMetadata2 = $this->metadataLoader->getClassMetadata(ClassA::class); + + $this->assertSame($classMetadata2, $classMetadata1); + + $entityMetadata1 = $this->metadataLoader->getEntityMetadata(EntityA::class); + $entityMetadata2 = $this->metadataLoader->getEntityMetadata(EntityA::class); + + $this->assertSame($entityMetadata2, $entityMetadata1); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->metadataLoader = new MetadataLoader(); + } +} diff --git a/tests/Unit/Stubs/ClassA.php b/tests/Unit/Stubs/ClassA.php new file mode 100644 index 0000000..74fd5bf --- /dev/null +++ b/tests/Unit/Stubs/ClassA.php @@ -0,0 +1,12 @@ +cardNumber; + } +} From c688e8916030543240f369eb073f7a42e7d70730 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Mon, 5 May 2025 23:45:46 +0200 Subject: [PATCH 08/11] feat: implement integration tests; improve overall logic; --- src/ODM/AbstractBuilder.php | 114 ----- src/ODM/AbstractOpArgs.php | 110 +++++ src/ODM/EntityManager.php | 27 +- src/ODM/EntityManagerFactory.php | 7 +- src/ODM/OpArgsBuilder.php | 51 +++ ...ilderException.php => OpArgsException.php} | 2 +- src/ODM/OpEnum.php | 11 + src/ODM/QueryArgs.php | 35 ++ src/ODM/QueryBuilder.php | 42 -- src/ODM/ResultStream.php | 8 +- src/ODM/ScanArgs.php | 22 + src/ODM/ScanBuilder.php | 30 -- src/Serializer/EntityDenormalizer.php | 2 +- src/Serializer/EntitySerializer.php | 5 +- tests/Integration/ODM/EntityManagerTest.php | 389 ++++++++++++++++++ tests/Integration/Stubs/EntityA.php | 43 ++ tests/Integration/Stubs/EntityB.php | 19 + tests/Integration/Stubs/EnumA.php | 12 + tests/Unit/Attribute/PartitionKeyTest.php | 2 +- tests/Unit/Attribute/SortKeyTest.php | 2 +- tests/Unit/Metadata/MetadataLoaderTest.php | 2 +- tests/Unit/Stubs/EntityA.php | 2 +- 22 files changed, 723 insertions(+), 214 deletions(-) delete mode 100644 src/ODM/AbstractBuilder.php create mode 100644 src/ODM/AbstractOpArgs.php create mode 100644 src/ODM/OpArgsBuilder.php rename src/ODM/{BuilderException.php => OpArgsException.php} (69%) create mode 100644 src/ODM/OpEnum.php create mode 100644 src/ODM/QueryArgs.php delete mode 100644 src/ODM/QueryBuilder.php create mode 100644 src/ODM/ScanArgs.php delete mode 100644 src/ODM/ScanBuilder.php create mode 100644 tests/Integration/ODM/EntityManagerTest.php create mode 100644 tests/Integration/Stubs/EntityA.php create mode 100644 tests/Integration/Stubs/EntityB.php create mode 100644 tests/Integration/Stubs/EnumA.php diff --git a/src/ODM/AbstractBuilder.php b/src/ODM/AbstractBuilder.php deleted file mode 100644 index 48daec9..0000000 --- a/src/ODM/AbstractBuilder.php +++ /dev/null @@ -1,114 +0,0 @@ - */ - protected array $parameters = []; - - public function __construct() - { - $this->marshaler = new Marshaler(); - } - - public function filterExpression(string $expression): self - { - $this->parameters['FilterExpression'] = $expression; - - return $this; - } - - public function projectionExpression(string $expression): self - { - $this->parameters['ProjectionExpression'] = $expression; - - return $this; - } - - /** - * @param array $names - */ - public function expressionAttributeNames(array $names): self - { - $this->parameters['ExpressionAttributeNames'] = $names; - - return $this; - } - - /** - * @param array $values - */ - public function expressionAttributeValues(array $values): self - { - $this->parameters['ExpressionAttributeValues'] = $this->serialize($values); - - return $this; - } - - public function limit(int $limit): self - { - if (0 < $limit) { - $this->parameters['Limit'] = $limit; - } - - return $this; - } - - public function select(string $select): self - { - $this->parameters['Select'] = $select; - - return $this; - } - - public function consistentRead(bool $value = true): self - { - $this->parameters['ConsistentRead'] = $value; - - return $this; - } - - public function returnConsumedCapacity(string $value): self - { - $this->parameters['ReturnConsumedCapacity'] = $value; - - return $this; - } - - /** - * @param array $startKey - */ - public function exclusiveStartKey(array $startKey): self - { - $this->parameters['ExclusiveStartKey'] = $startKey; - - return $this; - } - - /** - * @return array - */ - abstract public function build(): array; - - /** - * @param array $values - * @return array> - */ - protected function serialize(array $values): array - { - $marshaled = []; - - foreach ($values as $key => $value) { - $marshaled[$key] = $this->marshaler->marshalValue($value); - } - - return $marshaled; - } -} diff --git a/src/ODM/AbstractOpArgs.php b/src/ODM/AbstractOpArgs.php new file mode 100644 index 0000000..1ccb13f --- /dev/null +++ b/src/ODM/AbstractOpArgs.php @@ -0,0 +1,110 @@ + */ + protected array $args = [], + ) { + } + + /** + * @return array + */ + public function get(): array + { + return $this->args; + } + + public function tableName(string $table): static + { + $this->args['TableName'] = $table; + + return $this; + } + + public function indexName(string $index): static + { + $this->args['IndexName'] = $index; + + return $this; + } + + public function filterExpression(string $expression): static + { + $this->args['FilterExpression'] = $expression; + + return $this; + } + + public function projectionExpression(string $expression): static + { + $this->args['ProjectionExpression'] = $expression; + + return $this; + } + + /** + * @param array $names + */ + public function expressionAttributeNames(array $names): static + { + $this->args['ExpressionAttributeNames'] = $names; + + return $this; + } + + /** + * @param array $values + */ + public function expressionAttributeValues(array $values): static + { + $this->args['ExpressionAttributeValues'] = $values; + + return $this; + } + + public function limit(int $limit): static + { + if (0 < $limit) { + $this->args['Limit'] = $limit; + } + + return $this; + } + + public function select(string $select): static + { + $this->args['Select'] = $select; + + return $this; + } + + public function consistentRead(bool $value = true): static + { + $this->args['ConsistentRead'] = $value; + + return $this; + } + + public function returnConsumedCapacity(string $value): static + { + $this->args['ReturnConsumedCapacity'] = $value; + + return $this; + } + + /** + * @param array $startKey + */ + public function exclusiveStartKey(array $startKey): static + { + $this->args['ExclusiveStartKey'] = $startKey; + + return $this; + } +} diff --git a/src/ODM/EntityManager.php b/src/ODM/EntityManager.php index cc15205..4abc645 100644 --- a/src/ODM/EntityManager.php +++ b/src/ODM/EntityManager.php @@ -16,6 +16,7 @@ public function __construct( protected DynamoDbClient $dynamoDbClient, protected MetadataLoader $metadataLoader, protected EntitySerializer $entitySerializer, + protected OpArgsBuilder $opArgsBuilder, ) { } @@ -55,12 +56,12 @@ public function get(string $class, array $keyFieldValues): ?object * @return T|null * @throws EntityManagerException */ - public function queryOne(string $class, QueryBuilder $queryBuilder): ?object + public function queryOne(string $class, QueryArgs $queryBuilder): ?object { $queryBuilder->limit(1); - /** @var array $result */ - $result = $this->query($class, $queryBuilder)->getItems(true); + /** @var array $result */ + $result = $this->query($class, $queryBuilder)->getResult(true); return $result[0] ?? null; } @@ -71,14 +72,14 @@ public function queryOne(string $class, QueryBuilder $queryBuilder): ?object * @return ResultStream * @throws EntityManagerException */ - public function query(string $class, QueryBuilder $queryBuilder): ResultStream + public function query(string $class, QueryArgs $queryArgs): ResultStream { - $items = (function () use ($class, $queryBuilder): Generator { + $result = (function () use ($class, $queryArgs): Generator { try { $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + $queryArgs->tableName($table); - $params = $queryBuilder->build(); - $params['TableName'] = $table; + $params = $this->opArgsBuilder->serialize($queryArgs); $remainingLimit = $params['Limit'] ?? null; do { @@ -107,7 +108,7 @@ public function query(string $class, QueryBuilder $queryBuilder): ResultStream } })(); - return new ResultStream($items); + return new ResultStream($result); } /** @@ -116,14 +117,14 @@ public function query(string $class, QueryBuilder $queryBuilder): ResultStream * @return ResultStream * @throws EntityManagerException */ - public function scan(string $class, ScanBuilder $scanBuilder): ResultStream + public function scan(string $class, ScanArgs $scanArgs): ResultStream { - $items = (function () use ($class, $scanBuilder): Generator { + $result = (function () use ($class, $scanArgs): Generator { try { $table = $this->metadataLoader->getEntityMetadata($class)->getTable(); + $scanArgs->tableName($table); - $params = $scanBuilder->build(); - $params['TableName'] = $table; + $params = $this->opArgsBuilder->serialize($scanArgs); $remainingLimit = $params['Limit'] ?? null; do { @@ -152,7 +153,7 @@ public function scan(string $class, ScanBuilder $scanBuilder): ResultStream } })(); - return new ResultStream($items); + return new ResultStream($result); } /** diff --git a/src/ODM/EntityManagerFactory.php b/src/ODM/EntityManagerFactory.php index c6325eb..c408958 100644 --- a/src/ODM/EntityManagerFactory.php +++ b/src/ODM/EntityManagerFactory.php @@ -5,6 +5,7 @@ namespace EduardoMarques\DynamoPHP\ODM; use Aws\DynamoDb\DynamoDbClient; +use Aws\DynamoDb\Marshaler; use EduardoMarques\DynamoPHP\Metadata\MetadataLoader; use EduardoMarques\DynamoPHP\Serializer\EntityDenormalizer; use EduardoMarques\DynamoPHP\Serializer\EntityNormalizer; @@ -36,10 +37,12 @@ public static function create(array $dbClientOptions, array $options = []): Enti ), ]); + $marshaler = new Marshaler(); $normalizer = new EntityNormalizer($metadataLoader, $serializer); $denormalizer = new EntityDenormalizer($metadataLoader, $serializer); - $entitySerializer = new EntitySerializer($normalizer, $denormalizer); + $entitySerializer = new EntitySerializer($normalizer, $denormalizer, $marshaler); + $opArgsBuilder = new OpArgsBuilder($serializer, $marshaler); - return new EntityManager($dynamoDbClient, $metadataLoader, $entitySerializer); + return new EntityManager($dynamoDbClient, $metadataLoader, $entitySerializer, $opArgsBuilder); } } diff --git a/src/ODM/OpArgsBuilder.php b/src/ODM/OpArgsBuilder.php new file mode 100644 index 0000000..97c1b97 --- /dev/null +++ b/src/ODM/OpArgsBuilder.php @@ -0,0 +1,51 @@ + $args + */ + public function create(OpEnum $op, array $args = []): AbstractOpArgs + { + return match ($op) { + OpEnum::QUERY => new QueryArgs($args), + OpEnum::SCAN => new ScanArgs($args), + }; + } + + /** + * @return array + * @throws ExceptionInterface + */ + public function serialize(AbstractOpArgs $args): array + { + /** @var array $normalizedArgs */ + $normalizedArgs = $this->normalizer->normalize($args->get()); + + $serializedArgs = []; + + foreach ($normalizedArgs as $arg => $value) { + if (in_array($arg, ['ExpressionAttributeValues', 'ExclusiveStartKey'])) { + $value = $this->marshaler->marshalItem($value); + } + + $serializedArgs[$arg] = $value; + } + + return $serializedArgs; + } +} diff --git a/src/ODM/BuilderException.php b/src/ODM/OpArgsException.php similarity index 69% rename from src/ODM/BuilderException.php rename to src/ODM/OpArgsException.php index bcd3726..1399d15 100644 --- a/src/ODM/BuilderException.php +++ b/src/ODM/OpArgsException.php @@ -6,6 +6,6 @@ use Exception; -class BuilderException extends Exception +class OpArgsException extends Exception { } diff --git a/src/ODM/OpEnum.php b/src/ODM/OpEnum.php new file mode 100644 index 0000000..e0b346e --- /dev/null +++ b/src/ODM/OpEnum.php @@ -0,0 +1,11 @@ +args['KeyConditionExpression'] = $expression; + + return $this; + } + + public function scanIndexForward(bool $asc = true): static + { + $this->args['ScanIndexForward'] = $asc; + + return $this; + } + + /** + * @inheritdoc + * @throws OpArgsException + */ + public function get(): array + { + if (!isset($this->args['KeyConditionExpression'])) { + throw new OpArgsException('KeyConditionExpression is required for query operations.'); + } + + return parent::get(); + } +} diff --git a/src/ODM/QueryBuilder.php b/src/ODM/QueryBuilder.php deleted file mode 100644 index e52ee3b..0000000 --- a/src/ODM/QueryBuilder.php +++ /dev/null @@ -1,42 +0,0 @@ -parameters['IndexName'] = $index; - - return $this; - } - - public function keyConditionExpression(string $expression): self - { - $this->parameters['KeyConditionExpression'] = $expression; - - return $this; - } - - public function scanIndexForward(bool $asc = true): self - { - $this->parameters['ScanIndexForward'] = $asc; - - return $this; - } - - /** - * @inheritdoc - * @throws BuilderException - */ - public function build(): array - { - if (!isset($this->parameters['KeyConditionExpression'])) { - throw new BuilderException('KeyConditionExpression is required for query operations.'); - } - - return $this->parameters; - } -} diff --git a/src/ODM/ResultStream.php b/src/ODM/ResultStream.php index cde1cdd..a80b620 100644 --- a/src/ODM/ResultStream.php +++ b/src/ODM/ResultStream.php @@ -12,18 +12,18 @@ readonly class ResultStream { /** - * @param Generator $items + * @param Generator $result */ public function __construct( - protected Generator $items, + protected Generator $result, ) { } /** * @return Generator|array */ - public function getItems(bool $asArray = false): iterable + public function getResult(bool $asArray = false): iterable { - return $asArray ? iterator_to_array($this->items) : $this->items; + return $asArray ? iterator_to_array($this->result) : $this->result; } } diff --git a/src/ODM/ScanArgs.php b/src/ODM/ScanArgs.php new file mode 100644 index 0000000..6741fa5 --- /dev/null +++ b/src/ODM/ScanArgs.php @@ -0,0 +1,22 @@ +args['Segment'] = $segment; + + return $this; + } + + public function totalSegments(int $totalSegments): static + { + $this->args['TotalSegments'] = $totalSegments; + + return $this; + } +} diff --git a/src/ODM/ScanBuilder.php b/src/ODM/ScanBuilder.php deleted file mode 100644 index 3121c8b..0000000 --- a/src/ODM/ScanBuilder.php +++ /dev/null @@ -1,30 +0,0 @@ -parameters['Segment'] = $segment; - - return $this; - } - - public function totalSegments(int $totalSegments): self - { - $this->parameters['TotalSegments'] = $totalSegments; - - return $this; - } - - /** - * @inheritdoc - */ - public function build(): array - { - return $this->parameters; - } -} diff --git a/src/Serializer/EntityDenormalizer.php b/src/Serializer/EntityDenormalizer.php index 3d5b213..852d6a1 100644 --- a/src/Serializer/EntityDenormalizer.php +++ b/src/Serializer/EntityDenormalizer.php @@ -21,7 +21,7 @@ public function __construct( /** * @template T of object * @param array $data - * @param class-string $class + * @param class-string $class * @return T * @throws ExceptionInterface * @throws ReflectionException diff --git a/src/Serializer/EntitySerializer.php b/src/Serializer/EntitySerializer.php index 9f56e82..3764e2a 100644 --- a/src/Serializer/EntitySerializer.php +++ b/src/Serializer/EntitySerializer.php @@ -11,13 +11,11 @@ readonly class EntitySerializer { - protected Marshaler $marshaler; - public function __construct( protected EntityNormalizer $entityNormalizer, protected EntityDenormalizer $entityDenormalizer, + protected Marshaler $marshaler, ) { - $this->marshaler = new Marshaler(); } /** @@ -55,6 +53,7 @@ public function serializePrimaryKey(object|string $entity, array $keyFieldValues * @template T of object * @param array> $data * @param class-string $class + * @return T * @throws ExceptionInterface * @throws ReflectionException * @throws MetadataException diff --git a/tests/Integration/ODM/EntityManagerTest.php b/tests/Integration/ODM/EntityManagerTest.php new file mode 100644 index 0000000..4d7fd84 --- /dev/null +++ b/tests/Integration/ODM/EntityManagerTest.php @@ -0,0 +1,389 @@ + 'eu-central-1', + 'endpoint' => 'http://localstack:4566', + 'credentials' => ['key' => 'key', 'secret' => 'secret'], + ]); + } + + #[Test] + public function itGetsEntity(): void + { + $id = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate = new DateTime(); + $entity = new EntityA($id); + $entity->name = 'John Doe'; + $entity->creationDate = $creationDate; + + self::$entityManager->put($entity); + + $persistedEntity = self::$entityManager->get(EntityA::class, ['id' => $id, 'creationDate' => $creationDate]); + + $this->assertEquals($entity, $persistedEntity); + + self::$entityManager->delete($entity); + } + + #[Test] + public function itReturnsNullWhenEntityIsNotFoundWhileGetting(): void + { + $id = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate = new DateTime(); + + $persistedEntity = self::$entityManager->get(EntityA::class, ['id' => $id, 'creationDate' => $creationDate]); + + $this->assertNull($persistedEntity); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToGetEntity(): void + { + $id = '8798b91f-fe8e-498c-8145-c757029346ef'; + + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + self::$entityManager->get(EntityA::class, ['id' => $id]); + } + + #[Test] + public function itQueriesEntities(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + $id3 = $id1; + $creationDate3 = new DateTime('2025-01-04'); + $entity3 = new EntityA($id3); + $entity3->name = 'John Doe Jr.'; + $entity3->creationDate = $creationDate3; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + self::$entityManager->put($entity3); + + $queryArgs = (new QueryArgs()) + ->keyConditionExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id1, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->query(EntityA::class, $queryArgs); + + $this->assertEquals([$entity1, $entity3], $result->getResult(true)); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + self::$entityManager->delete($entity3); + } + + #[Test] + public function itQueriesEntitiesWithLimit(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + $id3 = $id1; + $creationDate3 = new DateTime('2025-01-04'); + $entity3 = new EntityA($id3); + $entity3->name = 'John Doe Jr.'; + $entity3->creationDate = $creationDate3; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + self::$entityManager->put($entity3); + + $queryArgs = (new QueryArgs()) + ->limit(1) + ->keyConditionExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id1, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->query(EntityA::class, $queryArgs); + + $this->assertEquals([$entity1], $result->getResult(true)); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + self::$entityManager->delete($entity3); + } + + #[Test] + public function itQueriesOneEntity(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + + $queryArgs = (new QueryArgs()) + ->limit(1) + ->keyConditionExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id1, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->queryOne(EntityA::class, $queryArgs); + + $this->assertEquals($entity1, $result); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + } + + #[Test] + public function itReturnsNullWhenEntityIsNotFoundWhileQuerying(): void + { + $id = '8798b91f-fe8e-498c-8145-c757029346ef'; + + $queryArgs = (new QueryArgs()) + ->limit(1) + ->keyConditionExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->queryOne(EntityA::class, $queryArgs); + + $this->assertNull($result); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToQueryEntities(): void + { + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + $queryArgs = new QueryArgs(); + + self::$entityManager->query(EntityA::class, $queryArgs)->getResult(true); + } + + #[Test] + public function itScansAllEntities(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + $id3 = $id1; + $creationDate3 = new DateTime('2025-01-04'); + $entity3 = new EntityA($id3); + $entity3->name = 'John Doe Jr.'; + $entity3->creationDate = $creationDate3; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + self::$entityManager->put($entity3); + + $scanArgs = new ScanArgs(); + + $result = self::$entityManager->scan(EntityA::class, $scanArgs); + + $this->assertEquals([$entity2, $entity1, $entity3], $result->getResult(true)); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + self::$entityManager->delete($entity3); + } + + #[Test] + public function itScansEntities(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + $id3 = $id1; + $creationDate3 = new DateTime('2025-01-04'); + $entity3 = new EntityA($id3); + $entity3->name = 'John Doe Jr.'; + $entity3->creationDate = $creationDate3; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + self::$entityManager->put($entity3); + + $scanArgs = (new ScanArgs()) + ->filterExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id1, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->scan(EntityA::class, $scanArgs); + + $this->assertEquals([$entity1, $entity3], $result->getResult(true)); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + self::$entityManager->delete($entity3); + } + + #[Test] + public function itScansEntitiesWithLimit(): void + { + $id1 = '8798b91f-fe8e-498c-8145-c757029346ef'; + $creationDate1 = new DateTime('2025-01-02'); + $entity1 = new EntityA($id1); + $entity1->name = 'John Doe'; + $entity1->creationDate = $creationDate1; + + $id2 = '49fbcfb9-4fe7-4204-8001-29b826d903cb'; + $creationDate2 = new DateTime('2025-01-03'); + $entity2 = new EntityA($id2); + $entity2->name = 'Mary Jane'; + $entity2->creationDate = $creationDate2; + + self::$entityManager->put($entity1); + self::$entityManager->put($entity2); + + $scanArgs = (new ScanArgs()) + ->limit(1) + ->filterExpression('PK = :pk AND SK BETWEEN :start AND :end') + ->expressionAttributeValues([ + ':pk' => $id1, + ':start' => new DateTime('2025-01-01'), + ':end' => new DateTime('2025-01-10'), + ]); + + $result = self::$entityManager->scan(EntityA::class, $scanArgs); + + $this->assertEquals([$entity1], $result->getResult(true)); + + self::$entityManager->delete($entity1); + self::$entityManager->delete($entity2); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToScanEntities(): void + { + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + $scanArgs = (new ScanArgs())->filterExpression('invalid expression'); + + self::$entityManager->scan(EntityA::class, $scanArgs)->getResult(true); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToPutEntity(): void + { + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + $entity = new EntityB(); + $entity->id = 1; + + self::$entityManager->put($entity); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToDeleteEntity(): void + { + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + $entity = new EntityB(); + $entity->id = 1; + + self::$entityManager->delete($entity); + } + + #[Test] + public function itDescribesEntity(): void + { + /** @var array $description */ + $description = self::$entityManager->describe(EntityA::class)->getResult(true); + + $this->assertArrayHasKey('Table', $description); + $this->assertArrayHasKey('@metadata', $description); + } + + #[Test] + public function itWrapsExceptionThrownWhileTryingToDescribeEntity(): void + { + $this->expectException(EntityManagerException::class); + $this->expectExceptionMessageMatches('/^An error occurred\. .*Exception:.+/'); + + self::$entityManager->describe(EntityB::class)->getResult(true); + } +} diff --git a/tests/Integration/Stubs/EntityA.php b/tests/Integration/Stubs/EntityA.php new file mode 100644 index 0000000..66cc5bd --- /dev/null +++ b/tests/Integration/Stubs/EntityA.php @@ -0,0 +1,43 @@ +id; + } +} diff --git a/tests/Integration/Stubs/EntityB.php b/tests/Integration/Stubs/EntityB.php new file mode 100644 index 0000000..6a552db --- /dev/null +++ b/tests/Integration/Stubs/EntityB.php @@ -0,0 +1,19 @@ + 'id', 'id', 'name']); + $partitionKey = new PartitionKey(['id', 'id', 'name']); $this->assertSame(['id', 'name'], $partitionKey->getFields()); } diff --git a/tests/Unit/Attribute/SortKeyTest.php b/tests/Unit/Attribute/SortKeyTest.php index 69793ae..fe127f1 100644 --- a/tests/Unit/Attribute/SortKeyTest.php +++ b/tests/Unit/Attribute/SortKeyTest.php @@ -14,7 +14,7 @@ final class SortKeyTest extends TestCase #[Test] public function itReturnsFieldsSanitized(): void { - $partitionKey = new SortKey(['id' => 'id', 'id', 'name']); + $partitionKey = new SortKey(['id', 'id', 'name']); $this->assertSame(['id', 'name'], $partitionKey->getFields()); } diff --git a/tests/Unit/Metadata/MetadataLoaderTest.php b/tests/Unit/Metadata/MetadataLoaderTest.php index 239016f..b4da80b 100644 --- a/tests/Unit/Metadata/MetadataLoaderTest.php +++ b/tests/Unit/Metadata/MetadataLoaderTest.php @@ -36,7 +36,7 @@ public function itReturnsEntityMetadata(): void $this->assertSame('tests', $metadata->getTable()); $this->assertSame(['id'], $metadata->getPartitionKey()->getFields()); - $this->assertSame(['creationDate'], $metadata->getSortKey()->getFields()); + $this->assertSame(['creationDate'], $metadata->getSortKey()?->getFields()); $propertyAttributes = $metadata->getPropertyAttributes(); $properties = array_keys($propertyAttributes); diff --git a/tests/Unit/Stubs/EntityA.php b/tests/Unit/Stubs/EntityA.php index 4bde666..4c17e0b 100644 --- a/tests/Unit/Stubs/EntityA.php +++ b/tests/Unit/Stubs/EntityA.php @@ -34,7 +34,7 @@ final class EntityA public function __construct( #[Attribute] - protected string $cardNumber + protected string $cardNumber, ) { } From 4669e78b6ec806f848c565719709f46182730da7 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Tue, 6 May 2025 00:04:33 +0200 Subject: [PATCH 09/11] feat: adjust CI script --- .github/workflows/base.yml | 16 ++++++++++++++-- .github/workflows/pr.yml | 6 ++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index a8908c7..b961ea3 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -14,12 +14,24 @@ jobs: - name: checkout repo uses: actions/checkout@v4 + - name: cache dependencies + uses: actions/cache@v4 + with: + path: ./vendor + key: ${{ github.sha }} + + - name: validate dependencies + run: make composer c=validate + - name: build app run: make start - name: install dependencies run: make composer c=install + - name: audit dependencies + run: make composer c=audit + - name: run lint run: make lint @@ -27,7 +39,7 @@ jobs: run: make test-cov - name: upload coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: - files: coverage/clover/clover.xml + token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ea7c24e..fea9530 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -12,6 +12,12 @@ jobs: - name: checkout repo uses: actions/checkout@v4 + - name: cache vendor folder + uses: actions/cache@v4 + with: + path: ./vendor + key: ${{ github.sha }} + - name: build app run: make start From ec4d97ecced22ba958b6e035466725b33d181728 Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Tue, 6 May 2025 00:06:10 +0200 Subject: [PATCH 10/11] feat: fix CI script --- .github/workflows/base.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index b961ea3..2cde156 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -20,12 +20,12 @@ jobs: path: ./vendor key: ${{ github.sha }} - - name: validate dependencies - run: make composer c=validate - - name: build app run: make start + - name: validate dependencies + run: make composer c=validate + - name: install dependencies run: make composer c=install From 74c6baf3caef619c756010b7720ef8c3fc315feb Mon Sep 17 00:00:00 2001 From: Eduardo Marques Date: Tue, 6 May 2025 00:27:49 +0200 Subject: [PATCH 11/11] feat: improve CI script --- .github/workflows/base.yml | 8 ++++++++ Dockerfile | 2 ++ Makefile | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index 2cde156..f2e5617 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -43,3 +43,11 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true + + - name: upload test results + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + file: ./coverage/junit.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true diff --git a/Dockerfile b/Dockerfile index 2351842..b4e8479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,6 @@ RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers \ COPY --from=composer /usr/bin/composer /usr/bin/composer +RUN git config --global --add safe.directory /app + CMD ["tail", "-f", "/dev/null"] diff --git a/Makefile b/Makefile index 643d974..a9bf523 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ test-cov-integration: ## Run integration tests and generate coverage report @$(DOCKER_COMPOSE) exec -e XDEBUG_MODE=coverage app vendor/bin/phpunit --testsuite=integration --testdox --coverage-clover coverage/clover/clover.xml --coverage-html coverage/html test-cov: ## Run all tests and generate coverage report - @$(DOCKER_COMPOSE) exec -e XDEBUG_MODE=coverage app vendor/bin/phpunit --testdox --coverage-clover coverage/clover/clover.xml --coverage-html coverage/html + @$(DOCKER_COMPOSE) exec -e XDEBUG_MODE=coverage app vendor/bin/phpunit --testdox --coverage-clover coverage/clover/clover.xml --coverage-html coverage/html --log-junit coverage/junit.xml cov-unit: test-cov-unit cov-report ## Generate and open unit test coverage report