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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin/ef
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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();
25 changes: 25 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
44 changes: 42 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -195,6 +193,9 @@ $response->send();
| `tenant:create <id>` | `--name` | Onboard a new tenant |
| `make:middleware <Name>` | `--auth`, `--output` | Scaffold a middleware class (use `--auth` for `AuthMiddlewareInterface` stub) |
| `make:controller <Name>` | `--output` | Scaffold a controller class with CRUD stubs |
| `field:add <entity> <field_name> <type>` | `--tenant`, `--label`, `--required` | Register a custom field definition for a tenant |
| `field:list <entity>` | `--tenant` | List all custom fields registered for a tenant entity |
| `field:remove <id>` | `--tenant` | Remove a custom field definition by ID |

`generate:all` uses a single `EntityGenerator` instance to guarantee monotonically ordered migration timestamps within a session.

Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/Console/FieldAddCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace EntityForge\Console;

use EntityForge\Core\Application;
use EntityForge\Tenant\TenantFieldRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class FieldAddCommand extends Command
{
protected function configure(): void
{
$this
->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('<error>--tenant is required</error>');
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("<info>Field '{$fieldName}' ({$type}) added to '{$entity}' for tenant '{$tenantId}'</info>");
} catch (\Throwable $e) {
$output->writeln("<error>{$e->getMessage()}</error>");
return Command::FAILURE;
}

return Command::SUCCESS;
}
}
63 changes: 63 additions & 0 deletions src/Console/FieldListCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace EntityForge\Console;

use EntityForge\Core\Application;
use EntityForge\Tenant\TenantFieldRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class FieldListCommand extends Command
{
protected function configure(): void
{
$this
->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('<error>--tenant is required</error>');
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("<comment>No custom fields registered for '{$entity}' (tenant: {$tenantId})</comment>");
return Command::SUCCESS;
}

$output->writeln("<info>Custom fields for '{$entity}' (tenant: {$tenantId}):</info>");
$output->writeln('');

foreach ($fields as $field) {
$required = $field['required'] ? ' <comment>[required]</comment>' : '';
$output->writeln(
" <info>#{$field['id']}</info> {$field['field_name']} <comment>({$field['field_type']})</comment> {$field['label']}{$required}"
);
}
} catch (\Throwable $e) {
$output->writeln("<error>{$e->getMessage()}</error>");
return Command::FAILURE;
}

return Command::SUCCESS;
}
}
49 changes: 49 additions & 0 deletions src/Console/FieldRemoveCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace EntityForge\Console;

use EntityForge\Core\Application;
use EntityForge\Tenant\TenantFieldRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class FieldRemoveCommand extends Command
{
protected function configure(): void
{
$this
->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('<error>--tenant is required</error>');
return Command::FAILURE;
}

$app = new Application(getcwd() . '/config');
$app->boot([], false);

$registry = new TenantFieldRegistry($app->getConfig());

try {
$registry->remove($id, $tenantId);
$output->writeln("<info>Field #{$id} removed for tenant '{$tenantId}'</info>");
} catch (\Throwable $e) {
$output->writeln("<error>{$e->getMessage()}</error>");
return Command::FAILURE;
}

return Command::SUCCESS;
}
}
18 changes: 18 additions & 0 deletions src/Core/CoreSchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ private function definitions(): array
{
return [
$this->tenantsTable(),
$this->tenantFieldsTable(),
];
}

Expand All @@ -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 <<<SQL
CREATE TABLE IF NOT EXISTS tenant_fields (
id INT AUTO_INCREMENT PRIMARY KEY,
tenant_id VARCHAR(255) NOT NULL,
entity VARCHAR(255) NOT NULL,
field_name VARCHAR(255) NOT NULL,
field_type VARCHAR(50) NOT NULL,
label VARCHAR(255) NOT NULL,
required BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uix_tenant_fields (tenant_id, entity, field_name)
)
SQL;
}
}
4 changes: 4 additions & 0 deletions src/Generator/Builder/EntityBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public function build(EntitySchema $schema): string
$properties .= " public {$this->mapType($type)} \${$name};\n";
}

if ($schema->hasMetadata()) {
$properties .= " public ?array \$metadata = null;\n";
}

$relationsCode = $this->buildRelations($schema);
$useBlock = $this->buildUseStatements($schema);
$body = $properties . ($relationsCode !== '' ? "\n{$relationsCode}" : '');
Expand Down
4 changes: 4 additions & 0 deletions src/Generator/Builder/MigrationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
5 changes: 5 additions & 0 deletions src/Generator/Schema/EntitySchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,9 @@ public function getIndexes(): array
{
return $this->config['indexes'] ?? [];
}

public function hasMetadata(): bool
{
return $this->config['metadata'] ?? false;
}
}
5 changes: 5 additions & 0 deletions src/Generator/Schema/SchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down
Loading
Loading