From c4af2aac41133f914746d668df63e07baa606c32 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Thu, 15 Jan 2026 12:48:35 +0300
Subject: [PATCH 01/32] ci: Updated Workflows
- Added PHP 8.5
- Add Fixed Version for Re-Usable Workflows
- Renamed Workflows by removing the word 'Build'
---
.github/workflows/php81.yaml | 4 +-
.github/workflows/php82.yml | 4 +-
.github/workflows/php83.yml | 4 +-
.github/workflows/php84.yml | 16 +----
.github/workflows/php85.yml | 128 +++++++++++++++++++++++++++++++++++
5 files changed, 135 insertions(+), 21 deletions(-)
create mode 100644 .github/workflows/php85.yml
diff --git a/.github/workflows/php81.yaml b/.github/workflows/php81.yaml
index 0f9bfadd2..cdacb46ce 100644
--- a/.github/workflows/php81.yaml
+++ b/.github/workflows/php81.yaml
@@ -1,4 +1,4 @@
-name: Build PHP 8.1
+name: PHP 8.1
on:
push:
@@ -104,7 +104,7 @@ jobs:
code-coverage:
name: Coverage
needs: test
- uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main
+ uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.1
with:
php-version: '8.1'
coverage-file: 'php-8.1-coverage.xml'
diff --git a/.github/workflows/php82.yml b/.github/workflows/php82.yml
index a162da5a0..68f379762 100644
--- a/.github/workflows/php82.yml
+++ b/.github/workflows/php82.yml
@@ -1,4 +1,4 @@
-name: Build PHP 8.2
+name: PHP 8.2
on:
push:
@@ -105,7 +105,7 @@ jobs:
code-coverage:
name: Coverage
needs: test
- uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main
+ uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.1
with:
php-version: '8.2'
coverage-file: 'php-8.2-coverage.xml'
diff --git a/.github/workflows/php83.yml b/.github/workflows/php83.yml
index 805dd4da0..7afb7d0e5 100644
--- a/.github/workflows/php83.yml
+++ b/.github/workflows/php83.yml
@@ -1,4 +1,4 @@
-name: Build PHP 8.3
+name: PHP 8.3
on:
push:
@@ -105,7 +105,7 @@ jobs:
code-coverage:
name: Coverage
needs: test
- uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main
+ uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.1
with:
php-version: '8.3'
coverage-file: 'php-8.3-coverage.xml'
diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml
index 282ed4545..33700f172 100644
--- a/.github/workflows/php84.yml
+++ b/.github/workflows/php84.yml
@@ -106,23 +106,9 @@ jobs:
code-coverage:
name: Coverage
needs: test
- uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main
+ uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.1
with:
php-version: '8.4'
coverage-file: 'php-8.4-coverage.xml'
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
-
- code-quality:
- name: Code Quality
- needs: test
- uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@main
- secrets:
- SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
-
- release-prod:
- name: Prepare Production Release Branch / Publish Release
- needs: [code-coverage, code-quality]
- uses: WebFiori/workflows/.github/workflows/release-php.yaml@main
- with:
- branch: 'main'
\ No newline at end of file
diff --git a/.github/workflows/php85.yml b/.github/workflows/php85.yml
new file mode 100644
index 000000000..62cdbab71
--- /dev/null
+++ b/.github/workflows/php85.yml
@@ -0,0 +1,128 @@
+name: PHP 8.5
+
+on:
+ push:
+ branches: [ main, dev ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ env:
+ SA_SQL_SERVER_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }}
+ MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
+
+ services:
+ sqlserver:
+ image: mcr.microsoft.com/mssql/server:2019-latest
+ env:
+ SA_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }}
+ ACCEPT_EULA: Y
+ MSSQL_PID: Express
+ ports:
+ - "1433:1433"
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
+ MYSQL_DATABASE: testing_db
+ MYSQL_ROOT_HOST: '%'
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
+ strategy:
+ fail-fast: true
+
+ name: Run PHPUnit Tests
+
+ steps:
+ - name: Clone Repo
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.5
+ extensions: mysqli, mbstring, sqlsrv
+ tools: phpunit:11.5.27, composer
+
+ - name: Install ODBC Driver for SQL Server
+ run: |
+ curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc
+ curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list
+ sudo apt update
+ sudo ACCEPT_EULA=Y apt install mssql-tools18 unixodbc-dev msodbcsql18
+
+ - name: Wait for SQL Server
+ run: |
+ for i in {1..12}; do
+ if /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'SELECT 1' -C > /dev/null 2>&1; then
+ echo "SQL Server is ready"
+ break
+ fi
+ echo "Waiting for SQL Server... ($i/12)"
+ sleep 10
+ done
+
+ - name: Create SQL Server Database
+ run: /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'create database testing_db' -C
+
+ - name: Setup MySQL Client
+ run: |
+ sudo apt update
+ sudo apt install mysql-client-core-8.0
+
+ - name: Wait for MySQL
+ run: |
+ until mysqladmin ping -h 127.0.0.1 --silent; do
+ echo 'waiting for mysql...'
+ sleep 1
+ done
+
+ - name: Create MySQL Database
+ run: |
+ mysql -h 127.0.0.1 -u root -p${{ secrets.MYSQL_ROOT_PASSWORD }} -e "CREATE DATABASE IF NOT EXISTS testing_db;"
+
+ - name: Install Dependencies
+ run: composer install --prefer-source --no-interaction
+
+ - name: Execute Tests
+ run: phpunit --configuration=tests/phpunit10.xml --coverage-clover=clover.xml
+
+ - name: Rename coverage report
+ run: |
+ mv clover.xml php-8.5-coverage.xml
+
+ - name: Upload Coverage Report
+ uses: actions/upload-artifact@v4
+ with:
+ name: code-coverage
+ path: php-8.5-coverage.xml
+
+
+ code-coverage:
+ name: Coverage
+ needs: test
+ uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.1
+ with:
+ php-version: '8.5'
+ coverage-file: 'php-8.5-coverage.xml'
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
+ code-quality:
+ name: Code Quality
+ needs: test
+ uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@v1.2.1
+ secrets:
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+
+ release-prod:
+ name: Prepare Production Release Branch / Publish Release
+ needs: [code-coverage, code-quality]
+ uses: WebFiori/workflows/.github/workflows/release-php.yaml@v1.2.1
+ with:
+ branch: 'main'
\ No newline at end of file
From 9f9aff6591eef9cd4a4256979542eba44ce6e7bd Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Thu, 15 Jan 2026 13:07:36 +0300
Subject: [PATCH 02/32] ci: Update PHPUnit Version for PHP 8.5
---
.github/workflows/php85.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/php85.yml b/.github/workflows/php85.yml
index 62cdbab71..287d99f5a 100644
--- a/.github/workflows/php85.yml
+++ b/.github/workflows/php85.yml
@@ -47,7 +47,7 @@ jobs:
with:
php-version: 8.5
extensions: mysqli, mbstring, sqlsrv
- tools: phpunit:11.5.27, composer
+ tools: phpunit:12.5.4, composer
- name: Install ODBC Driver for SQL Server
run: |
From 778128fe12814ff3f21e313aef580b10331b335a Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Thu, 15 Jan 2026 13:15:10 +0300
Subject: [PATCH 03/32] ci: Added Concurrency Control
---
.github/workflows/php81.yaml | 5 +++++
.github/workflows/php82.yml | 4 ++++
.github/workflows/php83.yml | 4 ++++
.github/workflows/php84.yml | 4 ++++
.github/workflows/php85.yml | 4 ++++
5 files changed, 21 insertions(+)
diff --git a/.github/workflows/php81.yaml b/.github/workflows/php81.yaml
index cdacb46ce..83660001d 100644
--- a/.github/workflows/php81.yaml
+++ b/.github/workflows/php81.yaml
@@ -5,6 +5,11 @@ on:
branches: [ main ]
pull_request:
branches: [ main ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/php82.yml b/.github/workflows/php82.yml
index 68f379762..df1fd71e0 100644
--- a/.github/workflows/php82.yml
+++ b/.github/workflows/php82.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ main ]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/php83.yml b/.github/workflows/php83.yml
index 7afb7d0e5..594ca1f56 100644
--- a/.github/workflows/php83.yml
+++ b/.github/workflows/php83.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ main, dev ]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/php84.yml b/.github/workflows/php84.yml
index 33700f172..b691330fc 100644
--- a/.github/workflows/php84.yml
+++ b/.github/workflows/php84.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ main ]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
diff --git a/.github/workflows/php85.yml b/.github/workflows/php85.yml
index 287d99f5a..5f2ba4d24 100644
--- a/.github/workflows/php85.yml
+++ b/.github/workflows/php85.yml
@@ -6,6 +6,10 @@ on:
pull_request:
branches: [ main ]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
test:
runs-on: ubuntu-latest
From 41c42caf2d2868b54513ea2b4e78662b78c7a230 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Thu, 15 Jan 2026 13:15:39 +0300
Subject: [PATCH 04/32] docs(readme): Updated PHP Version
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 74a9f8e6c..13c9c1353 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -34,7 +34,7 @@ WebFiori Framework is a mini web development framework which is built using PHP
|
|
|
|
|
|
-
+|
|
## Key Features
From 29a9e00c2a498ba912d916a74b00e9a18408fc87 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Thu, 15 Jan 2026 13:16:14 +0300
Subject: [PATCH 05/32] chore: Remove Funding Info
They already exist in .github
---
.github/FUNDING.yml | 3 ---
1 file changed, 3 deletions(-)
delete mode 100644 .github/FUNDING.yml
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index 2213db043..000000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-# These are supported funding model platforms
-custom: ["https://paypal.me/IbrahimBinAlshikh", "https://www.buymeacoffee.com/ibrahimdev"]
-ko_fi: ibrahimdev
From 868e123f2d8d75e12b2d9160d9425ddaa105ffc2 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Mon, 19 Jan 2026 22:45:03 +0300
Subject: [PATCH 06/32] fix: Request Method not Allowed
---
WebFiori/Framework/Router/Router.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/WebFiori/Framework/Router/Router.php b/WebFiori/Framework/Router/Router.php
index 47d0d8f0a..2b8389ed2 100644
--- a/WebFiori/Framework/Router/Router.php
+++ b/WebFiori/Framework/Router/Router.php
@@ -1378,7 +1378,8 @@ private function resolveUrlHelper(string $uri, bool $loadResource = true) {
* @throws RoutingException
*/
private function routeFound(RouterUri $route, bool $loadResource) {
- if ($route->isRequestMethodAllowed()) {
+
+ if ($route->isRequestMethodAllowed((App::getRequest()->getMethod()))) {
$this->uriObj = $route;
foreach ($route->getMiddleware() as $mw) {
From c67616e0e3bda1174251c0219b8efbd95fafaf08 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Mon, 19 Jan 2026 23:31:56 +0300
Subject: [PATCH 07/32] refactor: Migrations Creation Process
---
.../Framework/Cli/Helpers/CreateMigration.php | 98 +++++++--
.../Writers/DatabaseMigrationWriter.php | 154 -------------
.../Tests/Cli/CreateMigrationTest.php | 146 ++++++++++---
.../Writers/DatabaseMigrationWriterTest.php | 205 ------------------
4 files changed, 204 insertions(+), 399 deletions(-)
delete mode 100644 WebFiori/Framework/Writers/DatabaseMigrationWriter.php
delete mode 100644 tests/WebFiori/Framework/Tests/Writers/DatabaseMigrationWriterTest.php
diff --git a/WebFiori/Framework/Cli/Helpers/CreateMigration.php b/WebFiori/Framework/Cli/Helpers/CreateMigration.php
index 2dc0c16a6..6efe737fb 100644
--- a/WebFiori/Framework/Cli/Helpers/CreateMigration.php
+++ b/WebFiori/Framework/Cli/Helpers/CreateMigration.php
@@ -10,37 +10,109 @@
*/
namespace WebFiori\Framework\Cli\Helpers;
-use WebFiori\Database\Schema\SchemaRunner;
+use WebFiori\Database\Schema\DatabaseChangeGenerator;
+use WebFiori\Database\Schema\GeneratorOption;
use WebFiori\Framework\Cli\CLIUtils;
use WebFiori\Framework\Cli\Commands\CreateCommand;
-use WebFiori\Framework\Writers\DatabaseMigrationWriter;
/**
- * A helper class which is used to help in creating scheduler tasks classes using CLI.
+ * A helper class which is used to help in creating migration classes using CLI.
*
* @author Ibrahim
*
* @version 1.0
*/
-class CreateMigration extends CreateClassHelper {
- private $isConfigured;
+class CreateMigration {
+ private $command;
+ private $generator;
+ private $className;
+ private $dependencies = [];
+
/**
* Creates new instance of the class.
*
* @param CreateCommand $command A command that is used to call the class.
*/
public function __construct(CreateCommand $command) {
- $ns = APP_DIR.'\\Database\\migrations';
+ $this->command = $command;
+ $this->generator = new DatabaseChangeGenerator();
+
+ $ns = APP_DIR.'\\Database\\Migrations';
if (!$command->isArgProvided('--defaults')) {
$ns = CLIUtils::readNamespace($command, $ns , 'Migration namespace:');
}
-
- $runner = new SchemaRunner(new \WebFiori\Database\ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- parent::__construct($command, new DatabaseMigrationWriter($runner));
- $this->setNamespace($ns);
- $this->setClassName($command->readClassName('Provide a name for the class that will have migration logic:', null));
+ $this->generator->setNamespace($ns);
+ $this->generator->setPath(APP_PATH.'Database'.DS.'Migrations');
+
+ $this->className = $command->readClassName('Provide a name for the class that will have migration logic:', null);
+
+ if (!$command->isArgProvided('--defaults')) {
+ $this->readDependencies();
+ }
}
- public function isConfigured() : bool {
- return $this->isConfigured;
+
+ public function writeClass() {
+ $options = [];
+
+ if (!empty($this->dependencies)) {
+ $options[GeneratorOption::DEPENDENCIES] = $this->dependencies;
+ }
+
+ $filePath = $this->generator->createMigration($this->className, $options);
+ $this->command->info('New class was created at "'.dirname($filePath).'".');
+ }
+
+ private function readDependencies() {
+ if (!$this->command->confirm('Does this migration depend on other migrations?', false)) {
+ return;
+ }
+
+ $migrations = $this->getExistingMigrations();
+
+ if (empty($migrations)) {
+ $this->command->warning('No existing migrations found.');
+ return;
+ }
+
+ $this->command->println('Available migrations:');
+ foreach ($migrations as $idx => $migration) {
+ $this->command->println("$idx: $migration");
+ }
+
+ while (true) {
+ $input = $this->command->getInput('Enter migration number (or press Enter to finish):');
+
+ if (empty($input)) {
+ break;
+ }
+
+ $idx = (int)$input;
+ if (isset($migrations[$idx])) {
+ $fullClass = '\\'.$this->generator->getNamespace().'\\'.$migrations[$idx];
+ $this->dependencies[] = $fullClass;
+ $this->command->success("Added dependency: {$migrations[$idx]}");
+ } else {
+ $this->command->error('Invalid migration number.');
+ }
+ }
+ }
+
+ private function getExistingMigrations() : array {
+ $migrationsDir = APP_PATH.'Database'.DS.'Migrations';
+
+ if (!is_dir($migrationsDir)) {
+ return [];
+ }
+
+ $files = scandir($migrationsDir);
+ $migrations = [];
+
+ foreach ($files as $file) {
+ if (pathinfo($file, PATHINFO_EXTENSION) === 'php') {
+ $migrations[] = pathinfo($file, PATHINFO_FILENAME);
+ }
+ }
+
+ return $migrations;
}
}
diff --git a/WebFiori/Framework/Writers/DatabaseMigrationWriter.php b/WebFiori/Framework/Writers/DatabaseMigrationWriter.php
deleted file mode 100644
index 355952ee8..000000000
--- a/WebFiori/Framework/Writers/DatabaseMigrationWriter.php
+++ /dev/null
@@ -1,154 +0,0 @@
-runner = $runner;
- $name = $this->generateMigrationName();
-
- $this->setClassName($name);
-
- parent::__construct($name, APP_PATH.'Database'.DS.'Migrations', APP_DIR.'\\Database\\Migrations');
- $this->addUseStatement([
- Database::class,
- AbstractMigration::class,
- ]);
-
- }
-
- private function generateMigrationName() {
- $name = 'Migration' . str_pad(self::$migrationCounter, 3, '0', STR_PAD_LEFT);
- self::$migrationCounter++;
- return $name;
- }
-
- /**
- * Add an environment where this migration should run.
- */
- public function addEnv(string $env) {
- $this->environments[] = $env;
- }
-
- /**
- * Add a dependency migration class name.
- */
- public function addDependency(string $dependency) : bool {
- if (class_exists($dependency)) {
- $this->dependencies[] = $dependency;
- $this->addUseStatement($dependency);
- return true;
- }
- return false;
- }
-
- /**
- * Reset the migration counter for testing purposes.
- */
- public static function resetCounter() {
- self::$migrationCounter = 0;
- }
-
- public function writeClassBody() {
- $this->append([
- '/**',
- ' * Creates new instance of the class.',
- ' */',
- $this->f('__construct'),
-
- ], 1);
- $this->append("parent::__construct();", 2);
- $this->append('}', 1);
-
- $this->append('/**', 1);
- $this->append(' * Get the list of migrations this migration depends on.', 1);
- $this->append(' * ', 1);
- $this->append(' * @return array Array of migration class names that must be executed before this one.', 1);
- $this->append(' */', 1);
- $this->append($this->f('getDependencies', [], 'array'), 1);
- if (empty($this->dependencies)) {
- $this->append('return [];', 2);
- } else {
- $this->append('return [', 2);
- foreach ($this->dependencies as $dep) {
- $this->append(" $dep::class,", 2);
- }
- $this->append('];', 2);
- }
- $this->append('}', 1);
-
- $this->append('/**', 1);
- $this->append(' * Get the environments where this migration should be executed.', 1);
- $this->append(' * ', 1);
- $this->append(' * @return array Empty array means all environments.', 1);
- $this->append(' */', 1);
- $this->append($this->f('getEnvironments', [], 'array'), 1);
- if (empty($this->environments)) {
- $this->append('return [];', 2);
- } else {
- $this->append('return [', 2);
- foreach ($this->environments as $env) {
- $this->append(" '$env',", 2);
- }
- $this->append('];', 2);
- }
- $this->append('}', 1);
-
- $this->append('/**', 1);
- $this->append(' * Performs the action that will apply the migration.', 1);
- $this->append(' * ', 1);
- $this->append(' * @param Database $db The database at which the migration will be applied to.', 1);
- $this->append(' */', 1);
- $this->append($this->f('up', ['db' => 'Database'], 'void'), 1);
- $this->append('//TODO: Implement the action which will apply the migration to database.', 2);
- $this->append('}', 1);
-
- $this->append('/**', 1);
- $this->append(' * Performs the action that will revert back the migration.', 1);
- $this->append(' * ', 1);
- $this->append(' * @param Database $db The database at which the migration will be applied to.', 1);
- $this->append(' */', 1);
- $this->append($this->f('down', ['db' => 'Database'], 'void'), 1);
- $this->append('//TODO: Implement the action which will revert back the migration.', 2);
- $this->append('}', 1);
- $this->append('}');
- }
- public function writeClassComment() {
- $classTop = [
- '/**',
- ' * A database migration class.',
- ' */'
- ];
- $this->append($classTop);
- }
-
- public function writeClassDeclaration() {
- $this->append('class '.$this->getName().' extends AbstractMigration {');
- }
-}
diff --git a/tests/WebFiori/Framework/Tests/Cli/CreateMigrationTest.php b/tests/WebFiori/Framework/Tests/Cli/CreateMigrationTest.php
index 7dff77f2d..1b853afc4 100644
--- a/tests/WebFiori/Framework/Tests/Cli/CreateMigrationTest.php
+++ b/tests/WebFiori/Framework/Tests/Cli/CreateMigrationTest.php
@@ -1,7 +1,7 @@
assertEquals([
- "Migration namespace: Enter = 'App\Database\migrations'\n",
+ "Migration namespace: Enter = 'App\Database\Migrations'\n",
"Provide a name for the class that will have migration logic:\n",
+ "Does this migration depend on other migrations?(y/N)\n",
'Info: New class was created at "'. APP_PATH .'Database'.DS.'Migrations".'."\n",
], $this->executeMultiCommand([
CreateCommand::class,
@@ -57,26 +48,127 @@ public function testCreateMigration01() {
], [
"\n",
$name,
- "Great One",
- "11"
+ "n"
]));
$this->assertEquals(0, $this->getExitCode());
- // Check if file was written and require it
$filePath = APP_PATH . 'Database' . DS . 'Migrations' . DS . $name . '.php';
$this->assertTrue(file_exists($filePath), "Class file was not created: $filePath");
require_once $filePath;
$this->assertTrue(class_exists($clazz));
+
+ $instance = new $clazz();
+ $this->assertInstanceOf(AbstractMigration::class, $instance);
+ $this->assertEquals([], $instance->getDependencies());
+ $this->assertEquals([], $instance->getEnvironments());
+
$this->removeClass($clazz);
}
- private function getMName() {
- $runner = new SchemaRunner(null);
- $count = count($runner->getChanges());
- if ($count < 10) {
- return 'Migration00'.$count;
- } else if ($count < 100) {
- return 'Migration0'.$count;
- }
- return 'Migration'.$count;
+
+ /**
+ * @test
+ */
+ public function testCreateMigrationWithDependencies() {
+ // First create a base migration
+ $baseName = 'BaseMigration';
+
+ $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'migration',
+ ], [
+ "\n",
+ $baseName,
+ "n",
+ "n"
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+ $baseFile = APP_PATH . 'Database' . DS . 'Migrations' . DS . $baseName . '.php';
+ $this->assertTrue(file_exists($baseFile), "Base migration file should exist");
+
+ // Manually create a dependent migration using DatabaseChangeGenerator to verify it works
+ $generator = new \WebFiori\Database\Schema\DatabaseChangeGenerator();
+ $generator->setNamespace('App\\Database\\Migrations');
+ $generator->setPath(APP_PATH . 'Database' . DS . 'Migrations');
+ $generator->createMigration('ManualDependent', [
+ \WebFiori\Database\Schema\GeneratorOption::DEPENDENCIES => ['\\App\\Database\\Migrations\\BaseMigration']
+ ]);
+
+ $manualFile = APP_PATH . 'Database' . DS . 'Migrations' . DS . 'ManualDependent.php';
+ $this->assertTrue(file_exists($manualFile));
+ $content = file_get_contents($manualFile);
+ $this->assertStringContainsString('getDependencies', $content);
+ $this->assertStringContainsString('BaseMigration', $content);
+
+ require_once $baseFile;
+ require_once $manualFile;
+ $instance = new \App\Database\Migrations\ManualDependent();
+ $this->assertEquals(['App\\Database\\Migrations\\BaseMigration'], $instance->getDependencies());
+
+ $this->removeClass('\\App\\Database\\Migrations\\ManualDependent');
+ $this->removeClass('\\App\\Database\\Migrations\\'.$baseName);
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateMigrationWithEnvironments() {
+ // Note: DatabaseChangeGenerator doesn't support environments for migrations yet
+ // This test is kept for future compatibility
+ $this->markTestSkipped('DatabaseChangeGenerator does not support environments for migrations yet');
+
+ $name = 'EnvMigration';
+ $clazz = '\\App\\Database\\Migrations\\'.$name;
+
+ $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'migration',
+ ], [
+ "\n",
+ $name,
+ "n",
+ "y",
+ "dev",
+ "y",
+ "staging",
+ "n"
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Database' . DS . 'Migrations' . DS . $name . '.php';
+ require_once $filePath;
+ $this->assertTrue(class_exists($clazz));
+
+ $instance = new $clazz();
+ $this->assertInstanceOf(AbstractMigration::class, $instance);
+ $this->assertEquals(['dev', 'staging'], $instance->getEnvironments());
+
+ $this->removeClass($clazz);
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateMigrationWithDefaults() {
+ $name = 'DefaultMigration';
+ $clazz = '\\App\\Database\\Migrations\\'.$name;
+
+ $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'migration',
+ '--defaults' => ''
+ ], [
+ $name
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Database' . DS . 'Migrations' . DS . $name . '.php';
+ $this->assertTrue(file_exists($filePath));
+ require_once $filePath;
+ $this->assertTrue(class_exists($clazz));
+
+ $this->removeClass($clazz);
}
-}
\ No newline at end of file
+}
diff --git a/tests/WebFiori/Framework/Tests/Writers/DatabaseMigrationWriterTest.php b/tests/WebFiori/Framework/Tests/Writers/DatabaseMigrationWriterTest.php
deleted file mode 100644
index 9c3adaaf5..000000000
--- a/tests/WebFiori/Framework/Tests/Writers/DatabaseMigrationWriterTest.php
+++ /dev/null
@@ -1,205 +0,0 @@
-removeClass($clazz);
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $writter = new DatabaseMigrationWriter($runner);
- $this->assertEquals('Migration000', $writter->getName());
- $this->assertEquals('App\\Database\\Migrations', $writter->getNamespace());
- $this->assertEquals('', $writter->getSuffix());
- $this->assertEquals([
- "WebFiori\Database\Database",
- "WebFiori\Database\Schema\AbstractMigration",
- ], $writter->getUseStatements());
- $writter->writeClass();
-
- // Check if file was written and require it
- $filePath = $writter->getPath() . DS . $writter->getName() . '.php';
- $this->assertTrue(file_exists($filePath), "Class file was not created: $filePath");
- require_once $filePath;
- $this->assertTrue(class_exists($clazz));
- $runner->register($clazz);
- $allClasses[] = $clazz;
- $migrations = $runner->getChanges();
- $this->assertEquals(1, count($migrations));
- $m00 = $migrations[0];
- $this->assertTrue($m00 instanceof AbstractMigration);
- $this->assertEquals('App\\Database\\Migrations\\Migration000', $m00->getName());
- $this->removeClass($clazz);
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- }
- /**
- * @test
- */
- public function test01() {
- DatabaseMigrationWriter::resetCounter();
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $path = APP_PATH.DS.'Database'.DS.'Migrations';
- $ns = '\\App\\Database\\Migrations';
- $writter = new DatabaseMigrationWriter($runner);
- $writter->setClassName('MyMigration');
- $this->assertEquals('MyMigration', $writter->getName());
- $this->assertEquals('App\\Database\\Migrations', $writter->getNamespace());
-
- $writter->writeClass();
- $clazz = "\\App\\Database\\Migrations\\MyMigration";
-
- // Check if file was written and require it
- $filePath = $writter->getPath() . DS . $writter->getName() . '.php';
- $this->assertTrue(file_exists($filePath), "Class file was not created: $filePath");
- require_once $filePath;
- $this->assertTrue(class_exists($clazz));
- $runner->register($clazz);
- $allClasses[] = $clazz;
- $migrations = $runner->getChanges();
- $this->assertEquals(1, count($migrations));
- $m00 = $migrations[0];
- $this->assertTrue($m00 instanceof AbstractMigration);
- $this->assertEquals('App\\Database\\Migrations\\MyMigration', $m00->getName());
- $this->removeClass($clazz);
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- }
- /**
- * @test
- */
- public function test02() {
- DatabaseMigrationWriter::resetCounter();
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $path = APP_PATH.DS.'Database'.DS.'Migrations';
- $ns = '\\App\\Database\\Migrations';
- $writter = new DatabaseMigrationWriter($runner);
- $this->assertEquals('Migration000', $writter->getName());
- $writter->writeClass();
- $clazz = "\\App\\Database\\Migrations\\Migration000";
-
- // Check if file was written and require it
- $filePath = $writter->getPath() . DS . $writter->getName() . '.php';
- $this->assertTrue(file_exists($filePath), "Class file was not created: $filePath");
- require_once $filePath;
- $this->assertTrue(class_exists($clazz));
- $runner->register($clazz);
- $allClasses[] = $clazz;
- $runner2 = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $runner2->register($clazz);
- $migrations = $runner2->getChanges();
- $this->assertEquals(1, count($migrations));
- $m00 = $migrations[0];
- $this->assertTrue($m00 instanceof AbstractMigration);
- $this->assertEquals('App\\Database\\Migrations\\Migration000', $m00->getName());
-
- $writter2 = new DatabaseMigrationWriter($runner2);
- $this->assertEquals('Migration001', $writter2->getName());
- $writter2->writeClass();
- $clazz2 = "\\App\\Database\\Migrations\\Migration001";
-
- // Check if file was written and require it
- $filePath2 = $writter2->getPath() . DS . $writter2->getName() . '.php';
- $this->assertTrue(file_exists($filePath2), "Class file was not created: $filePath2");
- require_once $filePath2;
- $this->assertTrue(class_exists($clazz2));
- $runner->register($clazz);
- $allClasses[] = $clazz;
- $runner3 = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $runner3->register($clazz);
- $runner3->register($clazz2);
- $migrations2 = $runner3->getChanges();
- $this->assertEquals(2, count($migrations2));
- $m01 = $migrations2[1];
- $this->assertTrue($m00 instanceof AbstractMigration);
- $this->assertEquals('App\\Database\\Migrations\\Migration001', $m01->getName());
- $this->removeClass($clazz);
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $this->removeClass($clazz2);
- }
- /**
- * @test
- */
- public function test03() {
- DatabaseMigrationWriter::resetCounter();
- $runner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- $path = APP_PATH.DS.'Database'.DS.'Migrations';
- $ns = '\\App\\Database\\Migrations';
- $allClasses = [];
- for ($x = 0 ; $x < 110 ; $x++) {
- $writter = new DatabaseMigrationWriter($runner);
- if ($x < 10) {
- $name = 'Migration00'.$x;
- } else if ($x < 100) {
- $name = 'Migration0'.$x;
- } else {
- $name = 'Migration'.$x;
- }
- $this->assertEquals($name, $writter->getName());
- $writter->writeClass();
- $clazz = "\\App\\Database\\Migrations\\".$name;
-
- // Check if file was written and require it
- $filePath = $writter->getPath() . DS . $writter->getName() . '.php';
- $this->assertTrue(file_exists($filePath), "Class file was not created: $filePath");
- require_once $filePath;
- $this->assertTrue(class_exists($clazz));
- $runner->register($clazz);
- $allClasses[] = $clazz;
- $xRunner = new SchemaRunner(new ConnectionInfo('mysql', 'test_user', 'test_pass', 'test_db'));
- foreach ($allClasses as $cls) {
- $xRunner->register($cls);
- }
-
- $migrations = $xRunner->getChanges();
- $this->assertEquals($x + 1, count($migrations));
- $m = $migrations[$x];
- $this->assertTrue($m instanceof AbstractMigration);
- $this->assertEquals("App\\Database\\Migrations\\" . $name, $m->getName());
- }
- foreach ($migrations as $m) {
- $this->removeClass("\\App\\Database\\Migrations\\".$m->getName());
- }
- }
- private function removeClass($classPath) {
- $file = new File(ROOT_PATH.$classPath.'.php');
- $file->remove();
- }
-}
From 237dd96cbda39cb25cbe1d272a3e990f7bb3fb6b Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 00:01:56 +0300
Subject: [PATCH 08/32] refactor: Migrations System Refactor
---
.../Cli/Commands/RunMigrationsCommand.php | 283 ++++++------------
.../Tests/Cli/RunMigrationsCommandTest.php | 171 +++--------
2 files changed, 142 insertions(+), 312 deletions(-)
diff --git a/WebFiori/Framework/Cli/Commands/RunMigrationsCommand.php b/WebFiori/Framework/Cli/Commands/RunMigrationsCommand.php
index fb65f9de2..017bcb869 100644
--- a/WebFiori/Framework/Cli/Commands/RunMigrationsCommand.php
+++ b/WebFiori/Framework/Cli/Commands/RunMigrationsCommand.php
@@ -14,8 +14,6 @@
use WebFiori\Cli\Argument;
use WebFiori\Cli\Command;
use WebFiori\Database\ConnectionInfo;
-use WebFiori\Database\Database;
-use WebFiori\Database\migration\AbstractMigration;
use WebFiori\Database\Schema\SchemaRunner;
use WebFiori\Framework\App;
use WebFiori\Framework\Cli\CLIUtils;
@@ -27,123 +25,67 @@
*/
class RunMigrationsCommand extends Command {
- private const EXIT_SUCCESS = 0;
- private const EXIT_ERROR = 1;
-
private ?SchemaRunner $runner = null;
- private ?ConnectionInfo $connection = null;
public function __construct() {
parent::__construct('migrations', [
- new Argument('--connection', 'The name of database connection to be used in executing the migrations.', true),
- new Argument('--runner', 'A class that extends the class "WebFiori\Database\Schema\SchemaRunner".', true),
- new Argument('--init', 'Creates migrations table in database if not exist.', true),
+ new Argument('--connection', 'The name of database connection to use.', true),
+ new Argument('--env', 'Environment name (dev, staging, production). Default: dev', true),
+ new Argument('--init', 'Create migrations tracking table.', true),
new Argument('--rollback', 'Rollback migrations.', true),
- new Argument('--all', 'If provided with --rollback, all migrations will be rolled back.', true),
+ new Argument('--batch', 'Rollback specific batch number.', true),
+ new Argument('--all', 'Rollback all migrations.', true),
+ new Argument('--dry-run', 'Preview changes without executing.', true),
], 'Execute database migrations.');
}
- /**
- * Execute the command.
- */
public function exec(): int {
try {
- if (!$this->initializeCommand()) {
- return self::EXIT_ERROR;
+ $connection = $this->getConnection();
+ if ($connection === null) {
+ return 1;
+ }
+
+ $env = $this->getArgValue('--env') ?? 'dev';
+ $this->runner = new SchemaRunner($connection, $env);
+
+ // Discover migrations
+ $migrationsPath = APP_PATH.'Database'.DS.'Migrations';
+ $namespace = APP_DIR.'\\Database\\Migrations';
+ $count = $this->runner->discoverFromPath($migrationsPath, $namespace);
+
+ if ($count === 0 && !$this->isArgProvided('--init')) {
+ $this->info('No migrations found.');
+ return 0;
}
if ($this->isArgProvided('--init')) {
- return $this->initializeMigrationsTable();
+ return $this->initTable();
}
if ($this->isArgProvided('--rollback')) {
- return $this->executeRollback();
+ return $this->rollback();
+ }
+
+ if ($this->isArgProvided('--dry-run')) {
+ return $this->dryRun();
}
- return $this->executeMigrations();
+ return $this->runMigrations();
} catch (Throwable $e) {
$this->error('An exception was thrown.');
- $this->println('Exception Message: ' . $e->getMessage());
- $this->println('Code: ' . $e->getCode());
- $this->println('At: ' . $e->getFile());
- $this->println('Line: ' . $e->getLine());
- $this->println('Stack Trace: ');
- $this->println($e->getTraceAsString());
- return self::EXIT_ERROR;
- }
- }
-
- /**
- * Initialize command dependencies.
- */
- private function initializeCommand(): bool {
- $this->connection = $this->resolveConnection();
- if ($this->connection === null) {
- return false;
- }
-
- $this->runner = $this->createRunner();
- if ($this->runner === null) {
- $runnerClass = $this->getArgValue('--runner');
- if ($runnerClass !== null) {
- // Runner creation failed, return false
- return false;
- }
- // Create default runner with connection
- $this->runner = new SchemaRunner($this->connection);
- }
-
- // Set connection on runner if it doesn't have one
- if ($this->runner->getConnectionInfo() === null) {
- $this->runner = new SchemaRunner($this->connection);
- }
-
- return true;
- }
-
- /**
- * Create SchemaRunner instance from --runner argument.
- */
- private function createRunner(): ?SchemaRunner {
- $runnerClass = $this->getArgValue('--runner');
-
- if ($runnerClass === null) {
- return null; // Will be created later with connection
- }
-
- if (!class_exists($runnerClass)) {
- $this->error("The argument --runner has invalid value: Class \"$runnerClass\" does not exist.");
- return null;
- }
-
- try {
- $runner = new $runnerClass();
- } catch (Throwable $e) {
- $this->error("The argument --runner has invalid value: Exception: \"{$e->getMessage()}\".");
- return null;
+ $this->println('Message: ' . $e->getMessage());
+ $this->println('File: ' . $e->getFile() . ':' . $e->getLine());
+ return 1;
}
-
- if (!($runner instanceof SchemaRunner)) {
- $this->error("The argument --runner has invalid value: \"$runnerClass\" is not an instance of \"SchemaRunner\".");
- return null;
- }
-
- return $runner;
}
- /**
- * Resolve database connection.
- */
- private function resolveConnection(): ?ConnectionInfo {
- // Check if runner already has a connection
- if ($this->runner !== null && $this->runner->getConnectionInfo() !== null) {
- return $this->runner->getConnectionInfo();
- }
-
+ private function getConnection(): ?ConnectionInfo {
$connections = App::getConfig()->getDBConnections();
+
if (empty($connections)) {
- $this->info('No connections were found in application configuration.');
+ $this->info('No database connections configured.');
return null;
}
@@ -152,7 +94,7 @@ private function resolveConnection(): ?ConnectionInfo {
if ($connectionName !== null) {
$connection = App::getConfig()->getDBConnection($connectionName);
if ($connection === null) {
- $this->error("No connection was found which has the name '$connectionName'.");
+ $this->error("Connection '$connectionName' not found.");
return null;
}
return $connection;
@@ -161,132 +103,97 @@ private function resolveConnection(): ?ConnectionInfo {
return CLIUtils::getConnectionName($this);
}
- /**
- * Initialize migrations table.
- */
- private function initializeMigrationsTable(): int {
+ private function initTable(): int {
try {
- $this->println("Initializing migrations table...");
+ $this->println('Creating migrations tracking table...');
$this->runner->createSchemaTable();
- $this->success("Migrations table successfully created.");
- return self::EXIT_SUCCESS;
+ $this->success('Migrations table created successfully.');
+ return 0;
} catch (Throwable $e) {
- $this->error('Unable to create migrations table due to following:');
- $this->println($e->getMessage());
- return self::EXIT_ERROR;
+ $this->error('Failed to create migrations table: ' . $e->getMessage());
+ return 1;
}
}
- /**
- * Execute migrations rollback.
- */
- private function executeRollback(): int {
- $migrations = $this->runner->getChanges();
- if (empty($migrations)) {
- $this->info("No migrations found.");
- return self::EXIT_SUCCESS;
- }
-
- $this->println("Rolling back migrations...");
-
+ private function rollback(): int {
try {
if ($this->isArgProvided('--all')) {
- $rolledBack = $this->runner->rollbackUpTo(null);
+ $this->println('Rolling back all migrations...');
+ $rolled = $this->runner->rollbackUpTo(null);
+ } else if ($this->isArgProvided('--batch')) {
+ $batch = (int)$this->getArgValue('--batch');
+ $this->println("Rolling back batch $batch...");
+ $rolled = $this->runner->rollbackBatch($batch);
} else {
- $rolledBack = $this->rollbackLast();
+ $this->println('Rolling back last batch...');
+ $rolled = $this->runner->rollbackLastBatch();
}
- if (empty($rolledBack)) {
- $this->info("No migrations were rolled back.");
+ if (empty($rolled)) {
+ $this->info('No migrations to rollback.');
} else {
- foreach ($rolledBack as $migration) {
- $this->success("Migration '{$migration->getName()}' was successfully rolled back.");
+ foreach ($rolled as $change) {
+ $this->success('Rolled back: ' . $change->getName());
}
+ $this->info('Total rolled back: ' . count($rolled));
}
- return self::EXIT_SUCCESS;
-
+ return 0;
} catch (Throwable $e) {
- $this->error('Failed to execute migration due to following:');
- $this->println($e->getMessage() . ' (Line ' . $e->getLine() . ')');
- $this->warning('Execution stopped.');
- return self::EXIT_ERROR;
+ $this->error('Rollback failed: ' . $e->getMessage());
+ return 1;
}
}
- /**
- * Rollback the last applied migration.
- */
- private function rollbackLast(): array {
- $changes = $this->runner->getChanges();
- $lastApplied = null;
+ private function dryRun(): int {
+ $pending = $this->runner->getPendingChanges(true);
- // Find the last applied migration
- foreach ($changes as $change) {
- if ($this->runner->isApplied($change->getName())) {
- $lastApplied = $change;
- }
+ if (empty($pending)) {
+ $this->info('No pending migrations.');
+ return 0;
}
- if ($lastApplied === null) {
- return [];
+ $this->println('Pending migrations:');
+ foreach ($pending as $item) {
+ $this->println(' - ' . $item['change']->getName());
+ if (!empty($item['queries'])) {
+ $this->println(' Queries:');
+ foreach ($item['queries'] as $query) {
+ $this->println(' ' . $query);
+ }
+ }
}
- return $this->runner->rollbackUpTo($lastApplied->getName());
+ return 0;
}
- /**
- * Execute migrations.
- */
- private function executeMigrations(): int {
- $migrations = $this->runner->getChanges();
- if (empty($migrations)) {
- $this->info("No migrations found.");
- return self::EXIT_SUCCESS;
- }
+ private function runMigrations(): int {
+ $this->println('Running migrations...');
- $this->println("Starting to execute migrations...");
- $appliedMigrations = [];
+ $result = $this->runner->apply();
- try {
- while (($migration = $this->getNextMigration()) !== null) {
- $applied = $this->runner->applyOne();
- if ($applied !== null) {
- $this->success("Migration '{$applied->getName()}' applied successfully.");
- $appliedMigrations[] = $applied;
- } else {
- break;
- }
+ if ($result->hasApplied()) {
+ foreach ($result->getApplied() as $change) {
+ $this->success('Applied: ' . $change->getName());
}
-
- if (empty($appliedMigrations)) {
- $this->info("No migrations were executed.");
- } else {
- $this->info("Number of applied migrations: " . count($appliedMigrations));
- $this->println("Names of applied migrations:");
- $names = array_map(fn($m) => $m->getName(), $appliedMigrations);
- $this->printList($names);
+ }
+
+ if ($result->hasSkipped()) {
+ foreach ($result->getSkipped() as $item) {
+ $this->warning('Skipped: ' . $item['change']->getName() . ' (' . $item['reason'] . ')');
}
-
- return self::EXIT_SUCCESS;
-
- } catch (Throwable $e) {
- $this->error('Failed to execute migration due to following:');
- $this->println($e->getMessage() . ' (Line ' . $e->getLine() . ')');
- $this->warning('Execution stopped.');
- return self::EXIT_ERROR;
}
- }
-
- /**
- * Get the next migration to apply.
- */
- private function getNextMigration(): ?AbstractMigration {
- foreach ($this->runner->getChanges() as $migration) {
- if (!$this->runner->isApplied($migration->getName())) {
- return $migration;
+
+ if ($result->hasFailed()) {
+ foreach ($result->getFailed() as $item) {
+ $this->error('Failed: ' . $item['change']->getName());
+ $this->println(' Error: ' . $item['error']->getMessage());
}
}
- return null;
+
+ $this->info('Applied: ' . $result->count() . ' migrations');
+ $this->info('Time: ' . round($result->getTotalTime(), 2) . 'ms');
+
+ return $result->hasFailed() ? 1 : 0;
}
}
diff --git a/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandTest.php b/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandTest.php
index ba8bd931d..7c6d38698 100644
--- a/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandTest.php
+++ b/tests/WebFiori/Framework/Tests/Cli/RunMigrationsCommandTest.php
@@ -2,7 +2,6 @@
namespace WebFiori\Framework\Test\Cli;
use WebFiori\Database\ConnectionInfo;
-use WebFiori\Database\Schema\SchemaRunner;
use WebFiori\Framework\App;
use WebFiori\Framework\Cli\CLITestCase;
use WebFiori\Framework\Cli\Commands\RunMigrationsCommand;
@@ -19,9 +18,11 @@ class RunMigrationsCommandTest extends CLITestCase {
protected function setUp(): void {
parent::setUp();
$this->setupTestConnection();
+ $this->cleanupMigrations();
}
protected function tearDown(): void {
+ $this->cleanupMigrations();
App::getConfig()->removeAllDBConnections();
parent::tearDown();
}
@@ -32,6 +33,17 @@ private function setupTestConnection(): void {
App::getConfig()->addOrUpdateDBConnection($this->testConnection);
}
+ private function cleanupMigrations(): void {
+ $dir = APP_PATH . 'Database' . DS . 'Migrations';
+ if (is_dir($dir)) {
+ foreach (glob($dir . DS . '*.php') as $file) {
+ if (basename($file) !== '.gitkeep') {
+ unlink($file);
+ }
+ }
+ }
+ }
+
/**
* @test
*/
@@ -42,7 +54,7 @@ public function testExecWithNoConnections(): void {
RunMigrationsCommand::class
]);
- $this->assertContains("Info: No connections were found in application configuration.\n", $output);
+ $this->assertContains("Info: No database connections configured.\n", $output);
$this->assertEquals(1, $this->getExitCode());
}
@@ -55,35 +67,7 @@ public function testExecWithInvalidConnection(): void {
'--connection' => 'invalid-connection'
]);
- $this->assertContains("Error: No connection was found which has the name 'invalid-connection'.\n", $output);
- $this->assertEquals(1, $this->getExitCode());
- }
-
- /**
- * @test
- */
- public function testExecWithInvalidRunnerClass(): void {
- $output = $this->executeMultiCommand([
- RunMigrationsCommand::class,
- '--connection' => 'test-connection',
- '--runner' => 'NonExistentClass'
- ]);
-
- $this->assertContains("Error: The argument --runner has invalid value: Class \"NonExistentClass\" does not exist.\n", $output);
- $this->assertEquals(1, $this->getExitCode());
- }
-
- /**
- * @test
- */
- public function testExecWithInvalidRunnerType(): void {
- $output = $this->executeMultiCommand([
- RunMigrationsCommand::class,
- '--connection' => 'test-connection',
- '--runner' => 'stdClass'
- ]);
-
- $this->assertContains("Error: The argument --runner has invalid value: \"stdClass\" is not an instance of \"SchemaRunner\".\n", $output);
+ $this->assertContains("Error: Connection 'invalid-connection' not found.\n", $output);
$this->assertEquals(1, $this->getExitCode());
}
@@ -97,20 +81,15 @@ public function testInitializeMigrationsTable(): void {
'--init'
]);
- $this->assertContains("Initializing migrations table...\n", $output);
+ $this->assertContains("Creating migrations tracking table...\n", $output);
+ $this->assertContains("Success: Migrations table created successfully.\n", $output);
$this->assertEquals(0, $this->getExitCode());
-
- // Verify table was actually created using mysqli
- $mysqli = new \mysqli('127.0.0.1', 'root', MYSQL_ROOT_PASSWORD, 'testing_db', 3306);
- $result = $mysqli->query("SHOW TABLES LIKE 'schema_changes'");
- $this->assertEquals(1, $result->num_rows, 'Migrations table should be created');
- $mysqli->close();
}
/**
* @test
*/
- public function testExecuteMigrationsWithNoMigrations(): void {
+ public function testRunWithNoMigrations(): void {
$output = $this->executeMultiCommand([
RunMigrationsCommand::class,
'--connection' => 'test-connection'
@@ -123,103 +102,47 @@ public function testExecuteMigrationsWithNoMigrations(): void {
/**
* @test
*/
- public function testRollbackWithNoMigrations(): void {
- $output = $this->executeMultiCommand([
- RunMigrationsCommand::class,
- '--connection' => 'test-connection',
- '--rollback'
- ]);
+ public function testDryRun(): void {
+ // Create a test migration
+ $this->createTestMigration('TestMigration');
- // Debug: Print actual output
- $this->assertEquals(0, $this->getExitCode());
- }
-
- /**
- * @test
- */
- public function testRollbackAllWithNoMigrations(): void {
$output = $this->executeMultiCommand([
RunMigrationsCommand::class,
'--connection' => 'test-connection',
- '--rollback',
- '--all'
+ '--dry-run'
]);
- // Debug: Print actual output
- $this->assertEquals(0, $this->getExitCode());
- }
-
- /**
- * @test
- */
- public function testExecuteMigrationsWithValidRunner(): void {
- $this->createTestMigrationRunner();
-
- $output = $this->executeMultiCommand([
- RunMigrationsCommand::class,
- '--connection' => 'test-connection',
- '--runner' => 'TestMigrationRunner'
- ]);
- // The test runner has no migrations, so it should report no migrations found
- $this->assertContains("Info: No migrations found.\n", $output);
+ // Check if output contains expected text
+ $outputStr = implode('', $output);
+ $this->assertStringContainsString('Pending migrations:', $outputStr);
+ $this->assertStringContainsString('TestMigration', $outputStr);
$this->assertEquals(0, $this->getExitCode());
-
- $this->cleanupTestMigrationRunner();
}
- /**
- * @test
- */
- public function testExceptionHandling(): void {
- $this->createFaultyMigrationRunner();
-
- $output = $this->executeMultiCommand([
- RunMigrationsCommand::class,
- '--connection' => 'test-connection',
- '--runner' => 'FaultyMigrationRunner'
- ]);
-
- // The exception is caught during runner creation
- $this->assertContains("Error: The argument --runner has invalid value: Exception: \"Test exception\".\n", $output);
- $this->assertEquals(1, $this->getExitCode());
-
- $this->cleanupFaultyMigrationRunner();
- }
-
- private function createTestMigrationRunner(): void {
- $code = 'getDBConnection("test-connection");
- parent::__construct($conn);
- }
-}';
- file_put_contents(APP_PATH . 'TestMigrationRunner.php', $code);
- require_once APP_PATH . 'TestMigrationRunner.php';
- }
-
- private function cleanupTestMigrationRunner(): void {
- if (file_exists(APP_PATH . 'TestMigrationRunner.php')) {
- unlink(APP_PATH . 'TestMigrationRunner.php');
+ private function createTestMigration(string $name): void {
+ $dir = APP_PATH . 'Database' . DS . 'Migrations';
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
}
+
+ $content = <<getDBConnection("test-connection");
- parent::__construct($conn);
- throw new \Exception("Test exception");
- }
-}';
- file_put_contents(APP_PATH . 'FaultyMigrationRunner.php', $code);
- require_once APP_PATH . 'FaultyMigrationRunner.php';
+ public function down(Database \$db): void {
+ // Test rollback
}
-
- private function cleanupFaultyMigrationRunner(): void {
- if (file_exists(APP_PATH . 'FaultyMigrationRunner.php')) {
- unlink(APP_PATH . 'FaultyMigrationRunner.php');
- }
+}
+PHP;
+
+ file_put_contents($dir . DS . $name . '.php', $content);
}
}
From 3b1c77e2555ea8943d5c4f137afeadbb50eab405 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:41:08 +0300
Subject: [PATCH 09/32] Create AttributeTableWriter.php
---
.../Writers/AttributeTableWriter.php | 81 +++++++++++++++++++
1 file changed, 81 insertions(+)
create mode 100644 WebFiori/Framework/Writers/AttributeTableWriter.php
diff --git a/WebFiori/Framework/Writers/AttributeTableWriter.php b/WebFiori/Framework/Writers/AttributeTableWriter.php
new file mode 100644
index 000000000..129518884
--- /dev/null
+++ b/WebFiori/Framework/Writers/AttributeTableWriter.php
@@ -0,0 +1,81 @@
+addUseStatement([
+ 'WebFiori\\Database\\Attributes\\Column',
+ 'WebFiori\\Database\\Attributes\\Table',
+ 'WebFiori\\Database\\DataType'
+ ]);
+ }
+
+ public function setTableName(string $name) {
+ $this->tableName = $name;
+ }
+
+ public function addColumn(string $name, string $type, array $options = []) {
+ $this->columns[] = array_merge([
+ 'name' => $name,
+ 'type' => $type
+ ], $options);
+ }
+
+ public function writeClassBody() {
+ $this->append('}');
+ }
+
+ public function writeClassComment() {
+ $this->append('/**');
+ $this->append(' * Table definition using PHP 8 attributes.');
+ $this->append(' */');
+
+ // Add Table attribute
+ $this->append("#[Table(name: '{$this->tableName}')]", 0);
+
+ // Add Column attributes
+ foreach ($this->columns as $col) {
+ $attr = "#[Column(name: '{$col['name']}', type: DataType::{$col['type']}";
+
+ if (isset($col['size'])) {
+ $attr .= ", size: {$col['size']}";
+ }
+ if (isset($col['primary']) && $col['primary']) {
+ $attr .= ", primary: true";
+ }
+ if (isset($col['autoIncrement']) && $col['autoIncrement']) {
+ $attr .= ", autoIncrement: true";
+ }
+ if (isset($col['nullable']) && $col['nullable']) {
+ $attr .= ", nullable: true";
+ }
+
+ $attr .= ')]';
+ $this->append($attr, 0);
+ }
+ }
+
+ public function writeClassDeclaration() {
+ $this->append('class '.$this->getName().' {');
+ }
+}
From 92b86f6aa5a014d5f19277ad2ebb5f5e7278375e Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:41:31 +0300
Subject: [PATCH 10/32] Create DomainEntityWriter.php
---
.../Framework/Writers/DomainEntityWriter.php | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 WebFiori/Framework/Writers/DomainEntityWriter.php
diff --git a/WebFiori/Framework/Writers/DomainEntityWriter.php b/WebFiori/Framework/Writers/DomainEntityWriter.php
new file mode 100644
index 000000000..cc0b7f2b4
--- /dev/null
+++ b/WebFiori/Framework/Writers/DomainEntityWriter.php
@@ -0,0 +1,62 @@
+properties[] = [
+ 'name' => $name,
+ 'type' => $type,
+ 'nullable' => $nullable
+ ];
+ }
+
+ public function writeClassBody() {
+ $this->writeConstructor();
+ $this->append('}');
+ }
+
+ public function writeClassComment() {
+ $this->append([
+ '/**',
+ ' * Domain entity - pure PHP, no framework dependencies.',
+ ' */'
+ ]);
+ }
+
+ public function writeClassDeclaration() {
+ $this->append('class '.$this->getName().' {');
+ }
+
+ private function writeConstructor() {
+ $this->append('public function __construct(', 1);
+
+ $params = [];
+ foreach ($this->properties as $prop) {
+ $type = $prop['nullable'] ? '?'.$prop['type'] : $prop['type'];
+ $params[] = " public $type \${$prop['name']}";
+ }
+
+ $this->append(implode(",\n", $params));
+ $this->append(' ) {}', 0);
+ }
+}
From 3754c0349fc81f2778cabdbd0fb5e3e60b534c5d Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:41:53 +0300
Subject: [PATCH 11/32] Create RepositoryWriter.php
---
.../Framework/Writers/RepositoryWriter.php | 110 ++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 WebFiori/Framework/Writers/RepositoryWriter.php
diff --git a/WebFiori/Framework/Writers/RepositoryWriter.php b/WebFiori/Framework/Writers/RepositoryWriter.php
new file mode 100644
index 000000000..fc33c12a5
--- /dev/null
+++ b/WebFiori/Framework/Writers/RepositoryWriter.php
@@ -0,0 +1,110 @@
+addUseStatement([
+ 'WebFiori\\Database\\Repository\\AbstractRepository'
+ ]);
+ }
+
+ public function setEntityClass(string $class) {
+ $this->entityClass = $class;
+ $this->addUseStatement($class);
+ }
+
+ public function setTableName(string $name) {
+ $this->tableName = $name;
+ }
+
+ public function setIdField(string $field) {
+ $this->idField = $field;
+ }
+
+ public function addProperty(string $name, string $type) {
+ $this->properties[] = ['name' => $name, 'type' => $type];
+ }
+
+ public function writeClassBody() {
+ $this->writeGetTableName();
+ $this->writeGetIdField();
+ $this->writeToEntity();
+ $this->writeToArray();
+ $this->append('}');
+ }
+
+ public function writeClassComment() {
+ $this->append([
+ '/**',
+ ' * Repository for '.$this->entityClass.' entities.',
+ ' */'
+ ]);
+ }
+
+ public function writeClassDeclaration() {
+ $this->append('class '.$this->getName().' extends AbstractRepository {');
+ }
+
+ private function writeGetTableName() {
+ $this->append($this->f('getTableName', [], 'string').' {', 1);
+ $this->append("return '{$this->tableName}';", 2);
+ $this->append('}', 1);
+ $this->append('', 1);
+ }
+
+ private function writeGetIdField() {
+ $this->append($this->f('getIdField', [], 'string').' {', 1);
+ $this->append("return '{$this->idField}';", 2);
+ $this->append('}', 1);
+ $this->append('', 1);
+ }
+
+ private function writeToEntity() {
+ $entityShortName = basename(str_replace('\\', '/', $this->entityClass));
+ $this->append($this->f('toEntity', ['row' => 'array'], $entityShortName).' {', 1);
+ $this->append("return new $entityShortName(", 2);
+
+ $params = [];
+ foreach ($this->properties as $prop) {
+ $cast = $prop['type'] === 'int' ? '(int) ' : '';
+ $params[] = " {$cast}\$row['{$prop['name']}']";
+ }
+
+ $this->append(implode(",\n", $params));
+ $this->append(' );', 0);
+ $this->append('}', 1);
+ $this->append('', 1);
+ }
+
+ private function writeToArray() {
+ $this->append($this->f('toArray', ['entity' => 'object'], 'array').' {', 1);
+ $this->append('return [', 2);
+
+ foreach ($this->properties as $prop) {
+ $this->append("'{$prop['name']}' => \$entity->{$prop['name']},", 3);
+ }
+
+ $this->append('];', 2);
+ $this->append('}', 1);
+ }
+}
From 5067f41da62f035b362bc9ec612844ba7a83d467 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:42:02 +0300
Subject: [PATCH 12/32] Create RestServiceWriter.php
---
.../Framework/Writers/RestServiceWriter.php | 115 ++++++++++++++++++
1 file changed, 115 insertions(+)
create mode 100644 WebFiori/Framework/Writers/RestServiceWriter.php
diff --git a/WebFiori/Framework/Writers/RestServiceWriter.php b/WebFiori/Framework/Writers/RestServiceWriter.php
new file mode 100644
index 000000000..0ff9b862e
--- /dev/null
+++ b/WebFiori/Framework/Writers/RestServiceWriter.php
@@ -0,0 +1,115 @@
+setSuffix('Service');
+ $this->addUseStatement([
+ 'WebFiori\\Http\\WebService',
+ 'WebFiori\\Http\\Annotations\\RestController',
+ 'WebFiori\\Http\\Annotations\\GetMapping',
+ 'WebFiori\\Http\\Annotations\\PostMapping',
+ 'WebFiori\\Http\\Annotations\\PutMapping',
+ 'WebFiori\\Http\\Annotations\\DeleteMapping',
+ 'WebFiori\\Http\\Annotations\\Param',
+ 'WebFiori\\Http\\Annotations\\ResponseBody',
+ 'WebFiori\\Http\\Annotations\\AllowAnonymous',
+ 'WebFiori\\Http\\ParamType'
+ ]);
+ }
+
+ public function setDescription(string $desc) {
+ $this->description = $desc;
+ }
+
+ public function addMethod(string $httpMethod, string $methodName, array $params = [], string $returnType = 'array') {
+ $this->methods[] = [
+ 'http' => $httpMethod,
+ 'name' => $methodName,
+ 'params' => $params,
+ 'return' => $returnType
+ ];
+ }
+
+ public function writeClassBody() {
+ foreach ($this->methods as $method) {
+ $this->writeMethod($method);
+ }
+ $this->append('}');
+ }
+
+ public function writeClassComment() {
+ $serviceName = strtolower(str_replace('Service', '', $this->getName()));
+ $this->append('/**');
+ $this->append(' * '.$this->description);
+ $this->append(' */');
+ $this->append("#[RestController('$serviceName', '{$this->description}')]", 0);
+ }
+
+ public function writeClassDeclaration() {
+ $this->append('class '.$this->getName().' extends WebService {');
+ }
+
+ private function writeMethod(array $method) {
+ $this->append('', 1);
+ $mapping = ucfirst(strtolower($method['http'])).'Mapping';
+ $this->append("#[$mapping]", 1);
+ $this->append('#[ResponseBody]', 1);
+ $this->append('#[AllowAnonymous]', 1);
+
+ foreach ($method['params'] as $param) {
+ $paramAttr = "#[Param('{$param['name']}', ParamType::{$param['type']}, '{$param['description']}'";
+ if (isset($param['min'])) {
+ $paramAttr .= ", min: {$param['min']}";
+ }
+ if (isset($param['max'])) {
+ $paramAttr .= ", max: {$param['max']}";
+ }
+ $paramAttr .= ')]';
+ $this->append($paramAttr, 1);
+ }
+
+ $signature = 'public function '.$method['name'].'(';
+ $paramList = [];
+ foreach ($method['params'] as $param) {
+ $type = $this->mapParamType($param['type']);
+ $paramList[] = "?$type \${$param['name']} = null";
+ }
+ $signature .= implode(', ', $paramList);
+ $signature .= '): '.$method['return'].' {';
+
+ $this->append($signature, 1);
+ $this->append('// TODO: Implement method logic', 2);
+ $this->append('return [];', 2);
+ $this->append('}', 1);
+ }
+
+ private function mapParamType(string $type): string {
+ return match($type) {
+ 'INT' => 'int',
+ 'STRING', 'EMAIL', 'URL' => 'string',
+ 'DOUBLE' => 'float',
+ 'BOOL' => 'bool',
+ 'ARRAY' => 'array',
+ default => 'string'
+ };
+ }
+}
From 8faefd774b1629e996256758fc3270d7856d0bb1 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:42:13 +0300
Subject: [PATCH 13/32] Create CreateDomainEntity.php
---
.../Cli/Helpers/CreateDomainEntity.php | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 WebFiori/Framework/Cli/Helpers/CreateDomainEntity.php
diff --git a/WebFiori/Framework/Cli/Helpers/CreateDomainEntity.php b/WebFiori/Framework/Cli/Helpers/CreateDomainEntity.php
new file mode 100644
index 000000000..cb351138c
--- /dev/null
+++ b/WebFiori/Framework/Cli/Helpers/CreateDomainEntity.php
@@ -0,0 +1,54 @@
+isArgProvided('--defaults')) {
+ $ns = $this->getCommand()->getInput('Entity namespace: Enter = \''.$ns.'\'') ?: $ns;
+ }
+
+ $this->setNamespace($ns);
+ $this->setClassName($command->readClassName('Enter entity class name:'));
+
+ if (!$command->isArgProvided('--defaults')) {
+ $this->readProperties();
+ }
+ }
+
+ private function readProperties() {
+ $this->println('Add properties to the entity:');
+
+ while (true) {
+ $name = $this->getInput('Property name (or press Enter to finish):');
+ if (empty($name)) {
+ break;
+ }
+
+ $type = $this->select('Property type:', ['int', 'string', 'bool', 'float', 'array'], 1);
+ $nullable = $this->confirm('Is nullable?', false);
+
+ $this->getWriter()->addProperty($name, $type, $nullable);
+ $this->success("Added property: $name");
+ }
+ }
+}
From 6f4a7fc315667081ca22893068a4f744003b7344 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 17:59:18 +0300
Subject: [PATCH 14/32] feat: Create Attributes Table
---
.../Framework/Cli/Commands/CreateCommand.php | 10 ++
.../Cli/Helpers/CreateAttributeTable.php | 77 +++++++++++++++
.../Tests/Cli/CreateAttributeTableTest.php | 98 +++++++++++++++++++
3 files changed, 185 insertions(+)
create mode 100644 WebFiori/Framework/Cli/Helpers/CreateAttributeTable.php
create mode 100644 tests/WebFiori/Framework/Tests/Cli/CreateAttributeTableTest.php
diff --git a/WebFiori/Framework/Cli/Commands/CreateCommand.php b/WebFiori/Framework/Cli/Commands/CreateCommand.php
index bc5c2d3d6..744000a5a 100644
--- a/WebFiori/Framework/Cli/Commands/CreateCommand.php
+++ b/WebFiori/Framework/Cli/Commands/CreateCommand.php
@@ -15,9 +15,11 @@
use WebFiori\Framework\Cli\CLIUtils;
use WebFiori\Framework\Cli\Helpers\ClassInfoReader;
use WebFiori\Framework\Cli\Helpers\CreateAPITestCase;
+use WebFiori\Framework\Cli\Helpers\CreateAttributeTable;
use WebFiori\Framework\Cli\Helpers\CreateBackgroundTask;
use WebFiori\Framework\Cli\Helpers\CreateCLIClassHelper;
use WebFiori\Framework\Cli\Helpers\CreateDBAccessHelper;
+use WebFiori\Framework\Cli\Helpers\CreateDomainEntity;
use WebFiori\Framework\Cli\Helpers\CreateFullRESTHelper;
use WebFiori\Framework\Cli\Helpers\CreateMiddleware;
use WebFiori\Framework\Cli\Helpers\CreateMigration;
@@ -82,8 +84,14 @@ public function exec() : int {
} else if ($answer == 'Database table class.') {
$create = new CreateTableObj($this);
$create->readClassInfo();
+ } else if ($answer == 'Attribute-based table schema (Clean Architecture).') {
+ $create = new CreateAttributeTable($this);
+ $create->writeClass();
} else if ($answer == 'Entity class from table.') {
$this->createEntityFromQuery();
+ } else if ($answer == 'Pure domain entity (Clean Architecture).') {
+ $create = new CreateDomainEntity($this);
+ $create->writeClass();
} else if ($answer == 'Web service.') {
$create = new CreateWebService($this);
$create->readClassInfo();
@@ -127,7 +135,9 @@ public function exec() : int {
private function getWhat() {
$options = [];
$options['table'] = 'Database table class.';
+ $options['table-attributes'] = 'Attribute-based table schema (Clean Architecture).';
$options['entity'] = 'Entity class from table.';
+ $options['domain-entity'] = 'Pure domain entity (Clean Architecture).';
$options['web-service'] = 'Web service.';
$options['task'] = 'Background Task.';
$options['middleware'] = 'Middleware.';
diff --git a/WebFiori/Framework/Cli/Helpers/CreateAttributeTable.php b/WebFiori/Framework/Cli/Helpers/CreateAttributeTable.php
new file mode 100644
index 000000000..8dc15a48c
--- /dev/null
+++ b/WebFiori/Framework/Cli/Helpers/CreateAttributeTable.php
@@ -0,0 +1,77 @@
+isArgProvided('--defaults')) {
+ $ns = $this->getCommand()->getInput('Table schema namespace: Enter = \''.$ns.'\'') ?: $ns;
+ }
+
+ $this->setNamespace($ns);
+ $this->setClassName($command->readClassName('Enter table class name:', 'Table'));
+
+ $tableName = $this->getInput('Enter database table name:');
+ $this->getWriter()->setTableName($tableName);
+
+ if (!$command->isArgProvided('--defaults')) {
+ $this->readColumns();
+ }
+ }
+
+ private function readColumns() {
+ $this->println('Add columns to the table:');
+
+ while (true) {
+ $name = $this->getInput('Column name (or press Enter to finish):');
+ if (empty($name)) {
+ break;
+ }
+
+ $type = $this->select('Column type:', [
+ 'INT', 'VARCHAR', 'TEXT', 'DATETIME', 'TIMESTAMP', 'BOOL', 'DOUBLE', 'DECIMAL'
+ ], 1);
+
+ $options = [];
+
+ if ($type === 'VARCHAR' || $type === 'DECIMAL') {
+ $size = (int)$this->getInput('Size:', $type === 'VARCHAR' ? '255' : '10');
+ $options['size'] = $size;
+ }
+
+ if ($this->confirm('Is primary key?', false)) {
+ $options['primary'] = true;
+ if ($type === 'INT' && $this->confirm('Auto increment?', true)) {
+ $options['autoIncrement'] = true;
+ }
+ }
+
+ if ($this->confirm('Is nullable?', false)) {
+ $options['nullable'] = true;
+ }
+
+ $this->getWriter()->addColumn($name, $type, $options);
+ $this->success("Added column: $name");
+ }
+ }
+}
diff --git a/tests/WebFiori/Framework/Tests/Cli/CreateAttributeTableTest.php b/tests/WebFiori/Framework/Tests/Cli/CreateAttributeTableTest.php
new file mode 100644
index 000000000..8debc52cf
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Cli/CreateAttributeTableTest.php
@@ -0,0 +1,98 @@
+cleanupInfrastructure();
+ parent::tearDown();
+ }
+
+ private function cleanupInfrastructure(): void {
+ $dir = APP_PATH . 'Infrastructure' . DS . 'Schema';
+ if (is_dir($dir)) {
+ foreach (glob($dir . DS . '*.php') as $file) {
+ unlink($file);
+ }
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateAttributeTable() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'table-attributes'
+ ], [
+ "\n", // namespace (use default)
+ 'UsersTable', // class name
+ 'users', // table name
+ 'id', // column name
+ '0', // type: INT
+ '11', // size
+ 'y', // is primary
+ 'y', // auto increment
+ 'n', // not nullable
+ 'name', // column name
+ '1', // type: VARCHAR
+ '100', // size
+ 'n', // not primary
+ 'n', // not nullable
+ 'email', // column name
+ '1', // type: VARCHAR
+ '150', // size
+ 'n', // not primary
+ 'n', // not nullable
+ "\n" // finish
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Infrastructure' . DS . 'Schema' . DS . 'UsersTable.php';
+ $this->assertTrue(file_exists($filePath), 'Table schema file should exist');
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('namespace App\\Infrastructure\\Schema', $content);
+ $this->assertStringContainsString('use WebFiori\\Database\\Attributes\\Table', $content);
+ $this->assertStringContainsString('use WebFiori\\Database\\Attributes\\Column', $content);
+ $this->assertStringContainsString('#[Table(name: \'users\')]', $content);
+ $this->assertStringContainsString('#[Column(name: \'id\'', $content);
+ $this->assertStringContainsString('primary: true', $content);
+ $this->assertStringContainsString('autoIncrement: true', $content);
+ $this->assertStringContainsString('#[Column(name: \'name\'', $content);
+ $this->assertStringContainsString('#[Column(name: \'email\'', $content);
+ $this->assertStringContainsString('class UsersTable', $content);
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateAttributeTableWithDefaults() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'table-attributes',
+ '--defaults' => ''
+ ], [
+ 'ProductsTable',
+ 'products'
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Infrastructure' . DS . 'Schema' . DS . 'ProductsTable.php';
+ $this->assertTrue(file_exists($filePath));
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('class ProductsTable', $content);
+ $this->assertStringContainsString('#[Table(name: \'products\')]', $content);
+ }
+}
From 71d34ec99a04d497acc2dbf9d05764497aa2297a Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:20:45 +0300
Subject: [PATCH 15/32] Create CreateRestService.php
---
.../Cli/Helpers/CreateRestService.php | 100 ++++++++++++++++++
1 file changed, 100 insertions(+)
create mode 100644 WebFiori/Framework/Cli/Helpers/CreateRestService.php
diff --git a/WebFiori/Framework/Cli/Helpers/CreateRestService.php b/WebFiori/Framework/Cli/Helpers/CreateRestService.php
new file mode 100644
index 000000000..572cb2524
--- /dev/null
+++ b/WebFiori/Framework/Cli/Helpers/CreateRestService.php
@@ -0,0 +1,100 @@
+isArgProvided('--defaults')) {
+ $ns = $this->getCommand()->getInput('Service namespace: Enter = \''.$ns.'\'') ?: $ns;
+ }
+
+ $this->setNamespace($ns);
+ $this->setClassName($command->readClassName('Enter service class name:', 'Service'));
+
+ $description = $this->getInput('Service description:');
+ $this->getWriter()->setDescription($description);
+
+ if (!$command->isArgProvided('--defaults')) {
+ $this->readMethods();
+ }
+ }
+
+ private function readMethods() {
+ $this->println('Add HTTP methods to the service:');
+
+ while (true) {
+ $httpMethod = $this->select('HTTP method (or select Cancel):', ['GET', 'POST', 'PUT', 'DELETE', 'Cancel'], 0);
+
+ if ($httpMethod === 'Cancel') {
+ break;
+ }
+
+ $methodName = $this->getInput('Method name:');
+ $params = [];
+
+ if ($this->confirm('Add parameters?', false)) {
+ $params = $this->readParameters();
+ }
+
+ $returnType = $this->select('Return type:', ['array', 'string', 'int', 'bool'], 0);
+
+ $this->getWriter()->addMethod($httpMethod, $methodName, $params, $returnType);
+ $this->success("Added method: $methodName");
+
+ if (!$this->confirm('Add another method?', false)) {
+ break;
+ }
+ }
+ }
+
+ private function readParameters(): array {
+ $params = [];
+
+ while (true) {
+ $name = $this->getInput('Parameter name (or press Enter to finish):');
+ if (empty($name)) {
+ break;
+ }
+
+ $type = $this->select('Parameter type:', ['STRING', 'INT', 'DOUBLE', 'BOOL', 'EMAIL', 'URL', 'ARRAY'], 0);
+ $description = $this->getInput('Parameter description:');
+
+ $param = [
+ 'name' => $name,
+ 'type' => $type,
+ 'description' => $description
+ ];
+
+ if ($type === 'INT' || $type === 'DOUBLE') {
+ if ($this->confirm('Set min/max values?', false)) {
+ $param['min'] = (int)$this->getInput('Minimum value:');
+ $param['max'] = (int)$this->getInput('Maximum value:');
+ }
+ }
+
+ $params[] = $param;
+ $this->success("Added parameter: $name");
+ }
+
+ return $params;
+ }
+}
From 2a88eb70eb3915685a62df724f7d8d238a927f91 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:21:05 +0300
Subject: [PATCH 16/32] Update RepositoryWriter.php
---
WebFiori/Framework/Writers/RepositoryWriter.php | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/WebFiori/Framework/Writers/RepositoryWriter.php b/WebFiori/Framework/Writers/RepositoryWriter.php
index fc33c12a5..85611659c 100644
--- a/WebFiori/Framework/Writers/RepositoryWriter.php
+++ b/WebFiori/Framework/Writers/RepositoryWriter.php
@@ -66,23 +66,23 @@ public function writeClassDeclaration() {
}
private function writeGetTableName() {
- $this->append($this->f('getTableName', [], 'string').' {', 1);
- $this->append("return '{$this->tableName}';", 2);
+ $this->append($this->f('getTableName', [], 'string'), 1);
+ $this->append('return \''.$this->tableName.'\';', 2);
$this->append('}', 1);
$this->append('', 1);
}
private function writeGetIdField() {
- $this->append($this->f('getIdField', [], 'string').' {', 1);
- $this->append("return '{$this->idField}';", 2);
+ $this->append($this->f('getIdField', [], 'string'), 1);
+ $this->append('return \''.$this->idField.'\';', 2);
$this->append('}', 1);
$this->append('', 1);
}
private function writeToEntity() {
$entityShortName = basename(str_replace('\\', '/', $this->entityClass));
- $this->append($this->f('toEntity', ['row' => 'array'], $entityShortName).' {', 1);
- $this->append("return new $entityShortName(", 2);
+ $this->append($this->f('toEntity', ['row' => 'array'], $entityShortName), 1);
+ $this->append('return new '.$entityShortName.'(', 2);
$params = [];
foreach ($this->properties as $prop) {
@@ -97,7 +97,7 @@ private function writeToEntity() {
}
private function writeToArray() {
- $this->append($this->f('toArray', ['entity' => 'object'], 'array').' {', 1);
+ $this->append($this->f('toArray', ['entity' => 'object'], 'array'), 1);
$this->append('return [', 2);
foreach ($this->properties as $prop) {
From 980209f90a070a0bd972a438e78dcd83d496ceba Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:21:39 +0300
Subject: [PATCH 17/32] Create CreateRepository.php
---
.../Cli/Helpers/CreateRepository.php | 62 +++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 WebFiori/Framework/Cli/Helpers/CreateRepository.php
diff --git a/WebFiori/Framework/Cli/Helpers/CreateRepository.php b/WebFiori/Framework/Cli/Helpers/CreateRepository.php
new file mode 100644
index 000000000..850550158
--- /dev/null
+++ b/WebFiori/Framework/Cli/Helpers/CreateRepository.php
@@ -0,0 +1,62 @@
+isArgProvided('--defaults')) {
+ $ns = $this->getCommand()->getInput('Repository namespace: Enter = \''.$ns.'\'') ?: $ns;
+ }
+
+ $this->setNamespace($ns);
+ $this->setClassName($command->readClassName('Enter repository class name:', 'Repository'));
+
+ $entityClass = $this->getInput('Enter entity class (e.g., App\\Domain\\User):');
+ $this->getWriter()->setEntityClass($entityClass);
+
+ $tableName = $this->getInput('Enter table name:');
+ $this->getWriter()->setTableName($tableName);
+
+ $idField = $this->getInput('Enter ID field name:', 'id');
+ $this->getWriter()->setIdField($idField);
+
+ if (!$command->isArgProvided('--defaults')) {
+ $this->readProperties();
+ }
+ }
+
+ private function readProperties() {
+ $this->println('Add entity properties (for mapping):');
+
+ while (true) {
+ $name = $this->getInput('Property name (or press Enter to finish):');
+ if (empty($name)) {
+ break;
+ }
+
+ $type = $this->select('Property type:', ['int', 'string', 'bool', 'float'], 1);
+
+ $this->getWriter()->addProperty($name, $type);
+ $this->success("Added property: $name");
+ }
+ }
+}
From 74b6959e831b1e7b796af4bc0b2b0cdb2c4e9fdb Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:21:56 +0300
Subject: [PATCH 18/32] Update CreateCommand.php
---
WebFiori/Framework/Cli/Commands/CreateCommand.php | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/WebFiori/Framework/Cli/Commands/CreateCommand.php b/WebFiori/Framework/Cli/Commands/CreateCommand.php
index 744000a5a..642a5e019 100644
--- a/WebFiori/Framework/Cli/Commands/CreateCommand.php
+++ b/WebFiori/Framework/Cli/Commands/CreateCommand.php
@@ -18,11 +18,14 @@
use WebFiori\Framework\Cli\Helpers\CreateAttributeTable;
use WebFiori\Framework\Cli\Helpers\CreateBackgroundTask;
use WebFiori\Framework\Cli\Helpers\CreateCLIClassHelper;
+use WebFiori\Framework\Cli\Helpers\CreateCleanArchStack;
use WebFiori\Framework\Cli\Helpers\CreateDBAccessHelper;
use WebFiori\Framework\Cli\Helpers\CreateDomainEntity;
use WebFiori\Framework\Cli\Helpers\CreateFullRESTHelper;
use WebFiori\Framework\Cli\Helpers\CreateMiddleware;
use WebFiori\Framework\Cli\Helpers\CreateMigration;
+use WebFiori\Framework\Cli\Helpers\CreateRepository;
+use WebFiori\Framework\Cli\Helpers\CreateRestService;
use WebFiori\Framework\Cli\Helpers\CreateTableObj;
use WebFiori\Framework\Cli\Helpers\CreateThemeHelper;
use WebFiori\Framework\Cli\Helpers\CreateWebService;
@@ -95,6 +98,9 @@ public function exec() : int {
} else if ($answer == 'Web service.') {
$create = new CreateWebService($this);
$create->readClassInfo();
+ } else if ($answer == 'Annotation-based REST service (Clean Architecture).') {
+ $create = new CreateRestService($this);
+ $create->writeClass();
} else if ($answer == 'Middleware.') {
$create = new CreateMiddleware($this);
$create->readClassInfo();
@@ -116,6 +122,12 @@ public function exec() : int {
$create->readEntityInfo();
$create->confirnIncludeColsUpdate();
$create->writeClass();
+ } else if ($answer == 'Repository class (Clean Architecture).') {
+ $create = new CreateRepository($this);
+ $create->writeClass();
+ } else if ($answer == 'Complete clean architecture stack (Entity + Table + Repository).') {
+ $create = new CreateCleanArchStack($this);
+ return $create->writeClasses();
} else if ($answer == 'Complete REST backend (Database table, entity, database access and web services).') {
$create = new CreateFullRESTHelper($this);
$create->readInfo();
@@ -139,11 +151,14 @@ private function getWhat() {
$options['entity'] = 'Entity class from table.';
$options['domain-entity'] = 'Pure domain entity (Clean Architecture).';
$options['web-service'] = 'Web service.';
+ $options['rest-service'] = 'Annotation-based REST service (Clean Architecture).';
$options['task'] = 'Background Task.';
$options['middleware'] = 'Middleware.';
$options['command'] = 'CLI Command.';
$options['theme'] = 'Theme.';
$options['db'] = 'Database access class based on table.';
+ $options['repository'] = 'Repository class (Clean Architecture).';
+ $options['clean-stack'] = 'Complete clean architecture stack (Entity + Table + Repository).';
$options['rest'] = 'Complete REST backend (Database table, entity, database access and web services).';
$options['api-test'] = 'Web service test case.';
$options['migration'] = 'Database migration.';
From d14217474ff5f300ea0b174e523c321dff736490 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:22:30 +0300
Subject: [PATCH 19/32] Create CreateDomainEntityTest.php
---
.../Tests/Cli/CreateDomainEntityTest.php | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
create mode 100644 tests/WebFiori/Framework/Tests/Cli/CreateDomainEntityTest.php
diff --git a/tests/WebFiori/Framework/Tests/Cli/CreateDomainEntityTest.php b/tests/WebFiori/Framework/Tests/Cli/CreateDomainEntityTest.php
new file mode 100644
index 000000000..d66a3ae41
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Cli/CreateDomainEntityTest.php
@@ -0,0 +1,93 @@
+cleanupDomain();
+ parent::tearDown();
+ }
+
+ private function cleanupDomain(): void {
+ $dir = APP_PATH . 'Domain';
+ if (is_dir($dir)) {
+ foreach (glob($dir . DS . '*.php') as $file) {
+ unlink($file);
+ }
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateDomainEntity() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'domain-entity'
+ ], [
+ "\n", // namespace (use default)
+ 'User', // class name
+ 'id', // property name
+ '0', // type: int
+ 'y', // nullable
+ 'name', // property name
+ '1', // type: string
+ 'n', // not nullable
+ 'email', // property name
+ '1', // type: string
+ 'n', // not nullable
+ "\n" // finish
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Domain' . DS . 'User.php';
+ $this->assertTrue(file_exists($filePath), 'Entity file should exist');
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('namespace App\\Domain', $content);
+ $this->assertStringContainsString('class User', $content);
+ $this->assertStringContainsString('public ?int $id', $content);
+ $this->assertStringContainsString('public string $name', $content);
+ $this->assertStringContainsString('public string $email', $content);
+
+ // Test that class can be loaded
+ require_once $filePath;
+ $this->assertTrue(class_exists('\\App\\Domain\\User'));
+
+ // Test instantiation
+ $user = new \App\Domain\User(1, 'John Doe', 'john@example.com');
+ $this->assertEquals(1, $user->id);
+ $this->assertEquals('John Doe', $user->name);
+ $this->assertEquals('john@example.com', $user->email);
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateDomainEntityWithDefaults() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'domain-entity',
+ '--defaults' => ''
+ ], [
+ 'Product'
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Domain' . DS . 'Product.php';
+ $this->assertTrue(file_exists($filePath));
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('class Product', $content);
+ }
+}
From f00a100fa7f39d69c713138fd16819c0b2be8a08 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 18:22:46 +0300
Subject: [PATCH 20/32] Create CreateRepositoryTest.php
---
.../Tests/Cli/CreateRepositoryTest.php | 93 +++++++++++++++++++
1 file changed, 93 insertions(+)
create mode 100644 tests/WebFiori/Framework/Tests/Cli/CreateRepositoryTest.php
diff --git a/tests/WebFiori/Framework/Tests/Cli/CreateRepositoryTest.php b/tests/WebFiori/Framework/Tests/Cli/CreateRepositoryTest.php
new file mode 100644
index 000000000..c6398100d
--- /dev/null
+++ b/tests/WebFiori/Framework/Tests/Cli/CreateRepositoryTest.php
@@ -0,0 +1,93 @@
+cleanupInfrastructure();
+ parent::tearDown();
+ }
+
+ private function cleanupInfrastructure(): void {
+ $dir = APP_PATH . 'Infrastructure' . DS . 'Repository';
+ if (is_dir($dir)) {
+ foreach (glob($dir . DS . '*.php') as $file) {
+ unlink($file);
+ }
+ }
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateRepository() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'repository'
+ ], [
+ "\n", // namespace (use default)
+ 'UserRepository', // class name
+ 'App\\Domain\\User', // entity class
+ 'users', // table name
+ 'id', // id field
+ 'id', // property name
+ '0', // type: int
+ 'name', // property name
+ '1', // type: string
+ 'email', // property name
+ '1', // type: string
+ "\n" // finish
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Infrastructure' . DS . 'Repository' . DS . 'UserRepository.php';
+ $this->assertTrue(file_exists($filePath), 'Repository file should exist');
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('namespace App\\Infrastructure\\Repository', $content);
+ $this->assertStringContainsString('use WebFiori\\Database\\Repository\\AbstractRepository', $content);
+ $this->assertStringContainsString('use App\\Domain\\User', $content);
+ $this->assertStringContainsString('class UserRepository extends AbstractRepository', $content);
+ $this->assertStringContainsString('function getTableName()', $content);
+ $this->assertStringContainsString('return \'users\'', $content);
+ $this->assertStringContainsString('function getIdField()', $content);
+ $this->assertStringContainsString('return \'id\'', $content);
+ $this->assertStringContainsString('function toEntity', $content);
+ $this->assertStringContainsString('function toArray', $content);
+ $this->assertStringContainsString('new User(', $content);
+ }
+
+ /**
+ * @test
+ */
+ public function testCreateRepositoryWithDefaults() {
+ $output = $this->executeMultiCommand([
+ CreateCommand::class,
+ '--c' => 'repository',
+ '--defaults' => ''
+ ], [
+ 'ProductRepository',
+ 'App\\Domain\\Product',
+ 'products',
+ 'id'
+ ]);
+
+ $this->assertEquals(0, $this->getExitCode());
+
+ $filePath = APP_PATH . 'Infrastructure' . DS . 'Repository' . DS . 'ProductRepository.php';
+ $this->assertTrue(file_exists($filePath));
+
+ $content = file_get_contents($filePath);
+ $this->assertStringContainsString('class ProductRepository', $content);
+ $this->assertStringContainsString('return \'products\'', $content);
+ }
+}
From eee51caff8a3b09c3920d3bf6837a0e53cf29eb3 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 22:44:53 +0300
Subject: [PATCH 21/32] refactor: How to Locate `ClassLoader`
---
WebFiori/Framework/App.php | 35 ++++++++++++++++++++---------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/WebFiori/Framework/App.php b/WebFiori/Framework/App.php
index df2feecc0..627a267d4 100644
--- a/WebFiori/Framework/App.php
+++ b/WebFiori/Framework/App.php
@@ -384,11 +384,14 @@ public static function initiate(string $appFolder = 'App', string $publicFolder
*/
define('PUBLIC_FOLDER', $publicFolder);
}
- if (!defined('WF_CORE_PATH')) {
+ if (!defined('WF_CORE_PATHS')) {
/**
- * Path to WebFiori's core library.
+ * Possible Paths to WebFiori's core library.
*/
- define('WF_CORE_PATH', ROOT_PATH.DS.'vendor'.DS.'webfiori'.DS.'framework'.DS.'WebFiori'.DS.'Framework');
+ define('WF_CORE_PATHS', [
+ ROOT_PATH.DS.'vendor'.DS.'webfiori'.DS.'framework'.DS.'WebFiori'.DS.'Framework',
+ ROOT_PATH.DS.'WebFiori'.DS.'Framework'
+ ]);
}
self::initAutoLoader();
self::checkStandardLibs();
@@ -649,20 +652,22 @@ private static function initAutoLoader() {
* Initialize autoloader.
*/
if (!class_exists('WebFiori\Framework\Autoload\ClassLoader',false)) {
- $autoloader = WF_CORE_PATH.DIRECTORY_SEPARATOR.'Autoload'.DIRECTORY_SEPARATOR.'ClassLoader.php';
-
- if (!file_exists($autoloader)) {
- throw new \Exception('Unable to locate the autoloader class.');
- }
- require_once $autoloader;
- }
- self::$AU = ClassLoader::get();
+ foreach (WF_CORE_PATHS as $path) {
+ $autoloader = $path.DIRECTORY_SEPARATOR.'Autoload'.DIRECTORY_SEPARATOR.'ClassLoader.php';
- if (!class_exists(APP_DIR.'\\Init\\InitAutoLoad')) {
- Ini::createAppDirs();
- Ini::get()->createIniClass('InitAutoLoad', 'Add user-defined directories to the set of directories at which the framework will search for classes.');
+ if (file_exists($autoloader)) {
+ require_once $autoloader;
+ self::$AU = ClassLoader::get();
+ }
+ if (!class_exists(APP_DIR.'\\Init\\InitAutoLoad')) {
+ Ini::createAppDirs();
+ Ini::get()->createIniClass('InitAutoLoad', 'Add user-defined directories to the set of directories at which the framework will search for classes.');
+ }
+ self::call(APP_DIR.'\\Init\\InitAutoLoad::init');
+ return;
+ }
}
- self::call(APP_DIR.'\\Init\\InitAutoLoad::init');
+ throw new \Exception('Unable to locate the autoloader class.');
}
/**
* Initialize global constants which has information about framework version.
From d505785653d59f9ae237ee70cdf0912c4800c336 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 22:45:51 +0300
Subject: [PATCH 22/32] Update .gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 713406857..e1e97f9d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ App/Config/*
*.Identifier
tests/clover.xml
/.vscode
+/App
From 153732cf28cc22251356c67ba35154b2a3b02c7c Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:06:57 +0300
Subject: [PATCH 23/32] refactor: Use of Lines Instead of String Concat
---
WebFiori/Framework/Writers/ClassWriter.php | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index ed7675442..362c3cf96 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -27,7 +27,7 @@ abstract class ClassWriter {
*
* @since 1.0
*/
- private $classAsStr;
+ private $classLines;
/**
* The name of the class that will be created.
*
@@ -405,13 +405,13 @@ public function setSuffix(string $classNameSuffix) : bool {
public function writeClass() {
$classFile = new File($this->getName().'.php', $this->getPath());
$classFile->remove();
- $this->classAsStr = '';
+ $this->classLines = [];
$this->writeNsDeclaration();
$this->writeUseStatements();
$this->writeClassComment();
$this->writeClassDeclaration();
$this->writeClassBody();
- $classFile->setRawData($this->classAsStr);
+ $classFile->setRawData(implode("\n", $this->classLines));
$classFile->write(false, true);
}
public abstract function writeClassBody();
@@ -446,7 +446,7 @@ public function writeUseStatements() {
}
private function a($str, $tapsCount) {
$tabStr = str_repeat(' ', $tapsCount);
- $this->classAsStr .= $tabStr.$str."\n";
+ $this->classLines[] = $tabStr.$str;
}
private function fixClassName($className) {
$classSuffix = $this->getSuffix();
From 18da8fad4602018a62724463acf587a682ed4773 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:14:02 +0300
Subject: [PATCH 24/32] refactor: Added Code Normalization
---
WebFiori/Framework/Writers/ClassWriter.php | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index 362c3cf96..b7c57de3f 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -411,7 +411,7 @@ public function writeClass() {
$this->writeClassComment();
$this->writeClassDeclaration();
$this->writeClassBody();
- $classFile->setRawData(implode("\n", $this->classLines));
+ $classFile->setRawData(implode("\n", $this->normalizeCode($this->classLines)));
$classFile->write(false, true);
}
public abstract function writeClassBody();
@@ -448,7 +448,23 @@ private function a($str, $tapsCount) {
$tabStr = str_repeat(' ', $tapsCount);
$this->classLines[] = $tabStr.$str;
}
- private function fixClassName($className) {
+ private function normalizeCode(array $lines) : array {
+ $normalized = [];
+ $prevLineEmpty = false;
+
+ foreach ($lines as $line) {
+ $isEmpty = trim($line) === '';
+
+ if ($isEmpty && $prevLineEmpty) {
+ continue;
+ }
+
+ $normalized[] = $line;
+ $prevLineEmpty = $isEmpty;
+ }
+
+ return $normalized;
+ } private function fixClassName($className) {
$classSuffix = $this->getSuffix();
if ($classSuffix == '') {
From 4d486b27446d6d6e53a0d2e54a23e09b8d6e40bc Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:25:43 +0300
Subject: [PATCH 25/32] feat: Add New Method" `method`
---
WebFiori/Framework/Writers/ClassWriter.php | 58 ++++++++++++++++++----
1 file changed, 48 insertions(+), 10 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index b7c57de3f..0d9d23f48 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -139,24 +139,62 @@ public function append($strOrArr, $tabsCount = 0) {
* it.
*/
public function f($funcName, $argsArr = [], ?string $returns = null) {
+ return $this->method($funcName, $argsArr, $returns);
+ }
+ /**
+ * Adds method definition with full control over modifiers.
+ *
+ * @param string $funcName Method name
+ * @param array $argsArr Arguments [name => type]
+ * @param string|null $returns Return type
+ * @param string $visibility Visibility: 'public', 'protected', 'private'
+ * @param bool $isStatic Is static method
+ * @param bool $isAbstract Is abstract method
+ * @param bool $isFinal Is final method
+ *
+ * @return string Method signature
+ */
+ public function method(
+ string $funcName,
+ array $argsArr = [],
+ ?string $returns = null,
+ string $visibility = 'public',
+ bool $isStatic = false,
+ bool $isAbstract = false,
+ bool $isFinal = false
+ ) : string {
+ $modifiers = [];
+
+ if ($isFinal) {
+ $modifiers[] = 'final';
+ }
+ if ($isAbstract) {
+ $modifiers[] = 'abstract';
+ }
+
+ $modifiers[] = $visibility;
+
+ if ($isStatic) {
+ $modifiers[] = 'static';
+ }
+
+ $signature = implode(' ', $modifiers) . ' function ' . $funcName;
+
$argsPart = '(';
-
foreach ($argsArr as $argName => $argType) {
if (strlen($argsPart) != 1) {
- $argsPart .= ', '.$argType.' $'.$argName;
- continue;
+ $argsPart .= ', ';
}
- $argsPart .= $argType.' $'.$argName;
+ $argsPart .= $argType . ' $' . $argName;
}
$argsPart .= ')';
-
+
if ($returns !== null) {
- $argsPart .= ' : '.$returns;
+ $argsPart .= ' : ' . $returns;
}
-
- return 'public function '.$funcName.$argsPart.' {';
- }
- /**
+
+ return $signature . $argsPart . ($isAbstract ? ';' : ' {');
+ } /**
* Returns the absolute path of the class that will be created.
*
* @return string The absolute path of the file that holds class information.
From bebe151ebf59b7612b4f064c5dfddcd926e7fd82 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:30:26 +0300
Subject: [PATCH 26/32] feat: Add `property` and `constant`
---
WebFiori/Framework/Writers/ClassWriter.php | 61 +++++++++++++++++++++-
1 file changed, 60 insertions(+), 1 deletion(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index 0d9d23f48..c48b15bbf 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -194,7 +194,66 @@ public function method(
}
return $signature . $argsPart . ($isAbstract ? ';' : ' {');
- } /**
+ }
+ /**
+ * Generate a property declaration.
+ *
+ * @param string $name Property name
+ * @param string $visibility Visibility: 'public', 'protected', 'private'
+ * @param string|null $type Property type
+ * @param string|null $defaultValue Default value as string
+ * @param bool $isStatic Is static property
+ * @param bool $isReadonly Is readonly property (PHP 8.1+)
+ *
+ * @return string Property declaration
+ */
+ public function property(
+ string $name,
+ string $visibility = 'private',
+ ?string $type = null,
+ ?string $defaultValue = null,
+ bool $isStatic = false,
+ bool $isReadonly = false
+ ) : string {
+ $modifiers = [$visibility];
+
+ if ($isReadonly) {
+ $modifiers[] = 'readonly';
+ }
+ if ($isStatic) {
+ $modifiers[] = 'static';
+ }
+
+ $declaration = implode(' ', $modifiers);
+
+ if ($type !== null) {
+ $declaration .= ' ' . $type;
+ }
+
+ $declaration .= ' $' . $name;
+
+ if ($defaultValue !== null) {
+ $declaration .= ' = ' . $defaultValue;
+ }
+
+ return $declaration . ';';
+ }
+ /**
+ * Generate a constant declaration.
+ *
+ * @param string $name Constant name
+ * @param string $value Constant value as string
+ * @param string $visibility Visibility: 'public', 'protected', 'private'
+ *
+ * @return string Constant declaration
+ */
+ public function constant(
+ string $name,
+ string $value,
+ string $visibility = 'public'
+ ) : string {
+ return $visibility . ' const ' . $name . ' = ' . $value . ';';
+ }/**
* Returns the absolute path of the class that will be created.
*
* @return string The absolute path of the file that holds class information.
From 0eb7760ea9107f5a004f30e2b2d0b5526e3eb9ac Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:38:53 +0300
Subject: [PATCH 27/32] feat: Doc-block Builder
---
WebFiori/Framework/Writers/ClassWriter.php | 13 +-
.../Framework/Writers/DocblockBuilder.php | 158 ++++++++++++++++++
2 files changed, 170 insertions(+), 1 deletion(-)
create mode 100644 WebFiori/Framework/Writers/DocblockBuilder.php
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index c48b15bbf..c09113c64 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -253,7 +253,18 @@ public function constant(
string $visibility = 'public'
) : string {
return $visibility . ' const ' . $name . ' = ' . $value . ';';
- }/**
+ }
+ /**
+ * Start building a docblock.
+ *
+ * @param string $description Main description
+ *
+ * @return DocblockBuilder
+ */
+ public function docblock(string $description = '') : DocblockBuilder {
+ return new DocblockBuilder($this, $description);
+ }
+ /**
* Returns the absolute path of the class that will be created.
*
* @return string The absolute path of the file that holds class information.
diff --git a/WebFiori/Framework/Writers/DocblockBuilder.php b/WebFiori/Framework/Writers/DocblockBuilder.php
new file mode 100644
index 000000000..d763b7abe
--- /dev/null
+++ b/WebFiori/Framework/Writers/DocblockBuilder.php
@@ -0,0 +1,158 @@
+writer = $writer;
+ $this->description = $description;
+ }
+
+ /**
+ * Add a parameter to the docblock.
+ *
+ * @param string $type Parameter type
+ * @param string $name Parameter name (without $)
+ * @param string $desc Optional description
+ *
+ * @return DocblockBuilder
+ */
+ public function param(string $type, string $name, string $desc = '') : self {
+ $this->params[] = ['type' => $type, 'name' => $name, 'desc' => $desc];
+ return $this;
+ }
+
+ /**
+ * Add a return tag to the docblock.
+ *
+ * @param string $type Return type
+ * @param string $desc Optional description
+ *
+ * @return DocblockBuilder
+ */
+ public function returns(string $type, string $desc = '') : self {
+ $this->return = ['type' => $type, 'desc' => $desc];
+ return $this;
+ }
+
+ /**
+ * Add a custom tag to the docblock.
+ *
+ * @param string $name Tag name (without @)
+ * @param string $value Optional tag value
+ *
+ * @return DocblockBuilder
+ */
+ public function tag(string $name, string $value = '') : self {
+ $this->tags[] = ['name' => $name, 'value' => $value];
+ return $this;
+ }
+
+ /**
+ * Add @throws tag.
+ *
+ * @param string $exception Exception class name
+ * @param string $desc Optional description
+ *
+ * @return DocblockBuilder
+ */
+ public function throws(string $exception, string $desc = '') : self {
+ return $this->tag('throws', $exception . ($desc ? ' ' . $desc : ''));
+ }
+
+ /**
+ * Add @deprecated tag.
+ *
+ * @param string $message Optional deprecation message
+ *
+ * @return DocblockBuilder
+ */
+ public function deprecated(string $message = '') : self {
+ return $this->tag('deprecated', $message);
+ }
+
+ /**
+ * Add @since tag.
+ *
+ * @param string $version Version number
+ *
+ * @return DocblockBuilder
+ */
+ public function since(string $version) : self {
+ return $this->tag('since', $version);
+ }
+
+ /**
+ * Build and append the docblock to the class writer.
+ *
+ * @param int $indent Indentation level (number of tabs)
+ *
+ * @return array The generated docblock lines
+ */
+ public function build(int $indent = 1) : array {
+ $lines = ['/**'];
+
+ if ($this->description) {
+ foreach (explode("\n", $this->description) as $line) {
+ $lines[] = ' * ' . $line;
+ }
+ if (!empty($this->params) || $this->return || !empty($this->tags)) {
+ $lines[] = ' *';
+ }
+ }
+
+ foreach ($this->params as $param) {
+ $line = ' * @param ' . $param['type'] . ' $' . $param['name'];
+ if ($param['desc']) {
+ $line .= ' ' . $param['desc'];
+ }
+ $lines[] = $line;
+ }
+
+ if ($this->return) {
+ $line = ' * @return ' . $this->return['type'];
+ if ($this->return['desc']) {
+ $line .= ' ' . $this->return['desc'];
+ }
+ $lines[] = $line;
+ }
+
+ foreach ($this->tags as $tag) {
+ $line = ' * @' . $tag['name'];
+ if ($tag['value']) {
+ $line .= ' ' . $tag['value'];
+ }
+ $lines[] = $line;
+ }
+
+ $lines[] = ' */';
+
+ $this->writer->append($lines, $indent);
+ return $lines;
+ }
+}
From ff37c27169234495d3fe8abc1a318ca039521535 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:46:22 +0300
Subject: [PATCH 28/32] feat: Added `getCode`
---
WebFiori/Framework/Writers/ClassWriter.php | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index c09113c64..ee08bb004 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -513,16 +513,23 @@ public function setSuffix(string $classNameSuffix) : bool {
public function writeClass() {
$classFile = new File($this->getName().'.php', $this->getPath());
$classFile->remove();
+ $classFile->setRawData($this->getCode());
+ $classFile->write(false, true);
+ }
+ /**
+ * Generate the class code without writing to disk.
+ *
+ * @return string The generated class code
+ */
+ public function getCode() : string {
$this->classLines = [];
$this->writeNsDeclaration();
$this->writeUseStatements();
$this->writeClassComment();
$this->writeClassDeclaration();
$this->writeClassBody();
- $classFile->setRawData(implode("\n", $this->normalizeCode($this->classLines)));
- $classFile->write(false, true);
- }
- public abstract function writeClassBody();
+ return implode("\n", $this->normalizeCode($this->classLines));
+ } public abstract function writeClassBody();
/**
* Writes the top section of the class that contains class comment.
*/
From 9e11d2be039a04ea2b0fa6797cd463e03b146584 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Tue, 20 Jan 2026 23:56:45 +0300
Subject: [PATCH 29/32] feat: Attributes
---
WebFiori/Framework/Writers/ClassWriter.php | 89 ++++++++++++++++++++++
1 file changed, 89 insertions(+)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index ee08bb004..64591e300 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -264,6 +264,95 @@ public function constant(
public function docblock(string $description = '') : DocblockBuilder {
return new DocblockBuilder($this, $description);
}
+ /**
+ /**
+ * Add an attribute for a class.
+ *
+ * @param string $name Attribute name (without #)
+ * @param array $params Attribute parameters
+ * @param int $indent Indentation level
+ *
+ * @return $this For chaining
+ */
+ public function classAttribute(string $name, array $params = [], int $indent = 0) {
+ $this->append($this->formatAttribute($name, $params), $indent);
+ return $this;
+ }
+ /**
+ * Add an attribute for a property.
+ *
+ * @param string $name Attribute name (without #)
+ * @param array $params Attribute parameters
+ * @param int $indent Indentation level
+ *
+ * @return $this For chaining
+ */
+ public function propertyAttribute(string $name, array $params = [], int $indent = 1) {
+ $this->append($this->formatAttribute($name, $params), $indent);
+ return $this;
+ }
+ /**
+ * Add an attribute for a method.
+ *
+ * @param string $name Attribute name (without #)
+ * @param array $params Attribute parameters
+ * @param int $indent Indentation level
+ *
+ * @return $this For chaining
+ */
+ public function methodAttribute(string $name, array $params = [], int $indent = 1) {
+ $this->append($this->formatAttribute($name, $params), $indent);
+ return $this;
+ }
+ /**
+ * Format an attribute string.
+ *
+ * @param string $name Attribute name
+ * @param array $params Attribute parameters
+ *
+ * @return string Formatted attribute
+ */
+ private function formatAttribute(string $name, array $params = []) : string {
+ $attr = '#[' . $name;
+
+ if (!empty($params)) {
+ $args = [];
+ foreach ($params as $key => $value) {
+ if (is_int($key)) {
+ $args[] = $this->formatAttributeValue($value);
+ } else {
+ $args[] = $key . ': ' . $this->formatAttributeValue($value);
+ }
+ }
+ $attr .= '(' . implode(', ', $args) . ')';
+ }
+
+ $attr .= ']';
+ return $attr;
+ }
+ /**
+ * Format a value for attribute parameters.
+ *
+ * @param mixed $value The value to format
+ *
+ * @return string Formatted value
+ */
+ private function formatAttributeValue($value) : string {
+ if (is_string($value)) {
+ return "'" . addslashes($value) . "'";
+ }
+ if (is_bool($value)) {
+ return $value ? 'true' : 'false';
+ }
+ if (is_array($value)) {
+ $items = array_map([$this, 'formatAttributeValue'], $value);
+ return '[' . implode(', ', $items) . ']';
+ }
+ if (is_null($value)) {
+ return 'null';
+ }
+ return (string)$value;
+ }
/**
* Returns the absolute path of the class that will be created.
*
From 57656d7029baa4199e5f7b307d8bbea6a79767d2 Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Wed, 21 Jan 2026 00:00:05 +0300
Subject: [PATCH 30/32] refactor: Throw Exceptions on Errors
---
WebFiori/Framework/Writers/ClassWriter.php | 46 ++++++++++++----------
1 file changed, 26 insertions(+), 20 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index 64591e300..28d958672 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -525,16 +525,18 @@ public function removeUseStatement(string $classToRemove) {
* @return boolean If the name is successfully set, the method will return true.
* Other than that, false is returned.
*/
- public function setClassName(string $name) : bool {
+ public function setClassName(string $name) {
$trimmed = trim($name);
- if (self::isValidClassName($trimmed)) {
- $this->className = $this->fixClassName($trimmed);
-
- return true;
+ if (!self::isValidClassName($trimmed)) {
+ throw new \InvalidArgumentException(
+ "Invalid class name '$name'. Class names must start with a letter or underscore, " .
+ "followed by letters, numbers, or underscores."
+ );
}
- return false;
+ $this->className = $this->fixClassName($trimmed);
+ return $this;
}
/**
@@ -549,11 +551,14 @@ public function setNamespace(string $namespace) {
$trimmed = trim($namespace, ' ');
if (!self::isValidNamespace($trimmed)) {
- return false;
+ throw new \InvalidArgumentException(
+ "Invalid namespace '$namespace'. Namespaces must contain valid PHP identifiers " .
+ "separated by backslashes."
+ );
}
+
$this->ns = $trimmed[0] == '\\' ? substr($trimmed, 1) : $trimmed;
-
- return true;
+ return $this;
}
/**
* Sets the location at which the class will be created on.
@@ -563,15 +568,15 @@ public function setNamespace(string $namespace) {
* @return boolean If the path is successfully set, the method will return true.
* Other than that, false is returned.
*/
- public function setPath(string $path) : bool {
+ public function setPath(string $path) {
$trimmed = trim($path);
if (strlen($trimmed) == 0) {
- return false;
+ throw new \InvalidArgumentException("Path cannot be empty.");
}
+
$this->path = str_replace('\\', DS, str_replace('/', DS, $trimmed));
-
- return true;
+ return $this;
}
/**
* Sets a string as a suffix to the class name.
@@ -581,15 +586,16 @@ public function setPath(string $path) : bool {
*
* @return bool If set, the method will return true. False otherises.
*/
- public function setSuffix(string $classNameSuffix) : bool {
- if (self::isValidClassName($classNameSuffix)) {
- $this->suffix = $classNameSuffix;
- $this->className = $this->fixClassName($this->className);
-
- return true;
+ public function setSuffix(string $classNameSuffix) {
+ if (!self::isValidClassName($classNameSuffix)) {
+ throw new \InvalidArgumentException(
+ "Invalid suffix '$classNameSuffix'. Suffix must be a valid class name."
+ );
}
- return false;
+ $this->suffix = $classNameSuffix;
+ $this->className = $this->fixClassName($this->className);
+ return $this;
}
/**
* Write the new class to a .php file.
From f54ed91850136e334b6092e27c797ace2cf6a63c Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Wed, 21 Jan 2026 00:02:02 +0300
Subject: [PATCH 31/32] feat: Code Reuse Helpers
---
WebFiori/Framework/Writers/ClassWriter.php | 108 +++++++++++++++++++++
1 file changed, 108 insertions(+)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index 28d958672..930592f10 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -354,6 +354,114 @@ private function formatAttributeValue($value) : string {
return (string)$value;
}
/**
+ * Write a standard constructor method.
+ *
+ * @param array $params Constructor parameters [name => type]
+ * @param string|array $body Constructor body (string or array of lines)
+ * @param string $description Optional docblock description
+ * @param int $indent Indentation level
+ */
+ protected function writeConstructor(
+ array $params = [],
+ $body = '',
+ string $description = 'Creates new instance of the class.',
+ int $indent = 1
+ ) {
+ $this->docblock($description)->build($indent);
+ $this->append($this->method('__construct', $params), $indent);
+
+ if (is_array($body)) {
+ $this->append($body, $indent + 1);
+ } else if ($body) {
+ $this->append($body, $indent + 1);
+ }
+
+ $this->append('}', $indent);
+ }
+ /**
+ * Write a standard getter method.
+ *
+ * @param string $property Property name
+ * @param string $type Return type
+ * @param string $description Optional description
+ * @param int $indent Indentation level
+ */
+ protected function writeGetter(
+ string $property,
+ string $type,
+ string $description = '',
+ int $indent = 1
+ ) {
+ $methodName = 'get' . ucfirst($property);
+
+ $this->docblock($description ?: "Returns the value of $property.")
+ ->returns($type)
+ ->build($indent);
+
+ $this->append($this->method($methodName, [], $type), $indent);
+ $this->append("return \$this->$property;", $indent + 1);
+ $this->append('}', $indent);
+ }
+ /**
+ * Write a standard setter method.
+ *
+ * @param string $property Property name
+ * @param string $type Parameter type
+ * @param string $description Optional description
+ * @param int $indent Indentation level
+ */
+ protected function writeSetter(
+ string $property,
+ string $type,
+ string $description = '',
+ int $indent = 1
+ ) {
+ $methodName = 'set' . ucfirst($property);
+
+ $this->docblock($description ?: "Sets the value of $property.")
+ ->param($type, $property)
+ ->returns('void')
+ ->build($indent);
+
+ $this->append($this->method($methodName, [$property => $type], 'void'), $indent);
+ $this->append("\$this->$property = \$$property;", $indent + 1);
+ $this->append('}', $indent);
+ }
+ /**
+ * Write both getter and setter for a property.
+ *
+ * @param string $property Property name
+ * @param string $type Property type
+ * @param int $indent Indentation level
+ */
+ protected function writeGetterSetter(string $property, string $type, int $indent = 1) {
+ $this->writeGetter($property, $type, '', $indent);
+ $this->writeSetter($property, $type, '', $indent);
+ }
+ /**
+ * Write an empty method stub with TODO comment.
+ *
+ * @param string $methodName Method name
+ * @param array $params Method parameters [name => type]
+ * @param string|null $returns Return type
+ * @param string $description Method description
+ * @param int $indent Indentation level
+ */
+ protected function writeMethodStub(
+ string $methodName,
+ array $params = [],
+ ?string $returns = null,
+ string $description = '',
+ int $indent = 1
+ ) {
+ if ($description) {
+ $this->docblock($description)->build($indent);
+ }
+
+ $this->append($this->method($methodName, $params, $returns), $indent);
+ $this->append('//TODO: Implement this method.', $indent + 1);
+ $this->append('}', $indent);
+ } /**
* Returns the absolute path of the class that will be created.
*
* @return string The absolute path of the file that holds class information.
From d3b24077f1652396226152581f043d8bc323c15a Mon Sep 17 00:00:00 2001
From: Ibrahim BinAlshikh
Date: Wed, 21 Jan 2026 00:13:32 +0300
Subject: [PATCH 32/32] feat: Chaining
---
WebFiori/Framework/Writers/ClassWriter.php | 38 +++++++++++++++-------
1 file changed, 27 insertions(+), 11 deletions(-)
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index 930592f10..29b694e32 100644
--- a/WebFiori/Framework/Writers/ClassWriter.php
+++ b/WebFiori/Framework/Writers/ClassWriter.php
@@ -102,6 +102,8 @@ public function addUseStatement($classesToUse) {
}
/**
* Appends a string or array of strings to the string that represents the
+
+ return $this;
* body of the class.
*
* @param string $strOrArr The string that will be appended. At the end of the string
@@ -116,12 +118,13 @@ public function append($strOrArr, $tabsCount = 0) {
if (gettype($strOrArr) != 'array') {
$this->a($strOrArr, $tabsCount);
- return;
+ return $this;
}
foreach ($strOrArr as $str) {
$this->a($str, $tabsCount);
}
+ return $this;
}
/**
* Adds method definition to the class.
@@ -152,7 +155,7 @@ public function f($funcName, $argsArr = [], ?string $returns = null) {
* @param bool $isAbstract Is abstract method
* @param bool $isFinal Is final method
*
- * @return string Method signature
+ * @return $this For chaining
*/
public function method(
string $funcName,
@@ -162,7 +165,7 @@ public function method(
bool $isStatic = false,
bool $isAbstract = false,
bool $isFinal = false
- ) : string {
+ ) {
$modifiers = [];
if ($isFinal) {
@@ -193,7 +196,8 @@ public function method(
$argsPart .= ' : ' . $returns;
}
- return $signature . $argsPart . ($isAbstract ? ';' : ' {');
+ $this->append($signature . $argsPart . ($isAbstract ? ';' : ' {'), 1);
+ return $this;
}
/**
* Generate a property declaration.
@@ -205,7 +209,9 @@ public function method(
* @param bool $isStatic Is static property
* @param bool $isReadonly Is readonly property (PHP 8.1+)
*
- * @return string Property declaration
+ * @param int|null $indent If provided, appends to class and returns $this for chaining
+ *
+ * @return string|$this Property declaration string, or $this if $indent is provided
*/
public function property(
string $name,
@@ -214,7 +220,7 @@ public function property(
?string $defaultValue = null,
bool $isStatic = false,
bool $isReadonly = false
- ) : string {
+ ) {
$modifiers = [$visibility];
if ($isReadonly) {
@@ -236,7 +242,8 @@ public function property(
$declaration .= ' = ' . $defaultValue;
}
- return $declaration . ';';
+ $this->append($declaration . ';', 1);
+ return $this;
}
/**
* Generate a constant declaration.
@@ -245,14 +252,24 @@ public function property(
* @param string $value Constant value as string
* @param string $visibility Visibility: 'public', 'protected', 'private'
*
- * @return string Constant declaration
+ * @return $this For chaining
*/
public function constant(
string $name,
string $value,
string $visibility = 'public'
- ) : string {
- return $visibility . ' const ' . $name . ' = ' . $value . ';';
+ ) {
+ $this->append($visibility . ' const ' . $name . ' = ' . $value . ';', 1);
+ return $this;
+ }
+ /**
+ * Add an empty line (fluent version).
+ *
+ * @return $this For chaining
+ */
+ public function addEmptyLine() {
+ $this->append('');
+ return $this;
}
/**
* Start building a docblock.
@@ -264,7 +281,6 @@ public function constant(
public function docblock(string $description = '') : DocblockBuilder {
return new DocblockBuilder($this, $description);
}
- /**
/**
* Add an attribute for a class.
*