diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index 2905147..0000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,112 +0,0 @@ -# https://help.github.com/en/categories/automating-your-workflow-with-github-actions - -name: "Benchmark" - -on: - pull_request: - -jobs: - phpbench: - name: "Benchmark" - - runs-on: ${{ matrix.operating-system }} - - strategy: - matrix: - dependencies: - - "locked" - php-version: - - "8.4" - operating-system: - - "ubuntu-latest" - - steps: - - name: "Install PHP" - uses: "shivammathur/setup-php@2.36.0" - with: - coverage: "none" - php-version: "${{ matrix.php-version }}" - ini-values: memory_limit=-1, opcache.enable_cli=1, opcache.jit=tracing, opcache.jit_buffer_size=64M - - - name: "Checkout base" - uses: actions/checkout@v6 - with: - ref: ${{ github.base_ref }} - - - uses: ramsey/composer-install@3.1.1 - with: - dependency-versions: ${{ matrix.dependencies }} - - - name: "phpbench on base" - run: "vendor/bin/phpbench run tests/Benchmark --progress=none --report=default --tag=base" - - - name: "Checkout" - uses: actions/checkout@v6 - with: - clean: false - - - uses: ramsey/composer-install@3.1.1 - with: - dependency-versions: ${{ matrix.dependencies }} - - - name: "phpbench diff" - run: "vendor/bin/phpbench run tests/Benchmark --progress=none --report=diff --ref=base > bench.txt" - - - name: "Get Bench Cursor" - id: phpbench - run: | - echo 'BENCH_RESULT<> $GITHUB_ENV - cat bench.txt >> $GITHUB_ENV - echo 'EOF' >> $GITHUB_ENV - - - uses: actions/github-script@v8 - with: - script: | - // Get the existing comments. - const {data: comments} = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.number, - }) - - // Find any comment already made by the bot. - const botComment = comments.find(comment => comment.user.id === 41898282) - const commentBody = ` - - Hello :wave: - -
- here is the most recent benchmark result: - -

- - \`\`\` - ${{ env.BENCH_RESULT }} - \`\`\` - -

-
- - This comment gets update everytime a new commit comes in! - - `; - - if (!context.payload.pull_request.head.repo.full_name.startsWith('patchlevel/')) { - console.log('Not attempting to write comment on PR from fork'); - } else { - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: commentBody - }) - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.number, - body: commentBody - }) - } - } diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index dbc9567..2697cf9 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -20,7 +20,7 @@ jobs: dependencies: - "locked" php-version: - - "8.4" + - "8.5" operating-system: - "ubuntu-latest" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 0b514bc..a8187aa 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -44,7 +44,7 @@ jobs: - "18.1" env: - POSTGRES_URI: 'pdo-pgsql://postgres:postgres@localhost:5432/eventstore?charset=utf8' + POSTGRES_URI: 'pgsql:host=localhost;port=5432;dbname=eventstore;user=postgres;password=postgres' steps: - name: "Checkout" @@ -56,7 +56,7 @@ jobs: coverage: "none" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 - extensions: pdo_pgsql + extensions: pdo_pgsql,mongodb - uses: ramsey/composer-install@3.1.1 with: @@ -105,7 +105,7 @@ jobs: coverage: "none" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 - extensions: mongodb + extensions: pdo_pgsql,mongodb - uses: ramsey/composer-install@3.1.1 with: diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index 3ea9386..c950f2a 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -16,7 +16,7 @@ jobs: dependencies: - "locked" php-version: - - "8.4" + - "8.5" operating-system: - "ubuntu-latest" diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index 157e155..e52deb8 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -20,7 +20,7 @@ jobs: dependencies: - "locked" php-version: - - "8.4" + - "8.5" operating-system: - "ubuntu-latest" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 6f34e4d..3d0f5f2 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -20,7 +20,7 @@ jobs: dependencies: - "locked" php-version: - - "8.4" + - "8.5" operating-system: - "ubuntu-latest" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index d832b9f..aeca5e5 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -21,26 +21,17 @@ jobs: - "lowest" - "highest" php-version: - - "8.2" - "8.3" - "8.4" operating-system: - "ubuntu-latest" include: - dependencies: "locked" - php-version: "8.4" - operating-system: "ubuntu-latest" - - dependencies: "locked" - php-version: "8.4" - operating-system: "windows-latest" - - dependencies: "lowest" php-version: "8.5" operating-system: "ubuntu-latest" - composer-options: "--ignore-platform-reqs" - - dependencies: "highest" + - dependencies: "locked" php-version: "8.5" - operating-system: "ubuntu-latest" - composer-options: "--ignore-platform-reqs" + operating-system: "windows-latest" steps: - name: "Checkout" uses: actions/checkout@v6 @@ -51,6 +42,7 @@ jobs: coverage: "pcov" php-version: "${{ matrix.php-version }}" ini-values: memory_limit=-1 + extensions: pdo_pgsql,mongodb - uses: ramsey/composer-install@3.1.1 with: diff --git a/Makefile b/Makefile index d9a2d71..3b49019 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,23 @@ phpstan-baseline: vendor vendor/bin/phpstan analyse --generate-baseline --memory-limit=-1 .PHONY: phpunit -phpunit: vendor ## run phpunit tests - MONGODB_URI="mongodb://localhost:27017" POSTGRES_URI="pgsql:host=localhost;port=5432;dbname=eventstore;user=postgres;password=postgres" XDEBUG_MODE=coverage vendor/bin/phpunit +phpunit: vendor phpunit-unit phpunit-integration ## run phpunit tests + +.PHONY: phpunit-integration +phpunit-integration: vendor ## run phpunit integration tests + MONGODB_URI="mongodb://localhost:27017" POSTGRES_URI="pgsql:host=localhost;port=5432;dbname=eventstore;user=postgres;password=postgres" vendor/bin/phpunit --testsuite=integration + +.PHONY: phpunit-integration-postgres +phpunit-integration-postgres: vendor ## run phpunit integration tests on postgres + POSTGRES_URI="pgsql:host=localhost;port=5432;dbname=eventstore;user=postgres;password=postgres" vendor/bin/phpunit --testsuite=integration + +.PHONY: phpunit-integration-mongodb +phpunit-integration-mongodb: vendor ## run phpunit integration tests on mysql + MONGODB_URI="mongodb://localhost:27017" vendor/bin/phpunit --testsuite=integration + +.PHONY: phpunit-unit +phpunit-unit: vendor ## run phpunit unit tests + XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit .PHONY: infection infection: vendor ## run infection diff --git a/composer.json b/composer.json index 4d46762..37201f9 100644 --- a/composer.json +++ b/composer.json @@ -22,14 +22,14 @@ ], "require": { "php": "~8.3.0 || ~8.4.0 || ~8.5.0" , - "patchlevel/hydrator": "dev-add-methods-on-normalizers as 1.23.0" + "patchlevel/hydrator": "^1.23.0" }, "require-dev": { "ext-mongodb": "^2.1", "infection/infection": "^0.31.9", "mongodb/mongodb": "^2.1", "patchlevel/coding-standard": "^1.3.0", - "patchlevel/rango": "1.0.0-alpha.3", + "patchlevel/rango": "1.0.0-alpha.4", "phpat/phpat": "^0.12.0", "phpbench/phpbench": "^1.2.15", "phpstan/phpstan": "^2.1.32", diff --git a/composer.lock b/composer.lock index 9806354..da86a0b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5679695186cd93daaefd4e8f01bc87e6", + "content-hash": "029da272dcd1d95dd5a84818dde21ca7", "packages": [ { "name": "patchlevel/hydrator", - "version": "dev-add-methods-on-normalizers", + "version": "1.23.0", "source": { "type": "git", "url": "https://github.com/patchlevel/hydrator.git", - "reference": "4b8f1007d56a47764057302c538241bc0b64b79f" + "reference": "70c0602819f7c0a878c9654f5797b788ba427623" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/4b8f1007d56a47764057302c538241bc0b64b79f", - "reference": "4b8f1007d56a47764057302c538241bc0b64b79f", + "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/70c0602819f7c0a878c9654f5797b788ba427623", + "reference": "70c0602819f7c0a878c9654f5797b788ba427623", "shasum": "" }, "require": { @@ -66,9 +66,9 @@ ], "support": { "issues": "https://github.com/patchlevel/hydrator/issues", - "source": "https://github.com/patchlevel/hydrator/tree/add-methods-on-normalizers" + "source": "https://github.com/patchlevel/hydrator/tree/1.23.0" }, - "time": "2026-04-08T14:05:16+00:00" + "time": "2026-04-09T08:24:28+00:00" }, { "name": "psr/cache", @@ -1901,16 +1901,16 @@ }, { "name": "patchlevel/rango", - "version": "1.0.0-alpha.3", + "version": "1.0.0-alpha.4", "source": { "type": "git", "url": "https://github.com/patchlevel/rango.git", - "reference": "140d3f45eb7cfe5578aa318011c19de6a439161f" + "reference": "dc7168f94cf664774b5bf64999486d328c6bc0b8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/rango/zipball/140d3f45eb7cfe5578aa318011c19de6a439161f", - "reference": "140d3f45eb7cfe5578aa318011c19de6a439161f", + "url": "https://api.github.com/repos/patchlevel/rango/zipball/dc7168f94cf664774b5bf64999486d328c6bc0b8", + "reference": "dc7168f94cf664774b5bf64999486d328c6bc0b8", "shasum": "" }, "require": { @@ -1957,9 +1957,9 @@ ], "support": { "issues": "https://github.com/patchlevel/rango/issues", - "source": "https://github.com/patchlevel/rango/tree/1.0.0-alpha.3" + "source": "https://github.com/patchlevel/rango/tree/1.0.0-alpha.4" }, - "time": "2026-03-28T14:39:25+00:00" + "time": "2026-04-09T12:17:35+00:00" }, { "name": "phar-io/manifest", @@ -5936,17 +5936,9 @@ "time": "2024-03-07T20:33:40+00:00" } ], - "aliases": [ - { - "package": "patchlevel/hydrator", - "version": "dev-add-methods-on-normalizers", - "alias": "1.23.0", - "alias_normalized": "1.23.0.0" - } - ], + "aliases": [], "minimum-stability": "stable", "stability-flags": { - "patchlevel/hydrator": 20, "patchlevel/rango": 15 }, "prefer-stable": false, diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..e8a9af1 --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,22 @@ +{ + "source": { + "directories": [ + "src" + ] + }, + "staticAnalysisTool": "phpstan", + "staticAnalysisToolOptions": "--memory-limit=-1", + "logs": { + "text": "infection.log", + "html": "infection.html", + "stryker": { + "report": "/[0-9]+.[0-9]+.x/" + } + }, + "mutators": { + "@default": true + }, + "minMsi": 72, + "minCoveredMsi": 87, + "testFrameworkOptions": "--testsuite=unit" +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e69de29..9154d6d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -0,0 +1,139 @@ +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 \$data of method Patchlevel\\ODM\\Hydrator\\MongoDBCipherKeyStore\:\:hydrate\(\) expects array\{_id\: string, subject_id\: string, key\: non\-empty\-string, method\: non\-empty\-string, created_at\: string\}, array\|object given\.$#' + identifier: argument.type + count: 2 + path: src/Hydrator/MongoDBCipherKeyStore.php + + - + message: '#^Parameter \#1 \$id of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Hydrator/MongoDBCipherKeyStore.php + + - + message: '#^Parameter \#2 \$subjectId of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Hydrator/MongoDBCipherKeyStore.php + + - + message: '#^Method Patchlevel\\ODM\\Hydrator\\RangoCipherKeyStore\:\:collection\(\) should return Patchlevel\\Rango\\Collection\ but returns Patchlevel\\Rango\\Collection\\>\.$#' + identifier: return.type + count: 1 + path: src/Hydrator/RangoCipherKeyStore.php + + - + message: '#^Parameter \#1 \$id of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Hydrator/RangoCipherKeyStore.php + + - + message: '#^Parameter \#2 \$subjectId of class Patchlevel\\Hydrator\\Extension\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + identifier: argument.type + count: 1 + path: src/Hydrator/RangoCipherKeyStore.php + + - + message: '#^Method Patchlevel\\ODM\\Metadata\\AttributeDocumentMetadataFactory\:\:metadata\(\) should return Patchlevel\\ODM\\Metadata\\DocumentMetadata\ but returns Patchlevel\\ODM\\Metadata\\DocumentMetadata\\.$#' + identifier: return.type + count: 1 + path: src/Metadata/AttributeDocumentMetadataFactory.php + + - + message: '#^Parameter \#1 \$filter of method Patchlevel\\ODM\\Metadata\\DocumentMetadata\\:\:mapFilterToFieldPathsWithFields\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 3 + path: src/Metadata/DocumentMetadata.php + + - + message: '#^Parameter \#2 \$children of class Patchlevel\\ODM\\Metadata\\FieldMapping constructor expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Metadata/StackHydratorFieldMappingResolver.php + + - + message: '#^PHPDoc tag @return contains generic type MongoDB\\Collection\\> but class MongoDB\\Collection is not generic\.$#' + identifier: generics.notGeneric + count: 1 + path: src/Repository/MongoDBRepository.php + + - + message: '#^Parameter \#1 \$documents of method MongoDB\\Collection\:\:insertMany\(\) expects list\, array\\> given\.$#' + identifier: argument.type + count: 1 + path: src/Repository/MongoDBRepository.php + + - + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array\|object given\.$#' + identifier: argument.type + count: 2 + path: src/Repository/MongoDBRepository.php + + - + message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\HydratorWithContext\:\:hydrate\(\) expects array\, array\|object\|null given\.$#' + identifier: argument.type + count: 2 + path: src/Repository/MongoDBRepository.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: src/Repository/MongoDBRepository.php + + - + message: '#^Method Patchlevel\\ODM\\Repository\\MongoDBRepositoryManager\:\:get\(\) should return Patchlevel\\ODM\\Repository\\MongoDBRepository\ but returns Patchlevel\\ODM\\Repository\\MongoDBRepository\\.$#' + identifier: return.type + count: 1 + path: src/Repository/MongoDBRepositoryManager.php + + - + message: '#^Parameter \#1 \$documents of method Patchlevel\\Rango\\Collection\\>\:\:insertMany\(\) expects list\\>, array\\> given\.$#' + identifier: argument.type + count: 1 + path: src/Repository/RangoRepository.php + + - + message: '#^Parameter \#1 \$operations of method Patchlevel\\Rango\\Collection\\>\:\:bulkWrite\(\) expects list\\>\>\>, non\-empty\-array\\}\}\}\> given\.$#' + identifier: argument.type + count: 1 + path: src/Repository/RangoRepository.php + + - + message: '#^Property Patchlevel\\ODM\\Repository\\RangoRepository\:\:\$collection with generic class Patchlevel\\Rango\\Collection does not specify its types\: TDocument$#' + identifier: missingType.generics + count: 1 + path: src/Repository/RangoRepository.php + + - + message: '#^Method Patchlevel\\ODM\\Repository\\RangoRepositoryManager\:\:get\(\) should return Patchlevel\\ODM\\Repository\\RangoRepository\ but returns Patchlevel\\ODM\\Repository\\RangoRepository\\.$#' + identifier: return.type + count: 1 + path: src/Repository/RangoRepositoryManager.php + + - + message: '#^Anonymous function should return string but returns mixed\.$#' + identifier: return.type + count: 3 + path: tests/Integration/RepositoryTestCase.php + + - + message: '#^Cannot access offset ''_id'' on array\|object\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/Integration/RepositoryTestCase.php + + - + message: '#^Cannot access offset ''name'' on array\|object\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: tests/Integration/RepositoryTestCase.php + + - + message: '#^Parameter \#1 \$iterator of function iterator_to_array expects iterable, mixed given\.$#' + identifier: argument.type + count: 2 + path: tests/Integration/RepositoryTestCase.php diff --git a/src/Hydrator/MongoDBCipherKeyStore.php b/src/Hydrator/MongoDBCipherKeyStore.php index e378dd3..fc218db 100644 --- a/src/Hydrator/MongoDBCipherKeyStore.php +++ b/src/Hydrator/MongoDBCipherKeyStore.php @@ -59,7 +59,6 @@ public function remove(string $id): void $this->collection()->deleteOne(['_id' => $id]); } - /** @return Collection */ private function collection(): Collection { return $this->database->selectCollection($this->collection); diff --git a/src/Hydrator/ODMMiddleware.php b/src/Hydrator/ODMMiddleware.php index 50ddfca..b86b7d2 100644 --- a/src/Hydrator/ODMMiddleware.php +++ b/src/Hydrator/ODMMiddleware.php @@ -13,6 +13,15 @@ class ODMMiddleware implements Middleware { private const ID_FIELD_NAME = '_id'; + /** + * @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 { $documentMetadata = $context[DocumentMetadata::class] ?? null; @@ -31,6 +40,15 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St 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 { $documentMetadata = $context[DocumentMetadata::class] ?? null; diff --git a/src/Metadata/AttributeDocumentMetadataFactory.php b/src/Metadata/AttributeDocumentMetadataFactory.php index 4845dcb..46cfd89 100644 --- a/src/Metadata/AttributeDocumentMetadataFactory.php +++ b/src/Metadata/AttributeDocumentMetadataFactory.php @@ -12,9 +12,7 @@ final class AttributeDocumentMetadataFactory implements DocumentMetadataFactory { - /** - * @var array, DocumentMetadata> - */ + /** @var array, DocumentMetadata> */ private array $metadataCache = []; public function __construct( diff --git a/src/Metadata/DocumentMetadata.php b/src/Metadata/DocumentMetadata.php index 8ac2489..2ce3e7b 100644 --- a/src/Metadata/DocumentMetadata.php +++ b/src/Metadata/DocumentMetadata.php @@ -6,14 +6,19 @@ use Patchlevel\ODM\Index; -/** - * @template T of object - */ +use function array_is_list; +use function array_map; +use function explode; +use function implode; +use function is_array; +use function str_starts_with; + +/** @template T of object */ final readonly class DocumentMetadata { /** - * @param class-string $className - * @param list $indexes + * @param class-string $className + * @param list $indexes * @param array $fields */ public function __construct( @@ -28,48 +33,41 @@ public function __construct( public function propertyPathToFieldPath(string $propertyPath): string { - $parts = explode('.', $propertyPath); - $fields = $this->fields; - $fieldParts = []; - - foreach ($parts as $part) { - if (!isset($fields[$part])) { - $fieldParts[] = $part; - continue; - } - - $field = $fields[$part]; - $fieldParts[] = $field->fieldName; - $fields = $field->children; - } - - return implode('.', $fieldParts); + return $this->propertyPathToFieldPathWithChildren($propertyPath, $this->fields)[0]; } /** - * @param array $filter + * @param array $filter * - * @return array + * @return array */ public function mapFilterToFieldPaths(array $filter): array + { + return $this->mapFilterToFieldPathsWithFields($filter, $this->fields); + } + + /** + * @param array $filter + * @param array $fields + * + * @return array + */ + private function mapFilterToFieldPathsWithFields(array $filter, array $fields): array { $result = []; foreach ($filter as $key => $value) { - if (!is_string($key)) { - $result[$key] = is_array($value) ? $this->mapFilterToFieldPaths($value) : $value; - continue; - } - if (str_starts_with($key, '$')) { if (is_array($value)) { if (array_is_list($value)) { $result[$key] = array_map( - static fn (mixed $item): mixed => is_array($item) ? $this->mapFilterToFieldPaths($item) : $item, - $value + fn (mixed $item): mixed => is_array($item) + ? $this->mapFilterToFieldPathsWithFields($item, $fields) + : $item, + $value, ); } else { - $result[$key] = $this->mapFilterToFieldPaths($value); + $result[$key] = $this->mapFilterToFieldPathsWithFields($value, $fields); } } else { $result[$key] = $value; @@ -78,14 +76,39 @@ public function mapFilterToFieldPaths(array $filter): array continue; } - $fieldPath = $this->propertyPathToFieldPath($key); + [$fieldPath, $childFields] = $this->propertyPathToFieldPathWithChildren($key, $fields); - $result[$fieldPath] = is_array($value) ? $this->mapFilterToFieldPaths($value) : $value; + $result[$fieldPath] = is_array($value) ? $this->mapFilterToFieldPathsWithFields($value, $childFields) : $value; } return $result; } + /** + * @param array $fields + * + * @return array{0: string, 1: array} + */ + private function propertyPathToFieldPathWithChildren(string $propertyPath, array $fields): array + { + $parts = explode('.', $propertyPath); + $fieldParts = []; + + foreach ($parts as $part) { + if (!isset($fields[$part])) { + $fieldParts[] = $part; + $fields = []; + continue; + } + + $field = $fields[$part]; + $fieldParts[] = $field->fieldName; + $fields = $field->children; + } + + return [implode('.', $fieldParts), $fields]; + } + /** * @param array $orderBy * diff --git a/src/Metadata/FieldMapping.php b/src/Metadata/FieldMapping.php index bd161b0..a82d857 100644 --- a/src/Metadata/FieldMapping.php +++ b/src/Metadata/FieldMapping.php @@ -1,15 +1,15 @@ $children - */ + /** @param array $children */ public function __construct( public string $fieldName, public array $children = [], ) { } -} \ No newline at end of file +} diff --git a/src/Metadata/FieldMappingResolver.php b/src/Metadata/FieldMappingResolver.php index e67b752..b5f67b4 100644 --- a/src/Metadata/FieldMappingResolver.php +++ b/src/Metadata/FieldMappingResolver.php @@ -1,5 +1,7 @@ hydrator->metadata($reflectionProperty->getDeclaringClass()->getName()); $property = $metadata->properties[$reflectionProperty->getName()]; @@ -53,21 +55,22 @@ private function resolveNormalizer(string $fieldName, Normalizer $normalizer): F private function resolveObjectNormalizer(string $fieldName, ObjectNormalizer $objectNormalizer): FieldMapping { - $metadata = $this->hydrator->metadata($objectNormalizer->getClassName()); + $metadata = $this->hydrator->metadata($objectNormalizer->className()); $children = []; foreach ($metadata->properties as $property) { - $children[$property->getName()] = $this->resolvePropertyMetadata($property); + $children[$property->propertyName] = $this->resolvePropertyMetadata($property); } return new FieldMapping( $fieldName, - $children + $children, ); } private function resolveArrayShape(string $fieldName, ArrayShapeNormalizer $arrayShapeNormalizer): FieldMapping { + /** @var array $children */ $children = []; foreach ($arrayShapeNormalizer->innerNormalizers() as $key => $normalizer) { @@ -76,7 +79,7 @@ private function resolveArrayShape(string $fieldName, ArrayShapeNormalizer $arra return new FieldMapping( $fieldName, - $children + $children, ); } -} \ No newline at end of file +} diff --git a/src/Repository/InsertionFailed.php b/src/Repository/InsertionFailed.php new file mode 100644 index 0000000..38bb6b4 --- /dev/null +++ b/src/Repository/InsertionFailed.php @@ -0,0 +1,11 @@ +collection = $this->database->selectCollection($this->metadata->collection); } @@ -35,32 +36,36 @@ public function __construct( /** @param list ...$objects */ public function insert(object ...$objects): void { - if (count($objects) === 1) { - $object = $objects[0]; + try { + if (count($objects) === 1) { + $object = $objects[0]; - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); - } + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } - $data = $this->hydrator->extract( - $object, - [DocumentMetadata::class => $this->metadata], - ); - $this->collection->insertOne($data); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); + $this->collection->insertOne($data); - return; - } - - $this->collection->insertMany(array_map(function (object $object): array { - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); + return; } - return $this->hydrator->extract( - $object, - [DocumentMetadata::class => $this->metadata], - ); - }, $objects)); + $this->collection->insertMany(array_map(function (object $object): array { + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } + + return $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); + }, $objects)); + } catch (ServerException $e) { + throw new InsertionFailed($e->getMessage(), $e->getCode(), $e); + } } /** @param list ...$objects */ @@ -309,16 +314,16 @@ public function updateIndexes(bool $dropUnknown = false): void } foreach (iterator_to_array($this->collection->listIndexes()) as $index) { - if (in_array($index['name'], $desiredNames, true)) { + if (in_array($index->getName(), $desiredNames, true)) { continue; } // Keep the built-in _id index. - if (str_ends_with($index['name'], '_id_')) { + if (str_ends_with($index->getName(), '_id_')) { continue; } - $this->collection->dropIndex($index['name']); + $this->collection->dropIndex($index->getName()); } } } diff --git a/src/Repository/MongoDBRepositoryManager.php b/src/Repository/MongoDBRepositoryManager.php index f5e16b7..5babf86 100644 --- a/src/Repository/MongoDBRepositoryManager.php +++ b/src/Repository/MongoDBRepositoryManager.php @@ -7,7 +7,7 @@ use MongoDB\Client; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension; -use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\ODM\Hydrator\ODMExtension; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; @@ -22,7 +22,7 @@ final class MongoDBRepositoryManager implements RepositoryManager public function __construct( private readonly Client $client, private readonly DocumentMetadataFactory $metadataFactory, - private readonly Hydrator $hydrator, + private readonly HydratorWithContext $hydrator, private readonly string $defaultDatabase = 'default', ) { } diff --git a/src/Repository/RangoRepository.php b/src/Repository/RangoRepository.php index 9a9b9a5..f356ea0 100644 --- a/src/Repository/RangoRepository.php +++ b/src/Repository/RangoRepository.php @@ -4,10 +4,11 @@ namespace Patchlevel\ODM\Repository; -use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use Patchlevel\ODM\Metadata\DocumentMetadata; use Patchlevel\Rango\Collection; use Patchlevel\Rango\Database; +use Patchlevel\Rango\Exception\QueryException; use function array_map; use function count; @@ -26,7 +27,7 @@ public function __construct( private Database $database, private DocumentMetadata $metadata, - private Hydrator $hydrator, + private HydratorWithContext $hydrator, ) { $this->collection = $this->database->getCollection($this->metadata->collection); } @@ -34,33 +35,42 @@ public function __construct( /** @param list ...$objects */ public function insert(object ...$objects): void { - if (count($objects) === 1) { - $object = $objects[0]; + try { + if (count($objects) === 1) { + $object = $objects[0]; - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); - } - - $data = $this->hydrator->extract( - $object, - [DocumentMetadata::class => $this->metadata], - ); + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } - $this->collection->insertOne($data); + $data = $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); - return; - } + $this->collection->insertOne($data); - $this->collection->insertMany(array_map(function (object $object): array { - if ($object::class !== $this->metadata->className) { - throw new WrongClass($this->metadata->className, $object::class); + return; } - return $this->hydrator->extract( - $object, - [DocumentMetadata::class => $this->metadata], + $this->collection->insertMany( + array_map( + function (object $object): array { + if ($object::class !== $this->metadata->className) { + throw new WrongClass($this->metadata->className, $object::class); + } + + return $this->hydrator->extract( + $object, + [DocumentMetadata::class => $this->metadata], + ); + }, + $objects, + ), ); - }, $objects)); + } catch (QueryException $e) { + throw new InsertionFailed($e->getMessage(), $e->getCode(), $e); + } } /** @param list ...$objects */ @@ -292,16 +302,16 @@ public function updateIndexes(bool $dropUnknown = false): void } foreach ($this->collection->listIndexes() as $index) { - if (in_array($index['name'], $desiredNames, true)) { + if (in_array($index->getName(), $desiredNames, true)) { continue; } // Keep primary key index. - if (str_ends_with($index['name'], '_pkey')) { + if (str_ends_with($index->getName(), '_pkey')) { continue; } - $this->collection->dropIndex($index['name']); + $this->collection->dropIndex($index->getName()); } } } diff --git a/src/Repository/RangoRepositoryManager.php b/src/Repository/RangoRepositoryManager.php index e86fcea..a42c763 100644 --- a/src/Repository/RangoRepositoryManager.php +++ b/src/Repository/RangoRepositoryManager.php @@ -6,7 +6,7 @@ use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension; -use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use Patchlevel\Hydrator\StackHydratorBuilder; use Patchlevel\ODM\Hydrator\ODMExtension; use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; @@ -22,7 +22,7 @@ final class RangoRepositoryManager implements RepositoryManager public function __construct( private readonly Client $client, private readonly DocumentMetadataFactory $metadataFactory, - private readonly Hydrator $hydrator, + private readonly HydratorWithContext $hydrator, private readonly string $defaultDatabase = 'public', ) { } diff --git a/tests/Integration/MongoDBRepositoryTest.php b/tests/Integration/MongoDBRepositoryTest.php index ae2c047..6e7315e 100644 --- a/tests/Integration/MongoDBRepositoryTest.php +++ b/tests/Integration/MongoDBRepositoryTest.php @@ -5,508 +5,31 @@ namespace Patchlevel\ODM\Tests\Integration; use MongoDB\Client; -use MongoDB\Driver\Exception\BulkWriteException; -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; -use Patchlevel\ODM\Hydrator\ODMExtension; -use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; +use Patchlevel\Hydrator\HydratorWithContext; +use Patchlevel\ODM\Metadata\DocumentMetadataFactory; use Patchlevel\ODM\Repository\MongoDBRepositoryManager; -use Patchlevel\ODM\Tests\Integration\Fixtures\Profile; -use Patchlevel\ODM\Tests\Integration\Fixtures\Skill; -use Patchlevel\ODM\Tests\Integration\Fixtures\Status; -use Patchlevel\ODM\Tests\Integration\Fixtures\UniqueProfile; -use PHPUnit\Framework\TestCase; -use function array_map; use function getenv; -use function is_array; -use function iterator_to_array; -class MongoDBRepositoryTest extends TestCase +final class MongoDBRepositoryTest extends RepositoryTestCase { - protected MongoDBRepositoryManager $repositoryManager; - private Client $client; - - public function setUp(): void - { - $this->client = new Client(getenv('MONGODB_URI')); - - $documentMetadataFactory = new AttributeDocumentMetadataFactory(); - - $hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension()) - ->build(); + public function createRepositoryManager( + HydratorWithContext $hydrator, + DocumentMetadataFactory $documentMetadataFactory, + ): MongoDBRepositoryManager { + $uri = getenv('MONGODB_URI'); + + if (!$uri) { + $this->markTestSkipped('MONGODB_URI is not set'); + } - $this->client->dropDatabase('patchlevel'); + $client = new Client($uri); - $this->repositoryManager = new MongoDBRepositoryManager( - $this->client, + return new MongoDBRepositoryManager( + $client, $documentMetadataFactory, $hydrator, 'patchlevel', ); } - - protected function tearDown(): void - { - $this->client->dropDatabase('patchlevel'); - } - - public function testInsert(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $document = new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]); - $repository->insert($document); - - $raw = $repository->collection()->findOne(['_id' => 'r-1']); - - self::assertNotNull($raw); - self::assertSame('r-1', $raw['_id']); - self::assertSame('Rango', $raw['name']); - self::assertSame('active', $raw['status']); - - $skills = $raw['skills']; - if (! is_array($skills)) { - $skills = iterator_to_array($skills); - } - - self::assertSame(['php'], $skills); - } - - public function testInsertMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->insert( - new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), - new Profile('r-2', 'Beans', Status::INACTIVE, [new Skill('js')]), - ); - - self::assertSame(2, $repository->count()); - self::assertTrue($repository->has('r-1')); - self::assertTrue($repository->has('r-2')); - } - - public function testUpdate(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->update(new Profile('r-1', 'Updated', Status::INACTIVE, [new Skill('go')])); - - $updated = $repository->collection()->findOne(['_id' => 'r-1']); - - self::assertNotNull($updated); - self::assertSame('Updated', $updated['name']); - self::assertSame('inactive', $updated['status']); - self::assertSame(['go'], is_array($updated['skills']) ? $updated['skills'] : iterator_to_array($updated['skills'])); - } - - public function testUpdateMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $repository->update( - new Profile('r-1', 'Rango Updated', Status::ACTIVE, [new Skill('php'), new Skill('mongodb')]), - new Profile('r-2', 'Beans Updated', Status::ACTIVE, [new Skill('ts')]), - ); - - $r1 = $repository->collection()->findOne(['_id' => 'r-1']); - $r2 = $repository->collection()->findOne(['_id' => 'r-2']); - - self::assertNotNull($r1); - self::assertNotNull($r2); - self::assertSame('Rango Updated', $r1['name']); - self::assertSame('Beans Updated', $r2['name']); - } - - public function testLoad(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $loaded = $repository->find('r-1'); - - self::assertEquals(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), $loaded); - } - - public function testHas(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - self::assertFalse($repository->has('r-1')); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - self::assertTrue($repository->has('r-1')); - } - - public function testCount(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - self::assertSame(0, $repository->count()); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - self::assertSame(2, $repository->count()); - } - - public function testFindWithFilter(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy(['status' => 'active']), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithFilterById(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithLimit(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], limit: 2), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-2'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithOffset(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], offset: 1), false); - - self::assertCount(2, $results); - self::assertSame(['r-2', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithSort(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], orderBy: ['name' => 'asc']), false); - - self::assertCount(3, $results); - self::assertSame(['r-2', 'r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindOne(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $result = $repository->findOneBy(['name' => 'Beans']); - - self::assertNotNull($result); - self::assertSame('r-2', $result->id); - } - - public function testNotFindOne(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $result = $repository->findOneBy(['name' => 'Foo']); - - self::assertNull($result); - } - - public function testRemove(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $repository->remove('r-1'); - - self::assertFalse($repository->has('r-1')); - self::assertNull($repository->collection()->findOne(['_id' => 'r-1'])); - self::assertSame(1, $repository->count()); - } - - public function testRemoveMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Elsa', - 'status' => 'active', - 'skills' => ['go'], - ]); - - $repository->remove('r-1', 'r-3'); - - self::assertFalse($repository->has('r-1')); - self::assertFalse($repository->has('r-3')); - self::assertTrue($repository->has('r-2')); - self::assertSame(1, $repository->count()); - } - - public function testDropCollection(): void - { - $repository = $this->repositoryManager->get(Profile::class); - $repository->insert(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')])); - - $repository->dropCollection(); - - self::assertSame(0, $repository->count()); - } - - public function testCreateCollectionCreatesIndexes(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->createCollection(); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - } - - public function testUpdateIndexesCreatesIndexes(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->updateIndexes(); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - } - - public function testUpdateIndexesDropsUnknownWhenRequested(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->updateIndexes(); - $repository->collection()->createIndex(['name' => 1], ['name' => 'custom_idx']); - - $repository->updateIndexes(true); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - self::assertNotContains('custom_idx', $indexNames); - } - - public function testUniqueIndexIsEnforced(): void - { - $repository = $this->repositoryManager->get(UniqueProfile::class); - - $repository->updateIndexes(); - $repository->collection()->insertOne([ - '_id' => 'u-1', - 'email' => 'rango@example.com', - ]); - - $this->expectException(BulkWriteException::class); - - $repository->collection()->insertOne([ - '_id' => 'u-2', - 'email' => 'rango@example.com', - ]); - } } diff --git a/tests/Integration/RangoRepositoryTest.php b/tests/Integration/RangoRepositoryTest.php index 18571c8..fd143cc 100644 --- a/tests/Integration/RangoRepositoryTest.php +++ b/tests/Integration/RangoRepositoryTest.php @@ -4,509 +4,32 @@ namespace Patchlevel\ODM\Tests\Integration; -use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\StackHydratorBuilder; -use Patchlevel\ODM\Hydrator\ODMExtension; -use Patchlevel\ODM\Metadata\AttributeDocumentMetadataFactory; +use Patchlevel\Hydrator\HydratorWithContext; +use Patchlevel\ODM\Metadata\DocumentMetadataFactory; use Patchlevel\ODM\Repository\RangoRepositoryManager; -use Patchlevel\ODM\Tests\Integration\Fixtures\Profile; -use Patchlevel\ODM\Tests\Integration\Fixtures\Skill; -use Patchlevel\ODM\Tests\Integration\Fixtures\Status; -use Patchlevel\ODM\Tests\Integration\Fixtures\UniqueProfile; use Patchlevel\Rango\Client; -use Patchlevel\Rango\Exception\QueryException; -use PHPUnit\Framework\TestCase; -use function array_map; use function getenv; -use function iterator_to_array; -class RangoRepositoryTest extends TestCase +final class RangoRepositoryTest extends RepositoryTestCase { - protected RangoRepositoryManager $repositoryManager; - private Client $client; - - public function setUp(): void - { + public function createRepositoryManager( + HydratorWithContext $hydrator, + DocumentMetadataFactory $documentMetadataFactory, + ): RangoRepositoryManager { $uri = getenv('POSTGRES_URI'); if (!$uri) { - self::markTestSkipped('POSTGRES_URI is not set'); + $this->markTestSkipped('POSTGRES_URI is not set'); } - $this->client = new Client($uri); - - $documentMetadataFactory = new AttributeDocumentMetadataFactory(); - - $hydrator = (new StackHydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new ODMExtension()) - ->build(); - - $this->client->dropDatabase('patchlevel'); + $client = new Client($uri); - $this->repositoryManager = new RangoRepositoryManager( - $this->client, + return new RangoRepositoryManager( + $client, $documentMetadataFactory, $hydrator, 'patchlevel', ); } - - protected function tearDown(): void - { - $this->client->dropDatabase('patchlevel'); - } - - public function testInsert(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $document = new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]); - $repository->insert($document); - - $raw = $repository->collection()->findOne(['_id' => 'r-1']); - - self::assertNotNull($raw); - self::assertEquals( - ['_id' => 'r-1', 'name' => 'Rango', 'status' => 'active', 'skills' => ['php']], - $raw, - ); - } - - public function testInsertMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->insert( - new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), - new Profile('r-2', 'Beans', Status::INACTIVE, [new Skill('js')]), - ); - - self::assertSame(2, $repository->count()); - self::assertTrue($repository->has('r-1')); - self::assertTrue($repository->has('r-2')); - } - - public function testUpdate(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->update(new Profile('r-1', 'Updated', Status::INACTIVE, [new Skill('go')])); - - $updated = $repository->collection()->findOne(['_id' => 'r-1']); - - self::assertNotNull($updated); - self::assertEquals( - ['_id' => 'r-1', 'name' => 'Updated', 'status' => 'inactive', 'skills' => ['go']], - $updated, - ); - } - - public function testUpdateMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $repository->update( - new Profile('r-1', 'Rango Updated', Status::ACTIVE, [new Skill('php'), new Skill('mongodb')]), - new Profile('r-2', 'Beans Updated', Status::ACTIVE, [new Skill('ts')]), - ); - - $r1 = $repository->collection()->findOne(['_id' => 'r-1']); - $r2 = $repository->collection()->findOne(['_id' => 'r-2']); - - self::assertNotNull($r1); - self::assertNotNull($r2); - self::assertSame('Rango Updated', $r1['name']); - self::assertSame('Beans Updated', $r2['name']); - } - - public function testLoad(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $loaded = $repository->find('r-1'); - - self::assertEquals(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), $loaded); - } - - public function testHas(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - self::assertFalse($repository->has('r-1')); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - self::assertTrue($repository->has('r-1')); - } - - public function testCount(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - self::assertSame(0, $repository->count()); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - self::assertSame(2, $repository->count()); - } - - public function testFindWithFilter(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy(['status' => 'active']), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithFilterById(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithLimit(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], limit: 2), false); - - self::assertCount(2, $results); - self::assertSame(['r-1', 'r-2'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithOffset(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], offset: 1), false); - - self::assertCount(2, $results); - self::assertSame(['r-2', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindWithSort(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['tracking'], - ]); - - $results = iterator_to_array($repository->findBy([], orderBy: ['name' => 'asc']), false); - - self::assertCount(3, $results); - self::assertSame(['r-2', 'r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); - } - - public function testFindOne(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $result = $repository->findOneBy(['name' => 'Beans']); - - self::assertNotNull($result); - self::assertSame('r-2', $result->id); - } - - public function testNotFindOne(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $result = $repository->findOneBy(['name' => 'Foo']); - - self::assertNull($result); - } - - public function testRemove(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - - $repository->remove('r-1'); - - self::assertFalse($repository->has('r-1')); - self::assertNull($repository->collection()->findOne(['_id' => 'r-1'])); - self::assertSame(1, $repository->count()); - } - - public function testRemoveMany(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->collection()->insertOne([ - '_id' => 'r-1', - 'name' => 'Rango', - 'status' => 'active', - 'skills' => ['php'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-2', - 'name' => 'Beans', - 'status' => 'inactive', - 'skills' => ['js'], - ]); - $repository->collection()->insertOne([ - '_id' => 'r-3', - 'name' => 'Elsa', - 'status' => 'active', - 'skills' => ['go'], - ]); - - $repository->remove('r-1', 'r-3'); - - self::assertFalse($repository->has('r-1')); - self::assertFalse($repository->has('r-3')); - self::assertTrue($repository->has('r-2')); - self::assertSame(1, $repository->count()); - } - - public function testDropCollection(): void - { - $repository = $this->repositoryManager->get(Profile::class); - $repository->insert(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')])); - - $repository->dropCollection(); - - self::assertSame(0, $repository->count()); - } - - public function testCreateCollectionCreatesIndexes(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->createCollection(); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - } - - public function testUpdateIndexesCreatesIndexes(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->updateIndexes(); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - } - - public function testUpdateIndexesDropsUnknownWhenRequested(): void - { - $repository = $this->repositoryManager->get(Profile::class); - - $repository->updateIndexes(); - $repository->collection()->createIndex(['status' => 1], ['name' => 'custom_idx']); - - $repository->updateIndexes(true); - - $indexes = iterator_to_array($repository->collection()->listIndexes(), false); - $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); - - self::assertContains('by_status', $indexNames); - self::assertNotContains('custom_idx', $indexNames); - } - - public function testUniqueIndexIsEnforced(): void - { - $repository = $this->repositoryManager->get(UniqueProfile::class); - - $repository->updateIndexes(); - $repository->collection()->insertOne([ - '_id' => 'u-1', - 'email' => 'rango@example.com', - ]); - - $this->expectException(QueryException::class); - - $repository->collection()->insertOne([ - '_id' => 'u-2', - 'email' => 'rango@example.com', - ]); - } } diff --git a/tests/Integration/RepositoryTestCase.php b/tests/Integration/RepositoryTestCase.php new file mode 100644 index 0000000..ed365af --- /dev/null +++ b/tests/Integration/RepositoryTestCase.php @@ -0,0 +1,506 @@ +useExtension(new CoreExtension()) + ->useExtension(new ODMExtension()) + ->build(); + + $this->repositoryManager = $this->createRepositoryManager($hydrator, $documentMetadataFactory); + $this->repositoryManager->get(Profile::class)->database()->drop(); + } + + public function testInsert(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $document = new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]); + $repository->insert($document); + + $raw = $repository->collection()->findOne(['_id' => 'r-1']); + + self::assertNotNull($raw); + self::assertSame('r-1', $raw['_id']); + self::assertSame('Rango', $raw['name']); + self::assertSame('active', $raw['status']); + + $skills = $raw['skills']; + if (!is_array($skills)) { + $skills = iterator_to_array($skills); + } + + self::assertSame(['php'], $skills); + } + + public function testInsertMany(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->insert( + new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), + new Profile('r-2', 'Beans', Status::INACTIVE, [new Skill('js')]), + ); + + self::assertSame(2, $repository->count()); + self::assertTrue($repository->has('r-1')); + self::assertTrue($repository->has('r-2')); + } + + public function testUpdate(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + + $repository->update(new Profile('r-1', 'Updated', Status::INACTIVE, [new Skill('go')])); + + $updated = $repository->collection()->findOne(['_id' => 'r-1']); + + self::assertNotNull($updated); + self::assertSame('Updated', $updated['name']); + self::assertSame('inactive', $updated['status']); + self::assertSame( + ['go'], + is_array($updated['skills']) ? $updated['skills'] : iterator_to_array($updated['skills']), + ); + } + + public function testUpdateMany(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + + $repository->update( + new Profile('r-1', 'Rango Updated', Status::ACTIVE, [new Skill('php'), new Skill('mongodb')]), + new Profile('r-2', 'Beans Updated', Status::ACTIVE, [new Skill('ts')]), + ); + + $r1 = $repository->collection()->findOne(['_id' => 'r-1']); + $r2 = $repository->collection()->findOne(['_id' => 'r-2']); + + self::assertNotNull($r1); + self::assertNotNull($r2); + self::assertSame('Rango Updated', $r1['name']); + self::assertSame('Beans Updated', $r2['name']); + } + + public function testLoad(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $loaded = $repository->find('r-1'); + + self::assertEquals(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')]), $loaded); + } + + public function testHas(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + self::assertFalse($repository->has('r-1')); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + + self::assertTrue($repository->has('r-1')); + } + + public function testCount(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + self::assertSame(0, $repository->count()); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + + self::assertSame(2, $repository->count()); + } + + public function testFindWithFilter(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy(['status' => 'active']), false); + + self::assertCount(2, $results); + self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + + public function testFindWithFilterById(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy(['id' => ['$in' => ['r-1', 'r-3']]]), false); + + self::assertCount(2, $results); + self::assertSame(['r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + + public function testFindWithLimit(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy([], limit: 2), false); + + self::assertCount(2, $results); + self::assertSame(['r-1', 'r-2'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + + public function testFindWithOffset(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy([], offset: 1), false); + + self::assertCount(2, $results); + self::assertSame(['r-2', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + + public function testFindWithSort(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['tracking'], + ]); + + $results = iterator_to_array($repository->findBy([], orderBy: ['name' => 'asc']), false); + + self::assertCount(3, $results); + self::assertSame(['r-2', 'r-1', 'r-3'], array_map(static fn (Profile $doc) => $doc->id, $results)); + } + + public function testFindOne(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + + $result = $repository->findOneBy(['name' => 'Beans']); + + self::assertNotNull($result); + self::assertSame('r-2', $result->id); + } + + public function testNotFindOne(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + + $result = $repository->findOneBy(['name' => 'Foo']); + + self::assertNull($result); + } + + public function testRemove(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + + $repository->remove('r-1'); + + self::assertFalse($repository->has('r-1')); + self::assertNull($repository->collection()->findOne(['_id' => 'r-1'])); + self::assertSame(1, $repository->count()); + } + + public function testRemoveMany(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->collection()->insertOne([ + '_id' => 'r-1', + 'name' => 'Rango', + 'status' => 'active', + 'skills' => ['php'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-2', + 'name' => 'Beans', + 'status' => 'inactive', + 'skills' => ['js'], + ]); + $repository->collection()->insertOne([ + '_id' => 'r-3', + 'name' => 'Elsa', + 'status' => 'active', + 'skills' => ['go'], + ]); + + $repository->remove('r-1', 'r-3'); + + self::assertFalse($repository->has('r-1')); + self::assertFalse($repository->has('r-3')); + self::assertTrue($repository->has('r-2')); + self::assertSame(1, $repository->count()); + } + + public function testDropCollection(): void + { + $repository = $this->repositoryManager->get(Profile::class); + $repository->insert(new Profile('r-1', 'Rango', Status::ACTIVE, [new Skill('php')])); + + $repository->dropCollection(); + + self::assertSame(0, $repository->count()); + } + + public function testCreateCollectionCreatesIndexes(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->createCollection(); + + $indexes = iterator_to_array($repository->collection()->listIndexes(), false); + $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); + + self::assertContains('by_status', $indexNames); + } + + public function testUpdateIndexesCreatesIndexes(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->updateIndexes(); + + $indexes = iterator_to_array($repository->collection()->listIndexes(), false); + $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); + + self::assertContains('by_status', $indexNames); + } + + public function testUpdateIndexesDropsUnknownWhenRequested(): void + { + $repository = $this->repositoryManager->get(Profile::class); + + $repository->updateIndexes(); + $repository->collection()->createIndex(['name' => 1], ['name' => 'custom_idx']); + + $repository->updateIndexes(true); + + $indexes = iterator_to_array($repository->collection()->listIndexes(), false); + $indexNames = array_map(static fn ($index): string => $index['name'], $indexes); + + self::assertContains('by_status', $indexNames); + self::assertNotContains('custom_idx', $indexNames); + } + + public function testUniqueIndexIsEnforced(): void + { + $repository = $this->repositoryManager->get(UniqueProfile::class); + + $repository->updateIndexes(); + + $repository->insert( + new UniqueProfile('r-1', 'Rango'), + ); + + $this->expectException(InsertionFailed::class); + + $repository->insert( + new UniqueProfile('r-1', 'Rango'), + ); + } +} diff --git a/tests/Unit/Fixtures/Address.php b/tests/Unit/Fixtures/Address.php new file mode 100644 index 0000000..e41767d --- /dev/null +++ b/tests/Unit/Fixtures/Address.php @@ -0,0 +1,18 @@ + 'asc'])] +final readonly class Profile +{ + /** + * @param list $skills + * @param list
$addresses + * @param array{height: int, addresses: list
} $stats + */ + public function __construct( + #[Id] + public string $id, + #[NormalizedName('_personal_data')] + public PersonalData $personalData, + public Status $status, + #[NormalizedName('_skills')] + public array $skills, + #[NormalizedName('_addresses')] + public array $addresses, + #[NormalizedName('_stats')] + public array $stats, + ) { + } +} diff --git a/tests/Unit/Fixtures/Skill.php b/tests/Unit/Fixtures/Skill.php new file mode 100644 index 0000000..8fd385e --- /dev/null +++ b/tests/Unit/Fixtures/Skill.php @@ -0,0 +1,14 @@ + $context */ + public function normalize(mixed $value, array $context = []): mixed + { + if ($value === null) { + return null; + } + + if (!$value instanceof Skill) { + throw new InvalidType(); + } + + return $value->value; + } + + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): mixed + { + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw new InvalidType(); + } + + return new Skill($value); + } +} diff --git a/tests/Unit/Fixtures/Status.php b/tests/Unit/Fixtures/Status.php new file mode 100644 index 0000000..4d5d02e --- /dev/null +++ b/tests/Unit/Fixtures/Status.php @@ -0,0 +1,11 @@ +propertyPathToFieldPath('unknown')); } + + public function testMapFilterWithoutMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + self::assertSame(['name' => 'foo'], $metadata->mapFilterToFieldPaths(['name' => 'foo'])); + } + + public function testMapFilterWithMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'name' => new FieldMapping('_name'), + ], + ); + + self::assertSame(['_name' => 'foo'], $metadata->mapFilterToFieldPaths(['name' => 'foo'])); + } + + public function testMapFilterWithNestedMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address', [ + 'street' => new FieldMapping('_street'), + ]), + ], + ); + + self::assertSame( + ['_address._street' => 'Main St'], + $metadata->mapFilterToFieldPaths(['address.street' => 'Main St']), + ); + } + + public function testMapFilterWithComparisonOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'age' => new FieldMapping('_age'), + ], + ); + + self::assertSame( + ['_age' => ['$gt' => 18]], + $metadata->mapFilterToFieldPaths(['age' => ['$gt' => 18]]), + ); + } + + public function testMapFilterWithElemMatch(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'tags' => new FieldMapping('_tags', [ + 'value' => new FieldMapping('_value'), + ]), + ], + ); + + self::assertSame( + ['_tags' => ['$elemMatch' => ['_value' => 'php']]], + $metadata->mapFilterToFieldPaths(['tags' => ['$elemMatch' => ['value' => 'php']]]), + ); + } + + public function testMapFilterWithAndOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'status' => new FieldMapping('_status'), + 'age' => new FieldMapping('_age'), + ], + ); + + self::assertSame( + ['$and' => [['_status' => 'active'], ['_age' => ['$gt' => 18]]]], + $metadata->mapFilterToFieldPaths(['$and' => [['status' => 'active'], ['age' => ['$gt' => 18]]]]), + ); + } + + public function testMapFilterWithOrOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'status' => new FieldMapping('_status'), + ], + ); + + self::assertSame( + ['$or' => [['_status' => 'active'], ['_status' => 'pending']]], + $metadata->mapFilterToFieldPaths(['$or' => [['status' => 'active'], ['status' => 'pending']]]), + ); + } + + public function testMapFilterWithNorOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'status' => new FieldMapping('_status'), + ], + ); + + self::assertSame( + ['$nor' => [['_status' => 'deleted'], ['_status' => 'banned']]], + $metadata->mapFilterToFieldPaths(['$nor' => [['status' => 'deleted'], ['status' => 'banned']]]), + ); + } + + public function testMapFilterWithNotOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'status' => new FieldMapping('_status'), + ], + ); + + self::assertSame( + ['_status' => ['$not' => ['$eq' => 'deleted']]], + $metadata->mapFilterToFieldPaths(['status' => ['$not' => ['$eq' => 'deleted']]]), + ); + } + + public function testMapFilterEmptyFilter(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + self::assertSame([], $metadata->mapFilterToFieldPaths([])); + } + + public function testMapFilterWithValueSameAsPropertyName(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address', [ + 'street' => new FieldMapping('_street'), + ]), + ], + ); + + self::assertSame( + ['_address._street' => 'address'], + $metadata->mapFilterToFieldPaths(['address.street' => 'address']), + ); + } + + public function testMapFilterWithValueSameAsPropertyNameAndOperator(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address', [ + 'street' => new FieldMapping('_street'), + ]), + ], + ); + + self::assertSame( + ['_address._street' => ['$in' => ['street', 'address']]], + $metadata->mapFilterToFieldPaths(['address.street' => ['$in' => ['street', 'address']]]), + ); + } + + public function testMapSortingWithoutMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + self::assertSame(['name' => 1], $metadata->mapSortingToFieldPaths(['name' => 'asc'])); + } + + public function testMapSortingAscWithMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'name' => new FieldMapping('_name'), + ], + ); + + self::assertSame(['_name' => 1], $metadata->mapSortingToFieldPaths(['name' => 'asc'])); + } + + public function testMapSortingDescWithMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'name' => new FieldMapping('_name'), + ], + ); + + self::assertSame(['_name' => -1], $metadata->mapSortingToFieldPaths(['name' => 'desc'])); + } + + public function testMapSortingMultipleFields(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'lastName' => new FieldMapping('_lastName'), + 'firstName' => new FieldMapping('_firstName'), + ], + ); + + self::assertSame( + ['_lastName' => 1, '_firstName' => -1], + $metadata->mapSortingToFieldPaths(['lastName' => 'asc', 'firstName' => 'desc']), + ); + } + + public function testMapSortingWithNestedMapping(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + fields: [ + 'address' => new FieldMapping('_address', [ + 'city' => new FieldMapping('_city'), + ]), + ], + ); + + self::assertSame( + ['_address._city' => 1], + $metadata->mapSortingToFieldPaths(['address.city' => 'asc']), + ); + } + + public function testMapSortingEmptyOrderBy(): void + { + $metadata = new DocumentMetadata( + className: stdClass::class, + database: null, + collection: 'test', + idProperty: 'id', + ); + + self::assertSame([], $metadata->mapSortingToFieldPaths([])); + } } diff --git a/tests/Unit/Metadata/StackHydratorFieldMappingResolverTest.php b/tests/Unit/Metadata/StackHydratorFieldMappingResolverTest.php new file mode 100644 index 0000000..1d77e6b --- /dev/null +++ b/tests/Unit/Metadata/StackHydratorFieldMappingResolverTest.php @@ -0,0 +1,90 @@ +resolver = new StackHydratorFieldMappingResolver( + new StackHydrator( + new AttributeMetadataFactory( + null, + new BuiltInGuesser(), + ), + ), + ); + } + + public function testResolvePropertyWithoutNormalizer(): void + { + $mapping = $this->resolver->resolve(new ReflectionProperty(PersonalData::class, 'name')); + + self::assertEquals(new FieldMapping('_name'), $mapping); + } + + public function testResolvePropertyWithObjectNormalizer(): void + { + $mapping = $this->resolver->resolve(new ReflectionProperty(Profile::class, 'personalData')); + + self::assertEquals( + new FieldMapping('_personal_data', [ + 'name' => new FieldMapping('_name'), + 'age' => new FieldMapping('_age'), + ]), + $mapping, + ); + } + + public function testResolvePropertyWithArrayOfObjectNormalizer(): void + { + $mapping = $this->resolver->resolve(new ReflectionProperty(Profile::class, 'addresses')); + + self::assertEquals( + new FieldMapping('_addresses', [ + 'street' => new FieldMapping('_street'), + 'city' => new FieldMapping('_city'), + ]), + $mapping, + ); + } + + public function testResolvePropertyWithCustomNormalizer(): void + { + $mapping = $this->resolver->resolve(new ReflectionProperty(Profile::class, 'skills')); + + self::assertEquals( + new FieldMapping('_skills'), + $mapping, + ); + } + + public function testResolvePropertyWithArrayShapeNormalizer(): void + { + $mapping = $this->resolver->resolve(new ReflectionProperty(Profile::class, 'stats')); + + self::assertEquals( + new FieldMapping('_stats', [ + 'addresses' => new FieldMapping('addresses', [ + 'street' => new FieldMapping('_street'), + 'city' => new FieldMapping('_city'), + ]), + ]), + $mapping, + ); + } +}