diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3e2edc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + tests: + name: "PHPUnit (PHP ${{ matrix.php }})" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + steps: + - uses: actions/checkout@v4 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 5afc422..837f356 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # Composer composer.lock vendor + +# PHPUnit +.phpunit.cache diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b255a7d --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +#!/usr/bin/make -f + +SHELL:=/bin/bash + +define checkExecutables + $(foreach exec,$(1),\ + $(if $(shell command -v $(exec)),,$(error Unable to find `$(exec)` in your PATH))) +endef + +.PHONY: help +help: ## Shows this help + $(call checkExecutables, fgrep) + @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' + +.PHONY: init +init: ## Initializes the project and all dependencies + $(call checkExecutables, composer) + @make install-vendors + +.PHONY: test +test: ## Runs all tests + @vendor/bin/phpunit + +.PHONY: install-vendors +install-vendors: ## Installs all vendor dependencies + $(call checkExecutables, composer) + @composer install + +.PHONY: update-vendors +update-vendors: ## Updates all vendor dependencies + $(call checkExecutables, composer) + @composer update diff --git a/README.md b/README.md index c8e61a1..ada8ff8 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Use the following instructions to get started with this Doctrine extension. ### Prerequisites -This extension is compatible with [Doctrine 3](https://github.com/doctrine/orm) and PHP >= 8.1. +This extension is compatible with [Doctrine 3](https://github.com/doctrine/orm) and PHP >= 8.2. *If you're looking for PHP >= 7.4 support, please use `1.0.3`, the last version to support it* ### Installation ```bash -composer install rentpost/doctrine-multi-tenancy +composer require rentpost/doctrine-multi-tenancy ``` ### Setup @@ -216,6 +216,48 @@ specify a context where no filters will be applied. This can be especially usef with the `FilterStrategy::FirstMatch` strategy. The combination of these two allows you to entirely, or selectively, ignore all multi-tenancy for an entity - for a given context, that is. +## Using ConditionResolver for Raw SQL Queries + +The `Filter` class (Doctrine's `SQLFilter`) only applies to DQL queries. For raw SQL queries in repositories, use `ConditionResolver` directly to get the same multi-tenancy conditions: + +```php +use Rentpost\Doctrine\MultiTenancy\ConditionResolver; + +// The Listener is the same one registered with the EventManager during setup +$resolver = new ConditionResolver($listener); + +// Resolve conditions for a given entity class and table alias +$condition = $resolver->resolve(Product::class, 'p'); +// Returns e.g.: "p.company_id = 42 AND p.id IN(SELECT product_id FROM ...)" + +// Use in a raw SQL query +$sql = "SELECT p.* FROM product p WHERE {$condition} AND p.status = 'active'"; +``` + +The `resolve()` method reads the `#[MultiTenancy]` attribute from the entity class, evaluates the current context via `ContextProviders`, substitutes values from `ValueHolders`, and returns the composed WHERE clause fragment. It uses the same logic as the `Filter` — just without requiring Doctrine's DQL layer. + +## Development + +### Running Tests + +```bash +make test +``` + +Or directly via PHPUnit: + +```bash +vendor/bin/phpunit +``` + +### Setup + +```bash +make init +``` + +This installs all Composer dependencies, including PHPUnit for running the test suite. + ## Issues / Bugs / Questions Please feel free to raise an issue against this repository if you have any questions or problems. diff --git a/composer.json b/composer.json index 257e311..b9e7936 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,20 @@ "doctrine/orm": "^2.10 || ^3.0", "doctrine/event-manager": "^2.0" }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, "autoload": { "psr-4": { "Rentpost\\Doctrine\\MultiTenancy\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Rentpost\\Doctrine\\MultiTenancy\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..cf50813 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/src/ConditionResolver.php b/src/ConditionResolver.php new file mode 100644 index 0000000..d236de2 --- /dev/null +++ b/src/ConditionResolver.php @@ -0,0 +1,201 @@ + + */ +class ConditionResolver +{ + + public function __construct( + private readonly Listener $listener, + ) {} + + + /** + * The default map of syntax constructs to replace in filter strings + * + * @return string[] + */ + private function getDefaultMap(string $tableAlias): array + { + return [ + '/\$this/' => $tableAlias, + '/\\n\s+\*/' => '', + ]; + } + + + /** + * Gets the identifiers and values array maps from the ValueHolders + * + * @return string[][] + * + * @throws KeyValueException When a filter contains an identifier but the ValueHolder has no value + */ + private function getValueHolderIdentifiersAndValues(FilterAttribute $filter, string $entityClassName): array + { + $identifiers = []; + $values = []; + foreach ($this->listener->getValueHolders() as $valueHolder) { + assert($valueHolder instanceof ValueHolderInterface); + + $placeholder = '{' . $valueHolder->getIdentifier() . '}'; + if (!\str_contains($filter->getWhereClause(), $placeholder)) { + continue; + } + + $value = $valueHolder->getValue(); + + if ($value === null) { + throw new KeyValueException(\sprintf( + 'Filter identifier, {%s}, in "%s" where clause, for %s, evaluates to null. ' . + 'Ensure the ValueHolder has a value set before the filter is applied.', + $valueHolder->getIdentifier(), + $filter->getWhereClause(), + $entityClassName, + )); + } + + $identifiers[] = '/\{' . $valueHolder->getIdentifier() . '\}/'; + $values[] = $value; + } + + return [$identifiers, $values]; + } + + + /** + * Gets the merged maps + * + * @param string[] $defaultMap + * @param string[] $identifiers + * @param string[] $values + * + * @return string[][] + */ + private function getMergedMaps(array $defaultMap, array $identifiers, array $values): array + { + return [ + array_merge(array_keys($defaultMap), $identifiers), + array_merge(array_values($defaultMap), $values), + ]; + } + + + /** + * Checks to see if the given context is considered to be in context, or contextual + * + * @param string[] $context An array of all the contexts that apply + */ + private function isContextual(array $context): bool + { + if (!count($context)) { + return true; + } + + foreach ($context as $c) { + $contextProvider = $this->listener->getContextProvider($c); + if ($contextProvider->isContextual()) { + return true; + } + } + + return false; + } + + + /** + * Parses the attribute where clause, replacing identifiers with values + * + * @param string[] $identifiers + * @param string[] $values + */ + private function parseWhereClause(string $filter, array $identifiers, array $values): string + { + return \preg_replace($identifiers, $values, $filter); + } + + + /** + * Resolves the multi-tenancy WHERE conditions for the given entity class. + * + * @param class-string $entityClass The fully-qualified entity class name + * @param string $tableAlias The SQL table alias to substitute for $this + * + * @return string The WHERE clause fragment (empty string if disabled or no applicable filters) + * + * @throws AttributeException If entity lacks the #[MultiTenancy] attribute + * @throws KeyValueException If a required ValueHolder has no value + */ + public function resolve(string $entityClass, string $tableAlias): string + { + $reflClass = new \ReflectionClass($entityClass); + $attributes = $reflClass->getAttributes(MultiTenancy::class); + + if (count($attributes) === 0) { + throw new AttributeException(sprintf( + '%s must have the MultiTenancy attribute added to the class docblock.', + $entityClass, + )); + } + + $multiTenancy = $attributes[0]->newInstance(); + assert($multiTenancy instanceof MultiTenancy); + + if (!$multiTenancy->isEnabled()) { + return ''; + } + + $filters = $multiTenancy->getFilters(); + if (!$filters) { + throw new AttributeException(sprintf( + '%s is enabled for MultiTenancy, but there were not any added filters.', + $entityClass, + )); + } + + $whereClauses = []; + $defaultMap = $this->getDefaultMap($tableAlias); + foreach ($filters as $filter) { + assert($filter instanceof FilterAttribute); + + if (!$this->isContextual($filter->getContext())) { + continue; + } + + if ($filter->isIgnored()) { + if ($multiTenancy->getFilterStrategy() === FilterStrategy::FirstMatch) { + break; + } + + continue; + } + + [$identifiers, $values] = $this->getMergedMaps( + $defaultMap, + ...$this->getValueHolderIdentifiersAndValues($filter, $entityClass), + ); + + $whereClauses[] = $this->parseWhereClause($filter->getWhereClause(), $identifiers, $values); + + if ($multiTenancy->getFilterStrategy() === FilterStrategy::FirstMatch) { + break; + } + } + + return implode(' AND ', $whereClauses); + } +} diff --git a/src/Filter.php b/src/Filter.php index d51b781..87b4537 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -7,12 +7,12 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Filter\SQLFilter; -use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy; -use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy\Filter as FilterAttribute; -use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy\FilterStrategy; /** - * Multi-tenancy filter to handle filtering by the company + * Doctrine SQLFilter adapter for multi-tenancy. + * + * Delegates condition resolution to ConditionResolver, which can also be used + * independently for raw SQL queries outside of Doctrine's DQL layer. * * @author Jacob Thomason */ @@ -32,7 +32,6 @@ protected function getEntityManager(): EntityManagerInterface { if ($this->entityManager === null) { $refl = new \ReflectionProperty('Doctrine\ORM\Query\Filter\SQLFilter', 'em'); - $refl->setAccessible(true); $this->entityManager = $refl->getValue($this); } @@ -66,191 +65,20 @@ protected function getListener(): Listener } - /** - * The default map are things we want to replace in the filter string by default. These - * are syntax constrcuts. Merge in our defaultMapping last, making sure this isn't cached - * since we don't want the $targetTableAlias to be cached as the first value. - * - * @return string[][] - */ - protected function getDefaultMap(string $targetTableAlias): array - { - return [ - '/\$this/' => $targetTableAlias, - '/\\n\s+\*/' => '', // Regex pattern to replace: \n * - ]; - } - - - /** - * Gets the identifiers and values array maps from the ValueHolders - * - * @return string[][] - * - * @throws KeyValueException When a filter contains an identifier but the ValueHolder has no value - */ - protected function getValueHolderIdentifiersAndValues(FilterAttribute $filter, ClassMetadata $targetEntity): array - { - $identifiers = []; - $values = []; - foreach ($this->getListener()->getValueHolders() as $valueHolder) { - assert($valueHolder instanceof ValueHolderInterface); - - // Only build values for the identifiers in the filter where clause if it contains the - // identifier wrapped in braces. Otherwise it's not needed and in some scopes may not - // be available at all. - $placeholder = '{' . $valueHolder->getIdentifier() . '}'; - if (!\str_contains($filter->getWhereClause(), $placeholder)) { - continue; - } - - $value = $valueHolder->getValue(); - - if ($value === null) { - throw new KeyValueException(\sprintf( - 'Filter identifier, {%s}, in "%s" where clause, for %s, evaluates to null. ' . - 'Ensure the ValueHolder has a value set before the filter is applied.', - $valueHolder->getIdentifier(), - $filter->getWhereClause(), - $targetEntity->rootEntityName, - )); - } - - $identifiers[] = '/\{' . $valueHolder->getIdentifier() . '\}/'; - $values[] = $value; - } - - return [$identifiers, $values]; - } - - - /** - * Gets the merged maps - * - * @param string[] $defaultMap - * @param string[][] $identifiers - * @param string[][] $values - * - * @return string[][] - */ - protected function getMergedMaps(array $defaultMap, array $identifiers, array $values): array - { - return [ - array_merge(array_keys($defaultMap), $identifiers), - array_merge(array_values($defaultMap), $values), - ]; - } - - - /** - * Checks to see if the given context is considered to be in context, or contexual - * - * @param string[] $context An array of all the contexts that apply - */ - protected function isContextual(array $context): bool - { - // If we don't have any contexts, it applies to all by default - if (!count($context)) { - return true; - } - - foreach ($context as $c) { - $contextProvider = $this->getListener()->getContextProvider($c); - if ($contextProvider->isContextual()) { - return true; - } - } - - return false; - } - - - /** - * Parses the attribute where clause, replacing identifiers with values - * - * @param string[] $identifiers - * @param string[] $values - */ - protected function parseWhereClause(string $filter, array $identifiers, array $values): string - { - return \preg_replace($identifiers, $values, $filter); - } - - /** * Adds a SQL query filter based on the attribute syntax and the ValueHolders values - * supplied to the MultiTenancy\Listener and identifier by their "identifier". - * - * These ValueHolders expose a string value that can be used within the attribute syntax. + * supplied to the MultiTenancy\Listener and identified by their "identifier". * * @param string $targetTableAlias */ public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string { - // If we're explicitly disabling multi-tenancy, there is nothing to do here if (!$this->getEntityManager()->getFilters()->isEnabled('multi-tenancy')) { return ''; } - $attributes = $targetEntity->reflClass->getAttributes(MultiTenancy::class); - - // If no attributes have been defined, this is an issue. For security reasons, we want - // to ensure that the entity has explicitly been disabled for multi-tenancy. - if (count($attributes) === 0) { - throw new AttributeException(sprintf( - '%s must have the MultiTenancy attribute added to the class docblock.', - $targetEntity->rootEntityName, - )); - } - - $multiTenancy = $attributes[0]->newInstance(); - assert($multiTenancy instanceof MultiTenancy); - - // Check to see if multi-tenancy is enabled on the entity - if (!$multiTenancy->isEnabled()) { - return ''; - } - - $filters = $multiTenancy->getFilters(); - if (!$filters) { - throw new AttributeException(sprintf( - '%s is enabled for MultiTenancy, but there were not any added filters.', - $targetEntity->rootEntityName, - )); - } - - $whereClauses = []; - $defaultMap = $this->getDefaultMap($targetTableAlias); - foreach ($filters as $filter) { - assert($filter instanceof FilterAttribute); - - if (!$this->isContextual($filter->getContext())) { - continue; - } - - if ($filter->isIgnored()) { - // If we're using a FirstMatch strategy, we need to break, that's it. - if ($multiTenancy->getFilterStrategy() === FilterStrategy::FirstMatch) { - break; - } else { - continue; - } - } - - [$identifiers, $values] = $this->getMergedMaps( - $defaultMap, - ...$this->getValueHolderIdentifiersAndValues($filter, $targetEntity), - ); - - $whereClauses[] = $this->parseWhereClause($filter->getWhereClause(), $identifiers, $values); - - // At this point we've processed at least one contextual filter. For a FirstMatch filter - // strategy, this is where we break - if ($multiTenancy->getFilterStrategy() === FilterStrategy::FirstMatch) { - break; - } - } + $resolver = new ConditionResolver($this->getListener()); - return implode(' AND ', $whereClauses); + return $resolver->resolve($targetEntity->getName(), $targetTableAlias); } } diff --git a/tests/Fixture/Entity/Book.php b/tests/Fixture/Entity/Book.php new file mode 100644 index 0000000..bf5675c --- /dev/null +++ b/tests/Fixture/Entity/Book.php @@ -0,0 +1,14 @@ +identifier; + } + + + public function isContextual(): bool + { + return $this->contextual; + } +} diff --git a/tests/Fixture/StubValueHolder.php b/tests/Fixture/StubValueHolder.php new file mode 100644 index 0000000..4da9d77 --- /dev/null +++ b/tests/Fixture/StubValueHolder.php @@ -0,0 +1,28 @@ +identifier; + } + + + public function getValue(): ?string + { + return $this->value; + } +} diff --git a/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php b/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php new file mode 100644 index 0000000..beeb746 --- /dev/null +++ b/tests/Unit/Attribute/MultiTenancy/FilterStrategyTest.php @@ -0,0 +1,29 @@ +assertSame('FirstMatch', FilterStrategy::FirstMatch->name); + } + + + public function testAnyMatchCaseExists(): void + { + $this->assertSame('AnyMatch', FilterStrategy::AnyMatch->name); + } + + + public function testCaseCount(): void + { + $this->assertCount(2, FilterStrategy::cases()); + } +} diff --git a/tests/Unit/Attribute/MultiTenancy/FilterTest.php b/tests/Unit/Attribute/MultiTenancy/FilterTest.php new file mode 100644 index 0000000..d1a2a6f --- /dev/null +++ b/tests/Unit/Attribute/MultiTenancy/FilterTest.php @@ -0,0 +1,60 @@ +assertSame([], $filter->getContext()); + $this->assertSame('', $filter->getWhereClause()); + $this->assertFalse($filter->isIgnored()); + } + + + public function testWithContext(): void + { + $filter = new Filter(context: ['staff', 'publisher']); + + $this->assertSame(['staff', 'publisher'], $filter->getContext()); + } + + + public function testWithWhereClause(): void + { + $where = '$this.store_id = {storeId}'; + $filter = new Filter(where: $where); + + $this->assertSame($where, $filter->getWhereClause()); + } + + + public function testWithIgnored(): void + { + $filter = new Filter(ignore: true); + + $this->assertTrue($filter->isIgnored()); + } + + + public function testAllParameters(): void + { + $filter = new Filter( + context: ['customer'], + where: '$this.customer_id = {customerId}', + ignore: false, + ); + + $this->assertSame(['customer'], $filter->getContext()); + $this->assertSame('$this.customer_id = {customerId}', $filter->getWhereClause()); + $this->assertFalse($filter->isIgnored()); + } +} diff --git a/tests/Unit/Attribute/MultiTenancyTest.php b/tests/Unit/Attribute/MultiTenancyTest.php new file mode 100644 index 0000000..0a20d0f --- /dev/null +++ b/tests/Unit/Attribute/MultiTenancyTest.php @@ -0,0 +1,53 @@ +assertTrue($mt->isEnabled()); + $this->assertSame([], $mt->getFilters()); + $this->assertSame(FilterStrategy::AnyMatch, $mt->getFilterStrategy()); + } + + + public function testDisabled(): void + { + $mt = new MultiTenancy(enable: false); + + $this->assertFalse($mt->isEnabled()); + } + + + public function testWithFilters(): void + { + $filters = [ + new Filter(where: '$this.store_id = {storeId}'), + new Filter(context: ['staff'], where: '$this.staff_id = {staffId}'), + ]; + + $mt = new MultiTenancy(filters: $filters); + + $this->assertCount(2, $mt->getFilters()); + $this->assertSame($filters, $mt->getFilters()); + } + + + public function testFirstMatchStrategy(): void + { + $mt = new MultiTenancy(strategy: FilterStrategy::FirstMatch); + + $this->assertSame(FilterStrategy::FirstMatch, $mt->getFilterStrategy()); + } +} diff --git a/tests/Unit/ConditionResolverTest.php b/tests/Unit/ConditionResolverTest.php new file mode 100644 index 0000000..a68ccc7 --- /dev/null +++ b/tests/Unit/ConditionResolverTest.php @@ -0,0 +1,326 @@ +addValueHolder($vh); + } + + foreach ($contextProviders as $cp) { + $listener->addContextProvider($cp); + } + + return $listener; + } + + + public function testSimpleFilter(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', '42'), + ]); + $resolver = new ConditionResolver($listener); + + $result = $resolver->resolve(Book::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testTableAliasSubstitution(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', '42'), + ]); + $resolver = new ConditionResolver($listener); + + $result = $resolver->resolve(Book::class, 'b'); + + $this->assertSame('b.store_id = 42', $result); + } + + + public function testDisabledEntityReturnsEmptyString(): void + { + $listener = $this->createListener(); + $resolver = new ConditionResolver($listener); + + $result = $resolver->resolve(ExternalCatalog::class, 't0'); + + $this->assertSame('', $result); + } + + + public function testNoAttributeThrowsException(): void + { + $listener = $this->createListener(); + $resolver = new ConditionResolver($listener); + + $this->expectException(AttributeException::class); + $this->expectExceptionMessage('must have the MultiTenancy attribute'); + + $resolver->resolve(UnmappedEntity::class, 't0'); + } + + + public function testEnabledNoFiltersThrowsException(): void + { + $listener = $this->createListener(); + $resolver = new ConditionResolver($listener); + + $this->expectException(AttributeException::class); + $this->expectExceptionMessage('there were not any added filters'); + + $resolver->resolve(MisconfiguredEntity::class, 't0'); + } + + + public function testAnyMatchJoinsMultipleFilters(): void + { + $listener = $this->createListener( + [ + new StubValueHolder('storeId', '42'), + new StubValueHolder('customerId', '7'), + ], + [ + new StubContextProvider('customer', true), + new StubContextProvider('publisher', false), + ], + ); + $resolver = new ConditionResolver($listener); + + // Product has: default store filter + customer filter + publisher filter + // customer is contextual, publisher is not + $result = $resolver->resolve(Product::class, 't0'); + + $this->assertSame( + 't0.store_id = 42 AND t0.id IN(SELECT product_id FROM customer_purchase WHERE customer_id = 7)', + $result, + ); + } + + + public function testFirstMatchUsesOnlyFirstMatchingFilter(): void + { + $listener = $this->createListener( + [ + new StubValueHolder('storeId', '42'), + new StubValueHolder('customerId', '7'), + ], + [ + new StubContextProvider('staff', true), + new StubContextProvider('customer', true), + ], + ); + $resolver = new ConditionResolver($listener); + + // Review: staff filter first, customer filter second, both contextual + // FirstMatch should only use the staff filter + $result = $resolver->resolve(Review::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testNonContextualFilterIsSkipped(): void + { + $listener = $this->createListener( + [ + new StubValueHolder('storeId', '42'), + new StubValueHolder('customerId', '7'), + ], + [ + new StubContextProvider('customer', false), + new StubContextProvider('publisher', false), + ], + ); + $resolver = new ConditionResolver($listener); + + // Product: default filter (no context, always applies) + customer + publisher + // Both customer and publisher are not contextual, so only default filter applies + $result = $resolver->resolve(Product::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testContextualFilterIsApplied(): void + { + $listener = $this->createListener( + [ + new StubValueHolder('storeId', '42'), + new StubValueHolder('publisherId', '15'), + ], + [ + new StubContextProvider('customer', false), + new StubContextProvider('publisher', true), + ], + ); + $resolver = new ConditionResolver($listener); + + $result = $resolver->resolve(Product::class, 't0'); + + $this->assertSame('t0.store_id = 42 AND t0.publisher_id = 15', $result); + } + + + public function testMultipleContextsOneMatchSuffices(): void + { + $listener = $this->createListener( + [ + new StubValueHolder('storeId', '42'), + new StubValueHolder('customerId', '7'), + ], + [ + // Review has staff and customer contexts + new StubContextProvider('staff', false), + new StubContextProvider('customer', true), + ], + ); + $resolver = new ConditionResolver($listener); + + // FirstMatch: staff not contextual, so its filter skipped. Customer is contextual. + $result = $resolver->resolve(Review::class, 't0'); + + $this->assertSame( + 't0.book_id IN(SELECT book_id FROM customer_review WHERE customer_id = 7)', + $result, + ); + } + + + public function testEmptyContextAppliesToAll(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', '42'), + ]); + $resolver = new ConditionResolver($listener); + + // Book has a filter with empty context — should always apply + $result = $resolver->resolve(Book::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testIgnoredFilterAnyMatchSkipsButOthersApply(): void + { + $listener = $this->createListener( + [new StubValueHolder('storeId', '42')], + [new StubContextProvider('staff', true)], + ); + $resolver = new ConditionResolver($listener); + + // Order: staff filter (ignored) + default store filter + // AnyMatch: ignored filter skipped via continue, store filter still applies + $result = $resolver->resolve(Order::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testIgnoredFilterFirstMatchBreaks(): void + { + $listener = $this->createListener( + [new StubValueHolder('customerId', '7')], + [ + new StubContextProvider('staff', true), + new StubContextProvider('customer', true), + ], + ); + $resolver = new ConditionResolver($listener); + + // Wishlist: staff filter (ignored, contextual) + customer filter + // FirstMatch: ignored filter breaks loop, customer filter never reached + $result = $resolver->resolve(Wishlist::class, 't0'); + + $this->assertSame('', $result); + } + + + public function testValueHolderNullThrowsException(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', null), + ]); + $resolver = new ConditionResolver($listener); + + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage('{storeId}'); + + $resolver->resolve(Book::class, 't0'); + } + + + public function testValueHolderNotInWhereClauseNullIsOk(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', '42'), + new StubValueHolder('unusedId', null), // Not referenced in Book's where clause + ]); + $resolver = new ConditionResolver($listener); + + // Should not throw despite null value — identifier not used in where clause + $result = $resolver->resolve(Book::class, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testMultipleValueHolderSubstitutions(): void + { + $listener = $this->createListener([ + new StubValueHolder('storeId', '42'), + new StubValueHolder('authorId', '7'), + ]); + $resolver = new ConditionResolver($listener); + + $result = $resolver->resolve(Invoice::class, 't0'); + + $this->assertSame('t0.store_id = 42 AND t0.author_id = 7', $result); + } + + + public function testNoApplicableFiltersReturnsEmptyString(): void + { + $listener = $this->createListener( + [new StubValueHolder('storeId', '42')], + [ + new StubContextProvider('staff', false), + new StubContextProvider('customer', false), + ], + ); + $resolver = new ConditionResolver($listener); + + // Review only has contextual filters (staff, customer), both non-contextual + $result = $resolver->resolve(Review::class, 't0'); + + $this->assertSame('', $result); + } +} diff --git a/tests/Unit/FilterTest.php b/tests/Unit/FilterTest.php new file mode 100644 index 0000000..2923b3f --- /dev/null +++ b/tests/Unit/FilterTest.php @@ -0,0 +1,91 @@ +createMock(FilterCollection::class); + $filterCollection->method('isEnabled') + ->with('multi-tenancy') + ->willReturn($filterEnabled); + + $evm = new EventManager(); + if ($listener) { + $evm->addEventSubscriber($listener); + } + + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getFilters')->willReturn($filterCollection); + $em->method('getEventManager')->willReturn($evm); + + return new Filter($em); + } + + + private function createClassMetadata(string $entityClass): ClassMetadata + { + $metadata = $this->createMock(ClassMetadata::class); + $metadata->method('getName')->willReturn($entityClass); + $metadata->reflClass = new \ReflectionClass($entityClass); + + return $metadata; + } + + + public function testDelegatesToConditionResolver(): void + { + $listener = new Listener(); + $listener->addValueHolder(new StubValueHolder('storeId', '42')); + + $filter = $this->createFilter(true, $listener); + $metadata = $this->createClassMetadata(Book::class); + + $result = $filter->addFilterConstraint($metadata, 't0'); + + $this->assertSame('t0.store_id = 42', $result); + } + + + public function testDisabledFilterReturnsEmptyString(): void + { + $filter = $this->createFilter(false); + $metadata = $this->createClassMetadata(Book::class); + + $result = $filter->addFilterConstraint($metadata, 't0'); + + $this->assertSame('', $result); + } + + + public function testNoListenerThrowsFilterException(): void + { + $filter = $this->createFilter(true, null); + $metadata = $this->createClassMetadata(Book::class); + + $this->expectException(FilterException::class); + $this->expectExceptionMessage('Listener'); + + $filter->addFilterConstraint($metadata, 't0'); + } +} diff --git a/tests/Unit/ListenerTest.php b/tests/Unit/ListenerTest.php new file mode 100644 index 0000000..6d6222e --- /dev/null +++ b/tests/Unit/ListenerTest.php @@ -0,0 +1,113 @@ +assertSame(['loadClassMetadata'], $listener->getSubscribedEvents()); + } + + + public function testLoadClassMetadataIsNoOp(): void + { + $listener = new Listener(); + $eventArgs = $this->createMock(EventArgs::class); + + // Should not throw + $listener->loadClassMetadata($eventArgs); + $this->assertTrue(true); + } + + + public function testAddAndGetValueHolder(): void + { + $listener = new Listener(); + $vh = new StubValueHolder('storeId', '42'); + + $listener->addValueHolder($vh); + + $this->assertSame($vh, $listener->getValueHolder('storeId')); + } + + + public function testGetValueHolders(): void + { + $listener = new Listener(); + $vh1 = new StubValueHolder('storeId', '42'); + $vh2 = new StubValueHolder('authorId', '7'); + + $listener->addValueHolder($vh1); + $listener->addValueHolder($vh2); + + $holders = $listener->getValueHolders(); + $this->assertCount(2, $holders); + $this->assertSame($vh1, $holders['storeId']); + $this->assertSame($vh2, $holders['authorId']); + } + + + public function testAddAndGetContextProvider(): void + { + $listener = new Listener(); + $cp = new StubContextProvider('staff', true); + + $listener->addContextProvider($cp); + + $this->assertSame($cp, $listener->getContextProvider('staff')); + } + + + public function testGetContextProviders(): void + { + $listener = new Listener(); + $cp1 = new StubContextProvider('staff', true); + $cp2 = new StubContextProvider('customer', false); + + $listener->addContextProvider($cp1); + $listener->addContextProvider($cp2); + + $providers = $listener->getContextProviders(); + $this->assertCount(2, $providers); + $this->assertSame($cp1, $providers['staff']); + $this->assertSame($cp2, $providers['customer']); + } + + + public function testGetContextProviderNotFoundThrowsException(): void + { + $listener = new Listener(); + + $this->expectException(KeyValueException::class); + $this->expectExceptionMessage('Unable to find a ContextProvider for "nonexistent"'); + + $listener->getContextProvider('nonexistent'); + } + + + public function testValueHolderOverwrite(): void + { + $listener = new Listener(); + $vh1 = new StubValueHolder('storeId', '42'); + $vh2 = new StubValueHolder('storeId', '99'); + + $listener->addValueHolder($vh1); + $listener->addValueHolder($vh2); + + $this->assertSame($vh2, $listener->getValueHolder('storeId')); + $this->assertCount(1, $listener->getValueHolders()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..c562d38 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +