From 777265f2434de255088a9a24f6936af4025bed04 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 24 Sep 2024 11:55:34 +0100 Subject: [PATCH] feat(database.eloquent): Add tenant child model functionality (#32) * chore: Add basic hydration options to default tenancy * chore: Add attribute for marking method as a tenant relation * refactor: Rename IsTenantModel to IsTenant * chore: Add contract/interface for tenant relation handlers * chore: Add a base abstract tenant relation handler for common functionality * fix: Set the tenancy current resolver to null as default * feat(database.eloquent): Add belongs to tenant relation handler * feat(database.eloquent): Add belongs to many tenants relation handler * refactor: Abstract out tenancy options for hydration to flag class * chore: Current state of tenant child trait and has one/main handlers * test(database.eloquent): Testing for initial working tenant relation handlers * refactor: Removed Eloquent relation handler contract, implementations and tests * refactor: Tidied up tenancy options and updated typings for them * refactor: Create simple observer and base scope for belongsTo and belongsToMany relations * test: Add some tests for the initial trait functionality * refactor: Remove unused abstract tenant relation handler * feat: Complete single-database support * test: Fix observer tests to not throw exception when models not related to current tenant * refactor: Add a manual override for tenant restrictions to child models * refactor: Remove untestable and unnecessary code from belongs to many tenants observer * feat: Add method for temporarily disabling tenant restrictions Completes: #29 #30 #31 --- resources/config/multitenancy.php | 7 +- src/Attributes/TenantRelation.php | 11 + src/Contracts/Tenancy.php | 29 +- .../Concerns/BelongsToManyTenants.php | 21 ++ .../Eloquent/Concerns/BelongsToTenant.php | 21 ++ .../{IsTenantModel.php => IsTenant.php} | 2 +- .../Eloquent/Concerns/IsTenantChild.php | 122 ++++++ .../Eloquent/Contracts/OptionalTenant.php | 15 + .../BelongsToManyTenantsObserver.php | 224 +++++++++++ .../Observers/BelongsToTenantObserver.php | 179 +++++++++ .../Scopes/BelongsToManyTenantsScope.php | 54 +++ .../Eloquent/Scopes/BelongsToTenantScope.php | 53 +++ src/Database/Eloquent/TenantChildScope.php | 29 ++ src/Exceptions/TenantMismatch.php | 15 + src/Exceptions/TenantMissing.php | 16 + src/Managers/TenancyManager.php | 2 +- src/Support/DefaultTenancy.php | 49 ++- src/TenancyOptions.php | 53 +++ .../Eloquent/BelongsToManyTenantsTest.php | 320 ++++++++++++++++ .../Database/Eloquent/BelongsToTenantTest.php | 352 ++++++++++++++++++ tests/Database/Eloquent/TenantChildTest.php | 169 +++++++++ .../app/Models/NoTenantRelationModel.php | 15 + workbench/app/Models/TenantChild.php | 31 ++ workbench/app/Models/TenantChildOptional.php | 31 ++ workbench/app/Models/TenantChildren.php | 40 ++ .../app/Models/TenantChildrenOptional.php | 41 ++ workbench/app/Models/TenantModel.php | 8 +- .../app/Models/TooManyTenantRelationModel.php | 36 ++ .../database/factories/TenantChildFactory.php | 31 ++ .../factories/TenantChildOptionalFactory.php | 31 ++ .../factories/TenantChildrenFactory.php | 31 ++ .../TenantChildrenOptionalFactory.php | 31 ++ ...9_10_205548_create_tenant_child1_table.php | 29 ++ ...9_10_205642_create_tenant_child2_table.php | 33 ++ workbench/database/seeders/DatabaseSeeder.php | 22 +- 35 files changed, 2130 insertions(+), 23 deletions(-) create mode 100644 src/Attributes/TenantRelation.php create mode 100644 src/Database/Eloquent/Concerns/BelongsToManyTenants.php create mode 100644 src/Database/Eloquent/Concerns/BelongsToTenant.php rename src/Database/Eloquent/Concerns/{IsTenantModel.php => IsTenant.php} (98%) create mode 100644 src/Database/Eloquent/Concerns/IsTenantChild.php create mode 100644 src/Database/Eloquent/Contracts/OptionalTenant.php create mode 100644 src/Database/Eloquent/Observers/BelongsToManyTenantsObserver.php create mode 100644 src/Database/Eloquent/Observers/BelongsToTenantObserver.php create mode 100644 src/Database/Eloquent/Scopes/BelongsToManyTenantsScope.php create mode 100644 src/Database/Eloquent/Scopes/BelongsToTenantScope.php create mode 100644 src/Database/Eloquent/TenantChildScope.php create mode 100644 src/Exceptions/TenantMismatch.php create mode 100644 src/Exceptions/TenantMissing.php create mode 100644 src/TenancyOptions.php create mode 100644 tests/Database/Eloquent/BelongsToManyTenantsTest.php create mode 100644 tests/Database/Eloquent/BelongsToTenantTest.php create mode 100644 tests/Database/Eloquent/TenantChildTest.php create mode 100644 workbench/app/Models/NoTenantRelationModel.php create mode 100644 workbench/app/Models/TenantChild.php create mode 100644 workbench/app/Models/TenantChildOptional.php create mode 100644 workbench/app/Models/TenantChildren.php create mode 100644 workbench/app/Models/TenantChildrenOptional.php create mode 100644 workbench/app/Models/TooManyTenantRelationModel.php create mode 100644 workbench/database/factories/TenantChildFactory.php create mode 100644 workbench/database/factories/TenantChildOptionalFactory.php create mode 100644 workbench/database/factories/TenantChildrenFactory.php create mode 100644 workbench/database/factories/TenantChildrenOptionalFactory.php create mode 100644 workbench/database/migrations/2024_09_10_205548_create_tenant_child1_table.php create mode 100644 workbench/database/migrations/2024_09_10_205642_create_tenant_child2_table.php diff --git a/resources/config/multitenancy.php b/resources/config/multitenancy.php index b609326..64487e9 100644 --- a/resources/config/multitenancy.php +++ b/resources/config/multitenancy.php @@ -1,5 +1,7 @@ [ @@ -14,7 +16,10 @@ 'tenants' => [ 'provider' => 'tenants', - 'options' => [], + 'options' => [ + TenancyOptions::hydrateTenantRelation(), + TenancyOptions::throwIfNotRelated(), + ], ], ], diff --git a/src/Attributes/TenantRelation.php b/src/Attributes/TenantRelation.php new file mode 100644 index 0000000..e0a2796 --- /dev/null +++ b/src/Attributes/TenantRelation.php @@ -0,0 +1,11 @@ + + * @return list */ public function options(): array; /** - * Get a tenant option + * Check if a tenancy has an option * - * @param string $key - * @param mixed|null $default + * @param string $option * - * @return mixed + * @return bool + */ + public function hasOption(string $option): bool; + + /** + * Add an option to the tenancy + * + * @param string $option + * + * @return static + */ + public function addOption(string $option): static; + + /** + * Remove an option from the tenancy + * + * @param string $option + * + * @return static */ - public function option(string $key, mixed $default = null): mixed; + public function removeOption(string $option): static; } diff --git a/src/Database/Eloquent/Concerns/BelongsToManyTenants.php b/src/Database/Eloquent/Concerns/BelongsToManyTenants.php new file mode 100644 index 0000000..ee9776a --- /dev/null +++ b/src/Database/Eloquent/Concerns/BelongsToManyTenants.php @@ -0,0 +1,21 @@ +getMethods(ReflectionMethod::IS_PUBLIC)) + ->filter(function (ReflectionMethod $method) { + return ! $method->isStatic() && $method->getAttributes(TenantRelation::class); + }) + ->map(fn (ReflectionMethod $method) => $method->getName()); + + if ($methods->isEmpty()) { + throw new RuntimeException('No tenant relation found in model [' . static::class . ']'); + } + + if ($methods->count() > 1) { + throw new RuntimeException( + 'Models can only have one tenant relation, [' . static::class . '] has ' . $methods->count() + ); + } + + return $methods->first(); + } catch (ReflectionException $exception) { + throw new RuntimeException('Unable to find tenant relation for model [' . static::class . ']', previous: $exception); // @codeCoverageIgnore + } + } + + public function getTenantRelationName(): ?string + { + if (! isset($this->tenantRelationNames[static::class])) { + self::$tenantRelationName = $this->findTenantRelationName(); + } + + return self::$tenantRelationName ?? null; + } + + public function getTenancyName(): ?string + { + return null; + } + + public function getTenancy(): Tenancy + { + return app(TenancyManager::class)->get($this->getTenancyName()); + } + + public function getTenantRelation(): Relation + { + return $this->{$this->getTenantRelationName()}(); + } +} diff --git a/src/Database/Eloquent/Contracts/OptionalTenant.php b/src/Database/Eloquent/Contracts/OptionalTenant.php new file mode 100644 index 0000000..1595c06 --- /dev/null +++ b/src/Database/Eloquent/Contracts/OptionalTenant.php @@ -0,0 +1,15 @@ + $relation + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return bool + */ + private function doesModelAlreadyHaveATenant(Model $model, BelongsToMany $relation, Tenancy $tenancy): bool + { + // If the relation isn't loaded + if (! $model->relationLoaded($relation->getRelationName())) { + // Load it + $model->load($relation->getRelationName()); + } + + /** @var \Illuminate\Database\Eloquent\Collection $relatedModels */ + $relatedModels = $model->getRelation($relation->getRelationName()); + + // If it's not empty, there are already tenants + return $relatedModels->isNotEmpty(); + } + + /** + * Check if a model belongs to a different tenant + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * + * @return bool + */ + private function isTenantMismatched(Model $model, Tenant&Model $tenant, BelongsToMany $relation): bool + { + /** @var \Illuminate\Database\Eloquent\Collection|null $relatedModels */ + $relatedModels = $model->getRelation($relation->getRelationName()); + + // If the tenant model isn't in the loaded relation, or the relation is + // null, there's a mismatch + return $relatedModels?->first(function (Tenant&Model $model) use ($tenant) { + return $model->is($tenant); + }) === null; + } + + /** + * Perform initial checks and return they passed or not + * + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToManyTenants $model + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * + * @return bool + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMismatch + * @throws \Sprout\Exceptions\TenantMissing + */ + private function passesInitialChecks(Model $model, Tenancy $tenancy, BelongsToMany $relation, bool $succeedOnMatch = false): bool + { + // If we don't have a current tenant, we may need to do something + if (! $tenancy->check()) { + // The model doesn't require a tenant, so we exit silently + if ($model::isTenantOptional()) { // @phpstan-ignore-line + // We return true so that the model can be created + return false; + } + + // If we hit here then there's no tenant, and the model isn't + // marked as tenant being optional, so we throw an exception + throw TenantMissing::make($model::class, $tenancy->getName()); + } + + /** + * @var \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @phpstan-var TenantModel $tenant + */ + $tenant = $tenancy->tenant(); + + // The model already has the tenant foreign key set + if ($this->doesModelAlreadyHaveATenant($model, $relation, $tenancy)) { + // You're probably expecting the following to use the + // Tenant::getTenantKey() method, which would make sense, as that's + // what it's for, but this should be more flexible + if ($this->isTenantMismatched($model, $tenant, $relation)) { + // So, the current foreign key value doesn't match the current + // tenant, so we'll throw an exception...if we're allowed to + if (TenancyOptions::shouldThrowIfNotRelated($tenancy)) { + throw TenantMismatch::make($model::class, $tenancy->getName()); + } + + // If we hit here, we should continue without doing anything + // with the tenant + return false; + } + + // If we hit here, then the foreign key that's set is for the current + // tenant, so, we can assume that either the relation is already + // set in the model, or it doesn't need to be. + // Either way, we're finished here + return $succeedOnMatch; + } + + return true; + } + + /** + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToManyTenants $model + * + * @return void + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMissing + * @throws \Sprout\Exceptions\TenantMismatch + */ + public function created(Model $model): void + { + /** + * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @phpstan-ignore-next-line + */ + $relation = $model->getTenantRelation(); + + /** + * @var \Sprout\Contracts\Tenancy $tenancy + * @phpstan-ignore-next-line + */ + $tenancy = $model->getTenancy(); + + // If the initial checks do not pass + if (! $this->passesInitialChecks($model, $tenancy, $relation)) { + // Just exit, an exception will have be thrown + return; + } + + /** + * @var \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @phpstan-var TenantModel $tenant + */ + $tenant = $tenancy->tenant(); + + // Attach the tenant + $relation->attach($tenant); + + // Set the relation to contain the tenant + $this->setRelation($model, $relation, $tenant); + } + + /** + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToManyTenants $model + * + * @return void + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMissing + * @throws \Sprout\Exceptions\TenantMismatch + */ + public function retrieved(Model $model): void + { + /** + * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @phpstan-ignore-next-line + */ + $relation = $model->getTenantRelation(); + + /** + * @var \Sprout\Contracts\Tenancy $tenancy + * @phpstan-ignore-next-line + */ + $tenancy = $model->getTenancy(); + + // If the initial checks do not pass + if (! $this->passesInitialChecks($model, $tenancy, $relation, true)) { + // Just exit, an exception will have be thrown + return; + } + + /** + * @var \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @phpstan-var TenantModel $tenant + */ + $tenant = $tenancy->tenant(); + + // Set the relation to contain the tenant + $this->setRelation($model, $relation, $tenant); + } + + /** + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation + * @param \Sprout\Contracts\Tenant $tenant + * + * @phpstan-param ChildModel $model + * @phpstan-param TenantModel $tenant + * + * @return void + */ + private function setRelation(Model $model, BelongsToMany $relation, Tenant $tenant): void + { + $model->setRelation($relation->getRelationName(), $tenant->newCollection([$tenant])); + } +} diff --git a/src/Database/Eloquent/Observers/BelongsToTenantObserver.php b/src/Database/Eloquent/Observers/BelongsToTenantObserver.php new file mode 100644 index 0000000..cd41339 --- /dev/null +++ b/src/Database/Eloquent/Observers/BelongsToTenantObserver.php @@ -0,0 +1,179 @@ + $relation + * + * @return bool + */ + private function doesModelAlreadyHaveATenant(Model $model, BelongsTo $relation): bool + { + return $model->getAttribute($relation->getForeignKeyName()) !== null; + } + + /** + * Check if a model belongs to a different tenant + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @param \Illuminate\Database\Eloquent\Relations\BelongsTo $relation + * + * @return bool + */ + private function isTenantMismatched(Model $model, Tenant&Model $tenant, BelongsTo $relation): bool + { + return $model->getAttribute($relation->getForeignKeyName()) !== $tenant->getAttribute($relation->getOwnerKeyName()); + } + + /** + * Perform initial checks and return they passed or not + * + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToTenant $model + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Illuminate\Database\Eloquent\Relations\BelongsTo $relation + * @param bool $succeedOnMatch + * + * @return bool + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMismatch + * @throws \Sprout\Exceptions\TenantMissing + */ + private function passesInitialChecks(Model $model, Tenancy $tenancy, BelongsTo $relation, bool $succeedOnMatch = false): bool + { + // If we don't have a current tenant, we may need to do something + if (! $tenancy->check()) { + // The model doesn't require a tenant, so we exit silently + if ($model::isTenantOptional()) { // @phpstan-ignore-line + // We return true so that the model can be created + return false; + } + + // If we hit here then there's no tenant, and the model isn't + // marked as tenant being optional, so we throw an exception + throw TenantMissing::make($model::class, $tenancy->getName()); + } + + /** + * @var \Illuminate\Database\Eloquent\Model&\Sprout\Contracts\Tenant $tenant + * @phpstan-var TenantModel $tenant + */ + $tenant = $tenancy->tenant(); + + // The model already has the tenant foreign key set + if ($this->doesModelAlreadyHaveATenant($model, $relation)) { + // You're probably expecting the following to use the + // Tenant::getTenantKey() method, which would make sense, as that's + // what it's for, but this should be more flexible + if ($this->isTenantMismatched($model, $tenant, $relation)) { + // So, the current foreign key value doesn't match the current + // tenant, so we'll throw an exception...if we're allowed to + if (TenancyOptions::shouldThrowIfNotRelated($tenancy)) { + throw TenantMismatch::make($model::class, $tenancy->getName()); + } + + // If we hit here, we should continue without doing anything + // with the tenant + return false; + } + + // If we hit here, then the foreign key that's set is for the current + // tenant, so, we can assume that either the relation is already + // set in the model, or it doesn't need to be. + // Either way, we're finished here + return $succeedOnMatch; + } + + return true; + } + + /** + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToTenant $model + * + * @return bool + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMissing + * @throws \Sprout\Exceptions\TenantMismatch + */ + public function creating(Model $model): bool + { + /** + * @var \Illuminate\Database\Eloquent\Relations\BelongsTo $relation + * @phpstan-ignore-next-line + */ + $relation = $model->getTenantRelation(); + + /** + * @var \Sprout\Contracts\Tenancy $tenancy + * @phpstan-ignore-next-line + */ + $tenancy = $model->getTenancy(); + + // If the initial checks do not pass + if (! $this->passesInitialChecks($model, $tenancy, $relation)) { + // Just exit, an exception will have be thrown + return true; + } + + // Associate the model and the tenant + $relation->associate($tenancy->tenant()); + + return true; + } + + /** + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToTenant $model + * + * @return void + * + * @phpstan-param ChildModel $model + * + * @throws \Sprout\Exceptions\TenantMissing + * @throws \Sprout\Exceptions\TenantMismatch + */ + public function retrieved(Model $model): void + { + /** + * @var \Illuminate\Database\Eloquent\Relations\BelongsTo $relation + * @phpstan-ignore-next-line + */ + $relation = $model->getTenantRelation(); + + /** + * @var \Sprout\Contracts\Tenancy $tenancy + * @phpstan-ignore-next-line + */ + $tenancy = $model->getTenancy(); + + // If the initial checks do not pass + if (! $this->passesInitialChecks($model, $tenancy, $relation, true)) { + // Just exit, an exception will have be thrown + return; + } + + // Populate the relation with the tenant + $model->setRelation($relation->getRelationName(), $tenancy->tenant()); + } +} diff --git a/src/Database/Eloquent/Scopes/BelongsToManyTenantsScope.php b/src/Database/Eloquent/Scopes/BelongsToManyTenantsScope.php new file mode 100644 index 0000000..38cab63 --- /dev/null +++ b/src/Database/Eloquent/Scopes/BelongsToManyTenantsScope.php @@ -0,0 +1,54 @@ + $builder + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToTenant $model + * + * @phpstan-param Model $model + * + * @return void + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function apply(Builder $builder, Model $model): void + { + /** @phpstan-ignore-next-line */ + $tenancy = $model->getTenancy(); + + // If there's no current tenant + if (! $tenancy->check()) { + // We can exit early because the tenant is optional! + if ($model::isTenantOptional()) { // @phpstan-ignore-line + return; + } + + // We should throw an exception because the tenant is missing + throw TenantMissing::make($model::class, $tenancy->getName()); + } + + // Finally, add the clause so that all queries are scoped to the + // current tenant + $builder->whereHas( + /** @phpstan-ignore-next-line */ + $model->getTenantRelationName(), + function (Builder $builder) use ($tenancy) { + $builder->whereKey($tenancy->key()); + } + ); + } +} diff --git a/src/Database/Eloquent/Scopes/BelongsToTenantScope.php b/src/Database/Eloquent/Scopes/BelongsToTenantScope.php new file mode 100644 index 0000000..faabcb2 --- /dev/null +++ b/src/Database/Eloquent/Scopes/BelongsToTenantScope.php @@ -0,0 +1,53 @@ + $builder + * @param \Illuminate\Database\Eloquent\Model&\Sprout\Database\Eloquent\Concerns\BelongsToTenant $model + * + * @phpstan-param ModelClass $model + * + * @return void + * + * @throws \Sprout\Exceptions\TenantMissing + */ + public function apply(Builder $builder, Model $model): void + { + /** @phpstan-ignore-next-line */ + $tenancy = $model->getTenancy(); + + // If there's no current tenant + if (! $tenancy->check()) { + // We can exit early because the tenant is optional! + if ($model::isTenantOptional()) { // @phpstan-ignore-line + return; + } + + // We should throw an exception because the tenant is missing + throw TenantMissing::make($model::class, $tenancy->getName()); + } + + // Finally, add the clause so that all queries are scoped to the + // current tenant + $builder->where( + /** @phpstan-ignore-next-line */ + $model->getTenantRelation()->getForeignKeyName(), + '=', + $tenancy->key() + ); + } +} diff --git a/src/Database/Eloquent/TenantChildScope.php b/src/Database/Eloquent/TenantChildScope.php new file mode 100644 index 0000000..877f30b --- /dev/null +++ b/src/Database/Eloquent/TenantChildScope.php @@ -0,0 +1,29 @@ + $builder + * + * @return void + */ + public function extend(Builder $builder): void + { + $builder->macro('withoutTenants', function (Builder $builder) { + /** @phpstan-ignore-next-line */ + return $builder->withoutGlobalScope($this); + }); + } +} diff --git a/src/Exceptions/TenantMismatch.php b/src/Exceptions/TenantMismatch.php new file mode 100644 index 0000000..cd7a50f --- /dev/null +++ b/src/Exceptions/TenantMismatch.php @@ -0,0 +1,15 @@ + $config * @param string $name * - * @phpstan-param array{provider?: string|null, options?: array} $config + * @phpstan-param array{provider?: string|null, options?: list} $config * * @return \Sprout\Support\DefaultTenancy<\Sprout\Contracts\Tenant> */ diff --git a/src/Support/DefaultTenancy.php b/src/Support/DefaultTenancy.php index f40fa92..92ad42b 100644 --- a/src/Support/DefaultTenancy.php +++ b/src/Support/DefaultTenancy.php @@ -29,7 +29,7 @@ final class DefaultTenancy implements Tenancy /** * @var \Sprout\Contracts\IdentityResolver|null */ - private ?IdentityResolver $resolver; + private ?IdentityResolver $resolver = null; /** * @var \Sprout\Contracts\Tenant|null @@ -39,7 +39,7 @@ final class DefaultTenancy implements Tenancy private ?Tenant $tenant = null; /** - * @var array + * @var list */ private array $options; @@ -51,7 +51,7 @@ final class DefaultTenancy implements Tenancy /** * @param string $name * @param \Sprout\Contracts\TenantProvider $provider - * @param array $options + * @param list $options */ public function __construct(string $name, TenantProvider $provider, array $options) { @@ -254,7 +254,7 @@ public function setTenant(?Tenant $tenant): static /** * Get all tenant options * - * @return array + * @return list */ public function options(): array { @@ -264,14 +264,45 @@ public function options(): array /** * Get a tenant option * - * @param string $key - * @param mixed|null $default + * @param string $option * - * @return mixed + * @return bool + */ + public function hasOption(string $option): bool + { + return in_array($option, $this->options(), true); + } + + /** + * Add an option to the tenancy + * + * @param string $option + * + * @return static */ - public function option(string $key, mixed $default = null): mixed + public function addOption(string $option): static { - return $this->options()[$key] ?? $default; + if (! $this->hasOption($option)) { + $this->options[] = $option; + } + + return $this; + } + + /** + * Remove an option from the tenancy + * + * @param string $option + * + * @return static + */ + public function removeOption(string $option): static + { + if ($this->hasOption($option)) { + unset($this->options[array_search($option, $this->options(), true)]); + } + + return $this; } /** diff --git a/src/TenancyOptions.php b/src/TenancyOptions.php new file mode 100644 index 0000000..f41c184 --- /dev/null +++ b/src/TenancyOptions.php @@ -0,0 +1,53 @@ + $tenancy + * + * @return bool + */ + public static function shouldHydrateTenantRelation(Tenancy $tenancy): bool + { + return $tenancy->hasOption(static::hydrateTenantRelation()); + } + + /** + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return bool + */ + public static function shouldThrowIfNotRelated(Tenancy $tenancy): bool + { + return $tenancy->hasOption(static::throwIfNotRelated()); + } +} diff --git a/tests/Database/Eloquent/BelongsToManyTenantsTest.php b/tests/Database/Eloquent/BelongsToManyTenantsTest.php new file mode 100644 index 0000000..a2b7443 --- /dev/null +++ b/tests/Database/Eloquent/BelongsToManyTenantsTest.php @@ -0,0 +1,320 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + #[Test] + public function addsGlobalScope(): void + { + $model = new TenantChildren(); + + $this->assertContains(BelongsToManyTenants::class, class_uses_recursive($model)); + $this->assertArrayHasKey(BelongsToManyTenantsScope::class, $model->getGlobalScopes()); + } + + #[Test] + public function addsObservers(): void + { + $model = new TenantChildren(); + $dispatcher = TenantChildren::getEventDispatcher(); + + $this->assertContains(BelongsToManyTenants::class, class_uses_recursive($model)); + + if ($dispatcher instanceof Dispatcher) { + $this->assertTrue($dispatcher->hasListeners('eloquent.retrieved: ' . TenantChildren::class)); + $this->assertTrue($dispatcher->hasListeners('eloquent.created: ' . TenantChildren::class)); + + $listeners = $dispatcher->getRawListeners(); + + $this->assertContains(BelongsToManyTenantsObserver::class . '@retrieved', $listeners['eloquent.retrieved: ' . TenantChildren::class]); + $this->assertContains(BelongsToManyTenantsObserver::class . '@created', $listeners['eloquent.created: ' . TenantChildren::class]); + } else { + $this->markTestIncomplete('Cannot complete the test because a custom dispatcher is in place'); + } + } + + #[Test] + public function automaticallyAssociatesWithTenantWhenCreating(): void + { + $tenant = TenantModel::factory()->create(); + + app(TenancyManager::class)->get()->setTenant($tenant); + + $child = TenantChildren::factory()->create(); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenants')); + $this->assertNotNull($child->tenants->first(fn (Model $model) => $model->is($tenant))); + } + + #[Test] + public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenCreating(): void + { + $this->expectException(TenantMissing::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChildren::class + . '] requires a tenant, and the tenancy' + . ' [tenants] does not have one' + ); + + TenantChildren::factory()->create(); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithInterfaceWhenCreating(): void + { + $child = TenantChildrenOptional::factory()->create(); + + $this->assertInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertEmpty($child->tenants); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithOverrideWhenCreating(): void + { + TenantChildren::ignoreTenantRestrictions(); + + $child = TenantChildren::factory()->create(); + + TenantChildren::resetTenantRestrictions(); + + $this->assertNotInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertEmpty($child->tenants); + } + + #[Test] + public function doesNothingIfTheTenantIsAlreadySetOnTheModelWhenCreating(): void + { + $this->markTestSkipped('This test cannot be performed with a belongs to many relation'); + } + + #[Test] + public function throwsAnExceptionIfTheTenantIsAlreadySetOnTheModelAndItIsDifferentWhenCreating(): void + { + $this->markTestSkipped('This test cannot be performed with a belongs to many relation'); + } + + #[Test] + public function doesNotThrowAnExceptionForTenantMismatchIfNotSetToWhenCreating(): void + { + $this->markTestSkipped('This test cannot be performed with a belongs to many relation'); + } + + #[Test] + public function automaticallyPopulateTheTenantRelationWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + app(TenancyManager::class)->get()->setTenant($tenant); + + $child = TenantChildren::query()->find(TenantChildren::factory()->create()->getKey()); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenants')); + $this->assertNotNull($child->tenants->first(fn (Model $model) => $model->is($tenant))); + } + + #[Test] + public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChildren::factory()->create(); + + $tenancy->setTenant(null); + + $this->expectException(TenantMissing::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChildren::class + . '] requires a tenant, and the tenancy' + . ' [tenants] does not have one' + ); + + TenantChildren::query()->find($child->getKey()); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithInterfaceWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChildrenOptional::factory()->create(); + + $tenancy->setTenant(null); + + $child = TenantChildrenOptional::query()->find($child->getKey()); + + $this->assertInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenants')); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithOverrideWhenHydrating(): void + { + TenantChildren:: ignoreTenantRestrictions(); + + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChildren::factory()->create(); + + $tenancy->setTenant(null); + + $child = TenantChildren::query()->find($child->getKey()); + + TenantChildren::resetTenantRestrictions(); + + $this->assertNotInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenants')); + } + + #[Test] + public function throwsAnExceptionIfTheTenantIsAlreadySetOnTheModelAndItIsDifferentWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->addOption(TenancyOptions::throwIfNotRelated()); + + $child = TenantChildren::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $this->expectException(TenantMismatch::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChildren::class + . '] already has a tenant, but it is not the current tenant for the tenancy' + . ' [tenants]' + ); + + TenantChildren::query()->withoutTenants()->find($child->getKey()); + } + + #[Test] + public function doesNotThrowAnExceptionForTenantMismatchIfNotSetToWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $child = TenantChildren::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChildren::query()->withoutTenants()->find($child->getKey()); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenants')); + $this->assertNotNull($child->tenants->first(fn (Model $model) => $model->is($tenant))); + $this->assertNull($child->tenants->first(fn (Model $model) => $model->is($tenancy->tenant()))); + } + + #[Test] + public function onlyReturnsModelsForTheCurrentTenant(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $original = TenantChildren::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChildren::query()->find($original->getKey()); + + $this->assertNull($child); + + $tenancy->setTenant($tenant); + + $child = TenantChildren::query()->find($original->getKey()); + + $this->assertNotNull($child); + } + + #[Test] + public function ignoresTenantClauseWithBuilderMacro(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $original = TenantChildren::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChildren::query()->withoutTenants()->find($original->getKey()); + + $this->assertNotNull($child); + + $tenancy->setTenant($tenant); + + $child = TenantChildren::query()->withoutTenants()->find($original->getKey()); + + $this->assertNotNull($child); + } +} diff --git a/tests/Database/Eloquent/BelongsToTenantTest.php b/tests/Database/Eloquent/BelongsToTenantTest.php new file mode 100644 index 0000000..d00f92d --- /dev/null +++ b/tests/Database/Eloquent/BelongsToTenantTest.php @@ -0,0 +1,352 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + #[Test] + public function addsGlobalScope(): void + { + $model = new TenantChild(); + + $this->assertContains(BelongsToTenant::class, class_uses_recursive($model)); + $this->assertArrayHasKey(BelongsToTenantScope::class, $model->getGlobalScopes()); + } + + #[Test] + public function addsObservers(): void + { + $model = new TenantChild(); + $dispatcher = TenantChild::getEventDispatcher(); + + $this->assertContains(BelongsToTenant::class, class_uses_recursive($model)); + + if ($dispatcher instanceof Dispatcher) { + $this->assertTrue($dispatcher->hasListeners('eloquent.retrieved: ' . TenantChild::class)); + $this->assertTrue($dispatcher->hasListeners('eloquent.creating: ' . TenantChild::class)); + + $listeners = $dispatcher->getRawListeners(); + + $this->assertContains(BelongsToTenantObserver::class . '@retrieved', $listeners['eloquent.retrieved: ' . TenantChild::class]); + $this->assertContains(BelongsToTenantObserver::class . '@creating', $listeners['eloquent.creating: ' . TenantChild::class]); + } else { + $this->markTestIncomplete('Cannot complete the test because a custom dispatcher is in place'); + } + } + + #[Test] + public function automaticallyAssociatesWithTenantWhenCreating(): void + { + $tenant = TenantModel::factory()->create(); + + app(TenancyManager::class)->get()->setTenant($tenant); + + $child = TenantChild::factory()->create(); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenant')); + $this->assertTrue($child->tenant->is($tenant)); + } + + #[Test] + public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenCreating(): void + { + $this->expectException(TenantMissing::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChild::class + . '] requires a tenant, and the tenancy' + . ' [tenants] does not have one' + ); + + TenantChild::factory()->create(); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithInterfaceWhenCreating(): void + { + $child = TenantChildOptional::factory()->create(); + + $this->assertInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertNull($child->tenant); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithOverrideWhenCreating(): void + { + TenantChild::ignoreTenantRestrictions(); + $child = TenantChild::factory()->create(); + TenantChild::resetTenantRestrictions(); + + $this->assertNotInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertNull($child->tenant); + } + + #[Test] + public function doesNothingIfTheTenantIsAlreadySetOnTheModelWhenCreating(): void + { + $tenant = TenantModel::factory()->create(); + + app(TenancyManager::class)->get()->setTenant($tenant); + + $child = TenantChild::factory()->afterMaking(function (TenantChild $model) use ($tenant) { + $model->tenant()->associate($tenant); + })->create(); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenant')); + $this->assertTrue($child->tenant->is($tenant)); + } + + #[Test] + public function throwsAnExceptionIfTheTenantIsAlreadySetOnTheModelAndItIsDifferentWhenCreating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->addOption(TenancyOptions::throwIfNotRelated()); + + $this->expectException(TenantMismatch::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChild::class + . '] already has a tenant, but it is not the current tenant for the tenancy' + . ' [tenants]' + ); + + TenantChild::factory()->for(TenantModel::factory(), 'tenant')->create(); + } + + #[Test] + public function doesNotThrowAnExceptionForTenantMismatchIfNotSetToWhenCreating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $child = TenantChild::factory()->for(TenantModel::factory(), 'tenant')->create(); + + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertFalse($child->tenant->is($tenant)); + } + + #[Test] + public function automaticallyPopulateTheTenantRelationWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + app(TenancyManager::class)->get()->setTenant($tenant); + + $child = TenantChild::query()->find(TenantChild::factory()->create()->getKey()); + + $this->assertTrue($child->exists); + $this->assertTrue($child->relationLoaded('tenant')); + $this->assertTrue($child->tenant->is($tenant)); + } + + #[Test] + public function throwsAnExceptionIfTheresNoTenantAndTheTenantIsNotOptionalWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChild::factory()->create(); + + $tenancy->setTenant(null); + + $this->expectException(TenantMissing::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChild::class + . '] requires a tenant, and the tenancy' + . ' [tenants] does not have one' + ); + + TenantChild::query()->find($child->getKey()); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithInterfaceWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChildOptional::factory()->create(); + + $tenancy->setTenant(null); + + $child = TenantChildOptional::query()->find($child->getKey()); + + $this->assertInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + } + + #[Test] + public function doesNothingIfTheresNoTenantAndTheTenantIsOptionalWithOverrideWhenHydrating(): void + { + TenantChild::ignoreTenantRestrictions(); + + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $child = TenantChild::factory()->create(); + + $tenancy->setTenant(null); + + $child = TenantChild::query()->find($child->getKey()); + + TenantChild::resetTenantRestrictions(); + + $this->assertNotInstanceOf(OptionalTenant::class, $child); + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + } + + #[Test] + public function throwsAnExceptionIfTheTenantIsAlreadySetOnTheModelAndItIsDifferentWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->addOption(TenancyOptions::throwIfNotRelated()); + + $child = TenantChild::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $this->expectException(TenantMismatch::class); + $this->expectExceptionMessage( + 'Model [' + . TenantChild::class + . '] already has a tenant, but it is not the current tenant for the tenancy' + . ' [tenants]' + ); + + TenantChild::query()->withoutTenants()->find($child->getKey()); + } + + #[Test] + public function doesNotThrowAnExceptionForTenantMismatchIfNotSetToWhenHydrating(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $child = TenantChild::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChild::query()->withoutTenants()->find($child->getKey()); + + $this->assertTrue($child->exists); + $this->assertFalse($child->relationLoaded('tenant')); + $this->assertTrue($child->tenant->is($tenant)); + $this->assertFalse($child->tenant->is($tenancy->tenant())); + } + + #[Test] + public function onlyReturnsModelsForTheCurrentTenant(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + + $original = TenantChild::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChild::query()->find($original->getKey()); + + $this->assertNull($child); + + $tenancy->setTenant($tenant); + + $child = TenantChild::query()->find($original->getKey()); + + $this->assertNotNull($child); + } + + #[Test] + public function ignoresTenantClauseWithBuilderMacro(): void + { + $tenant = TenantModel::factory()->create(); + + $tenancy = app(TenancyManager::class)->get(); + + $tenancy->setTenant($tenant); + $tenancy->removeOption(TenancyOptions::throwIfNotRelated()); + + $original = TenantChild::factory()->create(); + + $tenancy->setTenant(TenantModel::factory()->create()); + + $child = TenantChild::query()->withoutTenants()->find($original->getKey()); + + $this->assertNotNull($child); + + $tenancy->setTenant($tenant); + + $child = TenantChild::query()->withoutTenants()->find($original->getKey()); + + $this->assertNotNull($child); + } +} diff --git a/tests/Database/Eloquent/TenantChildTest.php b/tests/Database/Eloquent/TenantChildTest.php new file mode 100644 index 0000000..9f1e7a3 --- /dev/null +++ b/tests/Database/Eloquent/TenantChildTest.php @@ -0,0 +1,169 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + }); + } + + #[Test] + public function canFindTenantRelationUsingAttribute(): void + { + $model = new TenantChild(); + + $this->assertContains(IsTenantChild::class, class_uses_recursive($model)); + $this->assertSame('tenant', $model->getTenantRelationName()); + } + + #[Test] + public function canManuallyProvideTenantRelationName(): void + { + $model = new TenantChildren(); + + $this->assertContains(IsTenantChild::class, class_uses_recursive($model)); + $this->assertSame('tenants', $model->getTenantRelationName()); + } + + #[Test] + public function throwsAnExceptionIfItCantFindTheTenantRelation(): void + { + $model = new NoTenantRelationModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No tenant relation found in model [' . NoTenantRelationModel::class . ']'); + + $model->getTenantRelationName(); + } + + #[Test] + public function throwsAnExceptionIfThereAreMultipleTenantRelations(): void + { + $model = new TooManyTenantRelationModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Models can only have one tenant relation, [' . TooManyTenantRelationModel::class . '] has 2'); + + $model->getTenantRelationName(); + } + + #[Test] + public function canRetrieveTenantRelationCorrectly(): void + { + $model1 = new TenantChild(); + $model2 = new TenantChildren(); + + $relation1 = $model1->getTenantRelation(); + $relation2 = $model2->getTenantRelation(); + + $this->assertContains(IsTenantChild::class, class_uses_recursive($model1)); + $this->assertContains(IsTenantChild::class, class_uses_recursive($model2)); + $this->assertInstanceOf(BelongsTo::class, $relation1); + $this->assertInstanceOf(BelongsToMany::class, $relation2); + $this->assertSame('tenant', $relation1->getRelationName()); + $this->assertSame('tenants', $relation2->getRelationName()); + } + + #[Test] + public function hasNullTenancyNameByDefault(): void + { + $model = new TenantChild(); + + $this->assertContains(IsTenantChild::class, class_uses_recursive($model)); + $this->assertNull($model->getTenancyName()); + } + + #[Test] + public function canManuallyProvideTheTenancyName(): void + { + $model = new TenantChildren(); + + $this->assertContains(IsTenantChild::class, class_uses_recursive($model)); + $this->assertSame('tenants', $model->getTenancyName()); + } + + #[Test] + public function canRetrieveTenancyCorrectly(): void + { + $model1 = new TenantChild(); + $model2 = new TenantChildren(); + + $tenancy1 = $model1->getTenancy(); + $tenancy2 = $model2->getTenancy(); + + $this->assertSame('tenants', $tenancy1->getName()); + $this->assertSame('tenants', $tenancy2->getName()); + } + + #[Test] + public function hasManualTenantRestrictionOverride(): void + { + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + + TenantChild::ignoreTenantRestrictions(); + + $this->assertTrue(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + + TenantChildren::ignoreTenantRestrictions(); + + $this->assertTrue(TenantChild::isTenantOptional()); + $this->assertTrue(TenantChildren::isTenantOptional()); + + TenantChild::resetTenantRestrictions(); + + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertTrue(TenantChildren::isTenantOptional()); + + TenantChildren::resetTenantRestrictions(); + + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + } + + #[Test] + public function hasManualTenantRestrictionTemporaryOverride(): void + { + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + + TenantChild::withoutTenantRestrictions(function () { + $this->assertTrue(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + }); + + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + + TenantChildren::withoutTenantRestrictions(function () { + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertTrue(TenantChildren::isTenantOptional()); + }); + + $this->assertFalse(TenantChild::isTenantOptional()); + $this->assertFalse(TenantChildren::isTenantOptional()); + } +} diff --git a/workbench/app/Models/NoTenantRelationModel.php b/workbench/app/Models/NoTenantRelationModel.php new file mode 100644 index 0000000..6281800 --- /dev/null +++ b/workbench/app/Models/NoTenantRelationModel.php @@ -0,0 +1,15 @@ + */ + use BelongsToTenant; + + protected $table = 'tenant_child1'; +} diff --git a/workbench/app/Models/TenantChild.php b/workbench/app/Models/TenantChild.php new file mode 100644 index 0000000..c649bf6 --- /dev/null +++ b/workbench/app/Models/TenantChild.php @@ -0,0 +1,31 @@ + */ + use HasFactory, BelongsToTenant; + + protected $table = 'tenant_child1'; + + protected static string $factory = TenantChildFactory::class; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Workbench\App\Models\TenantModel, self> + */ + #[TenantRelation] + public function tenant(): BelongsTo + { + return $this->belongsTo(TenantModel::class, 'tenant_id'); + } +} diff --git a/workbench/app/Models/TenantChildOptional.php b/workbench/app/Models/TenantChildOptional.php new file mode 100644 index 0000000..f2baf24 --- /dev/null +++ b/workbench/app/Models/TenantChildOptional.php @@ -0,0 +1,31 @@ + */ + use HasFactory, BelongsToTenant; + + protected $table = 'tenant_child1'; + + protected static string $factory = TenantChildOptionalFactory::class; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Workbench\App\Models\TenantModel, self> + */ + #[TenantRelation] + public function tenant(): BelongsTo + { + return $this->belongsTo(TenantModel::class, 'tenant_id'); + } +} diff --git a/workbench/app/Models/TenantChildren.php b/workbench/app/Models/TenantChildren.php new file mode 100644 index 0000000..f8f2389 --- /dev/null +++ b/workbench/app/Models/TenantChildren.php @@ -0,0 +1,40 @@ + + */ + use HasFactory, BelongsToManyTenants; + + protected $table = 'tenant_child2'; + + protected static string $factory = TenantChildrenFactory::class; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\Workbench\App\Models\TenantModel> + */ + public function tenants(): BelongsToMany + { + return $this->belongsToMany(TenantModel::class, 'tenant_relations', 'tenant_id', 'tenant_child2_id'); + } + + public function getTenantRelationName(): string + { + return 'tenants'; + } + + public function getTenancyName(): string + { + return 'tenants'; + } +} diff --git a/workbench/app/Models/TenantChildrenOptional.php b/workbench/app/Models/TenantChildrenOptional.php new file mode 100644 index 0000000..6128e8f --- /dev/null +++ b/workbench/app/Models/TenantChildrenOptional.php @@ -0,0 +1,41 @@ + + */ + use HasFactory, BelongsToManyTenants; + + protected $table = 'tenant_child2'; + + protected static string $factory = TenantChildrenOptionalFactory::class; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\Workbench\App\Models\TenantModel> + */ + public function tenants(): BelongsToMany + { + return $this->belongsToMany(TenantModel::class, 'tenant_relations', 'tenant_id', 'tenant_child2_id'); + } + + public function getTenantRelationName(): string + { + return 'tenants'; + } + + public function getTenancyName(): string + { + return 'tenants'; + } +} diff --git a/workbench/app/Models/TenantModel.php b/workbench/app/Models/TenantModel.php index 2cd944b..384d0ad 100644 --- a/workbench/app/Models/TenantModel.php +++ b/workbench/app/Models/TenantModel.php @@ -6,14 +6,18 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Sprout\Contracts\Tenant; -use Sprout\Database\Eloquent\Concerns\IsTenantModel; +use Sprout\Database\Eloquent\Concerns\IsTenant; +use Workbench\Database\Factories\TenantChildFactory; +use Workbench\Database\Factories\TenantModelFactory; class TenantModel extends Model implements Tenant { - use IsTenantModel, HasFactory; + use IsTenant, HasFactory; protected $table = 'tenants'; + protected static string $factory = TenantModelFactory::class; + protected $fillable = [ 'name', 'identifier', diff --git a/workbench/app/Models/TooManyTenantRelationModel.php b/workbench/app/Models/TooManyTenantRelationModel.php new file mode 100644 index 0000000..d094c61 --- /dev/null +++ b/workbench/app/Models/TooManyTenantRelationModel.php @@ -0,0 +1,36 @@ + */ + use BelongsToTenant; + + protected $table = 'tenant_child1'; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\Workbench\App\Models\TenantModel, self> + */ + #[TenantRelation] + public function tenant(): BelongsTo + { + return $this->belongsTo(TenantModel::class, 'tenant_id'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\Workbench\App\Models\TenantModel> + */ + #[TenantRelation] + public function tenants(): BelongsToMany + { + return $this->belongsToMany(TenantModel::class, 'tenant_relations', 'tenant_id', 'tenant_child2_id'); + } +} diff --git a/workbench/database/factories/TenantChildFactory.php b/workbench/database/factories/TenantChildFactory.php new file mode 100644 index 0000000..ee9d613 --- /dev/null +++ b/workbench/database/factories/TenantChildFactory.php @@ -0,0 +1,31 @@ + + */ +class TenantChildFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = TenantChild::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return []; + } +} diff --git a/workbench/database/factories/TenantChildOptionalFactory.php b/workbench/database/factories/TenantChildOptionalFactory.php new file mode 100644 index 0000000..c353c8a --- /dev/null +++ b/workbench/database/factories/TenantChildOptionalFactory.php @@ -0,0 +1,31 @@ + + */ +class TenantChildOptionalFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = TenantChildOptional::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return []; + } +} diff --git a/workbench/database/factories/TenantChildrenFactory.php b/workbench/database/factories/TenantChildrenFactory.php new file mode 100644 index 0000000..04fd9d0 --- /dev/null +++ b/workbench/database/factories/TenantChildrenFactory.php @@ -0,0 +1,31 @@ + + */ +class TenantChildrenFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = TenantChildren::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return []; + } +} diff --git a/workbench/database/factories/TenantChildrenOptionalFactory.php b/workbench/database/factories/TenantChildrenOptionalFactory.php new file mode 100644 index 0000000..0a29571 --- /dev/null +++ b/workbench/database/factories/TenantChildrenOptionalFactory.php @@ -0,0 +1,31 @@ + + */ +class TenantChildrenOptionalFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = TenantChildrenOptional::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return []; + } +} diff --git a/workbench/database/migrations/2024_09_10_205548_create_tenant_child1_table.php b/workbench/database/migrations/2024_09_10_205548_create_tenant_child1_table.php new file mode 100644 index 0000000..b1b58d5 --- /dev/null +++ b/workbench/database/migrations/2024_09_10_205548_create_tenant_child1_table.php @@ -0,0 +1,29 @@ +id(); + + $table->foreignId('tenant_id')->nullable()->references('id')->on('tenants'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_child1'); + } +}; diff --git a/workbench/database/migrations/2024_09_10_205642_create_tenant_child2_table.php b/workbench/database/migrations/2024_09_10_205642_create_tenant_child2_table.php new file mode 100644 index 0000000..5ef5d91 --- /dev/null +++ b/workbench/database/migrations/2024_09_10_205642_create_tenant_child2_table.php @@ -0,0 +1,33 @@ +id(); + $table->timestamps(); + }); + + Schema::create('tenant_relations', static function (Blueprint $table) { + $table->foreignId('tenant_id')->references('id')->on('tenants'); + $table->foreignId('tenant_child2_id')->references('id')->on('tenant_child2'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_child2'); + Schema::dropIfExists('tenant_relations'); + } +}; diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php index 8c9427c..5683eea 100644 --- a/workbench/database/seeders/DatabaseSeeder.php +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -2,18 +2,34 @@ namespace Workbench\Database\Seeders; +use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use Workbench\App\Models\TenantChild; +use Workbench\App\Models\TenantChildren; +use Workbench\App\Models\TenantModel; +use Workbench\Database\Factories\TenantChildFactory; +use Workbench\Database\Factories\TenantChildrenFactory; use Workbench\Database\Factories\TenantModelFactory; -use Workbench\Database\Factories\UserFactory; class DatabaseSeeder extends Seeder { + use WithoutModelEvents; + /** * Seed the application's database. */ public function run(): void { - TenantModelFactory::new()->createMany(20); + $tenants = TenantModelFactory::new()->createMany(20); + + $tenants->each(function (TenantModel $tenant, int $i) { + TenantChildFactory::new()->afterMaking(function (TenantChild $child) use ($tenant) { + $child->tenant()->associate($tenant); + })->createMany(5); + }); + + TenantChildrenFactory::new()->afterCreating(function (TenantChildren $child) use ($tenants) { + $child->tenants()->saveMany($tenants->random(random_int(1, 4))); + })->createMany(10); } }