diff --git a/bin/ef b/bin/ef index e9545bd..ea123de 100755 --- a/bin/ef +++ b/bin/ef @@ -19,6 +19,9 @@ use EntityForge\Console\MigrateCommand; use EntityForge\Console\MigrateAllTenantsCommand; use EntityForge\Console\RollbackCommand; use EntityForge\Console\TenantCreateCommand; +use EntityForge\Console\FieldAddCommand; +use EntityForge\Console\FieldListCommand; +use EntityForge\Console\FieldRemoveCommand; $application = new Application('EntityForge CLI', '1.0'); @@ -30,5 +33,8 @@ $application->addCommand(new MigrateCommand()); $application->addCommand(new MigrateAllTenantsCommand()); $application->addCommand(new RollbackCommand()); $application->addCommand(new TenantCreateCommand()); +$application->addCommand(new FieldAddCommand()); +$application->addCommand(new FieldListCommand()); +$application->addCommand(new FieldRemoveCommand()); $application->run(); \ No newline at end of file diff --git a/composer.json b/composer.json index 3a91e0e..ab4f844 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,31 @@ "description": "A configuration-driven PHP framework for generating entity models and building multi-tenant SaaS applications.", "type": "library", "license": "MIT", + "keywords": [ + "php", + "saas", + "multi-tenant", + "multitenancy", + "orm", + "entity", + "repository", + "migrations", + "code-generation", + "database-per-tenant", + "tenant-isolation", + "symfony-console", + "pdo", + "framework" + ], + "homepage": "https://entityforge.dev", + "authors": [ + { + "name": "Vedavith Ravula", + "email": "gangumalladeepthi@gmail.com", + "homepage": "https://github.com/vedavith", + "role": "Developer" + } + ], "autoload": { "psr-4": { "EntityForge\\": "src/", diff --git a/readme.md b/readme.md index 199f29d..3b7734b 100644 --- a/readme.md +++ b/readme.md @@ -64,8 +64,6 @@ php -S localhost:8181 -t public composer require entity-forge/entity-forge ``` -> Package publication to Packagist is pending. Clone the repo and run `composer install` to use locally. - --- ## Quick Start @@ -195,6 +193,9 @@ $response->send(); | `tenant:create ` | `--name` | Onboard a new tenant | | `make:middleware ` | `--auth`, `--output` | Scaffold a middleware class (use `--auth` for `AuthMiddlewareInterface` stub) | | `make:controller ` | `--output` | Scaffold a controller class with CRUD stubs | +| `field:add ` | `--tenant`, `--label`, `--required` | Register a custom field definition for a tenant | +| `field:list ` | `--tenant` | List all custom fields registered for a tenant entity | +| `field:remove ` | `--tenant` | Remove a custom field definition by ID | `generate:all` uses a single `EntityGenerator` instance to guarantee monotonically ordered migration timestamps within a session. @@ -525,6 +526,45 @@ Auth runs before tenant resolution if the tenant ID is embedded in the token. Re --- +## Dynamic Schema Extension + +Tenants can define their own custom fields on any opted-in entity without touching migrations. Add `"metadata": true` to the entity schema: + +```json +{ + "entity": "Account", + "metadata": true, + "fields": { "name": "string", "email": "string" } +} +``` + +This emits a `metadata JSON NULL` column in the migration and `getMeta`/`setMeta`/`getAllMeta` methods on the repository. A `tenant_fields` table (auto-created by `CoreSchemaManager`) stores each tenant's field definitions. + +```bash +# Register a custom field for tenant "beta" on the accounts entity +php bin/ef field:add accounts vat_number string --tenant=beta --label="VAT Number" + +# List all custom fields for a tenant entity +php bin/ef field:list accounts --tenant=beta + +# Remove a field by ID (shown in field:list output) +php bin/ef field:remove 1 --tenant=beta +``` + +Read and write custom field values via the repository: + +```php +$repo = new AccountRepository($app->getConfig()); + +$repo->setMeta(42, 'vat_number', 'GB123456789'); +$vatNumber = $repo->getMeta(42, 'vat_number'); +$allMeta = $repo->getAllMeta(42); // ['vat_number' => 'GB123456789'] +``` + +Works identically with both `shared` and `database` tenancy strategies. + +--- + ## Roadmap - [ ] Official Packagist release diff --git a/src/Console/FieldAddCommand.php b/src/Console/FieldAddCommand.php new file mode 100644 index 0000000..47cb727 --- /dev/null +++ b/src/Console/FieldAddCommand.php @@ -0,0 +1,57 @@ +setName('field:add') + ->setDescription('Register a custom field for a tenant entity') + ->addArgument('entity', InputArgument::REQUIRED, 'Entity table name (e.g. accounts)') + ->addArgument('field_name', InputArgument::REQUIRED, 'Field name (snake_case)') + ->addArgument('type', InputArgument::REQUIRED, 'Field type: string, int, float, bool') + ->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'Tenant ID') + ->addOption('label', null, InputOption::VALUE_OPTIONAL, 'Human-readable label (defaults to field_name)') + ->addOption('required', null, InputOption::VALUE_NONE, 'Mark field as required'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $entity = (string) $input->getArgument('entity'); + $fieldName = (string) $input->getArgument('field_name'); + $type = (string) $input->getArgument('type'); + $tenantId = (string) ($input->getOption('tenant') ?? ''); + $label = (string) ($input->getOption('label') ?? $fieldName); + $required = (bool) $input->getOption('required'); + + if ($tenantId === '') { + $output->writeln('--tenant is required'); + return Command::FAILURE; + } + + $app = new Application(getcwd() . '/config'); + $app->boot([], false); + + $registry = new TenantFieldRegistry($app->getConfig()); + + try { + $registry->register($tenantId, $entity, $fieldName, $type, $label, $required); + $output->writeln("Field '{$fieldName}' ({$type}) added to '{$entity}' for tenant '{$tenantId}'"); + } catch (\Throwable $e) { + $output->writeln("{$e->getMessage()}"); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Console/FieldListCommand.php b/src/Console/FieldListCommand.php new file mode 100644 index 0000000..54ca212 --- /dev/null +++ b/src/Console/FieldListCommand.php @@ -0,0 +1,63 @@ +setName('field:list') + ->setDescription('List all custom fields registered for a tenant entity') + ->addArgument('entity', InputArgument::REQUIRED, 'Entity table name (e.g. accounts)') + ->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'Tenant ID'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $entity = (string) $input->getArgument('entity'); + $tenantId = (string) ($input->getOption('tenant') ?? ''); + + if ($tenantId === '') { + $output->writeln('--tenant is required'); + return Command::FAILURE; + } + + $app = new Application(getcwd() . '/config'); + $app->boot([], false); + + $registry = new TenantFieldRegistry($app->getConfig()); + + try { + $fields = $registry->fieldsFor($tenantId, $entity); + + if (empty($fields)) { + $output->writeln("No custom fields registered for '{$entity}' (tenant: {$tenantId})"); + return Command::SUCCESS; + } + + $output->writeln("Custom fields for '{$entity}' (tenant: {$tenantId}):"); + $output->writeln(''); + + foreach ($fields as $field) { + $required = $field['required'] ? ' [required]' : ''; + $output->writeln( + " #{$field['id']} {$field['field_name']} ({$field['field_type']}) {$field['label']}{$required}" + ); + } + } catch (\Throwable $e) { + $output->writeln("{$e->getMessage()}"); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Console/FieldRemoveCommand.php b/src/Console/FieldRemoveCommand.php new file mode 100644 index 0000000..ae863a5 --- /dev/null +++ b/src/Console/FieldRemoveCommand.php @@ -0,0 +1,49 @@ +setName('field:remove') + ->setDescription('Remove a custom field definition by ID') + ->addArgument('id', InputArgument::REQUIRED, 'Field definition ID (from field:list)') + ->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'Tenant ID'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $id = (int) $input->getArgument('id'); + $tenantId = (string) ($input->getOption('tenant') ?? ''); + + if ($tenantId === '') { + $output->writeln('--tenant is required'); + return Command::FAILURE; + } + + $app = new Application(getcwd() . '/config'); + $app->boot([], false); + + $registry = new TenantFieldRegistry($app->getConfig()); + + try { + $registry->remove($id, $tenantId); + $output->writeln("Field #{$id} removed for tenant '{$tenantId}'"); + } catch (\Throwable $e) { + $output->writeln("{$e->getMessage()}"); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Core/CoreSchemaManager.php b/src/Core/CoreSchemaManager.php index 47b6147..8077d15 100644 --- a/src/Core/CoreSchemaManager.php +++ b/src/Core/CoreSchemaManager.php @@ -29,6 +29,7 @@ private function definitions(): array { return [ $this->tenantsTable(), + $this->tenantFieldsTable(), ]; } @@ -42,6 +43,23 @@ private function tenantsTable(): string status VARCHAR(50) DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) +SQL; + } + + private function tenantFieldsTable(): string + { + return <<hasMetadata()) { + $properties .= " public ?array \$metadata = null;\n"; + } + $relationsCode = $this->buildRelations($schema); $useBlock = $this->buildUseStatements($schema); $body = $properties . ($relationsCode !== '' ? "\n{$relationsCode}" : ''); diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php index 9cafe51..19d4298 100644 --- a/src/Generator/Builder/MigrationBuilder.php +++ b/src/Generator/Builder/MigrationBuilder.php @@ -41,6 +41,10 @@ public function buildUp(EntitySchema $schema, array $pkMap = [], string $strateg $definitions[] = "CONSTRAINT {$constraint} FOREIGN KEY ({$fkColumn}) REFERENCES {$refTable}({$refPk})"; } + if ($schema->hasMetadata()) { + $definitions[] = 'metadata JSON NULL'; + } + foreach ($indexes as $index) { $unique = $index['unique'] ?? false; $columns = $index['columns']; diff --git a/src/Generator/Schema/EntitySchema.php b/src/Generator/Schema/EntitySchema.php index a02ab5f..5a18967 100644 --- a/src/Generator/Schema/EntitySchema.php +++ b/src/Generator/Schema/EntitySchema.php @@ -52,4 +52,9 @@ public function getIndexes(): array { return $this->config['indexes'] ?? []; } + + public function hasMetadata(): bool + { + return $this->config['metadata'] ?? false; + } } diff --git a/src/Generator/Schema/SchemaValidator.php b/src/Generator/Schema/SchemaValidator.php index 71d0894..90e293d 100644 --- a/src/Generator/Schema/SchemaValidator.php +++ b/src/Generator/Schema/SchemaValidator.php @@ -43,6 +43,11 @@ public function validate(array $config): void } } + // Metadata validation + if (isset($config['metadata']) && !is_bool($config['metadata'])) { + throw new \InvalidArgumentException("'metadata' must be a boolean"); + } + // Indexes validation foreach ($config['indexes'] ?? [] as $i => $index) { if (!isset($index['columns']) || !is_array($index['columns']) || empty($index['columns'])) { diff --git a/src/Repository/BaseRepository.php b/src/Repository/BaseRepository.php index 54f054a..574af47 100644 --- a/src/Repository/BaseRepository.php +++ b/src/Repository/BaseRepository.php @@ -214,6 +214,69 @@ public function delete(int $id): bool return $this->connection->getPdo()->prepare($sql)->execute($params); } + private function assertMetaKey(string $key): void + { + if (!preg_match('/^[a-zA-Z0-9_]+$/', $key)) { + throw new \InvalidArgumentException("Invalid metadata key: '{$key}'"); + } + } + + /** + * @throws Exception + */ + public function getMeta(int $id, string $key): mixed + { + $this->assertMetaKey($key); + + $sql = "SELECT JSON_UNQUOTE(JSON_EXTRACT(metadata, :path)) FROM {$this->table} WHERE id = :id"; + $params = ['path' => '$.' . $key, 'id' => $id]; + + if ($this->shouldApplyTenantScope()) { + $sql .= " AND tenant_id = :tenant_id"; + $params['tenant_id'] = $this->getTenantId(); + } + + $stmt = $this->connection->getPdo()->prepare($sql); + $stmt->execute($params); + $val = $stmt->fetchColumn(); + + return $val !== false ? $val : null; + } + + /** + * @throws Exception + */ + public function setMeta(int $id, string $key, mixed $value): bool + { + $this->assertMetaKey($key); + + $patch = (string) json_encode([$key => $value]); + $sql = "UPDATE {$this->table} SET metadata = JSON_MERGE_PATCH(COALESCE(metadata, '{}'), :patch) WHERE id = :id"; + $params = ['patch' => $patch, 'id' => $id]; + + if ($this->shouldApplyTenantScope()) { + $sql .= " AND tenant_id = :tenant_id"; + $params['tenant_id'] = $this->getTenantId(); + } + + return $this->connection->getPdo()->prepare($sql)->execute($params); + } + + /** + * @return array + * @throws Exception + */ + public function getAllMeta(int $id): array + { + $row = $this->findById($id); + + if ($row === null || !isset($row['metadata'])) { + return []; + } + + return json_decode((string) $row['metadata'], true) ?? []; + } + public function beginTransaction(): void { $this->connection->getPdo()->beginTransaction(); diff --git a/src/Tenant/TenantFieldRegistry.php b/src/Tenant/TenantFieldRegistry.php new file mode 100644 index 0000000..a3cf0e1 --- /dev/null +++ b/src/Tenant/TenantFieldRegistry.php @@ -0,0 +1,120 @@ + $config */ + public function __construct(array $config) + { + $this->pdo = (new Connection($config['database']))->getPdo(); + } + + /** + * Register a custom field for a tenant + entity combination. + * + * @throws \InvalidArgumentException + */ + public function register(string $tenantId, string $entity, string $fieldName, string $fieldType, string $label, bool $required = false): void + { + $this->assertFieldName($fieldName); + $this->assertFieldType($fieldType); + + $sql = <<pdo->prepare($sql)->execute([ + 'tenant_id' => $tenantId, + 'entity' => $entity, + 'field_name' => $fieldName, + 'field_type' => $fieldType, + 'label' => $label, + 'required' => $required ? 1 : 0, + ]); + } + + /** + * Return all custom field definitions for a tenant + entity. + * + * @return array> + */ + public function fieldsFor(string $tenantId, string $entity): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM tenant_fields WHERE tenant_id = :tenant_id AND entity = :entity' + ); + $stmt->execute(['tenant_id' => $tenantId, 'entity' => $entity]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Remove a custom field definition by ID. + */ + public function remove(int $id, string $tenantId): void + { + $this->pdo->prepare( + 'DELETE FROM tenant_fields WHERE id = :id AND tenant_id = :tenant_id' + )->execute(['id' => $id, 'tenant_id' => $tenantId]); + } + + /** + * Validate custom field values against registered definitions. + * + * @param array $data submitted custom field values + * @param array> $fields result of fieldsFor() + * @throws \InvalidArgumentException + */ + public function validate(array $data, array $fields): void + { + foreach ($fields as $field) { + $name = (string) $field['field_name']; + $type = (string) $field['field_type']; + $required = (bool) $field['required']; + + if ($required && !array_key_exists($name, $data)) { + throw new \InvalidArgumentException("Custom field '{$name}' is required."); + } + + if (array_key_exists($name, $data)) { + $this->assertValueType($name, $data[$name], $type); + } + } + } + + private function assertFieldName(string $name): void + { + if (!preg_match('/^[a-z][a-z0-9_]*$/', $name)) { + throw new \InvalidArgumentException("Invalid field name: '{$name}'"); + } + } + + private function assertFieldType(string $type): void + { + if (!in_array($type, ['string', 'int', 'float', 'bool'], true)) { + throw new \InvalidArgumentException("Unsupported field type: '{$type}'"); + } + } + + private function assertValueType(string $name, mixed $value, string $type): void + { + $valid = match ($type) { + 'int' => is_int($value), + 'float' => is_float($value) || is_int($value), + 'bool' => is_bool($value), + 'string' => is_string($value), + default => true, + }; + + if (!$valid) { + throw new \InvalidArgumentException("Custom field '{$name}' must be of type {$type}."); + } + } +} diff --git a/tests/Console/FieldCommandsTest.php b/tests/Console/FieldCommandsTest.php new file mode 100644 index 0000000..561f64c --- /dev/null +++ b/tests/Console/FieldCommandsTest.php @@ -0,0 +1,252 @@ + ['strategy' => 'shared'], + 'database' => [ + 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, + 'database' => 'app', 'username' => 'root', 'password' => 'root', + ], + ]; + + $app = Mockery::mock('overload:' . Application::class); + $app->allows('boot'); + $app->allows('getConfig')->andReturn($config); + } + + // ── field:add ────────────────────────────────────────────────────────────── + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_add_registers_field_successfully(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('register') + ->with('beta', 'accounts', 'vat_number', 'string', 'VAT Number', false) + ->once(); + + $tester = new CommandTester(new FieldAddCommand()); + $code = $tester->execute([ + 'entity' => 'accounts', + 'field_name' => 'vat_number', + 'type' => 'string', + '--tenant' => 'beta', + '--label' => 'VAT Number', + ]); + + $this->assertSame(0, $code); + $this->assertStringContainsString('vat_number', $tester->getDisplay()); + $this->assertStringContainsString('beta', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_add_defaults_label_to_field_name(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('register') + ->with('beta', 'accounts', 'vat_number', 'string', 'vat_number', false) + ->once(); + + $tester = new CommandTester(new FieldAddCommand()); + $code = $tester->execute([ + 'entity' => 'accounts', + 'field_name' => 'vat_number', + 'type' => 'string', + '--tenant' => 'beta', + ]); + + $this->assertSame(0, $code); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_add_returns_failure_without_tenant(): void + { + $tester = new CommandTester(new FieldAddCommand()); + $code = $tester->execute([ + 'entity' => 'accounts', + 'field_name' => 'vat_number', + 'type' => 'string', + ]); + + $this->assertSame(1, $code); + $this->assertStringContainsString('--tenant is required', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_add_returns_failure_on_invalid_field(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('register')->andThrow(new \InvalidArgumentException('Invalid field name')); + + $tester = new CommandTester(new FieldAddCommand()); + $code = $tester->execute([ + 'entity' => 'accounts', + 'field_name' => 'Bad Name', + 'type' => 'string', + '--tenant' => 'beta', + ]); + + $this->assertSame(1, $code); + $this->assertStringContainsString('Invalid field name', $tester->getDisplay()); + } + + // ── field:list ───────────────────────────────────────────────────────────── + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_list_outputs_registered_fields(): void + { + $this->mockApp(); + + $fields = [ + ['id' => 1, 'field_name' => 'vat_number', 'field_type' => 'string', 'label' => 'VAT Number', 'required' => 0], + ]; + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('fieldsFor')->with('beta', 'accounts')->andReturn($fields); + + $tester = new CommandTester(new FieldListCommand()); + $code = $tester->execute(['entity' => 'accounts', '--tenant' => 'beta']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('vat_number', $tester->getDisplay()); + $this->assertStringContainsString('VAT Number', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_list_shows_message_when_empty(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('fieldsFor')->andReturn([]); + + $tester = new CommandTester(new FieldListCommand()); + $code = $tester->execute(['entity' => 'accounts', '--tenant' => 'beta']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('No custom fields', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_list_returns_failure_without_tenant(): void + { + $tester = new CommandTester(new FieldListCommand()); + $code = $tester->execute(['entity' => 'accounts']); + + $this->assertSame(1, $code); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_list_shows_required_label(): void + { + $this->mockApp(); + + $fields = [ + ['id' => 2, 'field_name' => 'vat_number', 'field_type' => 'string', 'label' => 'VAT Number', 'required' => 1], + ]; + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('fieldsFor')->andReturn($fields); + + $tester = new CommandTester(new FieldListCommand()); + $code = $tester->execute(['entity' => 'accounts', '--tenant' => 'beta']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('[required]', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_list_returns_failure_on_exception(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('fieldsFor')->andThrow(new \RuntimeException('DB error')); + + $tester = new CommandTester(new FieldListCommand()); + $code = $tester->execute(['entity' => 'accounts', '--tenant' => 'beta']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('DB error', $tester->getDisplay()); + } + + // ── field:remove ─────────────────────────────────────────────────────────── + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_remove_deletes_field(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('remove')->with(3, 'beta')->once(); + + $tester = new CommandTester(new FieldRemoveCommand()); + $code = $tester->execute(['id' => '3', '--tenant' => 'beta']); + + $this->assertSame(0, $code); + $this->assertStringContainsString('#3', $tester->getDisplay()); + $this->assertStringContainsString('beta', $tester->getDisplay()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_remove_returns_failure_without_tenant(): void + { + $tester = new CommandTester(new FieldRemoveCommand()); + $code = $tester->execute(['id' => '3']); + + $this->assertSame(1, $code); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_field_remove_returns_failure_on_exception(): void + { + $this->mockApp(); + + $registry = Mockery::mock('overload:' . TenantFieldRegistry::class); + $registry->allows('remove')->andThrow(new \RuntimeException('DB error')); + + $tester = new CommandTester(new FieldRemoveCommand()); + $code = $tester->execute(['id' => '5', '--tenant' => 'beta']); + + $this->assertSame(1, $code); + $this->assertStringContainsString('DB error', $tester->getDisplay()); + } +} diff --git a/tests/Core/CoreSchemaManagerTest.php b/tests/Core/CoreSchemaManagerTest.php index 7a66149..81ab301 100644 --- a/tests/Core/CoreSchemaManagerTest.php +++ b/tests/Core/CoreSchemaManagerTest.php @@ -24,8 +24,7 @@ public function test_ensure_executes_tenants_table_ddl(): void { $pdo = Mockery::mock(PDO::class); $pdo->allows('exec') - ->with(Mockery::pattern('/CREATE TABLE IF NOT EXISTS tenants/')) - ->once() + ->with(Mockery::pattern('/CREATE TABLE IF NOT EXISTS/')) ->andReturn(0); /** @var MockInterface&Connection $conn */ diff --git a/tests/Generator/Builder/EntityBuilderTest.php b/tests/Generator/Builder/EntityBuilderTest.php index 683e62f..ebb461b 100644 --- a/tests/Generator/Builder/EntityBuilderTest.php +++ b/tests/Generator/Builder/EntityBuilderTest.php @@ -159,4 +159,22 @@ public function test_generates_valid_php_opening(): void $this->assertStringStartsWith('builder->build($this->schema([ + 'entity' => 'Account', + 'fields' => [], + 'metadata' => true, + ])); + + $this->assertStringContainsString('public ?array $metadata = null;', $code); + } + + public function test_omits_metadata_property_when_not_flagged(): void + { + $code = $this->builder->build($this->schema(['entity' => 'Account', 'fields' => []])); + + $this->assertStringNotContainsString('$metadata', $code); + } } diff --git a/tests/Generator/Builder/MigrationBuilderTest.php b/tests/Generator/Builder/MigrationBuilderTest.php index c6aa789..28d7471 100644 --- a/tests/Generator/Builder/MigrationBuilderTest.php +++ b/tests/Generator/Builder/MigrationBuilderTest.php @@ -192,6 +192,26 @@ public function test_build_up_maps_unknown_type_to_text(): void $this->assertStringContainsString('data TEXT', $sql); } + public function test_build_up_emits_metadata_column_when_flagged(): void + { + $schema = new EntitySchema([ + 'entity' => 'Account', + 'fields' => ['name' => 'string'], + 'metadata' => true, + ]); + + $sql = $this->builder->buildUp($schema); + + $this->assertStringContainsString('metadata JSON NULL', $sql); + } + + public function test_build_up_omits_metadata_column_when_not_flagged(): void + { + $sql = $this->builder->buildUp($this->schema(['name' => 'string'])); + + $this->assertStringNotContainsString('metadata', $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 f73fad5..1fd7a88 100644 --- a/tests/Generator/Schema/EntitySchemaTest.php +++ b/tests/Generator/Schema/EntitySchemaTest.php @@ -79,4 +79,14 @@ public function test_get_primary_key_returns_id(): void { $this->assertSame('id', $this->schema()->getPrimaryKey()); } + + public function test_has_metadata_defaults_false(): void + { + $this->assertFalse($this->schema()->hasMetadata()); + } + + public function test_has_metadata_true_when_set(): void + { + $this->assertTrue($this->schema(['metadata' => true])->hasMetadata()); + } } diff --git a/tests/Generator/Schema/SchemaValidatorTest.php b/tests/Generator/Schema/SchemaValidatorTest.php index 7189aab..0cdae95 100644 --- a/tests/Generator/Schema/SchemaValidatorTest.php +++ b/tests/Generator/Schema/SchemaValidatorTest.php @@ -179,4 +179,18 @@ public function test_index_with_non_bool_unique_throws(): void 'indexes' => [['columns' => ['status'], 'unique' => 'yes']], ]); } + + public function test_metadata_true_passes(): void + { + $this->validator->validate(['entity' => 'Account', 'metadata' => true]); + $this->assertTrue(true); + } + + public function test_metadata_non_boolean_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/'metadata' must be a boolean/"); + + $this->validator->validate(['entity' => 'Account', 'metadata' => 'yes']); + } } diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php index 59092f0..0b09656 100644 --- a/tests/Repository/BaseRepositoryTest.php +++ b/tests/Repository/BaseRepositoryTest.php @@ -271,6 +271,134 @@ public function test_resolve_table_name_derives_from_class_name(): void $this->assertSame('widgets', $result); } + // ── getMeta ──────────────────────────────────────────────────────────────── + + public function test_get_meta_shared_strategy_scopes_by_tenant(): void + { + TenantContext::setTenantId('acme'); + + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->with(['path' => '$.vat_number', 'id' => 1, 'tenant_id' => 'acme'])->once()->andReturn(true); + $stmt->allows('fetchColumn')->andReturn('BE0123456789'); + + $this->pdo->allows('prepare') + ->with("SELECT JSON_UNQUOTE(JSON_EXTRACT(metadata, :path)) FROM widgets WHERE id = :id AND tenant_id = :tenant_id") + ->andReturn($stmt); + + $result = $this->repo('shared')->getMeta(1, 'vat_number'); + + $this->assertSame('BE0123456789', $result); + } + + public function test_get_meta_returns_null_when_not_found(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + $stmt->allows('fetchColumn')->andReturn(false); + + $this->pdo->allows('prepare') + ->with("SELECT JSON_UNQUOTE(JSON_EXTRACT(metadata, :path)) FROM widgets WHERE id = :id") + ->andReturn($stmt); + + $result = $this->repo('database')->getMeta(1, 'vat_number'); + + $this->assertNull($result); + } + + public function test_get_meta_throws_on_invalid_key(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid metadata key/'); + + $this->repo('database')->getMeta(1, 'bad key!'); + } + + // ── setMeta ──────────────────────────────────────────────────────────────── + + public function test_set_meta_shared_strategy_patches_json(): void + { + TenantContext::setTenantId('acme'); + + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + + $this->pdo->allows('prepare') + ->with("UPDATE widgets SET metadata = JSON_MERGE_PATCH(COALESCE(metadata, '{}'), :patch) WHERE id = :id AND tenant_id = :tenant_id") + ->andReturn($stmt); + + $result = $this->repo('shared')->setMeta(1, 'vat_number', 'BE0123'); + + $this->assertTrue($result); + } + + public function test_set_meta_database_strategy_no_tenant_scope(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + + $this->pdo->allows('prepare') + ->with("UPDATE widgets SET metadata = JSON_MERGE_PATCH(COALESCE(metadata, '{}'), :patch) WHERE id = :id") + ->andReturn($stmt); + + $result = $this->repo('database')->setMeta(1, 'vat_number', 'BE0123'); + + $this->assertTrue($result); + } + + public function test_set_meta_throws_on_invalid_key(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid metadata key/'); + + $this->repo('database')->setMeta(1, 'bad key!', 'value'); + } + + // ── getAllMeta ───────────────────────────────────────────────────────────── + + public function test_get_all_meta_decodes_json_column(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn([ + 'id' => 1, + 'metadata' => '{"vat_number":"BE0123","fiscal_year":"Apr"}', + ]); + + $this->pdo->allows('prepare') + ->with('SELECT * FROM widgets WHERE id = :id') + ->andReturn($stmt); + + $result = $this->repo('database')->getAllMeta(1); + + $this->assertSame(['vat_number' => 'BE0123', 'fiscal_year' => 'Apr'], $result); + } + + public function test_get_all_meta_returns_empty_when_metadata_null(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn(['id' => 1, 'metadata' => null]); + + $this->pdo->allows('prepare') + ->with('SELECT * FROM widgets WHERE id = :id') + ->andReturn($stmt); + + $this->assertSame([], $this->repo('database')->getAllMeta(1)); + } + + public function test_get_all_meta_returns_empty_when_row_not_found(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->once()->andReturn(true); + $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn(false); + + $this->pdo->allows('prepare') + ->with('SELECT * FROM widgets WHERE id = :id') + ->andReturn($stmt); + + $this->assertSame([], $this->repo('database')->getAllMeta(99)); + } + public function test_transaction_methods_delegate_to_pdo(): void { $this->pdo->allows('beginTransaction')->once()->andReturn(true); diff --git a/tests/Tenant/TenantFieldRegistryTest.php b/tests/Tenant/TenantFieldRegistryTest.php new file mode 100644 index 0000000..5b449f4 --- /dev/null +++ b/tests/Tenant/TenantFieldRegistryTest.php @@ -0,0 +1,262 @@ +pdo = Mockery::mock(PDO::class); + } + + protected function tearDown(): void + { + Mockery::close(); + } + + private function config(): array + { + return [ + 'database' => [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'database' => 'app', + 'username' => 'root', + 'password' => 'root', + ], + ]; + } + + private function registry(): TenantFieldRegistry + { + /** @var MockInterface&Connection $conn */ + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + return new TenantFieldRegistry($this->config()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_register_inserts_field_definition(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute') + ->with(Mockery::on(fn($p) => + $p['tenant_id'] === 'beta' && + $p['entity'] === 'accounts' && + $p['field_name'] === 'vat_number' && + $p['field_type'] === 'string' && + $p['label'] === 'VAT Number' && + $p['required'] === 0 + )) + ->once() + ->andReturn(true); + + $this->pdo->allows('prepare') + ->with(Mockery::pattern('/INSERT INTO tenant_fields/')) + ->andReturn($stmt); + + $this->registry()->register('beta', 'accounts', 'vat_number', 'string', 'VAT Number'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_register_throws_on_invalid_field_name(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Invalid field name/'); + + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + (new TenantFieldRegistry($this->config())) + ->register('beta', 'accounts', 'Bad Name', 'string', 'Bad'); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_register_throws_on_unsupported_field_type(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Unsupported field type/'); + + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + (new TenantFieldRegistry($this->config())) + ->register('beta', 'accounts', 'vat_number', 'array', 'VAT'); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_fields_for_returns_tenant_scoped_definitions(): void + { + $rows = [ + ['id' => 1, 'tenant_id' => 'beta', 'entity' => 'accounts', 'field_name' => 'vat_number', 'field_type' => 'string', 'label' => 'VAT Number', 'required' => 0], + ]; + + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->with(['tenant_id' => 'beta', 'entity' => 'accounts'])->once()->andReturn(true); + $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn($rows); + + $this->pdo->allows('prepare') + ->with('SELECT * FROM tenant_fields WHERE tenant_id = :tenant_id AND entity = :entity') + ->andReturn($stmt); + + $result = $this->registry()->fieldsFor('beta', 'accounts'); + + $this->assertSame($rows, $result); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_remove_deletes_by_id_and_tenant(): void + { + $stmt = Mockery::mock(PDOStatement::class); + $stmt->allows('execute')->with(['id' => 3, 'tenant_id' => 'beta'])->once()->andReturn(true); + + $this->pdo->allows('prepare') + ->with('DELETE FROM tenant_fields WHERE id = :id AND tenant_id = :tenant_id') + ->andReturn($stmt); + + $this->registry()->remove(3, 'beta'); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_passes_for_valid_data(): void + { + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [ + ['field_name' => 'vat_number', 'field_type' => 'string', 'required' => true], + ]; + + $registry->validate(['vat_number' => 'BE0123'], $fields); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_throws_when_required_field_missing(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/Custom field 'vat_number' is required/"); + + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [ + ['field_name' => 'vat_number', 'field_type' => 'string', 'required' => true], + ]; + + $registry->validate([], $fields); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_throws_on_type_mismatch(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches("/must be of type int/"); + + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [ + ['field_name' => 'fiscal_year', 'field_type' => 'int', 'required' => false], + ]; + + $registry->validate(['fiscal_year' => 'not-an-int'], $fields); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_optional_field_absent_passes(): void + { + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [ + ['field_name' => 'vat_number', 'field_type' => 'string', 'required' => false], + ]; + + $registry->validate([], $fields); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_passes_for_float_value(): void + { + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [['field_name' => 'rate', 'field_type' => 'float', 'required' => false]]; + $registry->validate(['rate' => 1.5], $fields); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_passes_for_bool_value(): void + { + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [['field_name' => 'active', 'field_type' => 'bool', 'required' => false]]; + $registry->validate(['active' => true], $fields); + + $this->assertTrue(true); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function test_validate_unknown_type_passes_without_error(): void + { + $conn = Mockery::mock('overload:' . Connection::class); + $conn->allows('getPdo')->andReturn($this->pdo); + + $registry = new TenantFieldRegistry($this->config()); + + $fields = [['field_name' => 'misc', 'field_type' => 'unknown', 'required' => false]]; + $registry->validate(['misc' => 'anything'], $fields); + + $this->assertTrue(true); + } +}