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 @@
+