From d287e26b65d50ab2a2449dadbc44a23519e64396 Mon Sep 17 00:00:00 2001 From: vedavith Date: Mon, 8 Jun 2026 10:56:26 +0530 Subject: [PATCH 1/5] Implement topological sort for entity generation and enhance primary key handling - Add `sortByDependencies` to ensure entities with dependencies are generated in the correct order. - Detect and report circular dependencies during sorting. - Enhance handling of primary keys in `EntityGenerator` and `MigrationBuilder` to support entities with non-standard primary key names. - Update `EntitySchema` to expose primary key details. --- src/Console/GenerateAllCommand.php | 79 +++++++++++++++++++--- src/Generator/Builder/MigrationBuilder.php | 17 +++-- src/Generator/EntityGenerator.php | 7 +- src/Generator/Schema/EntitySchema.php | 15 +++- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/src/Console/GenerateAllCommand.php b/src/Console/GenerateAllCommand.php index d017fd4..8f15e01 100644 --- a/src/Console/GenerateAllCommand.php +++ b/src/Console/GenerateAllCommand.php @@ -19,6 +19,51 @@ protected function configure(): void ->addOption('config-dir', null, InputOption::VALUE_OPTIONAL, 'Path to entity config directory', 'config/entities'); } + /** + * Topological sort so referenced entities are generated before their dependents. + * Throws if a circular dependency is detected. + * + * @param array> $configs keyed by entity name + * @return array> + */ + private function sortByDependencies(array $configs): array + { + $deps = []; + foreach ($configs as $name => $config) { + $deps[$name] = array_keys($config['relations']['belongsTo'] ?? []); + } + + $sorted = []; + $visited = []; + + $visit = function (string $name) use (&$visit, &$sorted, &$visited, $configs, $deps): void { + if (isset($visited[$name])) { + if ($visited[$name] === 'pending') { + throw new \RuntimeException("Circular dependency detected involving entity: {$name}"); + } + return; + } + + $visited[$name] = 'pending'; + + foreach ($deps[$name] ?? [] as $dep) { + if (!isset($configs[$dep])) { + continue; // referenced entity not in this batch — skip + } + $visit($dep); + } + + $visited[$name] = 'done'; + $sorted[$name] = $configs[$name]; + }; + + foreach (array_keys($configs) as $name) { + $visit($name); + } + + return $sorted; + } + protected function execute(InputInterface $input, OutputInterface $output): int { $configDir = $input->getOption('config-dir'); @@ -35,28 +80,40 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - // Ensure deterministic order sort($files); - $withMigration = $input->getOption('migration'); - - // IMPORTANT: single generator instance - $generator = new EntityGenerator(); - - + $configs = []; foreach ($files as $file) { $config = json_decode(file_get_contents($file), true); - if (!$config) { $output->writeln("Invalid JSON: {$file}"); continue; } + $configs[$config['entity']] = $config; + } - try { - $entityName = $config['entity'] ?? 'Unknown'; + try { + $configs = $this->sortByDependencies($configs); + } catch (\RuntimeException $e) { + $output->writeln("{$e->getMessage()}"); + return Command::FAILURE; + } + + $pkMap = []; + foreach ($configs as $entityName => $config) { + $fields = $config['fields'] ?? []; + $candidate = strtolower($entityName) . '_id'; + $pkMap[$entityName] = isset($fields['id']) ? 'id' : (isset($fields[$candidate]) ? $candidate : 'id'); + } + + $withMigration = $input->getOption('migration'); - $generator->generate($config, $withMigration); + // IMPORTANT: single generator instance + $generator = new EntityGenerator(); + foreach ($configs as $entityName => $config) { + try { + $generator->generate($config, $withMigration, $pkMap); $output->writeln("Generated {$entityName}"); } catch (\Throwable $e) { $output->writeln("{$e->getMessage()}"); diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index e35a41e..4471b1b 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -6,23 +6,28 @@ class MigrationBuilder { - public function buildUp(EntitySchema $schema): string + /** + * @param array $pkMap entity name → primary key column, used to resolve FK targets + */ + public function buildUp(EntitySchema $schema, array $pkMap = []): string { $table = strtolower($schema->getEntityName()) . 's'; $fields = $schema->getFields(); $relations = $schema->getRelations(); $indexes = $schema->getIndexes(); + $pk = $schema->getPrimaryKey(); $definitions = []; foreach ($fields as $name => $type) { - $definitions[] = $this->mapColumn($name, $type); + $definitions[] = $this->mapColumn($name, $type, $pk ?? ''); } foreach ($relations['belongsTo'] ?? [] as $refEntity => $fkColumn) { $refTable = strtolower($refEntity) . 's'; + $refPk = $pkMap[$refEntity] ?? 'id'; $constraint = "fk_{$table}_{$fkColumn}"; - $definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}(id)"; + $definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}({$refPk})"; } foreach ($indexes as $index) { @@ -49,7 +54,7 @@ public function buildDown(EntitySchema $schema): string return "DROP TABLE IF EXISTS {$table};"; } - private function mapColumn(string $name, string $type): string + private function mapColumn(string $name, string $type, string $pk = 'id'): string { $sqlType = match ($type) { 'int' => 'INT', @@ -59,8 +64,8 @@ private function mapColumn(string $name, string $type): string default => 'TEXT' }; - if ($name === 'id') { - return "id INT PRIMARY KEY AUTO_INCREMENT"; + if ($name === $pk) { + return "{$pk} INT PRIMARY KEY AUTO_INCREMENT"; } return "{$name} {$sqlType}"; diff --git a/src/Generator/EntityGenerator.php b/src/Generator/EntityGenerator.php index 05e6c34..b97f8b8 100644 --- a/src/Generator/EntityGenerator.php +++ b/src/Generator/EntityGenerator.php @@ -29,7 +29,10 @@ public function __construct() $this->writer = new FileWriter(); } - public function generate(array $config, bool $withMigration = false): void + /** + * @param array $pkMap entity name → primary key column for FK resolution + */ + public function generate(array $config, bool $withMigration = false, array $pkMap = []): void { // Validate config $this->validator->validate($config); @@ -49,7 +52,7 @@ public function generate(array $config, bool $withMigration = false): void if ($withMigration) { $baseName = $this->generateMigrationBaseName($entityName); - $upSql = $this->migrationBuilder->buildUp($schema); + $upSql = $this->migrationBuilder->buildUp($schema, $pkMap); $downSql = $this->migrationBuilder->buildDown($schema); $this->writer->write( diff --git a/src/Generator/Schema/EntitySchema.php b/src/Generator/Schema/EntitySchema.php index 10e1ddf..14de3a3 100644 --- a/src/Generator/Schema/EntitySchema.php +++ b/src/Generator/Schema/EntitySchema.php @@ -26,7 +26,20 @@ public function hasTimestamps(): bool return $this->config['timestamps'] ?? false; } - public function getFields(): array + public function getPrimaryKey(): ?string + { + $fields = $this->config['fields'] ?? []; + if (isset($fields['id'])) { + return 'id'; + } + $candidate = strtolower($this->config['entity']) . '_id'; + if (isset($fields[$candidate])) { + return $candidate; + } + return null; + } + +public function getFields(): array { $fields = $this->config['fields'] ?? []; From 4b96548eddb6a382f62d2def6db02df5d33d1ea9 Mon Sep 17 00:00:00 2001 From: vedavith Date: Mon, 8 Jun 2026 21:00:15 +0530 Subject: [PATCH 2/5] Add JwtTenantResolver for Bearer token tenant resolution Extracts tenant_id (or a configurable claim) from a signed JWT in the Authorization header. Registered in TenantResolverFactory as resolver type 'jwt'. Configurable via tenancy.jwt_public_key, jwt_algorithm (default RS256), and jwt_tenant_claim (default tenant_id). 8 tests covering happy path, custom claim, case-insensitive header, missing header, wrong scheme, invalid token, missing claim, and expiry. --- composer.json | 3 +- output/User.php | 9 -- output/UserRepository.php | 12 -- src/Tenant/Resolver/JwtTenantResolver.php | 51 +++++++ src/Tenant/TenantResolverFactory.php | 6 + .../Tenant/Resolver/JwtTenantResolverTest.php | 144 ++++++++++++++++++ 6 files changed, 203 insertions(+), 22 deletions(-) delete mode 100644 output/User.php delete mode 100644 output/UserRepository.php create mode 100644 src/Tenant/Resolver/JwtTenantResolver.php create mode 100644 tests/Tenant/Resolver/JwtTenantResolverTest.php diff --git a/composer.json b/composer.json index 88feeab..4bd7de7 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "symfony/console": "^8.1", "ext-pdo": "*", "nikic/fast-route": "^1.3", - "symfony/process": "^8.1" + "symfony/process": "^8.1", + "firebase/php-jwt": "^7.0" }, "require-dev": { "phpunit/phpunit": "^13.0", diff --git a/output/User.php b/output/User.php deleted file mode 100644 index 91bb5c1..0000000 --- a/output/User.php +++ /dev/null @@ -1,9 +0,0 @@ -applyTenantScope($data); - return $data; - } -} \ No newline at end of file diff --git a/src/Tenant/Resolver/JwtTenantResolver.php b/src/Tenant/Resolver/JwtTenantResolver.php new file mode 100644 index 0000000..f638627 --- /dev/null +++ b/src/Tenant/Resolver/JwtTenantResolver.php @@ -0,0 +1,51 @@ +publicKey, $this->algorithm)); + } catch (\Throwable $e) { + throw new Exception('Invalid JWT: ' . $e->getMessage(), 0, $e); + } + + $payload = (array) $decoded; + + if (empty($payload[$this->tenantClaim])) { + throw new Exception("JWT is missing the '{$this->tenantClaim}' claim."); + } + + return (string) $payload[$this->tenantClaim]; + } +} diff --git a/src/Tenant/TenantResolverFactory.php b/src/Tenant/TenantResolverFactory.php index 30631eb..ea94d4a 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\JwtTenantResolver; use EntityForge\Tenant\Resolver\SubdomainTenantResolver; use Exception; @@ -22,6 +23,11 @@ public static function create(array $config): TenantResolverInterface (int) ($config['tenancy']['subdomain_depth'] ?? 0), (int) ($config['tenancy']['subdomain_min_parts'] ?? 3) ), + 'jwt' => new JwtTenantResolver( + $config['tenancy']['jwt_public_key'] ?? '', + $config['tenancy']['jwt_algorithm'] ?? 'RS256', + $config['tenancy']['jwt_tenant_claim'] ?? 'tenant_id' + ), default => throw new Exception("Unsupported tenant resolver type: {$resolverType}"), }; } diff --git a/tests/Tenant/Resolver/JwtTenantResolverTest.php b/tests/Tenant/Resolver/JwtTenantResolverTest.php new file mode 100644 index 0000000..2b23c01 --- /dev/null +++ b/tests/Tenant/Resolver/JwtTenantResolverTest.php @@ -0,0 +1,144 @@ +privateKey, 'RS256'); + } + + private function resolver(?string $claim = null): JwtTenantResolver + { + return $claim + ? new JwtTenantResolver($this->publicKey, 'RS256', $claim) + : new JwtTenantResolver($this->publicKey); + } + + public function test_resolves_tenant_id_from_bearer_token(): void + { + $token = $this->makeToken(['tenant_id' => 'acme', 'exp' => time() + 60]); + + $tenantId = $this->resolver()->resolve([ + 'headers' => ['Authorization' => 'Bearer ' . $token], + ]); + + $this->assertSame('acme', $tenantId); + } + + public function test_resolves_custom_claim(): void + { + $token = $this->makeToken(['org' => 'corp', 'exp' => time() + 60]); + + $tenantId = $this->resolver('org')->resolve([ + 'headers' => ['Authorization' => 'Bearer ' . $token], + ]); + + $this->assertSame('corp', $tenantId); + } + + public function test_resolves_case_insensitive_authorization_header(): void + { + $token = $this->makeToken(['tenant_id' => 'acme', 'exp' => time() + 60]); + + $tenantId = $this->resolver()->resolve([ + 'headers' => ['authorization' => 'Bearer ' . $token], + ]); + + $this->assertSame('acme', $tenantId); + } + + public function test_throws_when_authorization_header_missing(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Authorization header missing/'); + + $this->resolver()->resolve(['headers' => []]); + } + + public function test_throws_when_bearer_scheme_absent(): void + { + $token = $this->makeToken(['tenant_id' => 'acme', 'exp' => time() + 60]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Bearer scheme/'); + + $this->resolver()->resolve(['headers' => ['Authorization' => $token]]); + } + + public function test_throws_when_token_is_invalid(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Invalid JWT/'); + + $this->resolver()->resolve(['headers' => ['Authorization' => 'Bearer not.a.jwt']]); + } + + public function test_throws_when_tenant_claim_missing_from_payload(): void + { + $token = $this->makeToken(['sub' => 'user_1', 'exp' => time() + 60]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches("/tenant_id/"); + + $this->resolver()->resolve(['headers' => ['Authorization' => 'Bearer ' . $token]]); + } + + public function test_throws_when_token_is_expired(): void + { + $token = $this->makeToken(['tenant_id' => 'acme', 'exp' => time() - 10]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Invalid JWT/'); + + $this->resolver()->resolve(['headers' => ['Authorization' => 'Bearer ' . $token]]); + } +} From feae816ce82589d61f0d12bec915f0566cf9d76a Mon Sep 17 00:00:00 2001 From: vedavith Date: Mon, 8 Jun 2026 22:26:33 +0530 Subject: [PATCH 3/5] Refactor tenancy configuration in application.yaml for clarity and organization --- config/application.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/config/application.yaml b/config/application.yaml index db917b1..6ca006a 100755 --- a/config/application.yaml +++ b/config/application.yaml @@ -3,9 +3,13 @@ application: tenancy: enabled: true - resolver: header + strategy: database # shared | database + resolver: header # header | subdomain | jwt header_key: X-Tenant-ID - strategy: database # shared | database + # resolver: jwt + # jwt_public_key: /path/to/public.pem + # jwt_algorithm: RS256 + # jwt_tenant_claim: tenant_id database: driver: mysql From 5328ec6a3eba69dceb65998a10a3979d26013d2d Mon Sep 17 00:00:00 2001 From: vedavith Date: Mon, 8 Jun 2026 22:29:52 +0530 Subject: [PATCH 4/5] Add Post entity schema and update User entity config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User.json: rename id→user_id, add unique email index and name index. Post.json: new entity with belongsTo User relation and composite indexes. application.yaml: switch active resolver back to header, leave JWT config commented for reference. --- config/entities/Post.json | 21 +++++++++++++++++++++ config/entities/User.json | 8 ++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 config/entities/Post.json diff --git a/config/entities/Post.json b/config/entities/Post.json new file mode 100644 index 0000000..e0da1d9 --- /dev/null +++ b/config/entities/Post.json @@ -0,0 +1,21 @@ +{ + "entity": "Post", + "multiTenant": true, + "timestamps": true, + "fields": { + "id": "int", + "user_id": "int", + "title": "string", + "body": "string" + }, + + "relations": { + "belongsTo": { + "User": "user_id" + } + }, + "indexes": [ + { "columns": ["user_id"] }, + { "columns": ["user_id", "title"], "unique": true } + ] +} \ No newline at end of file diff --git a/config/entities/User.json b/config/entities/User.json index e449216..de9e2c7 100644 --- a/config/entities/User.json +++ b/config/entities/User.json @@ -3,8 +3,12 @@ "multiTenant": true, "timestamps": true, "fields": { - "id": "int", + "user_id": "int", "name": "string", "email": "string" - } + }, + "indexes": [ + { "columns": ["email"], "unique": true }, + { "columns": ["name"] } + ] } \ No newline at end of file From ccd05163e676972f9090f70de2e28d6602939fca Mon Sep 17 00:00:00 2001 From: vedavith Date: Mon, 8 Jun 2026 22:33:41 +0530 Subject: [PATCH 5/5] Fix TenantResolverFactoryTest after jwt resolver was added The unsupported-type test was using 'jwt' as the unknown resolver, which is now valid. Changed to 'unknown' so the test correctly exercises the default throw branch. --- tests/Tenant/TenantResolverFactoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tenant/TenantResolverFactoryTest.php b/tests/Tenant/TenantResolverFactoryTest.php index 06cb68e..550b851 100644 --- a/tests/Tenant/TenantResolverFactoryTest.php +++ b/tests/Tenant/TenantResolverFactoryTest.php @@ -62,6 +62,6 @@ public function test_throws_for_unsupported_resolver_type(): void $this->expectException(Exception::class); $this->expectExceptionMessageMatches('/Unsupported tenant resolver type/'); - TenantResolverFactory::create(['tenancy' => ['resolver' => 'jwt']]); + TenantResolverFactory::create(['tenancy' => ['resolver' => 'unknown']]); } }