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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions config/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions config/entities/Post.json
Original file line number Diff line number Diff line change
@@ -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 }
]
}
8 changes: 6 additions & 2 deletions config/entities/User.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
]
}
9 changes: 0 additions & 9 deletions output/User.php

This file was deleted.

12 changes: 0 additions & 12 deletions output/UserRepository.php

This file was deleted.

79 changes: 68 additions & 11 deletions src/Console/GenerateAllCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<mixed>> $configs keyed by entity name
* @return array<string, array<mixed>>
*/
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');
Expand All @@ -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("<error>Invalid JSON: {$file}</error>");
continue;
}
$configs[$config['entity']] = $config;
}

try {
$entityName = $config['entity'] ?? 'Unknown';
try {
$configs = $this->sortByDependencies($configs);
} catch (\RuntimeException $e) {
$output->writeln("<error>{$e->getMessage()}</error>");
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("<info>Generated {$entityName}</info>");
} catch (\Throwable $e) {
$output->writeln("<error>{$e->getMessage()}</error>");
Expand Down
17 changes: 11 additions & 6 deletions src/Generator/Builder/MigrationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,28 @@

class MigrationBuilder
{
public function buildUp(EntitySchema $schema): string
/**
* @param array<string, string> $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) {
Expand All @@ -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',
Expand All @@ -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}";
Expand Down
7 changes: 5 additions & 2 deletions src/Generator/EntityGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public function __construct()
$this->writer = new FileWriter();
}

public function generate(array $config, bool $withMigration = false): void
/**
* @param array<string, string> $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);
Expand All @@ -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(
Expand Down
15 changes: 14 additions & 1 deletion src/Generator/Schema/EntitySchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? [];

Expand Down
51 changes: 51 additions & 0 deletions src/Tenant/Resolver/JwtTenantResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

namespace EntityForge\Tenant\Resolver;

use EntityForge\Tenant\TenantResolverInterface;
use Exception;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class JwtTenantResolver implements TenantResolverInterface
{
public function __construct(
private readonly string $publicKey,
private readonly string $algorithm = 'RS256',
private readonly string $tenantClaim = 'tenant_id'
) {}

/**
* @throws Exception
*/
public function resolve(array $context): string
{
$header = $context['headers']['Authorization']
?? $context['headers']['authorization']
?? null;

if ($header === null) {
throw new Exception('Authorization header missing.');
}

if (!str_starts_with($header, 'Bearer ')) {
throw new Exception('Authorization header must use Bearer scheme.');
}

$token = substr($header, 7);

try {
$decoded = JWT::decode($token, new Key($this->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];
}
}
6 changes: 6 additions & 0 deletions src/Tenant/TenantResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace EntityForge\Tenant;

use EntityForge\Tenant\Resolver\HeaderTenantResolver;
use EntityForge\Tenant\Resolver\JwtTenantResolver;
use EntityForge\Tenant\Resolver\SubdomainTenantResolver;
use Exception;

Expand All @@ -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}"),
};
}
Expand Down
Loading
Loading