From 656754d94f596bb974f3b901f266e81e64e8d6f0 Mon Sep 17 00:00:00 2001 From: vedavith Date: Fri, 19 Jun 2026 12:14:42 +0530 Subject: [PATCH 1/6] Fix CLI autoload path, console config resolution, and migration generation bugs - bin/ef: fall back to project-root autoload.php when installed as a Composer package (the old path resolved inside the package and failed) - Console commands: replace hardcoded __DIR__ config path with getcwd()/config so migrate/rollback/tenant:create resolve against the consuming project - MigrationBuilder: always prepend id and tenant_id columns; add FK column definitions before CONSTRAINT lines; map datetime type to DATETIME; skip id/tenant_id/FK columns from the fields loop to prevent duplicates - MigrationBuilderTest: update assertions and add coverage for new behaviour --- bin/ef | 9 ++- src/Console/MigrateAllTenantsCommand.php | 2 +- src/Console/MigrateCommand.php | 2 +- src/Console/RollbackCommand.php | 2 +- src/Console/TenantCreateCommand.php | 2 +- src/Generator/Builder/MigrationBuilder.php | 33 +++++---- .../Builder/MigrationBuilderTest.php | 68 +++++++++++++------ 7 files changed, 79 insertions(+), 39 deletions(-) diff --git a/bin/ef b/bin/ef index b5a3da7..e9545bd 100755 --- a/bin/ef +++ b/bin/ef @@ -1,7 +1,14 @@ #!/usr/bin/env php boot([], false); $config = $app->getConfig(); diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php index c500170..7d0f0ee 100644 --- a/src/Console/MigrateCommand.php +++ b/src/Console/MigrateCommand.php @@ -26,7 +26,7 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $app = new Application(__DIR__ . '/../../config'); + $app = new Application(getcwd() . '/config'); $app->boot([], false); $config = $app->getConfig()['database']; diff --git a/src/Console/RollbackCommand.php b/src/Console/RollbackCommand.php index b4a40dd..8cc33e4 100644 --- a/src/Console/RollbackCommand.php +++ b/src/Console/RollbackCommand.php @@ -26,7 +26,7 @@ protected function configure(): void */ protected function execute(InputInterface $input, OutputInterface $output): int { - $app = new Application(__DIR__ . '/../../config'); + $app = new Application(getcwd() . '/config'); $app->boot([], false); $db = $app->getConfig()['database']; diff --git a/src/Console/TenantCreateCommand.php b/src/Console/TenantCreateCommand.php index b83f2c7..d889c82 100644 --- a/src/Console/TenantCreateCommand.php +++ b/src/Console/TenantCreateCommand.php @@ -30,7 +30,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $tenantId = $input->getArgument('tenantId'); $name = $input->getOption('name') ?? $tenantId; - $app = new Application(__DIR__ . '/../../config'); + $app = new Application(getcwd() . '/config'); $app->boot([], false); $service = new TenantService($app->getConfig()); diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index 4471b1b..fd7673a 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -15,18 +15,26 @@ public function buildUp(EntitySchema $schema, array $pkMap = []): string $fields = $schema->getFields(); $relations = $schema->getRelations(); $indexes = $schema->getIndexes(); - $pk = $schema->getPrimaryKey(); - $definitions = []; + $fkColumns = array_values($relations['belongsTo'] ?? []); + + $definitions = [ + 'id INT AUTO_INCREMENT PRIMARY KEY', + 'tenant_id VARCHAR(255) NOT NULL', + ]; foreach ($fields as $name => $type) { - $definitions[] = $this->mapColumn($name, $type, $pk ?? ''); + if ($name === 'id' || $name === 'tenant_id' || in_array($name, $fkColumns, true)) { + continue; + } + $definitions[] = $this->mapColumn($name, $type); } foreach ($relations['belongsTo'] ?? [] as $refEntity => $fkColumn) { $refTable = strtolower($refEntity) . 's'; $refPk = $pkMap[$refEntity] ?? 'id'; $constraint = "fk_{$table}_{$fkColumn}"; + $definitions[] = "{$fkColumn} INT NOT NULL"; $definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}({$refPk})"; } @@ -54,20 +62,17 @@ public function buildDown(EntitySchema $schema): string return "DROP TABLE IF EXISTS {$table};"; } - private function mapColumn(string $name, string $type, string $pk = 'id'): string + private function mapColumn(string $name, string $type): string { $sqlType = match ($type) { - 'int' => 'INT', - 'string' => 'VARCHAR(255)', - 'float' => 'FLOAT', - 'bool' => 'BOOLEAN', - default => 'TEXT' + 'int' => 'INT', + 'string' => 'VARCHAR(255)', + 'float' => 'FLOAT', + 'bool' => 'BOOLEAN', + 'datetime' => 'DATETIME', + default => 'TEXT' }; - if ($name === $pk) { - return "{$pk} INT PRIMARY KEY AUTO_INCREMENT"; - } - return "{$name} {$sqlType}"; } -} \ No newline at end of file +} diff --git a/tests/Generator/Builder/MigrationBuilderTest.php b/tests/Generator/Builder/MigrationBuilderTest.php index 14a3af3..b28ec5e 100644 --- a/tests/Generator/Builder/MigrationBuilderTest.php +++ b/tests/Generator/Builder/MigrationBuilderTest.php @@ -15,9 +15,9 @@ protected function setUp(): void $this->builder = new MigrationBuilder(); } - private function schema(array $fields, string $entity = 'Product'): EntitySchema + private function schema(array $fields, string $entity = 'Product', array $extra = []): EntitySchema { - return new EntitySchema(['entity' => $entity, 'fields' => $fields]); + return new EntitySchema(array_merge(['entity' => $entity, 'fields' => $fields], $extra)); } public function test_build_up_creates_correct_table_name(): void @@ -27,6 +27,27 @@ public function test_build_up_creates_correct_table_name(): void $this->assertStringContainsString('CREATE TABLE products', $sql); } + public function test_build_up_always_includes_id_primary_key(): void + { + $sql = $this->builder->buildUp($this->schema([])); + + $this->assertStringContainsString('id INT AUTO_INCREMENT PRIMARY KEY', $sql); + } + + public function test_build_up_always_includes_tenant_id(): void + { + $sql = $this->builder->buildUp($this->schema([])); + + $this->assertStringContainsString('tenant_id VARCHAR(255) NOT NULL', $sql); + } + + public function test_build_up_does_not_duplicate_id_when_in_schema_fields(): void + { + $sql = $this->builder->buildUp($this->schema(['id' => 'int'])); + + $this->assertEquals(1, substr_count($sql, 'id INT')); + } + public function test_build_up_maps_int_to_int_column(): void { $sql = $this->builder->buildUp($this->schema(['count' => 'int'])); @@ -55,20 +76,11 @@ public function test_build_up_maps_bool_to_boolean(): void $this->assertStringContainsString('active BOOLEAN', $sql); } - public function test_build_up_maps_id_to_primary_key(): void - { - $sql = $this->builder->buildUp($this->schema(['id' => 'int'])); - - $this->assertStringContainsString('id INT PRIMARY KEY AUTO_INCREMENT', $sql); - } - - public function test_build_up_unknown_type_maps_to_text(): void + public function test_build_up_maps_datetime_to_datetime(): void { - // unknown type falls through match to TEXT via default - // float is actually mapped, but testing the column presence - $sql = $this->builder->buildUp($this->schema(['note' => 'string'])); + $sql = $this->builder->buildUp($this->schema(['date' => 'datetime'])); - $this->assertStringContainsString('note', $sql); + $this->assertStringContainsString('date DATETIME', $sql); } public function test_build_down_drops_correct_table(): void @@ -85,25 +97,39 @@ 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 + public function test_build_up_emits_fk_column_and_constraint(): void { $schema = new EntitySchema([ 'entity' => 'Order', - 'fields' => ['id' => 'int', 'user_id' => 'int'], + 'fields' => [], 'relations' => ['belongsTo' => ['User' => 'user_id']], ]); $sql = $this->builder->buildUp($schema); + $this->assertStringContainsString('user_id INT NOT NULL', $sql); $this->assertStringContainsString('CONSTRAINT fk_orders_user_id', $sql); $this->assertStringContainsString('FOREIGN KEY (user_id) REFERENCES users(id)', $sql); } + public function test_build_up_does_not_duplicate_fk_column_when_in_schema_fields(): void + { + $schema = new EntitySchema([ + 'entity' => 'Order', + 'fields' => ['user_id' => 'int'], + 'relations' => ['belongsTo' => ['User' => 'user_id']], + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertEquals(1, substr_count($sql, 'user_id INT')); + } + public function test_build_up_emits_index(): void { $schema = new EntitySchema([ 'entity' => 'Order', - 'fields' => ['id' => 'int', 'status' => 'string'], + 'fields' => ['status' => 'string'], 'indexes' => [['columns' => ['status']]], ]); @@ -116,7 +142,7 @@ public function test_build_up_emits_unique_index(): void { $schema = new EntitySchema([ 'entity' => 'User', - 'fields' => ['id' => 'int', 'email' => 'string'], + 'fields' => ['email' => 'string'], 'indexes' => [['columns' => ['email'], 'unique' => true]], ]); @@ -129,7 +155,7 @@ public function test_build_up_emits_composite_index(): void { $schema = new EntitySchema([ 'entity' => 'Order', - 'fields' => ['id' => 'int', 'user_id' => 'int', 'status' => 'string'], + 'fields' => ['status' => 'string'], 'indexes' => [['columns' => ['user_id', 'status']]], ]); @@ -142,13 +168,15 @@ 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'], + 'fields' => [], 'relations' => ['belongsTo' => ['Order' => 'order_id', 'Product' => 'product_id']], 'indexes' => [['columns' => ['order_id', 'product_id'], 'unique' => true]], ]); $sql = $this->builder->buildUp($schema); + $this->assertStringContainsString('order_id INT NOT NULL', $sql); + $this->assertStringContainsString('product_id INT NOT NULL', $sql); $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); From 1dcfcdafb963c3835ce4ba4795d8e631ce859909 Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 27 Jun 2026 09:43:44 +0530 Subject: [PATCH 2/6] Fix migration generation, table naming, strategy-awareness, and injectable output - MigrationBuilder: always prepend id PK; conditionally add tenant_id based on strategy (shared/database); emit FK column definitions before CONSTRAINT; use Str::toTableName() for snake_case plural table names - EntitySchema: remove isMultiTenant(); remove tenant_id injection from getFields(); timestamps use datetime type; getPrimaryKey() always returns 'id' - EntityGenerator: accept $strategy param; pass to MigrationBuilder; use Str::toTableName() for migration file basenames - EntityBuilder: delegate pluralisation to Str::pluralize() - Support/Str: new utility with toTableName() (snake_case + pluralize) and pluralize() - BaseRepository: create() returns lastInsertId(); resolveTableName() uses Str::toTableName() - MigrationRunner: injectable output callable; replaces all echo with ($this->output)() - GenerateAllCommand/GenerateCommand: load strategy from config/application.yaml; pass to generator; simplify pkMap to array_fill_keys - MigrateCommand/RollbackCommand/MigrateAllTenantsCommand: inject Symfony output callable into MigrationRunner - Tests: remove dead isMultiTenant tests; add database strategy test; update OrderItem table name assertion; add lastInsertId assertions to create() tests --- src/Console/GenerateAllCommand.php | 15 ++-- src/Console/GenerateCommand.php | 10 ++- src/Console/MigrateAllTenantsCommand.php | 2 +- src/Console/MigrateCommand.php | 2 +- src/Console/RollbackCommand.php | 2 +- src/Database/MigrationRunner.php | 47 +++++----- src/Generator/Builder/EntityBuilder.php | 36 +++----- src/Generator/Builder/MigrationBuilder.php | 21 +++-- src/Generator/EntityGenerator.php | 54 +++++------ src/Generator/Schema/EntitySchema.php | 28 ++---- src/Repository/BaseRepository.php | 90 ++++--------------- src/Support/Str.php | 23 +++++ .../Builder/MigrationBuilderTest.php | 9 +- tests/Generator/Schema/EntitySchemaTest.php | 18 ---- tests/Repository/BaseRepositoryTest.php | 4 + 15 files changed, 147 insertions(+), 214 deletions(-) create mode 100644 src/Support/Str.php diff --git a/src/Console/GenerateAllCommand.php b/src/Console/GenerateAllCommand.php index f4bba0f..5554921 100644 --- a/src/Console/GenerateAllCommand.php +++ b/src/Console/GenerateAllCommand.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Yaml\Yaml; class GenerateAllCommand extends Command { @@ -99,11 +100,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int 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'); + $pkMap = array_fill_keys(array_keys($configs), 'id'); + + $strategy = 'shared'; + $appYaml = getcwd() . '/config/application.yaml'; + if (file_exists($appYaml)) { + $yaml = Yaml::parseFile($appYaml); + $strategy = $yaml['tenancy']['strategy'] ?? 'shared'; } $withMigration = $input->getOption('migration'); @@ -113,7 +116,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($configs as $entityName => $config) { try { - $generator->generate($config, $withMigration, $pkMap); + $generator->generate($config, $withMigration, $pkMap, $strategy); $output->writeln("Generated {$entityName}"); } catch (\Throwable $e) { $output->writeln("{$e->getMessage()}"); diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php index 03a7db1..1a68117 100644 --- a/src/Console/GenerateCommand.php +++ b/src/Console/GenerateCommand.php @@ -9,6 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Yaml\Yaml; class GenerateCommand extends Command { @@ -42,10 +43,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + $strategy = 'shared'; + $appYaml = getcwd() . '/config/application.yaml'; + if (file_exists($appYaml)) { + $yaml = Yaml::parseFile($appYaml); + $strategy = $yaml['tenancy']['strategy'] ?? 'shared'; + } + $generator = new EntityGenerator(); try { - $generator->generate($config, $withMigration); + $generator->generate($config, $withMigration, [], $strategy); $output->writeln("Generated {$entity} successfully."); } catch (\Throwable $e) { $output->writeln("Error generating {$entity}: {$e->getMessage()}"); diff --git a/src/Console/MigrateAllTenantsCommand.php b/src/Console/MigrateAllTenantsCommand.php index f1de428..0c67003 100644 --- a/src/Console/MigrateAllTenantsCommand.php +++ b/src/Console/MigrateAllTenantsCommand.php @@ -109,7 +109,7 @@ private function migrateTenant( $dbConfig['database'] = $dbConfig['database'] . '_' . $tenantId; try { - $runner = new MigrationRunner(new Connection($dbConfig)); + $runner = new MigrationRunner(new Connection($dbConfig), fn(string $msg) => $output->writeln($msg)); $runner->run('database/migrations', $dryRun); $output->writeln($dryRun ? "Dry run: {$tenantId}" diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php index 7d0f0ee..f4d7f5f 100644 --- a/src/Console/MigrateCommand.php +++ b/src/Console/MigrateCommand.php @@ -33,7 +33,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $dryRun = (bool) $input->getOption('dry-run'); $connection = new Connection($config); - $runner = new MigrationRunner($connection); + $runner = new MigrationRunner($connection, fn(string $msg) => $output->writeln($msg)); try { $runner->run('database/migrations', $dryRun); diff --git a/src/Console/RollbackCommand.php b/src/Console/RollbackCommand.php index 8cc33e4..8a6e75c 100644 --- a/src/Console/RollbackCommand.php +++ b/src/Console/RollbackCommand.php @@ -32,7 +32,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $db = $app->getConfig()['database']; $dryRun = (bool) $input->getOption('dry-run'); - $runner = new MigrationRunner(new Connection($db)); + $runner = new MigrationRunner(new Connection($db), fn(string $msg) => $output->writeln($msg)); try { $runner->rollback('database/migrations', $dryRun); diff --git a/src/Database/MigrationRunner.php b/src/Database/MigrationRunner.php index faa7e77..840bba9 100644 --- a/src/Database/MigrationRunner.php +++ b/src/Database/MigrationRunner.php @@ -7,10 +7,15 @@ class MigrationRunner { private Connection $connection; + /** @var callable(string): void */ + private $output; - public function __construct(Connection $connection) + public function __construct(Connection $connection, ?callable $output = null) { $this->connection = $connection; + $this->output = $output ?? static function (string $msg): void { + echo $msg . "\n"; + }; } public function run(string $path, bool $dryRun = false): void @@ -25,7 +30,7 @@ public function run(string $path, bool $dryRun = false): void sort($files); if (empty($files)) { - echo "No migrations found.\n"; + ($this->output)('No migrations found.'); return; } @@ -37,12 +42,12 @@ public function run(string $path, bool $dryRun = false): void $name = basename($file); if (in_array($name, $executed, true)) { - echo "{$prefix}Skipped (already executed): {$name}\n"; + ($this->output)("{$prefix}Skipped (already executed): {$name}"); continue; } if ($dryRun) { - echo "{$prefix}Would execute: {$name}\n"; + ($this->output)("{$prefix}Would execute: {$name}"); continue; } @@ -54,13 +59,13 @@ public function run(string $path, bool $dryRun = false): void try { $pdo->exec($sql); $this->mark($name, $batch); - echo "Executed: {$name}\n"; + ($this->output)("Executed: {$name}"); } catch (\Throwable $e) { throw new \Exception("Migration failed: {$name} - " . $e->getMessage()); } } - echo $dryRun ? "{$prefix}Done (no changes applied)\n" : "✔ Done\n"; + ($this->output)($dryRun ? "{$prefix}Done (no changes applied)" : '✔ Done'); } public function rollback(string $path, bool $dryRun = false): void @@ -70,7 +75,7 @@ public function rollback(string $path, bool $dryRun = false): void $batch = $this->lastBatch(); if ($batch === 0) { - echo "Nothing to rollback.\n"; + ($this->output)('Nothing to rollback.'); return; } @@ -89,7 +94,7 @@ public function rollback(string $path, bool $dryRun = false): void } if ($dryRun) { - echo "{$prefix}Would roll back: {$migration}\n"; + ($this->output)("{$prefix}Would roll back: {$migration}"); continue; } @@ -100,18 +105,15 @@ public function rollback(string $path, bool $dryRun = false): void try { $pdo->exec($sql); - $pdo->prepare("DELETE FROM migrations WHERE migration = :m") ->execute(['m' => $migration]); - - echo "Rolled back: {$migration}\n"; - + ($this->output)("Rolled back: {$migration}"); } catch (\Throwable $e) { throw new \Exception("Rollback failed: {$migration} - " . $e->getMessage()); } } - echo $dryRun ? "{$prefix}Done (no changes applied)\n" : "✔ Rollback complete\n"; + ($this->output)($dryRun ? "{$prefix}Done (no changes applied)" : '✔ Rollback complete'); } private function ensureMigrationsTable(): void @@ -137,9 +139,7 @@ private function ensureMigrationsTable(): void } } - /** - * @return array - */ + /** @return array */ private function getExecuted(): array { $stmt = $this->connection->getPdo()->query("SELECT migration FROM migrations"); @@ -149,9 +149,7 @@ private function getExecuted(): array return $stmt->fetchAll(PDO::FETCH_COLUMN); } - /** - * @return array - */ + /** @return array */ private function getExecutedSafe(): array { try { @@ -163,14 +161,9 @@ private function getExecutedSafe(): array private function mark(string $migration, int $batch): void { - $stmt = $this->connection->getPdo()->prepare( - "INSERT INTO migrations (migration, batch) VALUES (:m, :b)" - ); - - $stmt->execute([ - 'm' => $migration, - 'b' => $batch - ]); + $this->connection->getPdo() + ->prepare("INSERT INTO migrations (migration, batch) VALUES (:m, :b)") + ->execute(['m' => $migration, 'b' => $batch]); } private function nextBatch(): int diff --git a/src/Generator/Builder/EntityBuilder.php b/src/Generator/Builder/EntityBuilder.php index de81d0c..3571c96 100644 --- a/src/Generator/Builder/EntityBuilder.php +++ b/src/Generator/Builder/EntityBuilder.php @@ -1,14 +1,16 @@ getEntityName(); - $fields = $schema->getFields(); + $fields = $schema->getFields(); $properties = ''; foreach ($fields as $name => $type) { @@ -16,9 +18,8 @@ public function build(EntitySchema $schema): string } $relationsCode = $this->buildRelations($schema); - $useBlock = $this->buildUseStatements($schema); - - $body = $properties . ($relationsCode !== '' ? "\n{$relationsCode}" : ''); + $useBlock = $this->buildUseStatements($schema); + $body = $properties . ($relationsCode !== '' ? "\n{$relationsCode}" : ''); return << 'int', - 'float' => 'float', - 'bool' => 'bool', + 'int' => 'int', + 'float' => 'float', + 'bool' => 'bool', 'string', 'text', 'datetime' => 'string', - default => 'mixed', + default => 'mixed', }; } private function buildUseStatements(EntitySchema $schema): string { $relations = $schema->getRelations(); - $entities = []; + $entities = []; /** @var array $belongsTo */ $belongsTo = $relations['belongsTo'] ?? []; @@ -75,7 +76,7 @@ private function buildUseStatements(EntitySchema $schema): string private function buildRelations(EntitySchema $schema): string { $relations = $schema->getRelations(); - $code = ''; + $code = ''; /** @var array $belongsTo */ $belongsTo = $relations['belongsTo'] ?? []; @@ -88,22 +89,11 @@ private function buildRelations(EntitySchema $schema): string /** @var array $hasMany */ $hasMany = $relations['hasMany'] ?? []; foreach ($hasMany as $entity => $foreignKey) { - $property = $this->pluralize(lcfirst((string) $entity)); + $property = Str::pluralize(lcfirst((string) $entity)); $code .= " /** @var {$entity}[] Loaded via {$foreignKey} */\n"; $code .= " public array \${$property} = [];\n"; } return $code; } - - private function pluralize(string $word): string - { - if (str_ends_with($word, 'y') && !preg_match('/[aeiou]y$/i', $word)) { - return substr($word, 0, -1) . 'ies'; - } - if (preg_match('/(s|x|z|ch|sh)$/', $word)) { - return $word . 'es'; - } - return $word . 's'; - } -} \ No newline at end of file +} diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index fd7673a..32839bd 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -3,25 +3,28 @@ namespace EntityForge\Generator\Builder; use EntityForge\Generator\Schema\EntitySchema; +use EntityForge\Support\Str; class MigrationBuilder { /** - * @param array $pkMap entity name → primary key column, used to resolve FK targets + * @param array $pkMap entity name → primary key column, used to resolve FK targets + * @param string $strategy tenancy strategy: 'shared' adds tenant_id, 'database' omits it */ - public function buildUp(EntitySchema $schema, array $pkMap = []): string + public function buildUp(EntitySchema $schema, array $pkMap = [], string $strategy = 'shared'): string { - $table = strtolower($schema->getEntityName()) . 's'; + $table = Str::toTableName($schema->getEntityName()); $fields = $schema->getFields(); $relations = $schema->getRelations(); $indexes = $schema->getIndexes(); $fkColumns = array_values($relations['belongsTo'] ?? []); - $definitions = [ - 'id INT AUTO_INCREMENT PRIMARY KEY', - 'tenant_id VARCHAR(255) NOT NULL', - ]; + $definitions = ['id INT AUTO_INCREMENT PRIMARY KEY']; + + if ($strategy === 'shared') { + $definitions[] = 'tenant_id VARCHAR(255) NOT NULL'; + } foreach ($fields as $name => $type) { if ($name === 'id' || $name === 'tenant_id' || in_array($name, $fkColumns, true)) { @@ -31,7 +34,7 @@ public function buildUp(EntitySchema $schema, array $pkMap = []): string } foreach ($relations['belongsTo'] ?? [] as $refEntity => $fkColumn) { - $refTable = strtolower($refEntity) . 's'; + $refTable = Str::toTableName($refEntity); $refPk = $pkMap[$refEntity] ?? 'id'; $constraint = "fk_{$table}_{$fkColumn}"; $definitions[] = "{$fkColumn} INT NOT NULL"; @@ -58,7 +61,7 @@ public function buildUp(EntitySchema $schema, array $pkMap = []): string public function buildDown(EntitySchema $schema): string { - $table = strtolower($schema->getEntityName()) . 's'; + $table = Str::toTableName($schema->getEntityName()); return "DROP TABLE IF EXISTS {$table};"; } diff --git a/src/Generator/EntityGenerator.php b/src/Generator/EntityGenerator.php index 784ba48..394049a 100644 --- a/src/Generator/EntityGenerator.php +++ b/src/Generator/EntityGenerator.php @@ -8,6 +8,7 @@ use EntityForge\Generator\Builder\RepositoryBuilder; use EntityForge\Generator\Builder\MigrationBuilder; use EntityForge\Generator\Writer\FileWriter; +use EntityForge\Support\Str; class EntityGenerator { @@ -17,70 +18,59 @@ class EntityGenerator private MigrationBuilder $migrationBuilder; private FileWriter $writer; - // Shared counter for this generation session private int $migrationCounter = 0; public function __construct() { - $this->validator = new SchemaValidator(); - $this->entityBuilder = new EntityBuilder(); + $this->validator = new SchemaValidator(); + $this->entityBuilder = new EntityBuilder(); $this->repositoryBuilder = new RepositoryBuilder(); - $this->migrationBuilder = new MigrationBuilder(); - $this->writer = new FileWriter(); + $this->migrationBuilder = new MigrationBuilder(); + $this->writer = new FileWriter(); } /** * @param array $config - * @param array $pkMap entity name → primary key column for FK resolution + * @param array $pkMap entity name → primary key column for FK resolution + * @param string $strategy tenancy strategy — passed through to MigrationBuilder */ - public function generate(array $config, bool $withMigration = false, array $pkMap = []): void - { - // Validate config + public function generate( + array $config, + bool $withMigration = false, + array $pkMap = [], + string $strategy = 'shared' + ): void { $this->validator->validate($config); - $schema = new EntitySchema($config); + $schema = new EntitySchema($config); $entityName = $schema->getEntityName(); - // Generate code - $entityCode = $this->entityBuilder->build($schema); - $repositoryCode = $this->repositoryBuilder->build($schema); - - // Write entity + repository - $this->writer->write("app/Entity/{$entityName}.php", $entityCode); - $this->writer->write("app/Repository/{$entityName}Repository.php", $repositoryCode); + $this->writer->write("app/Entity/{$entityName}.php", $this->entityBuilder->build($schema)); + $this->writer->write("app/Repository/{$entityName}Repository.php", $this->repositoryBuilder->build($schema)); - // Generate migration if ($withMigration) { $baseName = $this->generateMigrationBaseName($entityName); - $upSql = $this->migrationBuilder->buildUp($schema, $pkMap); - $downSql = $this->migrationBuilder->buildDown($schema); - $this->writer->write( "database/migrations/{$baseName}.up.sql", - $upSql + $this->migrationBuilder->buildUp($schema, $pkMap, $strategy) ); - $this->writer->write( - "database/migrations/{$baseName}.down.sql", - $downSql + "database/migrations/{$baseName}.down.sql", + $this->migrationBuilder->buildDown($schema) ); } } private function generateMigrationBaseName(string $entity): string { - $timestamp = date('Y_m_d_His'); - $this->migrationCounter++; - $table = strtolower($entity) . 's'; - return sprintf( '%s_%04d_create_%s_table', - $timestamp, + date('Y_m_d_His'), $this->migrationCounter, - $table + Str::toTableName($entity) ); } -} \ No newline at end of file +} diff --git a/src/Generator/Schema/EntitySchema.php b/src/Generator/Schema/EntitySchema.php index 263cc64..a02ab5f 100644 --- a/src/Generator/Schema/EntitySchema.php +++ b/src/Generator/Schema/EntitySchema.php @@ -18,27 +18,14 @@ public function getEntityName(): string return $this->config['entity']; } - public function isMultiTenant(): bool - { - return $this->config['multiTenant'] ?? false; - } - public function hasTimestamps(): bool { return $this->config['timestamps'] ?? false; } - public function getPrimaryKey(): ?string + 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; + return 'id'; } /** @return array */ @@ -46,14 +33,9 @@ public function getFields(): array { $fields = $this->config['fields'] ?? []; - if ($this->isMultiTenant()) { - $fields['tenant_id'] = 'string'; - } - - // Timestamp Support if ($this->hasTimestamps()) { - $fields['created_at'] = 'string'; - $fields['updated_at'] = 'string'; + $fields['created_at'] = 'datetime'; + $fields['updated_at'] = 'datetime'; } return $fields; @@ -70,4 +52,4 @@ public function getIndexes(): array { return $this->config['indexes'] ?? []; } -} \ No newline at end of file +} diff --git a/src/Repository/BaseRepository.php b/src/Repository/BaseRepository.php index 4fc2482..54f054a 100644 --- a/src/Repository/BaseRepository.php +++ b/src/Repository/BaseRepository.php @@ -3,6 +3,7 @@ namespace EntityForge\Repository; use EntityForge\Database\Connection; +use EntityForge\Support\Str; use EntityForge\Tenant\TenantConnectionResolver; use EntityForge\Tenant\TenantContext; use EntityForge\Tenant\TenantGuard; @@ -33,11 +34,8 @@ private function assertColumnName(string $column): void protected function resolveTableName(): string { - $class = (new \ReflectionClass($this)) - ->getShortName(); - return strtolower( - str_replace('Repository', '', $class) - ) . 's'; + $class = (new \ReflectionClass($this))->getShortName(); + return Str::toTableName(str_replace('Repository', '', $class)); } /** @@ -63,13 +61,9 @@ protected function applyTenantScope(array $data): array return $data; } - /** - * Determine if tenant scope should be applied - */ protected function shouldApplyTenantScope(): bool { - return ($this->config['tenancy']['strategy'] ?? 'shared') - === 'shared'; + return ($this->config['tenancy']['strategy'] ?? 'shared') === 'shared'; } /** @@ -85,10 +79,7 @@ public function create(array $data): array array_walk($columns, fn(string $c) => $this->assertColumnName($c)); - $placeholders = array_map( - fn(string $column) => ':' . $column, - $columns - ); + $placeholders = array_map(fn(string $column) => ':' . $column, $columns); $sql = sprintf( 'INSERT INTO %s (%s) VALUES (%s)', @@ -97,13 +88,10 @@ public function create(array $data): array implode(', ', $placeholders) ); - $statement = $this->connection - ->getPdo() - ->prepare($sql); + $pdo = $this->connection->getPdo(); + $pdo->prepare($sql)->execute($data); - $statement->execute($data); - - return $data; + return ['id' => (int) $pdo->lastInsertId()] + $data; } /** @@ -113,19 +101,14 @@ public function create(array $data): array public function findAll(): array { $sql = "SELECT * FROM {$this->table}"; - $params = []; if ($this->shouldApplyTenantScope()) { $sql .= " WHERE tenant_id = :tenant_id"; - $params['tenant_id'] = $this->getTenantId(); } - $statement = $this->connection - ->getPdo() - ->prepare($sql); - + $statement = $this->connection->getPdo()->prepare($sql); $statement->execute($params); return $statement->fetchAll(PDO::FETCH_ASSOC); @@ -138,25 +121,17 @@ public function findAll(): array public function findById(int $id): ?array { $sql = "SELECT * FROM {$this->table} WHERE id = :id"; - - $params = [ - 'id' => $id, - ]; + $params = ['id' => $id]; if ($this->shouldApplyTenantScope()) { $sql .= " AND tenant_id = :tenant_id"; - $params['tenant_id'] = $this->getTenantId(); } - $statement = $this->connection - ->getPdo() - ->prepare($sql); - + $statement = $this->connection->getPdo()->prepare($sql); $statement->execute($params); $result = $statement->fetch(PDO::FETCH_ASSOC); - return $result ?: null; } @@ -168,7 +143,6 @@ public function findById(int $id): ?array public function where(array $conditions): array { $clauses = []; - $params = []; foreach ($conditions as $column => $value) { @@ -179,7 +153,6 @@ public function where(array $conditions): array if ($this->shouldApplyTenantScope()) { $clauses[] = "tenant_id = :tenant_id"; - $params['tenant_id'] = $this->getTenantId(); } @@ -189,10 +162,7 @@ public function where(array $conditions): array implode(' AND ', $clauses) ); - $statement = $this->connection - ->getPdo() - ->prepare($sql); - + $statement = $this->connection->getPdo()->prepare($sql); $statement->execute($params); return $statement->fetchAll(PDO::FETCH_ASSOC); @@ -218,66 +188,44 @@ public function update(int $id, array $data): bool ); $params = $data; - $params['id'] = $id; if ($this->shouldApplyTenantScope()) { $sql .= " AND tenant_id = :tenant_id"; - $params['tenant_id'] = $this->getTenantId(); } - $statement = $this->connection - ->getPdo() - ->prepare($sql); - - return $statement->execute($params); + return $this->connection->getPdo()->prepare($sql)->execute($params); } /** - * Delete record by ID - * * @throws Exception */ public function delete(int $id): bool { $sql = "DELETE FROM {$this->table} WHERE id = :id"; - - $params = [ - 'id' => $id, - ]; + $params = ['id' => $id]; if ($this->shouldApplyTenantScope()) { $sql .= " AND tenant_id = :tenant_id"; - $params['tenant_id'] = $this->getTenantId(); } - $statement = $this->connection - ->getPdo() - ->prepare($sql); - - return $statement->execute($params); + return $this->connection->getPdo()->prepare($sql)->execute($params); } public function beginTransaction(): void { - $this->connection - ->getPdo() - ->beginTransaction(); + $this->connection->getPdo()->beginTransaction(); } public function commit(): void { - $this->connection - ->getPdo() - ->commit(); + $this->connection->getPdo()->commit(); } public function rollback(): void { - $this->connection - ->getPdo() - ->rollBack(); + $this->connection->getPdo()->rollBack(); } -} \ No newline at end of file +} diff --git a/src/Support/Str.php b/src/Support/Str.php new file mode 100644 index 0000000..7979579 --- /dev/null +++ b/src/Support/Str.php @@ -0,0 +1,23 @@ +assertStringContainsString('tenant_id VARCHAR(255) NOT NULL', $sql); } + public function test_build_up_database_strategy_omits_tenant_id(): void + { + $sql = $this->builder->buildUp($this->schema([]), [], 'database'); + + $this->assertStringNotContainsString('tenant_id', $sql); + } + public function test_build_up_does_not_duplicate_id_when_in_schema_fields(): void { $sql = $this->builder->buildUp($this->schema(['id' => 'int'])); @@ -179,6 +186,6 @@ public function test_build_up_emits_multiple_fks_and_indexes(): void $this->assertStringContainsString('product_id INT NOT NULL', $sql); $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); + $this->assertStringContainsString('UNIQUE INDEX uix_order_items_order_id_product_id', $sql); } } diff --git a/tests/Generator/Schema/EntitySchemaTest.php b/tests/Generator/Schema/EntitySchemaTest.php index 8e526a7..43aed92 100644 --- a/tests/Generator/Schema/EntitySchemaTest.php +++ b/tests/Generator/Schema/EntitySchemaTest.php @@ -20,16 +20,6 @@ public function test_get_entity_name(): void $this->assertSame('Order', $this->schema()->getEntityName()); } - public function test_is_multi_tenant_defaults_false(): void - { - $this->assertFalse($this->schema()->isMultiTenant()); - } - - public function test_is_multi_tenant_true_when_set(): void - { - $this->assertTrue($this->schema(['multiTenant' => true])->isMultiTenant()); - } - public function test_has_timestamps_defaults_false(): void { $this->assertFalse($this->schema()->hasTimestamps()); @@ -48,14 +38,6 @@ public function test_get_fields_returns_defined_fields(): void $this->assertArrayHasKey('total', $fields); } - public function test_get_fields_appends_tenant_id_when_multi_tenant(): void - { - $fields = $this->schema(['multiTenant' => true])->getFields(); - - $this->assertArrayHasKey('tenant_id', $fields); - $this->assertSame('string', $fields['tenant_id']); - } - public function test_get_fields_appends_timestamps_when_enabled(): void { $fields = $this->schema(['timestamps' => true])->getFields(); diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php index cbf1fe6..ed0c491 100644 --- a/tests/Repository/BaseRepositoryTest.php +++ b/tests/Repository/BaseRepositoryTest.php @@ -160,11 +160,13 @@ public function test_create_shared_strategy_injects_tenant_id(): void ->andReturn(true); $this->pdo->allows('prepare')->andReturn($stmt); + $this->pdo->allows('lastInsertId')->andReturn('42'); $result = $this->repo('shared')->create(['name' => 'Alice']); $this->assertArrayHasKey('tenant_id', $result); $this->assertSame('acme', $result['tenant_id']); + $this->assertSame(42, $result['id']); } public function test_create_database_strategy_does_not_inject_tenant_id(): void @@ -176,10 +178,12 @@ public function test_create_database_strategy_does_not_inject_tenant_id(): void ->andReturn(true); $this->pdo->allows('prepare')->andReturn($stmt); + $this->pdo->allows('lastInsertId')->andReturn('7'); $result = $this->repo('database')->create(['name' => 'Alice']); $this->assertArrayNotHasKey('tenant_id', $result); + $this->assertSame(7, $result['id']); } public function test_update_shared_strategy_appends_tenant_clause(): void From 116fb317ce7e6e6cc3c8ea54b5f0d01be0e65b6e Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 27 Jun 2026 12:09:18 +0530 Subject: [PATCH 3/6] Fix shared-strategy unique indexes, JSON request parsing, and onboard provisioning guard - MigrationBuilder: unique indexes in shared strategy now prepend tenant_id so the constraint is scoped per-tenant, not globally unique - Request::capture(): detects application/json Content-Type and parses php://input instead of relying on $_POST; adds json() helper method - TenantService::onboard(): only calls provisioner->create() for database strategy; shared strategy registers the tenant row without provisioning a DB --- src/Generator/Builder/MigrationBuilder.php | 14 +++++++++---- src/Http/Request.php | 21 +++++++++++++++---- src/Tenant/TenantService.php | 7 ++++++- .../Builder/MigrationBuilderTest.php | 20 +++++++++++++++--- tests/Tenant/TenantServiceTest.php | 21 +++++++++++++++++-- 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index 32839bd..9cafe51 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -42,11 +42,17 @@ public function buildUp(EntitySchema $schema, array $pkMap = [], string $strateg } 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'; + $columns = $index['columns']; + + if ($unique && $strategy === 'shared') { + $columns = array_unique(array_merge(['tenant_id'], $columns)); + } + + $cols = implode(', ', $columns); + $slug = implode('_', $columns); + $type = $unique ? 'UNIQUE INDEX' : 'INDEX'; + $prefix = $unique ? 'uix' : 'idx'; $definitions[] = "{$type} {$prefix}_{$table}_{$slug} ({$cols})"; } diff --git a/src/Http/Request.php b/src/Http/Request.php index cfa20e3..db284d9 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -23,14 +23,21 @@ public function __construct( public static function capture(): self { - $uri = $_SERVER['REQUEST_URI'] ?? '/'; - $parsed = parse_url($uri, PHP_URL_PATH); - $path = is_string($parsed) ? $parsed : '/'; + $uri = $_SERVER['REQUEST_URI'] ?? '/'; + $parsed = parse_url($uri, PHP_URL_PATH); + $path = is_string($parsed) ? $parsed : '/'; + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + + $body = $_POST; + if (str_contains($contentType, 'application/json')) { + $raw = file_get_contents('php://input'); + $body = $raw ? (json_decode($raw, true) ?? []) : []; + } return new self( headers: getallheaders() ?: [], query: $_GET, - body: $_POST, + body: $body, method: $_SERVER['REQUEST_METHOD'] ?? 'GET', path: $path ); @@ -51,6 +58,12 @@ public function body(string $key): mixed return $this->body[$key] ?? null; } + /** @return array */ + public function json(): array + { + return $this->body; + } + public function method(): string { return $this->method; diff --git a/src/Tenant/TenantService.php b/src/Tenant/TenantService.php index 07bb442..12e9cd9 100644 --- a/src/Tenant/TenantService.php +++ b/src/Tenant/TenantService.php @@ -30,7 +30,12 @@ public function onboard(string $tenantId, string $name): void throw new \Exception("Tenant already exists: {$tenantId}"); } - $this->provisioner->create($tenantId); + $strategy = $this->config['tenancy']['strategy'] ?? 'shared'; + + if ($strategy === 'database') { + $this->provisioner->create($tenantId); + } + $this->repo->create($tenantId, $name); } diff --git a/tests/Generator/Builder/MigrationBuilderTest.php b/tests/Generator/Builder/MigrationBuilderTest.php index 1a191e7..02bc9c2 100644 --- a/tests/Generator/Builder/MigrationBuilderTest.php +++ b/tests/Generator/Builder/MigrationBuilderTest.php @@ -145,7 +145,7 @@ public function test_build_up_emits_index(): void $this->assertStringContainsString('INDEX idx_orders_status (status)', $sql); } - public function test_build_up_emits_unique_index(): void + public function test_build_up_emits_unique_index_shared_prepends_tenant_id(): void { $schema = new EntitySchema([ 'entity' => 'User', @@ -153,9 +153,23 @@ public function test_build_up_emits_unique_index(): void 'indexes' => [['columns' => ['email'], 'unique' => true]], ]); - $sql = $this->builder->buildUp($schema); + $sql = $this->builder->buildUp($schema, [], 'shared'); + + $this->assertStringContainsString('UNIQUE INDEX uix_users_tenant_id_email (tenant_id, email)', $sql); + } + + public function test_build_up_emits_unique_index_database_strategy_no_tenant_id(): void + { + $schema = new EntitySchema([ + 'entity' => 'User', + 'fields' => ['email' => 'string'], + 'indexes' => [['columns' => ['email'], 'unique' => true]], + ]); + + $sql = $this->builder->buildUp($schema, [], 'database'); $this->assertStringContainsString('UNIQUE INDEX uix_users_email (email)', $sql); + $this->assertStringNotContainsString('tenant_id', $sql); } public function test_build_up_emits_composite_index(): void @@ -186,6 +200,6 @@ public function test_build_up_emits_multiple_fks_and_indexes(): void $this->assertStringContainsString('product_id INT NOT NULL', $sql); $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_order_items_order_id_product_id', $sql); + $this->assertStringContainsString('UNIQUE INDEX uix_order_items_tenant_id_order_id_product_id', $sql); } } diff --git a/tests/Tenant/TenantServiceTest.php b/tests/Tenant/TenantServiceTest.php index bb135d1..ad74594 100644 --- a/tests/Tenant/TenantServiceTest.php +++ b/tests/Tenant/TenantServiceTest.php @@ -71,7 +71,7 @@ public function test_onboard_accepts_valid_tenant_id_formats(): void $this->assertTrue(true); } - public function test_onboard_provisions_and_registers_tenant(): void + public function test_onboard_database_strategy_provisions_and_registers_tenant(): void { /** @var MockInterface&TenantRepository $repo */ $repo = Mockery::mock(TenantRepository::class); @@ -82,7 +82,24 @@ public function test_onboard_provisions_and_registers_tenant(): void $provisioner = Mockery::mock(TenantProvisioner::class); $provisioner->expects('create')->with('acme')->once(); - $service = new TenantService($this->config(), $repo, $provisioner); + $service = new TenantService($this->config('database'), $repo, $provisioner); + $service->onboard('acme', 'Acme Corp'); + + $this->assertTrue(true); + } + + public function test_onboard_shared_strategy_registers_without_provisioning(): void + { + /** @var MockInterface&TenantRepository $repo */ + $repo = Mockery::mock(TenantRepository::class); + $repo->allows('exists')->with('acme')->andReturn(false); + $repo->expects('create')->with('acme', 'Acme Corp')->once(); + + /** @var MockInterface&TenantProvisioner $provisioner */ + $provisioner = Mockery::mock(TenantProvisioner::class); + $provisioner->expects('create')->never(); + + $service = new TenantService($this->config('shared'), $repo, $provisioner); $service->onboard('acme', 'Acme Corp'); $this->assertTrue(true); From 3d4a7b4d026b7b260070f307393e3f11851a22a0 Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 27 Jun 2026 12:26:25 +0530 Subject: [PATCH 4/6] Fix PHPStan: cast preg_replace result to string before strtolower --- src/Support/Str.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/Str.php b/src/Support/Str.php index 7979579..4c334f9 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -6,7 +6,7 @@ class Str { public static function toTableName(string $entityName): string { - $snake = strtolower(preg_replace('/([A-Z])/', '_$1', lcfirst($entityName))); + $snake = strtolower((string) preg_replace('/([A-Z])/', '_$1', lcfirst($entityName))); return self::pluralize($snake); } From 6d168afe95630791cda5063ee350eb2d702751dd Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 27 Jun 2026 12:38:20 +0530 Subject: [PATCH 5/6] Increase patch coverage to 100% for 2.1.0 changes - Request: test json() method and capture() JSON content-type branch - EntitySchema: test getPrimaryKey() - BaseRepository: test resolveTableName() via reflection - MigrationBuilder: test default TEXT mapping for unknown field types - ConsoleCommands: test strategy loading from application.yaml for both GenerateCommand and GenerateAllCommand --- tests/Console/ConsoleCommandsTest.php | 47 +++++++++++++++++++ .../Builder/MigrationBuilderTest.php | 7 +++ tests/Generator/Schema/EntitySchemaTest.php | 5 ++ tests/Http/RequestTest.php | 35 ++++++++++++++ tests/Repository/BaseRepositoryTest.php | 9 ++++ 5 files changed, 103 insertions(+) diff --git a/tests/Console/ConsoleCommandsTest.php b/tests/Console/ConsoleCommandsTest.php index 2b080d2..8b6d53b 100644 --- a/tests/Console/ConsoleCommandsTest.php +++ b/tests/Console/ConsoleCommandsTest.php @@ -88,6 +88,30 @@ public function test_generate_succeeds_with_valid_schema(): void $this->assertStringContainsString('Generated Widget', $tester->getDisplay()); } + public function test_generate_reads_strategy_from_application_yaml(): void + { + $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid(); + mkdir($tmp . '/entities', 0755, true); + mkdir($tmp . '/config', 0755, true); + file_put_contents($tmp . '/entities/Widget.json', json_encode([ + 'entity' => 'Widget', + 'fields' => ['name' => 'string'], + ])); + file_put_contents($tmp . '/config/application.yaml', "tenancy:\n strategy: database\n"); + + $origDir = getcwd(); + chdir($tmp); + + $tester = new CommandTester(new GenerateCommand()); + $code = $tester->execute(['entity' => 'Widget', '--config' => 'entities']); + + chdir($origDir); + $this->removeDir($tmp); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Generated Widget', $tester->getDisplay()); + } + // ── GenerateAllCommand::execute() ────────────────────────────────────────── public function test_generate_all_fails_when_config_dir_missing(): void @@ -195,6 +219,29 @@ public function test_generate_all_generates_all_entities(): void $this->assertStringContainsString('Generated Item', $tester->getDisplay()); } + public function test_generate_all_reads_strategy_from_application_yaml(): void + { + $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid(); + mkdir($tmp . '/config/entities', 0755, true); + file_put_contents($tmp . '/config/entities/Item.json', json_encode([ + 'entity' => 'Item', + 'fields' => ['name' => 'string'], + ])); + file_put_contents($tmp . '/config/application.yaml', "tenancy:\n strategy: database\n"); + + $origDir = getcwd(); + chdir($tmp); + + $tester = new CommandTester(new GenerateAllCommand()); + $code = $tester->execute([]); + + chdir($origDir); + $this->removeDir($tmp); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Generated Item', $tester->getDisplay()); + } + private function removeDir(string $dir): void { if (!is_dir($dir)) { diff --git a/tests/Generator/Builder/MigrationBuilderTest.php b/tests/Generator/Builder/MigrationBuilderTest.php index 02bc9c2..c6aa789 100644 --- a/tests/Generator/Builder/MigrationBuilderTest.php +++ b/tests/Generator/Builder/MigrationBuilderTest.php @@ -185,6 +185,13 @@ public function test_build_up_emits_composite_index(): void $this->assertStringContainsString('INDEX idx_orders_user_id_status (user_id, status)', $sql); } + public function test_build_up_maps_unknown_type_to_text(): void + { + $sql = $this->builder->buildUp($this->schema(['data' => 'json'])); + + $this->assertStringContainsString('data TEXT', $sql); + } + public function test_build_up_emits_multiple_fks_and_indexes(): void { $schema = new EntitySchema([ diff --git a/tests/Generator/Schema/EntitySchemaTest.php b/tests/Generator/Schema/EntitySchemaTest.php index 43aed92..f73fad5 100644 --- a/tests/Generator/Schema/EntitySchemaTest.php +++ b/tests/Generator/Schema/EntitySchemaTest.php @@ -74,4 +74,9 @@ public function test_get_indexes_returns_defined_indexes(): void $schema = $this->schema(['indexes' => $indexes]); $this->assertSame($indexes, $schema->getIndexes()); } + + public function test_get_primary_key_returns_id(): void + { + $this->assertSame('id', $this->schema()->getPrimaryKey()); + } } diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 79ae476..4c8b834 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -170,4 +170,39 @@ public function test_attributes_chain(): void $this->assertSame(['id' => 1], $request->getAttribute('user')); $this->assertSame('admin', $request->getAttribute('role')); } + + // ── json() ───────────────────────────────────────────────────────────────── + + public function test_json_returns_entire_body_array(): void + { + $request = new Request(body: ['name' => 'Alice', 'age' => 30]); + + $this->assertSame(['name' => 'Alice', 'age' => 30], $request->json()); + } + + public function test_json_returns_empty_array_when_no_body(): void + { + $request = new Request(); + + $this->assertSame([], $request->json()); + } + + // ── capture() JSON content type ─────────────────────────────────────────── + + public function test_capture_uses_empty_body_when_json_content_type_but_no_input(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['REQUEST_URI'] = '/api/items'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = []; + + $request = Request::capture(); + + $this->assertSame('POST', $request->method()); + $this->assertSame([], $request->json()); + + unset($_SERVER['CONTENT_TYPE']); + $_SERVER['REQUEST_METHOD'] = 'GET'; + unset($_SERVER['REQUEST_URI']); + } } diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php index ed0c491..7c152e0 100644 --- a/tests/Repository/BaseRepositoryTest.php +++ b/tests/Repository/BaseRepositoryTest.php @@ -262,6 +262,15 @@ public function test_update_throws_on_invalid_column_name(): void $this->repo('database')->update(1, ['`injected`' => 'value']); } + public function test_resolve_table_name_derives_from_class_name(): void + { + $repo = (new \ReflectionClass(WidgetRepository::class))->newInstanceWithoutConstructor(); + $method = (new \ReflectionClass(BaseRepository::class))->getMethod('resolveTableName'); + $method->setAccessible(true); + + $this->assertSame('widgets', $method->invoke($repo)); + } + public function test_transaction_methods_delegate_to_pdo(): void { $this->pdo->allows('beginTransaction')->once()->andReturn(true); From 8e5498a6401eeef73b435b3c183453bfffb7d838 Mon Sep 17 00:00:00 2001 From: vedavith Date: Sat, 27 Jun 2026 12:41:01 +0530 Subject: [PATCH 6/6] Replace deprecated setAccessible with Closure::bind for protected method test --- tests/Repository/BaseRepositoryTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php index 7c152e0..59092f0 100644 --- a/tests/Repository/BaseRepositoryTest.php +++ b/tests/Repository/BaseRepositoryTest.php @@ -265,10 +265,10 @@ public function test_update_throws_on_invalid_column_name(): void public function test_resolve_table_name_derives_from_class_name(): void { $repo = (new \ReflectionClass(WidgetRepository::class))->newInstanceWithoutConstructor(); - $method = (new \ReflectionClass(BaseRepository::class))->getMethod('resolveTableName'); - $method->setAccessible(true); - $this->assertSame('widgets', $method->invoke($repo)); + $result = \Closure::bind(fn() => $this->resolveTableName(), $repo, BaseRepository::class)(); + + $this->assertSame('widgets', $result); } public function test_transaction_methods_delegate_to_pdo(): void