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
diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml
index a8908c7..f2e5617 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: build app
run: make start
+ - name: validate dependencies
+ run: make composer c=validate
+
- 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,15 @@ jobs:
run: make test-cov
- name: upload coverage
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ fail_ci_if_error: true
+
+ - name: upload test results
+ if: ${{ !cancelled() }}
+ uses: codecov/test-results-action@v1
with:
- files: coverage/clover/clover.xml
+ file: ./coverage/junit.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
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/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.
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
diff --git a/composer.json b/composer.json
index 8bb26da..4d8c22e 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": "^8.18",
+ "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..e8130f0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,9 +4,1694 @@
"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": "ff6ed7fbeda99e86f2aa6600614ba47c",
+ "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.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/aws/aws-sdk-php.git",
+ "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979",
+ "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979",
+ "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.3"
+ },
+ "time": "2025-05-02T18:04:58+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.32.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.32.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.32.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.32.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.32.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.32.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.32.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "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.32.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-12-23T08:48:59+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.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/serializer.git",
+ "reference": "be549655b034edc1a16ed23d8164aa04318c5ec1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/serializer/zipball/be549655b034edc1a16ed23d8164aa04318c5ec1",
+ "reference": "be549655b034edc1a16ed23d8164aa04318c5ec1",
+ "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.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-27T13:34:41+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.2.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
+ "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
+ "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.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-20T20:18:16+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": "v1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/composer-installer.git",
+ "reference": "4be43904336affa5c2f70744a348312336afd0da"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/4be43904336affa5c2f70744a348312336afd0da",
+ "reference": "4be43904336affa5c2f70744a348312336afd0da",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^1.0 || ^2.0",
+ "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",
+ "yoast/phpunit-polyfills": "^1.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPCSStandards\\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/PHPCSStandards/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/PHPCSStandards/composer-installer/issues",
+ "source": "https://github.com/PHPCSStandards/composer-installer"
+ },
+ "time": "2023-01-05T11:28:13+00:00"
+ },
{
"name": "myclabs/deep-copy",
"version": "1.13.1",
@@ -243,18 +1928,65 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
+ "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "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",
+ "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/2.1.0"
+ },
+ "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": {
@@ -299,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.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "05c33d01a856f9f62488d144bafddc3d7b7a4ebb"
+ "reference": "448f2c504d86dbff3949dcd02c95aa85db2c7617"
},
"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/448f2c504d86dbff3949dcd02c95aa85db2c7617",
+ "reference": "448f2c504d86dbff3949dcd02c95aa85db2c7617",
"shasum": ""
},
"require": {
@@ -330,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",
@@ -339,7 +2071,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "12.1.x-dev"
+ "dev-main": "12.2.x-dev"
}
},
"autoload": {
@@ -368,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.1"
},
"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-04T05:25:05+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -625,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": {
@@ -644,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",
@@ -702,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": [
{
@@ -726,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",
@@ -1541,6 +3285,71 @@
],
"time": "2025-02-07T05:00:38+00:00"
},
+ {
+ "name": "slevomat/coding-standard",
+ "version": "8.18.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/slevomat/coding-standard.git",
+ "reference": "f3b23cb9b26301b8c3c7bb03035a1bee23974593"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/f3b23cb9b26301b8c3c7bb03035a1bee23974593",
+ "reference": "f3b23cb9b26301b8c3c7bb03035a1bee23974593",
+ "shasum": ""
+ },
+ "require": {
+ "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": "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": "8.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.",
+ "keywords": [
+ "dev",
+ "phpcs"
+ ],
+ "support": {
+ "issues": "https://github.com/slevomat/coding-standard/issues",
+ "source": "https://github.com/slevomat/coding-standard/tree/8.18.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/kukulich",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-05-01T09:40:50+00:00"
+ },
{
"name": "squizlabs/php_codesniffer",
"version": "3.12.2",
@@ -1677,6 +3486,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 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 47cc539..0000000
--- a/phpcs.xml.dist
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- src/
- tests/Unit
- tests/Integration
-
-
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 88%
rename from phpunit.xml.dist
rename to phpunit.xml
index 80c506e..2f0301e 100644
--- a/phpunit.xml.dist
+++ 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/Attribute.php b/src/Attribute/Attribute.php
new file mode 100644
index 0000000..d49db17
--- /dev/null
+++ b/src/Attribute/Attribute.php
@@ -0,0 +1,16 @@
+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..1efc776
--- /dev/null
+++ b/src/Attribute/InvalidArgumentException.php
@@ -0,0 +1,11 @@
+ $fields
+ */
+ 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
new file mode 100644
index 0000000..e80667d
--- /dev/null
+++ b/src/Attribute/SortKey.php
@@ -0,0 +1,20 @@
+ $fields
+ */
+ 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
new file mode 100644
index 0000000..243c57e
--- /dev/null
+++ b/src/Metadata/ClassMetadata.php
@@ -0,0 +1,26 @@
+ */
+ protected array $properties,
+ ) {
+ }
+
+ public function has(string $property): bool
+ {
+ return isset($this->properties[$property]);
+ }
+
+ public function get(string $property): ?ReflectionProperty
+ {
+ return $this->properties[$property] ?? null;
+ }
+}
diff --git a/src/Metadata/EntityMetadata.php b/src/Metadata/EntityMetadata.php
new file mode 100644
index 0000000..dd5ced7
--- /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(): AbstractKey
+ {
+ return $this->entityAttribute->partitionKey;
+ }
+
+ public function getSortKey(): ?AbstractKey
+ {
+ 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..d961807
--- /dev/null
+++ b/src/Metadata/MetadataException.php
@@ -0,0 +1,11 @@
+> */
+ private array $cache = [];
+
+ /**
+ * @template T of object
+ * @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;
+ }
+
+ /**
+ * @template T of object
+ * @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;
+ }
+
+ /**
+ * @template T of object
+ * @param ReflectionClass $reflection
+ * @return array
+ */
+ private function getClassProperties(ReflectionClass $reflection): array
+ {
+ $properties = [];
+
+ foreach ($reflection->getProperties() as $property) {
+ $properties[$property->getName()] = $property;
+ }
+
+ return $properties;
+ }
+
+ /**
+ * @template T of object
+ * @param ReflectionClass $reflection
+ * @return array
+ */
+ private function getClassAttributes(ReflectionClass $reflection): array
+ {
+ $attributes = [];
+
+ foreach ($reflection->getAttributes() as $attribute) {
+ $instance = $attribute->newInstance();
+ $attributes[$instance::class] = $instance;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * @template T of object
+ * @param ReflectionClass $reflection
+ * @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(
+ static fn(ReflectionAttribute $attribute): object => $attribute->newInstance(),
+ $propertyAttributes,
+ );
+ }
+ }
+
+ return $attributes;
+ }
+}
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
new file mode 100644
index 0000000..4abc645
--- /dev/null
+++ b/src/ODM/EntityManager.php
@@ -0,0 +1,227 @@
+ $class
+ * @param array $keyFieldValues
+ * @return T|null
+ * @throws EntityManagerException
+ */
+ public function get(string $class, array $keyFieldValues): ?object
+ {
+ try {
+ $key = $this->entitySerializer->serializePrimaryKey($class, $keyFieldValues);
+ $table = $this->metadataLoader->getEntityMetadata($class)->getTable();
+
+ $result = $this->dynamoDbClient->getItem([
+ 'TableName' => $table,
+ 'Key' => $key,
+ ]);
+
+ $rawItem = $result['Item'] ?? null;
+
+ if (null === $rawItem) {
+ return null;
+ }
+
+ return $this->entitySerializer->deserialize($rawItem, $class);
+ } catch (Throwable $exception) {
+ $this->wrapException($exception);
+ }
+ }
+
+ /**
+ * @template T of object
+ * @param class-string $class
+ * @return T|null
+ * @throws EntityManagerException
+ */
+ public function queryOne(string $class, QueryArgs $queryBuilder): ?object
+ {
+ $queryBuilder->limit(1);
+
+ /** @var array $result */
+ $result = $this->query($class, $queryBuilder)->getResult(true);
+
+ return $result[0] ?? null;
+ }
+
+ /**
+ * @template T of object
+ * @param class-string $class
+ * @return ResultStream
+ * @throws EntityManagerException
+ */
+ public function query(string $class, QueryArgs $queryArgs): ResultStream
+ {
+ $result = (function () use ($class, $queryArgs): Generator {
+ try {
+ $table = $this->metadataLoader->getEntityMetadata($class)->getTable();
+ $queryArgs->tableName($table);
+
+ $params = $this->opArgsBuilder->serialize($queryArgs);
+ $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($result);
+ }
+
+ /**
+ * @template T of object
+ * @param class-string $class
+ * @return ResultStream
+ * @throws EntityManagerException
+ */
+ public function scan(string $class, ScanArgs $scanArgs): ResultStream
+ {
+ $result = (function () use ($class, $scanArgs): Generator {
+ try {
+ $table = $this->metadataLoader->getEntityMetadata($class)->getTable();
+ $scanArgs->tableName($table);
+
+ $params = $this->opArgsBuilder->serialize($scanArgs);
+ $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($result);
+ }
+
+ /**
+ * @template T of object
+ * @param T $entity
+ * @throws EntityManagerException
+ */
+ 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,
+ ]);
+ } catch (Throwable $exception) {
+ $this->wrapException($exception);
+ }
+ }
+
+ /**
+ * @template T of object
+ * @param T $entity
+ * @throws EntityManagerException
+ */
+ 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,
+ ]);
+ } catch (Throwable $exception) {
+ $this->wrapException($exception);
+ }
+ }
+
+ /**
+ * @param class-string $class
+ * @return ResultStream
+ * @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): 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
new file mode 100644
index 0000000..9f6ff33
--- /dev/null
+++ b/src/ODM/EntityManagerException.php
@@ -0,0 +1,11 @@
+ $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.u\Z';
+
+ $dynamoDbClient = new DynamoDbClient($dbClientOptions);
+ $metadataLoader = new MetadataLoader();
+ $serializer = new Serializer([
+ new BackedEnumNormalizer(),
+ new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => $datetimeFormat]),
+ new ObjectNormalizer(
+ propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new ReflectionExtractor()]),
+ ),
+ ]);
+
+ $marshaler = new Marshaler();
+ $normalizer = new EntityNormalizer($metadataLoader, $serializer);
+ $denormalizer = new EntityDenormalizer($metadataLoader, $serializer);
+ $entitySerializer = new EntitySerializer($normalizer, $denormalizer, $marshaler);
+ $opArgsBuilder = new OpArgsBuilder($serializer, $marshaler);
+
+ 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/OpArgsException.php b/src/ODM/OpArgsException.php
new file mode 100644
index 0000000..1399d15
--- /dev/null
+++ b/src/ODM/OpArgsException.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/ResultStream.php b/src/ODM/ResultStream.php
new file mode 100644
index 0000000..a80b620
--- /dev/null
+++ b/src/ODM/ResultStream.php
@@ -0,0 +1,29 @@
+ $result
+ */
+ public function __construct(
+ protected Generator $result,
+ ) {
+ }
+
+ /**
+ * @return Generator|array
+ */
+ public function getResult(bool $asArray = false): iterable
+ {
+ 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/Serializer/EntityDenormalizer.php b/src/Serializer/EntityDenormalizer.php
new file mode 100644
index 0000000..852d6a1
--- /dev/null
+++ b/src/Serializer/EntityDenormalizer.php
@@ -0,0 +1,47 @@
+ $data
+ * @param class-string $class
+ * @return T
+ * @throws ExceptionInterface
+ * @throws ReflectionException
+ * @throws MetadataException
+ */
+ public function denormalize(array $data, string $class): object
+ {
+ $entityMetadata = $this->metadataLoader->getEntityMetadata($class);
+ $propertyAttributes = $entityMetadata->getPropertyAttributes();
+
+ $normalizedData = [];
+
+ foreach ($propertyAttributes as $prop => $attr) {
+ $value = $data[$attr->name ?: $prop] ?? null;
+
+ if (null !== $value) {
+ $normalizedData[$prop] = $value;
+ }
+ }
+
+ return $this->denormalizer->denormalize($normalizedData, $class);
+ }
+}
diff --git a/src/Serializer/EntityNormalizer.php b/src/Serializer/EntityNormalizer.php
new file mode 100644
index 0000000..c181ca8
--- /dev/null
+++ b/src/Serializer/EntityNormalizer.php
@@ -0,0 +1,375 @@
+
+ * @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),
+ ];
+ }
+
+ /**
+ * @template T of object
+ * @param T|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),
+ ];
+ }
+
+ /**
+ * @template T of object
+ * @param T|class-string $entity
+ * @param array $keyFieldValues
+ * @return array
+ * @throws ReflectionException
+ * @throws ExceptionInterface
+ * @throws MetadataException
+ */
+ protected function normalizePartitionKey(object|string $entity, array $keyFieldValues = []): array
+ {
+ $this->validateKeyArguments($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];
+ }
+
+ /**
+ * @template T of object
+ * @param T|class-string $entity
+ * @param array $keyFieldValues
+ * @return array
+ * @throws ReflectionException
+ * @throws ExceptionInterface
+ * @throws MetadataException
+ */
+ protected function normalizeSortKey(object|string $entity, array $keyFieldValues = []): array
+ {
+ $this->validateKeyArguments($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 empty($sortKeyName) || empty($sortKeyValue)
+ ? []
+ : [$sortKeyName => $sortKeyValue];
+ }
+
+ /**
+ * @template T of object
+ * @param T|class-string $entity
+ * @param array $keyFieldValues
+ */
+ protected function validateKeyArguments(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.');
+ }
+ }
+
+ /**
+ * @template T of object
+ * @param class-string $class
+ * @throws ReflectionException
+ * @throws MetadataException
+ */
+ protected function normalizePartitionKeyName(string $class): string
+ {
+ $entityMetadata = $this->metadataLoader->getEntityMetadata($class);
+
+ return $entityMetadata->getPartitionKey()->getName();
+ }
+
+ /**
+ * @template T of object
+ * @param class-string $class
+ * @throws ReflectionException
+ * @throws MetadataException
+ */
+ protected function normalizeSortKeyName(string $class): ?string
+ {
+ $entityMetadata = $this->metadataLoader->getEntityMetadata($class);
+
+ return $entityMetadata->getSortKey()?->getName();
+ }
+
+ /**
+ * @template T of object
+ * @param T $entity
+ * @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->has($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->get($field);
+ $propertyValue = $reflectionProperty->getValue($entity);
+
+ /** @var scalar $currentFieldValue */
+ $currentFieldValue = $this->normalizer->normalize($propertyValue);
+
+ $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue;
+ }
+
+ return $finalValue;
+ }
+
+ /**
+ * @template T of object
+ * @param T $entity
+ * @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->has($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->get($field);
+ $propertyValue = $reflectionProperty->getValue($entity);
+
+ /** @var scalar $currentFieldValue */
+ $currentFieldValue = $this->normalizer->normalize($propertyValue);
+
+ $finalValue .= empty($finalValue) ? $currentFieldValue : $delimiter . $currentFieldValue;
+ }
+
+ return $finalValue;
+ }
+
+ /**
+ * @template T of object
+ * @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(array_flip($definedFields), $valuesByFieldSorted));
+
+ 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->has($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;
+ }
+
+ /**
+ * @template T of object
+ * @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];
+ }
+ }
+
+ $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->has($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;
+ }
+
+ /**
+ * @template T of object
+ * @param T $entity
+ * @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->get($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
new file mode 100644
index 0000000..3764e2a
--- /dev/null
+++ b/src/Serializer/EntitySerializer.php
@@ -0,0 +1,68 @@
+>
+ * @throws ExceptionInterface
+ * @throws MetadataException
+ * @throws ReflectionException
+ */
+ public function serialize(object $entity, bool $includePrimaryKey = true): array
+ {
+ $normalizedEntity = $this->entityNormalizer->normalize($entity, $includePrimaryKey);
+
+ return $this->marshaler->marshalItem($normalizedEntity);
+ }
+
+ /**
+ * @template T of object
+ * @param T|class-string $entity
+ * @param array $keyFieldValues
+ * @return array
+ * @throws ReflectionException
+ * @throws ExceptionInterface
+ * @throws MetadataException
+ */
+ public function serializePrimaryKey(object|string $entity, array $keyFieldValues = []): array
+ {
+ $normalizedEntity = $this->entityNormalizer->normalizePrimaryKey($entity, $keyFieldValues);
+
+ return $this->marshaler->marshalItem($normalizedEntity);
+ }
+
+ /**
+ * @template T of object
+ * @param array> $data
+ * @param class-string $class
+ * @return T
+ * @throws ExceptionInterface
+ * @throws ReflectionException
+ * @throws MetadataException
+ */
+ public function deserialize(array $data, string $class): object
+ {
+ /** @var array $normalizedData */
+ $normalizedData = $this->marshaler->unmarshalItem($data);
+
+ return $this->entityDenormalizer->denormalize($normalizedData, $class);
+ }
+}
diff --git a/src/Serializer/InvalidEntityException.php b/src/Serializer/InvalidEntityException.php
new file mode 100644
index 0000000..9263e0b
--- /dev/null
+++ b/src/Serializer/InvalidEntityException.php
@@ -0,0 +1,11 @@
+ '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 @@
+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..734417e
--- /dev/null
+++ b/tests/Unit/Attribute/PartitionKeyTest.php
@@ -0,0 +1,30 @@
+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..fe127f1
--- /dev/null
+++ b/tests/Unit/Attribute/SortKeyTest.php
@@ -0,0 +1,30 @@
+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..b4da80b
--- /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;
+ }
+}