Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Composer
composer.lock
vendor

# PHPUnit
.phpunit.cache
32 changes: 32 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
20 changes: 20 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
201 changes: 201 additions & 0 deletions src/ConditionResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<?php

declare(strict_types = 1);

namespace Rentpost\Doctrine\MultiTenancy;

use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy;
use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy\Filter as FilterAttribute;
use Rentpost\Doctrine\MultiTenancy\Attribute\MultiTenancy\FilterStrategy;

/**
* Resolves multi-tenancy SQL conditions for a given entity class.
*
* This class extracts the SQL generation logic from the Doctrine SQLFilter so it can be
* used independently — e.g. in raw SQL repository queries that bypass Doctrine's DQL layer.
*
* @author Jacob Thomason <jacob@rentpost.com>
*/
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);
}
}
Loading
Loading