Skip to content

Commit

Permalink
feat(database.eloquent): Add tenant child model functionality (#32)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ollieread authored Sep 24, 2024
1 parent 1029f3c commit 777265f
Show file tree
Hide file tree
Showing 35 changed files with 2,130 additions and 23 deletions.
7 changes: 6 additions & 1 deletion resources/config/multitenancy.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use Sprout\TenancyOptions;

return [

'defaults' => [
Expand All @@ -14,7 +16,10 @@

'tenants' => [
'provider' => 'tenants',
'options' => [],
'options' => [
TenancyOptions::hydrateTenantRelation(),
TenancyOptions::throwIfNotRelated(),
],
],

],
Expand Down
11 changes: 11 additions & 0 deletions src/Attributes/TenantRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);

namespace Sprout\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
final class TenantRelation
{
}
29 changes: 23 additions & 6 deletions src/Contracts/Tenancy.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,34 @@ public function setTenant(?Tenant $tenant): static;
/**
* Get all tenant options
*
* @return array<string, mixed>
* @return list<string>
*/
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;
}
21 changes: 21 additions & 0 deletions src/Database/Eloquent/Concerns/BelongsToManyTenants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace Sprout\Database\Eloquent\Concerns;

use Sprout\Database\Eloquent\Observers\BelongsToManyTenantsObserver;
use Sprout\Database\Eloquent\Scopes\BelongsToManyTenantsScope;

trait BelongsToManyTenants
{
use IsTenantChild;

public static function bootBelongsToManyTenants(): void
{
// Automatically scope queries
static::addGlobalScope(new BelongsToManyTenantsScope());

// Add the observer
static::observe(new BelongsToManyTenantsObserver());
}
}
21 changes: 21 additions & 0 deletions src/Database/Eloquent/Concerns/BelongsToTenant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);

namespace Sprout\Database\Eloquent\Concerns;

use Sprout\Database\Eloquent\Observers\BelongsToTenantObserver;
use Sprout\Database\Eloquent\Scopes\BelongsToTenantScope;

trait BelongsToTenant
{
use IsTenantChild;

public static function bootBelongsToTenant(): void
{
// Automatically scope queries
static::addGlobalScope(new BelongsToTenantScope());

// Add the observer
static::observe(new BelongsToTenantObserver());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @phpstan-require-implements \Sprout\Contracts\Tenant
* @phpstan-require-extends \Illuminate\Database\Eloquent\Model
*/
trait IsTenantModel
trait IsTenant
{
/**
* Get the tenant identifier
Expand Down
122 changes: 122 additions & 0 deletions src/Database/Eloquent/Concerns/IsTenantChild.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);

namespace Sprout\Database\Eloquent\Concerns;

use Illuminate\Database\Eloquent\Relations\Relation;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use RuntimeException;
use Sprout\Attributes\TenantRelation;
use Sprout\Contracts\Tenancy;
use Sprout\Database\Eloquent\Contracts\OptionalTenant;
use Sprout\Managers\TenancyManager;

/**
* @phpstan-require-extends \Illuminate\Database\Eloquent\Model
*
* @mixin \Illuminate\Database\Eloquent\Model
*/
trait IsTenantChild
{
/**
* @var string
*/
protected static string $tenantRelationName;

protected static bool $ignoreTenantRestrictions = false;

public static function shouldIgnoreTenantRestrictions(): bool
{
return self::$ignoreTenantRestrictions;
}

public static function ignoreTenantRestrictions(): void
{
self::$ignoreTenantRestrictions = true;
}

public static function resetTenantRestrictions(): void
{
self::$ignoreTenantRestrictions = false;
}

/**
* @template RetType of mixed
*
* @param callable(): RetType $callback
*
* @return mixed
*
* @phpstan-return RetType
*/
public static function withoutTenantRestrictions(callable $callback): mixed
{
self::ignoreTenantRestrictions();

$return = $callback();

self::resetTenantRestrictions();

return $return;
}

public static function isTenantOptional(): bool
{
return is_subclass_of(static::class, OptionalTenant::class)
|| (
method_exists(static::class, 'shouldIgnoreTenantRestrictions')
&& static::shouldIgnoreTenantRestrictions() // @phpstan-ignore-line
);
}

private function findTenantRelationName(): string
{
try {
$methods = collect((new ReflectionClass(static::class))->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()}();
}
}
15 changes: 15 additions & 0 deletions src/Database/Eloquent/Contracts/OptionalTenant.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Sprout\Database\Eloquent\Contracts;

/**
* Optional Tenant Contract
*
* This contract marks an Eloquent model that relates to a tenant as having
* that tenant be optional, so it can be interacted with if there's no
* tenant.
*/
interface OptionalTenant
{

}
Loading

0 comments on commit 777265f

Please sign in to comment.