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
$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 b9b91e5..0c67003 100644
--- a/src/Console/MigrateAllTenantsCommand.php
+++ b/src/Console/MigrateAllTenantsCommand.php
@@ -30,7 +30,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();
@@ -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 c500170..f4d7f5f 100644
--- a/src/Console/MigrateCommand.php
+++ b/src/Console/MigrateCommand.php
@@ -26,14 +26,14 @@ 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'];
$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 b4a40dd..8a6e75c 100644
--- a/src/Console/RollbackCommand.php
+++ b/src/Console/RollbackCommand.php
@@ -26,13 +26,13 @@ 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'];
$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/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/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 4471b1b..9cafe51 100644
--- a/src/Generator/Builder/MigrationBuilder.php
+++ b/src/Generator/Builder/MigrationBuilder.php
@@ -3,39 +3,56 @@
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();
- $pk = $schema->getPrimaryKey();
- $definitions = [];
+ $fkColumns = array_values($relations['belongsTo'] ?? []);
+
+ $definitions = ['id INT AUTO_INCREMENT PRIMARY KEY'];
+
+ if ($strategy === 'shared') {
+ $definitions[] = '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';
+ $refTable = Str::toTableName($refEntity);
$refPk = $pkMap[$refEntity] ?? 'id';
$constraint = "fk_{$table}_{$fkColumn}";
+ $definitions[] = "{$fkColumn} INT NOT NULL";
$definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}({$refPk})";
}
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})";
}
@@ -50,24 +67,21 @@ 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};";
}
- 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/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/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/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..4c334f9
--- /dev/null
+++ b/src/Support/Str.php
@@ -0,0 +1,23 @@
+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/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 14a3af3..c6aa789 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,34 @@ 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_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']));
+
+ $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 +83,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
+ public function test_build_up_maps_datetime_to_datetime(): void
{
- $sql = $this->builder->buildUp($this->schema(['id' => 'int']));
+ $sql = $this->builder->buildUp($this->schema(['date' => 'datetime']));
- $this->assertStringContainsString('id INT PRIMARY KEY AUTO_INCREMENT', $sql);
- }
-
- public function test_build_up_unknown_type_maps_to_text(): 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']));
-
- $this->assertStringContainsString('note', $sql);
+ $this->assertStringContainsString('date DATETIME', $sql);
}
public function test_build_down_drops_correct_table(): void
@@ -85,25 +104,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']]],
]);
@@ -112,24 +145,38 @@ 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',
- 'fields' => ['id' => 'int', 'email' => 'string'],
+ 'fields' => ['email' => 'string'],
'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
{
$schema = new EntitySchema([
'entity' => 'Order',
- 'fields' => ['id' => 'int', 'user_id' => 'int', 'status' => 'string'],
+ 'fields' => ['status' => 'string'],
'indexes' => [['columns' => ['user_id', 'status']]],
]);
@@ -138,19 +185,28 @@ 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([
'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);
+ $this->assertStringContainsString('UNIQUE INDEX uix_order_items_tenant_id_order_id_product_id', $sql);
}
}
diff --git a/tests/Generator/Schema/EntitySchemaTest.php b/tests/Generator/Schema/EntitySchemaTest.php
index 8e526a7..f73fad5 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();
@@ -92,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 cbf1fe6..59092f0 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
@@ -258,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();
+
+ $result = \Closure::bind(fn() => $this->resolveTableName(), $repo, BaseRepository::class)();
+
+ $this->assertSame('widgets', $result);
+ }
+
public function test_transaction_methods_delegate_to_pdo(): void
{
$this->pdo->allows('beginTransaction')->once()->andReturn(true);
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);