Skip to content

Commit ad4c924

Browse files
lukinovecCopilotstancl
authored
[MINOR BC] Create pending tenants with pending_since, improve --with-pending (#1458)
> Minor breaking change: Pending tenants would previously go through the creation pipeline as *not* pending and would only be marked as pending after full creation. Now, pending tenants go through the creation process with pending_since set from the start. Pending tenants aren't getting their `pending_since` set until they're created completely (e.g. their DB was created, migrated and seeded -- first, the tenant is created fully, and only after that, the tenant is updated to have `pending_since`). This is a problem if someone wants to e.g. add a job to the `DatabaseCreated` job pipeline that would check `$this->tenant->pending()`. Since at the point of `DatabaseCreated`, the tenant's `pending_since` isn't set yet, `$this->tenant->pending()` returns `false`, even for tenants created using `createPending()`. So instead of letting the pending tenant get fully created, and only after that, setting its `pending_since` (using `update()`), we now set `pending_since` in `create()`. `CreatingPendingTenant` is now dispatched from the `static::creating` hook, and `PendingTenantCreated` is dispatched from `static::created` for consistency. Setting `pending_since` right in `create()` made the `MigrateDatabase` and `SeedDatabase` jobs exclude the pending tenants during their creation if the `tenancy.pending.include_in_queries` config was set to `false` -- in that case, these jobs would never migrate or seed the databases of pending tenants. So these jobs now pass `--with-pending` to their underlying commands, with the value set in their `$includePending` static property (`true` by default). This overrides the `tenancy.pending.include_in_queries` config -- unless the `$includePending` properties are set to `false`, these jobs will always include pending tenants. The `--with-pending` tenant command option originally worked just to opt-in for including pending tenants in the command. Now, `--with-pending` can accept values (`true`/`1` or `false`/`0`), so e.g. - `tenants:run foo` with `--with-pending`/`--with-pending=true`/`--with-pending=1` includes pending tenants - `tenants:run foo` with `--with-pending=false`/`--with-pending=0` **excludes** pending tenants (also `--with-pending=foobar` -- invalid input, considered `false`) Passing `--with-pending` makes the command bypass the `tenancy.pending.include_in_queries` config (so e.g. if `tenancy.pending.include_in_queries` is set to `true`, and `--with-pending=false` is passed to a command, the command will exclude pending tenants). When `--with-pending` is not passed, the command will include or exclude pending tenants based on the `tenancy.pending.include_in_queries` config. --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Samuel Štancl <samuel@archte.ch>
1 parent c0fbf6d commit ad4c924

5 files changed

Lines changed: 208 additions & 48 deletions

File tree

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/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
}

tests/PendingTenantsTest.php

Lines changed: 163 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,25 @@
1616
use Stancl\Tenancy\Events\PullingPendingTenant;
1717
use Stancl\Tenancy\Tests\Etc\Tenant;
1818
use function Stancl\Tenancy\Tests\pest;
19+
use Stancl\Tenancy\Events\TenantCreated;
20+
use Stancl\JobPipeline\JobPipeline;
21+
use Stancl\Tenancy\Jobs\CreateDatabase;
22+
use Stancl\Tenancy\Jobs\MigrateDatabase;
23+
use Stancl\Tenancy\Jobs\SeedDatabase;
24+
use Stancl\Tenancy\Tests\Etc\User;
25+
use Stancl\Tenancy\Tests\Etc\TestSeeder;
26+
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
27+
use Stancl\Tenancy\Events\TenancyInitialized;
28+
use Stancl\Tenancy\Listeners\BootstrapTenancy;
29+
use Stancl\Tenancy\Events\TenancyEnded;
30+
use Stancl\Tenancy\Listeners\RevertToCentralContext;
1931

2032
beforeEach($cleanup = function () {
2133
Tenant::$extraCustomColumns = [];
2234
Tenant::$getPendingAttributesUsing = null;
35+
36+
MigrateDatabase::$includePending = true;
37+
SeedDatabase::$includePending = true;
2338
});
2439

2540
afterEach($cleanup);
@@ -154,8 +169,8 @@
154169
Event::assertDispatched(PendingTenantPulled::class);
155170
});
156171

157-
test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() {
158-
config(['tenancy.pending.include_in_queries' => false]);
172+
test('commands include tenants based on the include_in_queries config when --with-pending is not passed', function (bool $includeInQueries) {
173+
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
159174

160175
$tenants = collect([
161176
Tenant::create(),
@@ -164,21 +179,21 @@
164179
Tenant::createPending(),
165180
]);
166181

167-
pest()->artisan('tenants:migrate --with-pending');
168-
169-
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
182+
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo'");
170183

171-
$pendingTenants = $tenants->filter->pending();
172-
$readyTenants = $tenants->reject->pending();
173-
174-
$pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
175-
$readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
184+
$tenants->each(function ($tenant) use ($command, $includeInQueries) {
185+
if ($tenant->pending() && ! $includeInQueries) {
186+
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
187+
} else {
188+
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
189+
}
190+
});
176191

177-
$artisan->assertExitCode(0);
178-
});
192+
$command->assertSuccessful();
193+
})->with([true, false]);
179194

180-
test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
181-
config(['tenancy.pending.include_in_queries' => true]);
195+
test('commands include pending tenants when truthy --with-pending is passed', function (bool $includeInQueries) {
196+
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
182197

183198
$tenants = collect([
184199
Tenant::create(),
@@ -187,17 +202,22 @@
187202
Tenant::createPending(),
188203
]);
189204

190-
pest()->artisan('tenants:migrate --with-pending');
205+
foreach ([
206+
'--with-pending',
207+
'--with-pending=true',
208+
'--with-pending=1'
209+
] as $option) {
210+
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
191211

192-
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
212+
// Pending tenants are included regardless of tenancy.pending.include_in_queries
213+
$tenants->each(fn ($tenant) => $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
193214

194-
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
195-
196-
$artisan->assertExitCode(0);
197-
});
215+
$command->assertSuccessful();
216+
}
217+
})->with([true, false]);
198218

199-
test('commands run for pending tenants too if the with pending option is passed', function() {
200-
config(['tenancy.pending.include_in_queries' => false]);
219+
test('commands exclude pending tenants when falsy --with-pending is passed', function (bool $includeInQueries) {
220+
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
201221

202222
$tenants = collect([
203223
Tenant::create(),
@@ -206,14 +226,25 @@
206226
Tenant::createPending(),
207227
]);
208228

209-
pest()->artisan('tenants:migrate --with-pending');
210-
211-
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending");
212-
213-
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
214-
215-
$artisan->assertExitCode(0);
216-
});
229+
foreach ([
230+
'--with-pending=false',
231+
'--with-pending=0',
232+
'--with-pending=foo' // Invalid values are treated as false
233+
] as $option) {
234+
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
235+
236+
$tenants->each(function ($tenant) use ($command) {
237+
if ($tenant->pending()) {
238+
// Pending tenants are excluded regardless of tenancy.pending.include_in_queries
239+
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
240+
} else {
241+
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
242+
}
243+
});
244+
245+
$command->assertSuccessful();
246+
}
247+
})->with([true, false]);
217248

218249
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
219250
Schema::table('tenants', function (Blueprint $table) {
@@ -236,3 +267,105 @@
236267
else
237268
expect($fn)->toThrow(QueryException::class);
238269
})->with([true, false]);
270+
271+
test('pending tenant databases can be migrated using a job unless configured otherwise', function (bool $includeInQueries, ?bool $migrateWithPending) {
272+
config([
273+
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
274+
'tenancy.pending.include_in_queries' => $includeInQueries,
275+
]);
276+
277+
MigrateDatabase::$includePending = $migrateWithPending;
278+
279+
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
280+
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
281+
Event::listen(TenantCreated::class, JobPipeline::make([
282+
CreateDatabase::class,
283+
MigrateDatabase::class,
284+
])->send(function (TenantCreated $event) {
285+
return $event->tenant;
286+
})->toListener());
287+
288+
$pendingTenant = Tenant::createPending();
289+
290+
expect(Schema::hasTable('users'))->toBeFalse();
291+
292+
tenancy()->initialize($pendingTenant);
293+
294+
// MigrateDatabase includes/excludes pending tenants based on its $includePending property,
295+
// regardless of the tenancy.pending.include_in_queries config.
296+
expect(Schema::hasTable('users'))->toBe($migrateWithPending ?? $includeInQueries);
297+
})->with([
298+
'include pending in queries' => [true],
299+
'exclude pending from queries' => [false],
300+
])->with([
301+
'migrate with pending' => [true],
302+
'migrate without pending' => [false],
303+
'default to config' => [null],
304+
]);
305+
306+
test('pending tenant databases can be seeded using a job unless configured otherwise', function (bool $includeInQueries, ?bool $seedWithPending) {
307+
config([
308+
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
309+
'tenancy.pending.include_in_queries' => $includeInQueries,
310+
'tenancy.seeder_parameters.--class' => TestSeeder::class,
311+
]);
312+
313+
MigrateDatabase::$includePending = true;
314+
SeedDatabase::$includePending = $seedWithPending;
315+
316+
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
317+
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
318+
Event::listen(TenantCreated::class, JobPipeline::make([
319+
CreateDatabase::class,
320+
MigrateDatabase::class,
321+
SeedDatabase::class,
322+
])->send(function (TenantCreated $event) {
323+
return $event->tenant;
324+
})->toListener());
325+
326+
$pendingTenant = Tenant::createPending();
327+
328+
tenancy()->initialize($pendingTenant);
329+
330+
// SeedDatabase includes/excludes pending tenants based on its $includePending property,
331+
// regardless of the tenancy.pending.include_in_queries config.
332+
expect(User::where('email', 'seeded@user')->exists())->toBe($seedWithPending ?? $includeInQueries);
333+
})->with([
334+
'include pending in queries' => [true],
335+
'exclude pending from queries' => [false],
336+
])->with([
337+
'seed with pending' => [true],
338+
'seed without pending' => [false],
339+
'default to config' => [null],
340+
]);
341+
342+
test('jobs that run before tenants get fully created recognize pending tenants', function () {
343+
config([
344+
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
345+
]);
346+
347+
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
348+
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
349+
Event::listen(TenantCreated::class, JobPipeline::make([
350+
CreateDatabase::class,
351+
PendingTenantJob::class,
352+
])->send(function (TenantCreated $event) {
353+
return $event->tenant;
354+
})->toListener());
355+
356+
Tenant::createPending();
357+
358+
expect(app('tenant_is_pending'))->toBeTrue();
359+
});
360+
361+
class PendingTenantJob
362+
{
363+
public function __construct(
364+
public Tenant $tenant,
365+
) {}
366+
367+
public function handle()
368+
{
369+
app()->instance('tenant_is_pending', $this->tenant->pending());
370+
}
371+
}

0 commit comments

Comments
 (0)