Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/Tempest/src/Config/TempestTenantDatabaseSwitcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
use Tempest\Database\Connection\Connection;
use Tempest\Database\Connection\PDOConnection;
use Tempest\Database\Database;
use Tempest\Database\GenericDatabase;
use Tempest\Database\Transactions\GenericTransactionManager;
use Tempest\EventBus\EventBus;
use Tempest\Mapper\SerializerFactory;

/**
* licence Apache-2.0
Expand Down Expand Up @@ -51,12 +55,14 @@ public function switchOn(string|ConnectionReference $activatedConnection): void
$container->singleton(Connection::class, $connection, tag: $configTag);
}

// Register the tenant's already-built Connection as the default so IsDatabaseModel
// and Ecotone's DBAL share one PDO — enabling transaction rollback across both
// Promote the tenant's already-built Connection as the default so IsDatabaseModel
// and Ecotone's DBAL share one PDO — enabling transaction rollback across both.
// The default Database is rebuilt from that same Connection and registered as a
// singleton, otherwise Tempest's DatabaseInitializer would resolve it lazily from the
// discovered default DatabaseConfig and clobber the promoted Connection back to it.
$tenantConnection = $container->get(Connection::class, tag: $configTag);
$container->singleton(DatabaseConfig::class, $tenantConfig);
$container->singleton(Connection::class, $tenantConnection);
$container->unregister(Database::class);
$container->singleton(Database::class, $this->buildDatabaseFor($container, $tenantConnection));

// Close the Doctrine Connection so TempestDynamicDriver reconnects on next use,
// picking up the now-promoted default Connection's PDO
Expand All @@ -73,6 +79,16 @@ public function switchOff(): void
$this->closeDoctrineDefaultConnection($container);
}

private function buildDatabaseFor(GenericContainer $container, Connection $connection): GenericDatabase
{
return new GenericDatabase(
$connection,
new GenericTransactionManager($connection),
$container->get(SerializerFactory::class),
$container->get(EventBus::class),
);
}

private function closeDoctrineDefaultConnection(GenericContainer $container): void
{
if (! $container->has(DbalConnectionFactory::class)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Test\Ecotone\Tempest\Fixture\TenantAggregate;

/**
* licence Apache-2.0
*/
final class RegisterTenantProduct
{
public function __construct(
public readonly string $name,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Test\Ecotone\Tempest\Fixture\TenantAggregate;

use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration;
use Ecotone\Messaging\Attribute\ServiceContext;
use Ecotone\Tempest\Config\TempestConnectionReference;

/**
* licence Apache-2.0
*/
final class TenantAggregateConfiguration
{
#[ServiceContext]
public function multiTenantConfiguration(): MultiTenantConfiguration
{
return MultiTenantConfiguration::create(
tenantHeaderName: 'tenant',
tenantToConnectionMapping: [
'tenant_a' => TempestConnectionReference::create('tenant_a'),
'tenant_b' => TempestConnectionReference::create('tenant_b'),
],
);
}
}
44 changes: 44 additions & 0 deletions packages/Tempest/tests/Fixture/TenantAggregate/TenantProduct.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Test\Ecotone\Tempest\Fixture\TenantAggregate;

use Ecotone\Modelling\Attribute\Aggregate;
use Ecotone\Modelling\Attribute\CommandHandler;
use Ecotone\Modelling\Attribute\IdentifierMethod;
use Tempest\Database\IsDatabaseModel;
use Tempest\Database\PrimaryKey;
use Tempest\Database\Table;
use Tempest\Database\Uuid;

/**
* licence Apache-2.0
*/
#[Aggregate]
#[Table('tenant_products')]
final class TenantProduct
{
use IsDatabaseModel;

#[Uuid]
public PrimaryKey $id;

public string $name;

#[CommandHandler]
public static function register(RegisterTenantProduct $command): self
{
$product = new self();
$product->name = $command->name;
$product->save();

return $product;
}

#[IdentifierMethod('id')]
public function getId(): string
{
return (string) $this->id->value;
}
}
112 changes: 112 additions & 0 deletions packages/Tempest/tests/MultiTenant/TenantAggregatePersistenceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

namespace Test\Ecotone\Tempest\MultiTenant;

use Ecotone\Messaging\Config\ModulePackageList;
use Ecotone\Modelling\CommandBus;
use Ecotone\Tempest\EcotoneConfig;
use Ecotone\Tempest\EcotoneServiceInitializer;
use Ecotone\Tempest\MessagingSystemInitializer;
use PDO;
use Test\Ecotone\Tempest\EcotoneIntegrationTestCase;
use Test\Ecotone\Tempest\Fixture\TenantAggregate\RegisterTenantProduct;
use Test\Ecotone\Tempest\TempestDatabaseConfigFactory;
use Test\Ecotone\Tempest\TempestTestPaths;

/**
* licence Apache-2.0
* @internal
*/
final class TenantAggregatePersistenceTest extends EcotoneIntegrationTestCase
{
private CommandBus $commandBus;

protected function ecotoneConfig(): EcotoneConfig
{
return new EcotoneConfig(
namespaces: ['Test\\Ecotone\\Tempest\\Fixture\\TenantAggregate\\'],
skippedModulePackageNames: ModulePackageList::allPackagesExcept([
ModulePackageList::TEMPEST_PACKAGE,
ModulePackageList::DBAL_PACKAGE,
]),
test: false,
);
}

protected function discoverTestLocations(): array
{
return [
...parent::discoverTestLocations(),
new \Tempest\Discovery\DiscoveryLocation(
'Test\\Ecotone\\Tempest\\Fixture\\TenantAggregate\\',
TempestTestPaths::fixturePath() . '/TenantAggregate',
),
];
}

protected function setUp(): void
{
EcotoneServiceInitializer::clearCache();
MessagingSystemInitializer::clearDefinitionHolder();

$this->setupKernel();

$this->container->config(TempestDatabaseConfigFactory::primary('tenant_a'));
$this->container->config(TempestDatabaseConfigFactory::secondary('tenant_b'));

$this->createTenantProductsTable($this->postgresConnection());
$this->createTenantProductsTable($this->mysqlConnection());

$this->commandBus = $this->container->get(CommandBus::class);
}

protected function tearDown(): void
{
$this->postgresConnection()->exec('DROP TABLE IF EXISTS tenant_products');
$this->mysqlConnection()->exec('DROP TABLE IF EXISTS tenant_products');
parent::tearDown();
}

public function test_tempest_model_aggregate_persists_to_the_active_tenant(): void
{
$this->commandBus->send(new RegisterTenantProduct('Alice'), metadata: ['tenant' => 'tenant_a']);
$this->commandBus->send(new RegisterTenantProduct('Bob'), metadata: ['tenant' => 'tenant_a']);
$this->commandBus->send(new RegisterTenantProduct('Carol'), metadata: ['tenant' => 'tenant_b']);

$this->assertSame(['Alice', 'Bob'], $this->registeredNames($this->postgresConnection()));
$this->assertSame(['Carol'], $this->registeredNames($this->mysqlConnection()));
}

private function registeredNames(PDO $pdo): array
{
return $pdo->query('SELECT name FROM tenant_products ORDER BY name')->fetchAll(PDO::FETCH_COLUMN);
}

private function createTenantProductsTable(PDO $pdo): void
{
$pdo->exec('DROP TABLE IF EXISTS tenant_products');
$pdo->exec(
'CREATE TABLE tenant_products (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
)',
);
}

private function postgresConnection(): PDO
{
$config = TempestDatabaseConfigFactory::primary();

return new PDO($config->dsn, $config->username, $config->password);
}

private function mysqlConnection(): PDO
{
$config = TempestDatabaseConfigFactory::secondary();

return new PDO($config->dsn, $config->username, $config->password);
}
}
3 changes: 3 additions & 0 deletions quickstart-examples/Tempest/Model/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/vendor/
composer.lock
/.tempest/
106 changes: 106 additions & 0 deletions quickstart-examples/Tempest/Model/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Tempest Model — Active-Record Model as an Ecotone Aggregate

## 1. What you'll learn

This example shows how a **Tempest active-record model** (`use Tempest\Database\IsDatabaseModel`) becomes an **Ecotone `#[Aggregate]`**. The model carries its own `#[CommandHandler]` and `#[QueryHandler]` methods, and Ecotone persists it automatically through the `ecotone/tempest` package's `TempestRepository` (a `StandardRepository` that calls the model's own `save()`).

The same aggregate is then exercised three ways:

1. **Command Bus calling the model directly** — commands route to the model's handlers; persistence is automatic.
2. **`#[Repository]` business interface** — an Ecotone gateway that loads (and saves) the aggregate.
3. **`#[DbalQuery]` business interface** — an SQL read-side gateway over the underlying table.

## 2. How it fits together

```mermaid
flowchart LR
Client -->|send command| CommandBus
CommandBus -->|route| Product["Product\n#[Aggregate] + IsDatabaseModel"]
Product -->|save| Table[(products table\nPostgreSQL)]
Client -->|getBy / findBy / save| ProductRepository["ProductRepository\n#[Repository] gateway"]
ProductRepository -->|TempestRepository| Table
Client -->|findAll| ProductFinder["ProductFinder\n#[DbalQuery] gateway"]
ProductFinder -->|SELECT| Table
```

*Files involved:*
- `app/Domain/Product.php` — the Tempest model annotated with `#[Aggregate]`
- `app/Domain/Command/RegisterProduct.php`, `ChangePrice.php` — command messages
- `app/ProductRepository.php` — `#[Repository]` business-interface gateway (load + save)
- `app/ProductFinder.php` — `#[DbalQuery]` read-side business interface
- `app/Infrastructure/EcotoneConfiguration.php` — registers Tempest's `DatabaseConfig` as Ecotone's default `DbalConnectionFactory`
- `app/Infrastructure/ConnectionFactoryInitializer.php` — exposes the same DBAL connection to Tempest autowiring
- `app/database.config.php` — the Tempest `PostgresConfig`

## 3. The model as an aggregate

```php
#[Aggregate]
final class Product
{
use IsDatabaseModel;

public PrimaryKey $id;
public string $name;
public int $price;

#[CommandHandler]
public static function register(RegisterProduct $command): self { /* new self(); ...; $product->save(); */ }

#[CommandHandler(routingKey: 'product.changePrice')]
public function changePrice(ChangePrice $command): void { $this->price = $command->price; }

#[QueryHandler('product.getPrice')]
public function getPrice(): int { return $this->price; }

#[IdentifierMethod('id')]
public function getId(): int { return $this->id->value; }
}
```

- The **static** handler is the factory: it creates the row (and calls `save()` so the generated id is returned to the caller).
- The **instance** handler mutates state; `TempestRepository::save()` persists it after the handler returns — no explicit `save()` needed.
- `#[IdentifierMethod('id')]` maps Ecotone's aggregate identifier to the Tempest `PrimaryKey`'s int `value`, so commands/queries target the right row via `metadata: ['aggregate.id' => $id]`.

## 4. Identifier mapping and table setup

Ecotone needs a scalar identifier, but the model's identity is a `Tempest\Database\PrimaryKey` object. `#[IdentifierMethod('id')] getId(): int` exposes the underlying int. Loading uses `TempestRepository::findBy()` → `Product::findById($id)`.

The `products` table is created in `run_example.php` with Tempest's own schema builder (`CreateTableStatement`), matching how the package's integration test sets up its `orders` table — dropped first for idempotency:

```php
$createSql = (new CreateTableStatement('products'))
->primary('id')->string('name')->integer('price')
->compile($database->dialect);
```

## 5. Running it

```bash
docker compose up -d app database
docker compose exec app bash -lc 'cd quickstart-examples/Tempest/Model && composer update && php run_example.php'
```

The script prints a six-step ribbon ending with `== Example completed successfully ==`.

## 6. Tempest-specific wiring

1. `app/database.config.php` returns a Tempest `PostgresConfig`, auto-discovered as the container's `DatabaseConfig`.
2. `EcotoneConfiguration::databaseConnection()` returns `TempestConnectionReference::defaultConnection()`, registering that config as Ecotone's default `DbalConnectionFactory` (used by the DBAL business interface).
3. `ConnectionFactoryInitializer` resolves `Interop\Queue\ConnectionFactory` to the same Ecotone `DbalConnectionFactory`, so any Tempest-autowired service shares one connection.

Handlers, the aggregate and the business interfaces are discovered automatically from the `App\` PSR-4 root — **no `ecotone.config.php` is required** (zero-config).

> Boot order note: resolve `ConfiguredMessagingSystem` once right after `Tempest::boot()` before fetching the buses/gateways. That call compiles Ecotone's services and registers them with Tempest's container so `#[Repository]`/`#[DbalQuery]` gateways and the buses become resolvable via `$container->get(...)`.

## 7. The three demonstrations

| # | Mechanism | What it proves |
|---|-----------|----------------|
| 2-4 | Command/Query Bus → model | `RegisterProduct` creates and persists; `product.changePrice` mutates by `aggregate.id`; `product.getPrice` reads reconstituted state |
| 5 | `#[Repository]` gateway | `getBy(int)` / `findBy(int)` load the model; `save(Product)` persists an **already-loaded** aggregate (UPDATE) |
| 6 | `#[DbalQuery]` gateway | `findAll()` reads the underlying `products` table with raw SQL |

## 8. Known limitation (active-record + `#[Repository]` save)

The `#[Repository]` `save(Product $product)` gateway works for **existing** aggregates (their `PrimaryKey` is set, so `getId()` resolves). It does **not** work for a brand-new, unsaved `Product`: Ecotone reads the aggregate identifier via `getId()` before persisting, but Tempest only generates the `PrimaryKey` during the INSERT, so `getId()` throws "must not be accessed before initialization". Create new aggregates through the Command Bus path (the static `#[CommandHandler]` factory) — that returns the generated id — and use the repository gateway for loads and updates.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Command;

final readonly class ChangePrice
{
public function __construct(
public int $price,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/*
* licence Apache-2.0
*/

declare(strict_types=1);

namespace App\Domain\Command;

final readonly class RegisterProduct
{
public function __construct(
public string $name,
public int $price,
) {
}
}
Loading
Loading