diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php new file mode 100644 index 00000000..6c5b8224 --- /dev/null +++ b/src/Database/Concerns/BelongsToTenant.php @@ -0,0 +1,33 @@ +belongsTo(config('tenancy.tenant_model'), BelongsToTenant::$tenantIdColumn); + } + + public static function bootBelongsToTenant() + { + static::addGlobalScope(new TenantScope(BelongsToTenant::$tenantIdColumn)); + + static::creating(function ($model) { + if (! $model->getAttribute(BelongsToTenant::$tenantIdColumn) && ! $model->relationLoaded('tenant')) { + if (tenancy()->initialized) { + $model->setAttribute(BelongsToTenant::$tenantIdColumn, tenant()->getTenantKey()); + $model->setRelation('tenant', tenant()); + } + } + }); + } +} diff --git a/src/Database/Concerns/HasDomains.php b/src/Database/Concerns/HasDomains.php index b8e70f09..7e5634a8 100644 --- a/src/Database/Concerns/HasDomains.php +++ b/src/Database/Concerns/HasDomains.php @@ -5,7 +5,7 @@ namespace Stancl\Tenancy\Database\Concerns; use Stancl\Tenancy\Contracts\Domain; /** - * @property-read Domain[] $domains + * @property-read Domain[]\Illuminate\Database\Eloquent\Collection $domains */ trait HasDomains { diff --git a/src/Database/Models/Tenant.php b/src/Database/Models/Tenant.php index 1ccebc23..761b4c07 100644 --- a/src/Database/Models/Tenant.php +++ b/src/Database/Models/Tenant.php @@ -6,7 +6,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Events; use Stancl\Tenancy\Contracts; -use Stancl\Tenancy\Database\Collections\TenantCollection; +use Stancl\Tenancy\Database\TenantCollection; use Stancl\Tenancy\Database\Concerns; /** diff --git a/src/Database/Collections/TenantCollection.php b/src/Database/TenantCollection.php similarity index 90% rename from src/Database/Collections/TenantCollection.php rename to src/Database/TenantCollection.php index 298f0d25..689db690 100644 --- a/src/Database/Collections/TenantCollection.php +++ b/src/Database/TenantCollection.php @@ -1,6 +1,6 @@ initialized) { + return; + } + + $builder->where(BelongsToTenant::$tenantIdColumn, tenant()->getTenantKey()); + } + + public function extend(Builder $builder) + { + $builder->macro('withoutTenancy', function (Builder $builder) { + return $builder->withoutGlobalScope($this); + }); + } +} diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php new file mode 100644 index 00000000..1359c56a --- /dev/null +++ b/tests/SingleDatabaseTenancyTest.php @@ -0,0 +1,272 @@ +increments('id'); + $table->string('text'); + + $table->string('tenant_id'); + + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->increments('id'); + $table->string('text'); + + $table->string('post_id'); + + $table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade'); + }); + } + + /** @test */ + public function primary_models_are_scoped_to_the_current_tenant() + { + // acme context + tenancy()->initialize($acme = Tenant::create([ + 'id' => 'acme', + ])); + + $post = Post::create(['text' => 'Foo']); + + $this->assertSame('acme', $post->tenant_id); + $this->assertSame('acme', $post->tenant->id); + + $post = Post::first(); + + $this->assertSame('acme', $post->tenant_id); + $this->assertSame('acme', $post->tenant->id); + + // ====================================== + // foobar context + tenancy()->initialize($foobar = Tenant::create([ + 'id' => 'foobar', + ])); + + $post = Post::create(['text' => 'Bar']); + + $this->assertSame('foobar', $post->tenant_id); + $this->assertSame('foobar', $post->tenant->id); + + $post = Post::first(); + + $this->assertSame('foobar', $post->tenant_id); + $this->assertSame('foobar', $post->tenant->id); + + // ====================================== + // acme context again + + tenancy()->initialize($acme); + + $post = Post::first(); + $this->assertSame('acme', $post->tenant_id); + $this->assertSame('acme', $post->tenant->id); + + // Assert foobar models are inaccessible in acme context + $this->assertSame(1, Post::count()); + } + + /** @test */ + public function primary_models_are_not_scoped_in_the_central_context() + { + $this->primary_models_are_scoped_to_the_current_tenant(); + + tenancy()->end(); + + $this->assertSame(2, Post::count()); + } + + /** @test */ + public function secondary_models_are_scoped_to_the_current_tenant_when_accessed_via_primary_model() + { + // acme context + tenancy()->initialize($acme = Tenant::create([ + 'id' => 'acme', + ])); + + $post = Post::create(['text' => 'Foo']); + $post->comments()->create(['text' => 'Comment text']); + + // ================ + // foobar context + tenancy()->initialize($foobar = Tenant::create([ + 'id' => 'foobar', + ])); + + $post = Post::create(['text' => 'Bar']); + $post->comments()->create(['text' => 'Comment text 2']); + + // ================ + // acme context again + tenancy()->initialize($acme); + $this->assertSame(1, Post::count()); + $this->assertSame(1, Post::first()->comments->count()); + } + + /** @test */ + public function secondary_models_are_NOT_scoped_to_the_current_tenant_when_accessed_directly() + { + $this->secondary_models_are_scoped_to_the_current_tenant_when_accessed_via_primary_model(); + + // We're in acme context + $this->assertSame('acme', tenant('id')); + + // There is no way to scope this 🤷‍♂ + $this->assertSame(2, Comment::count()); + } + + /** @test */ + public function secondary_models_are_NOT_scoped_in_the_central_context() + { + $this->secondary_models_are_scoped_to_the_current_tenant_when_accessed_via_primary_model(); + + tenancy()->end(); + + $this->assertSame(2, Comment::count()); + } + + /** @test */ + public function global_models_are_not_scoped_at_all() + { + Schema::create('global_resource', function (Blueprint $table) { + $table->increments('id'); + $table->string('text'); + }); + + GlobalResource::create(['text' => 'First']); + GlobalResource::create(['text' => 'Second']); + + $acme = Tenant::create([ + 'id' => 'acme', + ]); + + $acme->run(function () { + $this->assertSame(2, GlobalResource::count()); + + GlobalResource::create(['text' => 'Third']); + GlobalResource::create(['text' => 'Fourth']); + }); + + $this->assertSame(4, GlobalResource::count()); + } + + /** @test */ + public function tenant_id_and_relationship_is_auto_added_when_creating_primary_resources_in_tenant_context() + { + tenancy()->initialize($acme = Tenant::create([ + 'id' => 'acme', + ])); + + $post = Post::create(['text' => 'Foo']); + + $this->assertSame('acme', $post->tenant_id); + $this->assertTrue($post->relationLoaded('tenant')); + $this->assertSame($acme, $post->tenant); + $this->assertSame(tenant(), $post->tenant); + } + + /** @test */ + public function tenant_id_is_not_auto_added_when_creating_primary_resources_in_central_context() + { + $this->expectException(QueryException::class); + + Post::create(['text' => 'Foo']); + } + + /** @test */ + public function tenant_id_column_name_can_be_customized() + { + BelongsToTenant::$tenantIdColumn = 'team_id'; + + Schema::drop('posts'); + Schema::create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->string('text'); + + $table->string('team_id'); + + $table->foreign('team_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + }); + + tenancy()->initialize($acme = Tenant::create([ + 'id' => 'acme', + ])); + + $post = Post::create(['text' => 'Foo']); + + $this->assertSame('acme', $post->team_id); + + // ====================================== + // foobar context + tenancy()->initialize($foobar = Tenant::create([ + 'id' => 'foobar', + ])); + + $post = Post::create(['text' => 'Bar']); + + $this->assertSame('foobar', $post->team_id); + + $post = Post::first(); + + $this->assertSame('foobar', $post->team_id); + + // ====================================== + // acme context again + + tenancy()->initialize($acme); + + $post = Post::first(); + $this->assertSame('acme', $post->team_id); + + // Assert foobar models are inaccessible in acme context + $this->assertSame(1, Post::count()); + } +} + +class Post extends Model +{ + use BelongsToTenant; + + protected $guarded = []; + public $timestamps = false; + + public function comments() + { + return $this->hasMany(Comment::class); + } +} + +class Comment extends Model +{ + protected $guarded = []; + public $timestamps = false; + + public function post() + { + return $this->belongsTo(Post::class); + } +} + +class GlobalResource extends Model +{ + protected $guarded = []; + public $timestamps = false; +} \ No newline at end of file