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 = <<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(); - } -}