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 diff --git a/.github/workflows/php81.yaml b/.github/workflows/php81.yaml index 0f9bfadd2..83660001d 100644 --- a/.github/workflows/php81.yaml +++ b/.github/workflows/php81.yaml @@ -1,10 +1,15 @@ -name: Build PHP 8.1 +name: PHP 8.1 on: push: branches: [ main ] pull_request: branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest @@ -104,7 +109,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..df1fd71e0 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: @@ -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 @@ -105,7 +109,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..594ca1f56 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: @@ -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 @@ -105,7 +109,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..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 @@ -106,23 +110,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..5f2ba4d24 --- /dev/null +++ b/.github/workflows/php85.yml @@ -0,0 +1,132 @@ +name: PHP 8.5 + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +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:12.5.4, 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 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 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
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.
diff --git a/WebFiori/Framework/Cli/Commands/CreateCommand.php b/WebFiori/Framework/Cli/Commands/CreateCommand.php
index bc5c2d3d6..642a5e019 100644
--- a/WebFiori/Framework/Cli/Commands/CreateCommand.php
+++ b/WebFiori/Framework/Cli/Commands/CreateCommand.php
@@ -15,12 +15,17 @@
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\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;
@@ -82,11 +87,20 @@ 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();
+ } 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();
@@ -108,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();
@@ -127,13 +147,18 @@ 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['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.';
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/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/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");
+ }
+ }
+}
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/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");
+ }
+ }
+}
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;
+ }
+}
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) {
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().' {');
+ }
+}
diff --git a/WebFiori/Framework/Writers/ClassWriter.php b/WebFiori/Framework/Writers/ClassWriter.php
index ed7675442..29b694e32 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.
*
@@ -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.
@@ -139,24 +142,342 @@ 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 $this For chaining
+ */
+ public function method(
+ string $funcName,
+ array $argsArr = [],
+ ?string $returns = null,
+ string $visibility = 'public',
+ bool $isStatic = false,
+ bool $isAbstract = false,
+ bool $isFinal = false
+ ) {
+ $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.' {';
+
+ $this->append($signature . $argsPart . ($isAbstract ? ';' : ' {'), 1);
+ return $this;
+ }
+ /**
+ * 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+)
+ *
+ * @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,
+ string $visibility = 'private',
+ ?string $type = null,
+ ?string $defaultValue = null,
+ bool $isStatic = false,
+ bool $isReadonly = false
+ ) {
+ $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;
+ }
+
+ $this->append($declaration . ';', 1);
+ return $this;
+ }
+ /**
+ * Generate a constant declaration.
+ *
+ * @param string $name Constant name
+ * @param string $value Constant value as string
+ * @param string $visibility Visibility: 'public', 'protected', 'private'
+ *
+ * @return $this For chaining
+ */
+ public function constant(
+ string $name,
+ string $value,
+ string $visibility = 'public'
+ ) {
+ $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.
+ *
+ * @param string $description Main description
+ *
+ * @return DocblockBuilder
+ */
+ 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;
}
/**
+ * 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.
@@ -328,16 +649,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;
}
/**
@@ -352,11 +675,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.
@@ -366,15 +692,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.
@@ -384,15 +710,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.
@@ -405,16 +732,23 @@ public function setSuffix(string $classNameSuffix) : bool {
public function writeClass() {
$classFile = new File($this->getName().'.php', $this->getPath());
$classFile->remove();
- $this->classAsStr = '';
+ $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($this->classAsStr);
- $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.
*/
@@ -446,9 +780,25 @@ 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) {
+ 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 == '') {
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/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;
+ }
+}
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);
+ }
+}
diff --git a/WebFiori/Framework/Writers/RepositoryWriter.php b/WebFiori/Framework/Writers/RepositoryWriter.php
new file mode 100644
index 000000000..85611659c
--- /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);
+ }
+}
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'
+ };
+ }
+}
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);
+ }
+}
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);
+ }
+}
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/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);
+ }
+}
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 = <<