diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 00000000..b6c8c9fb --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,48 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Check Docs" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + +jobs: + checkdocs: + name: "Check Docs" + + runs-on: ${{ matrix.operating-system }} + + strategy: + matrix: + dependencies: + - "locked" + php-version: + - "8.5" + operating-system: + - "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.37.2" + with: + coverage: none + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1, opcache.enable_cli=1 + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "extract php code" + run: "bin/docs-extract-php-code" + + - name: "lint php" + run: "php -l docs_php/*.php" + + - name: "docs code style" + run: "vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit" \ No newline at end of file diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000..03b44ed3 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,71 @@ +name: Publish docs + +on: + push: + branches: + - "[0-9]+.[0-9]+.x" + paths: + - "docs/**" + release: + types: + - published + +permissions: + contents: read + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Check whether this ref belongs to the latest release of its major version + id: check + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [ "$EVENT_NAME" = "release" ]; then + current="$RELEASE_TAG" + else + current="$REF_NAME" + fi + + major="${current%%.*}" + + latest_tag=$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 1000 \ + --json tagName \ + --jq '.[].tagName' \ + | grep -E "^${major}\.[0-9]+\.[0-9]+$" \ + | sort -V \ + | tail -n1) + latest_branch="${latest_tag%.*}.x" + + if [ "$EVENT_NAME" = "release" ]; then + expected="$latest_tag" + else + expected="$latest_branch" + fi + + echo "latest release of ${major}.x: ${latest_tag:-none} (branch ${latest_branch}), current: ${current}" + + if [ -n "$latest_tag" ] && [ "$current" = "$expected" ]; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "Skipping docs deployment: ${current} is not the latest release of ${major}.x." + fi + + - name: Trigger workflow in other repo + if: steps.check.outputs.deploy == 'true' + run: | + curl -L -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + https://api.github.com/repos/patchlevel/patchlevel.dev/actions/workflows/prod-deployment.yaml/dispatches \ + -d '{"ref":"main"}' diff --git a/.gitignore b/.gitignore index b0f6ffa0..a8264bba 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ phpstan.neon infection.log infection.html .phpbench/ +docs_php/ diff --git a/Makefile b/Makefile index 5840863f..faf64b56 100644 --- a/Makefile +++ b/Makefile @@ -44,5 +44,27 @@ benchmark-diff-test: vendor vendor/bin/phpbench run tests/Benchmark --revs=1 --report=diff --progress=none --ref=base +.PHONY: docs +docs: docs-extract-php docs-php-lint docs-phpcs docs-inject-php ## check and format docs code + +.PHONY: docs-extract-php +docs-extract-php: vendor + bin/docs-extract-php-code + +.PHONY: docs-inject-php +docs-inject-php: vendor + bin/docs-inject-php-code + +.PHONY: docs-format +docs-format: docs-phpcs docs-inject-php ## format docs + +.PHONY: docs-php-lint +docs-php-lint: docs-extract-php ## lint docs code + php -l docs_php/*.php | grep -E 'Parse error|Fatal error' || true + +.PHONY: docs-phpcs +docs-phpcs: docs-extract-php + vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes,SlevomatCodingStandard.ControlStructures.EarlyExit || true + .PHONY: dev dev: static test ## run dev tools diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md index cb3def00..a9d253f4 100644 --- a/UPGRADE-2.0.md +++ b/UPGRADE-2.0.md @@ -1,537 +1,2 @@ -# Upgrade 2.0 - -## Hydrator - -The hydrator was rebuilt on top of a middleware stack. The old -`MetadataHydrator` and its event dispatcher based extension points are gone, the -new entry points are the `StackHydrator` and the `StackHydratorBuilder`. - -### MetadataHydrator - -`MetadataHydrator` has been removed in favor of `StackHydrator`. The static -`create()` factory is gone, build the hydrator with the `StackHydratorBuilder` -and register the `CoreExtension`, which sets up the property mapping and the -built-in guesser. - -before: -```php -use Patchlevel\Hydrator\MetadataHydrator; - -$hydrator = MetadataHydrator::create(); -``` -after: -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->build(); -``` - -If you do not need any extensions, you can also instantiate the `StackHydrator` -directly, it defaults to the same middleware and guesser. - -before: -```php -use Patchlevel\Hydrator\MetadataHydrator; - -$hydrator = new MetadataHydrator(); -``` -after: -```php -use Patchlevel\Hydrator\StackHydrator; - -$hydrator = new StackHydrator(); -``` - -### Custom guessers - -The guessers are no longer passed to a static factory, register them on the -builder instead. - -before: -```php -use Patchlevel\Hydrator\MetadataHydrator; - -$hydrator = MetadataHydrator::create([new NameGuesser()]); -``` -after: -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->addGuesser(new NameGuesser()) - ->build(); -``` - -### Default lazy objects - -The `defaultLazy` constructor flag moved to `enableDefaultLazy()` on the builder. - -before: -```php -use Patchlevel\Hydrator\MetadataHydrator; - -$hydrator = new MetadataHydrator(defaultLazy: true); -``` -after: -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->enableDefaultLazy() - ->build(); -``` - -### HydratorWithContext - -The `HydratorWithContext` interface has been removed, its methods were merged -into the `Hydrator` interface. `hydrate` and `extract` now always take a -`$context` array, and the `OBJECT_TO_POPULATE` constant lives on `Hydrator`. -Type hint against `Hydrator` and read the constant from there. - -before: -```php -use Patchlevel\Hydrator\HydratorWithContext; - -function hydrate(HydratorWithContext $hydrator, Profile $profile): Profile -{ - return $hydrator->hydrate( - Profile::class, - ['name' => 'patchlevel'], - [HydratorWithContext::OBJECT_TO_POPULATE => $profile], - ); -} -``` -after: -```php -use Patchlevel\Hydrator\Hydrator; - -function hydrate(Hydrator $hydrator, Profile $profile): Profile -{ - return $hydrator->hydrate( - Profile::class, - ['name' => 'patchlevel'], - [Hydrator::OBJECT_TO_POPULATE => $profile], - ); -} -``` - -## Normalizer - -### Normalizer interface - -The `normalize` and `denormalize` methods now receive a mandatory `array $context` -parameter. The separate `NormalizerWithContext` interface has been removed, -every normalizer gets the context. Add the parameter to all of your custom -normalizers. - -before: -```php -use Patchlevel\Hydrator\Normalizer\InvalidArgument; -use Patchlevel\Hydrator\Normalizer\Normalizer; - -use function is_string; - -final class NameNormalizer implements Normalizer -{ - public function normalize(mixed $value): string|null - { - if (!$value instanceof Name) { - throw InvalidArgument::withWrongType(Name::class, $value); - } - - return $value->toString(); - } - - public function denormalize(mixed $value): Name|null - { - if (!is_string($value)) { - throw InvalidArgument::withWrongType('string', $value); - } - - return new Name($value); - } -} -``` -after: -```php -use Patchlevel\Hydrator\Normalizer\InvalidArgument; -use Patchlevel\Hydrator\Normalizer\Normalizer; - -use function is_string; - -final class NameNormalizer implements Normalizer -{ - /** @param array $context */ - public function normalize(mixed $value, array $context): string|null - { - if (!$value instanceof Name) { - throw InvalidArgument::withWrongType(Name::class, $value); - } - - return $value->toString(); - } - - /** @param array $context */ - public function denormalize(mixed $value, array $context): Name|null - { - if (!is_string($value)) { - throw InvalidArgument::withWrongType('string', $value); - } - - return new Name($value); - } -} -``` - -### ReflectionTypeAwareNormalizer - -`ReflectionTypeAwareNormalizer` has been removed in favor of -`TypeAwareNormalizer`. It no longer hands you a native `ReflectionType` but a -`Symfony\Component\TypeInfo\Type`, and the method was renamed from -`handleReflectionType()` to `handleType()`. - -before: -```php -use Patchlevel\Hydrator\Normalizer\Normalizer; -use Patchlevel\Hydrator\Normalizer\ReflectionTypeAwareNormalizer; -use ReflectionType; - -final class MyNormalizer implements Normalizer, ReflectionTypeAwareNormalizer -{ - public function handleReflectionType(ReflectionType|null $reflectionType): void - { - // ... - } -} -``` -after: -```php -use Patchlevel\Hydrator\Normalizer\Normalizer; -use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer; -use Symfony\Component\TypeInfo\Type; - -final class MyNormalizer implements Normalizer, TypeAwareNormalizer -{ - public function handleType(Type|null $type): void - { - // ... - } -} -``` - -## Lifecycle Hooks - -The `PreExtract` and `PostHydrate` method attributes and the `PreHydrate` and -`PostExtract` events have been replaced by a single `LifecycleExtension` with -four method attributes under `Patchlevel\Hydrator\Extension\Lifecycle\Attribute`. -Register the extension on the builder to enable them. - -before: -```php -use Patchlevel\Hydrator\MetadataHydrator; - -$hydrator = new MetadataHydrator(); -``` -after: -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new LifecycleExtension()) - ->build(); -``` - -The hook methods must now be `static` and receive the `$context` array as a -parameter. The two data hooks (`PreHydrate`, `PostExtract`) receive and return -the data array, the two object hooks (`PostHydrate`, `PreExtract`) receive the -object instance. - -before: -```php -use Patchlevel\Hydrator\Attribute\PostHydrate; -use Patchlevel\Hydrator\Attribute\PreExtract; - -final class Profile -{ - #[PostHydrate] - private function postHydrate(): void - { - // do something - } - - #[PreExtract] - private function preExtract(): void - { - // do something - } -} -``` -after: -```php -use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate; -use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract; - -final class Profile -{ - /** @param array $context */ - #[PostHydrate] - public static function postHydrate(object $object, array $context): void - { - // do something - } - - /** @param array $context */ - #[PreExtract] - public static function preExtract(object $object, array $context): void - { - // do something - } -} -``` - -## Events - -The `PreHydrate` and `PostExtract` events that were dispatched through -`symfony/event-dispatcher` are gone, together with the `eventDispatcher` -constructor argument of the hydrator. Global listeners that ran for every class -are now expressed as a `Middleware`. A middleware wraps the whole hydrate and -extract process: adjust the data before calling `$stack->next()` to replace a -`PreHydrate` listener, and adjust the returned data afterwards to replace a -`PostExtract` listener. - -before: -```php -use Patchlevel\Hydrator\Event\PostExtract; -use Patchlevel\Hydrator\Event\PreHydrate; -use Patchlevel\Hydrator\MetadataHydrator; -use Symfony\Component\EventDispatcher\EventDispatcher; - -$eventDispatcher = new EventDispatcher(); - -$eventDispatcher->addListener( - PreHydrate::class, - static function (PreHydrate $event): void { - // adjust $event->data before hydration - }, -); - -$eventDispatcher->addListener( - PostExtract::class, - static function (PostExtract $event): void { - // adjust $event->data after extraction - }, -); - -$hydrator = new MetadataHydrator(eventDispatcher: $eventDispatcher); -``` -after: -```php -use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; -use Patchlevel\Hydrator\Middleware\Stack; - -final class MyMiddleware implements Middleware -{ - /** - * @param ClassMetadata $metadata - * @param array $data - * @param array $context - * - * @return T - * - * @template T of object - */ - public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object - { - // adjust $data before hydration (was the PreHydrate event) - - return $stack->next()->hydrate($metadata, $data, $context, $stack); - } - - /** - * @param ClassMetadata $metadata - * @param T $object - * @param array $context - * - * @return array - * - * @template T of object - */ - public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array - { - $data = $stack->next()->extract($metadata, $object, $context, $stack); - - // adjust $data after extraction (was the PostExtract event) - - return $data; - } -} -``` - -Register the middleware on the builder. The default priority places it before -the `TransformMiddleware`, so it runs first on hydrate and last on extract. - -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->addMiddleware(new MyMiddleware()) - ->build(); -``` -> [!NOTE] -> The middleware has access to the `ClassMetadata`, so you can decide per class -> whether to act, which is what you previously did by inspecting `$event->metadata`. - -## Cryptography - -The cryptography support moved out of the core into a dedicated extension under -the `Patchlevel\Hydrator\Extension\Cryptography` namespace, and it is now wired -through a middleware instead of an event subscriber. - -### Configuration - -Instead of passing a `PayloadCryptographer` to the hydrator constructor, register -the `CryptographyExtension` on the builder. The cryptographer is now the -`BaseCryptographer`, created with `createWithOpenssl()`. - -before: -```php -use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; -use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; -use Patchlevel\Hydrator\MetadataHydrator; - -$cipherKeyStore = new InMemoryCipherKeyStore(); -$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); -$hydrator = new MetadataHydrator(cryptographer: $cryptographer); -``` -after: -```php -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; -use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; -use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; -use Patchlevel\Hydrator\StackHydratorBuilder; - -$cipherKeyStore = new InMemoryCipherKeyStore(); - -$hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) - ->build(); -``` - -### PersonalData attribute - -The `PersonalData` attribute has been renamed to `SensitiveData` and moved into -the cryptography extension namespace. This makes clear it is not limited to the -personal data of users. - -before: -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; - -final class EmailChanged -{ - public function __construct( - #[DataSubjectId] - public readonly string $profileId, - #[PersonalData] - public readonly string|null $email, - ) { - } -} -``` -after: -```php -use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; - -final class EmailChanged -{ - public function __construct( - #[DataSubjectId] - public readonly string $profileId, - #[SensitiveData] - public readonly string|null $email, - ) { - } -} -``` - -### DataSubjectId attribute - -`DataSubjectId` moved from `Patchlevel\Hydrator\Attribute\DataSubjectId` to -`Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId`. You can now -name a subject id and reference it from a field with `subjectIdName`, which lets -you use multiple subjects in one class. The default name is `default`. - -before: -```php -use Patchlevel\Hydrator\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Attribute\PersonalData; - -final class ProfilesMerged -{ - public function __construct( - #[DataSubjectId] - public readonly string $sourceProfileId, - #[PersonalData] - public readonly string|null $sourceEmail, - ) { - } -} -``` -after: -```php -use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; -use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; - -final class ProfilesMerged -{ - public function __construct( - #[DataSubjectId(name: 'source')] - public readonly string $sourceProfileId, - #[SensitiveData(subjectIdName: 'source')] - public readonly string|null $sourceEmail, - ) { - } -} -``` - -### Moved classes - -The remaining cryptography classes moved from `Patchlevel\Hydrator\Cryptography` -into `Patchlevel\Hydrator\Extension\Cryptography`. Update the imports for, among -others: - -* `Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore` to `Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore` -* `Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore` to `Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore` -* `Patchlevel\Hydrator\Cryptography\Cipher\Cipher` to `Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher` -* `Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipher` to `Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher` - -The `CipherKeyStore` interface also changed: keys are now stored and looked up -through `currentKeyFor()`, `get()`, `store()`, `remove()` and -`removeWithSubjectId()`, and a `CipherKey` now carries its own id. - -> [!WARNING] -> Removing a cipher key is irreversible. The encrypted data can never be decrypted -> again, that is the point of crypto-shredding, but make sure it is what you want. - -## Dependencies - -The `symfony/event-dispatcher` dependency has been dropped, since the lifecycle -hooks and the cryptography no longer use it. If you relied on it transitively -through this package, require it explicitly in your own `composer.json`. +The upgrade path is located in our docs. Please have a look at +[here](https://patchlevel.dev/docs/hydrator/latest/UPGRADE-2.0/). diff --git a/bin/docs-extract-php-code b/bin/docs-extract-php-code new file mode 100755 index 00000000..d50b642d --- /dev/null +++ b/bin/docs-extract-php-code @@ -0,0 +1,53 @@ +#!/usr/bin/env php +addExtension(new MarkdownRendererExtension()); + +$parser = new MarkdownParser($environment); + +$targetDir = __DIR__ . '/../docs_php'; + +if (file_exists($targetDir)) { + exec('rm -rf ' . $targetDir); +} + +mkdir($targetDir); + +$finder = new Symfony\Component\Finder\Finder(); +$finder->files()->in(__DIR__ . '/../docs')->name('*.md'); + +foreach ($finder as $file) { + $fileName = pathinfo($file->getBasename(), PATHINFO_FILENAME); + + $content = file_get_contents($file->getPathname()); + $document = $parser->parse($content); + + $result = (new Query()) + ->where(Query::type(FencedCode::class)) + ->findAll($document); + + /** + * @var FencedCode $node + */ + foreach ($result as $i => $node) { + if ($node->getInfo() !== 'php') { + continue; + } + + $source = sprintf('%s:%s', $file->getRealPath(), $node->getStartLine()); + + $code = "getLiteral(); + + $targetPath = $targetDir . '/' . $fileName . '_' . $i . '.php'; + file_put_contents($targetPath, $code); + } +} diff --git a/bin/docs-inject-php-code b/bin/docs-inject-php-code new file mode 100755 index 00000000..e4e81ead --- /dev/null +++ b/bin/docs-inject-php-code @@ -0,0 +1,70 @@ +#!/usr/bin/env php +addExtension(new MarkdownRendererExtension()); + +$parser = new MarkdownParser($environment); +$markdownRenderer = new MarkdownRenderer($environment); + +$targetDir = __DIR__ . '/../docs_php'; + +if (!file_exists($targetDir)) { + exit(1); +} + +$finder = new Symfony\Component\Finder\Finder(); +$finder->files()->in(__DIR__ . '/../docs')->name('*.md'); + +foreach ($finder as $file) { + $fileName = pathinfo($file->getBasename(), PATHINFO_FILENAME); + + $content = file_get_contents($file->getPathname()); + $document = $parser->parse($content); + + $result = (new Query()) + ->where(Query::type(FencedCode::class)) + ->findAll($document); + + /** + * @var FencedCode $node + */ + foreach ($result as $i => $node) { + if ($node->getInfo() !== 'php') { + $node->setLiteral(trim($node->getLiteral())); + continue; + } + + $targetPath = $targetDir . '/' . $fileName . '_' . $i . '.php'; + + if (!file_exists($targetPath)) { + $node->setLiteral(trim($node->getLiteral())); + continue; + } + + $code = file_get_contents($targetPath); + + $lines = explode("\n", $code); + array_splice($lines, 0, 2); + $code = implode("\n", $lines); + + $node->setLiteral(trim($code)); + } + + file_put_contents($file->getPathname(), $markdownRenderer->renderDocument($document)); +} + +if (file_exists($targetDir)) { + exec('rm -rf ' . $targetDir); +} \ No newline at end of file diff --git a/composer.json b/composer.json index 7ad66228..d6d3c1e4 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,8 @@ "phpstan/phpstan": "^2.1.39", "phpstan/phpstan-phpunit": "^2.0.15", "phpunit/phpunit": "^11.5.53", - "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0" + "symfony/var-dumper": "^5.4.29 || ^6.4.0 || ^7.0.0 || ^8.0.0", + "wnx/commonmark-markdown-renderer": "^1.6" }, "config": { "preferred-install": { diff --git a/composer.lock b/composer.lock index d05c6e85..7c5928b1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9474f60489f2575f2b839a88df24af8b", + "content-hash": "ade5adf4b300ed32a793ea92e1c81b46", "packages": [ { "name": "psr/cache", @@ -572,6 +572,81 @@ ], "time": "2025-11-11T04:32:07+00:00" }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, { "name": "doctrine/annotations", "version": "2.0.2", @@ -1228,6 +1303,195 @@ }, "time": "2026-02-15T15:06:22+00:00" }, + { + "name": "league/commonmark", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e", + "reference": "9d489ab67a02960fd8ffe624d93f751daf95439e", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-09-07T13:44:26+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, { "name": "marc-mabe/php-enum", "version": "v4.7.2", @@ -1361,6 +1625,164 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, { "name": "nikic/php-parser", "version": "v5.7.0", @@ -2538,6 +2960,56 @@ }, "time": "2022-11-25T14:36:26+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -4809,6 +5281,90 @@ ], "time": "2024-12-23T08:48:59+00:00" }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, { "name": "symfony/polyfill-php85", "version": "v1.33.0", @@ -5521,6 +6077,67 @@ "source": "https://github.com/webmozarts/glob/tree/4.7.0" }, "time": "2024-03-07T20:33:40+00:00" + }, + { + "name": "wnx/commonmark-markdown-renderer", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/stefanzweifel/commonmark-markdown-renderer.git", + "reference": "3a283076abd1a1ed043940f9be43cd35470cd0d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stefanzweifel/commonmark-markdown-renderer/zipball/3a283076abd1a1ed043940f9be43cd35470cd0d4", + "reference": "3a283076abd1a1ed043940f9be43cd35470cd0d4", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.0", + "rector/rector": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Wnx\\CommonmarkMarkdownRenderer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stefan Zweifel", + "email": "stefan@stefanzweifel.dev", + "role": "Developer" + } + ], + "description": "Render Markdown AST back to Markdown.", + "homepage": "https://github.com/stefanzweifel/commonmark-markdown-renderer", + "keywords": [ + "commonmark-markdown-renderer", + "markdown", + "renderer", + "wnx" + ], + "support": { + "issues": "https://github.com/stefanzweifel/commonmark-markdown-renderer/issues", + "source": "https://github.com/stefanzweifel/commonmark-markdown-renderer/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://github.com/stefanzweifel", + "type": "github" + } + ], + "time": "2025-11-23T20:14:14+00:00" } ], "aliases": [], diff --git a/docs/UPGRADE-2.0.md b/docs/UPGRADE-2.0.md new file mode 100644 index 00000000..80e7a392 --- /dev/null +++ b/docs/UPGRADE-2.0.md @@ -0,0 +1,541 @@ +# Upgrade from v1 to v2 + +## Hydrator + +The hydrator was rebuilt on top of a middleware stack. The old +`MetadataHydrator` and its event dispatcher based extension points are gone, the +new entry points are the `StackHydrator` and the `StackHydratorBuilder`. + +### MetadataHydrator + +`MetadataHydrator` has been removed in favor of `StackHydrator`. The static +`create()` factory is gone, build the hydrator with the `StackHydratorBuilder` +and register the `CoreExtension`, which sets up the property mapping and the +built-in guesser. + +before: + +```php +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = MetadataHydrator::create(); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->build(); +``` +If you do not need any extensions, you can also instantiate the `StackHydrator` +directly, it defaults to the same middleware and guesser. + +before: + +```php +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = new MetadataHydrator(); +``` +after: + +```php +use Patchlevel\Hydrator\StackHydrator; + +$hydrator = new StackHydrator(); +``` +### Custom guessers + +The guessers are no longer passed to a static factory, register them on the +builder instead. + +before: + +```php +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = MetadataHydrator::create([new NameGuesser()]); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addGuesser(new NameGuesser()) + ->build(); +``` +### Default lazy objects + +The `defaultLazy` constructor flag moved to `enableDefaultLazy()` on the builder. + +before: + +```php +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = new MetadataHydrator(defaultLazy: true); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->enableDefaultLazy() + ->build(); +``` +### HydratorWithContext + +The `HydratorWithContext` interface has been removed, its methods were merged +into the `Hydrator` interface. `hydrate` and `extract` now always take a +`$context` array, and the `OBJECT_TO_POPULATE` constant lives on `Hydrator`. +Type hint against `Hydrator` and read the constant from there. + +before: + +```php +use Patchlevel\Hydrator\HydratorWithContext; + +function hydrate(HydratorWithContext $hydrator, Profile $profile): Profile +{ + return $hydrator->hydrate( + Profile::class, + ['name' => 'patchlevel'], + [HydratorWithContext::OBJECT_TO_POPULATE => $profile], + ); +} +``` +after: + +```php +use Patchlevel\Hydrator\Hydrator; + +function hydrate(Hydrator $hydrator, Profile $profile): Profile +{ + return $hydrator->hydrate( + Profile::class, + ['name' => 'patchlevel'], + [Hydrator::OBJECT_TO_POPULATE => $profile], + ); +} +``` +## Normalizer + +### Normalizer interface + +The `normalize` and `denormalize` methods now receive a mandatory `array $context` +parameter. The separate `NormalizerWithContext` interface has been removed, +every normalizer gets the context. Add the parameter to all of your custom +normalizers. + +before: + +```php +use Patchlevel\Hydrator\Normalizer\InvalidArgument; +use Patchlevel\Hydrator\Normalizer\Normalizer; + +final class NameNormalizer implements Normalizer +{ + public function normalize(mixed $value): string|null + { + if (!$value instanceof Name) { + throw InvalidArgument::withWrongType(Name::class, $value); + } + + return $value->toString(); + } + + public function denormalize(mixed $value): Name|null + { + if (!is_string($value)) { + throw InvalidArgument::withWrongType('string', $value); + } + + return new Name($value); + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Normalizer\InvalidArgument; +use Patchlevel\Hydrator\Normalizer\Normalizer; + +final class NameNormalizer implements Normalizer +{ + /** @param array $context */ + public function normalize(mixed $value, array $context): string|null + { + if (!$value instanceof Name) { + throw InvalidArgument::withWrongType(Name::class, $value); + } + + return $value->toString(); + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context): Name|null + { + if (!is_string($value)) { + throw InvalidArgument::withWrongType('string', $value); + } + + return new Name($value); + } +} +``` +### ReflectionTypeAwareNormalizer + +`ReflectionTypeAwareNormalizer` has been removed in favor of +`TypeAwareNormalizer`. It no longer hands you a native `ReflectionType` but a +`Symfony\Component\TypeInfo\Type`, and the method was renamed from +`handleReflectionType()` to `handleType()`. + +before: + +```php +use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\ReflectionTypeAwareNormalizer; + +final class MyNormalizer implements Normalizer, ReflectionTypeAwareNormalizer +{ + public function handleReflectionType(ReflectionType|null $reflectionType): void + { + // ... + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer; +use Symfony\Component\TypeInfo\Type; + +final class MyNormalizer implements Normalizer, TypeAwareNormalizer +{ + public function handleType(Type|null $type): void + { + // ... + } +} +``` +## Lifecycle Hooks + +The `PreExtract` and `PostHydrate` method attributes and the `PreHydrate` and +`PostExtract` events have been replaced by a single `LifecycleExtension` with +four method attributes under `Patchlevel\Hydrator\Extension\Lifecycle\Attribute`. +Register the extension on the builder to enable them. + +before: + +```php +use Patchlevel\Hydrator\MetadataHydrator; + +$hydrator = new MetadataHydrator(); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Lifecycle\LifecycleExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new LifecycleExtension()) + ->build(); +``` +The hook methods must now be `static` and receive the `$context` array as a +parameter. The two data hooks (`PreHydrate`, `PostExtract`) receive and return +the data array, the two object hooks (`PostHydrate`, `PreExtract`) receive the +object instance. + +before: + +```php +use Patchlevel\Hydrator\Attribute\PostHydrate; +use Patchlevel\Hydrator\Attribute\PreExtract; + +final class Profile +{ + #[PostHydrate] + private function postHydrate(): void + { + // do something + } + + #[PreExtract] + private function preExtract(): void + { + // do something + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PostHydrate; +use Patchlevel\Hydrator\Extension\Lifecycle\Attribute\PreExtract; + +final class Profile +{ + /** @param array $context */ + #[PostHydrate] + public static function postHydrate(object $object, array $context): void + { + // do something + } + + /** @param array $context */ + #[PreExtract] + public static function preExtract(object $object, array $context): void + { + // do something + } +} +``` +## Events + +The `PreHydrate` and `PostExtract` events that were dispatched through +`symfony/event-dispatcher` are gone, together with the `eventDispatcher` +constructor argument of the hydrator. Global listeners that ran for every class +are now expressed as a `Middleware`. A middleware wraps the whole hydrate and +extract process: adjust the data before calling `$stack->next()` to replace a +`PreHydrate` listener, and adjust the returned data afterwards to replace a +`PostExtract` listener. + +before: + +```php +use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreHydrate; +use Patchlevel\Hydrator\MetadataHydrator; +use Symfony\Component\EventDispatcher\EventDispatcher; + +$eventDispatcher = new EventDispatcher(); + +$eventDispatcher->addListener( + PreHydrate::class, + static function (PreHydrate $event): void { + // adjust $event->data before hydration + }, +); + +$eventDispatcher->addListener( + PostExtract::class, + static function (PostExtract $event): void { + // adjust $event->data after extraction + }, +); + +$hydrator = new MetadataHydrator(eventDispatcher: $eventDispatcher); +``` +after: + +```php +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Middleware\Middleware; +use Patchlevel\Hydrator\Middleware\Stack; + +final class MyMiddleware implements Middleware +{ + /** + * @param ClassMetadata $metadata + * @param array $data + * @param array $context + * + * @return T + * + * @template T of object + */ + public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object + { + // adjust $data before hydration (was the PreHydrate event) + + return $stack->next()->hydrate($metadata, $data, $context, $stack); + } + + /** + * @param ClassMetadata $metadata + * @param T $object + * @param array $context + * + * @return array + * + * @template T of object + */ + public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array + { + return $stack->next()->extract($metadata, $object, $context, $stack); + } +} +``` +Register the middleware on the builder. The default priority places it before +the `TransformMiddleware`, so it runs first on hydrate and last on extract. + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->addMiddleware(new MyMiddleware()) + ->build(); +``` +> [!NOTE] +The middleware has access to the `ClassMetadata`, so you can decide per class +whether to act, which is what you previously did by inspecting `$event->metadata`. + +## Cryptography + +The cryptography support moved out of the core into a dedicated extension under +the `Patchlevel\Hydrator\Extension\Cryptography` namespace, and it is now wired +through a middleware instead of an event subscriber. + +### Configuration + +Instead of passing a `PayloadCryptographer` to the hydrator constructor, register +the `CryptographyExtension` on the builder. The cryptographer is now the +`BaseCryptographer`, created with `createWithOpenssl()`. + +before: + +```php +use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; +use Patchlevel\Hydrator\MetadataHydrator; + +$cipherKeyStore = new InMemoryCipherKeyStore(); +$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); +$hydrator = new MetadataHydrator(cryptographer: $cryptographer); +``` +after: + +```php +use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer; +use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension; +use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; +use Patchlevel\Hydrator\StackHydratorBuilder; + +$cipherKeyStore = new InMemoryCipherKeyStore(); + +$hydrator = (new StackHydratorBuilder()) + ->useExtension(new CoreExtension()) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) + ->build(); +``` +### PersonalData attribute + +The `PersonalData` attribute has been renamed to `SensitiveData` and moved into +the cryptography extension namespace. This makes clear it is not limited to the +personal data of users. + +before: + +```php +use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Attribute\PersonalData; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[PersonalData] + public readonly string|null $email, + ) { + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[SensitiveData] + public readonly string|null $email, + ) { + } +} +``` +### DataSubjectId attribute + +`DataSubjectId` moved from `Patchlevel\Hydrator\Attribute\DataSubjectId` to +`Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId`. You can now +name a subject id and reference it from a field with `subjectIdName`, which lets +you use multiple subjects in one class. The default name is `default`. + +before: + +```php +use Patchlevel\Hydrator\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Attribute\PersonalData; + +final class ProfilesMerged +{ + public function __construct( + #[DataSubjectId] + public readonly string $sourceProfileId, + #[PersonalData] + public readonly string|null $sourceEmail, + ) { + } +} +``` +after: + +```php +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId; +use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData; + +final class ProfilesMerged +{ + public function __construct( + #[DataSubjectId(name: 'source')] + public readonly string $sourceProfileId, + #[SensitiveData(subjectIdName: 'source')] + public readonly string|null $sourceEmail, + ) { + } +} +``` +### Moved classes + +The remaining cryptography classes moved from `Patchlevel\Hydrator\Cryptography` +into `Patchlevel\Hydrator\Extension\Cryptography`. Update the imports for, among +others: + +* `Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore` to `Patchlevel\Hydrator\Extension\Cryptography\Store\CipherKeyStore` +* `Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore` to `Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore` +* `Patchlevel\Hydrator\Cryptography\Cipher\Cipher` to `Patchlevel\Hydrator\Extension\Cryptography\Cipher\Cipher` +* `Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipher` to `Patchlevel\Hydrator\Extension\Cryptography\Cipher\OpensslCipher` + +The `CipherKeyStore` interface also changed: keys are now stored and looked up +through `currentKeyFor()`, `get()`, `store()`, `remove()` and +`removeWithSubjectId()`, and a `CipherKey` now carries its own id. + +> [!WARNING] +Removing a cipher key is irreversible. The encrypted data can never be decrypted +again, that is the point of crypto-shredding, but make sure it is what you want. + +## Dependencies + +The `symfony/event-dispatcher` dependency has been dropped, since the lifecycle +hooks and the cryptography no longer use it. If you relied on it transitively +through this package, require it explicitly in your own `composer.json`. diff --git a/docs/cryptography.md b/docs/cryptography.md index 611d597a..20187dac 100644 --- a/docs/cryptography.md +++ b/docs/cryptography.md @@ -29,7 +29,6 @@ $hydrator = (new StackHydratorBuilder()) ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore))) ->build(); ``` - ## DataSubjectId First you need to define which field identifies the subject the data belongs @@ -78,7 +77,6 @@ final class ProfilesMerged } } ``` - ## Fallback values If the data could not be decrypted, because the key has been removed, a @@ -100,7 +98,6 @@ final class ProfileCreated } } ``` - You can also use a callable as a fallback. It receives the subject id: ```php @@ -140,7 +137,6 @@ use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore; $cipherKeyStore = new InMemoryCipherKeyStore(); ``` - For production you have to implement the `CipherKeyStore` interface yourself, backed by a database or a key management service, because only you know where the keys should live: @@ -165,19 +161,17 @@ interface CipherKeyStore public function removeWithSubjectId(string $subjectId): void; } ``` - To avoid hitting your key storage for every operation, you can wrap the store in one of the cache decorators: ```php -use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator; +use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator; $cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool); // or $cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache); ``` - ## Remove personal data To remove personal data, you only need to remove the keys for the subject from diff --git a/docs/extensions.md b/docs/extensions.md index 520131b5..e278b66b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -21,7 +21,6 @@ $hydrator = (new StackHydratorBuilder()) ->useExtension(new LifecycleExtension()) ->build(); ``` - ## Built-in extensions The library ships with four extensions out of the box: @@ -60,7 +59,6 @@ final class RemoveNullValuesMiddleware implements Middleware } } ``` - Middlewares are added with a priority, higher priorities run first (outermost). The `TransformMiddleware` from the `CoreExtension` has priority `-64`, so it always runs last. @@ -68,7 +66,6 @@ always runs last. ```php $builder->addMiddleware(new RemoveNullValuesMiddleware(), 0); ``` - ## Metadata enricher A metadata enricher runs once per class when the metadata is created. It can @@ -94,7 +91,6 @@ final class AuditMetadataEnricher implements MetadataEnricher } } ``` - ```php $builder->addMetadataEnricher(new AuditMetadataEnricher()); ``` @@ -122,7 +118,6 @@ final class AuditExtension implements Extension } } ``` - ## Learn more * [How to run code before extract and after hydrate](lifecycle-hooks.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b63e7f1..4b427840 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -79,11 +79,10 @@ $event = new ProfileCreated( $data = $hydrator->extract($event); ``` - The result looks like this: ```php -[ +$eventData = [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', @@ -92,9 +91,8 @@ The result looks like this: ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', -] +]; ``` - You can now turn this array into JSON with `json_encode` and store it anywhere. ## Hydrate the object back diff --git a/docs/guesser.md b/docs/guesser.md index d164b1f3..af488674 100644 --- a/docs/guesser.md +++ b/docs/guesser.md @@ -33,7 +33,6 @@ final class NameGuesser implements Guesser } } ``` - To use the guesser, add it to the builder: ```php diff --git a/docs/hydrator.md b/docs/hydrator.md index a864f7fc..8cf62e48 100644 --- a/docs/hydrator.md +++ b/docs/hydrator.md @@ -20,7 +20,6 @@ $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->build(); ``` - If you don't need any extensions, you can also instantiate the `StackHydrator` directly, it defaults to the same middleware and guesser: @@ -40,8 +39,6 @@ Use the builder as soon as you want [extensions](extensions.md), custom To convert objects into serializable arrays, use the `extract` method. ```php -use DateTimeImmutable; - $event = new ProfileCreated( 1, 'patchlevel', @@ -52,12 +49,11 @@ $event = new ProfileCreated( $data = $hydrator->extract($event); ``` - The result is an array of scalars and nested arrays that can be passed straight to `json_encode`: ```php -[ +$eventData = [ 'id' => 1, 'name' => 'patchlevel', 'role' => 'admin', @@ -66,9 +62,8 @@ to `json_encode`: ['name' => 'event-sourcing', 'level' => 10], ], 'createdAt' => '2023-10-01T12:00:00+00:00', -] +]; ``` - ## Hydrate objects The reverse direction is the `hydrate` method. You specify the class that should @@ -114,7 +109,6 @@ $profile = $hydrator->hydrate( [Hydrator::OBJECT_TO_POPULATE => $profile], ); ``` - ## Rename fields By default, the property name is used to name the field in the extracted @@ -129,13 +123,10 @@ final class Profile public string $name; } ``` - The extracted result then looks like this: ```php -[ - 'profile_name' => 'patchlevel', -] +$data = ['profile_name' => 'patchlevel']; ``` :::tip You can rename a property without a backwards compatibility break in your stored @@ -177,7 +168,7 @@ use Patchlevel\Hydrator\HydratorException; try { $event = $hydrator->hydrate(ProfileCreated::class, $data); -} catch (HydratorException $e) { +} catch (HydratorException) { // invalid data, unsupported class, type mismatch, ... } ``` diff --git a/docs/introduction.md b/docs/introduction.md index 4b0b0491..4a9e7fb8 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -27,11 +27,11 @@ And if customization is required, it can be done easily using attributes. ```bash composer require patchlevel/hydrator ``` - ## Integration * [Event Sourcing](https://github.com/patchlevel/event-sourcing) - the hydrator powers the storage and retrieval of thousands of events and aggregates. * [ODM](https://github.com/patchlevel/odm) - a lightweight object document mapper for MongoDB and PostgreSQL that builds on the hydrator for fast object mapping and full extension support. + :::tip New here? Start with the [getting started guide](getting-started.md) and build your first hydrator in a few minutes. ::: diff --git a/docs/lazy.md b/docs/lazy.md index e683fb1f..154e4a1d 100644 --- a/docs/lazy.md +++ b/docs/lazy.md @@ -42,7 +42,6 @@ $hydrator = (new StackHydratorBuilder()) ->enableDefaultLazy() ->build(); ``` - Single classes can then opt out again with the attribute: ```php diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md index dd701e3f..91029091 100644 --- a/docs/lifecycle-hooks.md +++ b/docs/lifecycle-hooks.md @@ -19,7 +19,6 @@ $hydrator = (new StackHydratorBuilder()) ->useExtension(new LifecycleExtension()) ->build(); ``` - ## Hooks The hooks are **static** methods on the class being hydrated. The data hooks diff --git a/docs/normalizer.md b/docs/normalizer.md index 325befdb..5e0d27fe 100644 --- a/docs/normalizer.md +++ b/docs/normalizer.md @@ -12,10 +12,11 @@ For every property, the normalizer is determined in this order: 1. Does the property have a normalizer as an attribute? Use this. 2. Otherwise, the type of the property is determined: - 1. If it is an array shape, the `ArrayShapeNormalizer` is used (recursive). - 2. If it is a collection, the `ArrayNormalizer` is used (recursive). - 3. If it is an object, a normalizer attribute is searched on the class, its parents and interfaces. - 4. If none is found, the [guessers](guesser.md) are asked. The built-in guesser handles enums and date types and falls back to the `ObjectNormalizer`. + 1. If it is an array shape, the `ArrayShapeNormalizer` is used (recursive). + 2. If it is a collection, the `ArrayNormalizer` is used (recursive). + 3. If it is an object, a normalizer attribute is searched on the class, its parents and interfaces. + 4. If none is found, the [guessers](guesser.md) are asked. The built-in guesser handles enums and date types and falls back to the `ObjectNormalizer`. + The normalizer is only determined once per class because it is cached in the [metadata](caching.md). @@ -35,12 +36,10 @@ final readonly class ProfileCreated } } ``` - You can also set the `ArrayNormalizer` explicitly and pass it the normalizer for the elements: ```php -use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\ArrayNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; @@ -62,7 +61,6 @@ It is inferred automatically from an `array{...}` docblock, or you can configure it explicitly with a map of field name to normalizer. ```php -use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\ArrayShapeNormalizer; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; @@ -80,7 +78,6 @@ final class Profile public array $explicitMeta; } ``` - ## DateTimeImmutable With the `DateTimeImmutableNormalizer` you can convert `DateTimeImmutable` @@ -88,7 +85,6 @@ objects to a string and back again. It is applied automatically to `DateTimeImmutable` properties. ```php -use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile @@ -97,12 +93,10 @@ final class Profile public DateTimeImmutable $createdAt; } ``` - You can also define the format. Either describe it yourself as a string or use one of the existing constants. The default is `DateTimeImmutable::ATOM`. ```php -use DateTimeImmutable; use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer; final class Profile @@ -121,7 +115,6 @@ The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`, only for `DateTime` objects. The default format is `DateTime::ATOM`. ```php -use DateTime; use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer; final class Profile @@ -130,13 +123,11 @@ final class Profile public DateTime $lastSeen; } ``` - ## DateTimeZone To normalize a `DateTimeZone`, the `DateTimeZoneNormalizer` is used. ```php -use DateTimeZone; use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer; final class Profile @@ -145,14 +136,12 @@ final class Profile public DateTimeZone $timeZone; } ``` - ## DateInterval A `DateInterval` is converted to its ISO 8601 duration string with the `DateIntervalNormalizer`. The format can be customized. ```php -use DateInterval; use Patchlevel\Hydrator\Normalizer\DateIntervalNormalizer; final class Subscription @@ -161,7 +150,6 @@ final class Subscription public DateInterval $renewEvery; } ``` - ## Enum Backed enums are converted to their backing value. The enum class is inferred @@ -179,7 +167,6 @@ final class Profile public mixed $explicitRole; } ``` - ## Object If you have a complex object that you want to normalize, the `ObjectNormalizer` @@ -245,8 +232,12 @@ don't want to create a separate normalizer class. use Patchlevel\Hydrator\Normalizer\InlineNormalizer; #[InlineNormalizer( - normalize: static fn (self $email): string => $email->toString(), - denormalize: static fn (string $value): self => new self($value), + normalize: static function (self $email): string { + return $email->toString(); + }, + denormalize: static function (string $value): self { + return new self($value); + }, )] final class Email { @@ -263,7 +254,8 @@ final class Email ``` :::note Closures in attributes are only possible since PHP 8.5, therefore this normalizer -can only be used as an attribute with PHP 8.5. +can only be used as an attribute with PHP 8.5. Arrow functions (`fn`) are not +allowed there, you have to use a `static function` closure. ::: :::tip @@ -284,15 +276,11 @@ In this example we have a value object that holds a validated name: ```php final class Name { - private string $value; - - public function __construct(string $value) + public function __construct(private string $value) { if (strlen($value) < 3) { throw new NameIsTooShort($value); } - - $this->value = $value; } public function toString(): string @@ -301,11 +289,9 @@ final class Name } } ``` - The matching normalizer converts it to a string and back: ```php -use Attribute; use Patchlevel\Hydrator\Normalizer\InvalidArgument; use Patchlevel\Hydrator\Normalizer\Normalizer; @@ -339,7 +325,6 @@ final class NameNormalizer implements Normalizer } } ``` - Now you can use the normalizer directly on a property: ```php @@ -349,7 +334,6 @@ final class Profile public Name $name; } ``` - ## Define a normalizer on class level Instead of specifying the normalizer on each property, you can also set the diff --git a/docs/project.json b/docs/project.json index 1f74881b..5fae563a 100644 --- a/docs/project.json +++ b/docs/project.json @@ -20,6 +20,12 @@ { "title": "Cryptography", "file": "cryptography.md" }, { "title": "Upcasting", "file": "upcasting.md" } ] + }, + { + "title": "Miscellaneous", + "subEntries": [ + { "title": "Upgrade from 1.x", "file": "UPGRADE-2.0md" } + ] } ] } diff --git a/docs/upcasting.md b/docs/upcasting.md index 0fb023c6..d56efb6b 100644 --- a/docs/upcasting.md +++ b/docs/upcasting.md @@ -65,7 +65,6 @@ final class RenameEmailUpcaster implements Upcaster } } ``` - For the common case of a single class and a closure, use the `CallbackUpcaster`. It compares the class name for you and only invokes the callback for a match. The callback receives the data and the context: @@ -83,7 +82,6 @@ $upcaster = CallbackUpcaster::forClass( }, ); ``` - ## When upcasters run The hydrator decodes the stored payload in stages: first it is read as raw