diff --git a/packages/database/src/Builder/QueryBuilders/BuildsQuery.php b/packages/database/src/Builder/QueryBuilders/BuildsQuery.php index 59f69c432..712ac65b2 100644 --- a/packages/database/src/Builder/QueryBuilders/BuildsQuery.php +++ b/packages/database/src/Builder/QueryBuilders/BuildsQuery.php @@ -4,6 +4,7 @@ use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Query; +use UnitEnum; /** * @template TModel @@ -26,6 +27,13 @@ interface BuildsQuery get; } + /** + * The database tag for targeting a specific database connection. + */ + public null|string|UnitEnum $onDatabase { + get; + } + /** * Creates a {@see Query} instance with the specified optional bindings. * diff --git a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php index b2b730560..98b0105a1 100644 --- a/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -83,6 +83,8 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou } } + $builder->onDatabase = $source->onDatabase; + /** @var CountQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php index d7e40a8dd..4e7df8591 100644 --- a/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/DeleteQueryBuilder.php @@ -60,6 +60,8 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->appendWhere($where); } + $builder->onDatabase = $source->onDatabase; + /** @var DeleteQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index ea21f4a72..42aeabf0c 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -3,6 +3,7 @@ namespace Tempest\Database\Builder\QueryBuilders; use Tempest\Database\Exceptions\ModelDidNotHavePrimaryColumn; +use Tempest\Database\OnDatabase; use Tempest\Database\PrimaryKey; use Tempest\Mapper\SerializerFactory; @@ -15,13 +16,15 @@ /** * @template TModel of object */ -final readonly class QueryBuilder +final class QueryBuilder { + use OnDatabase; + /** * @param class-string|string|TModel $model */ public function __construct( - private string|object $model, + private readonly string|object $model, ) {} /** @@ -41,7 +44,7 @@ public function select(string ...$columns): SelectQueryBuilder return new SelectQueryBuilder( model: $this->model, fields: $columns !== [] ? arr($columns)->unique() : null, - ); + )->onDatabase($this->onDatabase); } /** @@ -66,7 +69,7 @@ public function insert(mixed ...$values): InsertQueryBuilder model: $this->model, rows: $values, serializerFactory: get(SerializerFactory::class), - ); + )->onDatabase($this->onDatabase); } /** @@ -88,7 +91,7 @@ public function update(mixed ...$values): UpdateQueryBuilder model: $this->model, values: $values, serializerFactory: get(SerializerFactory::class), - ); + )->onDatabase($this->onDatabase); } /** @@ -106,7 +109,7 @@ public function update(mixed ...$values): UpdateQueryBuilder */ public function delete(): DeleteQueryBuilder { - return new DeleteQueryBuilder($this->model); + return new DeleteQueryBuilder($this->model)->onDatabase($this->onDatabase); } /** @@ -124,7 +127,7 @@ public function count(?string $column = null): CountQueryBuilder return new CountQueryBuilder( model: $this->model, column: $column, - ); + )->onDatabase($this->onDatabase); } /** @@ -255,9 +258,7 @@ public function create(mixed ...$params): object $model = $this->new(...$params); - $id = query($this->model) - ->insert($model) - ->execute(); + $id = $this->insert($model)->execute(); $inspector = inspect($this->model); $primaryKeyProperty = $inspector->getPrimaryKeyProperty(); @@ -338,6 +339,7 @@ public function updateOrCreate(array $find, array $update): object } query($model) + ->onDatabase($this->onDatabase) ->update(...$update) ->execute(); diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 8c48debd1..cfe45ef0e 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -171,6 +171,8 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou } } + $builder->onDatabase = $source->onDatabase; + /** @var SelectQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php index 65d944220..c1f25a26f 100644 --- a/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/UpdateQueryBuilder.php @@ -93,6 +93,8 @@ public static function fromQueryBuilder(BuildsQuery&SupportsWhereStatements $sou $builder->appendWhere($where); } + $builder->onDatabase = $source->onDatabase; + /** @var UpdateQueryBuilder $builder */ return $builder; } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index e89b06ae2..60d2c813f 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -13,6 +13,7 @@ use Tempest\Reflection\PropertyReflector; use Tempest\Router\IsBindingValue; use Tempest\Validation\SkipValidation; +use UnitEnum; use function Tempest\Support\arr; use function Tempest\Support\str; @@ -22,6 +23,31 @@ trait IsDatabaseModel #[IsBindingValue, SkipValidation] public PrimaryKey $id; + #[SkipValidation, Virtual] + private null|string|UnitEnum $onDatabase = null; + + /** + * Returns a query builder targeting the specified database connection. + * + * @return QueryBuilder + */ + public static function on(null|string|UnitEnum $databaseTag): QueryBuilder + { + return static::queryBuilder()->onDatabase(databaseTag: $databaseTag); + } + + /** + * Targets a specific database connection for this model instance. + */ + public function onDatabase(null|string|UnitEnum $databaseTag): static + { + $clone = clone $this; + + $clone->onDatabase = $databaseTag; + + return $clone; + } + /** * @return QueryBuilder */ @@ -185,7 +211,9 @@ public function refresh(): static $primaryKeyProperty = $model->getPrimaryKeyProperty(); $primaryKeyValue = $primaryKeyProperty->getValue($this); - $new = static::select() + $new = static::queryBuilder() + ->onDatabase($this->onDatabase) + ->select() ->with(...$loadedRelations->map(fn (Relation $relation) => $relation->name)) ->get($primaryKeyValue); @@ -216,7 +244,9 @@ public function load(string ...$relations): static $primaryKeyProperty = $model->getPrimaryKeyProperty(); $primaryKeyValue = $primaryKeyProperty->getValue($this); - $new = static::get($primaryKeyValue, $relations); + $new = static::queryBuilder() + ->onDatabase($this->onDatabase) + ->get($primaryKeyValue, $relations); $fieldsToUpdate = arr($relations) ->map(fn (string $relation) => str($relation)->before('.')->toString()) @@ -240,6 +270,7 @@ public function save(): static // Models without primary keys always insert if (! $model->hasPrimaryKey()) { query($this::class) + ->onDatabase($this->onDatabase) ->insert($this) ->execute(); @@ -254,6 +285,7 @@ public function save(): static // to generate the id and populate the model instance with it if ($primaryKeyValue === null) { $id = query($this::class) + ->onDatabase($this->onDatabase) ->insert($this) ->execute(); @@ -264,8 +296,9 @@ public function save(): static return $this; } - // Is the model was already save, we update it + // Is the model was already saved, we update it query($this) + ->onDatabase($this->onDatabase) ->update(...inspect($this)->getPropertyValues()) ->execute(); @@ -282,6 +315,7 @@ public function update(mixed ...$params): static $model->validate(...$params); query($this) + ->onDatabase($this->onDatabase) ->update(...$params) ->whereField($model->getPrimaryKey(), $model->getPrimaryKeyValue()) ->execute(); @@ -299,6 +333,7 @@ public function update(mixed ...$params): static public function delete(): void { query($this) + ->onDatabase($this->onDatabase) ->delete() ->build() ->execute(); diff --git a/tests/Integration/Database/Builder/IsDatabaseModelTest.php b/tests/Integration/Database/Builder/IsDatabaseModelTest.php index 8086c3364..1a312bad0 100644 --- a/tests/Integration/Database/Builder/IsDatabaseModelTest.php +++ b/tests/Integration/Database/Builder/IsDatabaseModelTest.php @@ -8,6 +8,7 @@ use DateTime as NativeDateTime; use DateTimeImmutable; use Tempest\Database\BelongsTo; +use Tempest\Database\Builder\QueryBuilders\QueryBuilder; use Tempest\Database\Exceptions\DeleteStatementWasInvalid; use Tempest\Database\Exceptions\RelationWasMissing; use Tempest\Database\Exceptions\ValueWasMissing; @@ -694,6 +695,100 @@ public function test_nullable_relation_save(): void $this->assertNotNull($a->b); $this->assertSame('b', $a->b->name); } + + public function test_on_returns_query_builder_with_database_tag(): void + { + $builder = Foo::on('analytics'); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + $this->assertSame('analytics', $builder->onDatabase); + } + + public function test_on_propagates_tag_to_select_builder(): void + { + $selectBuilder = Foo::on('analytics')->select(); + + $this->assertSame('analytics', $selectBuilder->onDatabase); + } + + public function test_on_propagates_tag_to_insert_builder(): void + { + $insertBuilder = Foo::on('analytics')->insert(); + + $this->assertSame('analytics', $insertBuilder->onDatabase); + } + + public function test_on_propagates_tag_to_count_builder(): void + { + $countBuilder = Foo::on('analytics')->count(); + + $this->assertSame('analytics', $countBuilder->onDatabase); + } + + public function test_on_propagates_tag_to_delete_builder(): void + { + $deleteBuilder = Foo::on('analytics')->delete(); + + $this->assertSame('analytics', $deleteBuilder->onDatabase); + } + + public function test_on_with_null_sets_null_tag(): void + { + $builder = Foo::on(null); + + $this->assertNull($builder->onDatabase); + } + + public function test_on_with_enum_tag(): void + { + $builder = Foo::on(TestDatabaseTag::Analytics); + + $this->assertSame(TestDatabaseTag::Analytics, $builder->onDatabase); + } + + public function test_on_database_returns_clone(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create(bar: 'baz'); + $clone = $foo->onDatabase('analytics'); + + $this->assertNotSame($foo, $clone); + } + + public function test_on_database_does_not_mutate_original(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create(bar: 'baz'); + $foo->onDatabase('analytics'); + + // Original still works against default database + $foo->update(bar: 'updated'); + $refreshed = Foo::get($foo->id); + + $this->assertSame('updated', $refreshed->bar); + } + + public function test_on_database_preserves_model_data(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + FooDatabaseMigration::class, + ); + + $foo = Foo::create(bar: 'baz'); + $clone = $foo->onDatabase('analytics'); + + $this->assertSame('baz', $clone->bar); + $this->assertEquals($foo->id, $clone->id); + } } final class Foo @@ -1117,3 +1212,9 @@ final class ModelWithHookedVirtualProperty get => strtoupper($this->name); } } + +enum TestDatabaseTag +{ + case Analytics; + case Reporting; +}