diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
index 7d257b5..218e7b0 100644
--- a/.github/workflows/php.yml
+++ b/.github/workflows/php.yml
@@ -1,39 +1,91 @@
-name: PHP Composer
+name: CI
on:
push:
- branches: [ "main" ]
+ branches: ["main", "feature/**"]
pull_request:
- branches: [ "main" ]
+ branches: ["main"]
permissions:
contents: read
jobs:
- build:
-
+ test:
+ name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.4"]
+
+ env:
+ # Collect coverage only on PRs and pushes to main โ keeps all other runs fast
+ LOG_COVERAGE: "${{ fromJSON('{true: \"1\", false: \"\"}')[matrix.php == '8.4' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))] }}"
+
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: pdo, pdo_mysql, mbstring, xml
+ coverage: ${{ env.LOG_COVERAGE && 'pcov' || 'none' }}
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
- - name: Validate composer.json and composer.lock
- run: composer validate --strict
+ - name: Cache Composer packages
+ uses: actions/cache@v4
+ with:
+ path: vendor
+ key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-${{ matrix.php }}-
- - name: Cache Composer packages
- id: composer-cache
- uses: actions/cache@v3
- with:
- path: vendor
- key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
- restore-keys: |
- ${{ runner.os }}-php-
+ - name: Install dependencies
+ run: |
+ composer install --prefer-dist --no-progress --no-interaction
+ if [ -n "$LOG_COVERAGE" ]; then mkdir -p coverage; fi
- - name: Install dependencies
- run: composer install --prefer-dist --no-progress
+ - name: Run tests
+ run: |
+ vendor/bin/phpunit \
+ --no-progress \
+ --fail-on-warning \
+ --fail-on-risky \
+ --fail-on-notice \
+ --fail-on-deprecation \
+ --display-notices \
+ --display-deprecations \
+ --display-phpunit-deprecations \
+ --display-warnings \
+ --display-errors \
+ $(if [ -n "$LOG_COVERAGE" ]; then echo "--coverage-clover coverage/clover.xml --coverage-text"; else echo "--no-coverage"; fi)
- # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit"
- # Docs: https://getcomposer.org/doc/articles/scripts.md
+ - name: Enforce 80% line coverage
+ if: env.LOG_COVERAGE
+ run: |
+ php -r "
+ \$xml = simplexml_load_file('coverage/clover.xml');
+ \$m = \$xml->project->metrics;
+ \$total = (int)\$m['statements'];
+ \$covered = (int)\$m['coveredstatements'];
+ \$pct = \$total > 0 ? round(\$covered / \$total * 100, 2) : 0;
+ echo \"Line coverage: {\$pct}%\n\";
+ if (\$pct < 80) {
+ echo \"FAIL: {\$pct}% is below the required 80%.\n\";
+ exit(1);
+ }
+ echo \"PASS\n\";
+ "
- # - name: Run test suite
- # run: composer run-script test
+ - 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
diff --git a/.gitignore b/.gitignore
index 854da81..16d3c2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,10 @@
.env
.DS_Store
composer.lock
-/vendor
\ No newline at end of file
+/vendor
+/coverage
+CLAUDE.md
+/.claude
+/app
+/database
+.phpunit.result.cache
\ No newline at end of file
diff --git a/bin/ef b/bin/ef
new file mode 100755
index 0000000..5e0a4cf
--- /dev/null
+++ b/bin/ef
@@ -0,0 +1,21 @@
+#!/usr/bin/env php
+addCommand(new GenerateCommand());
+$application->addCommand(new GenerateAllCommand());
+$application->addCommand(new MigrateCommand());
+$application->addCommand(new RollbackCommand());
+$application->addCommand(new TenantCreateCommand());
+
+$application->run();
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 5717a76..6ae0880 100644
--- a/composer.json
+++ b/composer.json
@@ -1,32 +1,29 @@
{
"name": "entity-forge/entity-forge",
- "version": "1.0",
- "description": "EntityForge โ a simple PHP library to generate and manage entity models",
+ "description": "A configuration-driven PHP framework for generating entity models and building multi-tenant SaaS applications.",
"type": "library",
"license": "MIT",
- "authors": [
- {
- "name": "Vedavith Ravula",
- "email": "vedavithravula1996@gmail.com"
+ "autoload": {
+ "psr-4": {
+ "EntityForge\\": "src/",
+ "App\\": "app/"
}
- ],
+ },
"require": {
- "php": ">=8.0",
- "symfony/console": "^v6.4.8",
- "ext-pdo": "*",
- "symfony/dependency-injection": "^v7.1.1"
+ "php": "^8.4",
+ "symfony/yaml": "^8.1",
+ "symfony/console": "^8.1",
+ "ext-pdo": "*"
},
"require-dev": {
- "phpunit/phpunit": "^9.6"
+ "phpunit/phpunit": "^13.0",
+ "mockery/mockery": "^1.6"
},
- "autoload": {
- "psr-4": {
- "EntityForge\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "EntityForge\\Tests\\": "tests/"
- }
- }
+
+ "bin": [
+ "bin/ef"
+ ],
+
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
diff --git a/config/application.yaml b/config/application.yaml
new file mode 100755
index 0000000..db917b1
--- /dev/null
+++ b/config/application.yaml
@@ -0,0 +1,16 @@
+application:
+ name: entity-forge-app
+
+tenancy:
+ enabled: true
+ resolver: header
+ header_key: X-Tenant-ID
+ strategy: database # shared | database
+
+database:
+ driver: mysql
+ host: localhost
+ port: 3306
+ username: root
+ password: root
+ database: entity_forge
\ No newline at end of file
diff --git a/config/entities/User.json b/config/entities/User.json
new file mode 100644
index 0000000..e449216
--- /dev/null
+++ b/config/entities/User.json
@@ -0,0 +1,10 @@
+{
+ "entity": "User",
+ "multiTenant": true,
+ "timestamps": true,
+ "fields": {
+ "id": "int",
+ "name": "string",
+ "email": "string"
+ }
+}
\ No newline at end of file
diff --git a/config/saas.yaml b/config/saas.yaml
new file mode 100755
index 0000000..73905cc
--- /dev/null
+++ b/config/saas.yaml
@@ -0,0 +1,4 @@
+tenancy:
+ enabled: true
+ strategy: shared_table
+ tenant_id_column: tenant_id
\ No newline at end of file
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..6b92d22
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,399 @@
+# ๐๏ธ EntityForge Architecture
+
+## ๐ฏ Overview
+
+EntityForge is a **configuration-driven, multi-tenant SaaS framework** built in PHP.
+
+It supports:
+
+* Code generation via JSON configs
+* Multi-tenant strategies (shared & database)
+* Automated migrations and rollback
+* Tenant provisioning and lifecycle management
+
+---
+
+# ๐ง Core Principles
+
+* **Separation of concerns**
+* **Explicit over implicit**
+* **Configuration-driven architecture**
+* **Tenant isolation by design**
+* **Idempotent infrastructure**
+
+---
+
+# ๐ฆ System Architecture
+
+```
+Application
+โ
+โโโ Core Layer
+โ โโโ Application
+โ โโโ ConfigLoader
+โ โโโ CoreSchemaManager
+โ
+โโโ Tenant Layer
+โ โโโ TenantContext
+โ โโโ TenantResolver
+โ โโโ TenantConnectionResolver
+โ โโโ TenantProvisioner
+โ โโโ TenantRepository
+โ โโโ TenantService
+โ
+โโโ Database Layer
+โ โโโ Connection
+โ โโโ MigrationRunner
+โ
+โโโ Generator Layer
+โ โโโ EntityGenerator
+โ โโโ Builders (Entity, Repository, Migration)
+โ โโโ FileWriter
+โ
+โโโ Repository Layer
+โ โโโ BaseRepository
+โ โโโ Generated Repositories
+โ
+โโโ Console Layer
+ โโโ GenerateCommand
+ โโโ GenerateAllCommand
+ โโโ MigrateCommand
+ โโโ RollbackCommand
+ โโโ TenantCreateCommand
+```
+
+---
+
+# ๐งฉ Core Components
+
+## 1. Application
+
+Handles:
+
+* Bootstrapping config
+* Tenant resolution (optional)
+* Core schema initialization
+
+```php
+$app->boot($context, $resolveTenant);
+```
+
+---
+
+## 2. CoreSchemaManager
+
+Responsible for:
+
+* Ensuring framework-level tables exist
+
+### Currently manages:
+
+* `tenants` table
+
+โ Runs automatically during application boot
+โ Idempotent (`CREATE TABLE IF NOT EXISTS`)
+
+---
+
+## 3. Tenant System
+
+### TenantContext
+
+* Stores current tenant ID (runtime state)
+
+---
+
+### TenantResolver
+
+* Extracts tenant from request context
+
+---
+
+### TenantConnectionResolver
+
+* Resolves DB connection based on strategy:
+
+| Strategy | Behavior |
+| -------- | ------------- |
+| shared | single DB |
+| database | DB per tenant |
+
+---
+
+### TenantProvisioner
+
+Handles:
+
+* Creating tenant database
+* Running migrations
+
+---
+
+### TenantRepository
+
+Uses **main DB** to:
+
+* Store tenant records
+* Check existence
+* List tenants
+
+---
+
+### TenantService
+
+Entry point for onboarding:
+
+```php
+$service->onboard($tenantId, $name);
+```
+
+Flow:
+
+```
+Check โ Create DB โ Run migrations โ Register tenant
+```
+
+---
+
+# ๐๏ธ Database Architecture
+
+## ๐น Main Database
+
+```
+entity_forge
+ โโโ tenants
+```
+
+Stores:
+
+* tenant metadata
+* lifecycle state
+
+---
+
+## ๐น Tenant Databases
+
+```
+entity_forge_tenant_1
+entity_forge_tenant_2
+```
+
+Stores:
+
+* application data (users, orders, etc.)
+
+---
+
+# ๐ Multi-Tenant Strategies
+
+## 1. Shared Database
+
+* Single DB
+* `tenant_id` column used for isolation
+
+```text
+users
+ id
+ name
+ tenant_id
+```
+
+---
+
+## 2. Database per Tenant
+
+* One DB per tenant
+* No `tenant_id` needed
+
+```text
+tenant_1_db โ users
+tenant_2_db โ users
+```
+
+---
+
+# ๐งฑ Repository Layer
+
+## BaseRepository
+
+Handles:
+
+* DB connection resolution
+* Tenant scoping
+* Insert/query abstraction
+
+### Features:
+
+* `create()`
+* `findAll()`
+* `findById()`
+* `where()`
+
+---
+
+## Generated Repositories
+
+* Extend BaseRepository
+* Contain no logic by default
+* Used for customization when needed
+
+---
+
+# โ๏ธ Migration System
+
+## MigrationRunner
+
+Supports:
+
+* Running migrations
+* Tracking execution
+* Rollback by batch
+
+---
+
+## Migration Structure
+
+```
+database/migrations/
+ 2026_..._create_users_table.up.sql
+ 2026_..._create_users_table.down.sql
+```
+
+---
+
+## Features
+
+* Idempotent execution
+* Batch tracking
+* Rollback support
+
+---
+
+# ๐๏ธ Generator System
+
+## Input
+
+```json
+{
+ "entity": "User",
+ "multiTenant": true,
+ "timestamps": true,
+ "fields": {
+ "name": "string",
+ "email": "string"
+ }
+}
+```
+
+---
+
+## Output
+
+* Entity class
+* Repository class
+* Migration files
+
+---
+
+# ๐งฐ CLI Commands
+
+| Command | Purpose |
+| ---------------- | ---------------------- |
+| generate | Generate single entity |
+| generate:all | Generate all entities |
+| migrate | Run migrations |
+| migrate:rollback | Rollback last batch |
+| tenant:create | Provision new tenant |
+
+---
+
+# ๐ Tenant Lifecycle
+
+## Onboarding Flow
+
+```
+TenantService
+ โ
+TenantProvisioner
+ โ
+Create DB
+ โ
+Run Migrations
+ โ
+Register Tenant
+```
+
+---
+
+## Runtime Flow
+
+```
+Request
+ โ
+Application boot
+ โ
+Tenant resolved
+ โ
+Connection resolved
+ โ
+Repository used
+```
+
+---
+
+# โ ๏ธ Key Rules
+
+## ๐ด Never mix:
+
+| Concern | Location |
+| --------------- | ----------------- |
+| tenant registry | main DB |
+| user data | tenant DB |
+| core schema | CoreSchemaManager |
+| business schema | migrations |
+
+---
+
+## ๐ด Never reuse:
+
+* Repository instances across tenants
+* Connections across tenant switches
+
+---
+
+## ๐ด Always:
+
+* Boot before using repositories
+* Create new repository after tenant switch
+
+---
+
+# ๐ Current Status
+
+## Completed
+
+* โ
Multi-tenant architecture
+* โ
DB-per-tenant strategy
+* โ
Migration system with rollback
+* โ
Code generator
+* โ
Tenant provisioning
+* โ
Tenant registry
+* โ
Query layer
+
+---
+
+## Upcoming
+
+* ๐ฒ Middleware (auto tenant resolution)
+* ๐ฒ Dependency injection container
+* ๐ฒ API layer
+
+---
+
+# ๐ง Final Thought
+
+This is no longer just a framework.
+
+It is:
+
+> **A foundation for building real multi-tenant SaaS systems**
diff --git a/docs/CLEANUP.md b/docs/CLEANUP.md
new file mode 100644
index 0000000..e69de29
diff --git a/docs/CONCEPT.md b/docs/CONCEPT.md
new file mode 100644
index 0000000..f56f1f3
--- /dev/null
+++ b/docs/CONCEPT.md
@@ -0,0 +1,16 @@
+## Core Concepts
+
+### Application
+The entry point of Entity-Forge. Boots configuration and runtime.
+
+### Entity
+A configuration-defined model representing a database table.
+
+### Repository
+A data access layer automatically scoped to a tenant.
+
+### Tenant
+A logical boundary that isolates data between users/customers.
+
+### Tenant Context
+The current tenant resolved at runtime.
\ No newline at end of file
diff --git a/output/User.php b/output/User.php
new file mode 100644
index 0000000..91bb5c1
--- /dev/null
+++ b/output/User.php
@@ -0,0 +1,9 @@
+applyTenantScope($data);
+ return $data;
+ }
+}
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..2bccbf3
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/readme.md b/readme.md
index b8559fa..238ffc0 100644
--- a/readme.md
+++ b/readme.md
@@ -1,104 +1,321 @@
-# Entity-Forge
+# ๐ EntityForge
-A simple PHP Entity ORM for generating and managing entity models.
+**EntityForge** is a WIP open source configuration-driven, multi-tenant SaaS framework built in PHP.
-## Installation
+It enables developers to build scalable SaaS applications with:
-Install via Composer:
+* JSON-based code generation
+* Multi-tenant architecture (shared or database-per-tenant)
+* Automated migrations and rollback
+* Tenant provisioning and lifecycle management
+
+---
+
+# โจ Features
+
+## ๐งฉ Configuration-Driven Development
+
+Define your application using JSON:
+
+```json
+{
+ "entity": "User",
+ "multiTenant": true,
+ "timestamps": true,
+ "fields": {
+ "name": "string",
+ "email": "string"
+ }
+}
+```
+
+---
+
+## ๐ข Multi-Tenant Architecture
+
+Supports two strategies:
+
+* **Shared Database**
+* **Database per Tenant**
+
+---
+
+## โ๏ธ Code Generation
+
+Generate:
+
+* Entities
+* Repositories
+* Migrations
```bash
-composer require entity-forge/entity-forge
+php bin/ef generate User
+php bin/ef generate:all
```
-## Features
+---
+
+## ๐๏ธ Migration System
+
+* Forward migrations
+* Rollback support
+* Batch tracking
+
+```bash
+php bin/ef migrate
+php bin/ef migrate:rollback
+```
+
+---
+
+## ๐๏ธ Tenant Provisioning
-- Generating MySQL tables and related POCO classes from JSON objects.
+Automatically creates:
-## Folder Structure
+* Tenant database
+* Schema (via migrations)
-- `src/Core/` - Core classes like ModelGenerator
-- `src/EntityConnector/` - Database connection classes
-- `src/EntityGenerator/` - Model generation logic
-- `src/EntityModels/` - Generated model classes
-# EntityForge
+```bash
+php bin/ef tenant:create tenant_1
+```
-EntityForge is a lightweight PHP utility to generate PHP model classes and repository scaffolding from JSON model definitions. It focuses on developer productivity: define your data model as JSON, then generate POPO model classes and thin repositories that delegate data access to a central `EntityDriver`.
+---
-Version: 1.0
+## ๐ง Tenant Registry
-## Goals
+Central `tenants` table stores:
-- Provide a simple, composable generator to convert JSON model descriptions into PHP model classes and repositories.
-- Keep runtime footprint minimal โ generated model classes are plain PHP objects; repositories are thin adapters.
-- Make the code generation pipeline extensible (custom templates or additional generators).
+* tenant_id
+* name
+* status
-## Quick Install
+---
-Install via Composer (local development):
+# ๐ฆ Installation
+
+This will be part of PHP Packagist and will replace the entity-forge ORM.
```bash
-composer install --dev
+composer require vedavith/entity-forge
```
-## Project Layout
+---
-- `src/Core/` โ Generators: `ModelGenerator`, `RepositoryGenerator`.
-- `src/EntityGenerator/` โ Orchestration code that reads JSON models and invokes generators.
-- `src/EntityModels/` โ Generated model classes (POPOs).
-- `src/EntityRepository/` โ Generated repository classes (thin wrappers using `EntityDriver`).
-- `src/JsonModels/` โ JSON model definitions included with the package (used by the generator).
-- `tests/` โ PHPUnit tests that validate generation behavior.
+# โก Quick Start
-## JSON Model Format
+## 1. Configure tenancy
-Each model is a JSON file with a top-level `model` (class name) and `fields` object. Example `src/JsonModels/users.model.json`:
+### ๐ข Shared Database
-```json
-{
- "model": "User",
- "fields": {
- "id": { "type": "int", "primary": true },
- "username": { "type": "string", "maxLength": 100 },
- "email": { "type": "string", "maxLength": 255 }
- }
-}
+```yaml
+tenancy:
+ enabled: true
+ strategy: shared
+
+database:
+ driver: mysql
+ host: 127.0.0.1
+ port: 3306
+ database: entity_forge
+ username: root
+ password: root
+```
+
+---
+
+### ๐ต Database per Tenant
+
+```yaml
+tenancy:
+ enabled: true
+ strategy: database
+
+database:
+ driver: mysql
+ host: 127.0.0.1
+ port: 3306
+ database: entity_forge
+ username: root
+ password: root
```
-The generator creates a PHP class `User` in `src/EntityModels/User.php` and a repository `UserRepository` in `src/EntityRepository/UserRepository.php`.
+---
+
+## 2. Generate entities
-## CLI: Generate Models & Repositories
+```bash
+php bin/ef generate User --migration
+php bin/ef migrate
+```
-Use the bundled CLI to generate model and repository files from JSON models included in `src/JsonModels`:
+---
+
+## 3. Create a tenant
```bash
-php src/EntityGenerator/Entity:Gen create-model --model=users
+php bin/ef tenant:create tenant_1
+```
+
+---
+
+## 4. Use in application
+
+```php
+$app->boot([
+ 'headers' => ['X-Tenant-ID' => 'tenant_1']
+], true);
+
+$repo = new UserRepository($app->getConfig());
+
+$repo->create([
+ 'name' => 'Ved',
+ 'email' => 'ved@example.com'
+]);
+
+print_r($repo->findAll());
+```
+
+---
+
+# โ๏ธ Configuration Details
+
+## ๐ข Shared Database Strategy
+
+All tenants share a single database.
+
+### Structure
+
+```
+entity_forge
+ โโโ users
+ id
+ name
+ tenant_id
+```
+
+### Characteristics
+
+* Uses `tenant_id` for isolation
+* Lower infrastructure cost
+* Easier to manage
+
+---
+
+## ๐ต Database per Tenant Strategy
+
+Each tenant gets a dedicated database.
+
+### Structure
+
+```
+entity_forge (main DB)
+ โโโ tenants
+
+entity_forge_tenant_1
+ โโโ users
+
+entity_forge_tenant_2
+ โโโ users
+```
+
+### Characteristics
+
+* Full data isolation
+* No `tenant_id` column required
+* Better for enterprise use cases
+
+---
+
+## Tenant Database Naming
+
+```
+{base_database}_{tenant_id}
+```
+
+Example:
+
+```
+entity_forge_tenant_1
+entity_forge_tenant_2
+```
+
+---
+
+# ๐งฑ Architecture Overview
+
+```
+Application
+ โโโ Core (boot, config, schema)
+ โโโ Tenant (context, resolver, provisioning)
+ โโโ Database (connection, migrations)
+ โโโ Generator (entity, repository, migration)
+ โโโ Repository (data access layer)
+```
+
+---
+
+# ๐ Tenant Lifecycle
+
+```
+Onboard โ Create DB โ Run Migrations โ Register Tenant
```
-- `--model` refers to the base filename in `src/JsonModels` (without `.model.json`).
-- The command writes the generated model file to `src/EntityModels/` and repository to `src/EntityRepository/`.
+---
-## Running Tests
+# ๐๏ธ Database Design
-Install dev dependencies and run PHPUnit:
+## Main Database
+
+```
+entity_forge
+ โโโ tenants
+```
+
+## Tenant Databases
+
+```
+entity_forge_tenant_1
+entity_forge_tenant_2
+```
+
+---
+
+# โ ๏ธ Important Rules
+
+* 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
+
+---
+
+# ๐งช CLI Commands
```bash
-composer install --dev
-vendor/bin/phpunit --testdox
+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
```
-Note: PHPUnit requires the PHP `dom` extension. On Debian/Ubuntu install via `sudo apt install php-xml`.
+---
+
+# ๐ง Roadmap
-## Future Work (planned for next releases)
+* [ ] Middleware for automatic tenant resolution
+* [ ] Dependency Injection container
+* [ ] API layer
-- Read and write tables from the JSON model definitions directly (persist schema/state as JSON) instead of auto-creating SQL tables.
-- Add configurable templates for code generation (allow custom class templates).
-- Dependency injection for repositories and integration tests with an in-memory database.
+---
-## Contribution
+# ๐ค Contributing
-Contributions welcome. Fork the repo, create a feature branch, and open a PR describing the change.
+Contributions are welcome.
+Feel free to open issues or pull requests.
-## License
+---
-MIT
+# ๐ License
+MIT License
diff --git a/run-generator.php b/run-generator.php
new file mode 100644
index 0000000..217b4aa
--- /dev/null
+++ b/run-generator.php
@@ -0,0 +1,20 @@
+generate($config);
+
+echo "Generation complete.\n";
\ No newline at end of file
diff --git a/src/Config/ConfigLoader.php b/src/Config/ConfigLoader.php
new file mode 100644
index 0000000..b7d8b63
--- /dev/null
+++ b/src/Config/ConfigLoader.php
@@ -0,0 +1,40 @@
+ Yaml::parseFile($path),
+ 'json' => json_decode(file_get_contents($path), true),
+ default => throw new \Exception("Unsupported config format: {$extension}")
+ };
+ }
+
+ public function loadMultiple(array $paths): array
+ {
+ $merged = [];
+
+ foreach ($paths as $path) {
+ $config = $this->load($path);
+ $merged = array_replace_recursive($merged, $config);
+ }
+
+ return $merged;
+ }
+}
\ No newline at end of file
diff --git a/src/Config/ConfigValidator.php b/src/Config/ConfigValidator.php
new file mode 100644
index 0000000..6506ccc
--- /dev/null
+++ b/src/Config/ConfigValidator.php
@@ -0,0 +1,17 @@
+setName('generate:all')
+ ->setDescription('Generate all entities from config')
+ ->addOption('migration', null, InputOption::VALUE_NONE, 'Generate migration');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $configDir = 'config/entities';
+
+ if (!is_dir($configDir)) {
+ $output->writeln("Directory not found: {$configDir}");
+ return Command::FAILURE;
+ }
+
+ $files = glob($configDir . '/*.json');
+
+ if (empty($files)) {
+ $output->writeln("No entity configs found.");
+ return Command::SUCCESS;
+ }
+
+ // Ensure deterministic order
+ sort($files);
+
+ $withMigration = $input->getOption('migration');
+
+ // IMPORTANT: single generator instance
+ $generator = new EntityGenerator();
+
+
+ foreach ($files as $file) {
+ $config = json_decode(file_get_contents($file), true);
+
+ if (!$config) {
+ $output->writeln("Invalid JSON: {$file}");
+ continue;
+ }
+
+ try {
+ $entityName = $config['entity'] ?? 'Unknown';
+
+ $generator->generate($config, $withMigration);
+
+ $output->writeln("Generated {$entityName}");
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+ }
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php
new file mode 100644
index 0000000..6e8b4f0
--- /dev/null
+++ b/src/Console/GenerateCommand.php
@@ -0,0 +1,57 @@
+setName('generate')
+ ->setDescription('Generate entity and repository')
+ ->addArgument('entity', InputArgument::REQUIRED, 'Entity name')
+ ->addOption('config', null, InputOption::VALUE_OPTIONAL, 'Config path', 'config/entities')
+ ->addOption('migration', null, InputOption::VALUE_NONE, 'Generate migration');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $entity = $input->getArgument('entity');
+ $configDir = $input->getOption('config');
+ $withMigration = $input->getOption('migration');
+
+ $configPath = "{$configDir}/{$entity}.json";
+
+ if (!file_exists($configPath)) {
+ $output->writeln("Config not found: {$configPath}");
+ return Command::FAILURE;
+ }
+
+ $config = json_decode(file_get_contents($configPath), true);
+
+ if (!$config) {
+ $output->writeln("Invalid JSON config");
+ return Command::FAILURE;
+ }
+
+ $generator = new EntityGenerator();
+
+ try {
+ $generator->generate($config, $withMigration);
+ $output->writeln("Generated {$entity} successfully.");
+ } catch (\Throwable $e) {
+ $output->writeln("Error generating {$entity}: {$e->getMessage()}");
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php
new file mode 100644
index 0000000..e83cbe3
--- /dev/null
+++ b/src/Console/MigrateCommand.php
@@ -0,0 +1,45 @@
+setName('migrate')
+ ->setDescription('Run database migrations');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $app = new Application(__DIR__ . '/../../config');
+ $app->boot([], false);
+
+ $config = $app->getConfig()['database'];
+
+ $connection = new Connection($config);
+ $runner = new MigrationRunner($connection);
+
+ try {
+ $runner->run('database/migrations');
+ $output->writeln("Migrations executed successfully");
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Console/RollbackCommand.php b/src/Console/RollbackCommand.php
new file mode 100644
index 0000000..b4566da
--- /dev/null
+++ b/src/Console/RollbackCommand.php
@@ -0,0 +1,44 @@
+setName('migrate:rollback')
+ ->setDescription('Rollback last batch');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $app = new Application(__DIR__ . '/../../config');
+ $app->boot([], false);
+
+ $db = $app->getConfig()['database'];
+
+ $runner = new MigrationRunner(new Connection($db));
+
+ try {
+ $runner->rollback('database/migrations');
+ $output->writeln("Rollback successful");
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Console/TenantCreateCommand.php b/src/Console/TenantCreateCommand.php
new file mode 100644
index 0000000..16e0221
--- /dev/null
+++ b/src/Console/TenantCreateCommand.php
@@ -0,0 +1,46 @@
+setName('tenant:create')
+ ->setDescription('Create a new tenant')
+ ->addArgument('tenantId', InputArgument::REQUIRED, 'Tenant ID');
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $tenantId = $input->getArgument('tenantId');
+
+ $app = new Application(__DIR__ . '/../../config');
+ $app->boot([], false);
+
+ $provisioner = new TenantProvisioner($app->getConfig());
+
+ try {
+ $provisioner->create($tenantId);
+
+ $output->writeln("Tenant {$tenantId} created successfully");
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+ return Command::FAILURE;
+ }
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/src/Core/Application.php b/src/Core/Application.php
new file mode 100644
index 0000000..8cd6a84
--- /dev/null
+++ b/src/Core/Application.php
@@ -0,0 +1,77 @@
+configPath = rtrim($configPath, '/');
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function boot(array $context = [], bool $resolveTenant = true): void
+ {
+ $loader = new ConfigLoader();
+ $validator = new ConfigValidator();
+
+ $this->config = $loader->loadMultiple([
+ $this->configPath . '/saas.yaml',
+ $this->configPath . '/application.yaml'
+ ]);
+
+ $validator->validate($this->config);
+
+ // Check Strategy
+ $strategy = $this->config['tenancy']['strategy'] ?? 'shared';
+
+ if ($strategy === 'database') {
+ (new CoreSchemaManager($this->config))->ensure();
+ }
+
+ // ๐ Tenant resolution is explicit
+ if ($resolveTenant && ($this->config['tenancy']['enabled'] ?? false)) {
+ $this->resolveTenant($context);
+ }
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function resolveTenant(array $context): void
+ {
+ if (empty($context)) {
+ throw new Exception("Tenant resolution requires context.");
+ }
+
+ $resolver = TenantResolverFactory::create($this->config);
+
+ $tenantId = $resolver->resolve($context);
+
+ TenantContext::setTenantId($tenantId);
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function getConfig(): array
+ {
+ if (empty($this->config)) {
+ throw new Exception("Application not booted or config not loaded.");
+ }
+
+ return $this->config;
+ }
+}
\ No newline at end of file
diff --git a/src/Core/CoreSchemaManager.php b/src/Core/CoreSchemaManager.php
new file mode 100644
index 0000000..827443f
--- /dev/null
+++ b/src/Core/CoreSchemaManager.php
@@ -0,0 +1,46 @@
+pdo = (new Connection($config['database']))->getPdo();
+ }
+
+ public function ensure(): void
+ {
+ foreach ($this->definitions() as $sql) {
+ $this->pdo->exec($sql);
+ }
+ }
+
+ /**
+ * Define all core tables here
+ */
+ private function definitions(): array
+ {
+ return [
+ $this->tenantsTable(),
+ ];
+ }
+
+ private function tenantsTable(): string
+ {
+ return <<path = __DIR__ . '/../../EntityModels/';
- }
-
- public function setPath(string $path): self
- {
- $this->path = $path;
- return $this;
- }
-
- public function generateModel(\stdClass $model) : ?bool
- {
- $template = $this->fileGenerator($model);
- // Ensure target directory exists and is writable
- if (!is_dir($this->path)) {
- mkdir($this->path, 0755, true);
- }
- if (is_writable($this->path)) {
- return file_put_contents($this->path . $this->className . ".php", $template);
- }
- return false;
- }
-
- private function fileGenerator($modelData) : ?string
- {
- $this->className = $modelData->model;
- // PHP Template
- $fileTemplate = "className."\n";
- $fileTemplate .= "{\n\n";
-
- $fields = $modelData->fields;
- foreach ($fields as $field => $fieldSym) {
-
- // Checking for types
- $type = $fieldSym->type;
- if ($type == 'datetime' || $type == 'date') {
- $type = '\DateTime';
- }
-
- $fileTemplate.= "\t/** @var ".$type." */\n";
- $fileTemplate.= "\t public ".$type." $".$field.";\n\n";
- }
-
- $prop = "property";
- $value = "value";
- $currentInstance = "this";
- $fileTemplate .= "\t/** __get **/\n";
- $fileTemplate .= "\tpublic function __get($".$prop.") {\n";
- $fileTemplate .= "\t if (property_exists(\$".$currentInstance.",\$".$prop.")) {\n";
- $fileTemplate .= "\t\t return \$".$currentInstance."->\$".$prop.";\n";
- $fileTemplate .= "\t }\n";
- $fileTemplate .= "\t}\n\n";
- $fileTemplate .= "\t/** __set **/\n";
- $fileTemplate .= "\tpublic function __set(\$".$prop.",\$".$value.") {\n";
- $fileTemplate .= "\t if (property_exists($".$currentInstance.", $".$prop.")) {\n";
- $fileTemplate .= "\t\t \$".$currentInstance."->\$".$prop." = "."\$$value;\n";
- $fileTemplate .= "\t }\n";
- $fileTemplate .= "\t\t return \$".$currentInstance.";\n";
- $fileTemplate .= "\t }\n\n";
- $fileTemplate.= "}";
-
- return $fileTemplate;
- }
-}
\ No newline at end of file
diff --git a/src/Core/RepositoryGenerator.php b/src/Core/RepositoryGenerator.php
deleted file mode 100644
index 315dc9c..0000000
--- a/src/Core/RepositoryGenerator.php
+++ /dev/null
@@ -1,80 +0,0 @@
-path = __DIR__ . '/../../src/EntityRepository/';
- }
-
- public function setPath(string $path): self
- {
- $this->path = $path;
- return $this;
- }
-
- public function generateRepository(\stdClass $model) : ?bool
- {
- $template = $this->fileGenerator($model);
- if (!is_dir($this->path)) {
- mkdir($this->path, 0755, true);
- }
- if (is_writable($this->path)) {
- return (bool)file_put_contents($this->path . $this->className . ".php", $template);
- }
- return false;
- }
-
- private function fileGenerator(\stdClass $modelData) : string
- {
- $this->className = $modelData->model . 'Repository';
-
- // determine table name: explicit table property or guess from model name
- $table = property_exists($modelData, 'table') && !empty($modelData->table)
- ? $modelData->table
- : $this->guessTableName($modelData->model);
-
- // determine primary key from fields if marked
- $primary = 'id';
- if (property_exists($modelData, 'fields') && is_object($modelData->fields)) {
- foreach ($modelData->fields as $fname => $fmeta) {
- if (is_object($fmeta) && property_exists($fmeta, 'primary')) {
- if ($fmeta->primary === true || $fmeta->primary === 'true') {
- $primary = $fname;
- break;
- }
- }
- }
- }
-
- $tpl = "className} extends BaseRepository\n";
- $tpl .= "{\n";
- $tpl .= " protected string \$table = '{$table}';\n";
- $tpl .= " protected string \$primaryKey = '{$primary}';\n\n";
- $tpl .= " public function __construct(EntityDriver \$driver)\n";
- $tpl .= " {\n";
- $tpl .= " parent::__construct(\$driver, \$this->table, \$this->primaryKey);\n";
- $tpl .= " }\n";
- $tpl .= "}\n";
-
- return $tpl;
- }
-
- private function guessTableName(string $modelName): string
- {
- // convert CamelCase to snake_case
- $snake = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $modelName));
- // simple pluralize: append 's' if not already ending with 's'
- if (substr($snake, -1) !== 's') {
- $snake .= 's';
- }
- return $snake;
- }
-}
diff --git a/src/Database/Connection.php b/src/Database/Connection.php
new file mode 100644
index 0000000..f77127b
--- /dev/null
+++ b/src/Database/Connection.php
@@ -0,0 +1,40 @@
+pdo = new PDO(
+ $dsn,
+ $config['username'],
+ $config['password'],
+ [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ ]
+ );
+ } catch (PDOException $e) {
+ throw new \Exception("DB Connection failed: " . $e->getMessage());
+ }
+ }
+
+ public function getPdo(): PDO
+ {
+ return $this->pdo;
+ }
+}
\ No newline at end of file
diff --git a/src/Database/MigrationRunner.php b/src/Database/MigrationRunner.php
new file mode 100644
index 0000000..9ad9128
--- /dev/null
+++ b/src/Database/MigrationRunner.php
@@ -0,0 +1,153 @@
+connection = $connection;
+ }
+
+ public function run(string $path): void
+ {
+ $pdo = $this->connection->getPdo();
+
+ $this->ensureMigrationsTable();
+
+ $files = glob($path . '/*.up.sql');
+ sort($files);
+
+ if (empty($files)) {
+ echo "No migrations found.\n";
+ return;
+ }
+
+ $executed = $this->getExecuted();
+ $batch = $this->nextBatch();
+
+ foreach ($files as $file) {
+ $name = basename($file);
+
+ if (in_array($name, $executed, true)) {
+ echo "Skipped: {$name}\n";
+ continue;
+ }
+
+ $sql = file_get_contents($file);
+
+ try {
+ $pdo->exec($sql);
+
+ $this->mark($name, $batch);
+
+ echo "Executed: {$name}\n";
+
+ } catch (\Throwable $e) {
+ throw new \Exception("Migration failed: {$name} - " . $e->getMessage());
+ }
+ }
+
+ echo "โ Done\n";
+ }
+
+ public function rollback(string $path): void
+ {
+ $pdo = $this->connection->getPdo();
+
+ $batch = $this->lastBatch();
+
+ if ($batch === 0) {
+ echo "Nothing to rollback.\n";
+ return;
+ }
+
+ $stmt = $pdo->prepare(
+ "SELECT migration FROM migrations WHERE batch = :b ORDER BY id DESC"
+ );
+ $stmt->execute(['b' => $batch]);
+
+ $migrations = $stmt->fetchAll(PDO::FETCH_COLUMN);
+
+ foreach ($migrations as $migration) {
+ $down = $path . '/' . str_replace('.up.sql', '.down.sql', $migration);
+
+ if (!file_exists($down)) {
+ throw new \Exception("Missing down file: {$down}");
+ }
+
+ $sql = file_get_contents($down);
+
+ try {
+ $pdo->exec($sql);
+
+ $pdo->prepare("DELETE FROM migrations WHERE migration = :m")
+ ->execute(['m' => $migration]);
+
+ echo "Rolled back: {$migration}\n";
+
+ } catch (\Throwable $e) {
+ throw new \Exception("Rollback failed: {$migration} - " . $e->getMessage());
+ }
+ }
+
+ echo "โ Rollback complete\n";
+ }
+
+ private function ensureMigrationsTable(): void
+ {
+ $pdo = $this->connection->getPdo();
+
+ // Create table if not exists
+ $pdo->exec("
+ CREATE TABLE IF NOT EXISTS migrations (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ migration VARCHAR(255),
+ executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ ");
+
+ // Check if 'batch' column exists
+ $stmt = $pdo->query("SHOW COLUMNS FROM migrations LIKE 'batch'");
+ $column = $stmt->fetch();
+
+ if (!$column) {
+ $pdo->exec("ALTER TABLE migrations ADD COLUMN batch INT DEFAULT 1");
+ }
+ }
+
+ private function getExecuted(): array
+ {
+ return $this->connection->getPdo()
+ ->query("SELECT migration FROM migrations")
+ ->fetchAll(PDO::FETCH_COLUMN);
+ }
+
+ 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
+ ]);
+ }
+
+ private function nextBatch(): int
+ {
+ $stmt = $this->connection->getPdo()->query("SELECT MAX(batch) FROM migrations");
+ return ((int) $stmt->fetchColumn()) + 1;
+ }
+
+ private function lastBatch(): int
+ {
+ $stmt = $this->connection->getPdo()->query("SELECT MAX(batch) FROM migrations");
+ return (int) $stmt->fetchColumn();
+ }
+}
\ No newline at end of file
diff --git a/src/EntityConnector/EntityDriver.php b/src/EntityConnector/EntityDriver.php
deleted file mode 100644
index 76c8d11..0000000
--- a/src/EntityConnector/EntityDriver.php
+++ /dev/null
@@ -1,336 +0,0 @@
-params = new \stdClass();
- $this->params->host = $iniData['host'];
- $this->params->username = $iniData['username'];
- $this->params->password = $iniData['password'];
- $this->params->database = $iniData['database'];
- $this->params->driver = $iniData['driver'];
- } else {
- $this->params = $configOverride;
- }
- $this->connectionString = $this->prepareConnectionStringByDriver($this->params);
- parent::__construct($this->connectionString, $this->params->username, $this->params->password);
- }
-
- private function prepareConnectionStringByDriver(): string
- {
- $connString = '';
- if ($this->params->driver == "mysql") {
- $connString = "mysql:host=" . $this->params->host . ";dbname=" . $this->params->database;
- }
- return $connString;
- }
-
- public function select(string $table, $values = []): object
- {
- $this->queryType = "S";
- // Determine columns
- if (empty($values)) {
- $colString = '*';
- } elseif (is_array($values)) {
- $colString = implode(', ', array_map(function($c){ return $c; }, $values));
- } else {
- $colString = (string)$values;
- }
-
- $this->selectString = "SELECT {$colString} FROM {$table}";
- return $this;
- }
-
- // Accept either a string condition or an array with 'and' and/or 'or' keys.
- // Example: ['and' => ['a = 1', 'b = 2'], 'or' => ['c = 3']]
- public function where($condition): object {
- if (empty($condition)) {
- throw new \Exception("Where clause requires a condition");
- }
-
- // String condition: use as-is
- if (is_string($condition)) {
- $this->whereString = ' WHERE ' . $condition;
- return $this;
- }
-
- // Array condition: build groups for 'and' and 'or'
- if (is_array($condition)) {
- $groups = [];
-
- if (isset($condition['and']) && is_array($condition['and']) && !empty($condition['and'])) {
- $andParts = array_map(function ($c) {
- return '(' . $c . ')';
- }, $condition['and']);
- $groups[] = '(' . implode(' AND ', $andParts) . ')';
- }
-
- if (isset($condition['or']) && is_array($condition['or']) && !empty($condition['or'])) {
- $orParts = array_map(function ($c) {
- return '(' . $c . ')';
- }, $condition['or']);
- $groups[] = '(' . implode(' OR ', $orParts) . ')';
- }
-
- // Support LIKE conditions: provide array of expressions (e.g. "col LIKE '%foo%'")
- if (isset($condition['like']) && is_array($condition['like']) && !empty($condition['like'])) {
- $likeParts = array_map(function ($c) {
- return '(' . $c . ')';
- }, $condition['like']);
- $groups[] = '(' . implode(' OR ', $likeParts) . ')';
- }
-
- // Support BETWEEN conditions: provide array of expressions (e.g. "col BETWEEN 1 AND 10")
- if (isset($condition['between']) && is_array($condition['between']) && !empty($condition['between'])) {
- $betweenParts = array_map(function ($c) {
- return '(' . $c . ')';
- }, $condition['between']);
- $groups[] = '(' . implode(' OR ', $betweenParts) . ')';
- }
-
- if (empty($groups)) {
- throw new \Exception("Where clause array must contain non-empty 'and' or 'or' keys");
- }
-
- // If both 'and' and 'or' groups are present, combine groups with AND
- // Result example: (a AND b) AND (c OR d)
- $whereBody = implode(' AND ', $groups);
- $this->whereString = ' WHERE ' . $whereBody;
- return $this;
- }
-
- throw new \Exception("Unsupported where clause type");
- }
-
- public function joins(array $tables, array $joinConditions): self {
- // Build simple joins from parallel arrays: tables => joinConditions
- if (empty($tables) || empty($joinConditions) || count($tables) !== count($joinConditions)) {
- throw new \Exception("Join clause requires matching tables and join conditions");
- }
- $parts = [];
- foreach ($tables as $i => $tbl) {
- $cond = $joinConditions[$i] ?? null;
- if (empty($cond)) {
- throw new \Exception("Join condition missing for table {$tbl}");
- }
- $parts[] = "JOIN {$tbl} ON {$cond}";
- }
- $this->joinString = ' ' . implode(' ', $parts);
- return $this;
- }
-
- private function prepareCustomQuery(): object
- {
- try {
- $query = '';
- if (!empty($this->queryType)) {
- if ($this->queryType == 'S' && (!empty($this->selectString))) {
- $query = $this->selectString;
- }
- if (!empty($this->joinString)) {
- $query .= $this->joinString;
- }
- if (!empty($this->whereString)) {
- $query .= $this->whereString;
- }
- }
- if (empty($query)) {
- throw new \Exception('No query to prepare');
- }
- $this->returnPrepare = $this->prepare($query);
- return $this;
- } catch (PDOException | \Exception $ex) {
- return $ex;
- }
- }
-
- private function executeQuery(): object
- {
- try {
- $this->prepareCustomQuery();
- if ($this->returnPrepare instanceof \PDOStatement) {
- $this->returnPrepare->execute();
- } else {
- throw new \Exception('Prepare failed');
- }
- } catch (PDOException $ex) {
- return $ex;
- }
- return $this;
- }
-
- public function returnResult(): object
- {
- $this->executeQuery();
- $this->results = $this->returnPrepare instanceof \PDOStatement ? $this->returnPrepare->fetchAll(\PDO::FETCH_ASSOC) : [];
- // backward compatible property used in some places
- // Backward-compatible public property for callers expecting $obj->returnResult
- $this->returnResult = $this->results;
- // Also expose common alias
- $this->data = $this->results;
- return $this;
- }
-
- /**
- * findAll - fetch all rows from a table
- * Returns an array of associative rows or a PDOException on error
- */
- public function findAll(string $table)
- {
- try {
- $sql = "SELECT * FROM `{$table}`";
- $stmt = $this->prepare($sql);
- $stmt->execute();
- $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
- return $rows ?: [];
- } catch (PDOException $ex) {
- return $ex;
- }
- }
-
- /**
- * findById - fetch single row by primary key
- * Returns associative row, null if not found, or PDOException on error
- */
- public function findById(string $table, int $id, string $primaryKey = 'id')
- {
- try {
- $sql = "SELECT * FROM `{$table}` WHERE `{$primaryKey}` = :id LIMIT 1";
- $stmt = $this->prepare($sql);
- $stmt->execute(['id' => $id]);
- $row = $stmt->fetch(\PDO::FETCH_ASSOC);
- return $row ?: null;
- } catch (PDOException $ex) {
- return $ex;
- }
- }
-
- /**
- * find - convenience alias that returns all rows (keeps backward compatibility if callers expect find to list)
- */
- public function find(string $table)
- {
- return $this->findAll($table);
- }
-
- /**
- * insert - insert a row and return last insert id
- */
- public function insert(string $table, array $data)
- {
- try {
- $cols = array_keys($data);
- $placeholders = array_map(fn($c) => ':' . $c, $cols);
- $colList = implode(', ', array_map(fn($c) => "`$c`", $cols));
- $phList = implode(', ', $placeholders);
- $sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $colList, $phList);
- $stmt = $this->prepare($sql);
- foreach ($data as $k => $v) {
- $stmt->bindValue(':' . $k, $v);
- }
- $ok = $stmt->execute();
- if (!$ok) {
- return 0;
- }
- return (int)$this->lastInsertId();
- } catch (PDOException $ex) {
- return $ex;
- }
- }
-
- /**
- * update - update rows matching a where clause. $where is a string (with placeholders) and $params are bound values.
- */
- public function update(string $table, array $data, string $where, array $params = []): bool|
- \PDOException
- {
- try {
- $sets = [];
- foreach (array_keys($data) as $col) {
- $sets[] = "`$col` = :$col";
- }
- $sql = sprintf('UPDATE `%s` SET %s WHERE %s', $table, implode(', ', $sets), $where);
- $stmt = $this->prepare($sql);
- foreach ($data as $k => $v) {
- $stmt->bindValue(':' . $k, $v);
- }
- foreach ($params as $k => $v) {
- // allow numeric keys or named keys
- if (is_int($k)) {
- $stmt->bindValue($k + 1, $v);
- } else {
- $stmt->bindValue(':' . ltrim($k, ':'), $v);
- }
- }
- return $stmt->execute();
- } catch (PDOException $ex) {
- return $ex;
- }
- }
-
- /**
- * delete - delete rows matching where clause. $where is a string (with placeholders) and $params are bound values.
- */
- public function delete(string $table, string $where, array $params = []): bool|\PDOException
- {
- try {
- $sql = sprintf('DELETE FROM `%s` WHERE %s', $table, $where);
- $stmt = $this->prepare($sql);
- if (!empty($params)) {
- foreach ($params as $k => $v) {
- if (is_int($k)) {
- $stmt->bindValue($k + 1, $v);
- } else {
- $stmt->bindValue(':' . ltrim($k, ':'), $v);
- }
- }
- }
- return $stmt->execute();
- } catch (PDOException $ex) {
- return $ex;
- }
- }
-
- /**
- * create - Creates table using meta
- */
- public function create($meta): bool|\PDOException
- {
- try {
- $sql = "CREATE TABLE IF NOT EXISTS $meta->table ($meta->columns)";
- $sqlPrepd = $this->prepare($sql);
- return $sqlPrepd->execute();
- } catch (PDOException $pe) {
- var_dump($pe->getMessage());
- return false;
- }
- }
-}
-/**
- * END OF EntityDriver
- */
\ No newline at end of file
diff --git a/src/EntityGenerator/BuildEntity.php b/src/EntityGenerator/BuildEntity.php
deleted file mode 100644
index a9619eb..0000000
--- a/src/EntityGenerator/BuildEntity.php
+++ /dev/null
@@ -1,74 +0,0 @@
-modGenObject = new ModelGenerator();
- }
-
- protected function configure() : void {
- $this->setName('create-model')
- ->setDescription('Generates model and repository from a JSON model file')
- ->setHelp("Generates model and repository files from a JSON model definition\n Usage: Entity:Gen create-model --model=[modelName]")
- ->addOption('model', null, Inop::VALUE_REQUIRED);
- }
-
- protected function execute(Input $input, Output $output) : ?int
- {
- try {
-
- // Checking On json Model File Name
- $model = $input->getOption('model');
- if(empty($model)) {
- throw new \Exception("Please provide the file-name which has *.model.json extension");
- }
-
- // Table creation temporarily disabled; always update model only
- $table = false;
-
- // Reading models from given file path (use __DIR__ so path is correct regardless of CWD)
- // Json models are stored under src/JsonModels
- $path = __DIR__ . '/../JsonModels/' . $model . '.model.json';
- if(!file_exists($path)) {
- throw new \Exception("File not found");
- }
-
- // For compatability reasons we have changed to yaml to json
- $output->writeln('Reading a Json file...');
- $fileData = file_get_contents($path);
- $model = json_decode($fileData);
- $output->writeln('Generating Models and Repositories...');
-
- // Generating Models
- if($this->modGenObject->__builder(builderMeta: (object)$model, table: $table) instanceof \Exception) {
- throw new \Exception("Could not Create a Model");
- }
-
- return Cmd::SUCCESS;
- } catch (\Exception $ex) {
- $output->writeln("".$ex->getMessage()."");
- return Cmd::FAILURE;
- }
- }
-}
\ No newline at end of file
diff --git a/src/EntityGenerator/Entity:Gen b/src/EntityGenerator/Entity:Gen
deleted file mode 100755
index be55959..0000000
--- a/src/EntityGenerator/Entity:Gen
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/php
-add(new EntityBuilder());
- $this->run();
- }
-}
-
-new CommandBuilder();
diff --git a/src/EntityGenerator/GenerateModel.php b/src/EntityGenerator/GenerateModel.php
deleted file mode 100644
index e4f0779..0000000
--- a/src/EntityGenerator/GenerateModel.php
+++ /dev/null
@@ -1,102 +0,0 @@
-generateModelFile($builderMeta);
- } catch (\Exception $ex) {
- $this->logger[__FUNCTION__] = $ex->getMessage();
- return false;
- }
- }
-
- // Table generation has been removed.
-
- /**
- * buildModelFromMeta - builds a POPO (Plain Old PHP Object) from YAML Object
- *
- * Read json object and if table is true create a table in backend and create a poco file
- * @param object $builderMeta
- * @return boolean|\Exception
- */
- protected function buildModelFromMeta(object $builderMeta) : bool | \Exception {
- return true;
- }
-
- /**
- * @param object $builderMeta
- * @return self
- */
- private function extractMeta(object $builderMeta) : self {
- $meta = new \stdClass();
- $meta->table = $builderMeta->model;
- $columns = [];
- foreach ($builderMeta->fields as $field => $types) {
- $fieldMeta = null;
- if (!empty($types->type)) {
- $fieldMeta = $this->getDataTypeMapper($types->type);
- }
-
- if (!empty($types->maxLength)) {
- $fieldMeta .= "($types->maxLength)";
- }
- $columns[] = $field." ".$fieldMeta;
- }
- $meta->columns = implode(",", $columns);
- $this->metaObject = $meta;
- return $this;
- }
-
- /**
- * @return bool
- */
- // Table generation removed; driver usage eliminated.
-
- /**
- * @param object $builderMeta
- * @return bool
- */
- private function generateModelFile(object $builderMeta) : bool {
- try {
- $modelOk = (new Generator())->generateModel($builderMeta);
- $repoOk = (new RepoGenerator())->generateRepository($builderMeta);
- return (bool)($modelOk && $repoOk);
- } catch (\Exception $ex) {
- $this->setLogs([__FUNCTION__ => $ex->getMessage()]);
- return false;
- }
- }
-
- /**
- * @param object $builderMeta
- * @return bool
- */
- protected function validateModel(object $builderMeta) : bool {
- return true;
- }
-
-}
\ No newline at end of file
diff --git a/src/EntityGenerator/ModelMeta/AbstractModelMeta.php b/src/EntityGenerator/ModelMeta/AbstractModelMeta.php
deleted file mode 100644
index 2384b6c..0000000
--- a/src/EntityGenerator/ModelMeta/AbstractModelMeta.php
+++ /dev/null
@@ -1,153 +0,0 @@
- 'INTEGER',
- 'integer' => 'INTEGER',
- 'float' => 'DECIMAL',
- 'char' => 'CHAR',
- 'string' => 'VARCHAR',
- 'datetime' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL',
- 'date' => 'DATE'
- ];
- if (!array_key_exists($dataType, $dataTypeMapper)) {
- return 'VARCHAR';
- }
- return $dataTypeMapper[$dataType];
- }
-
- /**
- * Get the value of int
- */
- public function getInt() : string {
- return $this->int;
- }
-
- /**
- * Set the value of int
- *
- * @return self
- */
- public function setInt(string $int) : self {
- $this->int = $int == "int" ? "INT" : null;
- return $this;
- }
-
- /**
- * Get the value of float
- */
- public function getFloat() : string {
- return $this->float;
- }
-
- /**
- * Set the value of float
- *
- * @return self
- */
- public function setFloat(string $float) : self {
- $this->float = $float == "float" ? "DECIMAL" : null;
- return $this;
- }
-
- /**
- * Get the value of dateTime
- */
- public function getDateTime() : string{
- return $this->dateTime;
- }
-
- /**
- * Set the value of dateTime
- *
- * @return self
- */
- public function setDateTime(string $dateTime) : self {
- $this->dateTime = $dateTime == "timestamp" ? 'TIMESTAMP NOT NULL DEFAULT CURRENT_DATE()' : null;
- return $this;
- }
-
- /**
- * Get the value of maxLength
- */
- public function getMaxLength() : string {
- return $this->maxLength;
- }
-
- /**
- * Set the value of maxLength
- *
- * @return self
- */
- public function setMaxLength(int $maxLength) : self {
- $this->maxLength = !empty($maxLength) ? "($maxLength)" : null;
-
- return $this;
- }
-
- /**
- * Get the value of autoIncrement
- */
- public function getAutoIncrement() : string {
- return $this->autoIncrement;
- }
-
- /**
- * Set the value of autoIncrement
- *
- * @return self
- */
- public function setAutoIncrement(bool $autoIncrement) : self {
- $this->autoIncrement = $autoIncrement == true ?: 'NOT NULL AUTO_INCREMENT';
- return $this;
- }
-
- protected function setLogs($logs) : void {
- $this->logs[] = $logs;
- $this->generateLogs();
- }
-
- protected function getLogs() : array {
- return $this->logs;
- }
-
- /**
- * Destructor to log Exceptions
- *
- */
- private function generateLogs() {
- $logs = $this->getLogs();
- if (!empty($logs)) {
- error_log(json_encode($logs));
- }
- }
-
- //abstract model methods
- /**
- * validateModel - Used to validate yaml Object
- *
- * @param object $yamlObject
- * @return boolean|Exception
- */
- abstract protected function validateModel(object $yamlObject) : bool| \Exception;
- /**
- * buildModelFromMeta - builds a POPO (Plain Old PHP Object) from YAML Object
- *
- * @param object $builderMeta
- * @return boolean|Exception
- */
- abstract protected function buildModelFromMeta(object $builderMeta) : bool | \Exception;
-}
diff --git a/src/EntityModels/UserData.php b/src/EntityModels/UserData.php
deleted file mode 100644
index e3ac29c..0000000
--- a/src/EntityModels/UserData.php
+++ /dev/null
@@ -1,43 +0,0 @@
-$property;
- }
- }
-
- /** __set **/
- public function __set($property,$value) {
- if (property_exists($this, $property)) {
- $this->$property = $value;
- }
- return $this;
- }
-
-}
diff --git a/src/EntityRepository/BaseRepository.php b/src/EntityRepository/BaseRepository.php
deleted file mode 100644
index f9b830b..0000000
--- a/src/EntityRepository/BaseRepository.php
+++ /dev/null
@@ -1,72 +0,0 @@
-driver = $driver;
- if ($table) {
- $this->table = $table;
- }
- if ($primaryKey) {
- $this->primaryKey = $primaryKey;
- }
- }
-
- public function find(int $id): ?array
- {
- $res = $this->driver->findById($this->table, $id, $this->primaryKey);
- if ($res instanceof PDOException) {
- return null;
- }
- return $res ?: null;
- }
-
- public function findAll(): array
- {
- $res = $this->driver->findAll($this->table);
- if ($res instanceof PDOException) {
- return [];
- }
- return $res ?: [];
- }
-
- public function insert(array $data): int
- {
- $res = $this->driver->insert($this->table, $data);
- if ($res instanceof PDOException) {
- return 0;
- }
- return (int)$res;
- }
-
- public function update(int $id, array $data): bool
- {
- $where = "`{$this->primaryKey}` = :id";
- $params = ['id' => $id];
- $res = $this->driver->update($this->table, $data, $where, $params);
- if ($res instanceof PDOException) {
- return false;
- }
- return (bool)$res;
- }
-
- public function delete(int $id): bool
- {
- $where = "`{$this->primaryKey}` = :id";
- $params = ['id' => $id];
- $res = $this->driver->delete($this->table, $where, $params);
- if ($res instanceof PDOException) {
- return false;
- }
- return (bool)$res;
- }
-}
diff --git a/src/EntityRepository/RepositoryInterface.php b/src/EntityRepository/RepositoryInterface.php
deleted file mode 100644
index 142cb2b..0000000
--- a/src/EntityRepository/RepositoryInterface.php
+++ /dev/null
@@ -1,11 +0,0 @@
-table, $this->primaryKey);
- }
-}
diff --git a/src/Generator/Builder/EntityBuilder.php b/src/Generator/Builder/EntityBuilder.php
new file mode 100644
index 0000000..ca1001d
--- /dev/null
+++ b/src/Generator/Builder/EntityBuilder.php
@@ -0,0 +1,74 @@
+getEntityName();
+ $fields = $schema->getFields();
+
+ $properties = '';
+
+ foreach ($fields as $name => $type) {
+ $properties .= " public {$this->mapType($type)} \${$name};\n";
+ }
+
+ //Get Relations
+ $relationsCode = $this->buildRelations($schema);
+
+ return << 'int',
+ 'string' => 'string',
+ default => 'mixed'
+ };
+ }
+
+ private function buildRelations(EntitySchema $schema): string
+ {
+ $relations = $schema->getRelations();
+ $code = '';
+
+ // belongsTo
+ foreach ($relations['belongsTo'] ?? [] as $entity => $foreignKey) {
+ $method = lcfirst($entity);
+ $code .= "
+ public function {$method}(): string
+ {
+ return '{$entity} via {$foreignKey}';
+ }
+ ";
+ }
+
+ // hasMany
+ foreach ($relations['hasMany'] ?? [] as $entity => $foreignKey) {
+ $method = lcfirst($entity) . 's';
+ $code .= "
+ public function {$method}(): string
+ {
+ return '{$entity} list via {$foreignKey}';
+ }";
+ }
+
+ return $code;
+ }
+}
\ No newline at end of file
diff --git a/src/Generator/Builder/MigrationBuilder.php b/src/Generator/Builder/MigrationBuilder.php
new file mode 100644
index 0000000..1e7725a
--- /dev/null
+++ b/src/Generator/Builder/MigrationBuilder.php
@@ -0,0 +1,51 @@
+getEntityName()) . 's';
+ $fields = $schema->getFields();
+
+ $columns = [];
+
+ foreach ($fields as $name => $type) {
+ $columns[] = $this->mapColumn($name, $type);
+ }
+
+ $columnsSql = implode(",\n ", $columns);
+
+ return <<getEntityName()) . 's';
+ return "DROP TABLE IF EXISTS {$table};";
+ }
+
+ private function mapColumn(string $name, string $type): string
+ {
+ $sqlType = match ($type) {
+ 'int' => 'INT',
+ 'string' => 'VARCHAR(255)',
+ 'float' => 'FLOAT',
+ 'bool' => 'BOOLEAN',
+ default => 'TEXT'
+ };
+
+ if ($name === 'id') {
+ return "id INT PRIMARY KEY AUTO_INCREMENT";
+ }
+
+ return "{$name} {$sqlType}";
+ }
+}
\ No newline at end of file
diff --git a/src/Generator/Builder/RepositoryBuilder.php b/src/Generator/Builder/RepositoryBuilder.php
new file mode 100644
index 0000000..f44d41e
--- /dev/null
+++ b/src/Generator/Builder/RepositoryBuilder.php
@@ -0,0 +1,24 @@
+getEntityName() . 'Repository';
+
+ return <<validator = new SchemaValidator();
+ $this->entityBuilder = new EntityBuilder();
+ $this->repositoryBuilder = new RepositoryBuilder();
+ $this->migrationBuilder = new MigrationBuilder();
+ $this->writer = new FileWriter();
+ }
+
+ public function generate(array $config, bool $withMigration = false): void
+ {
+ // Validate config
+ $this->validator->validate($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);
+
+ // Generate migration
+ if ($withMigration) {
+ $baseName = $this->generateMigrationBaseName($entityName);
+
+ $upSql = $this->migrationBuilder->buildUp($schema);
+ $downSql = $this->migrationBuilder->buildDown($schema);
+
+ $this->writer->write(
+ "database/migrations/{$baseName}.up.sql",
+ $upSql
+ );
+
+ $this->writer->write(
+ "database/migrations/{$baseName}.down.sql",
+ $downSql
+ );
+ }
+ }
+
+ 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,
+ $this->migrationCounter,
+ $table
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/Generator/Schema/EntitySchema.php b/src/Generator/Schema/EntitySchema.php
new file mode 100644
index 0000000..d011c60
--- /dev/null
+++ b/src/Generator/Schema/EntitySchema.php
@@ -0,0 +1,50 @@
+config = $config;
+ }
+
+ 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 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';
+ }
+
+ return $fields;
+ }
+
+ public function getRelations(): array
+ {
+ return $this->config['relations'] ?? [];
+ }
+}
\ No newline at end of file
diff --git a/src/Generator/Schema/SchemaValidator.php b/src/Generator/Schema/SchemaValidator.php
new file mode 100644
index 0000000..80d08f0
--- /dev/null
+++ b/src/Generator/Schema/SchemaValidator.php
@@ -0,0 +1,35 @@
+ $type) {
+ if (!preg_match('/^[a-z_][a-z0-9_]*$/', $field)) {
+ throw new \InvalidArgumentException("Invalid field name: {$field}");
+ }
+
+ if (!in_array($type, ['int', 'string', 'float', 'bool'], true)) {
+ throw new \InvalidArgumentException("Unsupported field type: {$type} for {$field}");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Generator/Writer/FileWriter.php b/src/Generator/Writer/FileWriter.php
new file mode 100644
index 0000000..1742d10
--- /dev/null
+++ b/src/Generator/Writer/FileWriter.php
@@ -0,0 +1,27 @@
+headers[$key] ?? null;
+ }
+
+ public function query(string $key): mixed
+ {
+ return $this->query[$key] ?? null;
+ }
+
+ public function body(string $key): mixed
+ {
+ return $this->body[$key] ?? null;
+ }
+
+ public function method(): string
+ {
+ return $this->method;
+ }
+}
\ No newline at end of file
diff --git a/src/Http/Response.php b/src/Http/Response.php
new file mode 100644
index 0000000..c46cd90
--- /dev/null
+++ b/src/Http/Response.php
@@ -0,0 +1,13 @@
+config = $config;
+ $this->connection = TenantConnectionResolver::resolve($config);
+ $this->table = $this->resolveTableName();
+ }
+
+ protected function resolveTableName(): string
+ {
+ $class = (new \ReflectionClass($this))
+ ->getShortName();
+ return strtolower(
+ str_replace('Repository', '', $class)
+ ) . 's';
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected function getTenantId(): string
+ {
+ TenantGuard::ensureTenant();
+ return TenantContext::getTenantId();
+ }
+
+ /**
+ * Apply tenant scope only for shared strategy
+ *
+ * @throws Exception
+ */
+ protected function applyTenantScope(array $data): array
+ {
+ if ($this->shouldApplyTenantScope()) {
+ $data['tenant_id'] = $this->getTenantId();
+ }
+
+ return $data;
+ }
+
+ /**
+ * Determine if tenant scope should be applied
+ */
+ protected function shouldApplyTenantScope(): bool
+ {
+ return ($this->config['tenancy']['strategy'] ?? 'shared')
+ === 'shared';
+ }
+
+ /**
+ * Insert record
+ *
+ * @throws Exception
+ */
+ public function create(array $data): array
+ {
+ $data = $this->applyTenantScope($data);
+
+ $columns = array_keys($data);
+
+ $placeholders = array_map(
+ fn(string $column) => ':' . $column,
+ $columns
+ );
+
+ $sql = sprintf(
+ 'INSERT INTO %s (%s) VALUES (%s)',
+ $this->table,
+ implode(', ', $columns),
+ implode(', ', $placeholders)
+ );
+
+ $statement = $this->connection
+ ->getPdo()
+ ->prepare($sql);
+
+ $statement->execute($data);
+
+ return $data;
+ }
+
+ /**
+ * Fetch all records
+ *
+ * @throws Exception
+ */
+ 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->execute($params);
+
+ return $statement->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Find record by ID
+ *
+ * @throws Exception
+ */
+ public function findById(int $id): ?array
+ {
+ $sql = "SELECT * FROM {$this->table} WHERE 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->execute($params);
+
+ $result = $statement->fetch(PDO::FETCH_ASSOC);
+
+ return $result ?: null;
+ }
+
+ /**
+ * Find records by conditions
+ *
+ * @throws Exception
+ */
+ public function where(array $conditions): array
+ {
+ $clauses = [];
+
+ $params = [];
+
+ foreach ($conditions as $column => $value) {
+ $clauses[] = "{$column} = :{$column}";
+ $params[$column] = $value;
+ }
+
+ if ($this->shouldApplyTenantScope()) {
+ $clauses[] = "tenant_id = :tenant_id";
+
+ $params['tenant_id'] = $this->getTenantId();
+ }
+
+ $sql = sprintf(
+ 'SELECT * FROM %s WHERE %s',
+ $this->table,
+ implode(' AND ', $clauses)
+ );
+
+ $statement = $this->connection
+ ->getPdo()
+ ->prepare($sql);
+
+ $statement->execute($params);
+
+ return $statement->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ /**
+ * Update record by ID
+ *
+ * @throws Exception
+ */
+ public function update(int $id, array $data): bool
+ {
+ $setClauses = [];
+
+ foreach ($data as $column => $value) {
+ $setClauses[] = "{$column} = :{$column}";
+ }
+
+ $sql = sprintf(
+ 'UPDATE %s SET %s WHERE id = :id',
+ $this->table,
+ implode(', ', $setClauses)
+ );
+
+ $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);
+ }
+
+ /**
+ * Delete record by ID
+ *
+ * @throws Exception
+ */
+ public function delete(int $id): bool
+ {
+ $sql = "DELETE FROM {$this->table} WHERE 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);
+ }
+
+ public function beginTransaction(): void
+ {
+ $this->connection
+ ->getPdo()
+ ->beginTransaction();
+ }
+
+ public function commit(): void
+ {
+ $this->connection
+ ->getPdo()
+ ->commit();
+ }
+
+ public function rollback(): void
+ {
+ $this->connection
+ ->getPdo()
+ ->rollBack();
+ }
+}
\ No newline at end of file
diff --git a/src/Tenant/Resolver/HeaderTenantResolver.php b/src/Tenant/Resolver/HeaderTenantResolver.php
new file mode 100644
index 0000000..99dffb4
--- /dev/null
+++ b/src/Tenant/Resolver/HeaderTenantResolver.php
@@ -0,0 +1,29 @@
+headerKey = $headerKey;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function resolve(array $context): string
+ {
+ if (!isset($context['headers'][$this->headerKey])) {
+ throw new Exception("Tenant header '{$this->headerKey}' not found.");
+ }
+
+ return $context['headers'][$this->headerKey];
+ }
+}
\ No newline at end of file
diff --git a/src/Tenant/TenantConnectionResolver.php b/src/Tenant/TenantConnectionResolver.php
new file mode 100644
index 0000000..732368d
--- /dev/null
+++ b/src/Tenant/TenantConnectionResolver.php
@@ -0,0 +1,36 @@
+config['database'];
+ $dbName = $dbConfig['database'] . '_' . $tenantId;
+
+ // 1. Create DB
+ $this->createDatabase($dbConfig, $dbName);
+
+ // 2. Run migrations on tenant DB
+ $tenantConfig = $dbConfig;
+ $tenantConfig['database'] = $dbName;
+
+ $connection = new Connection($tenantConfig);
+ $runner = new MigrationRunner($connection);
+
+ $runner->run('database/migrations');
+ }
+
+ private function createDatabase(array $config, string $dbName): void
+ {
+ $dsn = sprintf(
+ '%s:host=%s;port=%s',
+ $config['driver'],
+ $config['host'],
+ $config['port']
+ );
+
+ $pdo = new \PDO(
+ $dsn,
+ $config['username'],
+ $config['password'],
+ [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]
+ );
+
+ $pdo->exec("CREATE DATABASE IF NOT EXISTS {$dbName}");
+ }
+}
\ No newline at end of file
diff --git a/src/Tenant/TenantRepository.php b/src/Tenant/TenantRepository.php
new file mode 100644
index 0000000..db94093
--- /dev/null
+++ b/src/Tenant/TenantRepository.php
@@ -0,0 +1,48 @@
+connection = new Connection($config['database']);
+ }
+
+ public function create(string $tenantId, string $name): void
+ {
+ $sql = "INSERT INTO tenants (tenant_id, name) VALUES (:tenant_id, :name)";
+
+ $stmt = $this->connection->getPdo()->prepare($sql);
+ $stmt->execute([
+ 'tenant_id' => $tenantId,
+ 'name' => $name
+ ]);
+ }
+
+ public function all(): array
+ {
+ return $this->connection->getPdo()
+ ->query("SELECT * FROM tenants")
+ ->fetchAll(\PDO::FETCH_ASSOC);
+ }
+
+ public function exists(string $tenantId): bool
+ {
+ $stmt = $this->connection->getPdo()->prepare(
+ "SELECT COUNT(*) FROM tenants WHERE tenant_id = :id"
+ );
+
+ $stmt->execute(['id' => $tenantId]);
+
+ return (int) $stmt->fetchColumn() > 0;
+ }
+}
\ No newline at end of file
diff --git a/src/Tenant/TenantResolverFactory.php b/src/Tenant/TenantResolverFactory.php
new file mode 100644
index 0000000..95a93c9
--- /dev/null
+++ b/src/Tenant/TenantResolverFactory.php
@@ -0,0 +1,23 @@
+ new HeaderTenantResolver(
+ $config['tenancy']['header_key'] ?? 'X-Tenant-ID'
+ ),
+ default => throw new Exception("Unsupported tenant resolver type: {$resolverType}")
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Tenant/TenantResolverInterface.php b/src/Tenant/TenantResolverInterface.php
new file mode 100644
index 0000000..51c83ec
--- /dev/null
+++ b/src/Tenant/TenantResolverInterface.php
@@ -0,0 +1,8 @@
+repo = new TenantRepository($config);
+ $this->provisioner = new TenantProvisioner($config);
+ }
+
+ public function onboard(string $tenantId, string $name): void
+ {
+ if ($this->repo->exists($tenantId)) {
+ throw new \Exception("Tenant already exists: {$tenantId}");
+ }
+
+ // 1. Create DB + run migrations
+ $this->provisioner->create($tenantId);
+
+ // 2. Register tenant
+ $this->repo->create($tenantId, $name);
+ }
+}
\ No newline at end of file
diff --git a/test.php b/test.php
new file mode 100644
index 0000000..9f5d532
--- /dev/null
+++ b/test.php
@@ -0,0 +1,37 @@
+boot([], false);
+
+$config = $app->getConfig();
+$tenantId = 'tenant_2';
+
+$provisioner = new TenantProvisioner($config);
+
+try {
+ $provisioner->create($tenantId);
+} catch (\Throwable $e) {
+ echo "Provisioning skipped or failed: " . $e->getMessage() . PHP_EOL;
+}
+
+$app->boot([
+ 'headers' => [
+ 'X-Tenant-ID' => $tenantId
+ ]
+], true);
+
+
+$repo = new UserRepository($config);
+$user = $repo->create([
+ 'name' => 'Ved',
+ 'email' => 'ved@example.com'
+]);
+
+echo "Inserted:\n";
+print_r($user);
\ No newline at end of file
diff --git a/tests/Config/ConfigLoaderTest.php b/tests/Config/ConfigLoaderTest.php
new file mode 100644
index 0000000..87168c7
--- /dev/null
+++ b/tests/Config/ConfigLoaderTest.php
@@ -0,0 +1,101 @@
+loader = new ConfigLoader();
+ $this->tmpDir = sys_get_temp_dir() . '/ef_test_' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ array_map('unlink', glob($this->tmpDir . '/*'));
+ rmdir($this->tmpDir);
+ }
+
+ public function test_load_yaml_file(): void
+ {
+ $path = $this->tmpDir . '/config.yaml';
+ file_put_contents($path, "tenancy:\n enabled: true\n");
+
+ $result = $this->loader->load($path);
+
+ $this->assertSame(['tenancy' => ['enabled' => true]], $result);
+ }
+
+ public function test_load_yml_extension(): void
+ {
+ $path = $this->tmpDir . '/config.yml';
+ file_put_contents($path, "database:\n host: localhost\n");
+
+ $result = $this->loader->load($path);
+
+ $this->assertSame(['database' => ['host' => 'localhost']], $result);
+ }
+
+ public function test_load_json_file(): void
+ {
+ $path = $this->tmpDir . '/config.json';
+ file_put_contents($path, json_encode(['entity' => 'User']));
+
+ $result = $this->loader->load($path);
+
+ $this->assertSame(['entity' => 'User'], $result);
+ }
+
+ public function test_load_throws_on_missing_file(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/Config file not found/');
+
+ $this->loader->load('/nonexistent/path/config.yaml');
+ }
+
+ public function test_load_throws_on_unsupported_extension(): void
+ {
+ $path = $this->tmpDir . '/config.ini';
+ file_put_contents($path, '[section]');
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Unsupported config format/');
+
+ $this->loader->load($path);
+ }
+
+ public function test_load_multiple_merges_with_later_file_winning(): void
+ {
+ $first = $this->tmpDir . '/saas.yaml';
+ $second = $this->tmpDir . '/app.yaml';
+ file_put_contents($first, "tenancy:\n strategy: shared\n enabled: false\n");
+ file_put_contents($second, "tenancy:\n strategy: database\n");
+
+ $result = $this->loader->loadMultiple([$first, $second]);
+
+ $this->assertSame('database', $result['tenancy']['strategy']);
+ $this->assertFalse($result['tenancy']['enabled']);
+ }
+
+ public function test_load_multiple_deep_merges_nested_keys(): void
+ {
+ $first = $this->tmpDir . '/a.yaml';
+ $second = $this->tmpDir . '/b.yaml';
+ file_put_contents($first, "database:\n host: localhost\n port: 3306\n");
+ file_put_contents($second, "database:\n host: db.prod\n");
+
+ $result = $this->loader->loadMultiple([$first, $second]);
+
+ $this->assertSame('db.prod', $result['database']['host']);
+ $this->assertSame(3306, $result['database']['port']);
+ }
+}
diff --git a/tests/Config/ConfigValidatorTest.php b/tests/Config/ConfigValidatorTest.php
new file mode 100644
index 0000000..626aa6d
--- /dev/null
+++ b/tests/Config/ConfigValidatorTest.php
@@ -0,0 +1,38 @@
+validator = new ConfigValidator();
+ }
+
+ public function test_valid_config_passes(): void
+ {
+ $this->validator->validate(['tenancy' => ['enabled' => true]]);
+ $this->assertTrue(true);
+ }
+
+ public function test_missing_tenancy_enabled_throws(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches("/Missing 'tenancy.enabled'/");
+
+ $this->validator->validate([]);
+ }
+
+ public function test_missing_tenancy_key_throws(): void
+ {
+ $this->expectException(Exception::class);
+
+ $this->validator->validate(['database' => ['host' => 'localhost']]);
+ }
+}
diff --git a/tests/Console/ConsoleCommandsTest.php b/tests/Console/ConsoleCommandsTest.php
new file mode 100644
index 0000000..403d59d
--- /dev/null
+++ b/tests/Console/ConsoleCommandsTest.php
@@ -0,0 +1,158 @@
+assertSame('generate', (new GenerateCommand())->getName());
+ }
+
+ public function test_generate_all_command_name(): void
+ {
+ $this->assertSame('generate:all', (new GenerateAllCommand())->getName());
+ }
+
+ public function test_migrate_command_name(): void
+ {
+ $this->assertSame('migrate', (new MigrateCommand())->getName());
+ }
+
+ public function test_rollback_command_name(): void
+ {
+ $this->assertSame('migrate:rollback', (new RollbackCommand())->getName());
+ }
+
+ public function test_tenant_create_command_name(): void
+ {
+ $this->assertSame('tenant:create', (new TenantCreateCommand())->getName());
+ }
+
+ // โโ GenerateCommand::execute() โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ public function test_generate_fails_when_config_file_missing(): void
+ {
+ $tester = new CommandTester(new GenerateCommand());
+ $code = $tester->execute(['entity' => 'Ghost', '--config' => '/nonexistent/dir']);
+
+ $this->assertSame(1, $code);
+ $this->assertStringContainsString('Config not found', $tester->getDisplay());
+ }
+
+ public function test_generate_fails_when_json_invalid(): void
+ {
+ $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid();
+ mkdir($tmp);
+ file_put_contents($tmp . '/Bad.json', '{invalid json}');
+
+ $tester = new CommandTester(new GenerateCommand());
+ $code = $tester->execute(['entity' => 'Bad', '--config' => $tmp]);
+
+ $this->assertSame(1, $code);
+ $this->assertStringContainsString('Invalid JSON', $tester->getDisplay());
+
+ unlink($tmp . '/Bad.json');
+ rmdir($tmp);
+ }
+
+ public function test_generate_succeeds_with_valid_schema(): void
+ {
+ $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid();
+ mkdir($tmp . '/entities', 0755, true);
+ file_put_contents($tmp . '/entities/Widget.json', json_encode([
+ 'entity' => 'Widget',
+ 'fields' => ['id' => 'int', 'name' => 'string'],
+ ]));
+
+ $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
+ {
+ $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid();
+ mkdir($tmp);
+ $origDir = getcwd();
+ chdir($tmp);
+
+ $tester = new CommandTester(new GenerateAllCommand());
+ $code = $tester->execute([]);
+
+ chdir($origDir);
+ rmdir($tmp);
+
+ $this->assertSame(1, $code);
+ $this->assertStringContainsString('Directory not found', $tester->getDisplay());
+ }
+
+ public function test_generate_all_succeeds_with_no_entity_files(): void
+ {
+ $tmp = sys_get_temp_dir() . '/ef_con_' . uniqid();
+ mkdir($tmp . '/config/entities', 0755, true);
+ $origDir = getcwd();
+ chdir($tmp);
+
+ $tester = new CommandTester(new GenerateAllCommand());
+ $code = $tester->execute([]);
+
+ chdir($origDir);
+ $this->removeDir($tmp);
+
+ $this->assertSame(0, $code);
+ $this->assertStringContainsString('No entity configs found', $tester->getDisplay());
+ }
+
+ public function test_generate_all_generates_all_entities(): 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' => ['id' => 'int'],
+ ]));
+ $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)) {
+ return;
+ }
+ foreach (glob($dir . '/*') ?: [] as $item) {
+ is_dir($item) ? $this->removeDir($item) : unlink($item);
+ }
+ rmdir($dir);
+ }
+}
diff --git a/tests/Core/ApplicationTest.php b/tests/Core/ApplicationTest.php
new file mode 100644
index 0000000..4f4df2d
--- /dev/null
+++ b/tests/Core/ApplicationTest.php
@@ -0,0 +1,110 @@
+tmpDir = sys_get_temp_dir() . '/ef_app_' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ TenantContext::clear();
+ array_map('unlink', glob($this->tmpDir . '/*.yaml'));
+ rmdir($this->tmpDir);
+ }
+
+ private function writeConfig(array $tenancy = [], array $db = []): void
+ {
+ $saas = "tenancy:\n enabled: true\n strategy: shared\n resolver: header\n header_key: X-Tenant-ID\n";
+ file_put_contents($this->tmpDir . '/saas.yaml', $saas);
+
+ $dbConfig = array_merge(['driver' => 'mysql', 'host' => 'localhost', 'port' => 3306, 'database' => 'app', 'username' => 'root', 'password' => 'root'], $db);
+ $app = "application:\n name: test\n";
+ foreach ($tenancy as $k => $v) {
+ $app .= "tenancy:\n {$k}: " . ($v === true ? 'true' : ($v === false ? 'false' : $v)) . "\n";
+ }
+ $app .= "database:\n";
+ foreach ($dbConfig as $k => $v) {
+ $app .= " {$k}: {$v}\n";
+ }
+ file_put_contents($this->tmpDir . '/application.yaml', $app);
+ }
+
+ public function test_boot_loads_config(): void
+ {
+ $this->writeConfig();
+
+ $app = new Application($this->tmpDir);
+ $app->boot([], false);
+
+ $config = $app->getConfig();
+ $this->assertArrayHasKey('tenancy', $config);
+ $this->assertArrayHasKey('database', $config);
+ }
+
+ public function test_boot_resolves_tenant_from_header(): void
+ {
+ $this->writeConfig();
+
+ $app = new Application($this->tmpDir);
+ $app->boot(['headers' => ['X-Tenant-ID' => 'acme']], true);
+
+ $this->assertSame('acme', TenantContext::getTenantId());
+ }
+
+ public function test_boot_skips_tenant_resolution_when_disabled(): void
+ {
+ $this->writeConfig();
+
+ $app = new Application($this->tmpDir);
+ $app->boot([], false);
+
+ $this->assertFalse(TenantContext::hasTenantId());
+ }
+
+ public function test_boot_throws_when_context_empty_and_tenant_enabled(): void
+ {
+ $this->writeConfig();
+
+ $app = new Application($this->tmpDir);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Tenant resolution requires context');
+
+ $app->boot([], true);
+ }
+
+ public function test_get_config_throws_before_boot(): void
+ {
+ $app = new Application($this->tmpDir);
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/not booted/');
+
+ $app->getConfig();
+ }
+
+ public function test_get_config_returns_merged_config_after_boot(): void
+ {
+ $this->writeConfig();
+
+ $app = new Application($this->tmpDir);
+ $app->boot([], false);
+
+ $config = $app->getConfig();
+ $this->assertSame('shared', $config['tenancy']['strategy']);
+ $this->assertSame('localhost', $config['database']['host']);
+ }
+}
diff --git a/tests/Core/CoreSchemaManagerTest.php b/tests/Core/CoreSchemaManagerTest.php
new file mode 100644
index 0000000..7a66149
--- /dev/null
+++ b/tests/Core/CoreSchemaManagerTest.php
@@ -0,0 +1,82 @@
+allows('exec')
+ ->with(Mockery::pattern('/CREATE TABLE IF NOT EXISTS tenants/'))
+ ->once()
+ ->andReturn(0);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $manager = new CoreSchemaManager([
+ 'database' => [
+ 'driver' => 'mysql',
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'app',
+ 'username' => 'root',
+ 'password' => 'root',
+ ],
+ ]);
+
+ $manager->ensure();
+
+ $this->assertTrue(true);
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_ensure_ddl_contains_tenant_id_column(): void
+ {
+ $captured = [];
+
+ $pdo = Mockery::mock(PDO::class);
+ $pdo->allows('exec')
+ ->with(Mockery::on(function (string $sql) use (&$captured) {
+ $captured[] = $sql;
+ return true;
+ }))
+ ->andReturn(0);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $manager = new CoreSchemaManager([
+ 'database' => [
+ 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306,
+ 'database' => 'app', 'username' => 'root', 'password' => 'root',
+ ],
+ ]);
+
+ $manager->ensure();
+
+ $this->assertNotEmpty($captured);
+ $this->assertStringContainsString('tenant_id', $captured[0]);
+ $this->assertStringContainsString('tenants', $captured[0]);
+ }
+}
diff --git a/tests/Database/ConnectionTest.php b/tests/Database/ConnectionTest.php
new file mode 100644
index 0000000..952b1fe
--- /dev/null
+++ b/tests/Database/ConnectionTest.php
@@ -0,0 +1,25 @@
+expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/DB Connection failed/');
+
+ new Connection([
+ 'driver' => 'mysql',
+ 'host' => '192.0.2.1', // TEST-NET, guaranteed unreachable
+ 'port' => 3306,
+ 'database' => 'nonexistent',
+ 'username' => 'root',
+ 'password' => 'wrong',
+ ]);
+ }
+}
diff --git a/tests/Database/MigrationRunnerTest.php b/tests/Database/MigrationRunnerTest.php
new file mode 100644
index 0000000..6ab812f
--- /dev/null
+++ b/tests/Database/MigrationRunnerTest.php
@@ -0,0 +1,221 @@
+tmpDir = sys_get_temp_dir() . '/ef_mig_' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+
+ $this->pdo = Mockery::mock(PDO::class);
+ $this->connection = Mockery::mock(Connection::class);
+ $this->connection->allows('getPdo')->andReturn($this->pdo);
+
+ /** @var Connection $conn */
+ $conn = $this->connection;
+ $this->runner = new MigrationRunner($conn);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ $this->removeDir($this->tmpDir);
+ }
+
+ private function removeDir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (glob($dir . '/*') as $item) {
+ is_dir($item) ? $this->removeDir($item) : unlink($item);
+ }
+ rmdir($dir);
+ }
+
+ private function mockMigrationsTable(array $executed = [], int $maxBatch = 0): void
+ {
+ // ensureMigrationsTable: CREATE TABLE
+ $this->pdo->allows('exec')->with(Mockery::pattern('/CREATE TABLE IF NOT EXISTS migrations/'))->once();
+
+ // SHOW COLUMNS to check 'batch' exists
+ $colStmt = Mockery::mock(PDOStatement::class);
+ $colStmt->allows('fetch')->andReturn(['Field' => 'batch']);
+ $this->pdo->allows('query')->with("SHOW COLUMNS FROM migrations LIKE 'batch'")->andReturn($colStmt);
+
+ // getExecuted: SELECT migration FROM migrations
+ $execStmt = Mockery::mock(PDOStatement::class);
+ $execStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn($executed);
+ $this->pdo->allows('query')->with('SELECT migration FROM migrations')->andReturn($execStmt);
+
+ // nextBatch: SELECT MAX(batch)
+ $batchStmt = Mockery::mock(PDOStatement::class);
+ $batchStmt->allows('fetchColumn')->andReturn($maxBatch);
+ $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt);
+ }
+
+ public function test_run_prints_no_migrations_when_dir_is_empty(): void
+ {
+ $this->mockMigrationsTable();
+
+ $this->expectOutputString("No migrations found.\n");
+
+ $this->runner->run($this->tmpDir);
+ }
+
+ public function test_run_executes_up_sql_file(): void
+ {
+ file_put_contents($this->tmpDir . '/0001_create_users.up.sql', 'CREATE TABLE users (id INT);');
+
+ $this->mockMigrationsTable([], 0);
+
+ $this->pdo->allows('exec')->with('CREATE TABLE users (id INT);')->once();
+
+ $insertStmt = Mockery::mock(PDOStatement::class);
+ $insertStmt->allows('execute')->once()->andReturn(true);
+ $this->pdo->allows('prepare')
+ ->with('INSERT INTO migrations (migration, batch) VALUES (:m, :b)')
+ ->andReturn($insertStmt);
+
+ $this->expectOutputRegex('/Executed: 0001_create_users\.up\.sql/');
+
+ $this->runner->run($this->tmpDir);
+ }
+
+ public function test_run_skips_already_executed_migration(): void
+ {
+ file_put_contents($this->tmpDir . '/0001_create_users.up.sql', 'CREATE TABLE users (id INT);');
+
+ $this->mockMigrationsTable(['0001_create_users.up.sql'], 1);
+
+ $this->expectOutputRegex('/Skipped: 0001_create_users\.up\.sql/');
+
+ $this->runner->run($this->tmpDir);
+ }
+
+ public function test_rollback_does_nothing_when_no_batches(): void
+ {
+ // rollback() calls lastBatch() only โ no ensureMigrationsTable
+ $batchStmt = Mockery::mock(PDOStatement::class);
+ $batchStmt->allows('fetchColumn')->andReturn(0);
+ $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt);
+
+ $this->expectOutputString("Nothing to rollback.\n");
+
+ $this->runner->rollback($this->tmpDir);
+ }
+
+ public function test_run_throws_on_failed_migration(): void
+ {
+ file_put_contents($this->tmpDir . '/0001_bad.up.sql', 'INVALID SQL;');
+
+ $this->mockMigrationsTable([], 0);
+
+ $this->pdo->allows('exec')
+ ->with('INVALID SQL;')
+ ->andThrow(new \Exception('syntax error'));
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Migration failed: 0001_bad\.up\.sql/');
+
+ $this->runner->run($this->tmpDir);
+ }
+
+ public function test_rollback_executes_down_sql_and_removes_record(): void
+ {
+ $migration = '0001_create_users.up.sql';
+ $downFile = $this->tmpDir . '/0001_create_users.down.sql';
+ file_put_contents($downFile, 'DROP TABLE users;');
+
+ // lastBatch โ 1
+ $batchStmt = Mockery::mock(PDOStatement::class);
+ $batchStmt->allows('fetchColumn')->andReturn(1);
+ $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt);
+
+ // SELECT migrations in batch
+ $listStmt = Mockery::mock(PDOStatement::class);
+ $listStmt->allows('execute')->with(['b' => 1])->andReturn(true);
+ $listStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn([$migration]);
+ $this->pdo->allows('prepare')
+ ->with('SELECT migration FROM migrations WHERE batch = :b ORDER BY id DESC')
+ ->andReturn($listStmt);
+
+ // execute .down.sql
+ $this->pdo->allows('exec')->with('DROP TABLE users;')->once()->andReturn(0);
+
+ // DELETE record
+ $delStmt = Mockery::mock(PDOStatement::class);
+ $delStmt->allows('execute')->with(['m' => $migration])->once()->andReturn(true);
+ $this->pdo->allows('prepare')
+ ->with('DELETE FROM migrations WHERE migration = :m')
+ ->andReturn($delStmt);
+
+ $this->expectOutputRegex('/Rolled back: 0001_create_users\.up\.sql/');
+
+ $this->runner->rollback($this->tmpDir);
+ }
+
+ public function test_rollback_throws_when_down_file_missing(): void
+ {
+ $migration = '0001_missing.up.sql';
+
+ $batchStmt = Mockery::mock(PDOStatement::class);
+ $batchStmt->allows('fetchColumn')->andReturn(1);
+ $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt);
+
+ $listStmt = Mockery::mock(PDOStatement::class);
+ $listStmt->allows('execute')->andReturn(true);
+ $listStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn([$migration]);
+ $this->pdo->allows('prepare')
+ ->with('SELECT migration FROM migrations WHERE batch = :b ORDER BY id DESC')
+ ->andReturn($listStmt);
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Missing down file/');
+
+ $this->runner->rollback($this->tmpDir);
+ }
+
+ public function test_rollback_throws_on_sql_failure(): void
+ {
+ $migration = '0001_fail.up.sql';
+ file_put_contents($this->tmpDir . '/0001_fail.down.sql', 'BAD SQL;');
+
+ $batchStmt = Mockery::mock(PDOStatement::class);
+ $batchStmt->allows('fetchColumn')->andReturn(1);
+ $this->pdo->allows('query')->with('SELECT MAX(batch) FROM migrations')->andReturn($batchStmt);
+
+ $listStmt = Mockery::mock(PDOStatement::class);
+ $listStmt->allows('execute')->andReturn(true);
+ $listStmt->allows('fetchAll')->with(PDO::FETCH_COLUMN)->andReturn([$migration]);
+ $this->pdo->allows('prepare')
+ ->with('SELECT migration FROM migrations WHERE batch = :b ORDER BY id DESC')
+ ->andReturn($listStmt);
+
+ $this->pdo->allows('exec')
+ ->with('BAD SQL;')
+ ->andThrow(new \Exception('syntax error'));
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Rollback failed/');
+
+ $this->runner->rollback($this->tmpDir);
+ }
+}
diff --git a/tests/GenerateModelTest.php b/tests/GenerateModelTest.php
deleted file mode 100644
index 5dc830e..0000000
--- a/tests/GenerateModelTest.php
+++ /dev/null
@@ -1,31 +0,0 @@
-assertFileExists($jsonPath, 'Source JSON model not found for test');
-
- $json = file_get_contents($jsonPath);
- $model = json_decode($json);
-
- $gen = new GenerateModel();
- $ok = $gen->__builder((object)$model, false);
-
- $this->assertTrue($ok, 'Generator reported failure');
-
- $modelPath = __DIR__ . '/../src/EntityModels/' . $model->model . '.php';
- $repoPath = __DIR__ . '/../src/EntityRepository/' . $model->model . 'Repository.php';
-
- $this->assertFileExists($modelPath, 'Model file was not created');
- $this->assertFileExists($repoPath, 'Repository file was not created');
-
- $this->assertStringContainsString('class ' . $model->model, file_get_contents($modelPath));
- $this->assertStringContainsString('class ' . $model->model . 'Repository', file_get_contents($repoPath));
- }
-}
diff --git a/tests/Generator/Builder/EntityBuilderTest.php b/tests/Generator/Builder/EntityBuilderTest.php
new file mode 100644
index 0000000..1c366b4
--- /dev/null
+++ b/tests/Generator/Builder/EntityBuilderTest.php
@@ -0,0 +1,97 @@
+builder = new EntityBuilder();
+ }
+
+ private function schema(array $config): EntitySchema
+ {
+ return new EntitySchema($config);
+ }
+
+ public function test_generates_class_with_correct_name(): void
+ {
+ $code = $this->builder->build($this->schema(['entity' => 'Invoice', 'fields' => []]));
+
+ $this->assertStringContainsString('class Invoice', $code);
+ }
+
+ public function test_generates_correct_namespace(): void
+ {
+ $code = $this->builder->build($this->schema(['entity' => 'Invoice', 'fields' => []]));
+
+ $this->assertStringContainsString('namespace App\\Entity', $code);
+ }
+
+ public function test_generates_int_property(): void
+ {
+ $code = $this->builder->build($this->schema([
+ 'entity' => 'Product',
+ 'fields' => ['quantity' => 'int'],
+ ]));
+
+ $this->assertStringContainsString('public int $quantity', $code);
+ }
+
+ public function test_generates_string_property(): void
+ {
+ $code = $this->builder->build($this->schema([
+ 'entity' => 'Product',
+ 'fields' => ['name' => 'string'],
+ ]));
+
+ $this->assertStringContainsString('public string $name', $code);
+ }
+
+ public function test_unknown_type_maps_to_mixed(): void
+ {
+ $code = $this->builder->build($this->schema([
+ 'entity' => 'Blob',
+ 'fields' => ['data' => 'float'],
+ ]));
+
+ $this->assertStringContainsString('public mixed $data', $code);
+ }
+
+ public function test_generates_belongs_to_relation_method(): void
+ {
+ $code = $this->builder->build($this->schema([
+ 'entity' => 'Order',
+ 'fields' => [],
+ 'relations' => ['belongsTo' => ['User' => 'user_id']],
+ ]));
+
+ $this->assertStringContainsString('function user()', $code);
+ $this->assertStringContainsString('User via user_id', $code);
+ }
+
+ public function test_generates_has_many_relation_method(): void
+ {
+ $code = $this->builder->build($this->schema([
+ 'entity' => 'User',
+ 'fields' => [],
+ 'relations' => ['hasMany' => ['Order' => 'user_id']],
+ ]));
+
+ $this->assertStringContainsString('function orders()', $code);
+ $this->assertStringContainsString('Order list via user_id', $code);
+ }
+
+ public function test_generates_valid_php_opening(): void
+ {
+ $code = $this->builder->build($this->schema(['entity' => 'Foo', 'fields' => []]));
+
+ $this->assertStringStartsWith('builder = new MigrationBuilder();
+ }
+
+ private function schema(array $fields, string $entity = 'Product'): EntitySchema
+ {
+ return new EntitySchema(['entity' => $entity, 'fields' => $fields]);
+ }
+
+ public function test_build_up_creates_correct_table_name(): void
+ {
+ $sql = $this->builder->buildUp($this->schema([], 'Product'));
+
+ $this->assertStringContainsString('CREATE TABLE products', $sql);
+ }
+
+ public function test_build_up_maps_int_to_int_column(): void
+ {
+ $sql = $this->builder->buildUp($this->schema(['count' => 'int']));
+
+ $this->assertStringContainsString('count INT', $sql);
+ }
+
+ public function test_build_up_maps_string_to_varchar(): void
+ {
+ $sql = $this->builder->buildUp($this->schema(['name' => 'string']));
+
+ $this->assertStringContainsString('name VARCHAR(255)', $sql);
+ }
+
+ public function test_build_up_maps_float_to_float(): void
+ {
+ $sql = $this->builder->buildUp($this->schema(['price' => 'float']));
+
+ $this->assertStringContainsString('price FLOAT', $sql);
+ }
+
+ public function test_build_up_maps_bool_to_boolean(): void
+ {
+ $sql = $this->builder->buildUp($this->schema(['active' => 'bool']));
+
+ $this->assertStringContainsString('active BOOLEAN', $sql);
+ }
+
+ public function test_build_up_maps_id_to_primary_key(): void
+ {
+ $sql = $this->builder->buildUp($this->schema(['id' => 'int']));
+
+ $this->assertStringContainsString('id INT PRIMARY KEY AUTO_INCREMENT', $sql);
+ }
+
+ public function test_build_up_unknown_type_maps_to_text(): void
+ {
+ // 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);
+ }
+
+ public function test_build_down_drops_correct_table(): void
+ {
+ $sql = $this->builder->buildDown($this->schema([], 'Order'));
+
+ $this->assertStringContainsString('DROP TABLE IF EXISTS orders', $sql);
+ }
+
+ public function test_table_name_is_lowercased_plural(): void
+ {
+ $sql = $this->builder->buildUp($this->schema([], 'Invoice'));
+
+ $this->assertStringContainsString('invoices', $sql);
+ }
+}
diff --git a/tests/Generator/Builder/RepositoryBuilderTest.php b/tests/Generator/Builder/RepositoryBuilderTest.php
new file mode 100644
index 0000000..39c4063
--- /dev/null
+++ b/tests/Generator/Builder/RepositoryBuilderTest.php
@@ -0,0 +1,50 @@
+builder = new RepositoryBuilder();
+ }
+
+ public function test_generates_correct_class_name(): void
+ {
+ $schema = new EntitySchema(['entity' => 'Invoice', 'fields' => []]);
+ $code = $this->builder->build($schema);
+
+ $this->assertStringContainsString('class InvoiceRepository', $code);
+ }
+
+ public function test_extends_base_repository(): void
+ {
+ $schema = new EntitySchema(['entity' => 'Invoice', 'fields' => []]);
+ $code = $this->builder->build($schema);
+
+ $this->assertStringContainsString('extends BaseRepository', $code);
+ }
+
+ public function test_uses_correct_namespace(): void
+ {
+ $schema = new EntitySchema(['entity' => 'Invoice', 'fields' => []]);
+ $code = $this->builder->build($schema);
+
+ $this->assertStringContainsString('namespace App\\Repository', $code);
+ $this->assertStringContainsString('use EntityForge\\Repository\\BaseRepository', $code);
+ }
+
+ public function test_generates_valid_php_opening(): void
+ {
+ $schema = new EntitySchema(['entity' => 'Foo', 'fields' => []]);
+ $code = $this->builder->build($schema);
+
+ $this->assertStringStartsWith('generator = new EntityGenerator();
+ $this->tmpDir = sys_get_temp_dir() . '/ef_gen_' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ $this->originalDir = getcwd();
+ chdir($this->tmpDir);
+ }
+
+ protected function tearDown(): void
+ {
+ chdir($this->originalDir);
+ $this->removeDir($this->tmpDir);
+ }
+
+ private function removeDir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (glob($dir . '/*') as $item) {
+ is_dir($item) ? $this->removeDir($item) : unlink($item);
+ }
+ rmdir($dir);
+ }
+
+ private function minimalConfig(string $entity = 'Widget'): array
+ {
+ return ['entity' => $entity, 'fields' => ['id' => 'int', 'name' => 'string']];
+ }
+
+ public function test_generate_creates_entity_file(): void
+ {
+ $this->generator->generate($this->minimalConfig());
+
+ $this->assertFileExists($this->tmpDir . '/app/Entity/Widget.php');
+ }
+
+ public function test_generate_creates_repository_file(): void
+ {
+ $this->generator->generate($this->minimalConfig());
+
+ $this->assertFileExists($this->tmpDir . '/app/Repository/WidgetRepository.php');
+ }
+
+ public function test_generate_with_migration_creates_up_file(): void
+ {
+ $this->generator->generate($this->minimalConfig(), true);
+
+ $migrations = glob($this->tmpDir . '/database/migrations/*.up.sql');
+ $this->assertCount(1, $migrations);
+ }
+
+ public function test_generate_with_migration_creates_down_file(): void
+ {
+ $this->generator->generate($this->minimalConfig(), true);
+
+ $migrations = glob($this->tmpDir . '/database/migrations/*.down.sql');
+ $this->assertCount(1, $migrations);
+ }
+
+ public function test_generate_without_migration_creates_no_migration_files(): void
+ {
+ $this->generator->generate($this->minimalConfig(), false);
+
+ $migrations = glob($this->tmpDir . '/database/migrations/*.sql') ?: [];
+ $this->assertCount(0, $migrations);
+ }
+
+ public function test_generate_multiple_entities_produce_unique_migration_names(): void
+ {
+ $this->generator->generate(['entity' => 'Alpha', 'fields' => ['id' => 'int']], true);
+ $this->generator->generate(['entity' => 'Beta', 'fields' => ['id' => 'int']], true);
+
+ $migrations = glob($this->tmpDir . '/database/migrations/*.up.sql');
+ $this->assertCount(2, $migrations);
+ $this->assertNotSame(basename($migrations[0]), basename($migrations[1]));
+ }
+
+ public function test_generate_throws_on_invalid_schema(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+
+ $this->generator->generate(['entity' => 'invalid_name', 'fields' => []]);
+ }
+}
diff --git a/tests/Generator/Schema/EntitySchemaTest.php b/tests/Generator/Schema/EntitySchemaTest.php
new file mode 100644
index 0000000..085eb4d
--- /dev/null
+++ b/tests/Generator/Schema/EntitySchemaTest.php
@@ -0,0 +1,83 @@
+ 'Order',
+ 'fields' => ['id' => 'int', 'total' => 'float'],
+ ], $overrides));
+ }
+
+ 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());
+ }
+
+ public function test_has_timestamps_true_when_set(): void
+ {
+ $this->assertTrue($this->schema(['timestamps' => true])->hasTimestamps());
+ }
+
+ public function test_get_fields_returns_defined_fields(): void
+ {
+ $fields = $this->schema()->getFields();
+
+ $this->assertArrayHasKey('id', $fields);
+ $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();
+
+ $this->assertArrayHasKey('created_at', $fields);
+ $this->assertArrayHasKey('updated_at', $fields);
+ }
+
+ public function test_get_fields_with_no_fields_key_returns_empty(): void
+ {
+ $schema = new EntitySchema(['entity' => 'Empty']);
+ $this->assertSame([], $schema->getFields());
+ }
+
+ public function test_get_relations_returns_empty_array_by_default(): void
+ {
+ $this->assertSame([], $this->schema()->getRelations());
+ }
+
+ public function test_get_relations_returns_defined_relations(): void
+ {
+ $schema = $this->schema(['relations' => ['belongsTo' => ['User' => 'user_id']]]);
+ $this->assertSame(['belongsTo' => ['User' => 'user_id']], $schema->getRelations());
+ }
+}
diff --git a/tests/Generator/Schema/SchemaValidatorTest.php b/tests/Generator/Schema/SchemaValidatorTest.php
new file mode 100644
index 0000000..7b881e7
--- /dev/null
+++ b/tests/Generator/Schema/SchemaValidatorTest.php
@@ -0,0 +1,97 @@
+validator = new SchemaValidator();
+ }
+
+ public function test_valid_schema_passes(): void
+ {
+ $this->validator->validate(['entity' => 'User', 'fields' => ['id' => 'int', 'name' => 'string']]);
+ $this->assertTrue(true);
+ }
+
+ public function test_missing_entity_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches("/Missing Required field 'entity'/");
+
+ $this->validator->validate([]);
+ }
+
+ public function test_empty_entity_name_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->validator->validate(['entity' => '']);
+ }
+
+ public function test_lowercase_entity_name_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches('/Invalid entity name/');
+
+ $this->validator->validate(['entity' => 'user']);
+ }
+
+ public function test_entity_name_with_underscore_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ $this->validator->validate(['entity' => 'My_Entity']);
+ }
+
+ public function test_fields_as_non_array_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches("/'fields' must be an object/");
+
+ $this->validator->validate(['entity' => 'User', 'fields' => 'invalid']);
+ }
+
+ public function test_invalid_field_name_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches('/Invalid field name/');
+
+ $this->validator->validate(['entity' => 'User', 'fields' => ['InvalidName' => 'string']]);
+ }
+
+ public function test_unsupported_field_type_throws(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessageMatches('/Unsupported field type/');
+
+ $this->validator->validate(['entity' => 'User', 'fields' => ['name' => 'array']]);
+ }
+
+ public function test_all_supported_types_pass(): void
+ {
+ $this->validator->validate([
+ 'entity' => 'Product',
+ 'fields' => [
+ 'id' => 'int',
+ 'name' => 'string',
+ 'price' => 'float',
+ 'active' => 'bool',
+ ],
+ ]);
+ $this->assertTrue(true);
+ }
+
+ public function test_schema_without_fields_key_passes(): void
+ {
+ $this->validator->validate(['entity' => 'User']);
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Generator/Writer/FileWriterTest.php b/tests/Generator/Writer/FileWriterTest.php
new file mode 100644
index 0000000..6ad307b
--- /dev/null
+++ b/tests/Generator/Writer/FileWriterTest.php
@@ -0,0 +1,84 @@
+writer = new FileWriter();
+ $this->tmpDir = sys_get_temp_dir() . '/ef_writer_' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDir($this->tmpDir);
+ }
+
+ private function removeDir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (glob($dir . '/*') as $item) {
+ is_dir($item) ? $this->removeDir($item) : unlink($item);
+ }
+ rmdir($dir);
+ }
+
+ public function test_writes_file_content(): void
+ {
+ $path = $this->tmpDir . '/out.php';
+
+ $this->writer->write($path, 'assertSame('tmpDir . '/deep/nested/dir/file.php';
+
+ $this->writer->write($path, 'content');
+
+ $this->assertFileExists($path);
+ }
+
+ public function test_overwrites_existing_file_by_default(): void
+ {
+ $path = $this->tmpDir . '/file.php';
+ file_put_contents($path, 'old');
+
+ $this->writer->write($path, 'new');
+
+ $this->assertSame('new', file_get_contents($path));
+ }
+
+ public function test_throws_when_overwrite_false_and_file_exists(): void
+ {
+ $path = $this->tmpDir . '/file.php';
+ file_put_contents($path, 'existing');
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches('/already exists/');
+
+ $this->writer->write($path, 'new', false);
+ }
+
+ public function test_does_not_throw_when_overwrite_false_and_file_not_exists(): void
+ {
+ $path = $this->tmpDir . '/new_file.php';
+
+ $this->writer->write($path, 'content', false);
+
+ $this->assertFileExists($path);
+ }
+}
diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php
new file mode 100644
index 0000000..901075a
--- /dev/null
+++ b/tests/Http/RequestTest.php
@@ -0,0 +1,86 @@
+ 'acme']);
+
+ $this->assertSame('acme', $request->header('X-Tenant-ID'));
+ }
+
+ public function test_header_returns_null_when_absent(): void
+ {
+ $request = new Request();
+
+ $this->assertNull($request->header('X-Tenant-ID'));
+ }
+
+ public function test_query_returns_value_when_present(): void
+ {
+ $request = new Request(query: ['page' => '2']);
+
+ $this->assertSame('2', $request->query('page'));
+ }
+
+ public function test_query_returns_null_when_absent(): void
+ {
+ $request = new Request();
+
+ $this->assertNull($request->query('missing'));
+ }
+
+ public function test_body_returns_value_when_present(): void
+ {
+ $request = new Request(body: ['name' => 'Alice']);
+
+ $this->assertSame('Alice', $request->body('name'));
+ }
+
+ public function test_body_returns_null_when_absent(): void
+ {
+ $request = new Request();
+
+ $this->assertNull($request->body('missing'));
+ }
+
+ public function test_method_defaults_to_get(): void
+ {
+ $request = new Request();
+
+ $this->assertSame('GET', $request->method());
+ }
+
+ public function test_method_returns_provided_method(): void
+ {
+ $request = new Request(method: 'POST');
+
+ $this->assertSame('POST', $request->method());
+ }
+
+ public function test_capture_skipped_outside_web_sapi(): void
+ {
+ if (!function_exists('getallheaders')) {
+ $this->markTestSkipped('getallheaders() not available outside web SAPI');
+ }
+
+ $_SERVER['REQUEST_METHOD'] = 'PUT';
+ $_GET = ['q' => 'search'];
+ $_POST = ['name' => 'Alice'];
+
+ $request = Request::capture();
+
+ $this->assertSame('PUT', $request->method());
+ $this->assertSame('search', $request->query('q'));
+ $this->assertSame('Alice', $request->body('name'));
+
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_GET = [];
+ $_POST = [];
+ }
+}
diff --git a/tests/Http/ResponseTest.php b/tests/Http/ResponseTest.php
new file mode 100644
index 0000000..b3933aa
--- /dev/null
+++ b/tests/Http/ResponseTest.php
@@ -0,0 +1,43 @@
+json(['key' => 'value']);
+ $output = ob_get_clean();
+
+ $this->assertSame('{"key":"value"}', $output);
+ }
+
+ public function test_json_outputs_nested_array(): void
+ {
+ $response = new Response();
+
+ ob_start();
+ $response->json(['user' => ['id' => 1, 'name' => 'Alice']]);
+ $output = ob_get_clean();
+
+ $decoded = json_decode($output, true);
+ $this->assertSame('Alice', $decoded['user']['name']);
+ }
+
+ public function test_json_outputs_empty_array(): void
+ {
+ $response = new Response();
+
+ ob_start();
+ $response->json([]);
+ $output = ob_get_clean();
+
+ $this->assertSame('[]', $output);
+ }
+}
diff --git a/tests/Repository/BaseRepositoryTest.php b/tests/Repository/BaseRepositoryTest.php
new file mode 100644
index 0000000..47c9c57
--- /dev/null
+++ b/tests/Repository/BaseRepositoryTest.php
@@ -0,0 +1,250 @@
+pdo = Mockery::mock(PDO::class);
+ $this->connection = Mockery::mock(Connection::class);
+ $this->connection->allows('getPdo')->andReturn($this->pdo);
+ }
+
+ protected function tearDown(): void
+ {
+ TenantContext::clear();
+ Mockery::close();
+ }
+
+ private function config(string $strategy = 'shared'): array
+ {
+ return [
+ 'tenancy' => ['strategy' => $strategy],
+ 'database' => [
+ 'driver' => 'mysql',
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'app',
+ 'username' => 'root',
+ 'password' => 'root',
+ ],
+ ];
+ }
+
+ private function repo(string $strategy = 'shared'): WidgetRepository
+ {
+ /** @var Connection $conn */
+ $conn = $this->connection;
+
+ $repo = new class($this->config($strategy), $conn) extends WidgetRepository {
+ public function __construct(array $config, Connection $conn)
+ {
+ $this->config = $config;
+ $this->connection = $conn;
+ $this->table = 'widgets'; // bypass ReflectionClass on anonymous class
+ }
+ };
+
+ /** @var WidgetRepository $repo */
+ return $repo;
+ }
+
+ public function test_table_name_resolves_to_widgets(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->with([])->andReturn(true);
+ $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([]);
+
+ $this->pdo->allows('prepare')
+ ->with('SELECT * FROM widgets')
+ ->andReturn($stmt);
+
+ $results = $this->repo('database')->findAll();
+
+ $this->assertIsArray($results);
+ }
+
+ public function test_find_all_shared_strategy_scopes_by_tenant(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->with(['tenant_id' => 'acme'])->once()->andReturn(true);
+ $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([['id' => 1]]);
+
+ $this->pdo->allows('prepare')
+ ->with('SELECT * FROM widgets WHERE tenant_id = :tenant_id')
+ ->andReturn($stmt);
+
+ $results = $this->repo('shared')->findAll();
+
+ $this->assertSame([['id' => 1]], $results);
+ }
+
+ public function test_find_all_database_strategy_no_tenant_scope(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->with([])->once()->andReturn(true);
+ $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([]);
+
+ $this->pdo->allows('prepare')
+ ->with('SELECT * FROM widgets')
+ ->andReturn($stmt);
+
+ $results = $this->repo('database')->findAll();
+
+ $this->assertSame([], $results);
+ }
+
+ public function test_find_by_id_shared_strategy_appends_tenant_clause(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->with(['id' => 5, 'tenant_id' => 'acme'])->once()->andReturn(true);
+ $stmt->allows('fetch')->with(PDO::FETCH_ASSOC)->andReturn(['id' => 5, 'name' => 'Foo']);
+
+ $this->pdo->allows('prepare')
+ ->with('SELECT * FROM widgets WHERE id = :id AND tenant_id = :tenant_id')
+ ->andReturn($stmt);
+
+ $result = $this->repo('shared')->findById(5);
+
+ $this->assertSame(['id' => 5, 'name' => 'Foo'], $result);
+ }
+
+ public function test_find_by_id_returns_null_when_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);
+
+ $result = $this->repo('database')->findById(99);
+
+ $this->assertNull($result);
+ }
+
+ public function test_create_shared_strategy_injects_tenant_id(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')
+ ->with(Mockery::on(fn($data) => isset($data['tenant_id']) && $data['tenant_id'] === 'acme'))
+ ->once()
+ ->andReturn(true);
+
+ $this->pdo->allows('prepare')->andReturn($stmt);
+
+ $result = $this->repo('shared')->create(['name' => 'Alice']);
+
+ $this->assertArrayHasKey('tenant_id', $result);
+ $this->assertSame('acme', $result['tenant_id']);
+ }
+
+ public function test_create_database_strategy_does_not_inject_tenant_id(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')
+ ->with(Mockery::on(fn($data) => !isset($data['tenant_id'])))
+ ->once()
+ ->andReturn(true);
+
+ $this->pdo->allows('prepare')->andReturn($stmt);
+
+ $result = $this->repo('database')->create(['name' => 'Alice']);
+
+ $this->assertArrayNotHasKey('tenant_id', $result);
+ }
+
+ public function test_update_shared_strategy_appends_tenant_clause(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->once()->andReturn(true);
+
+ $this->pdo->allows('prepare')
+ ->with('UPDATE widgets SET name = :name WHERE id = :id AND tenant_id = :tenant_id')
+ ->andReturn($stmt);
+
+ $result = $this->repo('shared')->update(1, ['name' => 'Bob']);
+
+ $this->assertTrue($result);
+ }
+
+ public function test_delete_shared_strategy_appends_tenant_clause(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->once()->andReturn(true);
+
+ $this->pdo->allows('prepare')
+ ->with('DELETE FROM widgets WHERE id = :id AND tenant_id = :tenant_id')
+ ->andReturn($stmt);
+
+ $result = $this->repo('shared')->delete(1);
+
+ $this->assertTrue($result);
+ }
+
+ public function test_where_shared_strategy_includes_tenant_scope(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')
+ ->with(['status' => 'active', 'tenant_id' => 'acme'])
+ ->once()
+ ->andReturn(true);
+ $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([]);
+
+ $this->pdo->allows('prepare')
+ ->with('SELECT * FROM widgets WHERE status = :status AND tenant_id = :tenant_id')
+ ->andReturn($stmt);
+
+ $results = $this->repo('shared')->where(['status' => 'active']);
+
+ $this->assertSame([], $results);
+ }
+
+ public function test_transaction_methods_delegate_to_pdo(): void
+ {
+ $this->pdo->allows('beginTransaction')->once()->andReturn(true);
+ $this->pdo->allows('commit')->once()->andReturn(true);
+ $this->pdo->allows('rollBack')->once()->andReturn(true);
+
+ $repo = $this->repo('database');
+ $repo->beginTransaction();
+ $repo->commit();
+ $repo->rollback();
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/tests/Tenant/Resolver/HeaderTenantResolverTest.php b/tests/Tenant/Resolver/HeaderTenantResolverTest.php
new file mode 100644
index 0000000..50bc921
--- /dev/null
+++ b/tests/Tenant/Resolver/HeaderTenantResolverTest.php
@@ -0,0 +1,56 @@
+resolve(['headers' => ['X-Tenant-ID' => 'acme']]);
+
+ $this->assertSame('acme', $tenantId);
+ }
+
+ public function test_resolves_tenant_from_custom_header(): void
+ {
+ $resolver = new HeaderTenantResolver('X-Custom-Tenant');
+
+ $tenantId = $resolver->resolve(['headers' => ['X-Custom-Tenant' => 'bigcorp']]);
+
+ $this->assertSame('bigcorp', $tenantId);
+ }
+
+ public function test_throws_when_header_missing(): void
+ {
+ $resolver = new HeaderTenantResolver();
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches("/Tenant header '.*' not found/");
+
+ $resolver->resolve(['headers' => []]);
+ }
+
+ public function test_throws_when_headers_key_absent(): void
+ {
+ $resolver = new HeaderTenantResolver();
+
+ $this->expectException(Exception::class);
+
+ $resolver->resolve([]);
+ }
+
+ public function test_throws_when_wrong_header_provided(): void
+ {
+ $resolver = new HeaderTenantResolver('X-Tenant-ID');
+
+ $this->expectException(Exception::class);
+
+ $resolver->resolve(['headers' => ['Authorization' => 'Bearer token']]);
+ }
+}
diff --git a/tests/Tenant/TenantConnectionResolverTest.php b/tests/Tenant/TenantConnectionResolverTest.php
new file mode 100644
index 0000000..735b2b4
--- /dev/null
+++ b/tests/Tenant/TenantConnectionResolverTest.php
@@ -0,0 +1,66 @@
+expectException(Exception::class);
+ $this->expectExceptionMessage('Unsupported tenancy strategy');
+
+ TenantConnectionResolver::resolve([
+ 'tenancy' => ['strategy' => 'schema'],
+ 'database' => [],
+ ]);
+ }
+
+ #[\PHPUnit\Framework\Attributes\RunInSeparateProcess]
+ #[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
+ public function test_shared_strategy_returns_connection(): void
+ {
+ $conn = \Mockery::mock('overload:' . \EntityForge\Database\Connection::class);
+
+ $result = \EntityForge\Tenant\TenantConnectionResolver::resolve([
+ 'tenancy' => ['strategy' => 'shared'],
+ 'database' => [
+ 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306,
+ 'database' => 'app', 'username' => 'root', 'password' => 'root',
+ ],
+ ]);
+
+ $this->assertInstanceOf(\EntityForge\Database\Connection::class, $result);
+ }
+
+ public function test_database_strategy_throws_when_tenant_not_set(): void
+ {
+ $this->expectException(Exception::class);
+
+ TenantConnectionResolver::resolve([
+ 'tenancy' => ['strategy' => 'database'],
+ 'database' => [
+ 'driver' => 'mysql',
+ 'host' => 'localhost',
+ 'port' => 3306,
+ 'database' => 'app',
+ 'username' => 'root',
+ 'password' => 'root',
+ ],
+ ]);
+ }
+}
diff --git a/tests/Tenant/TenantContextTest.php b/tests/Tenant/TenantContextTest.php
new file mode 100644
index 0000000..52c32ea
--- /dev/null
+++ b/tests/Tenant/TenantContextTest.php
@@ -0,0 +1,63 @@
+assertSame('acme', TenantContext::getTenantId());
+ }
+
+ public function test_has_tenant_id_false_when_not_set(): void
+ {
+ $this->assertFalse(TenantContext::hasTenantId());
+ }
+
+ public function test_has_tenant_id_true_after_set(): void
+ {
+ TenantContext::setTenantId('acme');
+
+ $this->assertTrue(TenantContext::hasTenantId());
+ }
+
+ public function test_get_tenant_id_throws_when_not_set(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Tenant ID is not set');
+
+ TenantContext::getTenantId();
+ }
+
+ public function test_clear_resets_tenant_id(): void
+ {
+ TenantContext::setTenantId('acme');
+ TenantContext::clear();
+
+ $this->assertFalse(TenantContext::hasTenantId());
+ }
+
+ public function test_set_overwrites_existing_tenant_id(): void
+ {
+ TenantContext::setTenantId('first');
+ TenantContext::setTenantId('second');
+
+ $this->assertSame('second', TenantContext::getTenantId());
+ }
+}
diff --git a/tests/Tenant/TenantGuardTest.php b/tests/Tenant/TenantGuardTest.php
new file mode 100644
index 0000000..28c2be5
--- /dev/null
+++ b/tests/Tenant/TenantGuardTest.php
@@ -0,0 +1,37 @@
+assertTrue(true);
+ }
+
+ public function test_ensure_tenant_throws_when_tenant_not_set(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Tenant ID is not set');
+
+ TenantGuard::ensureTenant();
+ }
+}
diff --git a/tests/Tenant/TenantRepositoryTest.php b/tests/Tenant/TenantRepositoryTest.php
new file mode 100644
index 0000000..12e3755
--- /dev/null
+++ b/tests/Tenant/TenantRepositoryTest.php
@@ -0,0 +1,120 @@
+ [
+ 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306,
+ 'database' => 'app', 'username' => 'root', 'password' => 'root',
+ ],
+ ];
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_create_inserts_tenant_row(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')
+ ->with(['tenant_id' => 'acme', 'name' => 'Acme Corp'])
+ ->once()
+ ->andReturn(true);
+
+ $pdo = Mockery::mock(PDO::class);
+ $pdo->allows('prepare')
+ ->with('INSERT INTO tenants (tenant_id, name) VALUES (:tenant_id, :name)')
+ ->andReturn($stmt);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $repo = new TenantRepository($this->dbConfig());
+ $repo->create('acme', 'Acme Corp');
+
+ $this->assertTrue(true);
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_all_returns_tenant_rows(): void
+ {
+ $rows = [['id' => 1, 'tenant_id' => 'acme', 'name' => 'Acme']];
+
+ $pdo = Mockery::mock(PDO::class);
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn($rows);
+ $pdo->allows('query')
+ ->with('SELECT * FROM tenants')
+ ->andReturn($stmt);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $repo = new TenantRepository($this->dbConfig());
+
+ $this->assertSame($rows, $repo->all());
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_exists_returns_true_when_found(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->with(['id' => 'acme'])->andReturn(true);
+ $stmt->allows('fetchColumn')->andReturn(1);
+
+ $pdo = Mockery::mock(PDO::class);
+ $pdo->allows('prepare')
+ ->with('SELECT COUNT(*) FROM tenants WHERE tenant_id = :id')
+ ->andReturn($stmt);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $repo = new TenantRepository($this->dbConfig());
+
+ $this->assertTrue($repo->exists('acme'));
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_exists_returns_false_when_not_found(): void
+ {
+ $stmt = Mockery::mock(PDOStatement::class);
+ $stmt->allows('execute')->andReturn(true);
+ $stmt->allows('fetchColumn')->andReturn(0);
+
+ $pdo = Mockery::mock(PDO::class);
+ $pdo->allows('prepare')->andReturn($stmt);
+
+ /** @var MockInterface&Connection $conn */
+ $conn = Mockery::mock('overload:' . Connection::class);
+ $conn->allows('getPdo')->andReturn($pdo);
+
+ $repo = new TenantRepository($this->dbConfig());
+
+ $this->assertFalse($repo->exists('unknown'));
+ }
+}
diff --git a/tests/Tenant/TenantResolverFactoryTest.php b/tests/Tenant/TenantResolverFactoryTest.php
new file mode 100644
index 0000000..dbeff37
--- /dev/null
+++ b/tests/Tenant/TenantResolverFactoryTest.php
@@ -0,0 +1,51 @@
+ ['resolver' => 'header']]);
+
+ $this->assertInstanceOf(HeaderTenantResolver::class, $resolver);
+ }
+
+ public function test_defaults_to_header_resolver_when_resolver_key_absent(): void
+ {
+ $resolver = TenantResolverFactory::create(['tenancy' => []]);
+
+ $this->assertInstanceOf(HeaderTenantResolver::class, $resolver);
+ }
+
+ public function test_header_resolver_uses_configured_header_key(): void
+ {
+ $resolver = TenantResolverFactory::create([
+ 'tenancy' => ['resolver' => 'header', 'header_key' => 'X-Custom-Tenant'],
+ ]);
+
+ $tenantId = $resolver->resolve(['headers' => ['X-Custom-Tenant' => 'myorg']]);
+ $this->assertSame('myorg', $tenantId);
+ }
+
+ public function test_header_resolver_uses_default_header_when_key_absent(): void
+ {
+ $resolver = TenantResolverFactory::create(['tenancy' => ['resolver' => 'header']]);
+
+ $tenantId = $resolver->resolve(['headers' => ['X-Tenant-ID' => 'org1']]);
+ $this->assertSame('org1', $tenantId);
+ }
+
+ public function test_throws_for_unsupported_resolver_type(): void
+ {
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessageMatches('/Unsupported tenant resolver type/');
+
+ TenantResolverFactory::create(['tenancy' => ['resolver' => 'subdomain']]);
+ }
+}
diff --git a/tests/Tenant/TenantServiceTest.php b/tests/Tenant/TenantServiceTest.php
new file mode 100644
index 0000000..295ed3e
--- /dev/null
+++ b/tests/Tenant/TenantServiceTest.php
@@ -0,0 +1,63 @@
+ [
+ 'driver' => 'mysql', 'host' => 'localhost', 'port' => 3306,
+ 'database' => 'app', 'username' => 'root', 'password' => 'root',
+ ],
+ ];
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_onboard_provisions_and_registers_tenant(): void
+ {
+ $repo = Mockery::mock('overload:' . TenantRepository::class);
+ $repo->allows('exists')->with('acme')->andReturn(false);
+ $repo->allows('create')->with('acme', 'Acme Corp')->once();
+
+ $provisioner = Mockery::mock('overload:' . TenantProvisioner::class);
+ $provisioner->allows('create')->with('acme')->once();
+
+ $service = new TenantService($this->config());
+ $service->onboard('acme', 'Acme Corp');
+
+ $this->assertTrue(true);
+ }
+
+ #[RunInSeparateProcess]
+ #[PreserveGlobalState(false)]
+ public function test_onboard_throws_when_tenant_already_exists(): void
+ {
+ $repo = Mockery::mock('overload:' . TenantRepository::class);
+ $repo->allows('exists')->with('acme')->andReturn(true);
+
+ Mockery::mock('overload:' . TenantProvisioner::class);
+
+ $service = new TenantService($this->config());
+
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Tenant already exists/');
+
+ $service->onboard('acme', 'Acme Corp');
+ }
+}