diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 218e7b0..8f66c71 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -82,10 +82,16 @@ jobs: echo \"PASS\n\"; " + - name: Import Codecov GPG key + if: env.LOG_COVERAGE + run: | + gpg --keyserver keyserver.ubuntu.com \ + --recv-keys 27034E7FDB850E0BBC2C62FF806BB28AED779869 || true + - name: Upload coverage to Codecov if: env.LOG_COVERAGE uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage/clover.xml - fail_ci_if_error: true + fail_ci_if_error: false diff --git a/readme.md b/readme.md index 238ffc0..088a0ee 100644 --- a/readme.md +++ b/readme.md @@ -1,321 +1,431 @@ -# ๐Ÿš€ EntityForge +# EntityForge -**EntityForge** is a WIP open source configuration-driven, multi-tenant SaaS framework built in PHP. +**EntityForge** is a configuration-driven, multi-tenant SaaS framework built in PHP 8.4. -It enables developers to build scalable SaaS applications with: - -* JSON-based code generation -* Multi-tenant architecture (shared or database-per-tenant) -* Automated migrations and rollback -* Tenant provisioning and lifecycle management +It provides everything needed to build a scalable SaaS backend: JSON-driven code generation, two tenant isolation strategies, automated migrations, an HTTP routing layer with middleware pipeline, and a dependency injection container โ€” all wired together through a single boot cycle. --- -# โœจ Features +## Features -## ๐Ÿงฉ Configuration-Driven Development +- **Code generation** from JSON schemas โ€” entities, repositories, and migrations in one command; supports field types, foreign key relations, and composite indexes +- **Two tenancy strategies** โ€” shared database (scoped by `tenant_id`) or database-per-tenant +- **Tenant lifecycle management** โ€” onboard, suspend, resume, offboard via `TenantService` +- **HTTP layer** โ€” `Router` (backed by FastRoute), immutable `Pipeline`, immutable `Request`/`Response` value objects +- **Middleware pipeline** โ€” composable, immutable, executed outermost-first +- **DI container** โ€” bind, singleton, instance, and reflection-based autowire +- **Migration system** โ€” forward and rollback with dry-run, batch-tracked, tenant-aware +- **Multi-worker safe** โ€” `RequestLifecycle` prevents tenant state leaking between requests in long-lived workers (Swoole, RoadRunner, Octane) -Define your application using JSON: +--- -```json -{ - "entity": "User", - "multiTenant": true, - "timestamps": true, - "fields": { - "name": "string", - "email": "string" - } -} -``` +## Requirements + +- PHP 8.4+ +- MySQL (PDO) +- Composer --- -## ๐Ÿข Multi-Tenant Architecture +## Installation -Supports two strategies: +```bash +composer require entity-forge/entity-forge +``` -* **Shared Database** -* **Database per Tenant** +> Package publication to Packagist is pending. Clone the repo and run `composer install` to use locally. --- -## โš™๏ธ Code Generation +## Quick Start -Generate: +### 1. Configure tenancy -* Entities -* Repositories -* Migrations +`config/application.yaml`: -```bash -php bin/ef generate User -php bin/ef generate:all +```yaml +tenancy: + enabled: true + strategy: shared # or: database + resolver: header # or: subdomain + header_key: X-Tenant-ID + +database: + driver: mysql + host: 127.0.0.1 + port: 3306 + database: entity_forge + username: root + password: root ``` ---- +### 2. Define an entity -## ๐Ÿ—„๏ธ Migration System +`config/entities/Order.json`: -* Forward migrations -* Rollback support -* Batch tracking +```json +{ + "entity": "Order", + "fields": { "amount": "float", "status": "string" }, + "relations": { + "belongsTo": { "User": "user_id" } + }, + "indexes": [ + { "columns": ["status"] }, + { "columns": ["user_id", "status"], "unique": true } + ] +} +``` + +`relations.belongsTo` emits a `CONSTRAINT fk_โ€ฆ FOREIGN KEY` clause. `indexes` emits `INDEX` or `UNIQUE INDEX` clauses. Both are optional. + +### 3. Generate and migrate ```bash +php bin/ef generate Order --migration php bin/ef migrate -php bin/ef migrate:rollback ``` ---- - -## ๐Ÿ—๏ธ Tenant Provisioning +### 4. Onboard a tenant (database strategy) -Automatically creates: +```bash +php bin/ef tenant:create acme --name "Acme Corp" +``` -* Tenant database -* Schema (via migrations) +Or programmatically โ€” this also registers the tenant in the `tenants` table: -```bash -php bin/ef tenant:create tenant_1 +```php +$app->getContainer()->make(TenantService::class)->onboard('acme', 'Acme Corp'); ``` ---- +### 5. Boot and query -## ๐Ÿง  Tenant Registry +```php +use EntityForge\Core\Application; +use App\Repository\OrderRepository; -Central `tenants` table stores: +$app = new Application(__DIR__ . '/config'); +$app->boot(['headers' => ['X-Tenant-ID' => 'acme']], true); -* tenant_id -* name -* status +$repo = new OrderRepository($app->getConfig()); +$repo->create(['amount' => 99.00, 'status' => 'pending']); +print_r($repo->findAll()); +``` ---- +### 6. Handle an HTTP request + +```php +use EntityForge\Http\{Router, Pipeline, Request, Response}; -# ๐Ÿ“ฆ Installation +$router = new Router(); +$router->get('/orders', fn(Request $req): Response => (new Response())->withJson($repo->findAll())); +$router->get('/orders/{id}', fn(Request $req): Response => (new Response())->withJson($repo->findById((int) $req->param('id')))); +$router->post('/orders', fn(Request $req): Response => (new Response())->withJson($repo->create(['amount' => $req->body('amount'), 'status' => 'pending']), 201)); -This will be part of PHP Packagist and will replace the entity-forge ORM. +$pipeline = (new Pipeline()) + ->pipe(new AuthMiddleware()) + ->pipe(new TenantMiddleware()); -```bash -composer require vedavith/entity-forge +$response = $pipeline->run(Request::capture(), fn(Request $req): Response => $router->dispatch($req)); +$response->send(); ``` --- -# โšก Quick Start +## CLI Reference -## 1. Configure tenancy +| Command | Options | Description | +|---|---|---| +| `generate ` | `--migration` | Generate entity + repository from JSON schema | +| `generate:all` | `--config-dir` | Generate all schemas in `config/entities/` | +| `migrate` | `--dry-run` | Run pending migrations on the main database | +| `migrate:rollback` | `--dry-run` | Roll back the last migration batch | +| `migrate:all-tenants` | `--tenant `, `--parallel N`, `--dry-run` | Run pending migrations on every active tenant DB | +| `tenant:create ` | `--name` | Onboard a new tenant | -### ๐ŸŸข Shared Database - -```yaml -tenancy: - enabled: true - strategy: shared +`generate:all` uses a single `EntityGenerator` instance to guarantee monotonically ordered migration timestamps within a session. -database: - driver: mysql - host: 127.0.0.1 - port: 3306 - database: entity_forge - username: root - password: root -``` +`migrate:all-tenants` spawns up to `--parallel N` (default 5) concurrent worker processes via `symfony/process`. Suspended tenants are skipped. A per-tenant failure is reported but does not stop other tenants from being migrated. --- -### ๐Ÿ”ต Database per Tenant +## Tenancy Strategies -```yaml -tenancy: - enabled: true - strategy: database +The pivot is `tenancy.strategy` in `config/application.yaml`. -database: - driver: mysql - host: 127.0.0.1 - port: 3306 - database: entity_forge - username: root - password: root +### `shared` โ€” single database, tenant_id column + +Every table has a `tenant_id` column. `BaseRepository` automatically appends `WHERE tenant_id = :tenant_id` (and `AND tenant_id = :tenant_id` on writes) to every query. + +``` +entity_forge + โ”œโ”€โ”€ tenants โ† registry + โ””โ”€โ”€ orders โ† tenant_id = 'acme' | 'corp' | ... ``` ---- +### `database` โ€” one database per tenant -## 2. Generate entities +Each tenant gets its own database named `{base_db}_{tenantId}`. `TenantConnectionResolver` selects the correct connection. No `tenant_id` column needed. -```bash -php bin/ef generate User --migration -php bin/ef migrate +``` +entity_forge โ† main DB: tenants registry only +entity_forge_acme โ† tenant DB: all application data +entity_forge_corp โ† tenant DB: all application data ``` --- -## 3. Create a tenant +## Tenant Resolution -```bash -php bin/ef tenant:create tenant_1 -``` +Configure via `tenancy.resolver` in `application.yaml`: + +| Resolver | Config keys | How it works | +|---|---|---| +| `header` | `header_key` (default: `X-Tenant-ID`) | Reads the named HTTP header from the request context | +| `subdomain` | `subdomain_depth`, `subdomain_min_parts` | Extracts the leading subdomain from the `host` context key (`acme.example.com` โ†’ `acme`). Set `subdomain_min_parts: 2` to support two-part hosts like `acme.io` | + +Add custom resolvers by implementing `TenantResolverInterface` and registering them in `TenantResolverFactory`. --- -## 4. Use in application +## Tenant Lifecycle + +`TenantService` is the canonical entry point for tenant operations. It is pre-registered as a singleton in the DI container after `boot()`. ```php -$app->boot([ - 'headers' => ['X-Tenant-ID' => 'tenant_1'] -], true); +$svc = $app->getContainer()->make(TenantService::class); -$repo = new UserRepository($app->getConfig()); +$svc->onboard('acme', 'Acme Corp'); // validates ID, provisions DB, runs migrations, registers tenant +$svc->suspend('acme'); // sets status = 'suspended'; blocks future boots +$svc->resume('acme'); // sets status = 'active' +$svc->offboard('acme'); // drops DB (database strategy) + removes tenant record +``` -$repo->create([ - 'name' => 'Ved', - 'email' => 'ved@example.com' -]); +`onboard()` rejects tenant IDs that do not match `^[a-zA-Z0-9_-]+$`. -print_r($repo->findAll()); -``` +`TenantProvisioner::create()` rolls back atomically on failure โ€” if migrations fail after the database was created, the database is dropped before re-throwing. No orphaned databases. ---- +Suspended tenants are blocked at `Application::boot()` โ€” `assertTenantActive()` throws before any repository is instantiated. -# โš™๏ธ Configuration Details +--- -## ๐ŸŸข Shared Database Strategy +## HTTP Layer -All tenants share a single database. +### Router -### Structure +Backed by `nikic/fast-route`. Supports `{name}` parameter segments. Register exact paths before parameterised ones โ€” routes match in registration order. +```php +$router = new Router(); +$router->get('/users', fn(Request $req): Response => ...); +$router->get('/users/{id}', fn(Request $req): Response => ... $req->param('id') ...); +$router->post('/users', fn(Request $req): Response => ...); +$router->put('/users/{id}', fn(Request $req): Response => ...); +$router->delete('/users/{id}', fn(Request $req): Response => ...); + +$response = $router->dispatch($request); // returns 404 or 405 automatically ``` -entity_forge - โ””โ”€โ”€ users - id - name - tenant_id + +### Request + +Immutable value object. Constructed directly or captured from PHP superglobals: + +```php +$request = new Request(headers: [...], query: [...], body: [...], method: 'POST', path: '/users'); +$request = Request::capture(); // reads $_SERVER, $_GET, $_POST, getallheaders() + +$request->header('X-Tenant-ID'); +$request->query('page'); +$request->body('name'); +$request->method(); // 'GET', 'POST', ... +$request->path(); // '/users/42' +$request->param('id'); // route parameter injected by Router +$request->params(); // all route parameters as array ``` -### Characteristics +### Response -* Uses `tenant_id` for isolation -* Lower infrastructure cost -* Easier to manage +Three output modes: ---- +```php +// Immutable builder โ€” standard pipeline path +$response = (new Response()) + ->withJson(['id' => 1], 201) + ->withHeader('X-Request-Id', $id); +$response->send(); // http_response_code + headers + echo body + +// Streaming โ€” caller controls chunk output and flush timing +(new Response()) + ->withStatus(200) + ->withHeader('Content-Type', 'text/csv') + ->stream(function (): void { + echo "id,name\n"; + flush(); + }); + +// Legacy direct-echo (kept for backwards compatibility) +(new Response())->json(['ok' => true], 200); +``` + +### Middleware Pipeline -## ๐Ÿ”ต Database per Tenant Strategy +Immutable chain โ€” each `pipe()` call returns a new instance. Executed outermost-first. -Each tenant gets a dedicated database. +```php +interface MiddlewareInterface { + public function handle(Request $request, callable $next): Response; +} -### Structure +$pipeline = (new Pipeline()) + ->pipe(new LoggingMiddleware()) + ->pipe(new AuthMiddleware()); +$response = $pipeline->run($request, fn(Request $req): Response => $router->dispatch($req)); ``` -entity_forge (main DB) - โ””โ”€โ”€ tenants -entity_forge_tenant_1 - โ””โ”€โ”€ users +--- -entity_forge_tenant_2 - โ””โ”€โ”€ users -``` +## DI Container -### Characteristics +```php +$container = $app->getContainer(); -* Full data isolation -* No `tenant_id` column required -* Better for enterprise use cases +$container->bind(MyService::class, fn($c) => new MyService($c->make(Dep::class))); // new instance per call +$container->singleton(Cache::class, fn() => new RedisCache()); // shared instance +$container->instance(Config::class, $myConfig); // pre-built object + +$service = $container->make(MyService::class); +``` + +Unregistered classes are resolved automatically via reflection. All constructor parameters must be typed class parameters or have default values; otherwise `make()` throws `InvalidArgumentException`. --- -## Tenant Database Naming +## Repository Layer -``` -{base_database}_{tenant_id} +All generated repositories extend `BaseRepository` and inherit: + +```php +public function create(array $data): array +public function findAll(): array +public function findById(int $id): ?array +public function where(array $conditions): array +public function update(int $id, array $data): bool +public function delete(int $id): bool + +public function beginTransaction(): void +public function commit(): void +public function rollback(): void ``` -Example: +Column names passed to `create()`, `where()`, and `update()` are validated against `^[a-zA-Z0-9_]+$` before SQL interpolation. `InvalidArgumentException` is thrown on violation. -``` -entity_forge_tenant_1 -entity_forge_tenant_2 -``` +The table name is derived from the class name (`OrderRepository` โ†’ `orders`). Set `$this->table` in the subclass constructor to override. + +Never reuse a repository instance across tenant switches โ€” instantiate a fresh one after `TenantContext::setTenantId()`. --- -# ๐Ÿงฑ Architecture Overview +## Migration System ``` -Application - โ”œโ”€โ”€ Core (boot, config, schema) - โ”œโ”€โ”€ Tenant (context, resolver, provisioning) - โ”œโ”€โ”€ Database (connection, migrations) - โ”œโ”€โ”€ Generator (entity, repository, migration) - โ””โ”€โ”€ Repository (data access layer) +database/migrations/ + 20260101_000001_create_orders_table.up.sql + 20260101_000001_create_orders_table.down.sql ``` ---- +Every `.up.sql` must have a paired `.down.sql`. A missing down file aborts rollback with an exception. `MigrationRunner` skips already-executed files based on the `migrations` tracking table (auto-created). -# ๐Ÿ”„ Tenant Lifecycle +Both `run()` and `rollback()` accept a dry-run mode โ€” all writes are skipped and output is prefixed with `[DRY RUN]`: -``` -Onboard โ†’ Create DB โ†’ Run Migrations โ†’ Register Tenant +```bash +php bin/ef migrate --dry-run +php bin/ef migrate:rollback --dry-run ``` --- -# ๐Ÿ—„๏ธ Database Design +## Long-Lived Workers -## Main Database +`TenantContext` is a static singleton. In PHP-FPM static state resets per process. In persistent runtimes (Swoole, RoadRunner, Octane), it persists between requests. -``` -entity_forge - โ””โ”€โ”€ tenants +`TenantContext::setTenantId()` throws `LogicException` if a tenant ID is already set โ€” a forgotten `RequestLifecycle::begin()` call surfaces as a hard error rather than a silent data leak. + +Wrap each request loop iteration: + +```php +RequestLifecycle::begin(); // clears TenantContext + flushes connection cache + +// ... handle request ... + +RequestLifecycle::end(); // clears again on teardown ``` -## Tenant Databases +--- + +## Boot Sequence ``` -entity_forge_tenant_1 -entity_forge_tenant_2 +Application::boot($context, $resolveTenant) + โ”‚ + โ”œโ”€โ”€ ConfigLoader::loadMultiple([saas.yaml, application.yaml]) + โ”‚ array_replace_recursive โ€” application.yaml wins on conflicts + โ”‚ + โ”œโ”€โ”€ ConfigValidator::validate() + โ”‚ requires: tenancy.enabled, database.{driver,host,port,database,username,password} + โ”‚ + โ”œโ”€โ”€ CoreSchemaManager::ensure() + โ”‚ CREATE TABLE IF NOT EXISTS tenants (always, both strategies) + โ”‚ + โ”œโ”€โ”€ Container::registerBindings() + โ”‚ singletons: TenantRepository, TenantProvisioner, TenantService + โ”‚ + โ””โ”€โ”€ if $resolveTenant && tenancy.enabled: + TenantResolverFactory::create() โ†’ resolver.resolve($context) + TenantContext::setTenantId() โ† throws LogicException if already set + if strategy === database: + TenantRepository::findByTenantId() โ€” throws if not found or suspended ``` +Pass `false` as the second argument to skip tenant resolution โ€” required for CLI commands that run before a tenant is set. + --- -# โš ๏ธ Important Rules +## Key Invariants -* Always call `boot()` before using repositories -* Never reuse repository instances across tenant switches -* Keep tenant registry in the main database -* Keep application data in tenant databases +1. **Tenant isolation is never optional.** Every query decision must account for both strategies. +2. **Main DB โ†” tenant DB boundary is sacred.** The `tenants` registry lives only in the main DB. Application data lives only in tenant DBs (or is scoped by `tenant_id` in shared mode). +3. **Repository instances are not reusable across tenant switches.** Instantiate fresh after `TenantContext::setTenantId()`. +4. **Idempotent infrastructure.** `CREATE TABLE IF NOT EXISTS`, batch-tracked migrations, `CoreSchemaManager` โ€” follow this pattern for all schema management. +5. **Explicit over implicit.** Tenant resolution, connection selection, and scope injection are always conscious calls. +6. **Configuration drives generation.** New entity types go through the generator pipeline, not handwritten files. --- -# ๐Ÿงช CLI Commands +## Running Tests ```bash -php bin/ef generate User -php bin/ef generate:all -php bin/ef migrate -php bin/ef migrate:rollback -php bin/ef tenant:create tenant_1 +composer install +vendor/bin/phpunit +vendor/bin/phpunit tests/Path/To/SomeTest.php # single file ``` --- -# ๐Ÿšง Roadmap +## Roadmap -* [ ] Middleware for automatic tenant resolution -* [ ] Dependency Injection container -* [ ] API layer +- [ ] JWT / session-based tenant resolver +- [ ] Artisan-style scaffolding for middleware and controllers +- [ ] Official Packagist release --- -# ๐Ÿค Contributing +## Contributing -Contributions are welcome. -Feel free to open issues or pull requests. +Contributions are welcome. Open an issue or pull request on GitHub. --- -# ๐Ÿ“„ License +## License -MIT License +MIT