Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(queue): Allow for tenant-aware jobs #39

Merged
merged 8 commits into from
Sep 27, 2024
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

![Packagist Version](https://img.shields.io/packagist/v/sprout/sprout)
![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/sprout/sprout)

![GitHub](https://img.shields.io/github/license/sprout-laravel/sprout)
![Laravel](https://img.shields.io/badge/laravel-11.x-red.svg)
[![codecov](https://codecov.io/gh/sprout-laravel/sprout/branch/main/graph/badge.svg?token=FHJ41NQMTA)](https://codecov.io/gh/sprout-laravel/sprout)

![Unit Tests](https://github.com/sprout-laravel/sprout/actions/workflows/tests.yml/badge.svg)
![Static Analysis](https://github.com/sprout-laravel/sprout/actions/workflows/static-analysis.yml/badge.svg)

# Sprout for Laravel
### A flexible, seamless and easy to use multitenancy solution for Laravel

Expand Down
1 change: 1 addition & 0 deletions resources/config/multitenancy.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
'options' => [
TenancyOptions::hydrateTenantRelation(),
TenancyOptions::throwIfNotRelated(),
TenancyOptions::makeJobsTenantAware(),
],
],

Expand Down
5 changes: 0 additions & 5 deletions resources/config/sprout.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,4 @@

'listen_for_routing' => true,

'context' => [
'key' => '{tenancy}_key',
'use' => 'key',
],

];
28 changes: 13 additions & 15 deletions src/Listeners/SetCurrentTenantContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,6 @@

final class SetCurrentTenantContext
{
/**
* @var \Sprout\Sprout
*/
private Sprout $sprout;

public function __construct(Sprout $sprout)
{
$this->sprout = $sprout;
}

/**
* @template TenantClass of \Sprout\Contracts\Tenant
*
Expand All @@ -28,12 +18,20 @@ public function __construct(Sprout $sprout)
*/
public function handle(CurrentTenantChanged $event): void
{
$contextKey = $this->sprout->contextKey($event->tenancy);
$contextKey = 'sprout.tenants';
$context = [];

if (Context::has($contextKey)) {
/** @var array<string, int|string> $context */
$context = Context::get($contextKey, []);
}

if ($event->current === null && Context::has($contextKey)) {
Context::forget($contextKey);
} else if ($event->current !== null) {
Context::add($contextKey, $this->sprout->contextValue($event->current));
if ($event->current === null) {
unset($context[$event->tenancy->getName()]);
} else {
$context[$event->tenancy->getName()] = $event->current->getTenantKey();
}

Context::add($contextKey, $context);
}
}
44 changes: 44 additions & 0 deletions src/Listeners/SetCurrentTenantForJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);

namespace Sprout\Listeners;

use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\Context;
use Sprout\Managers\TenancyManager;
use Sprout\TenancyOptions;

final class SetCurrentTenantForJob
{
/**
* @var \Sprout\Managers\TenancyManager
*/
private TenancyManager $tenancies;

public function __construct(TenancyManager $tenancies)
{
$this->tenancies = $tenancies;
}

public function handle(JobProcessing $event): void
{
/** @var array<string, string|int> $tenants */
$tenants = Context::get('sprout.tenants', []);

/**
* @var string $tenancyName
* @var int|string $key
*/
foreach ($tenants as $tenancyName => $key) {
/** @var \Sprout\Contracts\Tenancy<*> $tenancy */
$tenancy = $this->tenancies->get($tenancyName);

// We don't want to set a tenant if there's already one, and we don't
// want to set a tenant on tenancies that don't have tenant-aware jobs
if (! $tenancy->check() && TenancyOptions::shouldJobsBeTenantAware($tenancy)) {
// It's always the key, so we load instead of identifying
$tenancy->load($key);
}
}
}
}
18 changes: 0 additions & 18 deletions src/Sprout.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,24 +76,6 @@ public function shouldListenForRouting(): bool
return (bool) $this->config('listen_for_routing', true);
}

/**
* @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy
*
* @return string
*/
public function contextKey(Tenancy $tenancy): string
{
/** @phpstan-ignore-next-line */
return str_replace(['{tenancy}'], [$tenancy->getName()], $this->config('context.key', '{tenancy}_key'));
}

public function contextValue(Tenant $tenant): int|string
{
return $this->config('context.use', 'key') === 'key'
? $tenant->getTenantKey()
: $tenant->getTenantIdentifier();
}

public function resolvers(): IdentityResolverManager
{
return $this->app->make(IdentityResolverManager::class);
Expand Down
39 changes: 20 additions & 19 deletions src/SproutServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
namespace Sprout;

use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Routing\Router;
use Illuminate\Support\ServiceProvider;
use Sprout\Events\CurrentTenantChanged;
use Sprout\Http\Middleware\TenantRoutes;
use Sprout\Http\RouterMethods;
use Sprout\Listeners\SetCurrentTenantContext;
use Sprout\Listeners\IdentifyTenantOnRouting;
use Sprout\Listeners\PerformIdentityResolverSetup;
use Sprout\Listeners\SetCurrentTenantContext;
use Sprout\Listeners\SetCurrentTenantForJob;
use Sprout\Managers\IdentityResolverManager;
use Sprout\Managers\ProviderManager;
use Sprout\Managers\TenancyManager;
Expand All @@ -27,9 +29,6 @@ public function register(): void
$this->registerSprout();
$this->registerManagers();
$this->registerMiddleware();
$this->booting(function () {
$this->registerEventListeners();
});
$this->registerRouteMixin();
}

Expand Down Expand Up @@ -78,32 +77,34 @@ private function registerMiddleware(): void
$router->aliasMiddleware(TenantRoutes::ALIAS, TenantRoutes::class);
}

private function registerEventListeners(): void
{
/** @var \Illuminate\Contracts\Events\Dispatcher $events */
$events = $this->app->make(Dispatcher::class);

// If we should be listening for routing
if ($this->sprout->shouldListenForRouting()) {
$events->listen(RouteMatched::class, IdentifyTenantOnRouting::class);
}

$events->listen(CurrentTenantChanged::class, SetCurrentTenantContext::class);
$events->listen(CurrentTenantChanged::class, PerformIdentityResolverSetup::class);
}

protected function registerRouteMixin(): void
{
Router::mixin(new RouterMethods);
Router::mixin(new RouterMethods());
}

public function boot(): void
{
$this->publishConfig();
$this->registerEventListeners();
}

private function publishConfig(): void
{
$this->publishes([__DIR__ . '/../resources/config/multitenancy.php' => config_path('multitenancy.php')], 'config');
}

private function registerEventListeners(): void
{
/** @var \Illuminate\Contracts\Events\Dispatcher $events */
$events = $this->app->make(Dispatcher::class);

// If we should be listening for routing
if ($this->sprout->shouldListenForRouting()) {
$events->listen(RouteMatched::class, IdentifyTenantOnRouting::class);
}

$events->listen(CurrentTenantChanged::class, SetCurrentTenantContext::class);
$events->listen(CurrentTenantChanged::class, PerformIdentityResolverSetup::class);
$events->listen(JobProcessing::class, SetCurrentTenantForJob::class);
}
}
26 changes: 21 additions & 5 deletions src/TenancyOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ public static function throwIfNotRelated(): string
}

/**
* @template TenantClass of \Sprout\Contracts\Tenant
* Make sure that queued jobs are aware of the current tenant
*
* @param \Sprout\Contracts\Tenancy<TenantClass> $tenancy
* @return string
*/
public static function makeJobsTenantAware(): string
{
return 'tenant-aware.jobs';
}

/**
* @param \Sprout\Contracts\Tenancy<*> $tenancy
*
* @return bool
*/
Expand All @@ -40,14 +48,22 @@ public static function shouldHydrateTenantRelation(Tenancy $tenancy): bool
}

/**
* @template TenantClass of \Sprout\Contracts\Tenant
*
* @param \Sprout\Contracts\Tenancy<TenantClass> $tenancy
* @param \Sprout\Contracts\Tenancy<*> $tenancy
*
* @return bool
*/
public static function shouldThrowIfNotRelated(Tenancy $tenancy): bool
{
return $tenancy->hasOption(static::throwIfNotRelated());
}

/**
* @param \Sprout\Contracts\Tenancy<*> $tenancy
*
* @return bool
*/
public static function shouldJobsBeTenantAware(Tenancy $tenancy): bool
{
return $tenancy->hasOption(static::makeJobsTenantAware());
}
}
76 changes: 76 additions & 0 deletions tests/Listeners/SetCurrentTenantForJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);

namespace Sprout\Tests\Listeners;

use Illuminate\Config\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\QueueManager;
use Illuminate\Support\Facades\Context;
use Orchestra\Testbench\Concerns\WithWorkbench;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Sprout\Managers\TenancyManager;
use Sprout\TenancyOptions;
use Workbench\App\Jobs\TestTenantJob;
use Workbench\App\Models\TenantModel;

#[Group('listeners')]
class SetCurrentTenantForJobTest extends TestCase
{
use WithWorkbench, RefreshDatabase;

protected $enablesPackageDiscoveries = true;

protected function defineEnvironment($app): void
{
tap($app['config'], static function (Repository $config) {
$config->set('multitenancy.providers.tenants.model', TenantModel::class);
});
}

#[Test]
public function doesNotSetCurrentTenantForJobWithoutOption(): void
{
/** @var \Sprout\Contracts\Tenancy<*> $tenancy */
$tenancy = app(TenancyManager::class)->get();
$tenancy->removeOption(TenancyOptions::makeJobsTenantAware());

$this->assertFalse($tenancy->check());

$tenant = TenantModel::factory()->createOne();

Context::add('sprout.tenants', [$tenancy->getName() => $tenant->getKey()]);

$this->assertTrue(Context::has('sprout.tenants'));
$this->assertSame([$tenancy->getName() => $tenant->getKey()], Context::get('sprout.tenants'));

TestTenantJob::dispatchSync();

$this->assertFalse($tenancy->check());
}

#[Test]
public function setsCurrentTenantForJobWithOption(): void
{
/** @var \Sprout\Contracts\Tenancy<*> $tenancy */
$tenancy = app(TenancyManager::class)->get();
$tenancy->addOption(TenancyOptions::makeJobsTenantAware());

$this->assertFalse($tenancy->check());

$tenant = TenantModel::factory()->createOne();

Context::add('sprout.tenants', [$tenancy->getName() => $tenant->getKey()]);

$this->assertTrue(Context::has('sprout.tenants'));
$this->assertSame([$tenancy->getName() => $tenant->getKey()], Context::get('sprout.tenants'));

TestTenantJob::dispatchSync();

$this->assertTrue($tenancy->check());
$this->assertSame($tenant->getKey(), $tenancy->key());
$this->assertTrue($tenant->is($tenancy->tenant()));
}
}
Loading