From 6b25413e4ae2a33d7d8a05e1e05f1c0a2ad08abe Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 07:30:45 +0530 Subject: [PATCH 01/16] Fix architecture gaps: tenant lifecycle, HTTP layer, connection pooling, provisioning rollback - Route tenant:create through TenantService.onboard() so tenant is registered in DB - Add provisioning rollback: drop tenant DB on migration failure - Add migrate:all-tenants command for keeping existing tenant DBs in sync - Complete HTTP layer: Pipeline middleware runner, Router, Request::path(), Response value object (withJson/send/immutable) - Add SubdomainTenantResolver and wire into TenantResolverFactory - Add RequestLifecycle helper for concurrency safety (Swoole/RoadRunner) - Add tenant suspend/resume/offboard to TenantService + TenantRepository - Add connection registry to TenantConnectionResolver (reuse PDO per DB) - Run CoreSchemaManager for both tenancy strategies (always create tenants table) - Add --config-dir option to generate:all command - Expand ConfigValidator to require all database.* keys - 185 tests passing, 1 skipped (getallheaders unavailable in CLI SAPI) --- bin/ef | 2 + src/Config/ConfigValidator.php | 8 ++ src/Console/GenerateAllCommand.php | 5 +- src/Console/MigrateAllTenantsCommand.php | 65 +++++++++++ src/Console/TenantCreateCommand.php | 16 +-- src/Core/Application.php | 33 +++++- src/Http/Middleware/MiddlewareInterface.php | 3 +- src/Http/Pipeline.php | 29 +++++ src/Http/Request.php | 14 ++- src/Http/Response.php | 51 +++++++++ src/Http/Router.php | 46 ++++++++ src/Tenant/RequestLifecycle.php | 26 +++++ .../Resolver/SubdomainTenantResolver.php | 45 ++++++++ src/Tenant/TenantConnectionResolver.php | 15 ++- src/Tenant/TenantProvisioner.php | 41 ++++--- src/Tenant/TenantRepository.php | 31 +++++ src/Tenant/TenantResolverFactory.php | 12 +- src/Tenant/TenantService.php | 37 +++++- tests/Config/ConfigValidatorTest.php | 46 +++++++- .../Console/MigrateAllTenantsCommandTest.php | 20 ++++ tests/Core/ApplicationTest.php | 25 +++++ tests/Http/PipelineTest.php | 106 ++++++++++++++++++ tests/Http/RequestTest.php | 14 +++ tests/Http/ResponseValueObjectTest.php | 53 +++++++++ tests/Http/RouterTest.php | 93 +++++++++++++++ .../Resolver/SubdomainTenantResolverTest.php | 57 ++++++++++ tests/Tenant/TenantRepositoryTest.php | 94 ++++++++++++++++ tests/Tenant/TenantResolverFactoryTest.php | 18 ++- tests/Tenant/TenantServiceTest.php | 101 +++++++++++++++++ 29 files changed, 1062 insertions(+), 44 deletions(-) create mode 100644 src/Console/MigrateAllTenantsCommand.php create mode 100644 src/Http/Pipeline.php create mode 100644 src/Http/Router.php create mode 100644 src/Tenant/RequestLifecycle.php create mode 100644 src/Tenant/Resolver/SubdomainTenantResolver.php create mode 100644 tests/Console/MigrateAllTenantsCommandTest.php create mode 100644 tests/Http/PipelineTest.php create mode 100644 tests/Http/ResponseValueObjectTest.php create mode 100644 tests/Http/RouterTest.php create mode 100644 tests/Tenant/Resolver/SubdomainTenantResolverTest.php diff --git a/bin/ef b/bin/ef index 5e0a4cf..6c05816 100755 --- a/bin/ef +++ b/bin/ef @@ -7,6 +7,7 @@ use Symfony\Component\Console\Application; use EntityForge\Console\GenerateCommand; use EntityForge\Console\GenerateAllCommand; use EntityForge\Console\MigrateCommand; +use EntityForge\Console\MigrateAllTenantsCommand; use EntityForge\Console\RollbackCommand; use EntityForge\Console\TenantCreateCommand; @@ -15,6 +16,7 @@ $application = new Application('EntityForge CLI', '1.0'); $application->addCommand(new GenerateCommand()); $application->addCommand(new GenerateAllCommand()); $application->addCommand(new MigrateCommand()); +$application->addCommand(new MigrateAllTenantsCommand()); $application->addCommand(new RollbackCommand()); $application->addCommand(new TenantCreateCommand()); diff --git a/src/Config/ConfigValidator.php b/src/Config/ConfigValidator.php index 6506ccc..497f9bb 100644 --- a/src/Config/ConfigValidator.php +++ b/src/Config/ConfigValidator.php @@ -5,6 +5,8 @@ class ConfigValidator { + private const REQUIRED_DB_KEYS = ['driver', 'host', 'port', 'database', 'username', 'password']; + /** * @throws Exception */ @@ -13,5 +15,11 @@ public function validate(array $config): void if (!isset($config['tenancy']['enabled'])) { throw new Exception("Missing 'tenancy.enabled' in config"); } + + foreach (self::REQUIRED_DB_KEYS as $key) { + if (!isset($config['database'][$key])) { + throw new Exception("Missing 'database.{$key}' in config"); + } + } } } \ No newline at end of file diff --git a/src/Console/GenerateAllCommand.php b/src/Console/GenerateAllCommand.php index e2bde94..d017fd4 100644 --- a/src/Console/GenerateAllCommand.php +++ b/src/Console/GenerateAllCommand.php @@ -15,12 +15,13 @@ protected function configure(): void $this ->setName('generate:all') ->setDescription('Generate all entities from config') - ->addOption('migration', null, InputOption::VALUE_NONE, 'Generate migration'); + ->addOption('migration', null, InputOption::VALUE_NONE, 'Generate migration') + ->addOption('config-dir', null, InputOption::VALUE_OPTIONAL, 'Path to entity config directory', 'config/entities'); } protected function execute(InputInterface $input, OutputInterface $output): int { - $configDir = 'config/entities'; + $configDir = $input->getOption('config-dir'); if (!is_dir($configDir)) { $output->writeln("Directory not found: {$configDir}"); diff --git a/src/Console/MigrateAllTenantsCommand.php b/src/Console/MigrateAllTenantsCommand.php new file mode 100644 index 0000000..bbff102 --- /dev/null +++ b/src/Console/MigrateAllTenantsCommand.php @@ -0,0 +1,65 @@ +setName('migrate:all-tenants') + ->setDescription('Run migrations against every registered tenant database (database strategy only)'); + } + + /** + * @throws Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $app = new Application(__DIR__ . '/../../config'); + $app->boot([], false); + $config = $app->getConfig(); + + if (($config['tenancy']['strategy'] ?? 'shared') !== 'database') { + $output->writeln('migrate:all-tenants only applies to the database strategy.'); + return Command::SUCCESS; + } + + $tenantRepo = new TenantRepository($config); + $tenants = $tenantRepo->all(); + + if (empty($tenants)) { + $output->writeln('No tenants found.'); + return Command::SUCCESS; + } + + $failed = 0; + + foreach ($tenants as $tenant) { + $tenantId = $tenant['tenant_id']; + $dbConfig = $config['database']; + $dbConfig['database'] = $dbConfig['database'] . '_' . $tenantId; + + try { + $connection = new Connection($dbConfig); + $runner = new MigrationRunner($connection); + $runner->run('database/migrations'); + $output->writeln("Migrated: {$tenantId}"); + } catch (\Throwable $e) { + $output->writeln("Failed {$tenantId}: {$e->getMessage()}"); + $failed++; + } + } + + return $failed > 0 ? Command::FAILURE : Command::SUCCESS; + } +} diff --git a/src/Console/TenantCreateCommand.php b/src/Console/TenantCreateCommand.php index 16e0221..b83f2c7 100644 --- a/src/Console/TenantCreateCommand.php +++ b/src/Console/TenantCreateCommand.php @@ -3,11 +3,12 @@ namespace EntityForge\Console; use EntityForge\Core\Application; -use EntityForge\Tenant\TenantProvisioner; +use EntityForge\Tenant\TenantService; use Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class TenantCreateCommand extends Command @@ -16,8 +17,9 @@ protected function configure(): void { $this ->setName('tenant:create') - ->setDescription('Create a new tenant') - ->addArgument('tenantId', InputArgument::REQUIRED, 'Tenant ID'); + ->setDescription('Create and register a new tenant') + ->addArgument('tenantId', InputArgument::REQUIRED, 'Tenant ID') + ->addOption('name', null, InputOption::VALUE_OPTIONAL, 'Display name (defaults to tenantId)'); } /** @@ -26,16 +28,16 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $tenantId = $input->getArgument('tenantId'); + $name = $input->getOption('name') ?? $tenantId; $app = new Application(__DIR__ . '/../../config'); $app->boot([], false); - $provisioner = new TenantProvisioner($app->getConfig()); + $service = new TenantService($app->getConfig()); try { - $provisioner->create($tenantId); - - $output->writeln("Tenant {$tenantId} created successfully"); + $service->onboard($tenantId, $name); + $output->writeln("Tenant '{$tenantId}' created and registered successfully"); } catch (\Throwable $e) { $output->writeln("{$e->getMessage()}"); return Command::FAILURE; diff --git a/src/Core/Application.php b/src/Core/Application.php index 8cd6a84..0dc8aef 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -6,6 +6,7 @@ use EntityForge\Config\ConfigValidator; use EntityForge\Core\CoreSchemaManager; use EntityForge\Tenant\TenantContext; +use EntityForge\Tenant\TenantRepository; use EntityForge\Tenant\TenantResolverFactory; use Exception; @@ -34,16 +35,38 @@ public function boot(array $context = [], bool $resolveTenant = true): void $validator->validate($this->config); - // Check Strategy - $strategy = $this->config['tenancy']['strategy'] ?? 'shared'; + // Always run CoreSchemaManager — idempotently creates the tenants registry + // in both strategies (shared needs it too for tenant lookups and status checks) + (new CoreSchemaManager($this->config))->ensure(); - if ($strategy === 'database') { - (new CoreSchemaManager($this->config))->ensure(); - } + $strategy = $this->config['tenancy']['strategy'] ?? 'shared'; // 🔒 Tenant resolution is explicit if ($resolveTenant && ($this->config['tenancy']['enabled'] ?? false)) { $this->resolveTenant($context); + + // For database strategy, verify tenant exists and is not suspended + if ($strategy === 'database') { + $this->assertTenantActive(); + } + } + } + + /** + * @throws Exception + */ + private function assertTenantActive(): void + { + $tenantId = TenantContext::getTenantId(); + $repo = new TenantRepository($this->config); + $tenant = $repo->findByTenantId($tenantId); + + if (!$tenant) { + throw new Exception("Tenant not found: {$tenantId}"); + } + + if (($tenant['status'] ?? 'active') !== 'active') { + throw new Exception("Tenant is suspended: {$tenantId}"); } } diff --git a/src/Http/Middleware/MiddlewareInterface.php b/src/Http/Middleware/MiddlewareInterface.php index 940514d..118a3e9 100644 --- a/src/Http/Middleware/MiddlewareInterface.php +++ b/src/Http/Middleware/MiddlewareInterface.php @@ -3,8 +3,9 @@ namespace EntityForge\Http\Middleware; use EntityForge\Http\Request; +use EntityForge\Http\Response; interface MiddlewareInterface { - public function handle(Request $request, callable $next): void; + public function handle(Request $request, callable $next): Response; } \ No newline at end of file diff --git a/src/Http/Pipeline.php b/src/Http/Pipeline.php new file mode 100644 index 0000000..0086b5d --- /dev/null +++ b/src/Http/Pipeline.php @@ -0,0 +1,29 @@ +middleware[] = $middleware; + return $clone; + } + + public function run(Request $request, callable $destination): Response + { + $pipeline = array_reduce( + array_reverse($this->middleware), + fn(callable $carry, MiddlewareInterface $mw): callable => fn(Request $req): Response => $mw->handle($req, $carry), + $destination + ); + + return $pipeline($request); + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php index 3ea9828..97527f9 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -9,16 +9,21 @@ public function __construct( private array $headers = [], private array $query = [], private array $body = [], - private array|string $method = 'GET' + private array|string $method = 'GET', + private string $path = '/' ) {} public static function capture(): self { + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $path = parse_url($uri, PHP_URL_PATH) ?? '/'; + return new self( headers: getallheaders(), query: $_GET, body: $_POST, - method: $_SERVER['REQUEST_METHOD'] ?? 'GET' + method: $_SERVER['REQUEST_METHOD'] ?? 'GET', + path: $path ); } @@ -41,4 +46,9 @@ public function method(): string { return $this->method; } + + public function path(): string + { + return $this->path; + } } \ No newline at end of file diff --git a/src/Http/Response.php b/src/Http/Response.php index c46cd90..e5bbaa4 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -4,6 +4,57 @@ class Response { + private int $status = 200; + private string $body = ''; + private array $headers = []; + + public function withJson(array $data, int $status = 200): self + { + $clone = clone $this; + $clone->status = $status; + $clone->body = (string) json_encode($data); + $clone->headers['Content-Type'] = 'application/json'; + return $clone; + } + + public function withStatus(int $status): self + { + $clone = clone $this; + $clone->status = $status; + return $clone; + } + + public function withHeader(string $name, string $value): self + { + $clone = clone $this; + $clone->headers[$name] = $value; + return $clone; + } + + public function getStatus(): int + { + return $this->status; + } + + public function getBody(): string + { + return $this->body; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function send(): void + { + http_response_code($this->status); + foreach ($this->headers as $name => $value) { + header("{$name}: {$value}"); + } + echo $this->body; + } + public function json(array $data, int $status = 200): void { http_response_code($status); diff --git a/src/Http/Router.php b/src/Http/Router.php new file mode 100644 index 0000000..fb10f42 --- /dev/null +++ b/src/Http/Router.php @@ -0,0 +1,46 @@ +routes['GET'][$path] = $handler; + return $this; + } + + public function post(string $path, callable $handler): self + { + $this->routes['POST'][$path] = $handler; + return $this; + } + + public function put(string $path, callable $handler): self + { + $this->routes['PUT'][$path] = $handler; + return $this; + } + + public function delete(string $path, callable $handler): self + { + $this->routes['DELETE'][$path] = $handler; + return $this; + } + + public function dispatch(Request $request): Response + { + $method = strtoupper($request->method()); + $path = $request->path(); + + $handler = $this->routes[$method][$path] ?? null; + + if ($handler === null) { + return (new Response())->withJson(['error' => 'Not Found'], 404); + } + + return $handler($request); + } +} diff --git a/src/Tenant/RequestLifecycle.php b/src/Tenant/RequestLifecycle.php new file mode 100644 index 0000000..a220323 --- /dev/null +++ b/src/Tenant/RequestLifecycle.php @@ -0,0 +1,26 @@ +subdomainDepth + 3) { + throw new Exception("Cannot extract subdomain from host: {$host}"); + } + + $subdomain = $parts[$this->subdomainDepth]; + + if ($subdomain === '') { + throw new Exception("Empty subdomain in host: {$host}"); + } + + return $subdomain; + } +} diff --git a/src/Tenant/TenantConnectionResolver.php b/src/Tenant/TenantConnectionResolver.php index 732368d..ece6cf5 100644 --- a/src/Tenant/TenantConnectionResolver.php +++ b/src/Tenant/TenantConnectionResolver.php @@ -7,6 +7,8 @@ class TenantConnectionResolver { + private static array $connections = []; + /** * @throws Exception */ @@ -15,11 +17,12 @@ public static function resolve(array $config): Connection $strategy = $config['tenancy']['strategy'] ?? 'shared'; if ($strategy === 'shared') { - return new Connection($config['database']); + $key = 'shared:' . $config['database']['database']; + return self::$connections[$key] ??= new Connection($config['database']); } if ($strategy === 'database') { - $tenantId = \EntityForge\Tenant\TenantContext::getTenantId(); + $tenantId = TenantContext::getTenantId(); if (!$tenantId) { throw new Exception("Tenant ID not set"); @@ -28,9 +31,15 @@ public static function resolve(array $config): Connection $dbConfig = $config['database']; $dbConfig['database'] = $dbConfig['database'] . '_' . $tenantId; - return new Connection($dbConfig); + $key = 'database:' . $dbConfig['database']; + return self::$connections[$key] ??= new Connection($dbConfig); } throw new Exception("Unsupported tenancy strategy"); } + + public static function flush(): void + { + self::$connections = []; + } } \ No newline at end of file diff --git a/src/Tenant/TenantProvisioner.php b/src/Tenant/TenantProvisioner.php index 5e7ff94..0d2fc70 100644 --- a/src/Tenant/TenantProvisioner.php +++ b/src/Tenant/TenantProvisioner.php @@ -14,35 +14,46 @@ public function create(string $tenantId): void $dbConfig = $this->config['database']; $dbName = $dbConfig['database'] . '_' . $tenantId; - // 1. Create DB $this->createDatabase($dbConfig, $dbName); - // 2. Run migrations on tenant DB $tenantConfig = $dbConfig; $tenantConfig['database'] = $dbName; - $connection = new Connection($tenantConfig); - $runner = new MigrationRunner($connection); - - $runner->run('database/migrations'); + try { + $connection = new Connection($tenantConfig); + $runner = new MigrationRunner($connection); + $runner->run('database/migrations'); + } catch (\Throwable $e) { + $this->dropDatabase($dbConfig, $dbName); + throw $e; + } } - private function createDatabase(array $config, string $dbName): void + public function drop(string $tenantId): void { - $dsn = sprintf( - '%s:host=%s;port=%s', - $config['driver'], - $config['host'], - $config['port'] - ); + $dbConfig = $this->config['database']; + $dbName = $dbConfig['database'] . '_' . $tenantId; + $this->dropDatabase($dbConfig, $dbName); + } - $pdo = new \PDO( + private function getRootPdo(array $config): \PDO + { + $dsn = sprintf('%s:host=%s;port=%s', $config['driver'], $config['host'], $config['port']); + return new \PDO( $dsn, $config['username'], $config['password'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION] ); + } + + private function createDatabase(array $config, string $dbName): void + { + $this->getRootPdo($config)->exec("CREATE DATABASE IF NOT EXISTS {$dbName}"); + } - $pdo->exec("CREATE DATABASE IF NOT EXISTS {$dbName}"); + private function dropDatabase(array $config, string $dbName): void + { + $this->getRootPdo($config)->exec("DROP DATABASE IF EXISTS {$dbName}"); } } \ No newline at end of file diff --git a/src/Tenant/TenantRepository.php b/src/Tenant/TenantRepository.php index db94093..db78f9f 100644 --- a/src/Tenant/TenantRepository.php +++ b/src/Tenant/TenantRepository.php @@ -45,4 +45,35 @@ public function exists(string $tenantId): bool return (int) $stmt->fetchColumn() > 0; } + + public function findByTenantId(string $tenantId): ?array + { + $stmt = $this->connection->getPdo()->prepare( + "SELECT * FROM tenants WHERE tenant_id = :id LIMIT 1" + ); + + $stmt->execute(['id' => $tenantId]); + + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $row ?: null; + } + + public function updateStatus(string $tenantId, string $status): void + { + $stmt = $this->connection->getPdo()->prepare( + "UPDATE tenants SET status = :status WHERE tenant_id = :id" + ); + + $stmt->execute(['status' => $status, 'id' => $tenantId]); + } + + public function deleteByTenantId(string $tenantId): void + { + $stmt = $this->connection->getPdo()->prepare( + "DELETE FROM tenants WHERE tenant_id = :id" + ); + + $stmt->execute(['id' => $tenantId]); + } } \ No newline at end of file diff --git a/src/Tenant/TenantResolverFactory.php b/src/Tenant/TenantResolverFactory.php index 95a93c9..a2546f4 100644 --- a/src/Tenant/TenantResolverFactory.php +++ b/src/Tenant/TenantResolverFactory.php @@ -3,6 +3,7 @@ namespace EntityForge\Tenant; use EntityForge\Tenant\Resolver\HeaderTenantResolver; +use EntityForge\Tenant\Resolver\SubdomainTenantResolver; use Exception; class TenantResolverFactory @@ -14,10 +15,13 @@ public static function create(array $config): TenantResolverInterface { $resolverType = $config['tenancy']['resolver'] ?? 'header'; return match ($resolverType) { - 'header' => new HeaderTenantResolver( - $config['tenancy']['header_key'] ?? 'X-Tenant-ID' - ), - default => throw new Exception("Unsupported tenant resolver type: {$resolverType}") + 'header' => new HeaderTenantResolver( + $config['tenancy']['header_key'] ?? 'X-Tenant-ID' + ), + 'subdomain' => new SubdomainTenantResolver( + (int) ($config['tenancy']['subdomain_depth'] ?? 0) + ), + default => throw new Exception("Unsupported tenant resolver type: {$resolverType}"), }; } } \ No newline at end of file diff --git a/src/Tenant/TenantService.php b/src/Tenant/TenantService.php index 93e129f..dce8e1a 100644 --- a/src/Tenant/TenantService.php +++ b/src/Tenant/TenantService.php @@ -6,9 +6,11 @@ class TenantService { private TenantRepository $repo; private TenantProvisioner $provisioner; + private array $config; public function __construct(array $config) { + $this->config = $config; $this->repo = new TenantRepository($config); $this->provisioner = new TenantProvisioner($config); } @@ -19,10 +21,39 @@ public function onboard(string $tenantId, string $name): void throw new \Exception("Tenant already exists: {$tenantId}"); } - // 1. Create DB + run migrations $this->provisioner->create($tenantId); - - // 2. Register tenant $this->repo->create($tenantId, $name); } + + public function suspend(string $tenantId): void + { + $this->assertExists($tenantId); + $this->repo->updateStatus($tenantId, 'suspended'); + } + + public function resume(string $tenantId): void + { + $this->assertExists($tenantId); + $this->repo->updateStatus($tenantId, 'active'); + } + + public function offboard(string $tenantId): void + { + $this->assertExists($tenantId); + + $strategy = $this->config['tenancy']['strategy'] ?? 'shared'; + + if ($strategy === 'database') { + $this->provisioner->drop($tenantId); + } + + $this->repo->deleteByTenantId($tenantId); + } + + private function assertExists(string $tenantId): void + { + if (!$this->repo->exists($tenantId)) { + throw new \Exception("Tenant not found: {$tenantId}"); + } + } } \ No newline at end of file diff --git a/tests/Config/ConfigValidatorTest.php b/tests/Config/ConfigValidatorTest.php index 626aa6d..9f110cc 100644 --- a/tests/Config/ConfigValidatorTest.php +++ b/tests/Config/ConfigValidatorTest.php @@ -15,9 +15,20 @@ protected function setUp(): void $this->validator = new ConfigValidator(); } + private function fullConfig(): array + { + return [ + 'tenancy' => ['enabled' => true], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + } + public function test_valid_config_passes(): void { - $this->validator->validate(['tenancy' => ['enabled' => true]]); + $this->validator->validate($this->fullConfig()); $this->assertTrue(true); } @@ -35,4 +46,37 @@ public function test_missing_tenancy_key_throws(): void $this->validator->validate(['database' => ['host' => 'localhost']]); } + + public function test_missing_database_driver_throws(): void + { + $config = $this->fullConfig(); + unset($config['database']['driver']); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches("/Missing 'database\.driver'/"); + + $this->validator->validate($config); + } + + public function test_missing_database_host_throws(): void + { + $config = $this->fullConfig(); + unset($config['database']['host']); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches("/Missing 'database\.host'/"); + + $this->validator->validate($config); + } + + public function test_missing_database_password_throws(): void + { + $config = $this->fullConfig(); + unset($config['database']['password']); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches("/Missing 'database\.password'/"); + + $this->validator->validate($config); + } } diff --git a/tests/Console/MigrateAllTenantsCommandTest.php b/tests/Console/MigrateAllTenantsCommandTest.php new file mode 100644 index 0000000..d162524 --- /dev/null +++ b/tests/Console/MigrateAllTenantsCommandTest.php @@ -0,0 +1,20 @@ +assertSame('migrate:all-tenants', (new MigrateAllTenantsCommand())->getName()); + } + + public function test_command_description(): void + { + $this->assertStringContainsString('tenant', (new MigrateAllTenantsCommand())->getDescription()); + } +} diff --git a/tests/Core/ApplicationTest.php b/tests/Core/ApplicationTest.php index 4f4df2d..86418df 100644 --- a/tests/Core/ApplicationTest.php +++ b/tests/Core/ApplicationTest.php @@ -3,8 +3,12 @@ namespace Tests\Core; use EntityForge\Core\Application; +use EntityForge\Core\CoreSchemaManager; use EntityForge\Tenant\TenantContext; use Exception; +use Mockery; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; class ApplicationTest extends TestCase @@ -23,6 +27,7 @@ protected function tearDown(): void TenantContext::clear(); array_map('unlink', glob($this->tmpDir . '/*.yaml')); rmdir($this->tmpDir); + Mockery::close(); } private function writeConfig(array $tenancy = [], array $db = []): void @@ -42,10 +47,14 @@ private function writeConfig(array $tenancy = [], array $db = []): void file_put_contents($this->tmpDir . '/application.yaml', $app); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function test_boot_loads_config(): void { $this->writeConfig(); + Mockery::mock('overload:' . CoreSchemaManager::class)->allows('ensure'); + $app = new Application($this->tmpDir); $app->boot([], false); @@ -54,30 +63,42 @@ public function test_boot_loads_config(): void $this->assertArrayHasKey('database', $config); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function test_boot_resolves_tenant_from_header(): void { $this->writeConfig(); + Mockery::mock('overload:' . CoreSchemaManager::class)->allows('ensure'); + $app = new Application($this->tmpDir); $app->boot(['headers' => ['X-Tenant-ID' => 'acme']], true); $this->assertSame('acme', TenantContext::getTenantId()); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function test_boot_skips_tenant_resolution_when_disabled(): void { $this->writeConfig(); + Mockery::mock('overload:' . CoreSchemaManager::class)->allows('ensure'); + $app = new Application($this->tmpDir); $app->boot([], false); $this->assertFalse(TenantContext::hasTenantId()); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function test_boot_throws_when_context_empty_and_tenant_enabled(): void { $this->writeConfig(); + Mockery::mock('overload:' . CoreSchemaManager::class)->allows('ensure'); + $app = new Application($this->tmpDir); $this->expectException(Exception::class); @@ -96,10 +117,14 @@ public function test_get_config_throws_before_boot(): void $app->getConfig(); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function test_get_config_returns_merged_config_after_boot(): void { $this->writeConfig(); + Mockery::mock('overload:' . CoreSchemaManager::class)->allows('ensure'); + $app = new Application($this->tmpDir); $app->boot([], false); diff --git a/tests/Http/PipelineTest.php b/tests/Http/PipelineTest.php new file mode 100644 index 0000000..a7462cf --- /dev/null +++ b/tests/Http/PipelineTest.php @@ -0,0 +1,106 @@ +run($request, fn(Request $req): Response => (new Response())->withJson(['ok' => true])); + + $this->assertSame('{"ok":true}', $response->getBody()); + $this->assertSame(200, $response->getStatus()); + } + + public function test_middleware_can_short_circuit(): void + { + $blocker = new class implements MiddlewareInterface { + public function handle(Request $request, callable $next): Response + { + return (new Response())->withJson(['error' => 'blocked'], 403); + } + }; + + $pipeline = (new Pipeline())->pipe($blocker); + $request = new Request(); + + $response = $pipeline->run($request, fn(Request $req): Response => (new Response())->withJson(['ok' => true])); + + $this->assertSame(403, $response->getStatus()); + $this->assertStringContainsString('blocked', $response->getBody()); + } + + public function test_middleware_can_pass_through(): void + { + $passthrough = new class implements MiddlewareInterface { + public function handle(Request $request, callable $next): Response + { + return $next($request); + } + }; + + $pipeline = (new Pipeline())->pipe($passthrough); + $request = new Request(); + + $response = $pipeline->run($request, fn(Request $req): Response => (new Response())->withJson(['reached' => true])); + + $this->assertSame(200, $response->getStatus()); + $this->assertStringContainsString('reached', $response->getBody()); + } + + public function test_middleware_executes_in_order(): void + { + $log = []; + + $first = new class ($log) implements MiddlewareInterface { + public function __construct(private array &$log) {} + public function handle(Request $request, callable $next): Response + { + $this->log[] = 'first-before'; + $response = $next($request); + $this->log[] = 'first-after'; + return $response; + } + }; + + $second = new class ($log) implements MiddlewareInterface { + public function __construct(private array &$log) {} + public function handle(Request $request, callable $next): Response + { + $this->log[] = 'second-before'; + $response = $next($request); + $this->log[] = 'second-after'; + return $response; + } + }; + + $pipeline = (new Pipeline())->pipe($first)->pipe($second); + $pipeline->run(new Request(), fn(Request $req): Response => (new Response())->withJson([])); + + $this->assertSame(['first-before', 'second-before', 'second-after', 'first-after'], $log); + } + + public function test_pipe_returns_new_instance(): void + { + $pipeline = new Pipeline(); + $mw = new class implements MiddlewareInterface { + public function handle(Request $request, callable $next): Response + { + return $next($request); + } + }; + + $piped = $pipeline->pipe($mw); + + $this->assertNotSame($pipeline, $piped); + } +} diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 901075a..b772d60 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -63,6 +63,20 @@ public function test_method_returns_provided_method(): void $this->assertSame('POST', $request->method()); } + public function test_path_defaults_to_slash(): void + { + $request = new Request(); + + $this->assertSame('/', $request->path()); + } + + public function test_path_returns_provided_path(): void + { + $request = new Request(path: '/api/users'); + + $this->assertSame('/api/users', $request->path()); + } + public function test_capture_skipped_outside_web_sapi(): void { if (!function_exists('getallheaders')) { diff --git a/tests/Http/ResponseValueObjectTest.php b/tests/Http/ResponseValueObjectTest.php new file mode 100644 index 0000000..d43f0ae --- /dev/null +++ b/tests/Http/ResponseValueObjectTest.php @@ -0,0 +1,53 @@ +assertSame(200, $response->getStatus()); + $this->assertSame('', $response->getBody()); + $this->assertSame([], $response->getHeaders()); + } + + public function test_with_json_sets_body_and_status(): void + { + $response = (new Response())->withJson(['key' => 'value'], 201); + $this->assertSame(201, $response->getStatus()); + $this->assertSame('{"key":"value"}', $response->getBody()); + $this->assertSame('application/json', $response->getHeaders()['Content-Type']); + } + + public function test_with_json_is_immutable(): void + { + $original = new Response(); + $modified = $original->withJson(['x' => 1]); + $this->assertNotSame($original, $modified); + $this->assertSame('', $original->getBody()); + } + + public function test_with_status_sets_status(): void + { + $response = (new Response())->withStatus(422); + $this->assertSame(422, $response->getStatus()); + } + + public function test_with_header_adds_header(): void + { + $response = (new Response())->withHeader('X-Custom', 'abc'); + $this->assertSame('abc', $response->getHeaders()['X-Custom']); + } + + public function test_with_header_is_immutable(): void + { + $original = new Response(); + $modified = $original->withHeader('X-Foo', 'bar'); + $this->assertNotSame($original, $modified); + $this->assertArrayNotHasKey('X-Foo', $original->getHeaders()); + } +} diff --git a/tests/Http/RouterTest.php b/tests/Http/RouterTest.php new file mode 100644 index 0000000..66c2e8f --- /dev/null +++ b/tests/Http/RouterTest.php @@ -0,0 +1,93 @@ +dispatch($request); + + $this->assertSame(404, $response->getStatus()); + $this->assertStringContainsString('Not Found', $response->getBody()); + } + + public function test_dispatches_get_route(): void + { + $router = new Router(); + $router->get('/users', fn(Request $req): Response => (new Response())->withJson(['users' => []])); + + $request = new Request(method: 'GET', path: '/users'); + $response = $router->dispatch($request); + + $this->assertSame(200, $response->getStatus()); + $this->assertStringContainsString('users', $response->getBody()); + } + + public function test_dispatches_post_route(): void + { + $router = new Router(); + $router->post('/users', fn(Request $req): Response => (new Response())->withJson(['created' => true], 201)); + + $request = new Request(method: 'POST', path: '/users'); + $response = $router->dispatch($request); + + $this->assertSame(201, $response->getStatus()); + } + + public function test_dispatches_put_route(): void + { + $router = new Router(); + $router->put('/users/1', fn(Request $req): Response => (new Response())->withJson(['updated' => true])); + + $request = new Request(method: 'PUT', path: '/users/1'); + $response = $router->dispatch($request); + + $this->assertSame(200, $response->getStatus()); + } + + public function test_dispatches_delete_route(): void + { + $router = new Router(); + $router->delete('/users/1', fn(Request $req): Response => (new Response())->withJson(['deleted' => true])); + + $request = new Request(method: 'DELETE', path: '/users/1'); + $response = $router->dispatch($request); + + $this->assertSame(200, $response->getStatus()); + } + + public function test_method_mismatch_returns_404(): void + { + $router = new Router(); + $router->get('/ping', fn(Request $req): Response => (new Response())->withJson(['pong' => true])); + + $request = new Request(method: 'POST', path: '/ping'); + $response = $router->dispatch($request); + + $this->assertSame(404, $response->getStatus()); + } + + public function test_router_passes_request_to_handler(): void + { + $router = new Router(); + $captured = null; + $router->get('/echo', function (Request $req) use (&$captured): Response { + $captured = $req; + return (new Response())->withJson([]); + }); + + $request = new Request(method: 'GET', path: '/echo', headers: ['X-Foo' => 'bar']); + $router->dispatch($request); + + $this->assertSame('bar', $captured->header('X-Foo')); + } +} diff --git a/tests/Tenant/Resolver/SubdomainTenantResolverTest.php b/tests/Tenant/Resolver/SubdomainTenantResolverTest.php new file mode 100644 index 0000000..985a6a8 --- /dev/null +++ b/tests/Tenant/Resolver/SubdomainTenantResolverTest.php @@ -0,0 +1,57 @@ +resolve(['host' => 'acme.example.com']); + $this->assertSame('acme', $tenantId); + } + + public function test_resolves_subdomain_from_server_http_host(): void + { + $resolver = new SubdomainTenantResolver(); + $tenantId = $resolver->resolve(['server' => ['HTTP_HOST' => 'tenant1.myapp.io']]); + $this->assertSame('tenant1', $tenantId); + } + + public function test_strips_port_from_host(): void + { + $resolver = new SubdomainTenantResolver(); + $tenantId = $resolver->resolve(['host' => 'acme.example.com:8080']); + $this->assertSame('acme', $tenantId); + } + + public function test_throws_when_host_missing(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/HTTP_HOST not found/'); + + (new SubdomainTenantResolver())->resolve([]); + } + + public function test_throws_when_no_subdomain_present(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Cannot extract subdomain/'); + + (new SubdomainTenantResolver())->resolve(['host' => 'example.com']); + } + + public function test_host_key_takes_precedence_over_server(): void + { + $resolver = new SubdomainTenantResolver(); + $tenantId = $resolver->resolve([ + 'host' => 'primary.example.com', + 'server' => ['HTTP_HOST' => 'other.example.com'], + ]); + $this->assertSame('primary', $tenantId); + } +} diff --git a/tests/Tenant/TenantRepositoryTest.php b/tests/Tenant/TenantRepositoryTest.php index 12e3755..bb0c71c 100644 --- a/tests/Tenant/TenantRepositoryTest.php +++ b/tests/Tenant/TenantRepositoryTest.php @@ -117,4 +117,98 @@ public function test_exists_returns_false_when_not_found(): void $this->assertFalse($repo->exists('unknown')); } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_find_by_tenant_id_returns_row_when_found(): void + { + $row = ['id' => 1, 'tenant_id' => 'acme', 'name' => 'Acme', 'status' => 'active']; + + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->with(['id' => 'acme'])->andReturn(true); + $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn($row); + + $pdo = Mockery::mock(PDO::class); + $pdo->allows('prepare') + ->with('SELECT * FROM tenants WHERE tenant_id = :id LIMIT 1') + ->andReturn($stmt); + + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($pdo); + + $repo = new TenantRepository($this->dbConfig()); + + $this->assertSame($row, $repo->findByTenantId('acme')); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_find_by_tenant_id_returns_null_when_not_found(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->andReturn(true); + $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn(false); + + $pdo = Mockery::mock(PDO::class); + $pdo->allows('prepare')->andReturn($stmt); + + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($pdo); + + $repo = new TenantRepository($this->dbConfig()); + + $this->assertNull($repo->findByTenantId('ghost')); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_update_status_executes_update(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute') + ->with(['status' => 'suspended', 'id' => 'acme']) + ->once() + ->andReturn(true); + + $pdo = Mockery::mock(PDO::class); + $pdo->allows('prepare') + ->with('UPDATE tenants SET status = :status WHERE tenant_id = :id') + ->andReturn($stmt); + + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($pdo); + + $repo = new TenantRepository($this->dbConfig()); + $repo->updateStatus('acme', 'suspended'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_delete_by_tenant_id_executes_delete(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute') + ->with(['id' => 'acme']) + ->once() + ->andReturn(true); + + $pdo = Mockery::mock(PDO::class); + $pdo->allows('prepare') + ->with('DELETE FROM tenants WHERE tenant_id = :id') + ->andReturn($stmt); + + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($pdo); + + $repo = new TenantRepository($this->dbConfig()); + $repo->deleteByTenantId('acme'); + + $this->assertTrue(true); + } } diff --git a/tests/Tenant/TenantResolverFactoryTest.php b/tests/Tenant/TenantResolverFactoryTest.php index dbeff37..06cb68e 100644 --- a/tests/Tenant/TenantResolverFactoryTest.php +++ b/tests/Tenant/TenantResolverFactoryTest.php @@ -3,6 +3,7 @@ namespace Tests\Tenant; use EntityForge\Tenant\Resolver\HeaderTenantResolver; +use EntityForge\Tenant\Resolver\SubdomainTenantResolver; use EntityForge\Tenant\TenantResolverFactory; use Exception; use PHPUnit\Framework\TestCase; @@ -41,11 +42,26 @@ public function test_header_resolver_uses_default_header_when_key_absent(): void $this->assertSame('org1', $tenantId); } + public function test_creates_subdomain_resolver(): void + { + $resolver = TenantResolverFactory::create(['tenancy' => ['resolver' => 'subdomain']]); + + $this->assertInstanceOf(SubdomainTenantResolver::class, $resolver); + } + + public function test_subdomain_resolver_resolves_host(): void + { + $resolver = TenantResolverFactory::create(['tenancy' => ['resolver' => 'subdomain']]); + + $tenantId = $resolver->resolve(['host' => 'acme.example.com']); + $this->assertSame('acme', $tenantId); + } + public function test_throws_for_unsupported_resolver_type(): void { $this->expectException(Exception::class); $this->expectExceptionMessageMatches('/Unsupported tenant resolver type/'); - TenantResolverFactory::create(['tenancy' => ['resolver' => 'subdomain']]); + TenantResolverFactory::create(['tenancy' => ['resolver' => 'jwt']]); } } diff --git a/tests/Tenant/TenantServiceTest.php b/tests/Tenant/TenantServiceTest.php index 295ed3e..34e4dc2 100644 --- a/tests/Tenant/TenantServiceTest.php +++ b/tests/Tenant/TenantServiceTest.php @@ -60,4 +60,105 @@ public function test_onboard_throws_when_tenant_already_exists(): void $service->onboard('acme', 'Acme Corp'); } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_suspend_sets_status_to_suspended(): void + { + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('acme')->andReturn(true); + $repo->allows('updateStatus')->with('acme', 'suspended')->once(); + + Mockery::mock('overload:' . TenantProvisioner::class); + + $service = new TenantService($this->config()); + $service->suspend('acme'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_suspend_throws_when_tenant_not_found(): void + { + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('ghost')->andReturn(false); + + Mockery::mock('overload:' . TenantProvisioner::class); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Tenant not found/'); + + $service = new TenantService($this->config()); + $service->suspend('ghost'); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_resume_sets_status_to_active(): void + { + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('acme')->andReturn(true); + $repo->allows('updateStatus')->with('acme', 'active')->once(); + + Mockery::mock('overload:' . TenantProvisioner::class); + + $service = new TenantService($this->config()); + $service->resume('acme'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_offboard_deletes_tenant_in_shared_strategy(): void + { + $config = array_merge($this->config(), ['tenancy' => ['strategy' => 'shared']]); + + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('acme')->andReturn(true); + $repo->allows('deleteByTenantId')->with('acme')->once(); + + Mockery::mock('overload:' . TenantProvisioner::class); + + $service = new TenantService($config); + $service->offboard('acme'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_offboard_drops_db_and_deletes_tenant_in_database_strategy(): void + { + $config = array_merge($this->config(), ['tenancy' => ['strategy' => 'database']]); + + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('acme')->andReturn(true); + $repo->allows('deleteByTenantId')->with('acme')->once(); + + $provisioner = Mockery::mock('overload:' . TenantProvisioner::class); + $provisioner->allows('drop')->with('acme')->once(); + + $service = new TenantService($config); + $service->offboard('acme'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_offboard_throws_when_tenant_not_found(): void + { + $repo = Mockery::mock('overload:' . TenantRepository::class); + $repo->allows('exists')->with('ghost')->andReturn(false); + + Mockery::mock('overload:' . TenantProvisioner::class); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Tenant not found/'); + + $service = new TenantService($this->config()); + $service->offboard('ghost'); + } } From 546165b48321788eedb6534dce0c3c593e32faa4 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 07:34:55 +0530 Subject: [PATCH 02/16] Update documentation to reflect current architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ARCHITECTURE.md: rewrite covering all current components — Pipeline, Router, SubdomainTenantResolver, RequestLifecycle, MigrateAllTenantsCommand, tenant lifecycle, connection pooling, HTTP layer, boot sequence, concurrency notes - CONCEPT.md: expand from stub to full glossary of every major class and concept - CLEANUP.md: document known limitations and deferred work — parameterised routes, DI container, migration dry-run, tenant ID validation, SQL injection notes --- docs/ARCHITECTURE.md | 495 ++++++++++++++++++------------------------- docs/CLEANUP.md | 63 ++++++ docs/CONCEPT.md | 81 ++++++- 3 files changed, 339 insertions(+), 300 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b92d22..2266312 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,399 +1,316 @@ -# 🏗️ EntityForge Architecture +# EntityForge Architecture -## 🎯 Overview +## Overview -EntityForge is a **configuration-driven, multi-tenant SaaS framework** built in PHP. +EntityForge is a configuration-driven, multi-tenant SaaS framework built in PHP 8.4. It provides: -It supports: - -* Code generation via JSON configs -* Multi-tenant strategies (shared & database) -* Automated migrations and rollback -* Tenant provisioning and lifecycle management +- Multi-tenant data isolation (shared-table and database-per-tenant strategies) +- Configuration-driven entity, repository, and migration generation +- Batch-tracked migration system with rollback +- HTTP request/response pipeline with middleware support +- Tenant lifecycle management (onboard, suspend, resume, offboard) --- -# 🧠 Core Principles - -* **Separation of concerns** -* **Explicit over implicit** -* **Configuration-driven architecture** -* **Tenant isolation by design** -* **Idempotent infrastructure** - ---- - -# 📦 System Architecture +## System Map ``` -Application +src/ +├── Config/ +│ ├── ConfigLoader.php — merges YAML config files +│ └── ConfigValidator.php — validates required keys before boot +│ +├── Console/ +│ ├── GenerateCommand.php — generate single entity from JSON schema +│ ├── GenerateAllCommand.php — generate all entities in config/entities/ +│ ├── MigrateCommand.php — run pending migrations (main DB) +│ ├── MigrateAllTenantsCommand.php — run migrations on every tenant DB +│ ├── RollbackCommand.php — rollback last migration batch +│ └── TenantCreateCommand.php — onboard a new tenant via TenantService │ -├── Core Layer -│ ├── Application -│ ├── ConfigLoader -│ └── CoreSchemaManager +├── Core/ +│ ├── Application.php — boot entry point +│ └── CoreSchemaManager.php — creates tenants table on every boot │ -├── Tenant Layer -│ ├── TenantContext -│ ├── TenantResolver -│ ├── TenantConnectionResolver -│ ├── TenantProvisioner -│ ├── TenantRepository -│ └── TenantService +├── Database/ +│ ├── Connection.php — PDO wrapper +│ └── MigrationRunner.php — runs/tracks/rolls back SQL migrations │ -├── Database Layer -│ ├── Connection -│ ├── MigrationRunner +├── Generator/ +│ ├── EntityGenerator.php +│ ├── Builder/ — EntityBuilder, RepositoryBuilder, MigrationBuilder +│ ├── Schema/ — EntitySchema, SchemaValidator +│ └── Writer/FileWriter.php │ -├── Generator Layer -│ ├── EntityGenerator -│ ├── Builders (Entity, Repository, Migration) -│ └── FileWriter +├── Http/ +│ ├── Request.php — value object: headers, query, body, method, path +│ ├── Response.php — value object + legacy json() echo method +│ ├── Router.php — GET/POST/PUT/DELETE route dispatch +│ ├── Pipeline.php — immutable middleware chain runner +│ └── Middleware/ +│ └── MiddlewareInterface.php — handle(Request, callable): Response │ -├── Repository Layer -│ ├── BaseRepository -│ └── Generated Repositories +├── Repository/ +│ └── BaseRepository.php — auto-scoped CRUD + transactions │ -└── Console Layer - ├── GenerateCommand - ├── GenerateAllCommand - ├── MigrateCommand - ├── RollbackCommand - └── TenantCreateCommand +└── Tenant/ + ├── TenantContext.php — static singleton holding current tenant ID + ├── TenantGuard.php — throws if TenantContext is empty + ├── TenantConnectionResolver.php — resolves + caches PDO connection per tenant + ├── TenantResolverFactory.php — creates header or subdomain resolver + ├── TenantResolverInterface.php + ├── TenantProvisioner.php — creates/drops tenant DB, runs migrations + ├── TenantRepository.php — CRUD on the main DB tenants table + ├── TenantService.php — onboard/suspend/resume/offboard + ├── RequestLifecycle.php — clears context + connection cache per request + └── Resolver/ + ├── HeaderTenantResolver.php — reads tenant from a request header + └── SubdomainTenantResolver.php — extracts tenant from subdomain ``` --- -# 🧩 Core Components - -## 1. Application +## Boot Sequence -Handles: - -* Bootstrapping config -* Tenant resolution (optional) -* Core schema initialization - -```php -$app->boot($context, $resolveTenant); +``` +Application::boot($context, $resolveTenant) + │ + ├── ConfigLoader::loadMultiple([saas.yaml, application.yaml]) + │ array_replace_recursive — application.yaml wins on conflicts + │ + ├── ConfigValidator::validate() + │ requires: tenancy.enabled, database.{driver,host,port,database,username,password} + │ + ├── CoreSchemaManager::ensure() + │ CREATE TABLE IF NOT EXISTS tenants (always, both strategies) + │ + └── if $resolveTenant && tenancy.enabled: + TenantResolverFactory::create() → resolver.resolve($context) + TenantContext::setTenantId() + if strategy === database: + TenantRepository::findByTenantId() — throws if not found or suspended ``` ---- - -## 2. CoreSchemaManager - -Responsible for: - -* Ensuring framework-level tables exist - -### Currently manages: - -* `tenants` table - -✔ Runs automatically during application boot -✔ Idempotent (`CREATE TABLE IF NOT EXISTS`) - ---- - -## 3. Tenant System - -### TenantContext - -* Stores current tenant ID (runtime state) - ---- - -### TenantResolver - -* Extracts tenant from request context +Pass `false` as the second argument to skip tenant resolution (CLI commands, provisioning). --- -### TenantConnectionResolver - -* Resolves DB connection based on strategy: +## Multi-Tenancy Strategies -| Strategy | Behavior | -| -------- | ------------- | -| shared | single DB | -| database | DB per tenant | +The pivot is `tenancy.strategy` in `config/application.yaml`. ---- +### `shared` — single database, tenant_id column -### TenantProvisioner +Every table has a `tenant_id` column. `BaseRepository` automatically appends `WHERE tenant_id = :tenant_id` (and `AND tenant_id = :tenant_id` on writes) when `shouldApplyTenantScope()` returns true. -Handles: +``` +entity_forge + ├── tenants ← registry + └── users ← tenant_id = 'acme' | 'corp' | ... +``` -* Creating tenant database -* Running migrations +### `database` — one database per tenant ---- +Each tenant gets its own database named `{base_db}_{tenantId}`. Connection resolution selects the correct database. No `tenant_id` column needed; isolation is at the connection level. -### TenantRepository - -Uses **main DB** to: +``` +entity_forge ← main DB: tenants registry only +entity_forge_acme ← tenant DB: all application data +entity_forge_corp ← tenant DB: all application data +``` -* Store tenant records -* Check existence -* List tenants +`TenantConnectionResolver` caches open connections in a static registry keyed by database name. Call `TenantConnectionResolver::flush()` (or `RequestLifecycle::begin()`) between requests in worker-mode PHP. --- -### TenantService +## Tenant Lifecycle -Entry point for onboarding: +### Onboarding -```php -$service->onboard($tenantId, $name); ``` - -Flow: - +TenantService::onboard($tenantId, $name) + ├── TenantRepository::exists() — throws if already registered + ├── TenantProvisioner::create() + │ ├── CREATE DATABASE IF NOT EXISTS {base}_{tenantId} + │ ├── MigrationRunner::run('database/migrations') + │ └── on failure: DROP DATABASE {base}_{tenantId}, re-throw + └── TenantRepository::create() — INSERT into tenants ``` -Check → Create DB → Run migrations → Register tenant -``` - ---- -# 🗄️ Database Architecture +`bin/ef tenant:create [--name=]` calls this flow. -## 🔹 Main Database +### Suspension / Resumption -``` -entity_forge - └── tenants +```php +$service->suspend($tenantId); // sets status = 'suspended' +$service->resume($tenantId); // sets status = 'active' ``` -Stores: +Suspended tenants are blocked at `Application::boot()` — the status check in `assertTenantActive()` throws before any repository is instantiated. -* tenant metadata -* lifecycle state +### Offboarding ---- - -## 🔹 Tenant Databases - -``` -entity_forge_tenant_1 -entity_forge_tenant_2 +```php +$service->offboard($tenantId); +// database strategy: TenantProvisioner::drop() → DROP DATABASE IF EXISTS +// both strategies: TenantRepository::deleteByTenantId() ``` -Stores: - -* application data (users, orders, etc.) - --- -# 🔄 Multi-Tenant Strategies +## HTTP Layer -## 1. Shared Database +### Request -* Single DB -* `tenant_id` column used for isolation +Immutable value object. Constructed directly (tests, workers) or captured from globals: -```text -users - id - name - tenant_id +```php +$request = new Request(headers: [...], query: [...], body: [...], method: 'POST', path: '/users'); +$request = Request::capture(); // reads $_SERVER, $_GET, $_POST, getallheaders() ``` ---- - -## 2. Database per Tenant +### Response -* One DB per tenant -* No `tenant_id` needed +Dual-mode: immutable builder for pipeline use, plus legacy `json()` for direct output. -```text -tenant_1_db → users -tenant_2_db → users +```php +// Pipeline / Router path — immutable, chainable +$response = (new Response()) + ->withJson(['id' => 1], 201) + ->withHeader('X-Request-Id', $id); +$response->send(); // http_response_code + headers + echo + +// Legacy direct-output (kept for backwards compatibility) +(new Response())->json(['ok' => true], 200); ``` ---- - -# 🧱 Repository Layer +### Pipeline -## BaseRepository +Immutable middleware chain. Each `pipe()` call returns a new instance. -Handles: - -* DB connection resolution -* Tenant scoping -* Insert/query abstraction +```php +$pipeline = (new Pipeline()) + ->pipe(new AuthMiddleware()) + ->pipe(new TenantMiddleware()); -### Features: +$response = $pipeline->run($request, fn(Request $req): Response => $router->dispatch($req)); +``` -* `create()` -* `findAll()` -* `findById()` -* `where()` +Middleware is executed outermost-first. `$next` is a `callable(Request): Response`. ---- +### Router -## Generated Repositories +```php +$router = new Router(); +$router->get('/users', fn(Request $req): Response => ...); +$router->post('/users', fn(Request $req): Response => ...); +$router->put('/users/1', fn(Request $req): Response => ...); +$router->delete('/users/1', fn(Request $req): Response => ...); -* Extend BaseRepository -* Contain no logic by default -* Used for customization when needed +$response = $router->dispatch($request); // 404 if no match +``` --- -# ⚙️ Migration System +## Repository Layer -## MigrationRunner +`BaseRepository` handles connection resolution, tenant scoping, and standard CRUD. Generated repositories extend it and contain no logic by default. -Supports: +```php +public function create(array $data): array +public function findAll(): array +public function findById(int $id): ?array +public function where(array $conditions): array +public function update(int $id, array $data): bool +public function delete(int $id): bool + +public function beginTransaction(): void +public function commit(): void +public function rollback(): void +``` -* Running migrations -* Tracking execution -* Rollback by batch +`resolveTableName()` derives the table name from the class name (`UserRepository` → `users`). Override `$this->table` in the subclass constructor to use a custom name. --- -## Migration Structure +## Migration System + +`MigrationRunner` tracks executed migrations in a `migrations` table (auto-created). Rollback undoes all migrations from the most recent batch. ``` database/migrations/ - 2026_..._create_users_table.up.sql - 2026_..._create_users_table.down.sql + 20260101_000001_create_users_table.up.sql + 20260101_000001_create_users_table.down.sql ``` ---- +Every `.up.sql` must have a paired `.down.sql`. A missing down file aborts rollback with an exception. -## Features +### Keeping Tenant Databases in Sync + +When a new migration is added, existing tenant databases do not automatically receive it. Run: + +```bash +php bin/ef migrate:all-tenants +``` -* Idempotent execution -* Batch tracking -* Rollback support +This iterates `TenantRepository::all()`, connects to each tenant DB, and runs `MigrationRunner::run()` against it. Failures per-tenant are reported but do not stop other tenants from being migrated. --- -# 🏗️ Generator System +## Code Generation -## Input +Entity JSON schemas live in `config/entities/*.json`. A schema drives three builders: ```json { - "entity": "User", - "multiTenant": true, - "timestamps": true, - "fields": { - "name": "string", - "email": "string" - } + "entity": "Order", + "fields": { "id": "int", "amount": "float", "status": "string" } } ``` ---- - -## Output +Output: +- `app/Entity/Order.php` +- `app/Repository/OrderRepository.php` +- `database/migrations/{timestamp}_create_orders_table.up.sql` + `.down.sql` -* Entity class -* Repository class -* Migration files +`generate:all` uses a single `EntityGenerator` instance to guarantee monotonically ordered migration timestamps within a session. --- -# 🧰 CLI Commands +## CLI Commands -| Command | Purpose | -| ---------------- | ---------------------- | -| generate | Generate single entity | -| generate:all | Generate all entities | -| migrate | Run migrations | -| migrate:rollback | Rollback last batch | -| tenant:create | Provision new tenant | +| Command | Description | +|--------------------------|------------------------------------------------------| +| `generate ` | Generate entity + repository from JSON schema | +| `generate:all` | Generate all schemas in `config/entities/` (or `--config-dir`) | +| `migrate` | Run pending migrations on the main database | +| `migrate:rollback` | Roll back the last migration batch | +| `migrate:all-tenants` | Run pending migrations on every registered tenant DB | +| `tenant:create ` | Onboard a new tenant (`--name` for display name) | --- -# 🔁 Tenant Lifecycle +## Concurrency (Worker-Mode PHP) -## Onboarding Flow +`TenantContext` is a static singleton. In PHP-FPM each process handles one request so static state is reset automatically. In long-lived workers (Swoole, RoadRunner, Laravel Octane), static state persists between requests. -``` -TenantService - ↓ -TenantProvisioner - ↓ -Create DB - ↓ -Run Migrations - ↓ -Register Tenant -``` +Wrap each request loop iteration: ---- +```php +RequestLifecycle::begin(); // clears TenantContext + connection cache -## Runtime Flow +// ... handle request ... -``` -Request - ↓ -Application boot - ↓ -Tenant resolved - ↓ -Connection resolved - ↓ -Repository used +RequestLifecycle::end(); // clears again on teardown ``` --- -# ⚠️ Key Rules - -## 🔴 Never mix: - -| Concern | Location | -| --------------- | ----------------- | -| tenant registry | main DB | -| user data | tenant DB | -| core schema | CoreSchemaManager | -| business schema | migrations | - ---- - -## 🔴 Never reuse: - -* Repository instances across tenants -* Connections across tenant switches - ---- - -## 🔴 Always: - -* Boot before using repositories -* Create new repository after tenant switch - ---- - -# 🚀 Current Status - -## Completed - -* ✅ Multi-tenant architecture -* ✅ DB-per-tenant strategy -* ✅ Migration system with rollback -* ✅ Code generator -* ✅ Tenant provisioning -* ✅ Tenant registry -* ✅ Query layer - ---- - -## Upcoming - -* 🔲 Middleware (auto tenant resolution) -* 🔲 Dependency injection container -* 🔲 API layer - ---- - -# 🧠 Final Thought - -This is no longer just a framework. - -It is: +## Key Invariants -> **A foundation for building real multi-tenant SaaS systems** +1. **Tenant isolation is never optional.** Every query decision must account for both strategies. +2. **Main DB ↔ tenant DB boundary is sacred.** The `tenants` registry lives only in the main DB. Application data lives only in tenant DBs. +3. **Repository instances are not reusable across tenant switches.** Instantiate fresh after `TenantContext::setTenantId()`. +4. **Idempotent infrastructure.** `CREATE TABLE IF NOT EXISTS`, batch-tracked migrations, `CoreSchemaManager` — follow the pattern. +5. **Explicit over implicit.** Tenant resolution, connection selection, scope injection are always conscious calls. +6. **Configuration drives generation.** New entity types go through the generator pipeline, not handwritten files. diff --git a/docs/CLEANUP.md b/docs/CLEANUP.md index e69de29..e9719b6 100644 --- a/docs/CLEANUP.md +++ b/docs/CLEANUP.md @@ -0,0 +1,63 @@ +# Known Limitations & Future Work + +This document tracks deliberate gaps and deferred work in the current codebase. + +--- + +## Tenant Resolver + +**SubdomainTenantResolver requires 3-part hosts.** `example.com` (2 parts) throws. Two-level domains (`acme.io`) are not supported as tenant hosts without subdomains. This is by design — the resolver always strips the leading segment. + +**No JWT or session resolver.** `TenantResolverInterface` is designed for extension. Wire a new implementation into `TenantResolverFactory` and configure `tenancy.resolver` accordingly. + +--- + +## HTTP Layer + +**No regex or parameterised routes.** `Router` does exact path matching. Dynamic segments like `/users/{id}` are not supported. This is intentional scope — add a pattern-matching router (e.g. FastRoute) when needed. + +**No response streaming.** `Response::send()` buffers the full body in memory before output. Acceptable for API responses; replace with a streaming approach for file downloads. + +--- + +## Migration System + +**`migrate:all-tenants` has no concurrency control.** Migrations run against tenant databases sequentially. On large deployments (hundreds of tenants), consider parallelising with a process pool or a queue. + +**No dry-run mode.** Neither `MigrationRunner` nor the CLI commands support `--dry-run`. Add it as a future option. + +**`migrate:all-tenants` only targets active tenants.** Suspended tenants are included in `TenantRepository::all()` and will be migrated regardless of status. Filter by `status = 'active'` if you want to exclude suspended tenants from bulk migrations. + +--- + +## Connection Pooling + +**`TenantConnectionResolver` uses a static in-memory pool.** This is fine for PHP-FPM (process-per-request) and for single-request workers. It does not persist connections across process restarts. In persistent worker scenarios (Swoole, RoadRunner), the pool is per-worker-process, which is the expected behaviour. + +--- + +## Concurrency + +**`TenantContext` is a static singleton.** Safe for PHP-FPM. For worker-mode PHP, `RequestLifecycle::begin()` / `end()` must be called around each request. There is no enforcement mechanism — the application will silently serve the wrong tenant if lifecycle hooks are omitted. + +--- + +## Code Generator + +**`generate:all` defaults to CWD-relative `config/entities`.** If invoked from outside the project root without `--config-dir`, it will fail silently (no files found). Always run from the project root or pass `--config-dir` explicitly. + +**No relation or index support in the generator.** `MigrationBuilder` only handles column definitions. Foreign keys, composite indexes, and unique constraints must be added to the generated SQL files manually. + +--- + +## Security + +**No input sanitisation in `BaseRepository`.** Table and column names in `where()`, `update()`, and `delete()` are interpolated directly into SQL strings. These values come from internal code (not user input), so injection is not a current risk — but never pass user-supplied strings as column names. + +**Tenant DB names are derived from tenant IDs.** `{base}_{tenantId}` is used directly in a `CREATE DATABASE` statement via `PDO::exec`. Tenant IDs should be validated to be alphanumeric before onboarding. `TenantService::onboard()` does not enforce this — add a validation step if tenant IDs are user-supplied. + +--- + +## Dependency Injection + +**No DI container.** Components instantiate their dependencies with `new` internally. This makes unit testing require `Mockery::mock('overload:...')` with `#[RunInSeparateProcess]`. A DI container would allow constructor injection and standard mocking patterns. diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index f56f1f3..c80e9db 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -1,16 +1,75 @@ -## Core Concepts +# Core Concepts -### Application -The entry point of Entity-Forge. Boots configuration and runtime. +## Application +The boot entry point. Loads and merges YAML config, validates required keys, runs `CoreSchemaManager`, and optionally resolves the current tenant. Pass `false` as the second argument to `boot()` to skip tenant resolution (used by CLI commands). -### Entity -A configuration-defined model representing a database table. +## Entity +A configuration-defined model described by a JSON schema in `config/entities/`. Drives generation of a PHP class, a repository, and SQL migration files. -### Repository -A data access layer automatically scoped to a tenant. +## Repository +A data-access layer that auto-scopes queries to the current tenant. Generated repositories extend `BaseRepository` and inherit CRUD methods, transaction support, and tenant scoping. Never reuse a repository instance across tenant switches. -### Tenant -A logical boundary that isolates data between users/customers. +## BaseRepository +The abstract parent for all repositories. Resolves its PDO connection via `TenantConnectionResolver`, injects `WHERE tenant_id = :tenant_id` for shared strategy, and exposes `create`, `findAll`, `findById`, `where`, `update`, `delete`, `beginTransaction`, `commit`, `rollback`. -### Tenant Context -The current tenant resolved at runtime. \ No newline at end of file +## Tenant +A logical boundary that isolates data between customers. Represented by a `tenant_id` string. Stored in the `tenants` table on the main database. + +## Tenant Strategy + +**`shared`** — all tenants share one database. Every table has a `tenant_id` column; `BaseRepository` appends it to every query automatically. + +**`database`** — each tenant has its own database named `{base_db}_{tenantId}`. Isolation is at the connection level; no `tenant_id` column is needed. + +## TenantContext +A static singleton that holds the current tenant ID for the lifetime of a request. Use `TenantContext::setTenantId()`, `getTenantId()`, `hasTenantId()`, and `clear()`. In worker-mode PHP (Swoole, RoadRunner), call `RequestLifecycle::begin()` at the start of each request to reset it. + +## TenantConnectionResolver +Resolves and caches the PDO connection for the current tenant. In `shared` mode it returns a connection to the main DB. In `database` mode it connects to `{base_db}_{tenantId}`. Connections are pooled in a static registry; call `flush()` to clear the cache. + +## TenantResolver +Extracts a tenant ID from request context. Two implementations ship: +- `HeaderTenantResolver` — reads a configurable header (default: `X-Tenant-ID`) +- `SubdomainTenantResolver` — extracts the leading subdomain from the host (e.g. `acme.example.com` → `acme`) + +Configured via `tenancy.resolver` in `application.yaml`. Add new resolvers by implementing `TenantResolverInterface` and registering them in `TenantResolverFactory`. + +## TenantService +The intended entry point for tenant lifecycle operations: + +| Method | What it does | +|---|---| +| `onboard($id, $name)` | Provisions DB + runs migrations + registers in tenants table | +| `suspend($id)` | Sets `status = 'suspended'`; blocks future boots | +| `resume($id)` | Sets `status = 'active'` | +| `offboard($id)` | Drops tenant DB (database strategy) + removes tenant record | + +## TenantProvisioner +Low-level operator for the tenant database. `create()` runs `CREATE DATABASE` then executes all migrations. On migration failure it drops the partially-created database and re-throws — no orphaned databases. `drop()` runs `DROP DATABASE IF EXISTS`. + +## CoreSchemaManager +Ensures the `tenants` table exists on the main database. Runs on every `Application::boot()` for both strategies. Idempotent (`CREATE TABLE IF NOT EXISTS`). + +## RequestLifecycle +A helper for long-lived PHP worker processes. `begin()` and `end()` both clear `TenantContext` and flush the connection cache in `TenantConnectionResolver`, preventing tenant state from leaking between requests. + +## MigrationRunner +Executes `.up.sql` files in filename order, records each in a `migrations` table with a batch number, and skips already-executed ones. Rollback reverses all migrations from the last batch using paired `.down.sql` files. + +## Pipeline +An immutable middleware chain. Each `pipe()` returns a new `Pipeline` instance. `run(Request, callable): Response` processes the chain outermost-first, then calls the destination handler. Middleware implements `MiddlewareInterface::handle(Request, callable): Response`. + +## Router +A simple path-based request dispatcher. Register handlers with `get()`, `post()`, `put()`, `delete()`. `dispatch(Request): Response` returns a `404 Not Found` response for unregistered routes. + +## Request +An immutable value object representing an HTTP request. Constructed directly or captured from PHP superglobals via `Request::capture()`. Provides `header()`, `query()`, `body()`, `method()`, and `path()`. + +## Response +A dual-mode HTTP response. The immutable builder path (`withJson`, `withStatus`, `withHeader`, `send`) is used by the Pipeline and Router. The legacy `json()` method echoes directly and is kept for backwards compatibility. + +## ConfigLoader +Loads one or more YAML files and merges them with `array_replace_recursive`. Files are merged in order; later files override earlier ones. `saas.yaml` is loaded first, `application.yaml` second. + +## ConfigValidator +Validates required config keys before boot. Throws an `Exception` with a descriptive message if `tenancy.enabled` or any `database.*` key (`driver`, `host`, `port`, `database`, `username`, `password`) is missing. From bfe21c3d55d24439350ddf3487fc4d766dbcb35b Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:38:02 +0530 Subject: [PATCH 03/16] Filter suspended tenants from bulk migrations TenantRepository gains allActive() which selects only status='active' rows. MigrateAllTenantsCommand switches from all() to allActive() so suspended tenants are excluded from bulk migration runs. --- src/Tenant/TenantRepository.php | 9 +++++++++ tests/Tenant/TenantRepositoryTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/Tenant/TenantRepository.php b/src/Tenant/TenantRepository.php index db78f9f..43898b9 100644 --- a/src/Tenant/TenantRepository.php +++ b/src/Tenant/TenantRepository.php @@ -35,6 +35,15 @@ public function all(): array ->fetchAll(\PDO::FETCH_ASSOC); } + public function allActive(): array + { + $stmt = $this->connection->getPdo()->prepare( + "SELECT * FROM tenants WHERE status = :status" + ); + $stmt->execute(['status' => 'active']); + return $stmt->fetchAll(\PDO::FETCH_ASSOC); + } + public function exists(string $tenantId): bool { $stmt = $this->connection->getPdo()->prepare( diff --git a/tests/Tenant/TenantRepositoryTest.php b/tests/Tenant/TenantRepositoryTest.php index bb0c71c..52c4189 100644 --- a/tests/Tenant/TenantRepositoryTest.php +++ b/tests/Tenant/TenantRepositoryTest.php @@ -76,6 +76,30 @@ public function test_all_returns_tenant_rows(): void $this->assertSame($rows, $repo->all()); } + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_all_active_returns_only_active_tenants(): void + { + $rows = [['id' => 1, 'tenant_id' => 'acme', 'name' => 'Acme', 'status' => 'active']]; + + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->with(['status' => 'active'])->andReturn(true); + $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn($rows); + + $pdo = Mockery::mock(PDO::class); + $pdo->allows('prepare') + ->with('SELECT * FROM tenants WHERE status = :status') + ->andReturn($stmt); + + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($pdo); + + $repo = new TenantRepository($this->dbConfig()); + + $this->assertSame($rows, $repo->allActive()); + } + #[RunInSeparateProcess] #[PreserveGlobalState(false)] public function test_exists_returns_true_when_found(): void From 99c98dc62ceeead3842cccac07ff114f5fccf7a5 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:38:23 +0530 Subject: [PATCH 04/16] Validate column names in BaseRepository to prevent injection assertColumnName() enforces ^[a-zA-Z0-9_]+$ on every column name passed to create(), where(), and update() before it is interpolated into SQL. Throws InvalidArgumentException on violation. --- src/Repository/BaseRepository.php | 11 +++++++++++ tests/Repository/BaseRepositoryTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Repository/BaseRepository.php b/src/Repository/BaseRepository.php index afeb8c9..21da7fc 100644 --- a/src/Repository/BaseRepository.php +++ b/src/Repository/BaseRepository.php @@ -22,6 +22,13 @@ public function __construct(array $config) $this->table = $this->resolveTableName(); } + private function assertColumnName(string $column): void + { + if (!preg_match('/^[a-zA-Z0-9_]+$/', $column)) { + throw new \InvalidArgumentException("Invalid column name: '{$column}'"); + } + } + protected function resolveTableName(): string { $class = (new \ReflectionClass($this)) @@ -74,6 +81,8 @@ public function create(array $data): array $columns = array_keys($data); + array_walk($columns, fn(string $c) => $this->assertColumnName($c)); + $placeholders = array_map( fn(string $column) => ':' . $column, $columns @@ -163,6 +172,7 @@ public function where(array $conditions): array $params = []; foreach ($conditions as $column => $value) { + $this->assertColumnName($column); $clauses[] = "{$column} = :{$column}"; $params[$column] = $value; } @@ -198,6 +208,7 @@ public function update(int $id, array $data): bool $setClauses = []; foreach ($data as $column => $value) { + $this->assertColumnName($column); $setClauses[] = "{$column} = :{$column}"; } diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php index 47c9c57..cbf1fe6 100644 --- a/tests/Repository/BaseRepositoryTest.php +++ b/tests/Repository/BaseRepositoryTest.php @@ -234,6 +234,30 @@ public function test_where_shared_strategy_includes_tenant_scope(): void $this->assertSame([], $results); } + public function test_create_throws_on_invalid_column_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid column name/'); + + $this->repo('database')->create(['name; DROP TABLE widgets--' => 'bad']); + } + + public function test_where_throws_on_invalid_column_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid column name/'); + + $this->repo('database')->where(['col name' => 'value']); + } + + public function test_update_throws_on_invalid_column_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid column name/'); + + $this->repo('database')->update(1, ['`injected`' => 'value']); + } + public function test_transaction_methods_delegate_to_pdo(): void { $this->pdo->allows('beginTransaction')->once()->andReturn(true); From 2dcb61ba0ebb135228acf1688789f2ea0065600a Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:38:32 +0530 Subject: [PATCH 05/16] Add dry-run mode to migration commands MigrationRunner::run() and rollback() accept a $dryRun flag. In dry-run mode all writes are skipped and output is prefixed with [DRY RUN]. migrate, migrate:rollback, and migrate:all-tenants gain a --dry-run option wired through to the runner. --- src/Console/MigrateCommand.php | 12 ++++-- src/Console/RollbackCommand.php | 12 ++++-- src/Database/MigrationRunner.php | 47 ++++++++++++++------- tests/Database/MigrationRunnerTest.php | 57 +++++++++++++++++++++++++- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php index e83cbe3..c500170 100644 --- a/src/Console/MigrateCommand.php +++ b/src/Console/MigrateCommand.php @@ -8,6 +8,7 @@ use Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class MigrateCommand extends Command @@ -16,7 +17,8 @@ protected function configure(): void { $this ->setName('migrate') - ->setDescription('Run database migrations'); + ->setDescription('Run database migrations') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show pending migrations without executing them'); } /** @@ -29,12 +31,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $config = $app->getConfig()['database']; + $dryRun = (bool) $input->getOption('dry-run'); $connection = new Connection($config); $runner = new MigrationRunner($connection); try { - $runner->run('database/migrations'); - $output->writeln("Migrations executed successfully"); + $runner->run('database/migrations', $dryRun); + $output->writeln($dryRun + ? 'Dry run complete — no changes applied' + : 'Migrations executed successfully' + ); } catch (\Throwable $e) { $output->writeln("{$e->getMessage()}"); return Command::FAILURE; diff --git a/src/Console/RollbackCommand.php b/src/Console/RollbackCommand.php index b4566da..b4a40dd 100644 --- a/src/Console/RollbackCommand.php +++ b/src/Console/RollbackCommand.php @@ -8,6 +8,7 @@ use Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class RollbackCommand extends Command @@ -16,7 +17,8 @@ protected function configure(): void { $this ->setName('migrate:rollback') - ->setDescription('Rollback last batch'); + ->setDescription('Rollback last batch') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be rolled back without executing'); } /** @@ -29,11 +31,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $db = $app->getConfig()['database']; + $dryRun = (bool) $input->getOption('dry-run'); $runner = new MigrationRunner(new Connection($db)); try { - $runner->rollback('database/migrations'); - $output->writeln("Rollback successful"); + $runner->rollback('database/migrations', $dryRun); + $output->writeln($dryRun + ? 'Dry run complete — no changes applied' + : 'Rollback successful' + ); } catch (\Throwable $e) { $output->writeln("{$e->getMessage()}"); return Command::FAILURE; diff --git a/src/Database/MigrationRunner.php b/src/Database/MigrationRunner.php index 9ad9128..6ac44a1 100644 --- a/src/Database/MigrationRunner.php +++ b/src/Database/MigrationRunner.php @@ -13,11 +13,13 @@ public function __construct(Connection $connection) $this->connection = $connection; } - public function run(string $path): void + public function run(string $path, bool $dryRun = false): void { $pdo = $this->connection->getPdo(); - $this->ensureMigrationsTable(); + if (!$dryRun) { + $this->ensureMigrationsTable(); + } $files = glob($path . '/*.up.sql'); sort($files); @@ -27,39 +29,42 @@ public function run(string $path): void return; } - $executed = $this->getExecuted(); - $batch = $this->nextBatch(); + $executed = $dryRun ? $this->getExecutedSafe() : $this->getExecuted(); + $batch = $dryRun ? 0 : $this->nextBatch(); + $prefix = $dryRun ? '[DRY RUN] ' : ''; foreach ($files as $file) { $name = basename($file); if (in_array($name, $executed, true)) { - echo "Skipped: {$name}\n"; + echo "{$prefix}Skipped (already executed): {$name}\n"; continue; } $sql = file_get_contents($file); + if ($dryRun) { + echo "{$prefix}Would execute: {$name}\n"; + continue; + } + try { $pdo->exec($sql); - $this->mark($name, $batch); - echo "Executed: {$name}\n"; - } catch (\Throwable $e) { throw new \Exception("Migration failed: {$name} - " . $e->getMessage()); } } - echo "✔ Done\n"; + echo $dryRun ? "{$prefix}Done (no changes applied)\n" : "✔ Done\n"; } - public function rollback(string $path): void + public function rollback(string $path, bool $dryRun = false): void { - $pdo = $this->connection->getPdo(); - - $batch = $this->lastBatch(); + $pdo = $this->connection->getPdo(); + $prefix = $dryRun ? '[DRY RUN] ' : ''; + $batch = $this->lastBatch(); if ($batch === 0) { echo "Nothing to rollback.\n"; @@ -82,6 +87,11 @@ public function rollback(string $path): void $sql = file_get_contents($down); + if ($dryRun) { + echo "{$prefix}Would roll back: {$migration}\n"; + continue; + } + try { $pdo->exec($sql); @@ -95,7 +105,7 @@ public function rollback(string $path): void } } - echo "✔ Rollback complete\n"; + echo $dryRun ? "{$prefix}Done (no changes applied)\n" : "✔ Rollback complete\n"; } private function ensureMigrationsTable(): void @@ -127,6 +137,15 @@ private function getExecuted(): array ->fetchAll(PDO::FETCH_COLUMN); } + private function getExecutedSafe(): array + { + try { + return $this->getExecuted(); + } catch (\Throwable) { + return []; + } + } + private function mark(string $migration, int $batch): void { $stmt = $this->connection->getPdo()->prepare( diff --git a/tests/Database/MigrationRunnerTest.php b/tests/Database/MigrationRunnerTest.php index 6ab812f..97d81fd 100644 --- a/tests/Database/MigrationRunnerTest.php +++ b/tests/Database/MigrationRunnerTest.php @@ -105,11 +105,66 @@ public function test_run_skips_already_executed_migration(): void $this->mockMigrationsTable(['0001_create_users.up.sql'], 1); - $this->expectOutputRegex('/Skipped: 0001_create_users\.up\.sql/'); + $this->expectOutputRegex('/Skipped \(already executed\): 0001_create_users\.up\.sql/'); $this->runner->run($this->tmpDir); } + public function test_dry_run_shows_would_execute_without_writing(): void + { + file_put_contents($this->tmpDir . '/0001_create_users.up.sql', 'CREATE TABLE users (id INT);'); + + // dry-run: no ensureMigrationsTable, getExecutedSafe falls back to [] on failure + $execStmt = Mockery::mock(PDOStatement::class); + $execStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn([]); + $this->pdo->allows('query')->with('SELECT migration FROM migrations')->andReturn($execStmt); + + // pdo->exec must NOT be called + $this->pdo->shouldNotReceive('exec'); + + $this->expectOutputRegex('/\[DRY RUN\] Would execute: 0001_create_users\.up\.sql/'); + + $this->runner->run($this->tmpDir, true); + } + + public function test_dry_run_marks_already_executed_as_skipped(): void + { + file_put_contents($this->tmpDir . '/0001_create_users.up.sql', 'CREATE TABLE users (id INT);'); + + $execStmt = Mockery::mock(PDOStatement::class); + $execStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn(['0001_create_users.up.sql']); + $this->pdo->allows('query')->with('SELECT migration FROM migrations')->andReturn($execStmt); + + $this->pdo->shouldNotReceive('exec'); + + $this->expectOutputRegex('/\[DRY RUN\] Skipped \(already executed\): 0001_create_users\.up\.sql/'); + + $this->runner->run($this->tmpDir, true); + } + + public function test_dry_run_rollback_shows_would_roll_back_without_writing(): void + { + $migration = '0001_create_users.up.sql'; + file_put_contents($this->tmpDir . '/0001_create_users.down.sql', 'DROP TABLE users;'); + + $batchStmt = Mockery::mock(PDOStatement::class); + $batchStmt->allows('fetchColumn')->andReturn(1); + $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt); + + $listStmt = Mockery::mock(PDOStatement::class); + $listStmt->allows('execute')->with(['b' => 1])->andReturn(true); + $listStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn([$migration]); + $this->pdo->allows('prepare') + ->with('SELECT migration FROM migrations WHERE batch = :b ORDER BY id DESC') + ->andReturn($listStmt); + + $this->pdo->shouldNotReceive('exec'); + + $this->expectOutputRegex('/\[DRY RUN\] Would roll back: 0001_create_users\.up\.sql/'); + + $this->runner->rollback($this->tmpDir, true); + } + public function test_rollback_does_nothing_when_no_batches(): void { // rollback() calls lastBatch() only — no ensureMigrationsTable From 9a4cde7888b99b686e996631dd5b9d305d2062ba Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:38:42 +0530 Subject: [PATCH 06/16] Validate tenant ID format and add constructor injection to TenantService onboard() now rejects tenant IDs that do not match ^[a-zA-Z0-9_-]+$ before touching the database. TenantRepository and TenantProvisioner can be injected via constructor, eliminating the need for overload: mocks in tests. --- src/Tenant/TenantService.php | 17 +++- tests/Tenant/TenantServiceTest.php | 156 ++++++++++++++++------------- 2 files changed, 97 insertions(+), 76 deletions(-) diff --git a/src/Tenant/TenantService.php b/src/Tenant/TenantService.php index dce8e1a..01f3869 100644 --- a/src/Tenant/TenantService.php +++ b/src/Tenant/TenantService.php @@ -8,15 +8,22 @@ class TenantService private TenantProvisioner $provisioner; private array $config; - public function __construct(array $config) - { - $this->config = $config; - $this->repo = new TenantRepository($config); - $this->provisioner = new TenantProvisioner($config); + public function __construct( + array $config, + ?TenantRepository $repo = null, + ?TenantProvisioner $provisioner = null + ) { + $this->config = $config; + $this->repo = $repo ?? new TenantRepository($config); + $this->provisioner = $provisioner ?? new TenantProvisioner($config); } public function onboard(string $tenantId, string $name): void { + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $tenantId)) { + throw new \Exception("Invalid tenant ID '{$tenantId}': only letters, numbers, hyphens, and underscores are allowed."); + } + if ($this->repo->exists($tenantId)) { throw new \Exception("Tenant already exists: {$tenantId}"); } diff --git a/tests/Tenant/TenantServiceTest.php b/tests/Tenant/TenantServiceTest.php index 34e4dc2..bb135d1 100644 --- a/tests/Tenant/TenantServiceTest.php +++ b/tests/Tenant/TenantServiceTest.php @@ -6,8 +6,7 @@ use EntityForge\Tenant\TenantRepository; use EntityForge\Tenant\TenantService; use Mockery; -use PHPUnit\Framework\Attributes\PreserveGlobalState; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use Mockery\MockInterface; use PHPUnit\Framework\TestCase; class TenantServiceTest extends TestCase @@ -17,148 +16,163 @@ protected function tearDown(): void Mockery::close(); } - private function config(): array + private function config(string $strategy = 'shared'): array { return [ + 'tenancy' => ['strategy' => $strategy], 'database' => [ - 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, - 'database' => 'app', 'username' => 'root', 'password' => 'root', + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'app', + 'username' => 'root', + 'password' => 'root', ], ]; } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] + private function service( + string $strategy = 'shared', + ?TenantRepository $repo = null, + ?TenantProvisioner $provisioner = null + ): TenantService { + return new TenantService( + $this->config($strategy), + $repo ?? Mockery::mock(TenantRepository::class), + $provisioner ?? Mockery::mock(TenantProvisioner::class) + ); + } + + public function test_onboard_throws_for_invalid_tenant_id(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Invalid tenant ID/'); + + $this->service()->onboard('acme corp!', 'Acme Corp'); + } + + public function test_onboard_accepts_valid_tenant_id_formats(): void + { + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); + $repo->allows('exists')->andReturn(false); + $repo->allows('create'); + + /** @var MockInterface&TenantProvisioner $provisioner */ + $provisioner = Mockery::mock(TenantProvisioner::class); + $provisioner->allows('create'); + + $service = new TenantService($this->config(), $repo, $provisioner); + + foreach (['acme', 'acme-corp', 'acme_corp', 'Acme123'] as $id) { + $service->onboard($id, 'Test'); + } + + $this->assertTrue(true); + } + public function test_onboard_provisions_and_registers_tenant(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(false); - $repo->allows('create')->with('acme', 'Acme Corp')->once(); + $repo->expects('create')->with('acme', 'Acme Corp')->once(); - $provisioner = Mockery::mock('overload:' . TenantProvisioner::class); - $provisioner->allows('create')->with('acme')->once(); + /** @var MockInterface&TenantProvisioner $provisioner */ + $provisioner = Mockery::mock(TenantProvisioner::class); + $provisioner->expects('create')->with('acme')->once(); - $service = new TenantService($this->config()); + $service = new TenantService($this->config(), $repo, $provisioner); $service->onboard('acme', 'Acme Corp'); $this->assertTrue(true); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_onboard_throws_when_tenant_already_exists(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(true); - Mockery::mock('overload:' . TenantProvisioner::class); - - $service = new TenantService($this->config()); - $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Tenant already exists/'); - $service->onboard('acme', 'Acme Corp'); + $this->service(repo: $repo)->onboard('acme', 'Acme Corp'); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_suspend_sets_status_to_suspended(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(true); - $repo->allows('updateStatus')->with('acme', 'suspended')->once(); - - Mockery::mock('overload:' . TenantProvisioner::class); + $repo->expects('updateStatus')->with('acme', 'suspended')->once(); - $service = new TenantService($this->config()); - $service->suspend('acme'); + $this->service(repo: $repo)->suspend('acme'); $this->assertTrue(true); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_suspend_throws_when_tenant_not_found(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('ghost')->andReturn(false); - Mockery::mock('overload:' . TenantProvisioner::class); - $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Tenant not found/'); - $service = new TenantService($this->config()); - $service->suspend('ghost'); + $this->service(repo: $repo)->suspend('ghost'); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_resume_sets_status_to_active(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(true); - $repo->allows('updateStatus')->with('acme', 'active')->once(); - - Mockery::mock('overload:' . TenantProvisioner::class); + $repo->expects('updateStatus')->with('acme', 'active')->once(); - $service = new TenantService($this->config()); - $service->resume('acme'); + $this->service(repo: $repo)->resume('acme'); $this->assertTrue(true); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_offboard_deletes_tenant_in_shared_strategy(): void { - $config = array_merge($this->config(), ['tenancy' => ['strategy' => 'shared']]); - - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(true); - $repo->allows('deleteByTenantId')->with('acme')->once(); + $repo->expects('deleteByTenantId')->with('acme')->once(); - Mockery::mock('overload:' . TenantProvisioner::class); - - $service = new TenantService($config); - $service->offboard('acme'); + $this->service('shared', $repo)->offboard('acme'); $this->assertTrue(true); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_offboard_drops_db_and_deletes_tenant_in_database_strategy(): void { - $config = array_merge($this->config(), ['tenancy' => ['strategy' => 'database']]); - - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('acme')->andReturn(true); - $repo->allows('deleteByTenantId')->with('acme')->once(); + $repo->expects('deleteByTenantId')->with('acme')->once(); - $provisioner = Mockery::mock('overload:' . TenantProvisioner::class); - $provisioner->allows('drop')->with('acme')->once(); + /** @var MockInterface&TenantProvisioner $provisioner */ + $provisioner = Mockery::mock(TenantProvisioner::class); + $provisioner->expects('drop')->with('acme')->once(); - $service = new TenantService($config); - $service->offboard('acme'); + $this->service('database', $repo, $provisioner)->offboard('acme'); $this->assertTrue(true); } - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] public function test_offboard_throws_when_tenant_not_found(): void { - $repo = Mockery::mock('overload:' . TenantRepository::class); + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); $repo->allows('exists')->with('ghost')->andReturn(false); - Mockery::mock('overload:' . TenantProvisioner::class); - $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Tenant not found/'); - $service = new TenantService($this->config()); - $service->offboard('ghost'); + $this->service(repo: $repo)->offboard('ghost'); } -} +} \ No newline at end of file From 14ea95f37f7f674d7b55ced6d3eaee248a02f1a9 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:38:52 +0530 Subject: [PATCH 07/16] Add DI container with reflection-based auto-wiring Container supports bind(), singleton(), instance(), and make(). Auto- wiring resolves typed constructor parameters recursively. Application creates a container on boot, registers TenantRepository, TenantProvisioner, and TenantService as singletons, and exposes it via getContainer(). --- src/Core/Application.php | 34 +++++++++++++ src/Core/Container.php | 82 +++++++++++++++++++++++++++++ tests/Core/ContainerTest.php | 99 ++++++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/Core/Container.php create mode 100644 tests/Core/ContainerTest.php diff --git a/src/Core/Application.php b/src/Core/Application.php index 0dc8aef..059301f 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -4,6 +4,7 @@ use EntityForge\Config\ConfigLoader; use EntityForge\Config\ConfigValidator; +use EntityForge\Core\Container; use EntityForge\Core\CoreSchemaManager; use EntityForge\Tenant\TenantContext; use EntityForge\Tenant\TenantRepository; @@ -14,10 +15,12 @@ class Application { private array $config; private string $configPath; + private Container $container; public function __construct(string $configPath) { $this->configPath = rtrim($configPath, '/'); + $this->container = new Container(); } /** @@ -35,6 +38,8 @@ public function boot(array $context = [], bool $resolveTenant = true): void $validator->validate($this->config); + $this->registerBindings(); + // Always run CoreSchemaManager — idempotently creates the tenants registry // in both strategies (shared needs it too for tenant lookups and status checks) (new CoreSchemaManager($this->config))->ensure(); @@ -52,6 +57,30 @@ public function boot(array $context = [], bool $resolveTenant = true): void } } + private function registerBindings(): void + { + $config = $this->config; + + $this->container->singleton( + \EntityForge\Tenant\TenantRepository::class, + fn() => new \EntityForge\Tenant\TenantRepository($config) + ); + + $this->container->singleton( + \EntityForge\Tenant\TenantProvisioner::class, + fn() => new \EntityForge\Tenant\TenantProvisioner($config) + ); + + $this->container->singleton( + \EntityForge\Tenant\TenantService::class, + fn(Container $c) => new \EntityForge\Tenant\TenantService( + $config, + $c->make(\EntityForge\Tenant\TenantRepository::class), + $c->make(\EntityForge\Tenant\TenantProvisioner::class) + ) + ); + } + /** * @throws Exception */ @@ -97,4 +126,9 @@ public function getConfig(): array return $this->config; } + + public function getContainer(): Container + { + return $this->container; + } } \ No newline at end of file diff --git a/src/Core/Container.php b/src/Core/Container.php new file mode 100644 index 0000000..3f37f61 --- /dev/null +++ b/src/Core/Container.php @@ -0,0 +1,82 @@ +bindings[$abstract] = $factory; + } + + public function singleton(string $abstract, callable $factory): void + { + $this->singletons[$abstract] = $factory; + } + + public function instance(string $abstract, mixed $concrete): void + { + $this->instances[$abstract] = $concrete; + } + + public function make(string $abstract): mixed + { + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + if (isset($this->singletons[$abstract])) { + $this->instances[$abstract] = ($this->singletons[$abstract])($this); + return $this->instances[$abstract]; + } + + if (isset($this->bindings[$abstract])) { + return ($this->bindings[$abstract])($this); + } + + return $this->autowire($abstract); + } + + private function autowire(string $class): mixed + { + if (!class_exists($class)) { + throw new \InvalidArgumentException("Cannot resolve '{$class}': not bound and not a class."); + } + + $reflector = new \ReflectionClass($class); + $constructor = $reflector->getConstructor(); + + if ($constructor === null) { + return new $class(); + } + + $args = []; + + foreach ($constructor->getParameters() as $param) { + $type = $param->getType(); + + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $args[] = $this->make($type->getName()); + } elseif ($param->isDefaultValueAvailable()) { + $args[] = $param->getDefaultValue(); + } else { + throw new \InvalidArgumentException( + "Cannot auto-wire '{$class}': parameter '\${$param->getName()}' has no type hint or default." + ); + } + } + + return new $class(...$args); + } + + public function has(string $abstract): bool + { + return isset($this->instances[$abstract]) + || isset($this->singletons[$abstract]) + || isset($this->bindings[$abstract]); + } +} \ No newline at end of file diff --git a/tests/Core/ContainerTest.php b/tests/Core/ContainerTest.php new file mode 100644 index 0000000..40aaff7 --- /dev/null +++ b/tests/Core/ContainerTest.php @@ -0,0 +1,99 @@ +container = new Container(); + } + + public function test_bind_returns_new_instance_each_call(): void + { + $this->container->bind(\stdClass::class, fn() => new \stdClass()); + + $a = $this->container->make(\stdClass::class); + $b = $this->container->make(\stdClass::class); + + $this->assertNotSame($a, $b); + } + + public function test_singleton_returns_same_instance(): void + { + $this->container->singleton(\stdClass::class, fn() => new \stdClass()); + + $a = $this->container->make(\stdClass::class); + $b = $this->container->make(\stdClass::class); + + $this->assertSame($a, $b); + } + + public function test_instance_returns_registered_object(): void + { + $obj = new \stdClass(); + $obj->value = 42; + + $this->container->instance(\stdClass::class, $obj); + + $this->assertSame($obj, $this->container->make(\stdClass::class)); + } + + public function test_has_returns_true_for_bound_abstract(): void + { + $this->container->bind(\stdClass::class, fn() => new \stdClass()); + + $this->assertTrue($this->container->has(\stdClass::class)); + } + + public function test_has_returns_false_for_unbound_abstract(): void + { + $this->assertFalse($this->container->has('UnknownClass')); + } + + public function test_make_throws_for_unknown_and_non_existent_class(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Cannot resolve/'); + + $this->container->make('No\\Such\\Class'); + } + + public function test_autowire_resolves_class_with_no_constructor(): void + { + $result = $this->container->make(\stdClass::class); + + $this->assertInstanceOf(\stdClass::class, $result); + } + + public function test_factory_receives_container_instance(): void + { + $this->container->bind('test', function (Container $c) { + $obj = new \stdClass(); + $obj->self = $c; + return $obj; + }); + + $result = $this->container->make('test'); + + $this->assertSame($this->container, $result->self); + } + + public function test_autowire_throws_when_param_has_no_type_and_no_default(): void + { + // Create a class dynamically that has an unresolvable constructor param + $class = new class('placeholder') { + public function __construct(string $value) {} + }; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Cannot auto-wire/'); + + $this->container->make(get_class($class)); + } +} \ No newline at end of file From 1b52a757840c00c926c9eb6a0541b147f426a2ea Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:39:03 +0530 Subject: [PATCH 08/16] Add foreign key and index support to MigrationBuilder MigrationBuilder emits CONSTRAINT ... FOREIGN KEY from relations.belongsTo and INDEX / UNIQUE INDEX from indexes. SchemaValidator validates both new sections. EntitySchema exposes getIndexes(). --- src/Generator/Builder/MigrationBuilder.php | 29 +++++-- src/Generator/Schema/EntitySchema.php | 5 ++ src/Generator/Schema/SchemaValidator.php | 25 ++++++ .../Builder/MigrationBuilderTest.php | 69 +++++++++++++++ tests/Generator/Schema/EntitySchemaTest.php | 12 +++ .../Generator/Schema/SchemaValidatorTest.php | 83 +++++++++++++++++++ 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index 1e7725a..e35a41e 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -8,20 +8,37 @@ class MigrationBuilder { public function buildUp(EntitySchema $schema): string { - $table = strtolower($schema->getEntityName()) . 's'; - $fields = $schema->getFields(); + $table = strtolower($schema->getEntityName()) . 's'; + $fields = $schema->getFields(); + $relations = $schema->getRelations(); + $indexes = $schema->getIndexes(); - $columns = []; + $definitions = []; foreach ($fields as $name => $type) { - $columns[] = $this->mapColumn($name, $type); + $definitions[] = $this->mapColumn($name, $type); + } + + foreach ($relations['belongsTo'] ?? [] as $refEntity => $fkColumn) { + $refTable = strtolower($refEntity) . 's'; + $constraint = "fk_{$table}_{$fkColumn}"; + $definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}(id)"; + } + + foreach ($indexes as $index) { + $cols = implode(', ', $index['columns']); + $slug = implode('_', $index['columns']); + $unique = $index['unique'] ?? false; + $type = $unique ? 'UNIQUE INDEX' : 'INDEX'; + $prefix = $unique ? 'uix' : 'idx'; + $definitions[] = "{$type} {$prefix}_{$table}_{$slug} ({$cols})"; } - $columnsSql = implode(",\n ", $columns); + $body = implode(",\n ", $definitions); return <<config['relations'] ?? []; } + + public function getIndexes(): array + { + return $this->config['indexes'] ?? []; + } } \ No newline at end of file diff --git a/src/Generator/Schema/SchemaValidator.php b/src/Generator/Schema/SchemaValidator.php index 80d08f0..6e59881 100644 --- a/src/Generator/Schema/SchemaValidator.php +++ b/src/Generator/Schema/SchemaValidator.php @@ -31,5 +31,30 @@ public function validate(array $config): void throw new \InvalidArgumentException("Unsupported field type: {$type} for {$field}"); } } + + // Relations validation + foreach ($config['relations']['belongsTo'] ?? [] as $refEntity => $fkColumn) { + if (!preg_match('/^[A-Z][A-Za-z0-9]*$/', $refEntity)) { + throw new \InvalidArgumentException("Invalid relation entity name: {$refEntity}"); + } + if (!preg_match('/^[a-z_][a-z0-9_]*$/', $fkColumn)) { + throw new \InvalidArgumentException("Invalid foreign key column name: {$fkColumn}"); + } + } + + // Indexes validation + foreach ($config['indexes'] ?? [] as $i => $index) { + if (!isset($index['columns']) || !is_array($index['columns']) || empty($index['columns'])) { + throw new \InvalidArgumentException("Index #{$i} must have a non-empty 'columns' array"); + } + foreach ($index['columns'] as $col) { + if (!preg_match('/^[a-z_][a-z0-9_]*$/', $col)) { + throw new \InvalidArgumentException("Invalid column name '{$col}' in index #{$i}"); + } + } + if (isset($index['unique']) && !is_bool($index['unique'])) { + throw new \InvalidArgumentException("Index #{$i} 'unique' must be a boolean"); + } + } } } \ No newline at end of file diff --git a/tests/Generator/Builder/MigrationBuilderTest.php b/tests/Generator/Builder/MigrationBuilderTest.php index 0c969d4..14a3af3 100644 --- a/tests/Generator/Builder/MigrationBuilderTest.php +++ b/tests/Generator/Builder/MigrationBuilderTest.php @@ -84,4 +84,73 @@ public function test_table_name_is_lowercased_plural(): void $this->assertStringContainsString('invoices', $sql); } + + public function test_build_up_emits_foreign_key_for_belongs_to(): void + { + $schema = new EntitySchema([ + 'entity' => 'Order', + 'fields' => ['id' => 'int', 'user_id' => 'int'], + 'relations' => ['belongsTo' => ['User' => 'user_id']], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('CONSTRAINT fk_orders_user_id', $sql); + $this->assertStringContainsString('FOREIGN KEY (user_id) REFERENCES users(id)', $sql); + } + + public function test_build_up_emits_index(): void + { + $schema = new EntitySchema([ + 'entity' => 'Order', + 'fields' => ['id' => 'int', 'status' => 'string'], + 'indexes' => [['columns' => ['status']]], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('INDEX idx_orders_status (status)', $sql); + } + + public function test_build_up_emits_unique_index(): void + { + $schema = new EntitySchema([ + 'entity' => 'User', + 'fields' => ['id' => 'int', 'email' => 'string'], + 'indexes' => [['columns' => ['email'], 'unique' => true]], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('UNIQUE INDEX uix_users_email (email)', $sql); + } + + public function test_build_up_emits_composite_index(): void + { + $schema = new EntitySchema([ + 'entity' => 'Order', + 'fields' => ['id' => 'int', 'user_id' => 'int', 'status' => 'string'], + 'indexes' => [['columns' => ['user_id', 'status']]], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('INDEX idx_orders_user_id_status (user_id, status)', $sql); + } + + public function test_build_up_emits_multiple_fks_and_indexes(): void + { + $schema = new EntitySchema([ + 'entity' => 'OrderItem', + 'fields' => ['id' => 'int', 'order_id' => 'int', 'product_id' => 'int'], + 'relations' => ['belongsTo' => ['Order' => 'order_id', 'Product' => 'product_id']], + 'indexes' => [['columns' => ['order_id', 'product_id'], 'unique' => true]], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('FOREIGN KEY (order_id) REFERENCES orders(id)', $sql); + $this->assertStringContainsString('FOREIGN KEY (product_id) REFERENCES products(id)', $sql); + $this->assertStringContainsString('UNIQUE INDEX uix_orderitems_order_id_product_id', $sql); + } } diff --git a/tests/Generator/Schema/EntitySchemaTest.php b/tests/Generator/Schema/EntitySchemaTest.php index 085eb4d..8e526a7 100644 --- a/tests/Generator/Schema/EntitySchemaTest.php +++ b/tests/Generator/Schema/EntitySchemaTest.php @@ -80,4 +80,16 @@ public function test_get_relations_returns_defined_relations(): void $schema = $this->schema(['relations' => ['belongsTo' => ['User' => 'user_id']]]); $this->assertSame(['belongsTo' => ['User' => 'user_id']], $schema->getRelations()); } + + public function test_get_indexes_returns_empty_array_by_default(): void + { + $this->assertSame([], $this->schema()->getIndexes()); + } + + public function test_get_indexes_returns_defined_indexes(): void + { + $indexes = [['columns' => ['email'], 'unique' => true]]; + $schema = $this->schema(['indexes' => $indexes]); + $this->assertSame($indexes, $schema->getIndexes()); + } } diff --git a/tests/Generator/Schema/SchemaValidatorTest.php b/tests/Generator/Schema/SchemaValidatorTest.php index 7b881e7..5c62503 100644 --- a/tests/Generator/Schema/SchemaValidatorTest.php +++ b/tests/Generator/Schema/SchemaValidatorTest.php @@ -94,4 +94,87 @@ public function test_schema_without_fields_key_passes(): void $this->validator->validate(['entity' => 'User']); $this->assertTrue(true); } + + public function test_valid_belongs_to_relation_passes(): void + { + $this->validator->validate([ + 'entity' => 'Order', + 'fields' => ['id' => 'int', 'user_id' => 'int'], + 'relations' => ['belongsTo' => ['User' => 'user_id']], + ]); + $this->assertTrue(true); + } + + public function test_invalid_relation_entity_name_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid relation entity name/'); + + $this->validator->validate([ + 'entity' => 'Order', + 'relations' => ['belongsTo' => ['invalid_entity' => 'user_id']], + ]); + } + + public function test_invalid_fk_column_name_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid foreign key column name/'); + + $this->validator->validate([ + 'entity' => 'Order', + 'relations' => ['belongsTo' => ['User' => 'User ID']], + ]); + } + + public function test_valid_index_passes(): void + { + $this->validator->validate([ + 'entity' => 'Order', + 'indexes' => [['columns' => ['status']]], + ]); + $this->assertTrue(true); + } + + public function test_valid_unique_index_passes(): void + { + $this->validator->validate([ + 'entity' => 'User', + 'indexes' => [['columns' => ['email'], 'unique' => true]], + ]); + $this->assertTrue(true); + } + + public function test_index_without_columns_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/non-empty 'columns' array/"); + + $this->validator->validate([ + 'entity' => 'Order', + 'indexes' => [['unique' => true]], + ]); + } + + public function test_index_with_invalid_column_name_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid column name/'); + + $this->validator->validate([ + 'entity' => 'Order', + 'indexes' => [['columns' => ['Bad Column']]], + ]); + } + + public function test_index_with_non_bool_unique_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/'unique' must be a boolean/"); + + $this->validator->validate([ + 'entity' => 'Order', + 'indexes' => [['columns' => ['status'], 'unique' => 'yes']], + ]); + } } From ce472ccba132e5a75f1719ad4ffea312dda3d995 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:39:12 +0530 Subject: [PATCH 09/16] Make SubdomainTenantResolver minimum host parts configurable subdomainDepth + 3 hardcoded threshold replaced with a configurable minParts parameter (default 3). Set tenancy.subdomain_min_parts: 2 in application.yaml to support two-part hosts like acme.io. TenantResolverFactory reads the new config key. --- .../Resolver/SubdomainTenantResolver.php | 6 +-- src/Tenant/TenantResolverFactory.php | 3 +- .../Resolver/SubdomainTenantResolverTest.php | 38 +++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/Tenant/Resolver/SubdomainTenantResolver.php b/src/Tenant/Resolver/SubdomainTenantResolver.php index 0148374..a6bacb4 100644 --- a/src/Tenant/Resolver/SubdomainTenantResolver.php +++ b/src/Tenant/Resolver/SubdomainTenantResolver.php @@ -8,7 +8,8 @@ class SubdomainTenantResolver implements TenantResolverInterface { public function __construct( - private int $subdomainDepth = 0 + private int $subdomainDepth = 0, + private int $minParts = 3 ) {} /** @@ -29,8 +30,7 @@ public function resolve(array $context): string $parts = explode('.', $host); - // Need at least subdomainDepth + 3 parts: subdomain(s) + domain + tld - if (count($parts) < $this->subdomainDepth + 3) { + if (count($parts) < $this->subdomainDepth + $this->minParts) { throw new Exception("Cannot extract subdomain from host: {$host}"); } diff --git a/src/Tenant/TenantResolverFactory.php b/src/Tenant/TenantResolverFactory.php index a2546f4..30631eb 100644 --- a/src/Tenant/TenantResolverFactory.php +++ b/src/Tenant/TenantResolverFactory.php @@ -19,7 +19,8 @@ public static function create(array $config): TenantResolverInterface $config['tenancy']['header_key'] ?? 'X-Tenant-ID' ), 'subdomain' => new SubdomainTenantResolver( - (int) ($config['tenancy']['subdomain_depth'] ?? 0) + (int) ($config['tenancy']['subdomain_depth'] ?? 0), + (int) ($config['tenancy']['subdomain_min_parts'] ?? 3) ), default => throw new Exception("Unsupported tenant resolver type: {$resolverType}"), }; diff --git a/tests/Tenant/Resolver/SubdomainTenantResolverTest.php b/tests/Tenant/Resolver/SubdomainTenantResolverTest.php index 985a6a8..3911559 100644 --- a/tests/Tenant/Resolver/SubdomainTenantResolverTest.php +++ b/tests/Tenant/Resolver/SubdomainTenantResolverTest.php @@ -54,4 +54,42 @@ public function test_host_key_takes_precedence_over_server(): void ]); $this->assertSame('primary', $tenantId); } + + public function test_min_parts_2_resolves_two_part_host(): void + { + $resolver = new SubdomainTenantResolver(subdomainDepth: 0, minParts: 2); + $tenantId = $resolver->resolve(['host' => 'acme.io']); + $this->assertSame('acme', $tenantId); + } + + public function test_min_parts_2_still_throws_for_single_part_host(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Cannot extract subdomain/'); + + (new SubdomainTenantResolver(subdomainDepth: 0, minParts: 2))->resolve(['host' => 'localhost']); + } + + public function test_default_min_parts_3_still_rejects_two_part_host(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/Cannot extract subdomain/'); + + (new SubdomainTenantResolver())->resolve(['host' => 'acme.io']); + } + + public function test_factory_passes_subdomain_min_parts_from_config(): void + { + $config = [ + 'tenancy' => [ + 'resolver' => 'subdomain', + 'subdomain_min_parts' => 2, + ], + ]; + + $resolver = \EntityForge\Tenant\TenantResolverFactory::create($config); + $tenantId = $resolver->resolve(['host' => 'acme.io']); + + $this->assertSame('acme', $tenantId); + } } From 9fd56d36e24a3d5697c042dca72af94b7a4aa2b6 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:39:31 +0530 Subject: [PATCH 10/16] Add parameterised route support via FastRoute Router delegates to nikic/fast-route for route matching. {name} segments are resolved to named captures available via Request::param() and params(). Method mismatches now return 405 instead of 404. Exact routes registered before parameterised ones take priority. --- src/Http/Request.php | 20 +++++++++- src/Http/Router.php | 47 +++++++++++++++-------- tests/Http/RouterTest.php | 81 +++++++++++++++++++++++++++++++++++---- 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/src/Http/Request.php b/src/Http/Request.php index 97527f9..88e35e0 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -10,7 +10,8 @@ public function __construct( private array $query = [], private array $body = [], private array|string $method = 'GET', - private string $path = '/' + private string $path = '/', + private array $params = [] ) {} public static function capture(): self @@ -51,4 +52,21 @@ public function path(): string { return $this->path; } + + public function param(string $name): ?string + { + return $this->params[$name] ?? null; + } + + public function params(): array + { + return $this->params; + } + + public function withParams(array $params): self + { + $clone = clone $this; + $clone->params = $params; + return $clone; + } } \ No newline at end of file diff --git a/src/Http/Router.php b/src/Http/Router.php index fb10f42..0efde73 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -2,45 +2,58 @@ namespace EntityForge\Http; +use FastRoute\Dispatcher; +use FastRoute\RouteCollector; +use function FastRoute\simpleDispatcher; + class Router { private array $routes = []; public function get(string $path, callable $handler): self { - $this->routes['GET'][$path] = $handler; - return $this; + return $this->register('GET', $path, $handler); } public function post(string $path, callable $handler): self { - $this->routes['POST'][$path] = $handler; - return $this; + return $this->register('POST', $path, $handler); } public function put(string $path, callable $handler): self { - $this->routes['PUT'][$path] = $handler; - return $this; + return $this->register('PUT', $path, $handler); } public function delete(string $path, callable $handler): self { - $this->routes['DELETE'][$path] = $handler; + return $this->register('DELETE', $path, $handler); + } + + private function register(string $method, string $path, callable $handler): self + { + $this->routes[] = [$method, $path, $handler]; return $this; } public function dispatch(Request $request): Response { - $method = strtoupper($request->method()); - $path = $request->path(); - - $handler = $this->routes[$method][$path] ?? null; - - if ($handler === null) { - return (new Response())->withJson(['error' => 'Not Found'], 404); - } - - return $handler($request); + $routes = $this->routes; + $dispatcher = simpleDispatcher(function (RouteCollector $r) use ($routes): void { + foreach ($routes as [$method, $path, $handler]) { + $r->addRoute($method, $path, $handler); + } + }); + + $result = $dispatcher->dispatch( + strtoupper($request->method()), + $request->path() + ); + + return match ($result[0]) { + Dispatcher::FOUND => ($result[1])($request->withParams($result[2])), + Dispatcher::METHOD_NOT_ALLOWED => (new Response())->withJson(['error' => 'Method Not Allowed'], 405), + default => (new Response())->withJson(['error' => 'Not Found'], 404), + }; } } diff --git a/tests/Http/RouterTest.php b/tests/Http/RouterTest.php index 66c2e8f..c704f18 100644 --- a/tests/Http/RouterTest.php +++ b/tests/Http/RouterTest.php @@ -23,7 +23,7 @@ public function test_returns_404_for_unregistered_route(): void public function test_dispatches_get_route(): void { $router = new Router(); - $router->get('/users', fn(Request $req): Response => (new Response())->withJson(['users' => []])); + $router->get('/users', fn(Request $_req): Response => (new Response())->withJson(['users' => []])); $request = new Request(method: 'GET', path: '/users'); $response = $router->dispatch($request); @@ -35,7 +35,7 @@ public function test_dispatches_get_route(): void public function test_dispatches_post_route(): void { $router = new Router(); - $router->post('/users', fn(Request $req): Response => (new Response())->withJson(['created' => true], 201)); + $router->post('/users', fn(Request $_req): Response => (new Response())->withJson(['created' => true], 201)); $request = new Request(method: 'POST', path: '/users'); $response = $router->dispatch($request); @@ -46,7 +46,7 @@ public function test_dispatches_post_route(): void public function test_dispatches_put_route(): void { $router = new Router(); - $router->put('/users/1', fn(Request $req): Response => (new Response())->withJson(['updated' => true])); + $router->put('/users/1', fn(Request $_req): Response => (new Response())->withJson(['updated' => true])); $request = new Request(method: 'PUT', path: '/users/1'); $response = $router->dispatch($request); @@ -57,7 +57,7 @@ public function test_dispatches_put_route(): void public function test_dispatches_delete_route(): void { $router = new Router(); - $router->delete('/users/1', fn(Request $req): Response => (new Response())->withJson(['deleted' => true])); + $router->delete('/users/1', fn(Request $_req): Response => (new Response())->withJson(['deleted' => true])); $request = new Request(method: 'DELETE', path: '/users/1'); $response = $router->dispatch($request); @@ -65,15 +65,15 @@ public function test_dispatches_delete_route(): void $this->assertSame(200, $response->getStatus()); } - public function test_method_mismatch_returns_404(): void + public function test_method_mismatch_returns_405(): void { $router = new Router(); - $router->get('/ping', fn(Request $req): Response => (new Response())->withJson(['pong' => true])); + $router->get('/ping', fn(Request $_req): Response => (new Response())->withJson(['pong' => true])); $request = new Request(method: 'POST', path: '/ping'); $response = $router->dispatch($request); - $this->assertSame(404, $response->getStatus()); + $this->assertSame(405, $response->getStatus()); } public function test_router_passes_request_to_handler(): void @@ -88,6 +88,73 @@ public function test_router_passes_request_to_handler(): void $request = new Request(method: 'GET', path: '/echo', headers: ['X-Foo' => 'bar']); $router->dispatch($request); + $this->assertInstanceOf(Request::class, $captured); $this->assertSame('bar', $captured->header('X-Foo')); } + + public function test_single_param_is_extracted(): void + { + $router = new Router(); + $router->get('/users/{id}', fn(Request $req): Response => + (new Response())->withJson(['id' => $req->param('id')]) + ); + + $response = $router->dispatch(new Request(method: 'GET', path: '/users/42')); + + $this->assertSame(200, $response->getStatus()); + $this->assertStringContainsString('42', $response->getBody()); + } + + public function test_multiple_params_are_extracted(): void + { + $router = new Router(); + $router->get('/teams/{team}/users/{user}', fn(Request $req): Response => + (new Response())->withJson([ + 'team' => $req->param('team'), + 'user' => $req->param('user'), + ]) + ); + + $response = $router->dispatch(new Request(method: 'GET', path: '/teams/eng/users/99')); + + $this->assertSame(200, $response->getStatus()); + $this->assertStringContainsString('eng', $response->getBody()); + $this->assertStringContainsString('99', $response->getBody()); + } + + public function test_param_route_does_not_match_extra_segments(): void + { + $router = new Router(); + $router->get('/users/{id}', fn(Request $_req): Response => (new Response())->withJson([])); + + $response = $router->dispatch(new Request(method: 'GET', path: '/users/42/extra')); + + $this->assertSame(404, $response->getStatus()); + } + + public function test_exact_route_takes_precedence_over_param_route(): void + { + $router = new Router(); + $router->get('/users/me', fn(Request $_req): Response => (new Response())->withJson(['who' => 'me'])); + $router->get('/users/{id}', fn(Request $_req): Response => (new Response())->withJson(['who' => 'param'])); + + $response = $router->dispatch(new Request(method: 'GET', path: '/users/me')); + + $this->assertStringContainsString('me', $response->getBody()); + $this->assertStringNotContainsString('param', $response->getBody()); + } + + public function test_request_params_returns_all_extracted_params(): void + { + $router = new Router(); + $captured = null; + $router->get('/posts/{slug}', function (Request $req) use (&$captured): Response { + $captured = $req->params(); + return (new Response())->withJson([]); + }); + + $router->dispatch(new Request(method: 'GET', path: '/posts/hello-world')); + + $this->assertSame(['slug' => 'hello-world'], $captured); + } } From 693ad4dc543ceddcd025417de7d535d8ce3d8cd2 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:39:39 +0530 Subject: [PATCH 11/16] Add response streaming support Response::stream(callable) sends status and headers then delegates output to the caller. The callable echoes chunks and controls flush timing, avoiding full-body buffering for large responses. --- src/Http/Response.php | 9 +++++++ tests/Http/ResponseValueObjectTest.php | 34 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/Http/Response.php b/src/Http/Response.php index e5bbaa4..19899dd 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -55,6 +55,15 @@ public function send(): void echo $this->body; } + public function stream(callable $body): void + { + http_response_code($this->status); + foreach ($this->headers as $name => $value) { + header("{$name}: {$value}"); + } + $body(); + } + public function json(array $data, int $status = 200): void { http_response_code($status); diff --git a/tests/Http/ResponseValueObjectTest.php b/tests/Http/ResponseValueObjectTest.php index d43f0ae..ee12834 100644 --- a/tests/Http/ResponseValueObjectTest.php +++ b/tests/Http/ResponseValueObjectTest.php @@ -50,4 +50,38 @@ public function test_with_header_is_immutable(): void $this->assertNotSame($original, $modified); $this->assertArrayNotHasKey('X-Foo', $original->getHeaders()); } + + public function test_stream_invokes_callable_and_produces_output(): void + { + $response = (new Response())->withHeader('Content-Type', 'text/plain'); + + ob_start(); + $response->stream(function (): void { + echo 'chunk1'; + echo 'chunk2'; + }); + $output = ob_get_clean(); + + $this->assertSame('chunk1chunk2', $output); + } + + public function test_stream_callable_is_called_exactly_once(): void + { + $calls = 0; + (new Response())->stream(function () use (&$calls): void { + $calls++; + }); + + $this->assertSame(1, $calls); + } + + public function test_stream_uses_response_status_and_headers(): void + { + $response = (new Response()) + ->withStatus(206) + ->withHeader('Content-Range', 'bytes 0-999/5000'); + + $this->assertSame(206, $response->getStatus()); + $this->assertSame('bytes 0-999/5000', $response->getHeaders()['Content-Range']); + } } From 594768ba51aba325c3f89973584201f5feb03f1c Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:39:48 +0530 Subject: [PATCH 12/16] Enforce RequestLifecycle usage in worker-mode PHP TenantContext::setTenantId() throws LogicException if a tenant is already set, turning silent tenant leaks into hard errors. Applications using RequestLifecycle::begin() correctly are unaffected as begin() calls clear() before each request. --- src/Tenant/TenantContext.php | 6 ++++++ tests/Tenant/TenantContextTest.php | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Tenant/TenantContext.php b/src/Tenant/TenantContext.php index e078bce..d9aa6d1 100644 --- a/src/Tenant/TenantContext.php +++ b/src/Tenant/TenantContext.php @@ -10,6 +10,12 @@ class TenantContext public static function setTenantId(string $tenantId): void { + if (self::$tenantId !== null) { + throw new \LogicException( + 'TenantContext is already set. Call RequestLifecycle::begin() before handling a new request.' + ); + } + self::$tenantId = $tenantId; } diff --git a/tests/Tenant/TenantContextTest.php b/tests/Tenant/TenantContextTest.php index 52c32ea..effe5d8 100644 --- a/tests/Tenant/TenantContextTest.php +++ b/tests/Tenant/TenantContextTest.php @@ -53,9 +53,20 @@ public function test_clear_resets_tenant_id(): void $this->assertFalse(TenantContext::hasTenantId()); } - public function test_set_overwrites_existing_tenant_id(): void + public function test_set_throws_when_tenant_already_set(): void { TenantContext::setTenantId('first'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('/RequestLifecycle::begin/'); + + TenantContext::setTenantId('second'); + } + + public function test_set_succeeds_after_clear(): void + { + TenantContext::setTenantId('first'); + TenantContext::clear(); TenantContext::setTenantId('second'); $this->assertSame('second', TenantContext::getTenantId()); From a8561eda619b3c41488938cea8896af7119cf785 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:40:11 +0530 Subject: [PATCH 13/16] Add parallel worker support to migrate:all-tenants --parallel N (default 5) spawns N concurrent subprocesses via symfony/process, each running migrate:all-tenants --tenant=. Cross-platform: works on Linux, macOS, and Windows. --dry-run is forwarded to subprocesses automatically. Also adds nikic/fast-route dependency used by the HTTP router. --- composer.json | 4 +- src/Console/MigrateAllTenantsCommand.php | 92 +++++++++++++++---- .../Console/MigrateAllTenantsCommandTest.php | 19 ++++ 3 files changed, 96 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 6ae0880..4eb3c6a 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,9 @@ "php": "^8.4", "symfony/yaml": "^8.1", "symfony/console": "^8.1", - "ext-pdo": "*" + "ext-pdo": "*", + "nikic/fast-route": "^1.3", + "symfony/process": "^8.1" }, "require-dev": { "phpunit/phpunit": "^13.0", diff --git a/src/Console/MigrateAllTenantsCommand.php b/src/Console/MigrateAllTenantsCommand.php index bbff102..05ad81a 100644 --- a/src/Console/MigrateAllTenantsCommand.php +++ b/src/Console/MigrateAllTenantsCommand.php @@ -9,7 +9,9 @@ use Exception; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; class MigrateAllTenantsCommand extends Command { @@ -17,7 +19,10 @@ protected function configure(): void { $this ->setName('migrate:all-tenants') - ->setDescription('Run migrations against every registered tenant database (database strategy only)'); + ->setDescription('Run migrations against every active tenant database (database strategy only)') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show pending migrations per tenant without executing them') + ->addOption('parallel', null, InputOption::VALUE_OPTIONAL, 'Number of concurrent workers', 5) + ->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'Migrate a single tenant (used internally by parallel workers)'); } /** @@ -34,32 +39,83 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } + $dryRun = (bool) $input->getOption('dry-run'); + + // Subprocess mode — migrate one tenant and exit + if ($tenantId = $input->getOption('tenant')) { + return $this->migrateTenant($tenantId, $config, $dryRun, $output); + } + + // Orchestrator mode — spawn parallel subprocesses $tenantRepo = new TenantRepository($config); - $tenants = $tenantRepo->all(); + $tenants = $tenantRepo->allActive(); if (empty($tenants)) { $output->writeln('No tenants found.'); return Command::SUCCESS; } - $failed = 0; - - foreach ($tenants as $tenant) { - $tenantId = $tenant['tenant_id']; - $dbConfig = $config['database']; - $dbConfig['database'] = $dbConfig['database'] . '_' . $tenantId; - - try { - $connection = new Connection($dbConfig); - $runner = new MigrationRunner($connection); - $runner->run('database/migrations'); - $output->writeln("Migrated: {$tenantId}"); - } catch (\Throwable $e) { - $output->writeln("Failed {$tenantId}: {$e->getMessage()}"); - $failed++; + $parallel = max(1, (int) $input->getOption('parallel')); + $bin = PHP_BINARY; + $script = __DIR__ . '/../../bin/ef'; + + $queue = array_column($tenants, 'tenant_id'); + $running = []; + $failed = 0; + + while ($queue || $running) { + // Fill pool up to $parallel + while (count($running) < $parallel && $queue) { + $id = array_shift($queue); + $args = [$bin, $script, 'migrate:all-tenants', '--tenant', $id]; + if ($dryRun) { + $args[] = '--dry-run'; + } + $process = new Process($args); + $process->start(); + $running[$id] = $process; + } + + // Poll for finished processes + foreach ($running as $id => $process) { + if (!$process->isRunning()) { + $output->write($process->getOutput()); + if (!$process->isSuccessful()) { + $output->writeln("Failed {$id}: {$process->getErrorOutput()}"); + $failed++; + } + unset($running[$id]); + } + } + + if ($running) { + usleep(50_000); // 50ms poll interval } } return $failed > 0 ? Command::FAILURE : Command::SUCCESS; } -} + + private function migrateTenant( + string $tenantId, + array $config, + bool $dryRun, + OutputInterface $output + ): int { + $dbConfig = $config['database']; + $dbConfig['database'] = $dbConfig['database'] . '_' . $tenantId; + + try { + $runner = new MigrationRunner(new Connection($dbConfig)); + $runner->run('database/migrations', $dryRun); + $output->writeln($dryRun + ? "Dry run: {$tenantId}" + : "Migrated: {$tenantId}" + ); + return Command::SUCCESS; + } catch (\Throwable $e) { + $output->writeln("Failed {$tenantId}: {$e->getMessage()}"); + return Command::FAILURE; + } + } +} \ No newline at end of file diff --git a/tests/Console/MigrateAllTenantsCommandTest.php b/tests/Console/MigrateAllTenantsCommandTest.php index d162524..16d6871 100644 --- a/tests/Console/MigrateAllTenantsCommandTest.php +++ b/tests/Console/MigrateAllTenantsCommandTest.php @@ -17,4 +17,23 @@ public function test_command_description(): void { $this->assertStringContainsString('tenant', (new MigrateAllTenantsCommand())->getDescription()); } + + public function test_command_has_parallel_option(): void + { + $def = (new MigrateAllTenantsCommand())->getDefinition(); + $this->assertTrue($def->hasOption('parallel')); + $this->assertSame(5, $def->getOption('parallel')->getDefault()); + } + + public function test_command_has_tenant_option(): void + { + $def = (new MigrateAllTenantsCommand())->getDefinition(); + $this->assertTrue($def->hasOption('tenant')); + } + + public function test_command_has_dry_run_option(): void + { + $def = (new MigrateAllTenantsCommand())->getDefinition(); + $this->assertTrue($def->hasOption('dry-run')); + } } From 7cc408317ec0a37456874aadfda5576502e3c366 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:40:38 +0530 Subject: [PATCH 14/16] Update CLEANUP.md and remove orphaned scripts CLEANUP.md updated to reflect all completed fixes. Orphaned root-level scripts run-generator.php and test.php removed. --- docs/CLEANUP.md | 48 +++++++++++++++++++++++++++++++++++------------ run-generator.php | 20 -------------------- test.php | 37 ------------------------------------ 3 files changed, 36 insertions(+), 69 deletions(-) delete mode 100644 run-generator.php delete mode 100644 test.php diff --git a/docs/CLEANUP.md b/docs/CLEANUP.md index e9719b6..0c65e2d 100644 --- a/docs/CLEANUP.md +++ b/docs/CLEANUP.md @@ -6,7 +6,7 @@ This document tracks deliberate gaps and deferred work in the current codebase. ## Tenant Resolver -**SubdomainTenantResolver requires 3-part hosts.** `example.com` (2 parts) throws. Two-level domains (`acme.io`) are not supported as tenant hosts without subdomains. This is by design — the resolver always strips the leading segment. +**SubdomainTenantResolver minimum parts are configurable.** The default requires 3-part hosts (`acme.example.com`). Set `tenancy.subdomain_min_parts: 2` in `application.yaml` to support two-part hosts like `acme.io`. Single-part hosts (e.g. `localhost`) always throw regardless of this setting. **No JWT or session resolver.** `TenantResolverInterface` is designed for extension. Wire a new implementation into `TenantResolverFactory` and configure `tenancy.resolver` accordingly. @@ -14,19 +14,33 @@ This document tracks deliberate gaps and deferred work in the current codebase. ## HTTP Layer -**No regex or parameterised routes.** `Router` does exact path matching. Dynamic segments like `/users/{id}` are not supported. This is intentional scope — add a pattern-matching router (e.g. FastRoute) when needed. +**Parameterised routes are supported.** `Router` compiles `{name}` segments into named regex captures at registration time. Extracted values are available via `$request->param('name')` and `$request->params()`. Routes registered earlier take precedence — register exact paths before wildcard patterns to ensure correct priority. -**No response streaming.** `Response::send()` buffers the full body in memory before output. Acceptable for API responses; replace with a streaming approach for file downloads. +**Response streaming is supported.** `Response::stream(callable $body)` sends status and headers then delegates output to the callable — the caller echoes chunks and controls flush timing. Use `withStatus()` and `withHeader()` to set the status and headers before calling `stream()`. + +```php +(new Response()) + ->withStatus(200) + ->withHeader('Content-Type', 'text/csv') + ->stream(function (): void { + echo "id,name\n"; + flush(); + foreach ($rows as $row) { + echo "{$row['id']},{$row['name']}\n"; + flush(); + } + }); +``` --- ## Migration System -**`migrate:all-tenants` has no concurrency control.** Migrations run against tenant databases sequentially. On large deployments (hundreds of tenants), consider parallelising with a process pool or a queue. +**`migrate:all-tenants` supports parallel workers.** Pass `--parallel N` (default 5) to run up to N tenant migrations concurrently. Uses `symfony/process` to spawn subprocesses — cross-platform (Linux, macOS, Windows). Each subprocess calls `migrate:all-tenants --tenant=` and runs in isolation. `--dry-run` is forwarded to subprocesses automatically. -**No dry-run mode.** Neither `MigrationRunner` nor the CLI commands support `--dry-run`. Add it as a future option. +**`--dry-run` is supported.** `migrate`, `migrate:rollback`, and `migrate:all-tenants` all accept `--dry-run`. The runner skips all writes and prefixes output with `[DRY RUN]`. Executed-migration state is read best-effort (falls back to treating all as pending if the migrations table does not yet exist). -**`migrate:all-tenants` only targets active tenants.** Suspended tenants are included in `TenantRepository::all()` and will be migrated regardless of status. Filter by `status = 'active'` if you want to exclude suspended tenants from bulk migrations. +**`migrate:all-tenants` skips suspended tenants.** Uses `TenantRepository::allActive()` which filters by `status = 'active'`. Tenants in any other status are excluded from bulk migrations. --- @@ -38,26 +52,36 @@ This document tracks deliberate gaps and deferred work in the current codebase. ## Concurrency -**`TenantContext` is a static singleton.** Safe for PHP-FPM. For worker-mode PHP, `RequestLifecycle::begin()` / `end()` must be called around each request. There is no enforcement mechanism — the application will silently serve the wrong tenant if lifecycle hooks are omitted. +**`TenantContext` is a static singleton with lifecycle enforcement.** `setTenantId()` throws `LogicException` if a tenant is already set, making omitted `RequestLifecycle::begin()` calls a hard error rather than a silent data leak. `RequestLifecycle::begin()` calls `clear()` first, so correctly structured worker loops are unaffected. --- ## Code Generator -**`generate:all` defaults to CWD-relative `config/entities`.** If invoked from outside the project root without `--config-dir`, it will fail silently (no files found). Always run from the project root or pass `--config-dir` explicitly. +**`generate:all` defaults to CWD-relative `config/entities`.** If invoked from outside the project root without `--config-dir`, it will exit with an error (`Directory not found: config/entities`). Always run from the project root or pass `--config-dir` explicitly. + +**Relations and indexes are supported in the generator.** `MigrationBuilder` emits `FOREIGN KEY` constraints from `relations.belongsTo` and `INDEX` / `UNIQUE INDEX` clauses from `indexes`. Both sections are optional and validated by `SchemaValidator`. -**No relation or index support in the generator.** `MigrationBuilder` only handles column definitions. Foreign keys, composite indexes, and unique constraints must be added to the generated SQL files manually. +```json +"relations": { "belongsTo": { "User": "user_id" } }, +"indexes": [ + { "columns": ["email"], "unique": true }, + { "columns": ["status"] } +] +``` --- ## Security -**No input sanitisation in `BaseRepository`.** Table and column names in `where()`, `update()`, and `delete()` are interpolated directly into SQL strings. These values come from internal code (not user input), so injection is not a current risk — but never pass user-supplied strings as column names. +**Column names are validated in `BaseRepository`.** `create()`, `where()`, and `update()` run each column name through `assertColumnName()` which enforces `^[a-zA-Z0-9_]+$` and throws `InvalidArgumentException` on violation. The table name is derived from the class name and is not validated — do not allow external input to influence which repository class is instantiated. -**Tenant DB names are derived from tenant IDs.** `{base}_{tenantId}` is used directly in a `CREATE DATABASE` statement via `PDO::exec`. Tenant IDs should be validated to be alphanumeric before onboarding. `TenantService::onboard()` does not enforce this — add a validation step if tenant IDs are user-supplied. +**Tenant DB names are derived from tenant IDs.** `{base}_{tenantId}` is used directly in a `CREATE DATABASE` statement via `PDO::exec`. `TenantService::onboard()` validates that the tenant ID matches `^[a-zA-Z0-9_-]+$` and throws if it does not. Direct calls to `TenantProvisioner::create()` bypass this check — do not call it with user-supplied input. --- ## Dependency Injection -**No DI container.** Components instantiate their dependencies with `new` internally. This makes unit testing require `Mockery::mock('overload:...')` with `#[RunInSeparateProcess]`. A DI container would allow constructor injection and standard mocking patterns. +**`Container` is available.** `src/Core/Container.php` supports `bind()`, `singleton()`, `instance()`, and `make()` with reflection-based auto-wiring. `Application` creates a container on construction, registers `TenantRepository`, `TenantProvisioner`, and `TenantService` as singletons, and exposes it via `getContainer()`. + +`TenantService` accepts optional constructor injection for `TenantRepository` and `TenantProvisioner` — pass them directly in tests instead of using `overload:` mocks. Extend this pattern to other classes as needed. diff --git a/run-generator.php b/run-generator.php deleted file mode 100644 index 217b4aa..0000000 --- a/run-generator.php +++ /dev/null @@ -1,20 +0,0 @@ -generate($config); - -echo "Generation complete.\n"; \ No newline at end of file diff --git a/test.php b/test.php deleted file mode 100644 index 9f5d532..0000000 --- a/test.php +++ /dev/null @@ -1,37 +0,0 @@ -boot([], false); - -$config = $app->getConfig(); -$tenantId = 'tenant_2'; - -$provisioner = new TenantProvisioner($config); - -try { - $provisioner->create($tenantId); -} catch (\Throwable $e) { - echo "Provisioning skipped or failed: " . $e->getMessage() . PHP_EOL; -} - -$app->boot([ - 'headers' => [ - 'X-Tenant-ID' => $tenantId - ] -], true); - - -$repo = new UserRepository($config); -$user = $repo->create([ - 'name' => 'Ved', - 'email' => 'ved@example.com' -]); - -echo "Inserted:\n"; -print_r($user); \ No newline at end of file From 5f8bdd7fbc5ebb008c34b526a3e5ddf09442a3cd Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 5 Jun 2026 09:51:17 +0530 Subject: [PATCH 15/16] Update ARCHITECTURE.md and CONCEPT.md to reflect all completed fixes Documents: DI container, column name validation, tenant ID validation, parameterised routes with FastRoute, response streaming, dry-run mode, parallel migrate:all-tenants workers, configurable subdomain min parts, FK and index generator support, TenantContext lifecycle enforcement, and updated CLI command table with key options. --- docs/ARCHITECTURE.md | 76 +++++++++++++++++++++++++++++++++----------- docs/CONCEPT.md | 23 ++++++++------ 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2266312..3b7a6ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -30,6 +30,7 @@ src/ │ ├── Core/ │ ├── Application.php — boot entry point +│ ├── Container.php — DI container with auto-wiring │ └── CoreSchemaManager.php — creates tenants table on every boot │ ├── Database/ @@ -84,9 +85,12 @@ Application::boot($context, $resolveTenant) ├── CoreSchemaManager::ensure() │ CREATE TABLE IF NOT EXISTS tenants (always, both strategies) │ + ├── Container::registerBindings() + │ singletons: TenantRepository, TenantProvisioner, TenantService + │ └── if $resolveTenant && tenancy.enabled: TenantResolverFactory::create() → resolver.resolve($context) - TenantContext::setTenantId() + TenantContext::setTenantId() ← throws LogicException if already set if strategy === database: TenantRepository::findByTenantId() — throws if not found or suspended ``` @@ -129,6 +133,7 @@ entity_forge_corp ← tenant DB: all application data ``` TenantService::onboard($tenantId, $name) + ├── validate $tenantId matches ^[a-zA-Z0-9_-]+$ — throws if invalid ├── TenantRepository::exists() — throws if already registered ├── TenantProvisioner::create() │ ├── CREATE DATABASE IF NOT EXISTS {base}_{tenantId} @@ -169,16 +174,32 @@ $request = new Request(headers: [...], query: [...], body: [...], method: 'POST' $request = Request::capture(); // reads $_SERVER, $_GET, $_POST, getallheaders() ``` +Route parameters extracted by the router are available via: + +```php +$request->param('id'); // single named parameter +$request->params(); // all parameters as array +``` + ### Response -Dual-mode: immutable builder for pipeline use, plus legacy `json()` for direct output. +Three output modes: immutable builder, streaming, and legacy direct-echo. ```php -// Pipeline / Router path — immutable, chainable +// Immutable builder — pipeline / router path $response = (new Response()) ->withJson(['id' => 1], 201) ->withHeader('X-Request-Id', $id); -$response->send(); // http_response_code + headers + echo +$response->send(); // http_response_code + headers + echo body + +// Streaming — caller echoes chunks, controls flush timing +(new Response()) + ->withStatus(200) + ->withHeader('Content-Type', 'text/csv') + ->stream(function (): void { + echo "id,name\n"; + flush(); + }); // Legacy direct-output (kept for backwards compatibility) (new Response())->json(['ok' => true], 200); @@ -200,16 +221,20 @@ Middleware is executed outermost-first. `$next` is a `callable(Request): Respons ### Router +Backed by `nikic/fast-route`. Supports exact paths and `{name}` parameter segments. + ```php $router = new Router(); -$router->get('/users', fn(Request $req): Response => ...); -$router->post('/users', fn(Request $req): Response => ...); -$router->put('/users/1', fn(Request $req): Response => ...); -$router->delete('/users/1', fn(Request $req): Response => ...); +$router->get('/users', fn(Request $req): Response => ...); +$router->post('/users', fn(Request $req): Response => ...); +$router->get('/users/{id}', fn(Request $req): Response => ...); // $req->param('id') +$router->delete('/users/{id}', fn(Request $req): Response => ...); -$response = $router->dispatch($request); // 404 if no match +$response = $router->dispatch($request); // 404 not found, 405 method not allowed ``` +Routes are matched in registration order — register exact paths before parameterised ones. + --- ## Repository Layer @@ -231,6 +256,8 @@ public function rollback(): void `resolveTableName()` derives the table name from the class name (`UserRepository` → `users`). Override `$this->table` in the subclass constructor to use a custom name. +Column names passed to `create()`, `where()`, and `update()` are validated against `^[a-zA-Z0-9_]+$` before SQL interpolation. `InvalidArgumentException` is thrown on violation. + --- ## Migration System @@ -251,9 +278,11 @@ When a new migration is added, existing tenant databases do not automatically re ```bash php bin/ef migrate:all-tenants +php bin/ef migrate:all-tenants --parallel 5 # run 5 concurrent workers +php bin/ef migrate:all-tenants --dry-run # preview without applying ``` -This iterates `TenantRepository::all()`, connects to each tenant DB, and runs `MigrationRunner::run()` against it. Failures per-tenant are reported but do not stop other tenants from being migrated. +Only active tenants (`status = 'active'`) are included. Suspended tenants are skipped. Workers are spawned via `symfony/process` — cross-platform on Linux, macOS, and Windows. Failures per-tenant are reported but do not stop other tenants from being migrated. --- @@ -264,10 +293,17 @@ Entity JSON schemas live in `config/entities/*.json`. A schema drives three buil ```json { "entity": "Order", - "fields": { "id": "int", "amount": "float", "status": "string" } + "fields": { "id": "int", "amount": "float", "status": "string" }, + "relations": { "belongsTo": { "User": "user_id" } }, + "indexes": [ + { "columns": ["status"] }, + { "columns": ["user_id", "status"], "unique": true } + ] } ``` +`relations.belongsTo` emits `CONSTRAINT fk_… FOREIGN KEY` clauses. `indexes` emits `INDEX` or `UNIQUE INDEX` clauses. Both sections are optional. + Output: - `app/Entity/Order.php` - `app/Repository/OrderRepository.php` @@ -279,14 +315,14 @@ Output: ## CLI Commands -| Command | Description | -|--------------------------|------------------------------------------------------| -| `generate ` | Generate entity + repository from JSON schema | -| `generate:all` | Generate all schemas in `config/entities/` (or `--config-dir`) | -| `migrate` | Run pending migrations on the main database | -| `migrate:rollback` | Roll back the last migration batch | -| `migrate:all-tenants` | Run pending migrations on every registered tenant DB | -| `tenant:create ` | Onboard a new tenant (`--name` for display name) | +| Command | Key options | Description | +|--------------------------|------------------------------------|------------------------------------------------------| +| `generate ` | | Generate entity + repository from JSON schema | +| `generate:all` | `--config-dir` | Generate all schemas in `config/entities/` | +| `migrate` | `--dry-run` | Run pending migrations on the main database | +| `migrate:rollback` | `--dry-run` | Roll back the last migration batch | +| `migrate:all-tenants` | `--dry-run`, `--parallel N` | Run pending migrations on every active tenant DB | +| `tenant:create ` | `--name` | Onboard a new tenant | --- @@ -294,6 +330,8 @@ Output: `TenantContext` is a static singleton. In PHP-FPM each process handles one request so static state is reset automatically. In long-lived workers (Swoole, RoadRunner, Laravel Octane), static state persists between requests. +`TenantContext::setTenantId()` throws `LogicException` if a tenant is already set. This turns a forgotten `RequestLifecycle::begin()` call into an immediate hard error rather than a silent wrong-tenant data leak. + Wrap each request loop iteration: ```php diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md index c80e9db..a175e76 100644 --- a/docs/CONCEPT.md +++ b/docs/CONCEPT.md @@ -1,7 +1,7 @@ # Core Concepts ## Application -The boot entry point. Loads and merges YAML config, validates required keys, runs `CoreSchemaManager`, and optionally resolves the current tenant. Pass `false` as the second argument to `boot()` to skip tenant resolution (used by CLI commands). +The boot entry point. Loads and merges YAML config, validates required keys, runs `CoreSchemaManager`, registers core bindings in the `Container`, and optionally resolves the current tenant. Pass `false` as the second argument to `boot()` to skip tenant resolution (used by CLI commands). Access the container via `getContainer()`. ## Entity A configuration-defined model described by a JSON schema in `config/entities/`. Drives generation of a PHP class, a repository, and SQL migration files. @@ -10,7 +10,7 @@ A configuration-defined model described by a JSON schema in `config/entities/`. A data-access layer that auto-scopes queries to the current tenant. Generated repositories extend `BaseRepository` and inherit CRUD methods, transaction support, and tenant scoping. Never reuse a repository instance across tenant switches. ## BaseRepository -The abstract parent for all repositories. Resolves its PDO connection via `TenantConnectionResolver`, injects `WHERE tenant_id = :tenant_id` for shared strategy, and exposes `create`, `findAll`, `findById`, `where`, `update`, `delete`, `beginTransaction`, `commit`, `rollback`. +The abstract parent for all repositories. Resolves its PDO connection via `TenantConnectionResolver`, injects `WHERE tenant_id = :tenant_id` for shared strategy, and exposes `create`, `findAll`, `findById`, `where`, `update`, `delete`, `beginTransaction`, `commit`, `rollback`. Column names passed to `create()`, `where()`, and `update()` are validated against `^[a-zA-Z0-9_]+$` — `InvalidArgumentException` is thrown if a column name fails. ## Tenant A logical boundary that isolates data between customers. Represented by a `tenant_id` string. Stored in the `tenants` table on the main database. @@ -22,7 +22,7 @@ A logical boundary that isolates data between customers. Represented by a `tenan **`database`** — each tenant has its own database named `{base_db}_{tenantId}`. Isolation is at the connection level; no `tenant_id` column is needed. ## TenantContext -A static singleton that holds the current tenant ID for the lifetime of a request. Use `TenantContext::setTenantId()`, `getTenantId()`, `hasTenantId()`, and `clear()`. In worker-mode PHP (Swoole, RoadRunner), call `RequestLifecycle::begin()` at the start of each request to reset it. +A static singleton that holds the current tenant ID for the lifetime of a request. Use `TenantContext::setTenantId()`, `getTenantId()`, `hasTenantId()`, and `clear()`. `setTenantId()` throws `LogicException` if a tenant ID is already set — call `RequestLifecycle::begin()` (which calls `clear()` first) at the start of each request in worker-mode PHP to avoid this. ## TenantConnectionResolver Resolves and caches the PDO connection for the current tenant. In `shared` mode it returns a connection to the main DB. In `database` mode it connects to `{base_db}_{tenantId}`. Connections are pooled in a static registry; call `flush()` to clear the cache. @@ -30,7 +30,7 @@ Resolves and caches the PDO connection for the current tenant. In `shared` mode ## TenantResolver Extracts a tenant ID from request context. Two implementations ship: - `HeaderTenantResolver` — reads a configurable header (default: `X-Tenant-ID`) -- `SubdomainTenantResolver` — extracts the leading subdomain from the host (e.g. `acme.example.com` → `acme`) +- `SubdomainTenantResolver` — extracts the leading subdomain from the host (e.g. `acme.example.com` → `acme`). Set `tenancy.subdomain_min_parts: 2` to support two-part hosts like `acme.io`. Configured via `tenancy.resolver` in `application.yaml`. Add new resolvers by implementing `TenantResolverInterface` and registering them in `TenantResolverFactory`. @@ -39,11 +39,13 @@ The intended entry point for tenant lifecycle operations: | Method | What it does | |---|---| -| `onboard($id, $name)` | Provisions DB + runs migrations + registers in tenants table | +| `onboard($id, $name)` | Validates ID format, provisions DB + runs migrations + registers in tenants table | | `suspend($id)` | Sets `status = 'suspended'`; blocks future boots | | `resume($id)` | Sets `status = 'active'` | | `offboard($id)` | Drops tenant DB (database strategy) + removes tenant record | +`onboard()` rejects tenant IDs that do not match `^[a-zA-Z0-9_-]+$`. `TenantRepository` and `TenantProvisioner` can be injected via constructor for testing without `overload:` mocks. + ## TenantProvisioner Low-level operator for the tenant database. `create()` runs `CREATE DATABASE` then executes all migrations. On migration failure it drops the partially-created database and re-throws — no orphaned databases. `drop()` runs `DROP DATABASE IF EXISTS`. @@ -54,19 +56,22 @@ Ensures the `tenants` table exists on the main database. Runs on every `Applicat A helper for long-lived PHP worker processes. `begin()` and `end()` both clear `TenantContext` and flush the connection cache in `TenantConnectionResolver`, preventing tenant state from leaking between requests. ## MigrationRunner -Executes `.up.sql` files in filename order, records each in a `migrations` table with a batch number, and skips already-executed ones. Rollback reverses all migrations from the last batch using paired `.down.sql` files. +Executes `.up.sql` files in filename order, records each in a `migrations` table with a batch number, and skips already-executed ones. Rollback reverses all migrations from the last batch using paired `.down.sql` files. Both `run()` and `rollback()` accept a `$dryRun` flag — in dry-run mode all writes are skipped and output is prefixed with `[DRY RUN]`. ## Pipeline An immutable middleware chain. Each `pipe()` returns a new `Pipeline` instance. `run(Request, callable): Response` processes the chain outermost-first, then calls the destination handler. Middleware implements `MiddlewareInterface::handle(Request, callable): Response`. ## Router -A simple path-based request dispatcher. Register handlers with `get()`, `post()`, `put()`, `delete()`. `dispatch(Request): Response` returns a `404 Not Found` response for unregistered routes. +A FastRoute-backed request dispatcher. Register handlers with `get()`, `post()`, `put()`, `delete()`. Paths support `{name}` parameter segments — extracted values are available via `$request->param('name')` and `$request->params()`. `dispatch(Request): Response` returns `404 Not Found` for unregistered routes and `405 Method Not Allowed` for method mismatches. Routes match in registration order. ## Request -An immutable value object representing an HTTP request. Constructed directly or captured from PHP superglobals via `Request::capture()`. Provides `header()`, `query()`, `body()`, `method()`, and `path()`. +An immutable value object representing an HTTP request. Constructed directly or captured from PHP superglobals via `Request::capture()`. Provides `header()`, `query()`, `body()`, `method()`, `path()`, `param()`, and `params()`. Route parameters are attached by the Router via `withParams()` before the handler is called. ## Response -A dual-mode HTTP response. The immutable builder path (`withJson`, `withStatus`, `withHeader`, `send`) is used by the Pipeline and Router. The legacy `json()` method echoes directly and is kept for backwards compatibility. +A tri-mode HTTP response. The immutable builder path (`withJson`, `withStatus`, `withHeader`, `send`) is used by the Pipeline and Router. `stream(callable)` sends headers then delegates output to the caller for chunk-by-chunk streaming without buffering. The legacy `json()` method echoes directly and is kept for backwards compatibility. + +## Container +A lightweight DI container. Supports `bind()` (new instance per call), `singleton()` (shared instance), `instance()` (pre-built object), and `make()`. `make()` resolves typed constructor parameters recursively via reflection (auto-wiring). `Application` creates one on construction, registers core singletons, and exposes it via `getContainer()`. ## ConfigLoader Loads one or more YAML files and merges them with `array_replace_recursive`. Files are merged in order; later files override earlier ones. `saas.yaml` is loaded first, `application.yaml` second. From ba6713ca0d1de32de8fca54e2d31518d01bcf417 Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 6 Jun 2026 09:41:51 +0530 Subject: [PATCH 16/16] Add tests for migration and tenant commands, including success and failure scenarios --- .../Console/MigrateAllTenantsCommandTest.php | 92 +++++++++++++++++++ tests/Console/MigrateCommandTest.php | 90 ++++++++++++++++++ tests/Console/RollbackCommandTest.php | 90 ++++++++++++++++++ tests/Console/TenantCreateCommandTest.php | 84 +++++++++++++++++ tests/Http/ResponseValueObjectTest.php | 9 ++ tests/Tenant/TenantConnectionResolverTest.php | 66 +++++++++++++ 6 files changed, 431 insertions(+) create mode 100644 tests/Console/MigrateCommandTest.php create mode 100644 tests/Console/RollbackCommandTest.php create mode 100644 tests/Console/TenantCreateCommandTest.php diff --git a/tests/Console/MigrateAllTenantsCommandTest.php b/tests/Console/MigrateAllTenantsCommandTest.php index 16d6871..c660358 100644 --- a/tests/Console/MigrateAllTenantsCommandTest.php +++ b/tests/Console/MigrateAllTenantsCommandTest.php @@ -3,11 +3,103 @@ namespace Tests\Console; use EntityForge\Console\MigrateAllTenantsCommand; +use EntityForge\Core\Application; +use EntityForge\Database\Connection; +use EntityForge\Database\MigrationRunner; +use Mockery; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Tester\CommandTester; class MigrateAllTenantsCommandTest extends TestCase { + protected function tearDown(): void + { + Mockery::close(); + } + + private function mockApp(string $strategy = 'database'): void + { + $config = [ + 'tenancy' => ['strategy' => $strategy, 'enabled' => false], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $app = Mockery::mock('overload:' . Application::class); + $app->allows('boot'); + $app->allows('getConfig')->andReturn($config); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_tenant_mode_migrates_single_tenant_successfully(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->with('database/migrations', false)->once(); + + $tester = new CommandTester(new MigrateAllTenantsCommand()); + $code = $tester->execute(['--tenant' => 'acme']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('acme', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_tenant_mode_dry_run_passes_flag_to_runner(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->with('database/migrations', true)->once(); + + $tester = new CommandTester(new MigrateAllTenantsCommand()); + $code = $tester->execute(['--tenant' => 'acme', '--dry-run' => true]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Dry run', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_tenant_mode_returns_failure_on_exception(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->andThrow(new \Exception('tenant db error')); + + $tester = new CommandTester(new MigrateAllTenantsCommand()); + $code = $tester->execute(['--tenant' => 'acme']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('tenant db error', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_returns_success_for_shared_strategy(): void + { + $this->mockApp('shared'); + + $tester = new CommandTester(new MigrateAllTenantsCommand()); + $code = $tester->execute([]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('database strategy', $tester->getDisplay()); + } public function test_command_name(): void { $this->assertSame('migrate:all-tenants', (new MigrateAllTenantsCommand())->getName()); diff --git a/tests/Console/MigrateCommandTest.php b/tests/Console/MigrateCommandTest.php new file mode 100644 index 0000000..729ddfc --- /dev/null +++ b/tests/Console/MigrateCommandTest.php @@ -0,0 +1,90 @@ + ['strategy' => 'shared', 'enabled' => false], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ], $extra); + + $app = Mockery::mock('overload:' . Application::class); + $app->allows('boot'); + $app->allows('getConfig')->andReturn($config); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_runs_migrations_successfully(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->with('database/migrations', false)->once(); + + $tester = new CommandTester(new MigrateCommand()); + $code = $tester->execute([]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('successfully', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_dry_run_passes_flag_to_runner(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->with('database/migrations', true)->once(); + + $tester = new CommandTester(new MigrateCommand()); + $code = $tester->execute(['--dry-run' => true]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Dry run', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_returns_failure_on_exception(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('run')->andThrow(new \Exception('migration error')); + + $tester = new CommandTester(new MigrateCommand()); + $code = $tester->execute([]); + + $this->assertSame(1, $code); + $this->assertStringContainsString('migration error', $tester->getDisplay()); + } +} \ No newline at end of file diff --git a/tests/Console/RollbackCommandTest.php b/tests/Console/RollbackCommandTest.php new file mode 100644 index 0000000..8f25a42 --- /dev/null +++ b/tests/Console/RollbackCommandTest.php @@ -0,0 +1,90 @@ + ['strategy' => 'shared', 'enabled' => false], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $app = Mockery::mock('overload:' . Application::class); + $app->allows('boot'); + $app->allows('getConfig')->andReturn($config); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_rolls_back_successfully(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('rollback')->with('database/migrations', false)->once(); + + $tester = new CommandTester(new RollbackCommand()); + $code = $tester->execute([]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('successful', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_dry_run_passes_flag_to_runner(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('rollback')->with('database/migrations', true)->once(); + + $tester = new CommandTester(new RollbackCommand()); + $code = $tester->execute(['--dry-run' => true]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Dry run', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_returns_failure_on_exception(): void + { + $this->mockApp(); + + Mockery::mock('overload:' . Connection::class); + + $runner = Mockery::mock('overload:' . MigrationRunner::class); + $runner->allows('rollback')->andThrow(new \Exception('rollback error')); + + $tester = new CommandTester(new RollbackCommand()); + $code = $tester->execute([]); + + $this->assertSame(1, $code); + $this->assertStringContainsString('rollback error', $tester->getDisplay()); + } +} \ No newline at end of file diff --git a/tests/Console/TenantCreateCommandTest.php b/tests/Console/TenantCreateCommandTest.php new file mode 100644 index 0000000..23d5a83 --- /dev/null +++ b/tests/Console/TenantCreateCommandTest.php @@ -0,0 +1,84 @@ + ['strategy' => 'database', 'enabled' => false], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $app = Mockery::mock('overload:' . Application::class); + $app->allows('boot'); + $app->allows('getConfig')->andReturn($config); + $app->allows('getContainer')->andReturn(null); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_onboards_tenant_successfully(): void + { + $this->mockApp(); + + $service = Mockery::mock('overload:' . TenantService::class); + $service->allows('onboard')->with('acme', 'acme')->once(); + + $tester = new CommandTester(new TenantCreateCommand()); + $code = $tester->execute(['tenantId' => 'acme']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('acme', $tester->getDisplay()); + $this->assertStringContainsString('successfully', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_uses_name_option_when_provided(): void + { + $this->mockApp(); + + $service = Mockery::mock('overload:' . TenantService::class); + $service->allows('onboard')->with('acme', 'Acme Corp')->once(); + + $tester = new CommandTester(new TenantCreateCommand()); + $code = $tester->execute(['tenantId' => 'acme', '--name' => 'Acme Corp']); + + $this->assertSame(0, $code); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_execute_returns_failure_on_exception(): void + { + $this->mockApp(); + + $service = Mockery::mock('overload:' . TenantService::class); + $service->allows('onboard')->andThrow(new \Exception('already exists')); + + $tester = new CommandTester(new TenantCreateCommand()); + $code = $tester->execute(['tenantId' => 'acme']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('already exists', $tester->getDisplay()); + } +} \ No newline at end of file diff --git a/tests/Http/ResponseValueObjectTest.php b/tests/Http/ResponseValueObjectTest.php index ee12834..3e05a14 100644 --- a/tests/Http/ResponseValueObjectTest.php +++ b/tests/Http/ResponseValueObjectTest.php @@ -84,4 +84,13 @@ public function test_stream_uses_response_status_and_headers(): void $this->assertSame(206, $response->getStatus()); $this->assertSame('bytes 0-999/5000', $response->getHeaders()['Content-Range']); } + + public function test_legacy_json_outputs_encoded_body(): void + { + ob_start(); + (new Response())->json(['ok' => true], 200); + $output = ob_get_clean(); + + $this->assertSame('{"ok":true}', $output); + } } diff --git a/tests/Tenant/TenantConnectionResolverTest.php b/tests/Tenant/TenantConnectionResolverTest.php index 735b2b4..b5e6d43 100644 --- a/tests/Tenant/TenantConnectionResolverTest.php +++ b/tests/Tenant/TenantConnectionResolverTest.php @@ -63,4 +63,70 @@ public function test_database_strategy_throws_when_tenant_not_set(): void ], ]); } + + #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] + #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] + public function test_database_strategy_returns_connection_for_tenant(): void + { + TenantContext::setTenantId('acme'); + + \Mockery::mock('overload:' . \EntityForge\Database\Connection::class); + + $result = TenantConnectionResolver::resolve([ + 'tenancy' => ['strategy' => 'database'], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]); + + $this->assertInstanceOf(\EntityForge\Database\Connection::class, $result); + + \Mockery::close(); + } + + #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] + #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] + public function test_shared_strategy_caches_connection(): void + { + \Mockery::mock('overload:' . \EntityForge\Database\Connection::class); + + $config = [ + 'tenancy' => ['strategy' => 'shared'], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $a = TenantConnectionResolver::resolve($config); + $b = TenantConnectionResolver::resolve($config); + + $this->assertSame($a, $b); + + \Mockery::close(); + } + + #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] + #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] + public function test_flush_clears_connection_cache(): void + { + \Mockery::mock('overload:' . \EntityForge\Database\Connection::class); + + $config = [ + 'tenancy' => ['strategy' => 'shared'], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $a = TenantConnectionResolver::resolve($config); + TenantConnectionResolver::flush(); + $b = TenantConnectionResolver::resolve($config); + + $this->assertNotSame($a, $b); + + \Mockery::close(); + } }