Skip to content

Commit c6ba6a5

Browse files
authored
Merge branch 'master' into fix-cache-invalidation
2 parents 5e708bb + dfb0e1a commit c6ba6a5

10 files changed

Lines changed: 413 additions & 88 deletions

File tree

src/Bootstrappers/UrlGeneratorBootstrapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
/**
1717
* Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which:
1818
* - prefixes route names with the tenant route name prefix (PathTenantResolver::tenantRouteNamePrefix() by default)
19-
* - passes the tenant parameter to the link generated by route() and temporarySignedRoute() (PathTenantResolver::tenantParameterName() by default).
19+
* - passes the tenant parameter (PathTenantResolver::tenantParameterName() by default) to the link generated by the affected methods like route() and temporarySignedRoute().
2020
*
2121
* Used with path and query string identification.
2222
*

src/Concerns/HasTenantOptions.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ protected function getOptions()
1818
{
1919
return array_merge([
2020
new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null),
21-
new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs
21+
new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'),
2222
], parent::getOptions());
2323
}
2424

@@ -43,7 +43,11 @@ protected function getTenantsQuery(?array $tenantKeys = null): Builder
4343
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
4444
})
4545
->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) {
46-
$query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending'));
46+
$includePending = $this->input->hasParameterOption('--with-pending')
47+
? filter_var($this->option('with-pending') ?? true, FILTER_VALIDATE_BOOLEAN)
48+
: config('tenancy.pending.include_in_queries');
49+
50+
$query->withPending($includePending);
4751
});
4852
}
4953

src/Database/Concerns/HasPending.php

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ trait HasPending
2828
public static function bootHasPending(): void
2929
{
3030
static::addGlobalScope(new PendingScope());
31+
32+
static::creating(function (self $tenant): void {
33+
if ($tenant->pending()) {
34+
event(new CreatingPendingTenant($tenant));
35+
}
36+
});
37+
38+
static::created(function (self $tenant): void {
39+
if ($tenant->pending()) {
40+
event(new PendingTenantCreated($tenant));
41+
}
42+
});
3143
}
3244

3345
/** Initialize the trait. */
@@ -49,22 +61,11 @@ public function pending(): bool
4961
*/
5062
public static function createPending(array $attributes = []): Model&Tenant
5163
{
52-
$tenant = null;
53-
54-
try {
55-
$tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes));
56-
event(new CreatingPendingTenant($tenant));
57-
} finally {
58-
// Update the pending_since value only after the tenant is created so it's
59-
// not marked as pending until after migrations, seeders, etc are run.
60-
$tenant?->update([
61-
'pending_since' => now()->timestamp,
62-
]);
63-
}
64-
65-
event(new PendingTenantCreated($tenant));
66-
67-
return $tenant;
64+
return static::create(array_merge(
65+
static::getPendingAttributes($attributes),
66+
$attributes,
67+
['pending_since' => now()->timestamp],
68+
));
6869
}
6970

7071
/**

src/Features/UserImpersonation.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Stancl\Tenancy\Features;
66

7+
use Exception;
78
use Illuminate\Database\Eloquent\Model;
89
use Illuminate\Http\RedirectResponse;
910
use Illuminate\Support\Facades\Auth;
@@ -61,9 +62,9 @@ public static function makeResponse(#[\SensitiveParameter] string|Model $token,
6162

6263
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);
6364

64-
$token->delete();
65+
session()->put('tenancy_impersonation_guard', $token->auth_guard);
6566

66-
session()->put('tenancy_impersonating', true);
67+
$token->delete();
6768

6869
return redirect($token->redirect_url);
6970
}
@@ -76,16 +77,30 @@ public static function modelClass(): string
7677

7778
public static function isImpersonating(): bool
7879
{
79-
return session()->has('tenancy_impersonating');
80+
return session()->has('tenancy_impersonation_guard');
8081
}
8182

8283
/**
83-
* Logout from the current domain and forget impersonation session.
84+
* Stop user impersonation by forgetting the impersonation session.
85+
*
86+
* When $logout is true, the user will also be logged out
87+
* from the impersonation guard stored in the session.
88+
*
89+
* Throws an exception if impersonation is not active
90+
* (= the impersonation guard is not in the session).
8491
*/
85-
public static function stopImpersonating(): void
92+
public static function stopImpersonating(bool $logout = true): void
8693
{
87-
auth()->logout();
94+
if (! static::isImpersonating()) {
95+
throw new Exception('Not currently impersonating any user.');
96+
}
97+
98+
if ($logout) {
99+
$guard = session()->get('tenancy_impersonation_guard');
100+
101+
auth($guard)->logout();
102+
}
88103

89-
session()->forget('tenancy_impersonating');
104+
session()->forget('tenancy_impersonation_guard');
90105
}
91106
}

src/Jobs/MigrateDatabase.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class MigrateDatabase implements ShouldQueue
1717
{
1818
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
1919

20+
/**
21+
* Should pending tenants be included while migrating,
22+
* regardless of the tenancy.pending.include_in_queries config value.
23+
*
24+
* If false, pending tenants will be specifically excluded.
25+
*
26+
* If null, default to tenancy.pending.include_in_queries config.
27+
*/
28+
public static ?bool $includePending = true;
29+
2030
public function __construct(
2131
protected TenantWithDatabase&Model $tenant,
2232
) {}
@@ -25,6 +35,7 @@ public function handle(): void
2535
{
2636
Artisan::call('tenants:migrate', [
2737
'--tenants' => [$this->tenant->getTenantKey()],
38+
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
2839
]);
2940
}
3041
}

src/Jobs/SeedDatabase.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class SeedDatabase implements ShouldQueue
1717
{
1818
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
1919

20+
/**
21+
* Should pending tenants be included while seeding,
22+
* regardless of the tenancy.pending.include_in_queries config value.
23+
*
24+
* If false, pending tenants will be specifically excluded.
25+
*
26+
* If null, default to tenancy.pending.include_in_queries config.
27+
*/
28+
public static ?bool $includePending = true;
29+
2030
public function __construct(
2131
protected TenantWithDatabase&Model $tenant,
2232
) {}
@@ -25,6 +35,7 @@ public function handle(): void
2535
{
2636
Artisan::call('tenants:seed', [
2737
'--tenants' => [$this->tenant->getTenantKey()],
38+
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
2839
]);
2940
}
3041
}

src/Overrides/TenancyUrlGenerator.php

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@
2222
* - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled
2323
* This is a more universal solution since it supports both path identification and query parameter identification.
2424
*
25-
* - Prepends route names passed to route() and URL::temporarySignedRoute()
26-
* with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled.
25+
* - Prepends route names with the tenant route name prefix ('tenant.' by default,
26+
* configurable at tenant_route_name_prefix under PathTenantResolver) if $prefixRouteNames is enabled.
2727
* This is primarily useful when using route cloning with path identification.
2828
*
29-
* To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default).
29+
* Affected methods: route(), toRoute(), temporarySignedRoute(), signedRoute() (the last two via the route() override).
30+
*
31+
* To bypass this behavior on any single affected method call, pass the $bypassParameter as true (['central' => true] by default).
3032
*/
3133
class TenancyUrlGenerator extends UrlGenerator
3234
{
3335
/**
34-
* Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute().
36+
* Parameter which works as a flag for bypassing the behavior modification of the affected methods.
3537
*
3638
* For example, in tenant context:
3739
* Route::get('/', ...)->name('home');
@@ -44,12 +46,12 @@ class TenancyUrlGenerator extends UrlGenerator
4446
* Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though
4547
* it doesn't matter since it doesn't pass any extra parameters when not needed.
4648
*
47-
* @see UrlGeneratorBootstrapper
49+
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
4850
*/
4951
public static string $bypassParameter = 'central';
5052

5153
/**
52-
* Should route names passed to route() or temporarySignedRoute()
54+
* Should route names passed to the affected methods
5355
* get prefixed with the tenant route name prefix.
5456
*
5557
* This is useful when using e.g. path identification with third-party packages
@@ -59,12 +61,12 @@ class TenancyUrlGenerator extends UrlGenerator
5961
public static bool $prefixRouteNames = false;
6062

6163
/**
62-
* Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
64+
* Should the tenant parameter be passed to the affected methods.
6365
*
6466
* This is useful with path or query parameter identification. The former can be handled
6567
* more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults.
6668
*
67-
* @see UrlGeneratorBootstrapper
69+
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
6870
*/
6971
public static bool $passTenantParameterToRoutes = false;
7072

@@ -105,41 +107,47 @@ class TenancyUrlGenerator extends UrlGenerator
105107
public static bool $passQueryParameter = true;
106108

107109
/**
108-
* Override the route() method so that the route name gets prefixed
109-
* and the tenant parameter gets added when in tenant context.
110+
* Override the route() method to prefix the route name before $this->routes->getByName($name) is called
111+
* in the parent route() call.
112+
*
113+
* This is necessary because $this->routes->getByName($name) is called to retrieve the route
114+
* before passing it to toRoute(). If only the prefixed route (e.g. 'tenant.foo') is registered
115+
* and the original ('foo') isn't, route() would throw a RouteNotFoundException.
116+
* So route() has to be overridden to prefix the passed route name, even though toRoute() is overridden already.
117+
*
118+
* Only the name is taken from prepareRouteInputs() here — parameter handling
119+
* (adding tenant parameter, removing bypass parameter) is delegated to toRoute().
120+
*
121+
* Affects temporarySignedRoute() and signedRoute() as well since they call route() under the hood.
110122
*/
111123
public function route($name, $parameters = [], $absolute = true)
112124
{
113125
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
114126
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
115127
}
116128

117-
[$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type
129+
[$name] = $this->prepareRouteInputs(Arr::wrap($parameters), $name); // @phpstan-ignore argument.type
118130

119131
return parent::route($name, $parameters, $absolute);
120132
}
121133

122134
/**
123-
* Override the temporarySignedRoute() method so that the route name gets prefixed
124-
* and the tenant parameter gets added when in tenant context.
135+
* Override the toRoute() to prefix the route name
136+
* and add the tenant parameter when in tenant context.
137+
*
138+
* Also affects route(). Even though route() is overridden separately, it delegates parameter handling to toRoute().
125139
*/
126-
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true)
140+
public function toRoute($route, $parameters, $absolute)
127141
{
128-
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
129-
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
130-
}
131-
132-
$wrappedParameters = Arr::wrap($parameters);
142+
$name = $route->getName();
133143

134-
[$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type
144+
[$prefixedName, $parameters] = $this->prepareRouteInputs(Arr::wrap($parameters), $name);
135145

136-
if (isset($wrappedParameters[static::$bypassParameter])) {
137-
// If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it,
138-
// so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well.
139-
$parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter];
146+
if ($name && $prefixedName !== $name && $tenantRoute = $this->routes->getByName($prefixedName)) {
147+
$route = $tenantRoute;
140148
}
141149

142-
return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute);
150+
return parent::toRoute($route, $parameters, $absolute);
143151
}
144152

145153
/**
@@ -155,16 +163,19 @@ protected function routeBehaviorModificationBypassed(mixed $parameters): bool
155163
}
156164

157165
/**
158-
* Takes a route name and an array of parameters to return the prefixed route name
166+
* Takes an array of parameters and a route name to return the prefixed route name
159167
* and the route parameters with the tenant parameter added.
160168
*
161169
* To skip these modifications, pass the bypass parameter in route parameters.
162170
* Before returning the modified route inputs, the bypass parameter is removed from the parameters.
163171
*/
164-
protected function prepareRouteInputs(string $name, array $parameters): array
172+
protected function prepareRouteInputs(array $parameters, string|null $name): array
165173
{
166174
if (! $this->routeBehaviorModificationBypassed($parameters)) {
167-
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
175+
if (! is_null($name)) {
176+
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
177+
}
178+
168179
$parameters = $this->addTenantParameter($parameters);
169180
}
170181

tests/Bootstrappers/UrlGeneratorBootstrapperTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,47 @@
401401
->toContain('localhost/foo')
402402
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
403403
});
404+
405+
test('toRoute can automatically prefix the passed route name', function () {
406+
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
407+
408+
Route::get('/central/home', fn () => 'central')->name('home');
409+
Route::get('/tenant/home', fn () => 'tenant')->name('tenant.home');
410+
411+
TenancyUrlGenerator::$prefixRouteNames = true;
412+
413+
$tenant = Tenant::create();
414+
415+
tenancy()->initialize($tenant);
416+
417+
$centralRoute = Route::getRoutes()->getByName('home');
418+
419+
// url()->toRoute() prefixes the name of the passed route ('home') with the tenant prefix
420+
// and generates the URL for the tenant route (as if the 'tenant.home' route was passed to the method)
421+
expect(url()->toRoute($centralRoute, [], true))->toBe('http://localhost/tenant/home');
422+
423+
// Passing the bypass parameter skips the name prefixing, so the method returns the central route URL
424+
expect(url()->toRoute($centralRoute, ['central' => true], true))->toBe('http://localhost/central/home');
425+
});
426+
427+
test('toRoute modifies parameters even when the route has no name', function () {
428+
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
429+
430+
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
431+
432+
$unnamedRoute = Route::get('/unnamed', fn () => 'unnamed');
433+
434+
$tenant = Tenant::create();
435+
436+
tenancy()->initialize($tenant);
437+
438+
// The tenant parameter is added to the URL even for unnamed routes
439+
expect(url()->toRoute($unnamedRoute, [], true))
440+
->toBe("http://localhost/unnamed?tenant={$tenant->getTenantKey()}");
441+
442+
// The bypass parameter prevents passing the tenant parameter and is stripped from the URL
443+
expect(url()->toRoute($unnamedRoute, ['central' => true], true))
444+
->toBe("http://localhost/unnamed")
445+
->not()->toContain('tenant=')
446+
->not()->toContain('central=');
447+
});

0 commit comments

Comments
 (0)