diff --git a/.github/workflows/php84.yaml b/.github/workflows/php84.yaml index 53f26da6..4b233b8a 100644 --- a/.github/workflows/php84.yaml +++ b/.github/workflows/php84.yaml @@ -100,29 +100,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: code-coverage - path: php-8.4-coverage.xml - - - code-coverage: - name: Coverage - needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main - with: - php-version: '8.1' - coverage-file: 'php-8.1-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 + path: php-8.4-coverage.xml \ No newline at end of file diff --git a/.github/workflows/php85.yaml b/.github/workflows/php85.yaml new file mode 100644 index 00000000..31ebd387 --- /dev/null +++ b/.github/workflows/php85.yaml @@ -0,0 +1,128 @@ +name: PHP 8.5 + +on: + push: + branches: [ main ] + pull_request: + branches: [ main , dev] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + SA_SQL_SERVER_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }} + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} + + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2019-latest + env: + SA_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }} + ACCEPT_EULA: Y + MSSQL_PID: Express + ports: + - "1433:1433" + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} + MYSQL_DATABASE: testing_db + MYSQL_ROOT_HOST: '%' + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + strategy: + fail-fast: true + + name: Run PHPUnit Tests + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: mysqli, mbstring, sqlsrv + tools: phpunit:11.5.27, composer + + - name: Install ODBC Driver for SQL Server + run: | + curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc + curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt update + sudo ACCEPT_EULA=Y apt install mssql-tools18 unixodbc-dev msodbcsql18 + + - name: Wait for SQL Server + run: | + for i in {1..12}; do + if /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'SELECT 1' -C > /dev/null 2>&1; then + echo "SQL Server is ready" + break + fi + echo "Waiting for SQL Server... ($i/12)" + sleep 10 + done + + - name: Create SQL Server Database + run: /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'create database testing_db' -C + + - name: Setup MySQL Client + run: | + sudo apt update + sudo apt install mysql-client-core-8.0 + + - name: Wait for MySQL + run: | + until mysqladmin ping -h 127.0.0.1 --silent; do + echo 'waiting for mysql...' + sleep 1 + done + + - name: Create MySQL Database + run: | + mysql -h 127.0.0.1 -u root -p${{ secrets.MYSQL_ROOT_PASSWORD }} -e "CREATE DATABASE IF NOT EXISTS testing_db;" + + - name: Install Dependencies + run: composer install --prefer-source --no-interaction + + - name: Execute Tests + run: phpunit --configuration=tests/phpunit10.xml --coverage-clover=clover.xml --stop-on-failure + + - 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@main + 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@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/LICENSE b/LICENSE index f18f60fb..a86e8ad0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 WebFiori Framework +Copyright (c) 2020-present WebFiori Framework Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f42c8e2d..a3b670f3 100644 --- a/README.md +++ b/README.md @@ -1,446 +1,398 @@ -# Webfiori Database Abstraction Layer - -Database abstraction layer of WebFiori framework. - -

- - PHP 8 Build Status - - - CodeCov - - - Quality Checks - - - Version - - - Downloads - -

- -## Content - -* [Supported PHP Versions](#supported-php-versions) -* [Supported Databases](#supported-databases) -* [Features](#features) -* [Installation](#installation) -* [Usage](#usage) - * [Connecting to Database](#connecting-to-database) - * [Running Basic SQL Queries](#running-basic-sql-queries) - * [Insert Query](#insert-query) - * [Select Query](#select-query) - * [Update Query](#update-query) - * [Delete Query](#delete-query) - * [Building Database Structure](#building-database-structure) - * [Creating Table Blueprint](#creating-table-blueprint) - * [Seeding Structure to Database](#seeding-structure-to-database) - * [Creating Entity Classes and Using Them](#creating-entity-classes-and-using-them) - * [Creating an Entity Class](#creating-an-entity-class) - * [Using Entity Class](#using-entity-class) - * [Database Migrations](#database-migrations) - * [Database Seeders](#database-seeders) - * [Performance Monitoring](#performance-monitoring) - * [Transactions](#transactions) - -## Supported PHP Versions -| Build Status | -|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| -| | -| | -| | -| | - -## Supported Databases -- MySQL -- MSSQL - -## Features -* Building your database structure within PHP -* Fast and easy to use query builder -* Database abstraction which makes it easy to migrate your system to different DBMS -* Database migrations and seeders -* Performance monitoring and query analysis -* Entity mapping for object-relational mapping -* Transaction support with automatic rollback - -## Installation -To install the library using composer, add following dependency to `composer.json`: `"webfiori/database":"*"` - -## Usage - -### Connecting to Database - -Connecting to a database is simple. First step is to define database connection information using the class `ConnectionInfo`. Later, the instance can be used to establish a connection to the database using the class `Database`. - -```php -use WebFiori\Database\ConnectionInfo; -use WebFiori\Database\Database; - -// This assumes that MySQL is installed on localhost -// and root password is set to '123456' -// and there is a schema with name 'testing_db' -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); -``` - -### Running Basic SQL Queries - -Most common SQL queries that will be executed in any relational DBMS are insert, select, update, and delete. Following examples shows how the 4 types can be constructed. - -For every query, the table that the query will be executed on must be specified. To specify the table, use the method `Database::table(string $tblName)`. The method will return an instance of the class `AbstractQuery`. The class `AbstractQuery` has many methods which are used to further build the query. Commonly used methods include the following: - -* `AbstractQuery::insert(array $cols)`: Construct an insert query. -* `AbstractQuery::select(array $cols)`: Construct a select query. -* `AbstractQuery::update(array $cols)`: Construct an update query. -* `AbstractQuery::delete()`: Construct a delete query. -* `AbstractQuery::where($col, $val)`: Adds a condition to the query. - -After building the query, the method `AbstractQuery::execute()` can be called to execute the query. If the query is a `select` query, the method will return an instance of the class `ResultSet`. The instance can be used to traverse the records that was returned by the DBMS. - -#### Insert Query - -Insert query is used to add records to the database. To execute an insert query, use the method `AbstractQuery::insert(array $cols)`. The method accepts one parameter. The parameter is an associative array. The indices of the array are columns names and the values of the indices are the values that will be inserted. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->insert([ - 'title' => 'Super New Post', - 'author' => 'Me' -])->execute(); -``` - -#### Select Query - -A select query is used to fetch database records and use them in application logic. To execute a select query, use the method `AbstractQuery::select(array $cols)`. The method accepts one optional parameter. The parameter is an array that holds the names of the columns that will be selected. In this case, the method `AbstractQuery::execute()` will return an object of type `ResultSet`. The result set will contain raw fetched records as big array that holds the actual records. Each record is stored as an associative array. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -// This assumes that we have a table called 'posts' in the database. -$resultSet = $database->table('posts')->select()->execute(); - -foreach ($resultSet as $record) { - echo $record['title']; -} -``` - -It is possible to add a condition to the select query using the method `AbstractQuery::where()`. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -// This assumes that we have a table called 'posts' in the database. -$resultSet = $database->table('posts') - ->select() - ->where('author', 'Ibrahim') - ->execute(); - -foreach ($resultSet as $record) { - echo $record['title']; -} -``` - -#### Update Query - -Update query is used to update a single record or multiple records. To execute an update query, use the method `AbstractQuery::update(array $cols)`. The method accepts one parameter. The parameter is an associative array. The indices of the array are columns names and the values of the indices are the updated values. Usually, for any update query, a `where` condition will be included. To include a `where` condition, the method `AbstractQuery::where()` can be used. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->update([ - 'title' => 'Super New Post By Ibrahim', -])->where('author', 'Ibrahim') - ->andWhere('created-on', '2023-03-24') - ->execute(); -``` - -#### Delete Query - -This query is used to delete specific record from the database. To execute delete query, use the method `AbstractQuery::delete()`. A `where` condition should be included to delete specific record. To include a `where` condition, the method `AbstractQuery::where()` can be used. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->delete()->where('author', 'Ibrahim')->execute(); -``` - -### Building Database Structure - -One of the features of the library is the ability to define database structure in the source code and later, seed the created structure to create database tables. The blueprint of tables are represented by the class `Table`. The main aim of the blueprint is to make sure that data types in database are represented correctly in the source code. - -#### Creating Table Blueprint - -Each blueprint must have following attributes defined: - -* Name of the blueprint (database table name). -* Columns and their properties such as data type. -* Any relations with other tables. - -The method `Database::createBlueprint()` is used to create a table based on connected DBMS. The method will return an instance of the class `Table` which can be used to further customize the blueprint. - -```php -use WebFiori\Database\ColOption; -use WebFiori\Database\DataType; - -$database->createBlueprint('users_information')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 5, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'first-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15 - ], - 'last-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15 - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 128 - ] -]); -``` - -#### Seeding Structure to Database - -After creating all blueprints, a query must be structured and executed to create database tables. Building the query can be performed using the method `Database::createTables()`. After calling this method, the method `Database::execute()` must be called to create all database tables. - -```php -// Build the query -$database->createTables(); - -// Execute -$database->execute(); -``` - -### Creating Entity Classes and Using Them - -Entity classes are classes which are based on blueprints (or tables). They can be used to map records of tables to objects. Every blueprint will have an instance of the class `EntityMapper` which can be used to create an entity class. - -Entity classes that are generated using the class `EntityMapper` are special. They will have one static method with name `map()` which can automatically map a record to an instance of the entity. - -#### Creating an Entity Class - -First step in creating an entity is to have the blueprint at which the entity will be based on. From the blueprint, an instance of the class `EntityMapper` is generated. After having the instance, the properties of the entity is set such as its name, namespace and where it will be created. Finally, the method `EntityMapper::create()` can be invoked to write the source code of the class. - -```php -$blueprint = $database->getTable('users_information'); - -// Get entity mapper -$entityMapper = $blueprint->getEntityMapper(); - -// Set properties of the entity -$entityMapper->setEntityName('UserInformation'); -$entityMapper->setNamespace(''); -$entityMapper->setPath(__DIR__); - -// Create the entity. The output will be the class 'UserInformation'. -$entityMapper->create(); -``` - -#### Using Entity Class - -Entity class can be used to map a record to an object. Each entity will have a special method called `map()`. The method accepts a single parameter which is an associative array that represents fetched record. - -The result set instance has one of array methods which is called `map($callback)` This method acts exactly as the function `array_map($callback, $array)`. The return value of the method is another result set with mapped records. - -```php -$resultSet = $database->table('users_information') - ->select() - ->execute(); - -$mappedSet = $resultSet->map(function (array $record) { - return UserInformation::map($record); -}); - -foreach ($mappedSet as $record) { - // $record is an object of type UserInformation - echo $record->getFirstName() . ' ' . $record->getLastName() . "\n"; -} -``` - -### Database Migrations - -Migrations allow you to version control your database schema changes. Each migration represents a specific change to your database structure. - -```php -use WebFiori\Database\Schema\AbstractMigration; -use WebFiori\Database\Database; - -class CreateUsersTable extends AbstractMigration { - - public function up(Database $db): void { - $db->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150 - ] - ]); - - $db->createTables(); - $db->execute(); - } - - public function down(Database $db): void { - $db->setQuery("DROP TABLE users"); - $db->execute(); - } -} -``` - -To run migrations, use the SchemaRunner: - -```php -use WebFiori\Database\Schema\SchemaRunner; - -$runner = new SchemaRunner($connectionInfo); - -// Register migration classes -$runner->register('CreateUsersTable'); -$runner->register('AddEmailIndex'); - -// Create schema tracking table -$runner->createSchemaTable(); - -// Apply all pending migrations -$appliedMigrations = $runner->apply(); - -// Rollback migrations -$rolledBackMigrations = $runner->rollbackUpTo(null); -``` - -### Database Seeders - -Seeders allow you to populate your database with sample or default data. - -```php -use WebFiori\Database\Schema\AbstractSeeder; -use WebFiori\Database\Database; - -class UsersSeeder extends AbstractSeeder { - - public function run(Database $db): void { - $db->table('users')->insert([ - 'name' => 'Administrator', - 'email' => 'admin@example.com' - ])->execute(); - - $db->table('users')->insert([ - 'name' => 'John Doe', - 'email' => 'john@example.com' - ])->execute(); - } -} -``` - -To run seeders, use the same SchemaRunner: - -```php -use WebFiori\Database\Schema\SchemaRunner; - -$runner = new SchemaRunner($connectionInfo); - -// Register seeder classes -$runner->register('UsersSeeder'); -$runner->register('CategoriesSeeder'); - -// Create schema tracking table -$runner->createSchemaTable(); - -// Apply all pending seeders -$appliedSeeders = $runner->apply(); -``` - -### Performance Monitoring - -The library includes built-in performance monitoring to help you identify slow queries and optimize database performance. - -```php -use WebFiori\Database\Performance\PerformanceOption; -use WebFiori\Database\Performance\PerformanceAnalyzer; - -// Configure performance monitoring -$database->setPerformanceConfig([ - PerformanceOption::ENABLED => true, - PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold - PerformanceOption::SAMPLING_RATE => 1.0 // Monitor all queries -]); - -// Execute some queries -$database->table('users')->select()->execute(); -$database->table('posts')->select()->where('status', 'published')->execute(); - -// Analyze performance -$analyzer = $database->getPerformanceMonitor()->getAnalyzer(); - -echo "Total queries: " . $analyzer->getQueryCount() . "\n"; -echo "Average execution time: " . $analyzer->getAverageTime() . "ms\n"; -echo "Performance score: " . $analyzer->getScore() . "\n"; -echo "Slow queries: " . $analyzer->getSlowQueryCount() . "\n"; - -// Check if performance needs improvement -if ($analyzer->getScore() === PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT) { - $slowQueries = $analyzer->getSlowQueries(); - foreach ($slowQueries as $metric) { - echo "Slow query: " . $metric->getQuery() . " (" . $metric->getExecutionTimeMs() . "ms)\n"; - } -} -``` - -### Transactions - -Database transactions ensure that multiple operations are executed as a single unit of work. If any operation fails, all operations are rolled back. - -```php -$database->transaction(function (Database $db) { - // Insert user - $db->table('users')->insert([ - 'name' => 'John Doe', - 'email' => 'john@example.com' - ])->execute(); - - // Insert user profile - $db->table('user_profiles')->insert([ - 'user_id' => $db->getLastInsertId(), - 'bio' => 'Software Developer' - ])->execute(); - - // If any query fails, the entire transaction is rolled back -}); -``` - -You can also pass additional parameters to the transaction closure: - -```php -$userData = ['name' => 'Jane Doe', 'email' => 'jane@example.com']; - -$database->transaction(function (Database $db, array $user) { - $db->table('users')->insert($user)->execute(); - - $db->table('user_profiles')->insert([ - 'user_id' => $db->getLastInsertId(), - 'created_at' => date('Y-m-d H:i:s') - ])->execute(); - -}, [$userData]); -``` +# Webfiori Database Abstraction Layer + +Database abstraction layer of WebFiori framework. + +

+ + PHP 8 Build Status + + + CodeCov + + + Quality Checks + + + Version + + + Downloads + +

+ +## Content + +* [Supported PHP Versions](#supported-php-versions) +* [Supported Databases](#supported-databases) +* [Features](#features) +* [Installation](#installation) +* [Usage](#usage) + * [Connecting to Database](#connecting-to-database) + * [Running Basic SQL Queries](#running-basic-sql-queries) + * [Building Database Structure](#building-database-structure) + * [Repository Pattern](#repository-pattern) + * [Active Record Pattern](#active-record-pattern) + * [Entity Generation](#entity-generation) + * [Database Migrations](#database-migrations) + * [Database Seeders](#database-seeders) + * [Performance Monitoring](#performance-monitoring) + * [Transactions](#transactions) + +## Supported PHP Versions +| Build Status | +|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| | +| | +| | +| | +| | + +## Supported Databases +- MySQL +- MSSQL + +## Features +* Building your database structure within PHP +* Fast and easy to use query builder +* Database abstraction which makes it easy to migrate your system to different DBMS +* Repository pattern with `AbstractRepository` for clean data access +* Active Record pattern support for rapid development +* PHP 8 attributes for table definitions +* Database migrations and seeders +* Performance monitoring and query analysis +* Entity generation for object-relational mapping +* Transaction support with automatic rollback + +## Installation +To install the library using composer, add following dependency to `composer.json`: `"webfiori/database":"*"` + +## Usage + +### Connecting to Database + +Connecting to a database is simple. First step is to define database connection information using the class `ConnectionInfo`. Later, the instance can be used to establish a connection to the database using the class `Database`. + +```php +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\Database; + +$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); +$database = new Database($connection); +``` + +### Running Basic SQL Queries + +For every query, the table must be specified using `Database::table(string $tblName)`. The method returns an `AbstractQuery` instance with methods for building queries: + +* `insert(array $cols)`: Construct an insert query. +* `select(array $cols)`: Construct a select query. +* `update(array $cols)`: Construct an update query. +* `delete()`: Construct a delete query. +* `where($col, $val)`: Adds a condition to the query. + +After building the query, call `execute()` to run it. + +```php +// Insert +$database->table('posts')->insert([ + 'title' => 'Super New Post', + 'author' => 'Me' +])->execute(); + +// Select +$resultSet = $database->table('posts') + ->select() + ->where('author', 'Ibrahim') + ->execute(); + +foreach ($resultSet as $record) { + echo $record['title']; +} + +// Update +$database->table('posts')->update([ + 'title' => 'Updated Title', +])->where('id', 1)->execute(); + +// Delete +$database->table('posts')->delete()->where('id', 1)->execute(); +``` + +### Building Database Structure + +Define database structure in PHP code using blueprints: + +```php +use WebFiori\Database\ColOption; +use WebFiori\Database\DataType; + +$database->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150 + ] +]); + +// Create the table +$database->table('users')->createTable()->execute(); +``` + +### Repository Pattern + +The `AbstractRepository` class provides a clean way to handle data access with separation between entities and database logic. + +#### Creating an Entity + +```php +class Product { + public ?int $id = null; + public string $name; + public float $price; +} +``` + +#### Creating a Repository + +```php +use WebFiori\Database\Repository\AbstractRepository; + +class ProductRepository extends AbstractRepository { + protected function getTableName(): string { + return 'products'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + $product = new Product(); + $product->id = (int) $row['id']; + $product->name = $row['name']; + $product->price = (float) $row['price']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'price' => $entity->price + ]; + } +} +``` + +#### Using the Repository + +```php +$repo = new ProductRepository($database); + +// Create +$product = new Product(); +$product->name = 'Widget'; +$product->price = 29.99; +$repo->save($product); + +// Read +$product = $repo->findById(1); +$allProducts = $repo->findAll(); + +// Update +$product->price = 24.99; +$repo->save($product); + +// Delete +$repo->deleteById(1); + +// Pagination +$page = $repo->paginate(page: 1, perPage: 20); +``` + +### Active Record Pattern + +For rapid development, you can merge entity and repository into a single model class: + +```php +use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\Table; +use WebFiori\Database\DataType; +use WebFiori\Database\Repository\AbstractRepository; + +#[Table(name: 'articles')] +class Article extends AbstractRepository { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title = ''; + + #[Column(type: DataType::TEXT)] + public string $content = ''; + + protected function getTableName(): string { return 'articles'; } + protected function getIdField(): string { return 'id'; } + + protected function toEntity(array $row): object { + $article = new self($this->db); + $article->id = (int) $row['id']; + $article->title = $row['title']; + $article->content = $row['content']; + return $article; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'title' => $entity->title, + 'content' => $entity->content + ]; + } +} +``` + +Usage: + +```php +// Create and save +$article = new Article($database); +$article->title = 'Hello World'; +$article->content = 'My first article'; +$article->save(); + +// Query +$all = $article->findAll(); +$one = $article->findById(1); + +// Update +$article->title = 'Updated Title'; +$article->save(); + +// Delete +$article->deleteById(); + +// Reload from database +$fresh = $article->reload(); +``` + +### Entity Generation + +Generate entity classes from table blueprints: + +```php +$blueprint = $database->getTable('users'); + +$generator = $blueprint->getEntityGenerator('User', __DIR__, 'App\\Entity'); +$generator->generate(); +``` + +### Database Migrations + +Version control your database schema changes: + +```php +use WebFiori\Database\Schema\AbstractMigration; + +class CreateUsersTable extends AbstractMigration { + public function up(Database $db): void { + $db->createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100] + ]); + $db->table('users')->createTable()->execute(); + } + + public function down(Database $db): void { + $db->raw("DROP TABLE users")->execute(); + } +} +``` + +Run migrations: + +```php +use WebFiori\Database\Schema\SchemaRunner; + +$runner = new SchemaRunner($connectionInfo); +$runner->discoverFromPath(__DIR__ . '/migrations', 'App\\Migrations'); +$runner->createSchemaTable(); +$runner->apply(); +``` + +### Database Seeders + +Populate your database with sample data: + +```php +use WebFiori\Database\Schema\AbstractSeeder; + +class UsersSeeder extends AbstractSeeder { + public function run(Database $db): void { + $db->table('users')->insert([ + 'name' => 'Administrator', + 'email' => 'admin@example.com' + ])->execute(); + } +} +``` + +### Performance Monitoring + +Track and analyze query performance: + +```php +use WebFiori\Database\Performance\PerformanceOption; + +$database->setPerformanceConfig([ + PerformanceOption::ENABLED => true, + PerformanceOption::SLOW_QUERY_THRESHOLD => 50 +]); + +// Execute queries... + +$analyzer = $database->getPerformanceMonitor()->getAnalyzer(); +echo "Total queries: " . $analyzer->getQueryCount(); +echo "Slow queries: " . $analyzer->getSlowQueryCount(); +``` + +### Transactions + +Execute multiple operations as a single unit: + +```php +$database->transaction(function (Database $db) { + $db->table('users')->insert(['name' => 'John'])->execute(); + $db->table('profiles')->insert([ + 'user_id' => $db->getLastInsertId(), + 'bio' => 'Developer' + ])->execute(); +}); +``` + +## Examples + +See the [examples](examples/) directory for complete working examples: + +- [01-basic-connection](examples/01-basic-connection/) - Database connections +- [02-basic-queries](examples/02-basic-queries/) - CRUD operations +- [03-table-blueprints](examples/03-table-blueprints/) - Table structures +- [04-entity-mapping](examples/04-entity-mapping/) - Entity generation +- [05-transactions](examples/05-transactions/) - Transaction handling +- [06-migrations](examples/06-migrations/) - Schema migrations +- [07-seeders](examples/07-seeders/) - Data seeding +- [08-performance-monitoring](examples/08-performance-monitoring/) - Query analysis +- [09-multi-result-queries](examples/09-multi-result-queries/) - Stored procedures +- [10-attribute-based-tables](examples/10-attribute-based-tables/) - PHP 8 attributes +- [11-repository-pattern](examples/11-repository-pattern/) - Repository pattern +- [12-clean-architecture](examples/12-clean-architecture/) - Domain separation +- [13-pagination](examples/13-pagination/) - Pagination techniques +- [14-active-record-model](examples/14-active-record-model/) - Active Record pattern diff --git a/WebFiori/Database/AbstractQuery.php b/WebFiori/Database/AbstractQuery.php index c023db17..e225d7a4 100644 --- a/WebFiori/Database/AbstractQuery.php +++ b/WebFiori/Database/AbstractQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -14,6 +14,9 @@ use Throwable; use WebFiori\Database\MsSql\MSSQLQuery; use WebFiori\Database\MySql\MySQLQuery; +use WebFiori\Database\Query\Condition; +use WebFiori\Database\Query\Expression; +use WebFiori\Database\Query\InsertBuilder; /** * A base class that can be used to build SQL queries. * diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php new file mode 100644 index 00000000..fc8920b4 --- /dev/null +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -0,0 +1,151 @@ +getAttributes(Table::class)[0] ?? null; + if (!$tableAttr) { + throw new InvalidAttributeException("Class $entityClass must have #[Table] attribute"); + } + + $tableConfig = $tableAttr->newInstance(); + $columns = []; + $foreignKeys = []; + + $classColumnAttrs = $reflection->getAttributes(Column::class); + + if (!empty($classColumnAttrs)) { + foreach ($classColumnAttrs as $columnAttr) { + $columnConfig = $columnAttr->newInstance(); + $columnKey = $columnConfig->name ?? throw new InvalidAttributeException("Column name is required for class-level attributes"); + $columns[$columnKey] = self::columnConfigToArray($columnConfig); + } + + foreach ($reflection->getAttributes(ForeignKey::class) as $fkAttr) { + $foreignKeys[] = $fkAttr->newInstance(); + } + } else { + foreach ($reflection->getProperties() as $property) { + $columnAttrs = $property->getAttributes(Column::class); + if (empty($columnAttrs)) { + continue; + } + + $columnConfig = $columnAttrs[0]->newInstance(); + $columnKey = self::propertyToKey($property->getName()); + $columns[$columnKey] = self::columnConfigToArray($columnConfig); + + foreach ($property->getAttributes(ForeignKey::class) as $fkAttr) { + $fk = $fkAttr->newInstance(); + // For property-level FK, the local column is the property itself + $foreignKeys[] = ['localColumn' => $columnKey, 'config' => $fk]; + } + } + } + + $table = TableFactory::create($dbType, $tableConfig->name, $columns); + + if ($tableConfig->comment) { + $table->setComment($tableConfig->comment); + } + + // Add foreign keys + foreach ($foreignKeys as $fk) { + if ($fk instanceof ForeignKey) { + // Class-level FK + self::addForeignKey($table, $fk, $dbType); + } else { + // Property-level FK + self::addPropertyForeignKey($table, $fk['localColumn'], $fk['config'], $dbType); + } + } + + return $table; + } + + private static function addForeignKey(TableClass $table, ForeignKey $fk, string $dbType): void { + $refTableName = self::resolveTableName($fk->table); + $columnsMap = $fk->getColumnsMap(); + + // Build reference columns for the referenced table + $refColumns = []; + $mapping = []; + + foreach ($columnsMap as $local => $ref) { + if (is_int($local)) { + // Simple array ['col1', 'col2'] - same name on both sides + $local = $ref; + } + $refColumns[$ref] = [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true]; + $mapping[$local] = $ref; + } + + $refTable = TableFactory::create($dbType, $refTableName, $refColumns); + + $table->addReference( + $refTable, + $mapping, + $fk->name ?? 'fk_'.implode('_', array_keys($mapping)), + $fk->onUpdate, + $fk->onDelete + ); + } + + private static function addPropertyForeignKey(TableClass $table, string $localColumn, ForeignKey $fk, string $dbType): void { + $refTableName = self::resolveTableName($fk->table); + $refColName = $fk->column ?? $localColumn; + + $refTable = TableFactory::create($dbType, $refTableName, [ + $refColName => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + + $table->addReference( + $refTable, + [$localColumn => $refColName], + $fk->name ?? 'fk_'.$localColumn, + $fk->onUpdate, + $fk->onDelete + ); + } + + private static function resolveTableName(string $tableOrClass): string { + if (class_exists($tableOrClass)) { + $reflection = new ReflectionClass($tableOrClass); + $tableAttr = $reflection->getAttributes(Table::class)[0] ?? null; + if ($tableAttr) { + return $tableAttr->newInstance()->name; + } + } + return $tableOrClass; + } + + private static function columnConfigToArray(Column $config): array { + return [ + ColOption::TYPE => $config->type, + ColOption::NAME => $config->name, + ColOption::SIZE => $config->size, + ColOption::SCALE => $config->scale, + ColOption::PRIMARY => $config->primary, + ColOption::UNIQUE => $config->unique, + ColOption::NULL => $config->nullable, + ColOption::AUTO_INCREMENT => $config->autoIncrement, + ColOption::IDENTITY => $config->identity, + ColOption::AUTO_UPDATE => $config->autoUpdate, + ColOption::DEFAULT => $config->default, + ColOption::COMMENT => $config->comment, + ColOption::VALIDATOR => $config->callback + ]; + } + + private static function propertyToKey(string $propertyName): string { + return strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $propertyName)); + } +} diff --git a/WebFiori/Database/Attributes/Column.php b/WebFiori/Database/Attributes/Column.php new file mode 100644 index 00000000..462f0243 --- /dev/null +++ b/WebFiori/Database/Attributes/Column.php @@ -0,0 +1,24 @@ + 'refCol'] + */ + public function getColumnsMap(): array { + if ($this->column !== null) { + return [$this->column]; + } + return $this->columns; + } +} diff --git a/WebFiori/Database/Attributes/InvalidAttributeException.php b/WebFiori/Database/Attributes/InvalidAttributeException.php new file mode 100644 index 00000000..f93052b6 --- /dev/null +++ b/WebFiori/Database/Attributes/InvalidAttributeException.php @@ -0,0 +1,8 @@ +getDatatype() == 'char') { - return 'string'; - } - - return 'mixed'; + return DataType::toPHPType($this->getDatatype()); } /** * Returns the previous table which was owns the column. diff --git a/WebFiori/Database/Connection.php b/WebFiori/Database/Connection.php index 83e8bd8a..18e5bb71 100644 --- a/WebFiori/Database/Connection.php +++ b/WebFiori/Database/Connection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ConnectionInfo.php b/WebFiori/Database/ConnectionInfo.php index 0eb16ff8..a802daf7 100644 --- a/WebFiori/Database/ConnectionInfo.php +++ b/WebFiori/Database/ConnectionInfo.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DataType.php b/WebFiori/Database/DataType.php index 95b9a94d..18adf88d 100644 --- a/WebFiori/Database/DataType.php +++ b/WebFiori/Database/DataType.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2024 Ibrahim BinAlshikh + * Copyright (c) 2024-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -204,4 +204,19 @@ class DataType { * */ const VARCHAR = 'varchar'; + + /** + * Maps database data type to PHP type. + * + * @param string $dbType The database data type + * @return string The PHP type (int, float, bool, string) + */ + public static function toPHPType(string $dbType): string { + return match (strtolower($dbType)) { + self::INT, self::BIGINT => 'int', + self::FLOAT, self::DOUBLE, self::DECIMAL, self::MONEY => 'float', + self::BOOL, self::BIT => 'bool', + default => 'string' + }; + } } diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index 407d4a71..2d1bcf5d 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -12,6 +12,7 @@ namespace WebFiori\Database; use Exception; +use WebFiori\Database\Factory\TableFactory; use WebFiori\Database\MsSql\MSSQLConnection; use WebFiori\Database\MsSql\MSSQLQuery; use WebFiori\Database\MsSql\MSSQLTable; @@ -30,6 +31,12 @@ * */ class Database { + /** + * Queries captured during dry-run mode. + * + * @var array + */ + private array $capturedQueries = []; /** * The connection which is used to connect to the database. * @@ -46,6 +53,12 @@ class Database { * */ private $connectionInfo; + /** + * Whether dry-run mode is enabled. + * + * @var bool + */ + private bool $dryRun = false; private $lastErr; /** * Whether performance monitoring is enabled. @@ -223,13 +236,8 @@ public function clearPerformanceMetrics(): void { * and so on. */ public function createBlueprint(string $name) : Table { - $connection = $this->getConnection(); - - if ($connection === null) { - $dbType = 'mysql'; - } else { - $dbType = $connection->getConnectionInfo()->getDatabaseType(); - } + $connInfo = $this->getConnectionInfo(); + $dbType = $connInfo !== null ? $connInfo->getDatabaseType() : 'mysql'; if ($dbType == 'mssql') { $blueprint = new MSSQLTable($name); @@ -341,9 +349,20 @@ public function enablePerformanceMonitoring(): void { * */ public function execute() { - $conn = $this->getConnection(); $lastQuery = $this->getLastQuery(); + // Dry-run mode: capture query without executing + if ($this->dryRun) { + $this->capturedQueries[] = $lastQuery; + $this->queries[] = $lastQuery; + $this->clear(); + $this->getQueryGenerator()->setQuery(null); + + return new ResultSet([]); + } + + $conn = $this->getConnection(); + // Start performance monitoring $startTime = $this->performanceEnabled ? microtime(true) : null; @@ -353,7 +372,7 @@ public function execute() { $this->queries[] = $lastQuery; $this->clear(); $resultSet = $this->getLastResultSet(); - + $this->getQueryGenerator()->setQuery(null); // Record performance metrics @@ -364,6 +383,14 @@ public function execute() { return $resultSet; } + /** + * Get queries captured during dry-run mode. + * + * @return array Array of SQL query strings captured during dry-run. + */ + public function getCapturedQueries(): array { + return $this->capturedQueries; + } /** * Returns the connection at which the instance will use to run SQL queries. * @@ -523,7 +550,20 @@ public function getQueries() : array { * */ public function getQueryGenerator() : AbstractQuery { - if (!$this->isConnected()) { + // In dry-run mode, create query generator without connection + if ($this->dryRun && $this->queryGenerator === null) { + $connInfo = $this->getConnectionInfo(); + $dbType = $connInfo !== null ? $connInfo->getDatabaseType() : 'mysql'; + + if ($dbType == 'mssql') { + $this->queryGenerator = new MSSQLQuery(); + } else { + $this->queryGenerator = new MySQLQuery(); + } + $this->queryGenerator->setSchema($this); + } + + if ($this->queryGenerator === null && !$this->isConnected()) { if ($this->getConnectionInfo() === null) { throw new DatabaseException("Connection information not set."); } else { @@ -641,6 +681,14 @@ public function isConnected() : bool { return true; } + /** + * Check if dry-run mode is enabled. + * + * @return bool True if dry-run mode is enabled, false otherwise. + */ + public function isDryRun(): bool { + return $this->dryRun; + } /** * Sets the number of records that will be fetched by the query. * @@ -714,6 +762,27 @@ public function orWhere(string $col, mixed $val = null, string $cond = '=') : Ab public function page(int $num, int $itemsCount) : AbstractQuery { return $this->getQueryGenerator()->page($num, $itemsCount); } + /** + * Sets the database query to a raw SQL query. + * + * @param string $query A string that represents the query. + * + * @return Database The method will return the same instance at which the + * method is called on. + * + * @throws DatabaseException + */ + public function raw(string $query, array $params = []) : Database { + $t = $this->getQueryGenerator()->getTable(); + + if ($t !== null) { + $t->getSelect()->clear(); + } + $this->getQueryGenerator()->setQuery($query); + $this->getQueryGenerator()->setBindings($params); + + return $this; + } /** * Reset the bindings which was set by building and executing a query. * @@ -775,6 +844,21 @@ public function setConnectionInfo(ConnectionInfo $info) { } $this->connectionInfo = $info; } + /** + * Enable or disable dry-run mode. + * + * When dry-run mode is enabled, queries are captured but not executed. + * This is useful for previewing what SQL would be generated. + * + * @param bool $dryRun True to enable dry-run mode, false to disable. + */ + public function setDryRun(bool $dryRun): void { + $this->dryRun = $dryRun; + + if ($dryRun) { + $this->capturedQueries = []; + } + } /** * Configure performance monitoring settings. @@ -804,29 +888,8 @@ public function setPerformanceConfig(array $config): void { * @throws DatabaseException */ public function setQuery(string $query, array $params = []) : Database { - return $this->raw($query, $params); } - /** - * Sets the database query to a raw SQL query. - * - * @param string $query A string that represents the query. - * - * @return Database The method will return the same instance at which the - * method is called on. - * - * @throws DatabaseException - */ - public function raw(string $query, array $params = []) : Database { - $t = $this->getQueryGenerator()->getTable(); - - if ($t !== null) { - $t->getSelect()->clear(); - } - $this->getQueryGenerator()->setQuery($query); - $this->getQueryGenerator()->setBindings($params); - return $this; - } /** * Select one of the tables which exist on the schema and use it to build * SQL queries. diff --git a/WebFiori/Database/DatabaseException.php b/WebFiori/Database/DatabaseException.php index f91a17df..f2a6f65c 100644 --- a/WebFiori/Database/DatabaseException.php +++ b/WebFiori/Database/DatabaseException.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Entity/EntityGenerator.php b/WebFiori/Database/Entity/EntityGenerator.php new file mode 100644 index 00000000..ece65f11 --- /dev/null +++ b/WebFiori/Database/Entity/EntityGenerator.php @@ -0,0 +1,198 @@ +table = $table; + $this->entityName = $entityName; + $this->path = rtrim($path, '/\\'); + $this->namespace = trim($namespace, '\\'); + } + + /** + * Generates the entity class file. + * + * @return bool True on success, false on failure + */ + public function generate(): bool { + $code = $this->buildClass(); + $filePath = $this->path.DIRECTORY_SEPARATOR.$this->entityName.'.php'; + + return file_put_contents($filePath, $code) !== false; + } + + /** + * Builds the complete class code. + * + * @return string The generated PHP code + */ + private function buildClass(): string { + $code = "namespace) { + $code .= "namespace {$this->namespace};\n\n"; + } + + $code .= "/**\n"; + $code .= " * Auto-generated immutable entity for table '{$this->table->getName()}'\n"; + $code .= " * \n"; + $code .= " * Generated on: ".date('Y-m-d H:i:s')."\n"; + $code .= " * \n"; + $code .= " * This entity uses:\n"; + $code .= " * - Protected properties (extensible)\n"; + $code .= " * - Named arguments (PHP 8+)\n"; + $code .= " * - Immutable (no setters)\n"; + $code .= " */\n"; + $code .= "class {$this->entityName} {\n"; + + $code .= $this->buildConstructor(); + $code .= $this->buildGetters(); + + $code .= "}\n"; + + return $code; + } + + /** + * Builds the constructor with promoted properties. + * + * @return string The constructor code + */ + private function buildConstructor(): string { + $code = " public function __construct(\n"; + $params = []; + + foreach ($this->table->getCols() as $key => $col) { + $phpType = $col->getPHPType(); + $propName = $this->toCamelCase($key); + $nullable = $this->isNullable($col) ? '?' : ''; + $default = $this->getDefault($col); + + $params[] = " protected {$nullable}{$phpType} \${$propName}{$default}"; + } + + $code .= implode(",\n", $params); + $code .= "\n ) {}\n\n"; + + return $code; + } + + /** + * Builds getter methods for all properties. + * + * @return string The getter methods code + */ + private function buildGetters(): string { + $code = ''; + + foreach ($this->table->getCols() as $key => $col) { + $phpType = $col->getPHPType(); + $propName = $this->toCamelCase($key); + $methodName = 'get'.ucfirst($propName); + $nullable = $this->isNullable($col) ? '?' : ''; + + $code .= " public function {$methodName}(): {$nullable}{$phpType} {\n"; + $code .= " return \$this->{$propName};\n"; + $code .= " }\n\n"; + } + + return $code; + } + + /** + * Gets the default value for a property. + * + * @param Column $col The column to get default for + * @return string The default value as PHP code + */ + private function getDefault(Column $col): string { + if ($col->isAutoInc() || $col->isNull()) { + return ' = null'; + } + + $phpType = $col->getPHPType(); + $default = $col->getDefault(); + + $typeDefaults = [ + 'string' => " = ''", + 'int' => ' = 0', + 'float' => ' = 0.0', + 'bool' => ' = false' + ]; + + if ($default !== null) { + return match ($phpType) { + 'string' => " = '" . addslashes($default) . "'", + 'int', 'float' => " = {$default}", + 'bool' => $default ? ' = true' : ' = false', + default => '' + }; + } + + return $typeDefaults[$phpType] ?? ''; + } + + /** + * Checks if column should be nullable in PHP. + * + * @param Column $col The column to check + * @return bool True if nullable, false otherwise + */ + private function isNullable(Column $col): bool { + return $col->isNull() || $col->isAutoInc(); + } + + /** + * Converts kebab-case to camelCase. + * + * @param string $key The kebab-case string + * @return string The camelCase string + */ + private function toCamelCase(string $key): string { + $parts = explode('-', $key); + $camelCase = array_shift($parts); + + foreach ($parts as $part) { + $camelCase .= ucfirst($part); + } + + return $camelCase; + } +} diff --git a/WebFiori/Database/EntityMapper.php b/WebFiori/Database/Entity/EntityMapper.php similarity index 97% rename from WebFiori/Database/EntityMapper.php rename to WebFiori/Database/Entity/EntityMapper.php index f7b57497..e52dd960 100644 --- a/WebFiori/Database/EntityMapper.php +++ b/WebFiori/Database/Entity/EntityMapper.php @@ -3,15 +3,17 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Entity; use InvalidArgumentException; +use WebFiori\Database\Column; +use WebFiori\Database\Table; use WebFiori\Json\Json; use WebFiori\Json\JsonI; /** @@ -23,6 +25,13 @@ * - Static mapping method for converting database records to objects * - Proper type hints and documentation * + * @deprecated Use manual entity classes with Repository pattern instead. + * This class is kept for legacy support and rapid prototyping only. + * For production code, create entities manually and use AbstractRepository + * with toEntity() method for mapping. + * + * @see AbstractRepository For the recommended approach to entity mapping + * * @author Ibrahim * */ @@ -175,8 +184,10 @@ public function create() : bool { ."\n"; } $this->classStr .= "/**\n" - ." * An auto-generated entity class which maps to a record in the\n" + ." * Domain entity which maps to a record in the\n" ." * table '".trim($this->getTable()->getNormalName(), "`")."'\n" + ." *" + ." * Each model consist of table schema + domain entity.'\n" ." **/\n"; if ($this->implJsonI) { diff --git a/WebFiori/Database/RecordMapper.php b/WebFiori/Database/Entity/RecordMapper.php similarity index 94% rename from WebFiori/Database/RecordMapper.php rename to WebFiori/Database/Entity/RecordMapper.php index 83c6ba35..c435455a 100644 --- a/WebFiori/Database/RecordMapper.php +++ b/WebFiori/Database/Entity/RecordMapper.php @@ -3,13 +3,15 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Entity; + +use WebFiori\Database\DatabaseException; /** * A class which is used to map a database record to a system entity. @@ -126,11 +128,8 @@ public function map(array $record) { foreach ($this->getSettersMap() as $method => $colsNames) { if (is_callable([$instance, $method])) { foreach ($colsNames as $colName) { - try { - if (isset($record[$colName])) { - $instance->$method($record[$colName]); - } - } catch (\Throwable $ex) { + if (isset($record[$colName])) { + $instance->$method($record[$colName]); } } } diff --git a/WebFiori/Database/FK.php b/WebFiori/Database/FK.php index 039ce000..c0b52344 100644 --- a/WebFiori/Database/FK.php +++ b/WebFiori/Database/FK.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ColumnFactory.php b/WebFiori/Database/Factory/ColumnFactory.php similarity index 96% rename from WebFiori/Database/ColumnFactory.php rename to WebFiori/Database/Factory/ColumnFactory.php index 3e9a6492..fd735bfd 100644 --- a/WebFiori/Database/ColumnFactory.php +++ b/WebFiori/Database/Factory/ColumnFactory.php @@ -3,16 +3,21 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Factory; +use WebFiori\Database\ColOption; +use WebFiori\Database\Column; +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\DatabaseException; use WebFiori\Database\MsSql\MSSQLColumn; use WebFiori\Database\MySql\MySQLColumn; +use WebFiori\Database\Util\TypesMap; /** * A factory class for creating column objects. diff --git a/WebFiori/Database/TableFactory.php b/WebFiori/Database/Factory/TableFactory.php similarity index 83% rename from WebFiori/Database/TableFactory.php rename to WebFiori/Database/Factory/TableFactory.php index cefd010c..b120868e 100644 --- a/WebFiori/Database/TableFactory.php +++ b/WebFiori/Database/Factory/TableFactory.php @@ -3,16 +3,19 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 Ibrahim BinAlshikh + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Factory; +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\DatabaseException; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Table; /** * @@ -21,7 +24,7 @@ class TableFactory { public static function create(string $database, string $name, array $cols = []) : Table { if (!in_array($database, ConnectionInfo::SUPPORTED_DATABASES)) { - throw new DatabaseException('Not support database: '.$database); + throw new DatabaseException('Not support database: '.$database.'. Supported: '.implode(', ', ConnectionInfo::SUPPORTED_DATABASES)); } if ($database == 'mssql') { diff --git a/WebFiori/Database/ForeignKey.php b/WebFiori/Database/ForeignKey.php index 41f9cfd7..4e2234d4 100644 --- a/WebFiori/Database/ForeignKey.php +++ b/WebFiori/Database/ForeignKey.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/JoinTable.php b/WebFiori/Database/JoinTable.php index 388ef8f5..74bc9c8f 100644 --- a/WebFiori/Database/JoinTable.php +++ b/WebFiori/Database/JoinTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -17,6 +17,7 @@ use WebFiori\Database\MySql\MySQLColumn; use WebFiori\Database\MySql\MySQLQuery; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Query\Condition; /** * A class that represents two joined tables. * diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index b5cbd644..427333bc 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -12,9 +12,9 @@ namespace WebFiori\Database\MsSql; use WebFiori\Database\Column; -use WebFiori\Database\ColumnFactory; use WebFiori\Database\DatabaseException; -use WebFiori\Database\DateTimeValidator; +use WebFiori\Database\Factory\ColumnFactory; +use WebFiori\Database\Util\DateTimeValidator; /** * A class that represents a column in MSSQL table. * diff --git a/WebFiori/Database/MsSql/MSSQLConnection.php b/WebFiori/Database/MsSql/MSSQLConnection.php index 6cd65179..3c0fb92f 100644 --- a/WebFiori/Database/MsSql/MSSQLConnection.php +++ b/WebFiori/Database/MsSql/MSSQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -13,9 +13,9 @@ use WebFiori\Database\AbstractQuery; use WebFiori\Database\Connection; -use WebFiori\Database\MultiResultSet; use WebFiori\Database\ConnectionInfo; use WebFiori\Database\DatabaseException; +use WebFiori\Database\MultiResultSet; use WebFiori\Database\ResultSet; /** * A class that represents a connection to MSSQL server. @@ -177,7 +177,7 @@ public function rollBack(?string $name = null) { * Execute MSSQL query. * * @param AbstractQuery $query A query builder that has the generated MSSQL - /** + * /** * Execute a query and return execution status. * * @param AbstractQuery|null $query The query to execute. If null, uses the last set query. @@ -243,15 +243,17 @@ private function runOtherQuery() { if (!is_resource($r)) { $this->setSqlErr(); + return false; } // Collect all result sets $allResults = []; - + // First result set if (sqlsrv_has_rows($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -263,6 +265,7 @@ private function runOtherQuery() { // Additional result sets while (sqlsrv_next_result($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -285,14 +288,16 @@ private function runSelectQuery() { if (!is_resource($r)) { $this->setSqlErr(); + return false; } // Collect all result sets $allResults = []; - + // First result set $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -301,6 +306,7 @@ private function runSelectQuery() { // Additional result sets while (sqlsrv_next_result($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } diff --git a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php index 9e7befc9..ee493748 100644 --- a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php +++ b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -11,7 +11,7 @@ */ namespace WebFiori\Database\MsSql; -use WebFiori\Database\InsertBuilder; +use WebFiori\Database\Query\InsertBuilder; /** * A class which is used to construct insert query for MSSQL server. diff --git a/WebFiori/Database/MsSql/MSSQLQuery.php b/WebFiori/Database/MsSql/MSSQLQuery.php index ecefaad3..2098c546 100644 --- a/WebFiori/Database/MsSql/MSSQLQuery.php +++ b/WebFiori/Database/MsSql/MSSQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLTable.php b/WebFiori/Database/MsSql/MSSQLTable.php index 649d9fdb..6ab467da 100644 --- a/WebFiori/Database/MsSql/MSSQLTable.php +++ b/WebFiori/Database/MsSql/MSSQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MultiResultSet.php b/WebFiori/Database/MultiResultSet.php index 59ce88fe..4b61b64c 100644 --- a/WebFiori/Database/MultiResultSet.php +++ b/WebFiori/Database/MultiResultSet.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -29,12 +29,12 @@ class MultiResultSet implements Countable, Iterator { * @var int Current position in the result sets array */ private $cursorPos; - + /** * @var ResultSet[] Array of ResultSet objects */ private $resultSets; - + /** * Creates new instance of MultiResultSet. * @@ -43,12 +43,12 @@ class MultiResultSet implements Countable, Iterator { public function __construct(array $resultSets = []) { $this->cursorPos = 0; $this->resultSets = []; - + foreach ($resultSets as $resultData) { $this->addResultSet($resultData); } } - + /** * Add a result set to the collection. * @@ -61,7 +61,7 @@ public function addResultSet($resultData): void { $this->resultSets[] = new ResultSet($resultData); } } - + /** * Get the number of result sets. * @@ -70,7 +70,7 @@ public function addResultSet($resultData): void { public function count(): int { return count($this->resultSets); } - + /** * Get the current ResultSet object. * @@ -80,7 +80,7 @@ public function count(): int { public function current() { return $this->valid() ? $this->resultSets[$this->cursorPos] : null; } - + /** * Get a specific result set by index. * @@ -90,7 +90,7 @@ public function current() { public function getResultSet(int $index): ?ResultSet { return isset($this->resultSets[$index]) ? $this->resultSets[$index] : null; } - + /** * Get all result sets. * @@ -99,7 +99,22 @@ public function getResultSet(int $index): ?ResultSet { public function getResultSets(): array { return $this->resultSets; } - + + /** + * Get total number of records across all result sets. + * + * @return int Total number of records + */ + public function getTotalRecordCount(): int { + $total = 0; + + foreach ($this->resultSets as $resultSet) { + $total += $resultSet->getRowsCount(); + } + + return $total; + } + /** * Get the current cursor position. * @@ -109,7 +124,7 @@ public function getResultSets(): array { public function key() { return $this->cursorPos; } - + /** * Move to the next result set. */ @@ -117,7 +132,7 @@ public function key() { public function next(): void { $this->cursorPos++; } - + /** * Reset cursor to the first result set. */ @@ -125,20 +140,7 @@ public function next(): void { public function rewind(): void { $this->cursorPos = 0; } - - /** - * Get total number of records across all result sets. - * - * @return int Total number of records - */ - public function getTotalRecordCount(): int { - $total = 0; - foreach ($this->resultSets as $resultSet) { - $total += $resultSet->getRowsCount(); - } - return $total; - } - + /** * Check if current position is valid. * diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index 809e6b75..24c40d89 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -12,10 +12,10 @@ namespace WebFiori\Database\MySql; use WebFiori\Database\Column; -use WebFiori\Database\ColumnFactory; use WebFiori\Database\DatabaseException; -use WebFiori\Database\DateTimeValidator; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\Table; +use WebFiori\Database\Util\DateTimeValidator; /** * A class that represents a column in MySQL table. @@ -106,6 +106,12 @@ public function __toString() { if ($this->isUnique() && $colDataType != 'boolean' && $colDataType != 'bool') { $retVal .= 'unique '; } + + // Add auto_increment before default and comment + if ($this->isAutoInc()) { + $retVal .= 'auto_increment '; + } + $retVal .= $this->defaultPart(); if ($colDataType == 'varchar' || $colDataType == 'text' || $colDataType == 'mediumtext' || $colDataType == 'mixed') { diff --git a/WebFiori/Database/MySql/MySQLConnection.php b/WebFiori/Database/MySql/MySQLConnection.php index a6085b9a..d13fe2d6 100644 --- a/WebFiori/Database/MySql/MySQLConnection.php +++ b/WebFiori/Database/MySql/MySQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -13,11 +13,11 @@ use mysqli; use mysqli_stmt; -use WebFiori\Database\MultiResultSet; use WebFiori\Database\AbstractQuery; use WebFiori\Database\Connection; use WebFiori\Database\ConnectionInfo; use WebFiori\Database\DatabaseException; +use WebFiori\Database\MultiResultSet; use WebFiori\Database\ResultSet; /** * MySQL database connection handler with prepared statement support. @@ -151,6 +151,14 @@ public function connect() : bool { public function getMysqli() { return $this->link; } + /** + * Get the mysqli link for testing purposes. + * + * @return mysqli The mysqli connection link + */ + public function getMysqliLink() { + return $this->link; + } public function rollBack(?string $name = null) { //The null check is for php<8 @@ -172,7 +180,7 @@ public function rollBack(?string $name = null) { * * @param AbstractQuery $query A query builder that has the generated MySQL * query. - /** + * /** * Execute a query and return execution status. * * @param AbstractQuery|null $query The query to execute. If null, uses the last set query. @@ -205,6 +213,7 @@ public function runQuery(?AbstractQuery $query = null): bool { try { $result = false; + if ($qType == 'insert') { $result = $this->runInsertQuery(); } else if ($qType == 'update') { @@ -215,6 +224,7 @@ public function runQuery(?AbstractQuery $query = null): bool { $result = $this->runOtherQuery(); } $query->resetBinding(); + return $result; } catch (\Exception $ex) { $this->setErrCode($ex->getCode()); @@ -283,9 +293,11 @@ private function runOtherQuery() { $values = array_merge($this->getLastQuery()->getBindings()['values']); $successExec = false; $r = null; + // Execute query if (count($values) != 0 && !empty($params)) { $paramCount = substr_count($sql, '?'); + if ($paramCount == count($values) && strlen($params) == count($values)) { $stmt = mysqli_prepare($this->link, $sql); mysqli_stmt_bind_param($stmt, $params, ...$values); @@ -302,12 +314,13 @@ private function runOtherQuery() { if (($r === null || $r === false) && !$successExec) { $this->setErrMessage($this->link->error); $this->setErrCode($this->link->errno); + return false; } // Collect all result sets $allResults = []; - + // First result set if (is_object($r) && method_exists($r, 'fetch_assoc')) { $rows = mysqli_fetch_all($r, MYSQLI_ASSOC); @@ -318,6 +331,7 @@ private function runOtherQuery() { // Additional result sets while (mysqli_more_results($this->link)) { mysqli_next_result($this->link); + if ($result = mysqli_store_result($this->link)) { $rows = mysqli_fetch_all($result, MYSQLI_ASSOC); $allResults[] = $rows; @@ -333,17 +347,10 @@ private function runOtherQuery() { } $this->setErrCode(0); + return true; } - /** - * Get the mysqli link for testing purposes. - * - * @return mysqli The mysqli connection link - */ - public function getMysqliLink() { - return $this->link; - } - + private function runSelectQuery() { $sql = $this->getLastQuery()->getQuery(); $params = $this->getLastQuery()->getBindings()['bind']; @@ -363,12 +370,13 @@ private function runSelectQuery() { if (!$r) { $this->setErrMessage($this->link->error); $this->setErrCode($this->link->errno); + return false; } // Collect all result sets $allResults = []; - + // First result set $rows = mysqli_fetch_all($r, MYSQLI_ASSOC); $allResults[] = $rows; @@ -377,6 +385,7 @@ private function runSelectQuery() { // Additional result sets while (mysqli_more_results($this->link)) { mysqli_next_result($this->link); + if ($result = mysqli_store_result($this->link)) { $rows = mysqli_fetch_all($result, MYSQLI_ASSOC); $allResults[] = $rows; @@ -392,6 +401,7 @@ private function runSelectQuery() { } $this->setErrCode(0); + return true; } private function runUpdateQuery() { diff --git a/WebFiori/Database/MySql/MySQLInsertBuilder.php b/WebFiori/Database/MySql/MySQLInsertBuilder.php index 95657230..6fc7a7c7 100644 --- a/WebFiori/Database/MySql/MySQLInsertBuilder.php +++ b/WebFiori/Database/MySql/MySQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -12,7 +12,7 @@ namespace WebFiori\Database\MySql; use WebFiori\Database\Column; -use WebFiori\Database\InsertBuilder; +use WebFiori\Database\Query\InsertBuilder; /** * A class which is used to construct insert query for MySQL database. diff --git a/WebFiori/Database/MySql/MySQLQuery.php b/WebFiori/Database/MySql/MySQLQuery.php index ce509c11..806c43c8 100644 --- a/WebFiori/Database/MySql/MySQLQuery.php +++ b/WebFiori/Database/MySql/MySQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -339,6 +339,7 @@ public function setBindings(array $bindings, string $merge = 'none') { if (!isset($bindings['bind']) && !isset($bindings['values'])) { // Simple array - convert to structured format $bindString = ''; + foreach ($bindings as $value) { if (is_int($value)) { $bindString .= 'i'; @@ -353,7 +354,7 @@ public function setBindings(array $bindings, string $merge = 'none') { 'values' => $bindings ]; } - + $currentBinding = $this->bindings['bind']; $values = $this->bindings['values']; diff --git a/WebFiori/Database/MySql/MySQLTable.php b/WebFiori/Database/MySql/MySQLTable.php index 766a77a5..ff3ade73 100644 --- a/WebFiori/Database/MySql/MySQLTable.php +++ b/WebFiori/Database/MySql/MySQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -296,7 +296,7 @@ private function createTableColumns() { $index = 0; foreach ($cols as $colObj) { - $autoIncPart = $colObj->isAutoInc() ? ' auto_increment' : ''; + $autoIncPart = ''; if ($index + 1 == $count) { $queryStr .= ' '.$colObj->asString().$autoIncPart.""; diff --git a/WebFiori/Database/Performance/PerformanceAnalyzer.php b/WebFiori/Database/Performance/PerformanceAnalyzer.php index 7e0f653d..40b70861 100644 --- a/WebFiori/Database/Performance/PerformanceAnalyzer.php +++ b/WebFiori/Database/Performance/PerformanceAnalyzer.php @@ -1,120 +1,120 @@ -monitor = $monitor; - } - - /** - * Calculate the average execution time per query. - * - * @return float Average execution time in milliseconds, 0 if no metrics. - */ - public function getAverageTime(): float { - $metrics = $this->monitor->getMetrics(); - - if (empty($metrics)) { - return 0.0; - } - - return $this->getTotalTime() / count($metrics); - } - - /** - * Calculate query performance efficiency as percentage of fast queries. - * - * @return float Efficiency percentage (0-100). - */ - public function getEfficiency(): float { - $metrics = $this->monitor->getMetrics(); - - if (empty($metrics)) { - return 100.0; - } - - $slowCount = count($this->getSlowQueries()); - $fastCount = count($metrics) - $slowCount; - - return ($fastCount / count($metrics)) * 100; - } - - /** - * Get the total number of queries analyzed. - * - * @return int Total query count. - */ - public function getQueryCount(): int { - return count($this->monitor->getMetrics()); - } - - /** - * Get a performance score based on average execution time. - * - * @return string Performance score: SCORE_EXCELLENT, SCORE_GOOD, or SCORE_NEEDS_IMPROVEMENT. - */ - public function getScore(): string { - $avgTime = $this->getAverageTime(); - - if ($avgTime < 10) { - return self::SCORE_EXCELLENT; - } elseif ($avgTime < 50) { - return self::SCORE_GOOD; - } else { - return self::SCORE_NEEDS_IMPROVEMENT; - } - } - - /** - * Get all queries that exceed the slow query threshold. - * - * @return array Array of QueryMetric instances for slow queries. - */ - public function getSlowQueries(): array { - return $this->monitor->getSlowQueries(); - } - - /** - * Get the number of slow queries. - * - * @return int Slow query count. - */ - public function getSlowQueryCount(): int { - return count($this->getSlowQueries()); - } - - /** - * Calculate the total execution time of all queries. - * - * @return float Total execution time in milliseconds. - */ - public function getTotalTime(): float { - $total = 0.0; - - foreach ($this->monitor->getMetrics() as $metric) { - $total += $metric->getExecutionTimeMs(); - } - - return $total; - } -} +monitor = $monitor; + } + + /** + * Calculate the average execution time per query. + * + * @return float Average execution time in milliseconds, 0 if no metrics. + */ + public function getAverageTime(): float { + $metrics = $this->monitor->getMetrics(); + + if (empty($metrics)) { + return 0.0; + } + + return $this->getTotalTime() / count($metrics); + } + + /** + * Calculate query performance efficiency as percentage of fast queries. + * + * @return float Efficiency percentage (0-100). + */ + public function getEfficiency(): float { + $metrics = $this->monitor->getMetrics(); + + if (empty($metrics)) { + return 100.0; + } + + $slowCount = count($this->getSlowQueries()); + $fastCount = count($metrics) - $slowCount; + + return ($fastCount / count($metrics)) * 100; + } + + /** + * Get the total number of queries analyzed. + * + * @return int Total query count. + */ + public function getQueryCount(): int { + return count($this->monitor->getMetrics()); + } + + /** + * Get a performance score based on average execution time. + * + * @return string Performance score: SCORE_EXCELLENT, SCORE_GOOD, or SCORE_NEEDS_IMPROVEMENT. + */ + public function getScore(): string { + $avgTime = $this->getAverageTime(); + + if ($avgTime < 10) { + return self::SCORE_EXCELLENT; + } elseif ($avgTime < 50) { + return self::SCORE_GOOD; + } else { + return self::SCORE_NEEDS_IMPROVEMENT; + } + } + + /** + * Get all queries that exceed the slow query threshold. + * + * @return array Array of QueryMetric instances for slow queries. + */ + public function getSlowQueries(): array { + return $this->monitor->getSlowQueries(); + } + + /** + * Get the number of slow queries. + * + * @return int Slow query count. + */ + public function getSlowQueryCount(): int { + return count($this->getSlowQueries()); + } + + /** + * Calculate the total execution time of all queries. + * + * @return float Total execution time in milliseconds. + */ + public function getTotalTime(): float { + $total = 0.0; + + foreach ($this->monitor->getMetrics() as $metric) { + $total += $metric->getExecutionTimeMs(); + } + + return $total; + } +} diff --git a/WebFiori/Database/Performance/PerformanceOption.php b/WebFiori/Database/Performance/PerformanceOption.php index 9b4ec14d..2d4eea0e 100644 --- a/WebFiori/Database/Performance/PerformanceOption.php +++ b/WebFiori/Database/Performance/PerformanceOption.php @@ -1,117 +1,117 @@ -queryHash = $queryHash; - $this->queryType = $queryType; - $this->query = $query; - $this->executionTimeMs = $executionTimeMs; - $this->rowsAffected = $rowsAffected; - $this->memoryUsageMb = $memoryUsageMb; - $this->executedAt = $executedAt; - $this->databaseName = $databaseName; - } - - /** - * Get the database name. - * - * @return string Database name - */ - public function getDatabaseName(): string { - return $this->databaseName; - } - - /** - * Get the execution timestamp. - * - * @return float Unix timestamp with microseconds - */ - public function getExecutedAt(): float { - return $this->executedAt; - } - - /** - * Get the execution time in milliseconds. - * - * @return float Execution time with microsecond precision - */ - public function getExecutionTimeMs(): float { - return $this->executionTimeMs; - } - - /** - * Get the memory usage in megabytes. - * - * @return float Memory usage - */ - public function getMemoryUsageMb(): float { - return $this->memoryUsageMb; - } - - /** - * Get the actual SQL query that was executed. - * - * @return string The SQL query - */ - public function getQuery(): string { - return $this->query; - } - - /** - * Get the MD5 hash of the normalized query. - * - * @return string MD5 hash of the normalized query - */ - public function getQueryHash(): string { - return $this->queryHash; - } - - /** - * Get the query type. - * - * @return string Query type (SELECT, INSERT, UPDATE, DELETE) - */ - public function getQueryType(): string { - return $this->queryType; - } - - /** - * Get the number of rows affected or returned. - * - * @return int Row count - */ - public function getRowsAffected(): int { - return $this->rowsAffected; - } -} +queryHash = $queryHash; + $this->queryType = $queryType; + $this->query = $query; + $this->executionTimeMs = $executionTimeMs; + $this->rowsAffected = $rowsAffected; + $this->memoryUsageMb = $memoryUsageMb; + $this->executedAt = $executedAt; + $this->databaseName = $databaseName; + } + + /** + * Get the database name. + * + * @return string Database name + */ + public function getDatabaseName(): string { + return $this->databaseName; + } + + /** + * Get the execution timestamp. + * + * @return float Unix timestamp with microseconds + */ + public function getExecutedAt(): float { + return $this->executedAt; + } + + /** + * Get the execution time in milliseconds. + * + * @return float Execution time with microsecond precision + */ + public function getExecutionTimeMs(): float { + return $this->executionTimeMs; + } + + /** + * Get the memory usage in megabytes. + * + * @return float Memory usage + */ + public function getMemoryUsageMb(): float { + return $this->memoryUsageMb; + } + + /** + * Get the actual SQL query that was executed. + * + * @return string The SQL query + */ + public function getQuery(): string { + return $this->query; + } + + /** + * Get the MD5 hash of the normalized query. + * + * @return string MD5 hash of the normalized query + */ + public function getQueryHash(): string { + return $this->queryHash; + } + + /** + * Get the query type. + * + * @return string Query type (SELECT, INSERT, UPDATE, DELETE) + */ + public function getQueryType(): string { + return $this->queryType; + } + + /** + * Get the number of rows affected or returned. + * + * @return int Row count + */ + public function getRowsAffected(): int { + return $this->rowsAffected; + } +} diff --git a/WebFiori/Database/Performance/QueryPerformanceMonitor.php b/WebFiori/Database/Performance/QueryPerformanceMonitor.php index 0e2afbcb..4f8a4670 100644 --- a/WebFiori/Database/Performance/QueryPerformanceMonitor.php +++ b/WebFiori/Database/Performance/QueryPerformanceMonitor.php @@ -1,470 +1,470 @@ -config = array_merge($this->getDefaultConfig(), $config); - $this->database = $database; - $this->validateConfig(); - } - - /** - * Clear all stored metrics. - */ - public function clearMetrics(): void { - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - $this->memoryMetrics = []; - } else { - $this->clearDatabaseMetrics(); - } - } - - /** - * Create a performance analyzer for the collected metrics. - * - * @return PerformanceAnalyzer Analyzer instance with current metrics and configuration. - */ - public function getAnalyzer(): PerformanceAnalyzer { - return new PerformanceAnalyzer($this); - } - - /** - * Get all performance metrics. - * - * @return array Array of QueryMetric instances or metric arrays - */ - public function getMetrics(): array { - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - return $this->memoryMetrics; - } - - if (!$this->database) { - return []; - } - - try { - return $this->getMetricsFromDatabase(); - } catch (\Exception $e) { - // If table doesn't exist, return empty array - return []; - } - } - - /** - * Get slow queries based on configured threshold. - * - * @param int|null $thresholdMs Custom threshold in milliseconds - * @return array Array of slow query metrics - */ - public function getSlowQueries(?int $thresholdMs = null): array { - $threshold = $thresholdMs ?? $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; - $metrics = $this->getMetrics(); - - return array_values(array_filter($metrics, function($metric) use ($threshold) - { - $executionTime = $metric instanceof QueryMetric - ? $metric->getExecutionTimeMs() - : $metric['execution_time_ms']; - - return $executionTime >= $threshold; - })); - } - - /** - * Get the configured slow query threshold. - * - * @return float Slow query threshold in milliseconds. - */ - public function getSlowQueryThreshold(): float { - return (float) $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; - } - /** - * Get performance statistics summary. - * - * @return array Statistics including avg, min, max execution times - */ - public function getStatistics(): array { - $metrics = $this->getMetrics(); - - if (empty($metrics)) { - return [ - 'total_queries' => 0, - 'avg_execution_time' => 0, - 'min_execution_time' => 0, - 'max_execution_time' => 0, - 'slow_queries_count' => 0 - ]; - } - - $executionTimes = array_map(function($metric) - { - return $metric instanceof QueryMetric - ? $metric->getExecutionTimeMs() - : $metric['execution_time_ms']; - }, $metrics); - - return [ - 'total_queries' => count($metrics), - 'avg_execution_time' => array_sum($executionTimes) / count($executionTimes), - 'min_execution_time' => min($executionTimes), - 'max_execution_time' => max($executionTimes), - 'slow_queries_count' => count($this->getSlowQueries()) - ]; - } - - /** - * Record a query performance metric. - * - * @param string $query The SQL query that was executed - * @param float $executionTimeMs Execution time in milliseconds - * @param mixed $result Query result (for row count extraction) - */ - public function recordQuery(string $query, float $executionTimeMs, $result = null): void { - if (!$this->config[PerformanceOption::ENABLED]) { - return; - } - - if (!$this->shouldTrackQuery($query)) { - return; - } - - if (!$this->shouldSample()) { - return; - } - - $metric = $this->createMetric($query, $executionTimeMs, $result); - - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - $this->storeInMemory($metric); - } else { - $this->storeInDatabase($metric); - } - - $this->performCleanup(); - } - - /** - * Update monitoring configuration. - * - * @param array $config New configuration options - */ - public function updateConfig(array $config): void { - $this->config = array_merge($this->config, $config); - $this->validateConfig(); - } - - /** - * Clean up old metrics from database. - */ - private function cleanupOldDatabaseMetrics(): void { - if (!$this->database) { - return; - } - - $cutoffTime = microtime(true) - ($this->config[PerformanceOption::RETENTION_HOURS] * 3600); - - $this->database->table('query_performance_metrics') - ->delete() - ->where('executed_at', $cutoffTime, '<') - ->execute(); - } - - /** - * Clear metrics from database. - */ - private function clearDatabaseMetrics(): void { - if (!$this->database) { - return; - } - - $this->database->table('query_performance_metrics') - ->delete() - ->execute(); - } - - /** - * Create a QueryMetric instance from query data. - * - * @param string $query SQL query - * @param float $executionTimeMs Execution time - * @param mixed $result Query result - * @return QueryMetric - */ - private function createMetric(string $query, float $executionTimeMs, $result): QueryMetric { - return new QueryMetric( - md5($query), - $this->getQueryType($query), - $query, - $executionTimeMs, - $this->getRowCount($result), - $this->getMemoryUsage(), - microtime(true), - $this->database ? $this->database->getName() : 'unknown' - ); - } - - /** - * Ensure performance metrics table exists. - */ - private function ensureSchemaExists(): void { - if ($this->schemaCreated || !$this->database) { - return; - } - - $this->database->createBlueprint('query_performance_metrics') - ->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'query_hash' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 64 - ], - 'query_type' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20 - ], - 'execution_time_ms' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => 10, - ColOption::SCALE => 2 - ], - 'rows_affected' => [ - ColOption::TYPE => DataType::INT - ], - 'memory_usage_mb' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => '8,2' - ], - 'executed_at' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => '15,6' - ], - 'database_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 64 - ] - ]); - - $this->database->createTable()->execute(); - $this->schemaCreated = true; - } - - /** - * Get default configuration values. - * - * @return array Default configuration - */ - private function getDefaultConfig(): array { - return [ - PerformanceOption::ENABLED => false, - PerformanceOption::SLOW_QUERY_THRESHOLD => 1000, - PerformanceOption::WARNING_THRESHOLD => 500, - PerformanceOption::SAMPLING_RATE => 1.0, - PerformanceOption::MAX_SAMPLES => 10000, - PerformanceOption::STORAGE_TYPE => PerformanceOption::STORAGE_MEMORY, - PerformanceOption::RETENTION_HOURS => 24, - PerformanceOption::AUTO_CLEANUP => true, - PerformanceOption::MEMORY_LIMIT_MB => 50, - PerformanceOption::TRACK_SELECT => true, - PerformanceOption::TRACK_INSERT => true, - PerformanceOption::TRACK_UPDATE => true, - PerformanceOption::TRACK_DELETE => true - ]; - } - - /** - * Get current memory usage in MB. - * - * @return float Memory usage in megabytes - */ - private function getMemoryUsage(): float { - return memory_get_usage(true) / 1024 / 1024; - } - - /** - * Get metrics from database. - * - * @return array - */ - private function getMetricsFromDatabase(): array { - if (!$this->database) { - return []; - } - - $result = $this->database->table('query_performance_metrics') - ->select() - ->execute(); - - return $result->getRows(); - } - - /** - * Extract query type from SQL. - * - * @param string $query SQL query - * @return string Query type (SELECT, INSERT, UPDATE, DELETE) - */ - private function getQueryType(string $query): string { - $query = trim(strtoupper($query)); - - if (str_starts_with($query, 'SELECT')) { - return 'SELECT'; - } - - if (str_starts_with($query, 'INSERT')) { - return 'INSERT'; - } - - if (str_starts_with($query, 'UPDATE')) { - return 'UPDATE'; - } - - if (str_starts_with($query, 'DELETE')) { - return 'DELETE'; - } - - return 'OTHER'; - } - - /** - * Extract row count from query result. - * - * @param mixed $result Query result - * @return int Row count - */ - private function getRowCount($result): int { - if ($result instanceof ResultSet) { - return $result->count(); - } - - return 0; - } - - /** - * Perform cleanup based on configuration. - */ - private function performCleanup(): void { - if (!$this->config[PerformanceOption::AUTO_CLEANUP]) { - return; - } - - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_DATABASE) { - $this->cleanupOldDatabaseMetrics(); - } - } - - /** - * Check if current query should be sampled. - * - * @return bool True if query should be sampled - */ - private function shouldSample(): bool { - return mt_rand() / mt_getrandmax() <= $this->config[PerformanceOption::SAMPLING_RATE]; - } - - /** - * Check if query should be tracked based on type. - * - * @param string $query SQL query - * @return bool True if query should be tracked - */ - private function shouldTrackQuery(string $query): bool { - $queryType = $this->getQueryType($query); - - return match ($queryType) { - 'SELECT' => $this->config[PerformanceOption::TRACK_SELECT], - 'INSERT' => $this->config[PerformanceOption::TRACK_INSERT], - 'UPDATE' => $this->config[PerformanceOption::TRACK_UPDATE], - 'DELETE' => $this->config[PerformanceOption::TRACK_DELETE], - default => false - }; - } - - /** - * Store metric in database. - * - * @param QueryMetric $metric - */ - private function storeInDatabase(QueryMetric $metric): void { - if (!$this->database) { - return; - } - - $this->ensureSchemaExists(); - - $this->database->table('query_performance_metrics') - ->insert($metric->toArray()) - ->execute(); - } - - /** - * Store metric in memory. - * - * @param QueryMetric $metric - */ - private function storeInMemory(QueryMetric $metric): void { - $this->memoryMetrics[] = $metric; - - if (count($this->memoryMetrics) > $this->config[PerformanceOption::MAX_SAMPLES]) { - array_shift($this->memoryMetrics); - } - } - - /** - * Validate configuration values. - * - * @throws InvalidArgumentException If configuration is invalid - */ - private function validateConfig(): void { - if (!is_bool($this->config[PerformanceOption::ENABLED])) { - throw new InvalidArgumentException('ENABLED must be boolean'); - } - - if ($this->config[PerformanceOption::SAMPLING_RATE] < 0 || $this->config[PerformanceOption::SAMPLING_RATE] > 1) { - throw new InvalidArgumentException('SAMPLING_RATE must be between 0.0 and 1.0'); - } - - if (!in_array($this->config[PerformanceOption::STORAGE_TYPE], [ - PerformanceOption::STORAGE_MEMORY, - PerformanceOption::STORAGE_DATABASE - ])) { - throw new InvalidArgumentException('Invalid STORAGE_TYPE'); - } - } -} +config = array_merge($this->getDefaultConfig(), $config); + $this->database = $database; + $this->validateConfig(); + } + + /** + * Clear all stored metrics. + */ + public function clearMetrics(): void { + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + $this->memoryMetrics = []; + } else { + $this->clearDatabaseMetrics(); + } + } + + /** + * Create a performance analyzer for the collected metrics. + * + * @return PerformanceAnalyzer Analyzer instance with current metrics and configuration. + */ + public function getAnalyzer(): PerformanceAnalyzer { + return new PerformanceAnalyzer($this); + } + + /** + * Get all performance metrics. + * + * @return array Array of QueryMetric instances or metric arrays + */ + public function getMetrics(): array { + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + return $this->memoryMetrics; + } + + if (!$this->database) { + return []; + } + + try { + return $this->getMetricsFromDatabase(); + } catch (\Exception $e) { + // If table doesn't exist, return empty array + return []; + } + } + + /** + * Get slow queries based on configured threshold. + * + * @param int|null $thresholdMs Custom threshold in milliseconds + * @return array Array of slow query metrics + */ + public function getSlowQueries(?int $thresholdMs = null): array { + $threshold = $thresholdMs ?? $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; + $metrics = $this->getMetrics(); + + return array_values(array_filter($metrics, function($metric) use ($threshold) + { + $executionTime = $metric instanceof QueryMetric + ? $metric->getExecutionTimeMs() + : $metric['execution_time_ms']; + + return $executionTime >= $threshold; + })); + } + + /** + * Get the configured slow query threshold. + * + * @return float Slow query threshold in milliseconds. + */ + public function getSlowQueryThreshold(): float { + return (float) $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; + } + /** + * Get performance statistics summary. + * + * @return array Statistics including avg, min, max execution times + */ + public function getStatistics(): array { + $metrics = $this->getMetrics(); + + if (empty($metrics)) { + return [ + 'total_queries' => 0, + 'avg_execution_time' => 0, + 'min_execution_time' => 0, + 'max_execution_time' => 0, + 'slow_queries_count' => 0 + ]; + } + + $executionTimes = array_map(function($metric) + { + return $metric instanceof QueryMetric + ? $metric->getExecutionTimeMs() + : $metric['execution_time_ms']; + }, $metrics); + + return [ + 'total_queries' => count($metrics), + 'avg_execution_time' => array_sum($executionTimes) / count($executionTimes), + 'min_execution_time' => min($executionTimes), + 'max_execution_time' => max($executionTimes), + 'slow_queries_count' => count($this->getSlowQueries()) + ]; + } + + /** + * Record a query performance metric. + * + * @param string $query The SQL query that was executed + * @param float $executionTimeMs Execution time in milliseconds + * @param mixed $result Query result (for row count extraction) + */ + public function recordQuery(string $query, float $executionTimeMs, $result = null): void { + if (!$this->config[PerformanceOption::ENABLED]) { + return; + } + + if (!$this->shouldTrackQuery($query)) { + return; + } + + if (!$this->shouldSample()) { + return; + } + + $metric = $this->createMetric($query, $executionTimeMs, $result); + + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + $this->storeInMemory($metric); + } else { + $this->storeInDatabase($metric); + } + + $this->performCleanup(); + } + + /** + * Update monitoring configuration. + * + * @param array $config New configuration options + */ + public function updateConfig(array $config): void { + $this->config = array_merge($this->config, $config); + $this->validateConfig(); + } + + /** + * Clean up old metrics from database. + */ + private function cleanupOldDatabaseMetrics(): void { + if (!$this->database) { + return; + } + + $cutoffTime = microtime(true) - ($this->config[PerformanceOption::RETENTION_HOURS] * 3600); + + $this->database->table('query_performance_metrics') + ->delete() + ->where('executed_at', $cutoffTime, '<') + ->execute(); + } + + /** + * Clear metrics from database. + */ + private function clearDatabaseMetrics(): void { + if (!$this->database) { + return; + } + + $this->database->table('query_performance_metrics') + ->delete() + ->execute(); + } + + /** + * Create a QueryMetric instance from query data. + * + * @param string $query SQL query + * @param float $executionTimeMs Execution time + * @param mixed $result Query result + * @return QueryMetric + */ + private function createMetric(string $query, float $executionTimeMs, $result): QueryMetric { + return new QueryMetric( + md5($query), + $this->getQueryType($query), + $query, + $executionTimeMs, + $this->getRowCount($result), + $this->getMemoryUsage(), + microtime(true), + $this->database ? $this->database->getName() : 'unknown' + ); + } + + /** + * Ensure performance metrics table exists. + */ + private function ensureSchemaExists(): void { + if ($this->schemaCreated || !$this->database) { + return; + } + + $this->database->createBlueprint('query_performance_metrics') + ->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'query_hash' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 64 + ], + 'query_type' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 20 + ], + 'execution_time_ms' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => 10, + ColOption::SCALE => 2 + ], + 'rows_affected' => [ + ColOption::TYPE => DataType::INT + ], + 'memory_usage_mb' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => '8,2' + ], + 'executed_at' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => '15,6' + ], + 'database_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 64 + ] + ]); + + $this->database->createTable()->execute(); + $this->schemaCreated = true; + } + + /** + * Get default configuration values. + * + * @return array Default configuration + */ + private function getDefaultConfig(): array { + return [ + PerformanceOption::ENABLED => false, + PerformanceOption::SLOW_QUERY_THRESHOLD => 1000, + PerformanceOption::WARNING_THRESHOLD => 500, + PerformanceOption::SAMPLING_RATE => 1.0, + PerformanceOption::MAX_SAMPLES => 10000, + PerformanceOption::STORAGE_TYPE => PerformanceOption::STORAGE_MEMORY, + PerformanceOption::RETENTION_HOURS => 24, + PerformanceOption::AUTO_CLEANUP => true, + PerformanceOption::MEMORY_LIMIT_MB => 50, + PerformanceOption::TRACK_SELECT => true, + PerformanceOption::TRACK_INSERT => true, + PerformanceOption::TRACK_UPDATE => true, + PerformanceOption::TRACK_DELETE => true + ]; + } + + /** + * Get current memory usage in MB. + * + * @return float Memory usage in megabytes + */ + private function getMemoryUsage(): float { + return memory_get_usage(true) / 1024 / 1024; + } + + /** + * Get metrics from database. + * + * @return array + */ + private function getMetricsFromDatabase(): array { + if (!$this->database) { + return []; + } + + $result = $this->database->table('query_performance_metrics') + ->select() + ->execute(); + + return $result->getRows(); + } + + /** + * Extract query type from SQL. + * + * @param string $query SQL query + * @return string Query type (SELECT, INSERT, UPDATE, DELETE) + */ + private function getQueryType(string $query): string { + $query = trim(strtoupper($query)); + + if (str_starts_with($query, 'SELECT')) { + return 'SELECT'; + } + + if (str_starts_with($query, 'INSERT')) { + return 'INSERT'; + } + + if (str_starts_with($query, 'UPDATE')) { + return 'UPDATE'; + } + + if (str_starts_with($query, 'DELETE')) { + return 'DELETE'; + } + + return 'OTHER'; + } + + /** + * Extract row count from query result. + * + * @param mixed $result Query result + * @return int Row count + */ + private function getRowCount($result): int { + if ($result instanceof ResultSet) { + return $result->count(); + } + + return 0; + } + + /** + * Perform cleanup based on configuration. + */ + private function performCleanup(): void { + if (!$this->config[PerformanceOption::AUTO_CLEANUP]) { + return; + } + + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_DATABASE) { + $this->cleanupOldDatabaseMetrics(); + } + } + + /** + * Check if current query should be sampled. + * + * @return bool True if query should be sampled + */ + private function shouldSample(): bool { + return mt_rand() / mt_getrandmax() <= $this->config[PerformanceOption::SAMPLING_RATE]; + } + + /** + * Check if query should be tracked based on type. + * + * @param string $query SQL query + * @return bool True if query should be tracked + */ + private function shouldTrackQuery(string $query): bool { + $queryType = $this->getQueryType($query); + + return match ($queryType) { + 'SELECT' => $this->config[PerformanceOption::TRACK_SELECT], + 'INSERT' => $this->config[PerformanceOption::TRACK_INSERT], + 'UPDATE' => $this->config[PerformanceOption::TRACK_UPDATE], + 'DELETE' => $this->config[PerformanceOption::TRACK_DELETE], + default => false + }; + } + + /** + * Store metric in database. + * + * @param QueryMetric $metric + */ + private function storeInDatabase(QueryMetric $metric): void { + if (!$this->database) { + return; + } + + $this->ensureSchemaExists(); + + $this->database->table('query_performance_metrics') + ->insert($metric->toArray()) + ->execute(); + } + + /** + * Store metric in memory. + * + * @param QueryMetric $metric + */ + private function storeInMemory(QueryMetric $metric): void { + $this->memoryMetrics[] = $metric; + + if (count($this->memoryMetrics) > $this->config[PerformanceOption::MAX_SAMPLES]) { + array_shift($this->memoryMetrics); + } + } + + /** + * Validate configuration values. + * + * @throws InvalidArgumentException If configuration is invalid + */ + private function validateConfig(): void { + if (!is_bool($this->config[PerformanceOption::ENABLED])) { + throw new InvalidArgumentException('ENABLED must be boolean'); + } + + if ($this->config[PerformanceOption::SAMPLING_RATE] < 0 || $this->config[PerformanceOption::SAMPLING_RATE] > 1) { + throw new InvalidArgumentException('SAMPLING_RATE must be between 0.0 and 1.0'); + } + + if (!in_array($this->config[PerformanceOption::STORAGE_TYPE], [ + PerformanceOption::STORAGE_MEMORY, + PerformanceOption::STORAGE_DATABASE + ])) { + throw new InvalidArgumentException('Invalid STORAGE_TYPE'); + } + } +} diff --git a/WebFiori/Database/Condition.php b/WebFiori/Database/Query/Condition.php similarity index 98% rename from WebFiori/Database/Condition.php rename to WebFiori/Database/Query/Condition.php index 4724510f..3a82a54d 100644 --- a/WebFiori/Database/Condition.php +++ b/WebFiori/Database/Query/Condition.php @@ -3,13 +3,13 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * Represents a binary conditional statement for SQL WHERE clauses. diff --git a/WebFiori/Database/Expression.php b/WebFiori/Database/Query/Expression.php similarity index 95% rename from WebFiori/Database/Expression.php rename to WebFiori/Database/Query/Expression.php index 13b57fbf..9fa0918f 100644 --- a/WebFiori/Database/Expression.php +++ b/WebFiori/Database/Query/Expression.php @@ -3,13 +3,13 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * A class that can be used to represent any SQL expression. diff --git a/WebFiori/Database/InsertBuilder.php b/WebFiori/Database/Query/InsertBuilder.php similarity index 95% rename from WebFiori/Database/InsertBuilder.php rename to WebFiori/Database/Query/InsertBuilder.php index 614ae28c..c18f6016 100644 --- a/WebFiori/Database/InsertBuilder.php +++ b/WebFiori/Database/Query/InsertBuilder.php @@ -3,13 +3,16 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; + +use WebFiori\Database\Column; +use WebFiori\Database\Table; /** * A class which is used to build insert SQL queries for diffrent database engines. @@ -203,17 +206,16 @@ private function build() { } private function buildColsArr() { $colsArr = []; - $colsStr = ''; - $table = $this->getTable(); + $tableObj = $this->getTable(); foreach ($this->cols as $colKey) { - $colObj = $table->getColByKey($colKey); + $colObj = $tableObj->getColByKey($colKey); if ($colObj === null) { - $table->addColumns([ + $tableObj->addColumns([ $colKey => [] ]); - $colObj = $table->getColByKey($colKey); + $colObj = $tableObj->getColByKey($colKey); } $colObj->setWithTablePrefix(false); $colsArr[] = $colObj->getName(); @@ -263,7 +265,7 @@ private function initValsArr() { $colsAndVals = $this->data; if (isset($colsAndVals['cols']) && isset($colsAndVals['values'])) { - $cols = $colsAndVals['cols']; + $colsArr = $colsAndVals['cols']; $tempVals = $colsAndVals['values']; $temp = []; $topIndex = 0; @@ -272,14 +274,14 @@ private function initValsArr() { $index = 0; $temp[] = []; - foreach ($cols as $colKey) { + foreach ($colsArr as $colKey) { $temp[$topIndex][$colKey] = $valsArr[$index]; $index++; } $topIndex++; } $this->vals = $temp; - $this->cols = $cols; + $this->cols = $colsArr; } else { $this->cols = array_keys($colsAndVals); $this->vals = [$colsAndVals]; diff --git a/WebFiori/Database/SelectExpression.php b/WebFiori/Database/Query/SelectExpression.php similarity index 99% rename from WebFiori/Database/SelectExpression.php rename to WebFiori/Database/Query/SelectExpression.php index 40b90b6d..baac0aa5 100644 --- a/WebFiori/Database/SelectExpression.php +++ b/WebFiori/Database/Query/SelectExpression.php @@ -3,15 +3,19 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; use InvalidArgumentException; +use WebFiori\Database\AbstractQuery; +use WebFiori\Database\Column; +use WebFiori\Database\JoinTable; +use WebFiori\Database\Table; /** * A class which is used to build the select expression of a select query. diff --git a/WebFiori/Database/WhereExpression.php b/WebFiori/Database/Query/WhereExpression.php similarity index 98% rename from WebFiori/Database/WhereExpression.php rename to WebFiori/Database/Query/WhereExpression.php index 7b05a693..dba3714f 100644 --- a/WebFiori/Database/WhereExpression.php +++ b/WebFiori/Database/Query/WhereExpression.php @@ -3,13 +3,13 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * A class which is used to build 'where' expressions. diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php new file mode 100644 index 00000000..c49cd248 --- /dev/null +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -0,0 +1,340 @@ +db = $db; + } + + /** + * Returns the total number of records in the table. + * + * @return int Total record count. + */ + public function count(): int { + $result = $this->db->table($this->getTableName()) + ->selectCount(null, 'total') + ->execute(); + + return (int) $result->fetch()['total']; + } + + /** + * Deletes all records from the table. + */ + public function deleteAll(): void { + $this->db->table($this->getTableName()) + ->delete() + ->execute(); + } + + /** + * Deletes a record by its ID. + * + * If no ID is passed, uses the ID from $this. + * + * @param mixed $id The ID of the record to delete, or null to use $this->id. + * + * @throws \InvalidArgumentException If no ID is provided and $this has no ID. + */ + public function deleteById(mixed $id = null): void { + $id = $id ?? $this->getEntityId(); + if ($id === null) { + throw new RepositoryException('Cannot delete: no ID provided'); + } + $this->db->table($this->getTableName()) + ->delete() + ->where($this->getIdField(), $id) + ->execute(); + } + + /** + * Retrieves all records from the table. + * + * @return T[] Array of all entities. + */ + public function findAll(): array { + $result = $this->db->table($this->getTableName()) + ->select() + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + /** + * Finds a single record by its ID. + * + * @param mixed $id The ID to search for, or null to use $this->id. + * + * @return T|null The entity if found, null otherwise. + * + * @throws \InvalidArgumentException If no ID is provided and $this has no ID. + */ + public function findById(mixed $id = null): ?object { + $id = $id ?? $this->getEntityId(); + if ($id === null) { + throw new RepositoryException('Cannot find: no ID provided'); + } + $result = $this->db->table($this->getTableName()) + ->select() + ->where($this->getIdField(), $id) + ->execute(); + + return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; + } + + /** + * Reloads $this or the given entity from the database. + * + * @param T|null $entity The entity to reload, or null to reload $this. + * + * @return T|null Fresh entity from database, or null if not found. + * + * @throws \InvalidArgumentException If no entity provided and $this has no ID. + */ + public function reload(?object $entity = null): ?object { + if ($entity === null) { + return $this->findById(); + } + $id = $this->toArray($entity)[$this->getIdField()] ?? null; + return $this->findById($id); + } + + /** + * Gets the ID value from $this if it has entity properties. + * + * @return mixed The ID value or null. + */ + private function getEntityId(): mixed { + $idField = $this->getIdField(); + if (property_exists($this, $idField)) { + return $this->$idField; + } + return null; + } + + /** + * Retrieves paginated records using offset-based pagination. + * + * @param int $page Page number (1-based). + * @param int $perPage Number of records per page. + * @param array $orderBy Associative array of column => direction for sorting. + * + * @return Page Page object containing results and pagination metadata. + */ + public function paginate(int $page = 1, int $perPage = 20, array $orderBy = []): Page { + $page = max(1, $page); + $offset = ($page - 1) * $perPage; + + $total = $this->count(); + + $query = $this->db->table($this->getTableName()) + ->select() + ->limit($perPage) + ->offset($offset); + + if (!empty($orderBy)) { + $query->orderBy($orderBy); + } + + $result = $query->execute(); + $items = array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + + return new Page($items, $page, $perPage, $total); + } + + /** + * Retrieves paginated records using cursor-based pagination. + * + * @param string|null $cursor Base64-encoded cursor value, null for first page. + * @param int $limit Maximum number of records to return. + * @param string|null $cursorField Column to use for cursor, defaults to ID field. + * @param string $direction Sort direction ('ASC' or 'DESC'). + * + * @return CursorPage CursorPage object containing results and next cursor. + */ + public function paginateByCursor( + ?string $cursor = null, + int $limit = 20, + ?string $cursorField = null, + string $direction = 'ASC' + ): CursorPage { + $cursorField = $cursorField ?? $this->getIdField(); + $operator = $direction === 'ASC' ? '>' : '<'; + + $query = $this->db->table($this->getTableName())->select(); + + if ($cursor !== null) { + $cursorValue = base64_decode($cursor); + $query->where($cursorField, $cursorValue, $operator); + } + + $result = $query->orderBy([$cursorField => $direction]) + ->limit($limit + 1) + ->execute(); + + $rows = $result->fetchAll(); + $hasMore = count($rows) > $limit; + + if ($hasMore) { + array_pop($rows); + } + + $items = array_map(fn($row) => $this->toEntity($row), $rows); + + $nextCursor = null; + + if ($hasMore && !empty($rows)) { + $lastRow = end($rows); + $nextCursor = base64_encode((string) $lastRow[$cursorField]); + } + + return new CursorPage($items, $nextCursor, null, $hasMore); + } + + /** + * Saves an entity (insert if new, update if existing). + * + * An entity is considered new if its ID field is null. + * If no entity is passed and $this has entity properties, saves $this. + * + * @param T|null $entity The entity to save, or null to save $this. + * + * @throws \InvalidArgumentException If no entity is provided and $this has no entity properties. + */ + public function save(?object $entity = null): void { + if ($entity === null && !property_exists($this, $this->getIdField())) { + throw new RepositoryException('Cannot save: no entity provided'); + } + $entity = $entity ?? $this; + $data = $this->toArray($entity); + $id = $data[$this->getIdField()] ?? null; + unset($data[$this->getIdField()]); + + if ($id === null) { + $this->db->table($this->getTableName())->insert($data)->execute(); + } else { + $this->db->table($this->getTableName()) + ->update($data) + ->where($this->getIdField(), $id) + ->execute(); + } + } + + /** + * Saves multiple entities in a single transaction. + * + * New entities (null ID) are batch inserted in one query. + * Existing entities are updated individually. + * + * @param T[] $entities Array of entities to save. + */ + public function saveAll(array $entities): void { + if (empty($entities)) { + return; + } + + $newEntities = []; + $existingEntities = []; + $idField = $this->getIdField(); + + foreach ($entities as $entity) { + $data = $this->toArray($entity); + if (($data[$idField] ?? null) === null) { + unset($data[$idField]); + $newEntities[] = $data; + } else { + $existingEntities[] = $data; + } + } + + $this->db->transaction(function (Database $db) use ($newEntities, $existingEntities, $idField) { + if (!empty($newEntities)) { + $db->table($this->getTableName())->insert([ + 'cols' => array_keys($newEntities[0]), + 'values' => array_map(fn($e) => array_values($e), $newEntities) + ])->execute(); + } + + foreach ($existingEntities as $data) { + $id = $data[$idField]; + unset($data[$idField]); + $db->table($this->getTableName()) + ->update($data) + ->where($idField, $id) + ->execute(); + } + }); + } + + /** + * Creates a select query for the repository's table. + * + * @return AbstractQuery Query builder instance. + */ + protected function createQuery(): AbstractQuery { + return $this->db->table($this->getTableName())->select(); + } + + /** + * Returns the underlying database instance. + * + * @return Database The database connection. + */ + protected function getDatabase(): Database { + return $this->db; + } + + /** + * Returns the name of the ID/primary key field. + * + * @return string Column name of the primary key. + */ + abstract protected function getIdField(): string; + + /** + * Returns the database table name for this repository. + * + * @return string Table name. + */ + abstract protected function getTableName(): string; + + /** + * Converts an entity to an associative array for database operations. + * + * @param T $entity The entity to convert. + * + * @return array Associative array with column names as keys. + */ + abstract protected function toArray(object $entity): array; + + /** + * Converts a database row to an entity object. + * + * @param array $row Associative array from database. + * + * @return T The mapped entity. + */ + abstract protected function toEntity(array $row): object; +} diff --git a/WebFiori/Database/Repository/CursorPage.php b/WebFiori/Database/Repository/CursorPage.php new file mode 100644 index 00000000..fe41724e --- /dev/null +++ b/WebFiori/Database/Repository/CursorPage.php @@ -0,0 +1,42 @@ +items = $items; + $this->nextCursor = $nextCursor; + $this->previousCursor = $previousCursor; + $this->hasMore = $hasMore; + } + + /** @return T[] */ + public function getItems(): array { + return $this->items; + } + + public function getNextCursor(): ?string { + return $this->nextCursor; + } + + public function getPreviousCursor(): ?string { + return $this->previousCursor; + } + + public function hasMore(): bool { + return $this->hasMore; + } +} diff --git a/WebFiori/Database/Repository/Page.php b/WebFiori/Database/Repository/Page.php new file mode 100644 index 00000000..9e867a20 --- /dev/null +++ b/WebFiori/Database/Repository/Page.php @@ -0,0 +1,62 @@ +items = $items; + $this->currentPage = $currentPage; + $this->perPage = $perPage; + $this->totalItems = $totalItems; + } + + public function getCurrentPage(): int { + return $this->currentPage; + } + + /** @return T[] */ + public function getItems(): array { + return $this->items; + } + + public function getNextPage(): ?int { + return $this->hasNextPage() ? $this->currentPage + 1 : null; + } + + public function getPerPage(): int { + return $this->perPage; + } + + public function getPreviousPage(): ?int { + return $this->hasPreviousPage() ? $this->currentPage - 1 : null; + } + + public function getTotalItems(): int { + return $this->totalItems; + } + + public function getTotalPages(): int { + return (int) ceil($this->totalItems / $this->perPage); + } + + public function hasNextPage(): bool { + return $this->currentPage < $this->getTotalPages(); + } + + public function hasPreviousPage(): bool { + return $this->currentPage > 1; + } +} diff --git a/WebFiori/Database/Repository/RepositoryException.php b/WebFiori/Database/Repository/RepositoryException.php new file mode 100644 index 00000000..8a5d1d5b --- /dev/null +++ b/WebFiori/Database/Repository/RepositoryException.php @@ -0,0 +1,8 @@ +getRows()[$this->cursorPos]; } + public function fetch() : array { + if ($this->getCount() > 0) { + return $this->fetchAll()[0]; + } + + return []; + } + public function fetchAll() : array { + return $this->getRows(); + } /** * Filter the records of the result set using a custom callback. * @@ -116,6 +126,9 @@ public function filter(callable $filterFunction, array $mapArgs = []) : ResultSe return new ResultSet($result); } + public function getCount() : int { + return $this->getRowsCount(); + } /** * Returns an array which contains all original records in the set before * mapping. diff --git a/WebFiori/Database/Schema/AbstractMigration.php b/WebFiori/Database/Schema/AbstractMigration.php index fcae1bdc..6e05a0fe 100644 --- a/WebFiori/Database/Schema/AbstractMigration.php +++ b/WebFiori/Database/Schema/AbstractMigration.php @@ -1,98 +1,94 @@ -up($db); - } - - /** - * Get the environments where this migration should be executed. - * - * By default, migrations run in all environments (dev, test, prod). - * Override this method to restrict execution to specific environments. - * For example, return ['dev'] to only run in development. - * Migrations run in all environments by default. - * - * @return array Empty array means all environments. - */ - public function getEnvironments(): array { - return []; - } - - /** - * Get the type identifier for this database change. - * - * This method is used by the SchemaRunner to categorize and track - * different types of database changes. Migrations are distinguished - * from seeders by this type identifier. - * - * @return string Always returns 'migration'. - */ - public function getType(): string { - return 'migration'; - } - - /** - * Rollback the database change (undo the migration). - * - * This method contains the logic to rollback the database change by calling - * the down() method implemented by concrete migration classes. - * - * @param Database $db The database instance to execute rollback on. - */ - public function rollback(Database $db): void { - $this->down($db); - } - - /** - * Apply the migration changes to the database. - * - * This method should contain the forward migration logic such as: - * - Creating tables, columns, indexes - * - Modifying existing schema elements - * - Adding constraints and relationships - * - * @param Database $db The database instance to execute changes on. - */ - abstract public function up(Database $db): void; -} +up($db); + } + + /** + * Get the type identifier for this database change. + * + * This method is used by the SchemaRunner to categorize and track + * different types of database changes. Migrations are distinguished + * from seeders by this type identifier. + * + * @return string Always returns 'migration'. + */ + public function getType(): string { + return 'migration'; + } + + /** + * Rollback the database change (undo the migration). + * + * This method contains the logic to rollback the database change by calling + * the down() method implemented by concrete migration classes. + * + * @param Database $db The database instance to execute rollback on. + */ + public function rollback(Database $db): void { + $this->down($db); + } + + /** + * Apply the migration changes to the database. + * + * This method should contain the forward migration logic such as: + * - Creating tables, columns, indexes + * - Modifying existing schema elements + * - Adding constraints and relationships + * + * @param Database $db The database instance to execute changes on. + */ + abstract public function up(Database $db): void; +} diff --git a/WebFiori/Database/Schema/AbstractSeeder.php b/WebFiori/Database/Schema/AbstractSeeder.php index 8ca2a7da..cfd3f6aa 100644 --- a/WebFiori/Database/Schema/AbstractSeeder.php +++ b/WebFiori/Database/Schema/AbstractSeeder.php @@ -1,88 +1,83 @@ -run($db); - } - - /** - * Get the environments where this seeder should be executed. - * - * Seeders often need environment-specific behavior: - * - Production seeders: essential reference data only - * - Development seeders: sample data for testing - * - Test seeders: specific test fixtures - * Override this method to control execution environments. - * - * @return array Array of environment names. Empty array means all environments. - */ - public function getEnvironments(): array { - return []; - } - - /** - * Get the type identifier for this database change. - * - * This method is used by the SchemaRunner to categorize and track - * different types of database changes. Seeders are distinguished - * from migrations by this type identifier. - * - * @return string Always returns 'seeder'. - */ - public function getType(): string { - return 'seeder'; - } - - /** - * Rollback the database change (optional for seeders). - * - * Most seeders don't implement rollback functionality as data - * seeding is typically not reversible. Override this method - * if your seeder needs rollback capability. - * - * @param Database $db The database instance to execute rollback on. - */ - public function rollback(Database $db): void { - // Default implementation does nothing - // Override in concrete seeders if rollback is needed - } - - /** - * Run the seeder to populate the database with data. - * - * This method should contain the data insertion logic such as: - * - Inserting reference/lookup data - * - Creating default user accounts - * - Populating sample data for development - * - Setting up initial application configuration - * - * @param Database $db The database instance to execute seeding on. - */ - abstract public function run(Database $db): void; -} +run($db); + } + + /** + * Get the type identifier for this database change. + * + * This method is used by the SchemaRunner to categorize and track + * different types of database changes. Seeders are distinguished + * from migrations by this type identifier. + * + * @return string Always returns 'seeder'. + */ + public function getType(): string { + return 'seeder'; + } + + /** + * Rollback the database change (optional for seeders). + * + * Most seeders don't implement rollback functionality as data + * seeding is typically not reversible. Override this method + * if your seeder needs rollback capability. + * + * @param Database $db The database instance to execute rollback on. + */ + public function rollback(Database $db): void { + // Default implementation does nothing + // Override in concrete seeders if rollback is needed + } + + /** + * Run the seeder to populate the database with data. + * + * This method should contain the data insertion logic such as: + * - Inserting reference/lookup data + * - Creating default user accounts + * - Populating sample data for development + * - Setting up initial application configuration + * + * @param Database $db The database instance to execute seeding on. + */ + abstract public function run(Database $db): void; +} diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index 35519c97..1bee24f5 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -1,144 +1,185 @@ -setAppliedAt(date('Y-m-d H:i:s')); - } - /** - * Execute the database change. - * - * @param Database $db The database instance to execute against. - */ - /** - * Execute the database change (apply the migration or seeder). - * - * This method contains the logic to apply the database change. - * For migrations: create/modify tables, columns, indexes, etc. - * For seeders: insert data into tables. - * - * @param Database $db The database instance to execute changes on. - */ - abstract public function execute(Database $db): void; - /** - * Get the timestamp when this change was applied. - * - * @return string The date and time when this change was applied in Y-m-d H:i:s format. - */ - public function getAppliedAt(): string { - return $this->appliedAt; - } - - /** - * Get the list of changes this change depends on. - * - * Dependencies ensure changes are executed in the correct order. - * For example, a migration that adds a foreign key depends on - * the migration that creates the referenced table. - * - * @return array Array of class names that must be executed before this change. - */ - public function getDependencies(): array { - return []; - } - - /** - * Get the unique identifier for this database change. - * - * @return int The unique identifier assigned by the schema tracking system. - */ - public function getId(): int { - return $this->id; - } - - - /** - * Get the name of this database change. - * - * The name is derived from the class name and used for tracking - * and identification purposes in the schema management system. - * - * @return string The class name of this database change. - */ - public function getName(): string { - return static::class; - } - - /** - * Get the type of database change. - * - * @return string Either 'migration' or 'seeder'. - */ - abstract public function getType(): string; - - /** - * Rollback the database change (undo the migration or seeder). - * - * This method contains the logic to reverse the database change. - * For migrations: drop tables, remove columns, etc. - * For seeders: typically not implemented as data rollback is complex. - * - * @param Database $db The database instance to execute rollback on. - */ - abstract public function rollback(Database $db): void; - - /** - * Set the timestamp when this change was applied. - * - * @param string $date The date and time in Y-m-d H:i:s format. - */ - public function setAppliedAt(string $date) { - $this->appliedAt = $date; - } - /** - * Set the unique identifier for this database change. - * - * @param int $id The unique identifier assigned by the schema tracking system. - */ - public function setId(int $id) { - $this->id = $id; - } - - /** - * Set the database instance for this change. - * - * @param Database $db The database instance to use for this change. - */ - public function setDatabase(Database $db): void { - $this->database = $db; - } - - /** - * Get the database instance for this change. - * - * @return Database|null The database instance or null if not set. - */ - public function getDatabase(): ?Database { - return $this->database; - } -} +setAppliedAt(date('Y-m-d H:i:s')); + } + /** + * Execute the database change (apply the migration or seeder). + * + * This method contains the logic to apply the database change. + * For migrations: create/modify tables, columns, indexes, etc. + * For seeders: insert data into tables. + * + * @param Database $db The database instance to execute changes on. + */ + abstract public function execute(Database $db): void; + /** + * Get the timestamp when this change was applied. + * + * @return string The date and time when this change was applied in Y-m-d H:i:s format. + */ + public function getAppliedAt(): string { + return $this->appliedAt; + } + + /** + * Get the batch number when this change was applied. + * + * @return int The batch number, or 0 if not yet applied. + */ + public function getBatch(): int { + return $this->batch; + } + + /** + * Get the list of changes this change depends on. + * + * Dependencies ensure changes are executed in the correct order. + * For example, a migration that adds a foreign key depends on + * the migration that creates the referenced table. + * + * @return array Array of class names that must be executed before this change. + */ + public function getDependencies(): array { + return []; + } + + /** + * Get the environments where this change should be executed. + * + * Override this method to restrict execution to specific environments. + * + * @return array Array of environment names. Empty array means all environments. + */ + public function getEnvironments(): array { + return []; + } + + /** + * Get the unique identifier for this database change. + * + * @return int The unique identifier assigned by the schema tracking system. + */ + public function getId(): int { + return $this->id; + } + + + /** + * Get the name of this database change. + * + * The name is derived from the class name and used for tracking + * and identification purposes in the schema management system. + * + * @return string The class name of this database change. + */ + public function getName(): string { + return static::class; + } + + /** + * Get the type of database change. + * + * @return string Either 'migration' or 'seeder'. + */ + abstract public function getType(): string; + + /** + * Rollback the database change (undo the migration or seeder). + * + * This method contains the logic to reverse the database change. + * For migrations: drop tables, remove columns, etc. + * For seeders: typically not implemented as data rollback is complex. + * + * @param Database $db The database instance to execute rollback on. + */ + abstract public function rollback(Database $db): void; + + /** + * Set the timestamp when this change was applied. + * + * @param string $date The date and time in Y-m-d H:i:s format. + */ + public function setAppliedAt(string $date) { + $this->appliedAt = $date; + } + + /** + * Set the batch number for this change. + * + * @param int $batch The batch number. + */ + public function setBatch(int $batch): void { + $this->batch = $batch; + } + /** + * Set the unique identifier for this database change. + * + * @param int $id The unique identifier assigned by the schema tracking system. + */ + public function setId(int $id) { + $this->id = $id; + } + + /** + * Determine if this change should be wrapped in a database transaction. + * + * Override this method to control transaction behavior. By default, + * changes are wrapped in transactions for safety. + * + * Guidelines: + * - Return true for DML operations (INSERT, UPDATE, DELETE) - always safe + * - Return true for DDL on MSSQL/PostgreSQL - they support transactional DDL + * - Return false for DDL on MySQL - it auto-commits and can't be rolled back + * + * For DBMS-aware behavior, override and check the database type: + * ```php + * public function useTransaction(Database $db): bool { + * return $db->getConnectionInfo()->getDatabaseType() !== 'mysql'; + * } + * ``` + * + * @param Database $db The database instance (for DBMS-aware decisions). + * @return bool True to wrap in transaction, false to execute directly. + */ + public function useTransaction(Database $db): bool { + return true; + } +} diff --git a/WebFiori/Database/Schema/DatabaseChangeGenerator.php b/WebFiori/Database/Schema/DatabaseChangeGenerator.php new file mode 100644 index 00000000..cd8fa072 --- /dev/null +++ b/WebFiori/Database/Schema/DatabaseChangeGenerator.php @@ -0,0 +1,256 @@ +buildMigrationContent($name, $dependencies, $table); + + return $this->writeFile($name, $content); + } + + /** + * Create a seeder class file. + * + * @param string $name The class name (e.g., 'UsersSeeder'). + * @param array $options Optional settings: + * - GeneratorOption::ENVIRONMENTS: array of environments where seeder should run + * - GeneratorOption::DEPENDENCIES: array of class names this seeder depends on + * @return string The full path to the created file. + */ + public function createSeeder(string $name, array $options = []): string { + $environments = $options[GeneratorOption::ENVIRONMENTS] ?? []; + $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + + $content = $this->buildSeederContent($name, $environments, $dependencies); + + return $this->writeFile($name, $content); + } + + /** + * Get the configured namespace. + */ + public function getNamespace(): string { + return $this->namespace; + } + + /** + * Get the configured path. + */ + public function getPath(): string { + return $this->path; + } + + /** + * Check if timestamp prefix is enabled. + */ + public function isTimestampPrefixEnabled(): bool { + return $this->useTimestamp; + } + + /** + * Set the namespace for generated classes. + * + * @param string $namespace The PHP namespace. + */ + public function setNamespace(string $namespace): self { + $this->namespace = trim($namespace, '\\'); + + return $this; + } + + /** + * Set the directory path where generated files will be saved. + * + * @param string $path Absolute path to the directory. + */ + public function setPath(string $path): self { + $this->path = rtrim($path, DIRECTORY_SEPARATOR); + + return $this; + } + + /** + * Enable or disable timestamp prefix in filenames. + * + * When enabled, files are named like: 2026_01_04_175000_CreateUsersTable.php + * + * @param bool $use True to enable timestamp prefix. + */ + public function useTimestampPrefix(bool $use): self { + $this->useTimestamp = $use; + + return $this; + } + + private function buildMigrationContent(string $name, array $dependencies, ?string $table): string { + $lines = []; + $lines[] = 'namespace) { + $lines[] = 'namespace '.$this->namespace.';'; + $lines[] = ''; + } + + $lines[] = 'use WebFiori\Database\Schema\AbstractMigration;'; + $lines[] = 'use WebFiori\Database\Database;'; + $lines[] = ''; + $lines[] = "class {$name} extends AbstractMigration {"; + + // Add getDependencies if specified + if (!empty($dependencies)) { + $lines[] = ''; + $lines[] = ' public function getDependencies(): array {'; + $lines[] = ' return ['; + + foreach ($dependencies as $dep) { + $lines[] = ' '.$this->formatDependency($dep).','; + } + $lines[] = ' ];'; + $lines[] = ' }'; + } + + $lines[] = ''; + $lines[] = ' public function up(Database $db): void {'; + + if ($table) { + $lines[] = " // TODO: Create or modify table '{$table}'"; + } else { + $lines[] = ' // TODO: Implement migration'; + } + $lines[] = ' }'; + $lines[] = ''; + $lines[] = ' public function down(Database $db): void {'; + + if ($table) { + $lines[] = " // TODO: Reverse changes to table '{$table}'"; + } else { + $lines[] = ' // TODO: Reverse migration'; + } + $lines[] = ' }'; + $lines[] = '}'; + $lines[] = ''; + + return implode("\n", $lines); + } + + private function buildSeederContent(string $name, array $environments, array $dependencies): string { + $lines = []; + $lines[] = 'namespace) { + $lines[] = 'namespace '.$this->namespace.';'; + $lines[] = ''; + } + + $lines[] = 'use WebFiori\Database\Schema\AbstractSeeder;'; + $lines[] = 'use WebFiori\Database\Database;'; + $lines[] = ''; + $lines[] = "class {$name} extends AbstractSeeder {"; + + // Add getEnvironments if specified + if (!empty($environments)) { + $lines[] = ''; + $lines[] = ' public function getEnvironments(): array {'; + $lines[] = ' return ['.$this->formatStringArray($environments).'];'; + $lines[] = ' }'; + } + + // Add getDependencies if specified + if (!empty($dependencies)) { + $lines[] = ''; + $lines[] = ' public function getDependencies(): array {'; + $lines[] = ' return ['; + + foreach ($dependencies as $dep) { + $lines[] = ' '.$this->formatDependency($dep).','; + } + $lines[] = ' ];'; + $lines[] = ' }'; + } + + $lines[] = ''; + $lines[] = ' public function run(Database $db): void {'; + $lines[] = ' // TODO: Implement seeder'; + $lines[] = ' }'; + $lines[] = '}'; + $lines[] = ''; + + return implode("\n", $lines); + } + + private function formatDependency(string $dep): string { + // If it looks like a fully qualified class name, use ::class syntax + if (str_contains($dep, '\\')) { + return $dep.'::class'; + } + + // If it's a simple class name, also use ::class syntax + if (preg_match('/^[A-Z][a-zA-Z0-9_]*$/', $dep)) { + return $dep.'::class'; + } + + // Otherwise treat as string + return "'".$dep."'"; + } + + private function formatStringArray(array $items): string { + $formatted = array_map(fn($item) => "'{$item}'", $items); + + return implode(', ', $formatted); + } + + private function writeFile(string $name, string $content): string { + if (empty($this->path)) { + throw new DatabaseException('Path not set. Call setPath() first.'); + } + + if (!is_dir($this->path)) { + mkdir($this->path, 0755, true); + } + + $filename = $this->useTimestamp + ? date('Y_m_d_His').'_'.$name.'.php' + : $name.'.php'; + + $fullPath = $this->path.DIRECTORY_SEPARATOR.$filename; + file_put_contents($fullPath, $content); + + return $fullPath; + } +} diff --git a/WebFiori/Database/Schema/DatabaseChangeResult.php b/WebFiori/Database/Schema/DatabaseChangeResult.php new file mode 100644 index 00000000..aa1fe2f6 --- /dev/null +++ b/WebFiori/Database/Schema/DatabaseChangeResult.php @@ -0,0 +1,156 @@ + Changes that were successfully applied + */ + private array $applied = []; + + /** + * @var ConnectionInfo|null Connection info for the database changes were applied to + */ + private ?ConnectionInfo $connectionInfo = null; + + /** + * @var array Changes that failed + */ + private array $failed = []; + + /** + * @var array Changes that were skipped + */ + private array $skipped = []; + + /** + * @var float Total execution time in milliseconds + */ + private float $totalTimeMs = 0; + + /** + * Add an applied change. + */ + public function addApplied(DatabaseChange $change): void { + $this->applied[] = $change; + } + + /** + * Add a failed change with error. + */ + public function addFailed(DatabaseChange $change, \Throwable $error): void { + $this->failed[] = ['change' => $change, 'error' => $error]; + } + + /** + * Add a skipped change with reason. + */ + public function addSkipped(DatabaseChange $change, string $reason): void { + $this->skipped[] = ['change' => $change, 'reason' => $reason]; + } + + /** + * Get count of applied changes (Countable interface). + */ + public function count(): int { + return count($this->applied); + } + + /** + * Get all applied changes. + * + * @return array + */ + public function getApplied(): array { + return $this->applied; + } + + /** + * Get the connection info for the database changes were applied to. + */ + public function getConnectionInfo(): ?ConnectionInfo { + return $this->connectionInfo; + } + + /** + * Get the database name changes were applied to. + */ + public function getDatabaseName(): ?string { + return $this->connectionInfo?->getDBName(); + } + + /** + * Get all failed changes with errors. + * + * @return array + */ + public function getFailed(): array { + return $this->failed; + } + + /** + * Iterate over applied changes (IteratorAggregate interface). + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->applied); + } + + /** + * Get all skipped changes with reasons. + * + * @return array + */ + public function getSkipped(): array { + return $this->skipped; + } + + /** + * Get total execution time in milliseconds. + */ + public function getTotalTime(): float { + return $this->totalTimeMs; + } + + /** + * Check if all changes were successful (none failed). + */ + public function isSuccessful(): bool { + return empty($this->failed); + } + + /** + * Set the connection info for the database changes were applied to. + */ + public function setConnectionInfo(ConnectionInfo $connectionInfo): void { + $this->connectionInfo = $connectionInfo; + } + + /** + * Set total execution time. + */ + public function setTotalTime(float $timeMs): void { + $this->totalTimeMs = $timeMs; + } +} diff --git a/WebFiori/Database/Schema/GeneratorOption.php b/WebFiori/Database/Schema/GeneratorOption.php new file mode 100644 index 00000000..73828f3c --- /dev/null +++ b/WebFiori/Database/Schema/GeneratorOption.php @@ -0,0 +1,35 @@ +count(); + $this->deleteAll(); + return $count; + } + + /** + * Count applied changes. + * + * @param array $conditions Optional conditions (e.g., ['type' => 'migration']) + * @return int Number of applied changes + */ + public function count(array $conditions = []): int { + $query = $this->getDatabase()->table($this->getTableName())->select(); + + foreach ($conditions as $col => $val) { + $query->where($col, $val); + } + + return $query->execute()->getRowsCount(); + } + + /** + * Get all applied changes. + * + * @return array Array of change records + */ + public function getAllApplied(): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->execute() + ->getRows(); + } + + /** + * Get all applied migrations. + * + * @return array Array of migration records + */ + public function getAllMigrations(): array { + return $this->getByType('migration'); + } + + /** + * Get all applied seeders. + * + * @return array Array of seeder records + */ + public function getAllSeeders(): array { + return $this->getByType('seeder'); + } + + /** + * Get changes by batch number. + * + * @param int $batch The batch number + * @return array Array of change records + */ + public function getByBatch(int $batch): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('batch', $batch) + ->execute() + ->getRows(); + } + + /** + * Get applied changes by type. + * + * @param string $type Either 'migration' or 'seeder' + * @return array Array of change records + */ + public function getByType(string $type): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('type', $type) + ->execute() + ->getRows(); + } + + /** + * Get change names from the last batch. + * + * @return array Array of change names from the last batch + */ + public function getLastBatchChangeNames(): array { + $lastBatch = $this->getLastBatchNumber(); + + if ($lastBatch === 0) { + return []; + } + + $records = $this->getByBatch($lastBatch); + + return array_column($records, 'change_name'); + } + + /** + * Get the last batch number. + * + * @return int The last batch number, or 0 if no batches exist + */ + public function getLastBatchNumber(): int { + return $this->getNextBatchNumber() - 1; + } + + /** + * Get the next batch number. + * + * @return int The next batch number + */ + public function getNextBatchNumber(): int { + $result = $this->getDatabase()->table($this->getTableName()) + ->select(['batch']) + ->orderBy(['batch' => 'd']) + ->limit(1) + ->execute(); + + if ($result->getRowsCount() === 0) { + return 1; + } + + return (int) $result->getRows()[0]['batch'] + 1; + } + + /** + * Get the table name. + * + * @return string The table name + */ + public function getTableName(): string { + return 'schema_changes'; + } + + /** + * Check if a change has been applied. + * + * @param string $changeName The fully qualified class name of the change + * @return bool True if the change has been applied, false otherwise + */ + public function isApplied(string $changeName): bool { + return $this->count(['change_name' => $changeName]) > 0; + } + + /** + * Record a change as applied. + * + * @param DatabaseChange $change The change to record (must have batch set via setBatch()) + * @return int The ID of the inserted record + */ + public function recordChange(DatabaseChange $change): int { + $this->getDatabase()->table($this->getTableName()) + ->insert([ + 'change_name' => $change->getName(), + 'type' => $change->getType(), + 'applied-on' => date('Y-m-d H:i:s'), + 'db-name' => $this->getDatabase()->getConnectionInfo()->getDBName(), + 'batch' => $change->getBatch() + ])->execute(); + + return $this->getLastInsertId(); + } + + /** + * Remove a change record. + * + * @param string $changeName The fully qualified class name of the change + * @return int Number of records deleted + */ + public function removeChange(string $changeName): int { + return $this->getDatabase()->table($this->getTableName()) + ->delete() + ->where('change_name', $changeName) + ->execute() + ->getRowsCount(); + } + + /** + * Get the last insert ID from the database connection. + * + * @return int The last insert ID, or 0 if not available + */ + private function getLastInsertId(): int { + return (int)$this->getDatabase() + ->getQueryGenerator() + ->selectMax($this->getIdField(), 'max') + ->execute() + ->getRows()[0]['max']; + } + + /** + * Get the ID field name. + * + * @return string The ID field name + */ + protected function getIdField(): string { + return 'id'; + } + + /** + * Convert an entity to an array (not used for schema changes). + * + * @param object $entity The entity + * @return array The array representation + */ + protected function toArray(object $entity): array { + return (array) $entity; + } + + /** + * Convert a database record to an entity (not used for schema changes). + * + * @param array $row The database record + * @return object The entity + */ + protected function toEntity(array $row): object { + // Schema changes are not converted to entities, return stdClass + return (object) $row; + } +} diff --git a/WebFiori/Database/Schema/SchemaException.php b/WebFiori/Database/Schema/SchemaException.php new file mode 100644 index 00000000..c8740d28 --- /dev/null +++ b/WebFiori/Database/Schema/SchemaException.php @@ -0,0 +1,8 @@ +setPath(/path/to/migrations); - * $runner->setNamespace(App\Migrations); - * $runner->setEnvironment(dev); - * $runner->runAll(); // Execute all pending changes - * ``` - * - * @author Ibrahim - */ -class SchemaRunner extends Database { - private $dbChanges; - private $environment; - private $onErrCallbacks; - private $onRegErrCallbacks; - /** - * Initialize a new schema runner with configuration. - * - * @param ConnectionInfo|null $connectionInfo Database connection information. - * @param string $environment Target environment (dev, test, prod) - affects which changes run. - */ - public function __construct(?ConnectionInfo $connectionInfo, string $environment = 'dev') { - parent::__construct($connectionInfo); - $this->environment = $environment; - $dbType = $connectionInfo !== null ? $connectionInfo->getDatabaseType() : 'mysql'; - $this->onErrCallbacks = []; - $this->onRegErrCallbacks = []; - $this->createBlueprint('schema_changes')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true, - ColOption::IDENTITY => true, - ColOption::COMMENT => 'The unique identifier of the change.' - ], - 'change_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::COMMENT => 'The name of the change.' - ], - 'type' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20, - ColOption::COMMENT => 'The type of the change (migration, seeder, etc.).' - ], - 'db-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::COMMENT => 'The name of the database at which the migration was applied to.' - ], - 'applied-on' => [ - ColOption::TYPE => $dbType == ConnectionInfo::SUPPORTED_DATABASES[1] ? DataType::DATETIME2 : DataType::DATETIME, - ColOption::COMMENT => 'The date and time at which the change was applied.' - ] - ]); - - $this->dbChanges = []; - } - /** - * Register a callback to handle execution errors. - * - * The callback will be invoked when a migration or seeder fails during execution. - * Multiple callbacks can be registered and will be called in registration order. - * - * @param callable $callback Function to call on execution errors. Receives error details. - */ - public function addOnErrorCallback(callable $callback): void { - $this->onErrCallbacks[] = $callback; - } - /** - * Register a callback to handle class registration errors. - * - * The callback will be invoked when a migration or seeder class cannot be - * properly loaded or instantiated during the discovery process. - * - * @param callable $callback Function to call on registration errors. Receives error details. - */ - public function addOnRegisterErrorCallback(callable $callback): void { - $this->onRegErrCallbacks[] = $callback; - - if (empty($this->dbChanges)) { - // No changes registered - } - } - - /** - * Apply all pending database changes. - * - * @return array Array of applied DatabaseChange instances. - */ - public function apply(): array { - $applied = []; - - // Keep applying changes until no more can be applied - $appliedInPass = true; - - while ($appliedInPass) { - $appliedInPass = false; - - foreach ($this->dbChanges as $change) { - if ($this->isApplied($change->getName())) { - continue; - } - - if (!$this->shouldRunInEnvironment($change)) { - continue; - } - - if (!$this->areDependenciesSatisfied($change)) { - continue; - } - - try { - $this->transaction(function($db) use ($change) { - $change->execute($db); - $db->table('schema_changes') - ->insert([ - 'change_name' => $change->getName(), - 'type' => $change->getType(), - 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $db->getConnectionInfo()->getDatabase() - ])->execute(); - }); - - $applied[] = $change; - $appliedInPass = true; - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - // Continue with next change instead of breaking - } - } - } - - return $applied; - } - - /** - * Apply the next pending database change. - * - * @return DatabaseChange|null The applied change, or null if no pending changes. - */ - public function applyOne(): ?DatabaseChange { - $change = null; - try { - foreach ($this->dbChanges as $change) { - if ($this->isApplied($change->getName())) { - continue; - } - - if (!$this->shouldRunInEnvironment($change)) { - continue; - } - - if (!$this->areDependenciesSatisfied($change)) { - continue; - } - - $this->transaction(function($db) use ($change) { - $migrationDb = $change->getDatabase() ?? $db; - $change->execute($migrationDb); - $db->table('schema_changes') - ->insert([ - 'change_name' => $change->getName(), - 'type' => $change->getType(), - 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $db->getConnectionInfo()->getDatabase() - ])->execute(); - }); - - return $change; - } - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - } - - return null; - } - - /** - * Remove all registered execution error callbacks. - */ - public function clearErrorCallbacks(): void { - $this->onErrCallbacks = []; - } - - /** - * Remove all registered class registration error callbacks. - */ - public function clearRegisterErrorCallbacks(): void { - $this->onRegErrCallbacks = []; - } - - /** - * Create the schema tracking table if it does not exist. - * - * This table stores information about which migrations and seeders - * have been applied, including timestamps and execution status. - * Required for tracking database change history. - */ - public function createSchemaTable() { - $this->createTables(); - $this->execute(); - } - - /** - * Drop the schema tracking table from the database. - * - * Removes the table that stores migration and seeder execution history. - * Use with caution as this will lose all tracking information. - */ - public function dropSchemaTable() { - $this->table('schema_changes')->drop(); - $this->execute(); - } - - /** - * Get all discovered database changes (migrations and seeders). - * - * @return array Array of DatabaseChange instances found in the configured path. - */ - public function getChanges(): array { - return $this->dbChanges; - } - - /** - * Get the current execution environment. - * - * The environment determines which migrations and seeders will be executed. - * Changes can specify which environments they should run in. - * - * @return string The current environment (e.g., 'dev', 'test', 'prod'). - */ - public function getEnvironment(): string { - return $this->environment; - } - - /** - * Check if a database change exists in the discovered changes. - * - * @param string $name The class name of the change to check. - * @return bool True if the change exists, false otherwise. - */ - public function hasChange(string $name): bool { - return $this->findChangeByName($name) !== null; - } - - /** - * Check if a specific database change has been applied. - * - * @param string $name The class name of the change to check. - * @return bool True if the change has been applied, false otherwise. - */ - public function isApplied(string $name): bool { - return $this->table('schema_changes') - ->select(['change_name']) - ->where('change_name', $name) - ->execute() - ->getRowsCount() == 1; - } - /** - * Rollback database changes up to a specific change. - * - * @param string|null $changeName The change to rollback to, or null to rollback all. - * @return array Array of rolled back DatabaseChange instances. - */ - public function rollbackUpTo(?string $changeName): array { - $changes = array_reverse($this->getChanges()); - $rolled = []; - - if (empty($changes)) { - return $rolled; - } - - if ($changeName !== null && $this->hasChange($changeName)) { - foreach ($changes as $change) { - if ($change->getName() == $changeName && $this->isApplied($change->getName())) { - $this->attemptRoolback($change, $rolled); - - return $rolled; - } - } - } else if ($changeName === null) { - foreach ($changes as $change) { - if ($this->isApplied($change->getName()) && !$this->attemptRoolback($change, $rolled)) { - return $rolled; - } - } - } - - return $rolled; - } - - private function areDependenciesSatisfied(DatabaseChange $change): bool { - foreach ($change->getDependencies() as $depName) { - if (!$this->isApplied($depName)) { - return false; - } - } - - return true; - } - private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { - try { - $migrationDb = $change->getDatabase() ?? $this; - $change->rollback($migrationDb); - $this->table('schema_changes')->delete()->where('change_name', $change->getName())->execute(); - $rolled[] = $change; - - return true; - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - - return false; - } - } - - private function findChangeByName(string $name): ?DatabaseChange { - foreach ($this->dbChanges as $change) { - $changeName = $change->getName(); - - // Exact match - if ($changeName === $name) { - return $change; - } - - // Check if name is a short class name and change is full class name - if (str_ends_with($changeName, '\\'.$name)) { - return $change; - } - - // Check if name is full class name and change is short class name - if (str_ends_with($name, '\\'.$changeName)) { - return $change; - } - } - - return null; - } - - /** - * Register a database change. - * - * @param DatabaseChange|string $change The change instance or class name. - * @return bool True if registered successfully, false otherwise. - */ - public function register(DatabaseChange|string $change): bool { - try { - if (is_string($change)) { - if (!class_exists($change)) { - throw new Exception("Class does not exist: {$change}"); - } - - if (!is_subclass_of($change, DatabaseChange::class)) { - throw new Exception("Class is not a subclass of DatabaseChange: {$change}"); - } - - $change = new $change(); - } - - $this->dbChanges[] = $change; - return true; - - } catch (Throwable $ex) { - foreach ($this->onRegErrCallbacks as $callback) { - call_user_func_array($callback, [$ex]); - } - return false; - } - } - - /** - * Register multiple database changes. - * - * @param array $changes Array of DatabaseChange instances or class names. - */ - public function registerAll(array $changes): void { - foreach ($changes as $change) { - $this->register($change); - } - } - - private function shouldRunInEnvironment(DatabaseChange $change): bool { - $environments = $change->getEnvironments(); - - return empty($environments) || in_array($this->environment, $environments); - } - - private function sortChangesByDependencies() { - $sorted = []; - $visited = []; - - foreach ($this->dbChanges as $change) { - $visiting = []; - $this->topologicalSort($change, $visited, $sorted, $visiting); - } - - $this->dbChanges = $sorted; - } - - private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting) { - $className = $change->getName(); - - if (isset($visiting[$className])) { - $cycle = array_merge(array_keys($visiting), [$className]); - throw new DatabaseException('Circular dependency detected: '.implode(' -> ', $cycle)); - } - - if (isset($visited[$className])) { - return; - } - - $visiting[$className] = true; - - foreach ($change->getDependencies() as $depName) { - $dep = $this->findChangeByName($depName); - - if ($dep) { - $this->topologicalSort($dep, $visited, $sorted, $visiting); - } - } - - unset($visiting[$className]); - $visited[$className] = true; - $sorted[] = $change; - } -} +setPath(/path/to/migrations); + * $runner->setNamespace(App\Migrations); + * $runner->setEnvironment(dev); + * $runner->runAll(); // Execute all pending changes + * ``` + * + * @author Ibrahim + */ +class SchemaRunner extends Database { + private $dbChanges; + private $environment; + private $onErrCallbacks; + private $onRegErrCallbacks; + private SchemaChangeRepository $repository; + /** + * Initialize a new schema runner with configuration. + * + * @param ConnectionInfo|null $connectionInfo Database connection information. + * @param string $environment Target environment (dev, test, prod) - affects which changes run. + */ + public function __construct(?ConnectionInfo $connectionInfo, string $environment = 'dev') { + parent::__construct($connectionInfo); + $this->environment = $environment; + $this->onErrCallbacks = []; + $this->onRegErrCallbacks = []; + + $table = AttributeTableBuilder::build( + SchemaMigrationsTable::class, + $this->getConnectionInfo()->getDatabaseType() + ); + + // Handle MSSQL datetime2 type + if ($this->getConnectionInfo()->getDatabaseType() === ConnectionInfo::SUPPORTED_DATABASES[1]) { + $table->getColByKey('applied-on')->setDatatype(DataType::DATETIME2); + } + + $this->addTable($table); + + $this->repository = new SchemaChangeRepository($this); + $this->dbChanges = []; + } + /** + * Register a callback to handle execution errors. + * + * The callback will be invoked when a migration or seeder fails during execution. + * Multiple callbacks can be registered and will be called in registration order. + * + * @param callable $callback Function to call on execution errors. Receives error details. + */ + public function addOnErrorCallback(callable $callback): void { + $this->onErrCallbacks[] = $callback; + } + /** + * Register a callback to handle class registration errors. + * + * The callback will be invoked when a migration or seeder class cannot be + * properly loaded or instantiated during the discovery process. + * + * @param callable $callback Function to call on registration errors. Receives error details. + */ + public function addOnRegisterErrorCallback(callable $callback): void { + $this->onRegErrCallbacks[] = $callback; + + if (empty($this->dbChanges)) { + // No changes registered + } + } + + /** + * Apply all pending database changes. + * + * All changes applied in a single call to apply() are assigned the same + * batch number, allowing them to be rolled back together. + * + * @return DatabaseChangeResult Result containing applied, skipped, and failed changes. + */ + public function apply(): DatabaseChangeResult { + $result = new DatabaseChangeResult(); + $result->setConnectionInfo($this->getConnectionInfo()); + $batch = $this->getRepository()->getNextBatchNumber(); + $startTime = microtime(true); + + // Track which changes we've already processed + $processed = []; + + // Keep applying changes until no more can be applied + $appliedInPass = true; + + while ($appliedInPass) { + $appliedInPass = false; + + foreach ($this->dbChanges as $change) { + $name = $change->getName(); + + if (isset($processed[$name])) { + continue; + } + + if ($this->isApplied($name)) { + $processed[$name] = true; + $result->addSkipped($change, 'Already applied'); + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + $processed[$name] = true; + $result->addSkipped($change, 'Environment mismatch'); + continue; + } + + if (!$this->areDependenciesSatisfied($change)) { + continue; // Don't mark as processed - may be satisfied later + } + + try { + $this->executeChange($change); + $change->setBatch($batch); + $this->getRepository()->recordChange($change); + $result->addApplied($change); + $processed[$name] = true; + $appliedInPass = true; + } catch (\Throwable $ex) { + $result->addFailed($change, $ex); + $processed[$name] = true; + + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } + } + } + + // Mark unprocessed changes as skipped (unsatisfied dependencies) + foreach ($this->dbChanges as $change) { + if (!isset($processed[$change->getName()])) { + $result->addSkipped($change, 'Unsatisfied dependencies'); + } + } + + $result->setTotalTime((microtime(true) - $startTime) * 1000); + + return $result; + } + + /** + * Apply the next pending database change. + * + * Each call to applyOne() creates a new batch with a single change. + * + * @return DatabaseChange|null The applied change, or null if no pending changes. + */ + public function applyOne(): ?DatabaseChange { + $change = null; + $batch = $this->getRepository()->getNextBatchNumber(); + + try { + foreach ($this->dbChanges as $change) { + if ($this->isApplied($change->getName())) { + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + continue; + } + + if (!$this->areDependenciesSatisfied($change)) { + continue; + } + + try { + $this->executeChange($change); + $change->setBatch($batch); + $this->getRepository()->recordChange($change); + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } + + return $change; + } + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } + + return null; + } + + /** + * Remove all registered execution error callbacks. + */ + public function clearErrorCallbacks(): void { + $this->onErrCallbacks = []; + } + + /** + * Remove all registered class registration error callbacks. + */ + public function clearRegisterErrorCallbacks(): void { + $this->onRegErrCallbacks = []; + } + + /** + * Create the schema tracking table if it does not exist. + * + * This table stores information about which migrations and seeders + * have been applied, including timestamps and execution status. + * Required for tracking database change history. + */ + public function createSchemaTable() { + $this->createTables(); + $this->execute(); + } + + /** + * Discover and register database changes from a directory. + * + * Scans the specified directory for PHP files containing classes that extend + * DatabaseChange (migrations and seeders). Each discovered class is automatically + * registered with the schema runner. + * + * @param string $path Absolute path to the directory containing migration/seeder files. + * @param string $namespace The PHP namespace for classes in the directory. + * @param bool $recursive Whether to scan subdirectories recursively. Default is false. + * @return int Number of changes discovered and registered. + */ + public function discoverFromPath(string $path, string $namespace = '', bool $recursive = false): int { + $count = 0; + + if (!is_dir($path)) { + return $count; + } + + $namespace = rtrim($namespace, '\\'); + $iterator = $recursive + ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)) + : new \DirectoryIterator($path); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $className = $this->resolveClassName($file, $path, $namespace, $recursive); + + if ($className !== null && $this->register($className)) { + $count++; + } + } + } + + return $count; + } + + /** + * Drop the schema tracking table from the database. + * + * Removes the table that stores migration and seeder execution history. + * Use with caution as this will lose all tracking information. + */ + public function dropSchemaTable() { + $this->table('schema_changes')->drop(); + $this->execute(); + } + + /** + * Get all discovered database changes (migrations and seeders). + * + * @return array Array of DatabaseChange instances found in the configured path. + */ + public function getChanges(): array { + return $this->dbChanges; + } + + /** + * Get the current execution environment. + * + * The environment determines which migrations and seeders will be executed. + * Changes can specify which environments they should run in. + * + * @return string The current environment (e.g., 'dev', 'test', 'prod'). + */ + public function getEnvironment(): string { + return $this->environment; + } + /** + * Get pending database changes that would be applied. + * + * This method returns changes that have not been applied yet and would + * run in the current environment. Optionally captures the SQL queries + * that would be executed using dry-run mode. + * + * @param bool $withQueries If true, executes each change in dry-run mode + * to capture the SQL queries. Default is false. + * @return array Array of associative arrays with keys: + * - 'change': The DatabaseChange instance + * - 'queries': Array of SQL strings (only if $withQueries is true) + */ + public function getPendingChanges(bool $withQueries = false): array { + $pending = []; + + foreach ($this->dbChanges as $change) { + if ($this->isApplied($change->getName())) { + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + continue; + } + + $info = ['change' => $change, 'queries' => []]; + + if ($withQueries) { + $this->setDryRun(true); + try { + $change->execute($this); + $info['queries'] = $this->getCapturedQueries(); + } catch (\Throwable $ex) { + // Capture failed, queries may be partial + } + $this->setDryRun(false); + } + + $pending[] = $info; + } + + return $pending; + } + + /** + * Get the schema change repository. + * + * @return SchemaChangeRepository The repository instance + */ + public function getRepository(): SchemaChangeRepository { + return $this->repository; + } + + /** + * Check if a database change exists in the discovered changes. + * + * @param string $name The class name of the change to check. + * @return bool True if the change exists, false otherwise. + */ + public function hasChange(string $name): bool { + return $this->findChangeByName($name) !== null; + } + + /** + * Check if a specific database change has been applied. + * + * @param string $name The class name of the change to check. + * @return bool True if the change has been applied, false otherwise. + */ + public function isApplied(string $name): bool { + return $this->getRepository()->count([ + 'change_name' => $name + ]) == 1; + } + + /** + * Register a database change. + * + * If a change with the same name is already registered, this method + * returns false without registering a duplicate. + * + * @param DatabaseChange|string $change The change instance or class name. + * @return bool True if registered successfully, false if already registered or on error. + */ + public function register(DatabaseChange|string $change): bool { + try { + $name = is_string($change) ? $change : $change->getName(); + + if ($this->hasChange($name)) { + return false; + } + + if (is_string($change)) { + if (!class_exists($change)) { + throw new SchemaException("Class does not exist: {$change}"); + } + + if (!is_subclass_of($change, DatabaseChange::class)) { + throw new SchemaException("Class is not a subclass of DatabaseChange: {$change}"); + } + + $change = new $change(); + } + + $this->dbChanges[] = $change; + + return true; + } catch (\Throwable $ex) { + foreach ($this->onRegErrCallbacks as $callback) { + call_user_func_array($callback, [$ex]); + } + + return false; + } + } + + /** + * Register multiple database changes. + * + * @param array $changes Array of DatabaseChange instances or class names. + */ + public function registerAll(array $changes): void { + foreach ($changes as $change) { + $this->register($change); + } + } + + /** + * Rollback all changes from a specific batch. + * + * @param int $batch The batch number to rollback. + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackBatch(int $batch): array { + $changeNames = array_column($this->getRepository()->getByBatch($batch), 'change_name'); + $rolled = []; + + // Rollback in reverse order + $changes = array_reverse($this->getChanges()); + + foreach ($changes as $change) { + if (in_array($change->getName(), $changeNames)) { + $this->attemptRoolback($change, $rolled); + } + } + + return $rolled; + } + + /** + * Rollback all changes from the last batch. + * + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackLastBatch(): array { + $lastBatch = $this->getRepository()->getLastBatchNumber(); + + if ($lastBatch === 0) { + return []; + } + + return $this->rollbackBatch($lastBatch); + } + + /** + * Rollback database changes up to a specific change. + * + * @param string|null $changeName The change to rollback to, or null to rollback all. + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackUpTo(?string $changeName): array { + $changes = array_reverse($this->getChanges()); + $rolled = []; + + if (empty($changes)) { + return $rolled; + } + + if ($changeName !== null && $this->hasChange($changeName)) { + foreach ($changes as $change) { + if ($change->getName() == $changeName && $this->isApplied($change->getName())) { + $this->attemptRoolback($change, $rolled); + + return $rolled; + } + } + } else if ($changeName === null) { + foreach ($changes as $change) { + if ($this->isApplied($change->getName()) && !$this->attemptRoolback($change, $rolled)) { + return $rolled; + } + } + } + + return $rolled; + } + + private function areDependenciesSatisfied(DatabaseChange $change): bool { + foreach ($change->getDependencies() as $depName) { + if (!$this->isApplied($depName)) { + return false; + } + } + + return true; + } + private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { + try { + $change->rollback($this); + $this->repository->removeChange($change->getName()); + $rolled[] = $change; + + return true; + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + + return false; + } + } + + private function findChangeByName(string $name): ?DatabaseChange { + foreach ($this->dbChanges as $change) { + $changeName = $change->getName(); + + // Exact match + if ($changeName === $name) { + return $change; + } + + // Check if name is a short class name and change is full class name + if (str_ends_with($changeName, '\\'.$name)) { + return $change; + } + + // Check if name is full class name and change is short class name + if (str_ends_with($name, '\\'.$changeName)) { + return $change; + } + } + + return null; + } + + /** + * Resolve the fully qualified class name from a file. + * + * @param \SplFileInfo $file The file to resolve. + * @param string $basePath The base directory path. + * @param string $namespace The base namespace. + * @param bool $recursive Whether recursive scanning is enabled. + * @return string|null The fully qualified class name, or null if not a valid change class. + */ + private function resolveClassName(\SplFileInfo $file, string $basePath, string $namespace, bool $recursive): ?string { + $filename = $file->getBasename('.php'); + + if ($recursive) { + $relativePath = substr($file->getPath(), strlen($basePath)); + $relativePath = trim(str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath), '\\'); + $className = $relativePath ? $namespace.'\\'.$relativePath.'\\'.$filename : $namespace.'\\'.$filename; + } else { + $className = $namespace.'\\'.$filename; + } + + if (!class_exists($className)) { + require_once $file->getPathname(); + } + + if (class_exists($className) && is_subclass_of($className, DatabaseChange::class)) { + $reflection = new ReflectionClass($className); + + if (!$reflection->isAbstract()) { + return $className; + } + } + + return null; + } + + private function shouldRunInEnvironment(DatabaseChange $change): bool { + $environments = $change->getEnvironments(); + + return empty($environments) || in_array($this->environment, $environments); + } + + private function sortChangesByDependencies() { + $sorted = []; + $visited = []; + + foreach ($this->dbChanges as $change) { + $visiting = []; + $this->topologicalSort($change, $visited, $sorted, $visiting); + } + + $this->dbChanges = $sorted; + } + + private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting) { + $className = $change->getName(); + + if (isset($visiting[$className])) { + $cycle = array_merge(array_keys($visiting), [$className]); + throw new DatabaseException('Circular dependency detected: '.implode(' -> ', $cycle)); + } + + if (isset($visited[$className])) { + return; + } + + $visiting[$className] = true; + + foreach ($change->getDependencies() as $depName) { + $dep = $this->findChangeByName($depName); + + if ($dep) { + $this->topologicalSort($dep, $visited, $sorted, $visiting); + } + } + + unset($visiting[$className]); + $visited[$className] = true; + $sorted[] = $change; + } + + /** + * Execute a database change, optionally wrapped in a transaction. + * + * This method checks the change's useTransaction() method to determine + * whether to wrap the execution in a database transaction. + * + * @param DatabaseChange $change The change to execute. + */ + protected function executeChange(DatabaseChange $change): void { + if ($change->useTransaction($this)) { + $this->transaction(function (Database $db) use ($change) + { + $change->execute($db); + }); + } else { + $change->execute($this); + } + } +} diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index 32474dbf..72ee3365 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE @@ -11,8 +11,12 @@ */ namespace WebFiori\Database; +use WebFiori\Database\Entity\EntityGenerator; +use WebFiori\Database\Entity\EntityMapper; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Query\SelectExpression; /** * Abstract base class for representing database table structures. @@ -363,6 +367,25 @@ public function getColsNames() : array { public function getComment() { return $this->comment; } + + /** + * Returns an entity generator for generating PHP 8+ immutable entities. + * + * The generator creates entities with: + * - Protected properties (extensible) + * - Named arguments + * - Only getters (immutable) + * - Proper type hints + * + * @param string $entityName The name of the entity class + * @param string $path The directory where the entity will be created + * @param string $namespace The namespace for the entity + * + * @return EntityGenerator An instance of EntityGenerator + */ + public function getEntityGenerator(string $entityName = 'Entity', string $path = __DIR__, string $namespace = '') : EntityGenerator { + return new EntityGenerator($this, $entityName, $path, $namespace); + } /** * Returns an instance of the class 'EntityMapper' which can be used to map the * table to an entity class. @@ -380,6 +403,7 @@ public function getEntityMapper() : EntityMapper { return $this->mapper; } + /** * Returns a foreign key given its name. * diff --git a/WebFiori/Database/DateTimeValidator.php b/WebFiori/Database/Util/DateTimeValidator.php similarity index 96% rename from WebFiori/Database/DateTimeValidator.php rename to WebFiori/Database/Util/DateTimeValidator.php index 726da25f..d9f622fe 100644 --- a/WebFiori/Database/DateTimeValidator.php +++ b/WebFiori/Database/Util/DateTimeValidator.php @@ -3,13 +3,13 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Util; /** * A utility class which is used to validate date and time strings diff --git a/WebFiori/Database/TypesMap.php b/WebFiori/Database/Util/TypesMap.php similarity index 97% rename from WebFiori/Database/TypesMap.php rename to WebFiori/Database/Util/TypesMap.php index 012204ad..33348786 100644 --- a/WebFiori/Database/TypesMap.php +++ b/WebFiori/Database/Util/TypesMap.php @@ -3,13 +3,13 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Util; /** * A class that holds mapping of data types between different DBMSs. diff --git a/examples/01-basic-connection/README.md b/examples/01-basic-connection/README.md index f702061a..64e7edb9 100644 --- a/examples/01-basic-connection/README.md +++ b/examples/01-basic-connection/README.md @@ -10,7 +10,7 @@ This example demonstrates how to establish a connection to a database using the ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example @@ -21,3 +21,10 @@ php example.php ## Expected Output The example will output connection status and basic database information. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Learn CRUD operations after connecting +- [03-table-blueprints](../03-table-blueprints/) - Create table structures programmatically +- [06-migrations](../06-migrations/) - Manage schema changes with migrations diff --git a/examples/01-basic-connection/example.php b/examples/01-basic-connection/example.php index d72b4dd5..4f52d820 100644 --- a/examples/01-basic-connection/example.php +++ b/examples/01-basic-connection/example.php @@ -33,31 +33,34 @@ // Additional connection tests using raw() with parameters echo "\n--- Additional Connection Tests ---\n"; - + // Test current database $result = $database->raw("SELECT DATABASE() as current_db")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Current database: " . $result->getRows()[0]['current_db'] . "\n"; + echo "✓ Current database: ".$result->getRows()[0]['current_db']."\n"; } - + // Test server status $result = $database->raw("SHOW STATUS LIKE 'Uptime'")->execute(); + if ($result && $result->getRowsCount() > 0) { $uptime = $result->getRows()[0]['Value']; - echo "✓ Server uptime: " . $uptime . " seconds\n"; + echo "✓ Server uptime: ".$uptime." seconds\n"; } - + // Test connection info $result = $database->raw("SELECT CONNECTION_ID() as connection_id")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Connection ID: " . $result->getRows()[0]['connection_id'] . "\n"; + echo "✓ Connection ID: ".$result->getRows()[0]['connection_id']."\n"; } - + $result = $database->raw("SELECT USER() as user_name")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Current User: " . $result->getRows()[0]['user_name'] . "\n"; + echo "✓ Current User: ".$result->getRows()[0]['user_name']."\n"; } - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; echo "Note: Make sure MySQL is running and accessible with the provided credentials.\n"; diff --git a/examples/02-basic-queries/README.md b/examples/02-basic-queries/README.md index a18dc2e4..02ef8e6a 100644 --- a/examples/02-basic-queries/README.md +++ b/examples/02-basic-queries/README.md @@ -12,7 +12,7 @@ This example demonstrates CRUD operations (Create, Read, Update, Delete) using t ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example @@ -23,3 +23,11 @@ php example.php ## Expected Output The example will create a test table, perform various CRUD operations, and display the results of each operation. + + +## Related Examples + +- [01-basic-connection](../01-basic-connection/) - Database connection setup +- [03-table-blueprints](../03-table-blueprints/) - Define table structures +- [05-transactions](../05-transactions/) - Wrap operations in transactions +- [09-multi-result-queries](../09-multi-result-queries/) - Handle stored procedures with multiple result sets diff --git a/examples/02-basic-queries/example.php b/examples/02-basic-queries/example.php index 08c4ba26..baf8a260 100644 --- a/examples/02-basic-queries/example.php +++ b/examples/02-basic-queries/example.php @@ -104,7 +104,7 @@ // Multi-Result Query Example echo "\n6. Multi-Result Query Example:\n"; - + // Create a stored procedure that returns multiple result sets $database->raw("DROP PROCEDURE IF EXISTS GetUserStats")->execute(); $database->raw(" @@ -115,20 +115,21 @@ SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM test_users; END ")->execute(); - + // Execute the stored procedure $result = $database->raw("CALL GetUserStats()")->execute(); - + if ($result instanceof MultiResultSet) { echo "✓ Multi-result query executed successfully!\n"; - echo "Number of result sets: " . $result->count() . "\n"; - + echo "Number of result sets: ".$result->count()."\n"; + for ($i = 0; $i < $result->count(); $i++) { $resultSet = $result->getResultSet($i); - echo "\nResult Set " . ($i + 1) . ":\n"; - + echo "\nResult Set ".($i + 1).":\n"; + foreach ($resultSet as $row) { echo " "; + foreach ($row as $key => $value) { echo "$key: $value "; } @@ -137,8 +138,10 @@ } } else { echo "Single result set returned:\n"; + foreach ($result as $row) { echo " "; + foreach ($row as $key => $value) { echo "$key: $value "; } @@ -148,7 +151,7 @@ // Another multi-result example with conditional logic echo "\n7. Complex Multi-Result Example:\n"; - + $database->raw("DROP PROCEDURE IF EXISTS ComplexStats")->execute(); $database->raw(" CREATE PROCEDURE ComplexStats() @@ -177,26 +180,27 @@ GROUP BY age_group; END ")->execute(); - + $complexResult = $database->raw("CALL ComplexStats()")->execute(); - + if ($complexResult instanceof MultiResultSet) { echo "✓ Complex multi-result query executed!\n"; - + // Process each result set with specific handling for ($i = 0; $i < $complexResult->count(); $i++) { $rs = $complexResult->getResultSet($i); + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['section'])) { echo "\n--- {$firstRow['section']} ---\n"; - + foreach ($rs as $row) { if ($row['section'] === 'All Users') { echo " User: {$row['name']} ({$row['email']}) - Age: {$row['age']}\n"; } elseif ($row['section'] === 'Statistics') { - echo " Total: {$row['total_count']}, Min Age: {$row['min_age']}, Max Age: {$row['max_age']}, Avg Age: " . round($row['avg_age'], 1) . "\n"; + echo " Total: {$row['total_count']}, Min Age: {$row['min_age']}, Max Age: {$row['max_age']}, Avg Age: ".round($row['avg_age'], 1)."\n"; } elseif ($row['section'] === 'Age Groups') { echo " {$row['age_group']}: {$row['count']} users\n"; } @@ -212,10 +216,9 @@ $database->raw("DROP PROCEDURE IF EXISTS ComplexStats")->execute(); $database->raw("DROP TABLE test_users")->execute(); echo "✓ Test table and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; } echo "\n=== Example Complete ===\n"; diff --git a/examples/03-table-blueprints/README.md b/examples/03-table-blueprints/README.md index 40877f27..ff318f9e 100644 --- a/examples/03-table-blueprints/README.md +++ b/examples/03-table-blueprints/README.md @@ -12,8 +12,8 @@ This example demonstrates how to create database table structures using WebFiori ## Files -- `example.php` - Main example code -- `UserTable.php` - Custom table class extending MySQLTable +- [`example.php`](example.php) - Main example code +- [`UserTable.php`](UserTable.php) - Custom table class extending MySQLTable ## Running the Example @@ -24,3 +24,11 @@ php example.php ## Expected Output The example will create table blueprints using both the fluent API and custom table classes, generate SQL statements, and create the actual tables in the database. + + +## Related Examples + +- [04-entity-mapping](../04-entity-mapping/) - Map tables to entity classes +- [06-migrations](../06-migrations/) - Version control your schema changes +- [10-attribute-based-tables](../10-attribute-based-tables/) - Define tables using PHP 8 attributes +- [11-repository-pattern](../11-repository-pattern/) - Use repositories for data access diff --git a/examples/03-table-blueprints/UserTable.php b/examples/03-table-blueprints/UserTable.php index 57ff9cea..554e605f 100644 --- a/examples/03-table-blueprints/UserTable.php +++ b/examples/03-table-blueprints/UserTable.php @@ -1,46 +1,46 @@ -addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'full_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'is_active' => [ - ColOption::TYPE => DataType::BOOL, - ColOption::DEFAULT => true - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - } -} +addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'full-name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'is-active' => [ + ColOption::TYPE => DataType::BOOL, + ColOption::DEFAULT => true + ], + 'created-at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + } +} diff --git a/examples/03-table-blueprints/example.php b/examples/03-table-blueprints/example.php index 0f35be97..1bfa82d9 100644 --- a/examples/03-table-blueprints/example.php +++ b/examples/03-table-blueprints/example.php @@ -1,183 +1,179 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - echo "✓ Users table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($usersTable->getColsNames()))."\n\n"; - - echo "2. Creating Posts Table Blueprint:\n"; - - // Create posts table blueprint - $postsTable = $database->createBlueprint('posts')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'user_id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::NULL => false - ], - 'title' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 200, - ColOption::NULL => false - ], - 'content' => [ - ColOption::TYPE => DataType::TEXT - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - echo "✓ Posts table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($postsTable->getColsNames()))."\n\n"; - - echo "3. Adding Foreign Key Relationship:\n"; - - // Add foreign key relationship - $postsTable->addReference($usersTable, ['user_id'], 'user_fk'); - echo "✓ Foreign key relationship added (posts.user_id -> users.id)\n\n"; - - echo "4. Generating and Executing CREATE TABLE Statements:\n"; - - // Generate create table queries - $database->createTables(); - - // Show the generated SQL - $sql = $database->getLastQuery(); - echo "Generated SQL:\n"; - echo str_replace(';', ";\n", $sql)."\n\n"; - - // Execute the queries - $database->execute(); - echo "✓ Tables created successfully\n\n"; - - echo "5. Testing the Created Tables:\n"; - - // Insert test data - $database->table('users')->insert([ - 'username' => 'ahmad_salem', - 'email' => 'ahmad@example.com' - ])->execute(); - echo "✓ Inserted test user\n"; - - // Get the user ID - $userResult = $database->table('users') - ->select(['id']) - ->where('username', 'ahmad_salem') - ->execute(); - $userId = $userResult->getRows()[0]['id']; - - $database->table('posts')->insert([ - 'user_id' => $userId, - 'title' => 'My First Post', - 'content' => 'This is the content of my first post.' - ])->execute(); - echo "✓ Inserted test post\n"; - - // Query with join to show relationship - $result = $database->setQuery(" - SELECT u.username, p.title, p.created_at - FROM users u - JOIN posts p ON u.id = p.user_id - ")->execute(); - - echo "\nJoined data:\n"; - - foreach ($result as $row) { - echo " User: {$row['username']}, Post: {$row['title']}, Created: {$row['created_at']}\n"; - } - - echo "\n6. Using Custom Table Class (Extending MySQLTable):\n"; - - // Clean up previous tables first - $database->setQuery("DROP TABLE IF EXISTS posts")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - // Include the custom table class - require_once __DIR__.'/UserTable.php'; - - // Create an instance of the custom table - $customTable = new UserTable(); - - echo "✓ Custom UserTable class created\n"; - echo " Table name: ".$customTable->getName()."\n"; - echo " Engine: ".$customTable->getEngine()."\n"; - echo " Charset: ".$customTable->getCharSet()."\n"; - - // Generate and execute CREATE TABLE for custom table - $createQuery = $customTable->toSQL(); - echo "\nGenerated SQL for custom table:\n"; - echo $createQuery."\n\n"; - - // Execute the custom table creation - $database->setQuery($createQuery)->execute(); - echo "✓ Custom table created successfully\n"; - - // Test the custom table - $database->table('users_extended')->insert([ - 'username' => 'sara_ahmad', - 'email' => 'sara@example.com', - 'full_name' => 'Sara Ahmad Al-Mansouri' - ])->execute(); - echo "✓ Inserted test data into custom table\n"; - - // Query the custom table - $result = $database->table('users_extended')->select()->execute(); - echo "Custom table data:\n"; - - foreach ($result as $row) { - echo " User: {$row['full_name']} ({$row['username']}) - Active: ".($row['is_active'] ? 'Yes' : 'No')."\n"; - } - - echo "\n7. Final Cleanup:\n"; - $database->setQuery("DROP TABLE users_extended")->execute(); - echo "✓ Tables dropped\n"; -} catch (Exception $e) { - echo "✗ Error: ".$e->getMessage()."\n"; -} - -echo "\n=== Example Complete ===\n"; +createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'created_at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + echo "✓ Users table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($usersTable->getCols()))."\n\n"; + + echo "2. Creating Posts Table Blueprint:\n"; + + // Create posts table blueprint + $postsTable = $database->createBlueprint('posts')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'user-id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::NULL => false + ], + 'title' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 200, + ColOption::NULL => false + ], + 'content' => [ + ColOption::TYPE => DataType::TEXT + ], + 'created-at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + echo "✓ Posts table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($postsTable->getCols()))."\n\n"; + + echo "3. Adding Foreign Key Relationship:\n"; + + // Add foreign key relationship with CASCADE actions + $postsTable->addReference($usersTable, ['user-id' => 'id'], 'user_fk', 'cascade', 'cascade'); + echo "✓ Foreign key relationship added (posts.user_id -> users.id)\n\n"; + + echo "4. Creating Tables One by One:\n"; + + // Create users table first (no dependencies) + $database->table('users')->createTable(); + echo "SQL for users table:\n".$database->getLastQuery()."\n\n"; + $database->execute(); + echo "✓ Users table created\n"; + + // Create posts table (depends on users) + $database->table('posts')->createTable(); + echo "SQL for posts table:\n".$database->getLastQuery()."\n\n"; + $database->execute(); + echo "✓ Posts table created\n\n"; + + echo "5. Testing the Created Tables:\n"; + + // Insert test data + $database->table('users')->insert([ + 'username' => 'ahmad_salem', + 'email' => 'ahmad@example.com' + ])->execute(); + echo "✓ Inserted test user\n"; + + // Get the user ID + $userResult = $database->table('users') + ->select(['id']) + ->where('username', 'ahmad_salem') + ->execute(); + $userId = $userResult->getRows()[0]['id']; + + $database->table('posts')->insert([ + 'user-id' => $userId, + 'title' => 'My First Post', + 'content' => 'This is the content of my first post.' + ])->execute(); + echo "✓ Inserted test post\n"; + + // Query with join to show relationship + $result = $database->raw(" + SELECT u.username, p.title, p.created_at + FROM users u + JOIN posts p ON u.id = p.user_id + ")->execute(); + + echo "\nJoined data:\n"; + foreach ($result as $row) { + echo " User: {$row['username']}, Post: {$row['title']}, Created: {$row['created_at']}\n"; + } + + echo "\n6. Using Custom Table Class:\n"; + + // Include the custom table class + require_once __DIR__.'/UserTable.php'; + + // Create an instance of the custom table + $customTable = new UserTable(); + + echo "✓ Custom UserTable class created\n"; + echo " Table name: ".$customTable->getName()."\n"; + echo " Engine: ".$customTable->getEngine()."\n"; + echo " Charset: ".$customTable->getCharSet()."\n"; + + // Generate and execute CREATE TABLE for custom table + $createQuery = $customTable->toSQL(); + echo "\nGenerated SQL for custom table:\n$createQuery\n\n"; + + // Execute the custom table creation + $database->raw($createQuery)->execute(); + echo "✓ Custom table created successfully\n"; + + // Test the custom table + $database->addTable($customTable); + $database->table('users_extended')->insert([ + 'username' => 'sara_ahmad', + 'email' => 'sara@example.com', + 'full-name' => 'Sara Ahmad Al-Mansouri' + ])->execute(); + echo "✓ Inserted test data into custom table\n"; + + // Query the custom table + $result = $database->table('users_extended')->select()->execute(); + echo "Custom table data:\n"; + foreach ($result as $row) { + echo " User: {$row['full_name']} ({$row['username']}) - Active: ".($row['is_active'] ? 'Yes' : 'No')."\n"; + } + + echo "\n7. Cleanup:\n"; + $database->raw("DROP TABLE IF EXISTS users_extended")->execute(); + $database->raw("DROP TABLE IF EXISTS posts")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); + echo "✓ Tables dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/04-entity-mapping/README.md b/examples/04-entity-mapping/README.md index 2f6dca87..d08e21ca 100644 --- a/examples/04-entity-mapping/README.md +++ b/examples/04-entity-mapping/README.md @@ -11,7 +11,7 @@ This example demonstrates how to create and use entity classes for object-relati ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code - `User.php` - Generated entity class (created during execution) ## Running the Example @@ -23,3 +23,10 @@ php example.php ## Expected Output The example will create a table blueprint, generate an entity class, and demonstrate mapping database records to objects. + + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Create table structures first +- [11-repository-pattern](../11-repository-pattern/) - Recommended approach for entity mapping +- [12-clean-architecture](../12-clean-architecture/) - Separate domain entities from infrastructure diff --git a/examples/04-entity-mapping/example.php b/examples/04-entity-mapping/example.php index 86f98968..ca9f299a 100644 --- a/examples/04-entity-mapping/example.php +++ b/examples/04-entity-mapping/example.php @@ -1,167 +1,105 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'first_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'last_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'age' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 3 - ] - ]); - - echo "✓ User table blueprint created\n\n"; - - echo "2. Creating Entity Class:\n"; - - // Get entity mapper and create entity class - $entityMapper = $userTable->getEntityMapper(); - $entityMapper->setEntityName('User'); - $entityMapper->setNamespace(''); - $entityMapper->setPath(__DIR__); - - // Create the entity class - $entityMapper->create(); - echo "✓ User entity class created at: ".__DIR__."/User.php\n"; - - - echo "3. Creating Table in Database:\n"; - - // Create the table - $database->createTables(); - $database->execute(); - echo "✓ User table created in database\n\n"; - - echo "4. Inserting Test Data:\n"; - - // Insert test users - $database->table('users')->insert([ - 'first_name' => 'Khalid', - 'last_name' => 'Al-Rashid', - 'email' => 'khalid.rashid@example.com', - 'age' => 30 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Aisha', - 'last_name' => 'Mahmoud', - 'email' => 'aisha.mahmoud@example.com', - 'age' => 25 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Hassan', - 'last_name' => 'Al-Najjar', - 'email' => 'hassan.najjar@example.com', - 'age' => 35 - ])->execute(); - - echo "✓ Test users inserted\n\n"; - - echo "5. Fetching and Mapping Records:\n"; - - // Include the generated entity class - require_once __DIR__.'/User.php'; - - // Fetch records and map to objects - $resultSet = $database->table('users')->select()->execute(); - - $mappedUsers = $resultSet->map(function (array $record) - { - return User::map($record); - }); - - echo "Mapped users as objects:\n"; - - foreach ($mappedUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()}) - Age: {$user->getAge()}\n"; - } - echo "\n"; - - echo "6. Working with Individual Objects:\n"; - - // Get first user and demonstrate object methods - $firstUser = $mappedUsers->getRows()[0]; - echo "First user details:\n"; - echo " ID: {$firstUser->getId()}\n"; - echo " Full Name: {$firstUser->getFirstName()} {$firstUser->getLastName()}\n"; - echo " Email: {$firstUser->getEmail()}\n"; - echo " Age: {$firstUser->getAge()}\n\n"; - - echo "7. Filtering with Entity Objects:\n"; - - // Filter users by age - $adultUsers = []; - - foreach ($mappedUsers as $user) { - if ($user->getAge() >= 30) { - $adultUsers[] = $user; - } - } - - echo "Users 30 or older:\n"; - - foreach ($adultUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} (Age: {$user->getAge()})\n"; - } - - echo "\n8. Cleanup:\n"; - $database->setQuery("DROP TABLE users")->execute(); - echo "✓ User table dropped\n"; - - // Clean up generated file - if (file_exists(__DIR__.'/User.php')) { - unlink(__DIR__.'/User.php'); - echo "✓ Generated User.php file removed\n"; - } -} catch (Exception $e) { - echo "✗ Error: ".$e->getMessage()."\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - if (file_exists(__DIR__.'/User.php')) { - unlink(__DIR__.'/User.php'); - } - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'first-name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'last-name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], + 'age' => [ColOption::TYPE => DataType::INT, ColOption::SIZE => 3] + ]); + + $database->table('users')->createTable(); + $database->execute(); + echo "✓ User table created\n\n"; + + echo "2. Inserting Test Data:\n"; + + $database->table('users')->insert(['first-name' => 'Khalid', 'last-name' => 'Al-Rashid', 'email' => 'khalid@example.com', 'age' => 30])->execute(); + $database->table('users')->insert(['first-name' => 'Aisha', 'last-name' => 'Mahmoud', 'email' => 'aisha@example.com', 'age' => 25])->execute(); + $database->table('users')->insert(['first-name' => 'Hassan', 'last-name' => 'Al-Najjar', 'email' => 'hassan@example.com', 'age' => 35])->execute(); + echo "✓ Test users inserted\n\n"; + + // ============================================ + // APPROACH 1: Using EntityMapper (Deprecated) + // ============================================ + echo "3. Using EntityGenerator:\n"; + + $entityGenerator = $userTable->getEntityGenerator('User', __DIR__, ''); + $entityGenerator->generate(); + echo "✓ User entity class generated at: ".__DIR__."/User.php\n"; + + require_once __DIR__.'/User.php'; + + $resultSet = $database->table('users')->select()->execute(); + $mappedUsers = $resultSet->map(fn($record) => new User( + id: (int) $record['id'], + firstName: $record['first_name'], + lastName: $record['last_name'], + email: $record['email'], + age: (int) $record['age'] + )); + + echo "Mapped users (EntityGenerator):\n"; + foreach ($mappedUsers as $user) { + echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()})\n"; + } + echo "\n"; + + // ============================================ + // APPROACH 2: Manual Entity (Recommended) + // ============================================ + echo "4. Alternative: Manual Entity Mapping:\n"; + + // Define entity manually (in real code, this would be in a separate file) + // See example 11-repository-pattern for full implementation + + echo "Manual entity mapping example:\n"; + $result = $database->table('users')->select()->execute(); + foreach ($result as $row) { + // Manual mapping - more control, no code generation + $user = (object) [ + 'id' => (int) $row['id'], + 'firstName' => $row['first_name'], + 'lastName' => $row['last_name'], + 'email' => $row['email'], + 'age' => (int) $row['age'], + 'fullName' => $row['first_name'].' '.$row['last_name'] + ]; + echo " - {$user->fullName} ({$user->email}) - Age: {$user->age}\n"; + } + echo "\n"; + + echo "5. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); + echo "✓ User table dropped\n"; + + if (file_exists(__DIR__.'/User.php')) { + unlink(__DIR__.'/User.php'); + echo "✓ Generated User.php file removed\n"; + } +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { + $database->raw("DROP TABLE IF EXISTS users")->execute(); + if (file_exists(__DIR__.'/User.php')) unlink(__DIR__.'/User.php'); + } catch (Exception $cleanupError) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/05-transactions/README.md b/examples/05-transactions/README.md index 8533a6f1..1ab36998 100644 --- a/examples/05-transactions/README.md +++ b/examples/05-transactions/README.md @@ -11,7 +11,7 @@ This example demonstrates how to use database transactions to ensure data integr ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example @@ -22,3 +22,10 @@ php example.php ## Expected Output The example will demonstrate both successful transactions and failed transactions with rollback functionality. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Basic CRUD operations +- [06-migrations](../06-migrations/) - Schema changes with rollback support +- [07-seeders](../07-seeders/) - Populate data within transactions diff --git a/examples/05-transactions/example.php b/examples/05-transactions/example.php index dc078186..35a4db70 100644 --- a/examples/05-transactions/example.php +++ b/examples/05-transactions/example.php @@ -162,7 +162,7 @@ } echo "6. Multi-Result Transaction Analysis:\n"; - + // Create a stored procedure for transaction analysis $database->raw("DROP PROCEDURE IF EXISTS TransactionAnalysis")->execute(); $database->raw(" @@ -191,29 +191,30 @@ LIMIT 5; END ")->execute(); - + $analysisResult = $database->raw("CALL TransactionAnalysis()")->execute(); - + if (method_exists($analysisResult, 'count') && $analysisResult->count() > 1) { echo "✓ Multi-result transaction analysis completed!\n"; - + for ($i = 0; $i < $analysisResult->count(); $i++) { $rs = $analysisResult->getResultSet($i); + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['report_type'])) { echo "\n--- {$firstRow['report_type']} ---\n"; - + foreach ($rs as $row) { if ($row['report_type'] === 'Account Summary') { - echo " {$row['name']}: $" . number_format($row['balance'], 2) . "\n"; + echo " {$row['name']}: $".number_format($row['balance'], 2)."\n"; } elseif ($row['report_type'] === 'Transaction Summary') { echo " Total Transactions: {$row['total_transactions']}\n"; - echo " Total Amount: $" . number_format($row['total_amount'], 2) . "\n"; - echo " Average Amount: $" . number_format($row['avg_amount'], 2) . "\n"; + echo " Total Amount: $".number_format($row['total_amount'], 2)."\n"; + echo " Average Amount: $".number_format($row['avg_amount'], 2)."\n"; } elseif ($row['report_type'] === 'Recent Transactions') { - echo " {$row['from_name']} → {$row['to_name']}: $" . number_format($row['amount'], 2) . " ({$row['created_at']})\n"; + echo " {$row['from_name']} → {$row['to_name']}: $".number_format($row['amount'], 2)." ({$row['created_at']})\n"; } } } @@ -226,7 +227,6 @@ $database->raw("DROP TABLE transactions")->execute(); $database->raw("DROP TABLE accounts")->execute(); echo "✓ Test tables and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; diff --git a/examples/06-migrations/AddEmailIndexMigration.php b/examples/06-migrations/AddEmailIndexMigration.php index 9411921e..c165f71e 100644 --- a/examples/06-migrations/AddEmailIndexMigration.php +++ b/examples/06-migrations/AddEmailIndexMigration.php @@ -1,52 +1,21 @@ -setQuery("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); - } - - /** - * Rollback the migration changes from the database. - * - * Removes the unique index from the email column, - * allowing duplicate emails again. - * - * @param Database $db The database instance to execute rollback on. - */ - public function down(Database $db): void { - // Drop email index - $db->setQuery("ALTER TABLE users DROP INDEX idx_users_email")->execute(); - } -} +raw("ALTER TABLE users DROP INDEX idx_users_email")->execute(); + } + + public function getDependencies(): array { + return ['CreateUsersTableMigration']; + } + + public function up(Database $db): void { + $db->raw("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); + } +} diff --git a/examples/06-migrations/CreateUsersTableMigration.php b/examples/06-migrations/CreateUsersTableMigration.php index 6447e30d..c832de00 100644 --- a/examples/06-migrations/CreateUsersTableMigration.php +++ b/examples/06-migrations/CreateUsersTableMigration.php @@ -1,71 +1,48 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'password_hash' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::NULL => false - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - $db->createTables(); - $db->execute(); - } - - /** - * Rollback the migration changes from the database. - * - * Drops the users table and all its data. This operation - * is irreversible and will result in data loss. - * - * @param Database $db The database instance to execute rollback on. - */ - public function down(Database $db): void { - // Drop users table - $db->setQuery("DROP TABLE IF EXISTS users")->execute(); - } -} +raw("DROP TABLE IF EXISTS users")->execute(); + } + + public function up(Database $db): void { + $db->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'password-hash' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 255, + ColOption::NULL => false + ], + 'created-at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + $db->table('users')->createTable(); + $db->execute(); + } +} diff --git a/examples/06-migrations/README.md b/examples/06-migrations/README.md index 98c62abc..aec4f2f2 100644 --- a/examples/06-migrations/README.md +++ b/examples/06-migrations/README.md @@ -11,9 +11,9 @@ This example demonstrates how to create and run database migrations using WebFio ## Files -- `example.php` - Main example code -- `CreateUsersTableMigration.php` - Migration to create users table -- `AddEmailIndexMigration.php` - Migration to add email index +- [`example.php`](example.php) - Main example code +- [`CreateUsersTableMigration.php`](CreateUsersTableMigration.php) - Migration to create users table +- [`AddEmailIndexMigration.php`](AddEmailIndexMigration.php) - Migration to add email index ## Running the Example @@ -24,3 +24,10 @@ php example.php ## Expected Output The example will create migration classes, run them to modify the database schema, and demonstrate rollback functionality. + + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Define table structures +- [07-seeders](../07-seeders/) - Populate data after migrations +- [05-transactions](../05-transactions/) - Understand rollback behavior diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index 953603a8..6492b3b4 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -1,161 +1,152 @@ -register('CreateUsersTableMigration'); - $runner->register('AddEmailIndexMigration'); - - echo "✓ Schema runner created\n"; - echo "✓ Migration classes registered\n"; - - // Create schema tracking table - $runner->createSchemaTable(); - echo "✓ Schema tracking table created\n\n"; - - echo "3. Checking Available Migrations:\n"; - - $changes = $runner->getChanges(); - echo "Registered migrations:\n"; - foreach ($changes as $change) { - echo " - " . $change->getName() . "\n"; - } - echo "\n"; - - echo "4. Running Migrations:\n"; - - // Force apply all migrations - $changes = $runner->getChanges(); - $appliedChanges = []; - - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: " . $change->getName() . "\n"; - } - } - - if (empty($appliedChanges)) { - echo "No migrations to apply (all up to date)\n"; - } - echo "\n"; - - echo "5. Verifying Database Structure:\n"; - - // Check if table exists - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - if ($result->getRowsCount() > 0) { - echo "✓ Users table created\n"; - } - - // Check table structure - $result = $database->setQuery("DESCRIBE users")->execute(); - echo "Users table columns:\n"; - foreach ($result as $column) { - echo " - {$column['Field']} ({$column['Type']})\n"; - } - - // Check indexes - $result = $database->setQuery("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); - if ($result->getRowsCount() > 0) { - echo "✓ Email index created\n"; - } - echo "\n"; - - echo "6. Testing Data Operations:\n"; - - // Insert test data - $database->table('users')->insert([ - 'username' => 'ahmad_hassan', - 'email' => 'ahmad@example.com', - 'password_hash' => password_hash('password123', PASSWORD_DEFAULT) - ])->execute(); - - $database->table('users')->insert([ - 'username' => 'fatima_ali', - 'email' => 'fatima@example.com', - 'password_hash' => password_hash('password456', PASSWORD_DEFAULT) - ])->execute(); - - echo "✓ Test users inserted\n"; - - // Query data - $result = $database->table('users')->select(['username', 'email', 'created_at'])->execute(); - echo "Inserted users:\n"; - foreach ($result as $user) { - echo " - {$user['username']} ({$user['email']}) - {$user['created_at']}\n"; - } - echo "\n"; - - echo "7. Checking Migration Status:\n"; - - // Check which migrations are applied - echo "Migration status:\n"; - foreach ($changes as $change) { - $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; - echo " {$change->getName()}: $status\n"; - } - echo "\n"; - - echo "8. Rolling Back Migrations:\n"; - - // Rollback all migrations - $rolledBackChanges = $runner->rollbackUpTo(null); - - if (!empty($rolledBackChanges)) { - echo "Rolled back migrations:\n"; - foreach ($rolledBackChanges as $change) { - echo " ✓ " . $change->getName() . "\n"; - } - } else { - echo "No migrations to rollback\n"; - } - - // Verify rollback - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - if ($result->getRowsCount() == 0) { - echo "✓ Users table removed\n"; - } - - echo "\n9. Cleanup:\n"; - $runner->dropSchemaTable(); - echo "✓ Schema tracking table dropped\n"; - -} catch (Exception $e) { - echo "✗ Error: " . $e->getMessage() . "\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +discoverFromPath(__DIR__, ''); + + echo "✓ Schema runner created\n"; + echo "✓ Migration classes discovered from path\n"; + + // Create schema tracking table + $runner->createSchemaTable(); + echo "✓ Schema tracking table created\n\n"; + + echo "2. Checking Available Migrations:\n"; + + $changes = $runner->getChanges(); + echo "Discovered migrations:\n"; + foreach ($changes as $change) { + echo " - ".$change->getName()."\n"; + } + echo "\n"; + + echo "3. Running Migrations (using apply()):\n"; + + // Apply all pending migrations + $result = $runner->apply(); + + if ($result->count() > 0) { + echo "Applied migrations:\n"; + foreach ($result->getApplied() as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No migrations to apply (all up to date)\n"; + } + + if (!empty($result->getFailed())) { + echo "Failed migrations:\n"; + foreach ($result->getFailed() as $failure) { + echo " ✗ ".$failure['change']->getName().": ".$failure['error']->getMessage()."\n"; + } + } + echo "\n"; + + echo "4. Verifying Database Structure:\n"; + + // Check if table exists + $tableResult = $database->raw("SHOW TABLES LIKE 'users'")->execute(); + if ($tableResult->getRowsCount() > 0) { + echo "✓ Users table created\n"; + } + + // Check table structure + $descResult = $database->raw("DESCRIBE users")->execute(); + echo "Users table columns:\n"; + foreach ($descResult as $column) { + echo " - {$column['Field']} ({$column['Type']})\n"; + } + + // Check indexes + $indexResult = $database->raw("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); + if ($indexResult->getRowsCount() > 0) { + echo "✓ Email index created\n"; + } + echo "\n"; + + echo "5. Testing Data Operations:\n"; + + // Insert test data + $database->table('users')->insert([ + 'username' => 'ahmad_hassan', + 'email' => 'ahmad@example.com', + 'password-hash' => password_hash('password123', PASSWORD_DEFAULT) + ])->execute(); + + $database->table('users')->insert([ + 'username' => 'fatima_ali', + 'email' => 'fatima@example.com', + 'password-hash' => password_hash('password456', PASSWORD_DEFAULT) + ])->execute(); + + echo "✓ Test users inserted\n"; + + // Query data + $selectResult = $database->table('users')->select(['username', 'email', 'created-at'])->execute(); + echo "Inserted users:\n"; + foreach ($selectResult as $user) { + echo " - {$user['username']} ({$user['email']}) - {$user['created_at']}\n"; + } + echo "\n"; + + echo "6. Checking Migration Status:\n"; + echo "Migration status:\n"; + foreach ($changes as $change) { + $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; + echo " {$change->getName()}: $status\n"; + } + echo "\n"; + + echo "7. Rolling Back Migrations:\n"; + + // Rollback all migrations + $rolledBack = $runner->rollbackUpTo(null); + + if (!empty($rolledBack)) { + echo "Rolled back migrations:\n"; + foreach ($rolledBack as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No migrations to rollback\n"; + } + + // Verify rollback + $verifyResult = $database->raw("SHOW TABLES LIKE 'users'")->execute(); + if ($verifyResult->getRowsCount() == 0) { + echo "✓ Users table removed\n"; + } + + echo "\n8. Cleanup:\n"; + $runner->dropSchemaTable(); + echo "✓ Schema tracking table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->raw("DROP TABLE IF EXISTS schema_changes")->execute(); + } catch (Exception $cleanupError) { + // Ignore cleanup errors + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/07-seeders/CategoriesSeeder.php b/examples/07-seeders/CategoriesSeeder.php index e21d1aed..3a06cd78 100644 --- a/examples/07-seeders/CategoriesSeeder.php +++ b/examples/07-seeders/CategoriesSeeder.php @@ -1,64 +1,26 @@ - 'Technology', - 'description' => 'Articles about technology and programming', - 'slug' => 'technology' - ], - [ - 'name' => 'Science', - 'description' => 'Scientific articles and research', - 'slug' => 'science' - ], - [ - 'name' => 'Culture', - 'description' => 'Cultural topics and discussions', - 'slug' => 'culture' - ], - [ - 'name' => 'Sports', - 'description' => 'Sports news and updates', - 'slug' => 'sports' - ] - ]; - - foreach ($categories as $category) { - $db->table('categories')->insert($category)->execute(); - } - } -} + 'Technology', 'description' => 'Articles about technology', 'slug' => 'technology'], + ['name' => 'Science', 'description' => 'Scientific articles', 'slug' => 'science'], + ['name' => 'Culture', 'description' => 'Cultural topics', 'slug' => 'culture'], + ['name' => 'Sports', 'description' => 'Sports news', 'slug' => 'sports'] + ]; + + foreach ($categories as $category) { + $db->table('categories')->insert($category)->execute(); + } + } +} diff --git a/examples/07-seeders/README.md b/examples/07-seeders/README.md index edf75549..648901ec 100644 --- a/examples/07-seeders/README.md +++ b/examples/07-seeders/README.md @@ -11,9 +11,9 @@ This example demonstrates how to create and run database seeders using WebFiori' ## Files -- `example.php` - Main example code -- `UsersSeeder.php` - Seeder for user data -- `CategoriesSeeder.php` - Seeder for category data +- [`example.php`](example.php) - Main example code +- [`UsersSeeder.php`](UsersSeeder.php) - Seeder for user data +- [`CategoriesSeeder.php`](CategoriesSeeder.php) - Seeder for category data ## Running the Example @@ -24,3 +24,10 @@ php example.php ## Expected Output The example will create seeder classes, run them to populate the database with initial data, and show the seeded records. + + +## Related Examples + +- [06-migrations](../06-migrations/) - Create tables before seeding +- [05-transactions](../05-transactions/) - Wrap seeding in transactions +- [11-repository-pattern](../11-repository-pattern/) - Use repositories for data insertion diff --git a/examples/07-seeders/UsersSeeder.php b/examples/07-seeders/UsersSeeder.php index e9bfeccc..5e023112 100644 --- a/examples/07-seeders/UsersSeeder.php +++ b/examples/07-seeders/UsersSeeder.php @@ -1,55 +1,42 @@ - 'admin', - 'email' => 'admin@example.com', - 'full_name' => 'Administrator', - 'role' => 'admin' - ], - [ - 'username' => 'mohammed_ali', - 'email' => 'mohammed@example.com', - 'full_name' => 'Mohammed Ali Al-Rashid', - 'role' => 'user' - ], - [ - 'username' => 'zahra_hassan', - 'email' => 'zahra@example.com', - 'full_name' => 'Zahra Hassan Al-Mahmoud', - 'role' => 'user' - ], - [ - 'username' => 'omar_khalil', - 'email' => 'omar@example.com', - 'full_name' => 'Omar Khalil Al-Najjar', - 'role' => 'moderator' - ] - ]; - - foreach ($users as $user) { - $db->table('users')->insert($user)->execute(); - } - } -} + 'admin', + 'email' => 'admin@example.com', + 'full-name' => 'Administrator', + 'role' => 'admin' + ], + [ + 'username' => 'mohammed_ali', + 'email' => 'mohammed@example.com', + 'full-name' => 'Mohammed Ali Al-Rashid', + 'role' => 'user' + ], + [ + 'username' => 'zahra_hassan', + 'email' => 'zahra@example.com', + 'full-name' => 'Zahra Hassan Al-Mahmoud', + 'role' => 'user' + ], + [ + 'username' => 'omar_khalil', + 'email' => 'omar@example.com', + 'full-name' => 'Omar Khalil Al-Najjar', + 'role' => 'moderator' + ] + ]; + + foreach ($users as $user) { + $db->table('users')->insert($user)->execute(); + } + } +} diff --git a/examples/07-seeders/example.php b/examples/07-seeders/example.php index e6fc7dde..5b462f9d 100644 --- a/examples/07-seeders/example.php +++ b/examples/07-seeders/example.php @@ -1,207 +1,195 @@ -setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - // Create users table - $database->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'full_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'role' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20, - ColOption::DEFAULT => 'user' - ], - 'is_active' => [ - ColOption::TYPE => DataType::BOOL, - ColOption::DEFAULT => true - ] - ]); - - // Create categories table - $database->createBlueprint('categories')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100, - ColOption::NULL => false - ], - 'description' => [ - ColOption::TYPE => DataType::TEXT - ], - 'slug' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100, - ColOption::NULL => false - ] - ]); - - $database->createTables(); - $database->execute(); - - echo "✓ Test tables created\n\n"; - - echo "2. Loading Seeder Classes:\n"; - - // Include seeder classes - require_once __DIR__ . '/UsersSeeder.php'; - require_once __DIR__ . '/CategoriesSeeder.php'; - - echo "✓ Seeder classes loaded\n"; - - echo "3. Setting up Schema Runner:\n"; - - // Create schema runner - $runner = new SchemaRunner($connection); - - // Register seeder classes - $runner->register('UsersSeeder'); - $runner->register('CategoriesSeeder'); - - echo "✓ Schema runner created\n"; - echo "✓ Seeder classes registered\n"; - - // Create schema tracking table - $runner->createSchemaTable(); - echo "✓ Schema tracking table created\n\n"; - - echo "4. Checking Available Seeders:\n"; - - $changes = $runner->getChanges(); - echo "Registered seeders:\n"; - foreach ($changes as $change) { - echo " - " . $change->getName() . "\n"; - } - echo "\n"; - - echo "5. Running Seeders:\n"; - - // Force apply all seeders - $appliedChanges = []; - - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: " . $change->getName() . "\n"; - } - } - - if (empty($appliedChanges)) { - echo "No seeders to apply (all up to date)\n"; - } - echo "\n"; - - echo "6. Verifying Seeded Data:\n"; - - // Check users data - $result = $database->table('users')->select()->execute(); - echo "Seeded users ({$result->getRowsCount()} records):\n"; - foreach ($result as $user) { - $status = $user['is_active'] ? 'Active' : 'Inactive'; - echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - {$status}\n"; - } - echo "\n"; - - // Check categories data - $result = $database->table('categories')->select()->execute(); - echo "Seeded categories ({$result->getRowsCount()} records):\n"; - foreach ($result as $category) { - echo " - {$category['name']} ({$category['slug']})\n"; - echo " {$category['description']}\n"; - } - echo "\n"; - - echo "7. Testing Seeder Status:\n"; - - // Check which seeders are applied - echo "Seeder status:\n"; - foreach ($changes as $change) { - $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; - echo " {$change->getName()}: $status\n"; - } - echo "\n"; - - echo "8. Rolling Back Seeders:\n"; - - // Rollback all seeders (this will clear the data) - $rolledBackChanges = []; - - // Reverse order for rollback - $reversedChanges = array_reverse($changes); - foreach ($reversedChanges as $change) { - $change->rollback($database); - $rolledBackChanges[] = $change; - echo " ✓ Rolled back: " . $change->getName() . "\n"; - } - - // Verify rollback - $userCount = $database->table('users')->select()->execute()->getRowsCount(); - $categoryCount = $database->table('categories')->select()->execute()->getRowsCount(); - - echo "After rollback:\n"; - echo " Users: $userCount records\n"; - echo " Categories: $categoryCount records\n"; - echo "✓ Seeders rolled back successfully\n\n"; - - echo "9. Cleanup:\n"; - $runner->dropSchemaTable(); - $database->setQuery("DROP TABLE categories")->execute(); - $database->setQuery("DROP TABLE users")->execute(); - echo "✓ Test tables and schema tracking table dropped\n"; - -} catch (Exception $e) { - echo "✗ Error: " . $e->getMessage() . "\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +raw("DROP TABLE IF EXISTS categories")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); + + // Create users table + $database->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'full-name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'role' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 20, + ColOption::DEFAULT => 'user' + ], + 'is-active' => [ + ColOption::TYPE => DataType::BOOL, + ColOption::DEFAULT => true + ] + ]); + + // Create categories table + $database->createBlueprint('categories')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100, + ColOption::NULL => false + ], + 'description' => [ + ColOption::TYPE => DataType::TEXT + ], + 'slug' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100, + ColOption::NULL => false + ] + ]); + + // Create tables one by one + $database->table('users')->createTable(); + $database->execute(); + echo "✓ Users table created\n"; + + $database->table('categories')->createTable(); + $database->execute(); + echo "✓ Categories table created\n\n"; + + echo "2. Setting up Schema Runner:\n"; + + // Create schema runner + $runner = new SchemaRunner($connection); + + // Discover and register seeder classes from directory + $runner->discoverFromPath(__DIR__, ''); + + echo "✓ Schema runner created\n"; + echo "✓ Seeder classes discovered from path\n"; + + // Create schema tracking table + $runner->createSchemaTable(); + echo "✓ Schema tracking table created\n\n"; + + echo "3. Checking Available Seeders:\n"; + + $changes = $runner->getChanges(); + echo "Discovered seeders:\n"; + foreach ($changes as $change) { + echo " - ".$change->getName()."\n"; + } + echo "\n"; + + echo "4. Running Seeders (using apply()):\n"; + + // Apply all pending seeders + $result = $runner->apply(); + + if ($result->count() > 0) { + echo "Applied seeders:\n"; + foreach ($result->getApplied() as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No seeders to apply (all up to date)\n"; + } + echo "\n"; + + echo "5. Verifying Seeded Data:\n"; + + // Check users data + $usersResult = $database->table('users')->select()->execute(); + echo "Seeded users ({$usersResult->getRowsCount()} records):\n"; + foreach ($usersResult as $user) { + $status = $user['is_active'] ? 'Active' : 'Inactive'; + echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - $status\n"; + } + echo "\n"; + + // Check categories data + $categoriesResult = $database->table('categories')->select()->execute(); + echo "Seeded categories ({$categoriesResult->getRowsCount()} records):\n"; + foreach ($categoriesResult as $category) { + echo " - {$category['name']} ({$category['slug']})\n"; + } + echo "\n"; + + echo "6. Checking Seeder Status:\n"; + echo "Seeder status:\n"; + foreach ($changes as $change) { + $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; + echo " {$change->getName()}: $status\n"; + } + echo "\n"; + + echo "7. Rolling Back Seeders:\n"; + + // Rollback all seeders (note: seeders don't clear data by default) + $rolledBack = $runner->rollbackUpTo(null); + + if (!empty($rolledBack)) { + echo "Rolled back seeders (tracking removed):\n"; + foreach ($rolledBack as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No seeders to rollback\n"; + } + + // Note: Data remains because seeders don't implement rollback by default + $userCount = $database->table('users')->select()->execute()->getRowsCount(); + $categoryCount = $database->table('categories')->select()->execute()->getRowsCount(); + + echo "Note: Data remains after rollback (seeders don't clear data by default):\n"; + echo " Users: $userCount records\n"; + echo " Categories: $categoryCount records\n\n"; + + echo "8. Cleanup:\n"; + $runner->dropSchemaTable(); + $database->raw("DROP TABLE categories")->execute(); + $database->raw("DROP TABLE users")->execute(); + echo "✓ Test tables and schema tracking table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->raw("DROP TABLE IF EXISTS categories")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->raw("DROP TABLE IF EXISTS schema_changes")->execute(); + } catch (Exception $cleanupError) { + // Ignore cleanup errors + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/08-performance-monitoring/README.md b/examples/08-performance-monitoring/README.md index 87de1422..71a9aca9 100644 --- a/examples/08-performance-monitoring/README.md +++ b/examples/08-performance-monitoring/README.md @@ -12,7 +12,7 @@ This example demonstrates how to monitor database query performance using WebFio ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example @@ -23,3 +23,10 @@ php example.php ## Expected Output The example will execute various database operations while monitoring performance, then display detailed performance metrics and analysis. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Queries to monitor +- [09-multi-result-queries](../09-multi-result-queries/) - Complex queries to analyze +- [13-pagination](../13-pagination/) - Optimize paginated queries diff --git a/examples/08-performance-monitoring/example.php b/examples/08-performance-monitoring/example.php index 272446b8..c6275a5c 100644 --- a/examples/08-performance-monitoring/example.php +++ b/examples/08-performance-monitoring/example.php @@ -1,38 +1,38 @@ -setPerformanceConfig([ - PerformanceOption::ENABLED => true, - PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold - PerformanceOption::WARNING_THRESHOLD => 25, // 25ms warning - PerformanceOption::SAMPLING_RATE => 1.0, // Monitor all queries - PerformanceOption::MAX_SAMPLES => 1000 // Keep up to 1000 samples - ]); - echo "✓ Performance monitoring configured\n"; - echo " - Slow query threshold: 50ms\n"; - echo " - Warning threshold: 25ms\n"; - echo " - Sampling rate: 100%\n\n"; - - // 3. Create test table for demonstration - echo "3. Creating test table:\n"; - $database->setQuery("DROP TABLE IF EXISTS performance_test")->execute(); +setPerformanceConfig([ + PerformanceOption::ENABLED => true, + PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold + PerformanceOption::WARNING_THRESHOLD => 25, // 25ms warning + PerformanceOption::SAMPLING_RATE => 1.0, // Monitor all queries + PerformanceOption::MAX_SAMPLES => 1000 // Keep up to 1000 samples + ]); + echo "✓ Performance monitoring configured\n"; + echo " - Slow query threshold: 50ms\n"; + echo " - Warning threshold: 25ms\n"; + echo " - Sampling rate: 100%\n\n"; + + // 3. Create test table for demonstration + echo "3. Creating test table:\n"; + $database->setQuery("DROP TABLE IF EXISTS performance_test")->execute(); $database->setQuery(" CREATE TABLE performance_test ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -41,108 +41,108 @@ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_email (email) ) - ")->execute(); - echo "✓ Test table created\n\n"; - - // 4. Execute various queries with different performance characteristics - echo "4. Executing test queries:\n"; - - // Fast queries - for ($i = 1; $i <= 5; $i++) { - $database->table('performance_test')->insert([ - 'name' => "User $i", - 'email' => "user$i@example.com" - ])->execute(); - } - echo "✓ Executed 5 fast INSERT queries\n"; - - // Medium speed queries - for ($i = 1; $i <= 3; $i++) { - $database->table('performance_test') - ->select() - ->where('email', "user$i@example.com") - ->execute(); - } - echo "✓ Executed 3 medium SELECT queries\n"; - - // Simulate slow queries with SLEEP - $database->setQuery("SELECT SLEEP(0.03)")->execute(); // 30ms - $database->setQuery("SELECT SLEEP(0.08)")->execute(); // 80ms - $database->setQuery("SELECT SLEEP(0.12)")->execute(); // 120ms - echo "✓ Executed 3 slow queries with artificial delays\n\n"; - - // 5. Analyze performance using the new PerformanceAnalyzer - echo "5. Performance Analysis:\n"; - $analyzer = $database->getPerformanceMonitor()->getAnalyzer(); - - echo "Query Statistics:\n"; - echo " - Total queries: ".$analyzer->getQueryCount()."\n"; - echo " - Total execution time: ".number_format($analyzer->getTotalTime(), 2)." ms\n"; - echo " - Average execution time: ".number_format($analyzer->getAverageTime(), 2)." ms\n"; - echo " - Performance score: ".$analyzer->getScore()."\n"; - echo " - Query efficiency: ".number_format($analyzer->getEfficiency(), 1)."%\n\n"; - - // 6. Analyze slow queries - echo "6. Slow Query Analysis:\n"; - $slowQueries = $analyzer->getSlowQueries(); - echo " - Slow queries found: ".$analyzer->getSlowQueryCount()."\n"; - - if (!empty($slowQueries)) { - echo " - Slow query details:\n"; - - foreach ($slowQueries as $index => $metric) { - $query = $metric->getQuery(); - $time = $metric->getExecutionTimeMs(); - $rows = $metric->getRowsAffected(); - - // Truncate long queries for display - $displayQuery = strlen($query) > 60 ? substr($query, 0, 57).'...' : $query; - echo " ".($index + 1).". ".number_format($time, 2)."ms - $displayQuery ($rows rows)\n"; - } - } else { - echo " - No slow queries detected\n"; - } - echo "\n"; - - // 7. Performance recommendations - echo "7. Performance Recommendations:\n"; - $score = $analyzer->getScore(); - $efficiency = $analyzer->getEfficiency(); - - switch ($score) { - case PerformanceAnalyzer::SCORE_EXCELLENT: - echo " ✓ Excellent performance! Your queries are running very efficiently.\n"; - break; - case PerformanceAnalyzer::SCORE_GOOD: - echo " ✓ Good performance overall. Consider optimizing slow queries if any.\n"; - break; - case PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT: - echo " ⚠ Performance needs improvement. Focus on optimizing slow queries.\n"; - break; - } - - if ($efficiency < 80) { - echo " ⚠ Query efficiency is below 80%. Consider:\n"; - echo " - Adding database indexes\n"; - echo " - Optimizing query structure\n"; - echo " - Reviewing WHERE clauses\n"; - } - - if ($analyzer->getSlowQueryCount() > 0) { - echo " ⚠ Slow queries detected. Consider:\n"; - echo " - Adding appropriate indexes\n"; - echo " - Limiting result sets with LIMIT\n"; - echo " - Breaking complex queries into smaller ones\n"; - } - echo "\n"; - - // 8. Cleanup - echo "8. Cleanup:\n"; - $database->setQuery("DROP TABLE performance_test")->execute(); - echo "✓ Test table dropped\n"; -} catch (Exception $e) { - echo "Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n".$e->getTraceAsString()."\n"; -} - -echo "\n=== Example Complete ==="; + ")->execute(); + echo "✓ Test table created\n\n"; + + // 4. Execute various queries with different performance characteristics + echo "4. Executing test queries:\n"; + + // Fast queries + for ($i = 1; $i <= 5; $i++) { + $database->table('performance_test')->insert([ + 'name' => "User $i", + 'email' => "user$i@example.com" + ])->execute(); + } + echo "✓ Executed 5 fast INSERT queries\n"; + + // Medium speed queries + for ($i = 1; $i <= 3; $i++) { + $database->table('performance_test') + ->select() + ->where('email', "user$i@example.com") + ->execute(); + } + echo "✓ Executed 3 medium SELECT queries\n"; + + // Simulate slow queries with SLEEP + $database->setQuery("SELECT SLEEP(0.03)")->execute(); // 30ms + $database->setQuery("SELECT SLEEP(0.08)")->execute(); // 80ms + $database->setQuery("SELECT SLEEP(0.12)")->execute(); // 120ms + echo "✓ Executed 3 slow queries with artificial delays\n\n"; + + // 5. Analyze performance using the new PerformanceAnalyzer + echo "5. Performance Analysis:\n"; + $analyzer = $database->getPerformanceMonitor()->getAnalyzer(); + + echo "Query Statistics:\n"; + echo " - Total queries: ".$analyzer->getQueryCount()."\n"; + echo " - Total execution time: ".number_format($analyzer->getTotalTime(), 2)." ms\n"; + echo " - Average execution time: ".number_format($analyzer->getAverageTime(), 2)." ms\n"; + echo " - Performance score: ".$analyzer->getScore()."\n"; + echo " - Query efficiency: ".number_format($analyzer->getEfficiency(), 1)."%\n\n"; + + // 6. Analyze slow queries + echo "6. Slow Query Analysis:\n"; + $slowQueries = $analyzer->getSlowQueries(); + echo " - Slow queries found: ".$analyzer->getSlowQueryCount()."\n"; + + if (!empty($slowQueries)) { + echo " - Slow query details:\n"; + + foreach ($slowQueries as $index => $metric) { + $query = $metric->getQuery(); + $time = $metric->getExecutionTimeMs(); + $rows = $metric->getRowsAffected(); + + // Truncate long queries for display + $displayQuery = strlen($query) > 60 ? substr($query, 0, 57).'...' : $query; + echo " ".($index + 1).". ".number_format($time, 2)."ms - $displayQuery ($rows rows)\n"; + } + } else { + echo " - No slow queries detected\n"; + } + echo "\n"; + + // 7. Performance recommendations + echo "7. Performance Recommendations:\n"; + $score = $analyzer->getScore(); + $efficiency = $analyzer->getEfficiency(); + + switch ($score) { + case PerformanceAnalyzer::SCORE_EXCELLENT: + echo " ✓ Excellent performance! Your queries are running very efficiently.\n"; + break; + case PerformanceAnalyzer::SCORE_GOOD: + echo " ✓ Good performance overall. Consider optimizing slow queries if any.\n"; + break; + case PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT: + echo " ⚠ Performance needs improvement. Focus on optimizing slow queries.\n"; + break; + } + + if ($efficiency < 80) { + echo " ⚠ Query efficiency is below 80%. Consider:\n"; + echo " - Adding database indexes\n"; + echo " - Optimizing query structure\n"; + echo " - Reviewing WHERE clauses\n"; + } + + if ($analyzer->getSlowQueryCount() > 0) { + echo " ⚠ Slow queries detected. Consider:\n"; + echo " - Adding appropriate indexes\n"; + echo " - Limiting result sets with LIMIT\n"; + echo " - Breaking complex queries into smaller ones\n"; + } + echo "\n"; + + // 8. Cleanup + echo "8. Cleanup:\n"; + $database->setQuery("DROP TABLE performance_test")->execute(); + echo "✓ Test table dropped\n"; +} catch (Exception $e) { + echo "Error: ".$e->getMessage()."\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; +} + +echo "\n=== Example Complete ==="; diff --git a/examples/09-multi-result-queries/README.md b/examples/09-multi-result-queries/README.md new file mode 100644 index 00000000..a9870be3 --- /dev/null +++ b/examples/09-multi-result-queries/README.md @@ -0,0 +1,30 @@ +# Multi-Result Queries + +This example demonstrates how to handle stored procedures and queries that return multiple result sets. + +## What This Example Shows + +- Creating stored procedures with multiple SELECT statements +- Executing multi-result queries +- Working with `MultiResultSet` objects +- Processing individual result sets + +## Files + +- [`example.php`](example.php) - Main example code + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will create stored procedures, execute them, and demonstrate how to iterate through multiple result sets. + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Basic query operations +- [05-transactions](../05-transactions/) - Combine with transactions +- [08-performance-monitoring](../08-performance-monitoring/) - Monitor complex query performance diff --git a/examples/09-multi-result-queries/example.php b/examples/09-multi-result-queries/example.php index d790c338..190d609b 100644 --- a/examples/09-multi-result-queries/example.php +++ b/examples/09-multi-result-queries/example.php @@ -113,25 +113,25 @@ if ($result instanceof MultiResultSet) { echo "✓ Multi-result query executed successfully!\n"; - echo "Number of result sets: " . $result->count() . "\n\n"; + echo "Number of result sets: ".$result->count()."\n\n"; // Process each result set for ($i = 0; $i < $result->count(); $i++) { $resultSet = $result->getResultSet($i); - + if ($resultSet->getRowsCount() > 0) { $firstRow = $resultSet->getRows()[0]; - + if (isset($firstRow['report_section'])) { echo "--- {$firstRow['report_section']} ---\n"; - + foreach ($resultSet as $row) { if ($row['report_section'] === 'Product Inventory') { - echo " {$row['category']}: {$row['name']} - $" . number_format($row['price'], 2) . " (Stock: {$row['stock']})\n"; + echo " {$row['category']}: {$row['name']} - $".number_format($row['price'], 2)." (Stock: {$row['stock']})\n"; } elseif ($row['report_section'] === 'Sales by Category') { - echo " {$row['category']}: {$row['total_orders']} orders, {$row['total_quantity']} items, $" . number_format($row['total_revenue'], 2) . " revenue\n"; + echo " {$row['category']}: {$row['total_orders']} orders, {$row['total_quantity']} items, $".number_format($row['total_revenue'], 2)." revenue\n"; } elseif ($row['report_section'] === 'Recent Orders') { - echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} = $" . number_format($row['order_total'], 2) . " ({$row['order_date']})\n"; + echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} = $".number_format($row['order_total'], 2)." ({$row['order_date']})\n"; } } echo "\n"; @@ -184,21 +184,21 @@ for ($i = 0; $i < $categoryResult->count(); $i++) { $rs = $categoryResult->getResultSet($i); - + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['report_section'])) { echo "--- {$firstRow['report_section']} ---\n"; - + foreach ($rs as $row) { if ($row['report_section'] === 'Products in Category') { - echo " {$row['name']}: $" . number_format($row['price'], 2) . " (Stock: {$row['stock']})\n"; + echo " {$row['name']}: $".number_format($row['price'], 2)." (Stock: {$row['stock']})\n"; } elseif ($row['report_section'] === 'Category Statistics') { echo " Category: {$row['category']}\n"; echo " Product Count: {$row['product_count']}\n"; - echo " Average Price: $" . number_format($row['avg_price'], 2) . "\n"; - echo " Price Range: $" . number_format($row['min_price'], 2) . " - $" . number_format($row['max_price'], 2) . "\n"; + echo " Average Price: $".number_format($row['avg_price'], 2)."\n"; + echo " Price Range: $".number_format($row['min_price'], 2)." - $".number_format($row['max_price'], 2)."\n"; echo " Total Stock: {$row['total_stock']}\n"; } elseif ($row['report_section'] === 'Category Orders') { echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} ({$row['order_date']})\n"; @@ -222,12 +222,13 @@ $ordersResults = $businessReport->getResultSet(2); echo "✓ Extracted individual result sets:\n"; - echo " - Inventory results: " . $inventoryResults->getRowsCount() . " products\n"; - echo " - Sales results: " . $salesResults->getRowsCount() . " categories\n"; - echo " - Orders results: " . $ordersResults->getRowsCount() . " orders\n\n"; + echo " - Inventory results: ".$inventoryResults->getRowsCount()." products\n"; + echo " - Sales results: ".$salesResults->getRowsCount()." categories\n"; + echo " - Orders results: ".$ordersResults->getRowsCount()." orders\n\n"; // Process specific result set echo "Low stock products (< 20 items):\n"; + foreach ($inventoryResults as $product) { if ($product['stock'] < 20) { echo " ⚠️ {$product['name']}: {$product['stock']} remaining\n"; @@ -238,16 +239,16 @@ // Find best selling category $bestCategory = null; $bestRevenue = 0; - + foreach ($salesResults as $category) { if ($category['total_revenue'] > $bestRevenue) { $bestRevenue = $category['total_revenue']; $bestCategory = $category['category']; } } - + if ($bestCategory) { - echo "🏆 Best performing category: {$bestCategory} ($" . number_format($bestRevenue, 2) . " revenue)\n\n"; + echo "🏆 Best performing category: {$bestCategory} ($".number_format($bestRevenue, 2)." revenue)\n\n"; } } @@ -290,15 +291,16 @@ // Test with different parameters echo "Summary Report:\n"; $summaryResult = $database->raw("CALL GetDynamicReport(?)", ['summary'])->execute(); - + if ($summaryResult instanceof MultiResultSet) { for ($i = 0; $i < $summaryResult->count(); $i++) { $rs = $summaryResult->getResultSet($i); + foreach ($rs as $row) { if (isset($row['metric'])) { echo " {$row['metric']}: {$row['value']}\n"; } elseif (isset($row['month'])) { - echo " {$row['month']}: $" . number_format($row['revenue'], 2) . "\n"; + echo " {$row['month']}: $".number_format($row['revenue'], 2)."\n"; } } } @@ -311,10 +313,9 @@ $database->raw("DROP TABLE orders")->execute(); $database->raw("DROP TABLE products")->execute(); echo "✓ Test tables and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; // Clean up on error try { diff --git a/examples/10-attribute-based-tables/Article.php b/examples/10-attribute-based-tables/Article.php new file mode 100644 index 00000000..b2ff791c --- /dev/null +++ b/examples/10-attribute-based-tables/Article.php @@ -0,0 +1,25 @@ +getCols()))."\n"; + + echo "✓ Articles table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($articlesTable->getCols()))."\n\n"; + + echo "2. Generated SQL:\n"; + echo "Authors table:\n".$authorsTable->toSQL()."\n\n"; + echo "Articles table:\n".$articlesTable->toSQL()."\n\n"; + + echo "3. Creating Tables in Database:\n"; + + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + $database->raw("DROP TABLE IF EXISTS authors")->execute(); + + $database->raw($authorsTable->toSQL())->execute(); + echo "✓ Authors table created\n"; + + $database->raw($articlesTable->toSQL())->execute(); + echo "✓ Articles table created\n\n"; + + echo "4. Inserting Test Data:\n"; + + $database->addTable($authorsTable); + $database->addTable($articlesTable); + + $database->table('authors')->insert(['name' => 'Ibrahim Ali', 'email' => 'ibrahim@example.com'])->execute(); + $database->table('authors')->insert(['name' => 'Sara Ahmed', 'email' => 'sara@example.com'])->execute(); + echo "✓ Authors inserted\n"; + + $database->table('articles')->insert(['author-id' => 1, 'title' => 'Introduction to PHP 8 Attributes', 'content' => 'PHP 8 introduced attributes...'])->execute(); + $database->table('articles')->insert(['author-id' => 1, 'title' => 'Database Design Patterns', 'content' => 'Learn about patterns...'])->execute(); + $database->table('articles')->insert(['author-id' => 2, 'title' => 'Clean Architecture in PHP', 'content' => 'Implementing clean architecture...'])->execute(); + echo "✓ Articles inserted\n\n"; + + echo "5. Querying Data:\n"; + + $result = $database->raw(" + SELECT a.name as author, ar.title, ar.`published-at` + FROM authors a JOIN articles ar ON a.id = ar.`author-id` + ORDER BY ar.`published-at` DESC + ")->execute(); + + echo "Articles with authors:\n"; + foreach ($result as $row) { + echo " - {$row['title']} by {$row['author']} ({$row['published-at']})\n"; + } + echo "\n"; + + echo "6. Cleanup:\n"; + $database->raw("DROP TABLE articles")->execute(); + $database->raw("DROP TABLE authors")->execute(); + echo "✓ Tables dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + $database->raw("DROP TABLE IF EXISTS authors")->execute(); + } catch (Exception $cleanupError) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/11-repository-pattern/Product.php b/examples/11-repository-pattern/Product.php new file mode 100644 index 00000000..6848bbce --- /dev/null +++ b/examples/11-repository-pattern/Product.php @@ -0,0 +1,16 @@ +name = $name; + $this->category = $category; + $this->price = $price; + $this->stock = $stock; + } +} diff --git a/examples/11-repository-pattern/ProductRepository.php b/examples/11-repository-pattern/ProductRepository.php new file mode 100644 index 00000000..def57315 --- /dev/null +++ b/examples/11-repository-pattern/ProductRepository.php @@ -0,0 +1,53 @@ +id = (int) $row['id']; + $product->name = $row['name']; + $product->category = $row['category']; + $product->price = (float) $row['price']; + $product->stock = (int) $row['stock']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'category' => $entity->category, + 'price' => $entity->price, + 'stock' => $entity->stock + ]; + } + + public function findByCategory(string $category): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('category', $category) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + public function findLowStock(int $threshold = 10): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('stock', $threshold, '<') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/11-repository-pattern/README.md b/examples/11-repository-pattern/README.md new file mode 100644 index 00000000..7e763453 --- /dev/null +++ b/examples/11-repository-pattern/README.md @@ -0,0 +1,58 @@ +# Repository Pattern + +This example demonstrates how to use the Repository pattern with `AbstractRepository` for data access. + +## Entity + Repository = Model + +In traditional MVC frameworks, a "Model" class often combines data and database logic together (Active Record pattern). The Repository pattern separates these into two focused classes: + +| Component | Responsibility | +|-----------|----------------| +| **Entity** (`Product`) | Plain data object holding state. No database logic. | +| **Repository** (`ProductRepository`) | Handles all database operations for that entity. | + +``` +Traditional MVC Model = Entity + Repository +``` + +## Why Use This Approach? + +1. **Single Responsibility** - Each class has one job. Entities hold data, repositories handle persistence. + +2. **Testability** - Entities can be unit tested without a database. Repositories can be mocked in service tests. + +3. **Flexibility** - Swap database implementations without changing entity code. Easy to switch from MySQL to PostgreSQL or add caching. + +4. **Clean Domain Logic** - Entities stay focused on business rules, not database concerns. + +5. **Reusable Queries** - Common queries live in the repository and can be reused across the application. + +## What This Example Shows + +- Extending `AbstractRepository` for CRUD operations +- Implementing `toEntity()` and `toArray()` for mapping +- Using built-in methods: `findAll()`, `findById()`, `save()`, `deleteById()` +- Creating custom query methods (`findByCategory()`, `findLowStock()`) +- Pagination with `paginate()` + +## Files + +- [`example.php`](example.php) - Main example code +- [`Product.php`](Product.php) - Product entity class +- [`ProductRepository.php`](ProductRepository.php) - Repository extending AbstractRepository + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will demonstrate all repository operations including create, read, update, delete, and pagination. + +## Related Examples + +- [04-entity-mapping](../04-entity-mapping/) - Entity class basics +- [12-clean-architecture](../12-clean-architecture/) - Repository with domain separation +- [13-pagination](../13-pagination/) - Advanced pagination techniques diff --git a/examples/11-repository-pattern/example.php b/examples/11-repository-pattern/example.php new file mode 100644 index 00000000..42e3b680 --- /dev/null +++ b/examples/11-repository-pattern/example.php @@ -0,0 +1,118 @@ +raw("DROP TABLE IF EXISTS products")->execute(); + $database->createBlueprint('products')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], + 'category' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'price' => [ColOption::TYPE => DataType::DECIMAL, ColOption::SIZE => 10], + 'stock' => [ColOption::TYPE => DataType::INT] + ]); + $database->table('products')->createTable(); + $database->execute(); + echo "✓ Products table created\n\n"; + + echo "2. Creating Repository:\n"; + $productRepo = new ProductRepository($database); + echo "✓ ProductRepository created\n\n"; + + echo "3. Saving Products (Create):\n"; + $products = [ + new Product('Laptop', 'Electronics', 999.99, 15), + new Product('Mouse', 'Electronics', 29.99, 50), + new Product('Keyboard', 'Electronics', 79.99, 5), + new Product('Chair', 'Furniture', 199.99, 8), + new Product('Desk', 'Furniture', 299.99, 3), + new Product('Book', 'Education', 19.99, 100) + ]; + + foreach ($products as $product) { + $productRepo->save($product); + echo " ✓ Saved: {$product->name}\n"; + } + echo "\n"; + + echo "4. Finding All Products (Read):\n"; + $allProducts = $productRepo->findAll(); + echo "Total products: ".count($allProducts)."\n"; + foreach ($allProducts as $p) { + echo " - {$p->name} ({$p->category}): \${$p->price} - Stock: {$p->stock}\n"; + } + echo "\n"; + + echo "5. Finding by ID:\n"; + $product = $productRepo->findById(1); + if ($product) { + echo " Found: {$product->name} - \${$product->price}\n\n"; + } + + echo "6. Custom Query - Find by Category:\n"; + $electronics = $productRepo->findByCategory('Electronics'); + echo "Electronics products: ".count($electronics)."\n"; + foreach ($electronics as $p) { + echo " - {$p->name}: \${$p->price}\n"; + } + echo "\n"; + + echo "7. Custom Query - Find Low Stock:\n"; + $lowStock = $productRepo->findLowStock(10); + echo "Low stock products (< 10): ".count($lowStock)."\n"; + foreach ($lowStock as $p) { + echo " ⚠️ {$p->name}: {$p->stock} remaining\n"; + } + echo "\n"; + + echo "8. Updating a Product:\n"; + $product = $productRepo->findById(3); + if ($product) { + echo " Before: {$product->name} - Stock: {$product->stock}\n"; + $product->stock = 25; + $productRepo->save($product); + $updated = $productRepo->findById(3); + echo " After: {$updated->name} - Stock: {$updated->stock}\n\n"; + } + + echo "9. Pagination (Offset-based):\n"; + $page1 = $productRepo->paginate(1, 3); + echo "Page 1 (3 per page):\n"; + echo " Total items: {$page1->getTotalItems()}\n"; + echo " Total pages: {$page1->getTotalPages()}\n"; + foreach ($page1->getItems() as $p) { + echo " - {$p->name}\n"; + } + echo "\n"; + + echo "10. Counting Products:\n"; + echo " Total products in database: ".$productRepo->count()."\n\n"; + + echo "11. Deleting a Product:\n"; + $productRepo->deleteById(6); + echo " ✓ Deleted product with ID 6\n"; + echo " Products remaining: ".$productRepo->count()."\n\n"; + + echo "12. Cleanup:\n"; + $database->raw("DROP TABLE products")->execute(); + echo "✓ Products table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { $database->raw("DROP TABLE IF EXISTS products")->execute(); } catch (Exception $cleanupError) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/12-clean-architecture/Domain/User.php b/examples/12-clean-architecture/Domain/User.php new file mode 100644 index 00000000..96445191 --- /dev/null +++ b/examples/12-clean-architecture/Domain/User.php @@ -0,0 +1,15 @@ + $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } + + public function findById(mixed $id): ?User { + return parent::findById($id); + } + + public function findAll(): array { + return parent::findAll(); + } + + public function delete(mixed $id): void { + $this->deleteById($id); + } +} diff --git a/examples/12-clean-architecture/Infrastructure/Repository/UserRepository.php b/examples/12-clean-architecture/Infrastructure/Repository/UserRepository.php new file mode 100644 index 00000000..4a9ac083 --- /dev/null +++ b/examples/12-clean-architecture/Infrastructure/Repository/UserRepository.php @@ -0,0 +1,48 @@ + $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } + + /** @return User[] */ + public function findByAge(int $minAge): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('age', $minAge, '>=') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php b/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php new file mode 100644 index 00000000..ea17f7f2 --- /dev/null +++ b/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php @@ -0,0 +1,18 @@ +getCols()))."\n\n"; + + echo "2. Creating Table:\n"; + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->addTable($table); + $database->createTables()->execute(); + echo "✓ Users table created\n\n"; + + echo "3. Using Repository (extends AbstractRepository):\n"; + $userRepo = new UserRepository($database); + echo "✓ UserRepository created\n\n"; + + echo "4. Saving Domain Entities:\n"; + $users = [ + new User(null, 'Ahmed Ali', 'ahmed@example.com', 28), + new User(null, 'Sara Hassan', 'sara@example.com', 35), + new User(null, 'Omar Khalil', 'omar@example.com', 22) + ]; + + foreach ($users as $user) { + $userRepo->save($user); + echo " ✓ Saved: {$user->name}\n"; + } + echo "\n"; + + echo "5. Repository Operations:\n"; + + // findAll() + $all = $userRepo->findAll(); + echo "All users (".count($all)."):\n"; + foreach ($all as $u) { + echo " - {$u->name} ({$u->email}) - Age: {$u->age}\n"; + } + + // findById() + $user = $userRepo->findById(1); + echo "\nFind by ID 1: {$user->name}\n"; + + // Custom method + $adults = $userRepo->findByAge(25); + echo "\nUsers age >= 25 (".count($adults)."):\n"; + foreach ($adults as $u) { + echo " - {$u->name} (Age: {$u->age})\n"; + } + + // Pagination + $page = $userRepo->paginate(1, 2); + echo "\nPage 1 (2 per page): {$page->getTotalItems()} total, {$page->getTotalPages()} pages\n"; + + // count() + echo "\nTotal count: ".$userRepo->count()."\n\n"; + + echo "6. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); + echo "✓ Table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { $database->raw("DROP TABLE IF EXISTS users")->execute(); } catch (Exception $e) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/13-pagination/README.md b/examples/13-pagination/README.md new file mode 100644 index 00000000..b93bf95a --- /dev/null +++ b/examples/13-pagination/README.md @@ -0,0 +1,32 @@ +# Pagination + +This example demonstrates offset-based and cursor-based pagination techniques. + +## What This Example Shows + +- Offset-based pagination with `paginate()` +- Cursor-based pagination with `paginateByCursor()` +- Working with `Page` and `CursorPage` objects +- Pagination with ordering + +## Files + +- [`example.php`](example.php) - Main example code +- [`User.php`](User.php) - User entity class +- [`UserRepository.php`](UserRepository.php) - Repository extending AbstractRepository + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will demonstrate both pagination approaches with sample data, showing page navigation and cursor handling. + +## Related Examples + +- [11-repository-pattern](../11-repository-pattern/) - Repository with pagination +- [08-performance-monitoring](../08-performance-monitoring/) - Monitor pagination query performance +- [02-basic-queries](../02-basic-queries/) - Basic query operations diff --git a/examples/13-pagination/User.php b/examples/13-pagination/User.php new file mode 100644 index 00000000..41f80121 --- /dev/null +++ b/examples/13-pagination/User.php @@ -0,0 +1,8 @@ +id = (int) $row['id']; + $user->name = $row['name']; + $user->email = $row['email']; + $user->age = (int) $row['age']; + return $user; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } +} diff --git a/examples/13-pagination/example.php b/examples/13-pagination/example.php new file mode 100644 index 00000000..919d3a32 --- /dev/null +++ b/examples/13-pagination/example.php @@ -0,0 +1,94 @@ +raw("DROP TABLE IF EXISTS users")->execute(); + $database->createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], + 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], + 'age' => [ColOption::TYPE => DataType::INT] + ]); + $database->table('users')->createTable(); + $database->execute(); + + $names = ['Ahmed', 'Fatima', 'Omar', 'Layla', 'Hassan', 'Sara', 'Yusuf', 'Maryam', 'Ali', 'Noor', + 'Khalid', 'Aisha', 'Ibrahim', 'Zahra', 'Mahmoud', 'Hana', 'Tariq', 'Salma', 'Rami', 'Dina', + 'Faisal', 'Lina', 'Samir', 'Rania', 'Walid']; + + foreach ($names as $i => $name) { + $database->table('users')->insert([ + 'name' => $name, + 'email' => strtolower($name).'@example.com', + 'age' => 20 + ($i % 30) + ])->execute(); + } + echo "✓ Created 25 test users\n\n"; + + $repo = new UserRepository($database); + + echo "2. Offset-Based Pagination:\n"; + echo " (Traditional page numbers)\n\n"; + + for ($page = 1; $page <= 3; $page++) { + $result = $repo->paginate($page, 5); + echo "Page $page of {$result->getTotalPages()}:\n"; + foreach ($result->getItems() as $user) { + echo " - {$user->name} ({$user->email})\n"; + } + echo " Has next: ".($result->hasNextPage() ? 'Yes' : 'No')."\n\n"; + } + + echo "3. Cursor-Based Pagination:\n"; + echo " (Better for large datasets, infinite scroll)\n\n"; + + $cursor = null; // null = start from beginning (first page) + $pageNum = 1; + + while ($pageNum <= 3) { + $result = $repo->paginateByCursor($cursor, 5, 'id', 'ASC'); + echo "Cursor Page $pageNum:\n"; + foreach ($result->getItems() as $user) { + echo " - ID {$user->id}: {$user->name}\n"; + } + echo " Has more: ".($result->hasMore() ? 'Yes' : 'No')."\n"; + + if (!$result->hasMore()) break; + + // Next cursor is base64-encoded ID of last item, used to fetch next page + $cursor = $result->getNextCursor(); + echo " Next cursor: $cursor\n\n"; + $pageNum++; + } + + echo "\n4. Pagination with Ordering:\n"; + $result = $repo->paginate(1, 5, ['age' => 'DESC']); + echo "Top 5 oldest users:\n"; + foreach ($result->getItems() as $user) { + echo " - {$user->name} (Age: {$user->age})\n"; + } + + echo "\n5. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); + echo "✓ Table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { $database->raw("DROP TABLE IF EXISTS users")->execute(); } catch (Exception $e) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/14-active-record-model/Article.php b/examples/14-active-record-model/Article.php new file mode 100644 index 00000000..a1d94d9b --- /dev/null +++ b/examples/14-active-record-model/Article.php @@ -0,0 +1,66 @@ +db); + $article->id = (int) $row['id']; + $article->title = $row['title']; + $article->content = $row['content']; + $article->authorName = $row['author-name'] ?? ''; + $article->createdAt = $row['created-at'] ?? null; + return $article; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'title' => $entity->title, + 'content' => $entity->content, + 'author-name' => $entity->authorName, + ]; + } + + // Custom query methods + public function findByAuthor(string $author): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('author-name', $author) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/14-active-record-model/README.md b/examples/14-active-record-model/README.md new file mode 100644 index 00000000..6120b5c3 --- /dev/null +++ b/examples/14-active-record-model/README.md @@ -0,0 +1,83 @@ +# Active Record Model + +This example demonstrates merging Entity and Repository into a single Model class, similar to the Active Record pattern used in many MVC frameworks. + +## Concept + +Instead of separating Entity and Repository: + +``` +Separate (Repository Pattern): +├── Product.php (Entity - data only) +└── ProductRepository.php (Repository - database operations) + +Merged (Active Record): +└── Article.php (Model - data + database operations) +``` + +## How It Works + +The `Article` class: +1. **Extends `AbstractRepository`** - Inherits all CRUD operations +2. **Uses `#[Table]` and `#[Column]` attributes** - Defines table structure +3. **Has public properties** - Holds entity data +4. **Implements mapping methods** - `toEntity()` and `toArray()` + +```php +#[Table(name: 'articles')] +class Article extends AbstractRepository { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title = ''; + + // ... more properties +} +``` + +## Usage + +```php +// Create and save directly +$article = new Article($database); +$article->title = 'My Article'; +$article->content = 'Content here...'; +$article->save(); // Saves itself + +// Query using any instance +$articles = $article->findAll(); +$one = $article->findById(1); + +// Update +$one->title = 'Updated Title'; +$one->save(); +``` + +## Trade-offs + +| Approach | Pros | Cons | +|----------|------|------| +| **Merged (Active Record)** | Simple, less files, familiar to MVC developers | Harder to test, mixed responsibilities | +| **Separate (Repository)** | Testable, flexible, clean separation | More files, more boilerplate | + +Choose based on project complexity: +- **Small projects** → Active Record is simpler +- **Large projects** → Repository pattern scales better + +## Files + +- [`example.php`](example.php) - Main example code +- [`Article.php`](Article.php) - Model class with attributes + +## Running the Example + +```bash +php example.php +``` + +## Related Examples + +- [10-attribute-based-tables](../10-attribute-based-tables/) - Using attributes for table definition +- [11-repository-pattern](../11-repository-pattern/) - Separate Entity + Repository approach +- [12-clean-architecture](../12-clean-architecture/) - Full separation with domain layer diff --git a/examples/14-active-record-model/example.php b/examples/14-active-record-model/example.php new file mode 100644 index 00000000..96ba72f1 --- /dev/null +++ b/examples/14-active-record-model/example.php @@ -0,0 +1,99 @@ +addTable($table); + $database->table('articles')->createTable()->execute(); + echo "✓ Articles table created from class attributes\n\n"; + + echo "2. Using the Model:\n"; + + // Create and save articles directly + $article1 = new Article($database); + $article1->title = 'Introduction to WebFiori'; + $article1->content = 'WebFiori is a PHP framework...'; + $article1->authorName = 'Ahmad Hassan'; + $article1->save(); // Saves itself + + $article2 = new Article($database); + $article2->title = 'Database Patterns'; + $article2->content = 'Understanding repository pattern...'; + $article2->authorName = 'Fatima Ali'; + $article2->save(); + + $article3 = new Article($database); + $article3->title = 'Advanced PHP'; + $article3->content = 'PHP 8 features and attributes...'; + $article3->authorName = 'Ahmad Hassan'; + $article3->save(); + + echo "✓ Articles saved\n\n"; + + echo "3. Querying with Repository Methods:\n"; + + // Use any instance for queries + $articleModel = new Article($database); + + // findAll() + $all = $articleModel->findAll(); + echo "All articles ({$articleModel->count()}):\n"; + foreach ($all as $article) { + echo " - {$article->title} by {$article->authorName}\n"; + } + echo "\n"; + + // Custom query method + $byAuthor = $articleModel->findByAuthor('Ahmad Hassan'); + echo "Articles by Ahmad Hassan:\n"; + foreach ($byAuthor as $article) { + echo " - {$article->title}\n"; + } + echo "\n"; + + echo "4. Update and Delete:\n"; + + // Update + $first = $articleModel->findById(1); + $first->title = 'Updated: Introduction to WebFiori'; + $first->save(); // Saves itself + echo "✓ Article updated\n"; + + // Delete + $first->id = 2; + $first->deleteById(); + echo "✓ Article deleted\n"; + + echo "\nRemaining articles:\n"; + foreach ($articleModel->findAll() as $article) { + echo " - [{$article->id}] {$article->title}\n"; + } + echo "\n"; + + echo "5. Cleanup:\n"; + $database->raw("DROP TABLE articles")->execute(); + echo "✓ Table dropped\n"; + +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + } catch (Exception $cleanupError) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/README.md b/examples/README.md index 42b74fcc..e82bcdcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,15 +4,22 @@ This directory contains practical examples demonstrating how to use the WebFiori ## Examples Overview -1. **[01-basic-connection](01-basic-connection/)** - How to establish database connections -2. **[02-basic-queries](02-basic-queries/)** - CRUD operations (Insert, Select, Update, Delete) -3. **[03-table-blueprints](03-table-blueprints/)** - Creating and managing database table structures -4. **[04-entity-mapping](04-entity-mapping/)** - Working with entity classes and object mapping -5. **[05-transactions](05-transactions/)** - Database transactions for data integrity -6. **[06-migrations](06-migrations/)** - Database schema migrations and versioning -7. **[07-seeders](07-seeders/)** - Database data seeding and population -8. **[08-performance-monitoring](08-performance-monitoring/)** - Query performance tracking and analysis -9. **[09-multi-result-queries](09-multi-result-queries/)** - Multi-result query handling and stored procedures +| # | Example | Description | +|---|---------|-------------| +| 01 | [basic-connection](01-basic-connection/) | Establishing database connections | +| 02 | [basic-queries](02-basic-queries/) | CRUD operations (Insert, Select, Update, Delete) | +| 03 | [table-blueprints](03-table-blueprints/) | Creating and managing database table structures | +| 04 | [entity-mapping](04-entity-mapping/) | Working with entity classes and object mapping | +| 05 | [transactions](05-transactions/) | Database transactions for data integrity | +| 06 | [migrations](06-migrations/) | Database schema migrations and versioning | +| 07 | [seeders](07-seeders/) | Database data seeding and population | +| 08 | [performance-monitoring](08-performance-monitoring/) | Query performance tracking and analysis | +| 09 | [multi-result-queries](09-multi-result-queries/) | Multi-result query handling and stored procedures | +| 10 | [attribute-based-tables](10-attribute-based-tables/) | PHP 8 attributes for table definitions | +| 11 | [repository-pattern](11-repository-pattern/) | Repository pattern with AbstractRepository | +| 12 | [clean-architecture](12-clean-architecture/) | Clean architecture with domain/infrastructure separation | +| 13 | [pagination](13-pagination/) | Offset and cursor-based pagination | +| 14 | [active-record-model](14-active-record-model/) | Entity + Repository merged into single Model class | ## Prerequisites @@ -47,64 +54,82 @@ You can modify the connection parameters in each example as needed. - Testing connections with simple queries ### 02-basic-queries -- Table creation and management -- INSERT operations with data validation -- SELECT operations with filtering and conditions -- UPDATE operations with WHERE clauses -- DELETE operations with conditions -- Query result handling +- Using `raw()` method for SQL queries with parameters +- INSERT, SELECT, UPDATE, DELETE operations +- Multi-result queries with stored procedures ### 03-table-blueprints -- Creating table blueprints with column definitions -- Using different data types (INT, VARCHAR, TEXT, TIMESTAMP) +- Creating table blueprints with `createBlueprint()` +- Using `ColOption` and `DataType` constants - Setting column constraints (PRIMARY KEY, NOT NULL, AUTO_INCREMENT) -- Creating foreign key relationships +- Creating foreign key relationships with `addReference()` - Generating and executing CREATE TABLE statements ### 04-entity-mapping -- Generating entity classes from table blueprints +- Generating entity classes from table blueprints using `EntityMapper` +- Auto-generated getters/setters and `map()` method - Mapping database records to PHP objects -- Working with mapped objects and their methods -- Filtering and manipulating object collections ### 05-transactions -- Creating database transactions for data integrity -- Handling successful transaction commits -- Automatic rollback on transaction failures -- Error handling within transactions +- Creating database transactions with `transaction()` method +- Automatic commit on success +- Automatic rollback on exception - Complex multi-table operations ### 06-migrations - Creating migration classes extending `AbstractMigration` +- Implementing `up()` and `down()` methods - Using `SchemaRunner` for migration management -- Registering migrations with the schema runner -- Applying and rolling back migrations -- Schema change tracking and versioning +- Applying migrations with `apply()` +- Rolling back with `rollbackUpTo()` +- Schema change tracking ### 07-seeders - Creating seeder classes extending `AbstractSeeder` +- Implementing `run()` method +- Environment-specific seeding with `getEnvironments()` - Using `SchemaRunner` for seeder management -- Registering seeders with the schema runner -- Populating database with sample data -- Environment-specific seeding ### 08-performance-monitoring -- Configuring performance monitoring settings -- Tracking query execution times and statistics -- Identifying slow queries and performance bottlenecks -- Using `PerformanceAnalyzer` for detailed analysis -- Performance optimization recommendations +- Configuring performance monitoring with `setPerformanceConfig()` +- Using `PerformanceOption` constants +- Tracking query execution times +- Using `PerformanceAnalyzer` for metrics +- Identifying slow queries ### 09-multi-result-queries -- Executing stored procedures that return multiple result sets +- Executing stored procedures returning multiple result sets - Working with `MultiResultSet` objects -- Processing individual result sets from multi-result queries -- Parameterized stored procedure calls using `raw()` method -- Complex business reporting with multiple data views - -## Notes - -- All examples include proper error handling and cleanup -- Generated files (like entity classes) are automatically cleaned up -- Examples use temporary tables that are dropped after execution -- Each example is thoroughly tested and produces expected output +- Processing individual result sets + +### 10-attribute-based-tables +- Using PHP 8 attributes: `#[Table]`, `#[Column]`, `#[ForeignKey]` +- Building tables with `AttributeTableBuilder::build()` +- Defining entities with attribute-based schema + +### 11-repository-pattern +- Extending `AbstractRepository` for CRUD operations +- Implementing `toEntity()` and `toArray()` methods +- Using built-in methods: `findAll()`, `findById()`, `save()`, `deleteById()` +- Creating custom query methods +- Pagination with `paginate()` + +### 13-pagination +- Offset-based pagination with `paginate()` +- Cursor-based pagination with `paginateByCursor()` +- Working with `Page` and `CursorPage` objects +- Pagination with ordering + + +### 12-clean-architecture +- Separating Domain from Infrastructure +- Pure domain entities (no framework dependencies) +- Repository interface in Domain layer +- Database implementation in Infrastructure layer +- Dependency inversion principle + +### 14-active-record-model +- Merging Entity and Repository into a single Model class +- Using attributes to define table structure on the model +- Active Record pattern for simpler projects +- Trade-offs vs Repository pattern diff --git a/sonar-project.properties b/sonar-project.properties index 281a0445..ec19bba5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,12 +1,12 @@ -sonar.projectKey=WebFiori_database -sonar.organization=webfiori - -sonar.projectName=database -sonar.projectVersion=1.0 - -sonar.exclusions=tests/** -sonar.coverage.exclusions=tests/** - -sonar.php.coverage.reportPaths=clover.xml -# Encoding of the source code. Default is default system encoding -sonar.sourceEncoding=UTF-8 +sonar.projectKey=WebFiori_database +sonar.organization=webfiori + +sonar.projectName=database +sonar.projectVersion=1.0 + +sonar.exclusions=tests/**,examples/** +sonar.coverage.exclusions=tests/**,examples/** + +sonar.php.coverage.reportPaths=clover.xml +# Encoding of the source code. Default is default system encoding +sonar.sourceEncoding=UTF-8 diff --git a/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php b/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php new file mode 100644 index 00000000..014d29f1 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php @@ -0,0 +1,117 @@ + 'id'], name: 'fk_item_order', onUpdate: 'cascade', onDelete: 'cascade')] +#[ForeignKey(table: 'products', columns: ['product-id' => 'id'], name: 'fk_item_product', onUpdate: 'cascade', onDelete: 'cascade')] +class TestOrderItem { +} + +#[Table(name: 'composite_ref')] +#[Column(name: 'tenant-id', type: DataType::INT)] +#[Column(name: 'user-id', type: DataType::INT)] +#[Column(name: 'data', type: DataType::VARCHAR, size: 100)] +#[ForeignKey(table: 'tenant_users', columns: ['tenant-id' => 'tenant_id', 'user-id' => 'user_id'], name: 'fk_composite', onUpdate: 'cascade', onDelete: 'cascade')] +class TestCompositeFK { +} + +class ForeignKeyAttributeTest extends TestCase { + public function testSingleColumnFK() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertEquals(['id'], $fk->getColumnsMap()); + } + + public function testMultipleColumnsFK() { + $fk = new ForeignKey(table: 'users', columns: ['local_id' => 'id', 'tenant_id' => 'tenant_id']); + $this->assertEquals(['local_id' => 'id', 'tenant_id' => 'tenant_id'], $fk->getColumnsMap()); + } + + public function testBothColumnAndColumnsThrowsException() { + $this->expectException(InvalidAttributeException::class); + $this->expectExceptionMessage("ForeignKey: Use either 'column' or 'columns', not both"); + + new ForeignKey(table: 'users', column: 'id', columns: ['local_id' => 'id']); + } + + public function testClassReferenceResolution() { + $table = AttributeTableBuilder::build(TestPost::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('`users`', $sql); + $this->assertStringContainsString('fk_post_user', $sql); + } + + public function testPropertyLevelFK() { + $table = AttributeTableBuilder::build(TestPost::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('foreign key (`user-id`) references `users` (`id`)', $sql); + $this->assertStringContainsString('on update cascade on delete cascade', $sql); + } + + public function testClassLevelFKWithColumns() { + $table = AttributeTableBuilder::build(TestOrderItem::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('fk_item_order', $sql); + $this->assertStringContainsString('fk_item_product', $sql); + $this->assertStringContainsString('references `orders`', $sql); + $this->assertStringContainsString('references `products`', $sql); + } + + public function testCompositeForeignKey() { + $table = AttributeTableBuilder::build(TestCompositeFK::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('fk_composite', $sql); + $this->assertStringContainsString('references `tenant_users`', $sql); + $this->assertStringContainsString('(`tenant_id`, `user_id`)', $sql); + } + + public function testDefaultFKName() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertNull($fk->name); + } + + public function testDefaultOnUpdateOnDelete() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertEquals('set null', $fk->onUpdate); + $this->assertEquals('set null', $fk->onDelete); + } +} diff --git a/tests/WebFiori/Tests/Database/Common/ConditionTest.php b/tests/WebFiori/Tests/Database/Common/ConditionTest.php index 250d7621..bcdd40b4 100644 --- a/tests/WebFiori/Tests/Database/Common/ConditionTest.php +++ b/tests/WebFiori/Tests/Database/Common/ConditionTest.php @@ -1,7 +1,7 @@ assertEquals('mixed', $colObj->getPHPType()); + $this->assertEquals('string', $colObj->getPHPType()); $colObj->setIsNull(true); - $this->assertEquals('mixed|null', $colObj->getPHPType()); + $this->assertEquals('string|null', $colObj->getPHPType()); } /** * @test diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLQueryBuilderTest.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLQueryBuilderTest.php index 47ce9329..4b1ad9c3 100644 --- a/tests/WebFiori/Tests/Database/MsSql/MSSQLQueryBuilderTest.php +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLQueryBuilderTest.php @@ -7,7 +7,7 @@ use WebFiori\Database\ConnectionInfo; use WebFiori\Database\Database; use WebFiori\Database\DatabaseException; -use WebFiori\Database\Expression; +use WebFiori\Database\Query\Expression; use WebFiori\Database\MsSql\MSSQLConnection; use WebFiori\Tests\Database\MsSql\MSSQLTestSchema; diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLTableTest.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLTableTest.php index d6aeb02a..3fe74a6f 100644 --- a/tests/WebFiori/Tests/Database/MsSql/MSSQLTableTest.php +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLTableTest.php @@ -567,4 +567,41 @@ public function testWithBoolCol00() { ]); $this->assertEquals('boolean',$table->getColByKey('is-active')->getDatatype()); } -} + /** + * @test + */ + public function testAttributeBasedTable() { + $table = \WebFiori\Database\Attributes\AttributeTableBuilder::build( + \WebFiori\Tests\Database\MsSql\MSSQLAttributeTestUser::class, + 'mssql' + ); + + $this->assertEquals('[test_users]', $table->getName()); + $this->assertTrue($table->hasColumn('id')); + $this->assertTrue($table->hasColumn('name')); + $this->assertTrue($table->hasColumn('email')); + + $idCol = $table->getColByKey('id'); + $this->assertTrue($idCol->isPrimary()); + $this->assertTrue($idCol->isIdentity()); + + $emailCol = $table->getColByKey('email'); + $this->assertTrue($emailCol->isUnique()); + } + /** + * @test + */ + public function testAttributeBasedTableWithFK() { + $table = \WebFiori\Database\Attributes\AttributeTableBuilder::build( + \WebFiori\Tests\Database\MsSql\MSSQLAttributeTestPost::class, + 'mssql' + ); + + $this->assertEquals('[test_posts]', $table->getName()); + $this->assertTrue($table->hasColumnWithKey('user-id')); + + $fk = $table->getForeignKey('fk_post_user'); + $this->assertNotNull($fk); + $this->assertEquals('[test_users]', $fk->getSourceName()); + } +} diff --git a/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php b/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php index 89b180cc..5ee28134 100644 --- a/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php +++ b/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php @@ -9,7 +9,7 @@ namespace WebFiori\Tests\Database\MySql; use PHPUnit\Framework\TestCase; -use WebFiori\Database\ColumnFactory; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DataType; use WebFiori\Database\MsSql\MSSQLColumn; use WebFiori\Database\MySql\MySQLColumn; diff --git a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php new file mode 100644 index 00000000..e9eeabea --- /dev/null +++ b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php @@ -0,0 +1,243 @@ +id = $id; + $this->name = $name; + $this->value = $value; + } +} + +class TestRepository extends AbstractRepository { + protected function getTableName(): string { + return 'test_entities'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + return new TestEntity( + (int) $row['id'], + $row['name'], + (int) $row['value'] + ); + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'value' => $entity->value + ]; + } +} + +class AbstractRepositoryTest extends TestCase { + private static ?Database $db = null; + private static ?TestRepository $repo = null; + + public static function setUpBeforeClass(): void { + $conn = new ConnectionInfo('mysql', 'root', '123456', 'testing_db', '127.0.0.1'); + self::$db = new Database($conn); + + self::$db->createBlueprint('test_entities')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'value' => [ + ColOption::TYPE => DataType::INT + ] + ]); + + self::$db->table('test_entities')->createTable()->execute(); + self::$repo = new TestRepository(self::$db); + } + + public static function tearDownAfterClass(): void { + self::$db->setQuery('DROP TABLE IF EXISTS test_entities'); + self::$db->execute(); + } + + protected function setUp(): void { + self::$db->table('test_entities')->delete()->execute(); + } + + public function testSaveNewEntity() { + $entity = new TestEntity(null, 'Test', 100); + self::$repo->save($entity); + + $this->assertEquals(1, self::$repo->count()); + $found = self::$repo->findAll()[0]; + $this->assertEquals('Test', $found->name); + $this->assertEquals(100, $found->value); + } + + public function testSaveExistingEntity() { + $entity = new TestEntity(null, 'Original', 50); + self::$repo->save($entity); + + $found = self::$repo->findAll()[0]; + $found->name = 'Updated'; + $found->value = 75; + self::$repo->save($found); + + $this->assertEquals(1, self::$repo->count()); + $updated = self::$repo->findById($found->id); + $this->assertEquals('Updated', $updated->name); + $this->assertEquals(75, $updated->value); + } + + public function testSaveAllEmpty() { + self::$repo->saveAll([]); + $this->assertEquals(0, self::$repo->count()); + } + + public function testSaveAllNewEntities() { + $entities = [ + new TestEntity(null, 'Item1', 10), + new TestEntity(null, 'Item2', 20), + new TestEntity(null, 'Item3', 30) + ]; + + self::$repo->saveAll($entities); + + $this->assertEquals(3, self::$repo->count()); + $all = self::$repo->findAll(); + $names = array_map(fn($e) => $e->name, $all); + $this->assertContains('Item1', $names); + $this->assertContains('Item2', $names); + $this->assertContains('Item3', $names); + } + + public function testSaveAllExistingEntities() { + // Insert initial entities + self::$repo->saveAll([ + new TestEntity(null, 'A', 1), + new TestEntity(null, 'B', 2) + ]); + + // Update them + $all = self::$repo->findAll(); + foreach ($all as $entity) { + $entity->value = $entity->value * 10; + } + self::$repo->saveAll($all); + + $this->assertEquals(2, self::$repo->count()); + $updated = self::$repo->findAll(); + $values = array_map(fn($e) => $e->value, $updated); + sort($values); + $this->assertEquals([10, 20], $values); + } + + public function testSaveAllMixed() { + // Insert one entity first + self::$repo->save(new TestEntity(null, 'Existing', 100)); + $existing = self::$repo->findAll()[0]; + $existing->name = 'Modified'; + + // Save mix of new and existing + self::$repo->saveAll([ + $existing, + new TestEntity(null, 'New1', 200), + new TestEntity(null, 'New2', 300) + ]); + + $this->assertEquals(3, self::$repo->count()); + + $modified = self::$repo->findById($existing->id); + $this->assertEquals('Modified', $modified->name); + + $all = self::$repo->findAll(); + $names = array_map(fn($e) => $e->name, $all); + $this->assertContains('New1', $names); + $this->assertContains('New2', $names); + } + + public function testFindByIdWithNullThrowsException() { + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('Cannot find: no ID provided'); + + self::$repo->findById(null); + } + + public function testDeleteByIdWithNullThrowsException() { + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('Cannot delete: no ID provided'); + + self::$repo->deleteById(null); + } + + public function testFindByIdWithValidId() { + self::$repo->save(new TestEntity(null, 'FindMe', 42)); + $all = self::$repo->findAll(); + $id = $all[0]->id; + + $found = self::$repo->findById($id); + + $this->assertNotNull($found); + $this->assertEquals('FindMe', $found->name); + } + + public function testDeleteByIdWithValidId() { + self::$repo->save(new TestEntity(null, 'DeleteMe', 99)); + $all = self::$repo->findAll(); + $id = $all[0]->id; + + self::$repo->deleteById($id); + + $this->assertEquals(0, self::$repo->count()); + } + + public function testSaveWithNullOnPureRepoThrowsException() { + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('Cannot save: no entity provided'); + + self::$repo->save(); + } + + public function testReloadWithEntity() { + $entity = new TestEntity(null, 'Original', 100); + self::$repo->save($entity); + $saved = self::$repo->findAll()[0]; + + // Modify in database directly + self::$db->table('test_entities') + ->update(['name' => 'Modified']) + ->where('id', $saved->id) + ->execute(); + + $reloaded = self::$repo->reload($saved); + + $this->assertEquals('Modified', $reloaded->name); + } + + public function testReloadWithNullOnPureRepoThrowsException() { + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('Cannot find: no ID provided'); + + self::$repo->reload(); + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php b/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php new file mode 100644 index 00000000..adef9712 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php @@ -0,0 +1,138 @@ +getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertInstanceOf(DatabaseChangeResult::class, $result); + $this->assertCount(1, $result->getApplied()); + $this->assertTrue($result->isSuccessful()); + $this->assertGreaterThan(0, $result->getTotalTime()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksSkippedEnvironment() { + $runner = new SchemaRunner($this->getConnectionInfo(), 'production'); + $runner->register(DevOnlySeeder::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + $this->assertEquals('Environment mismatch', $result->getSkipped()[0]['reason']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksAlreadyApplied() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + // First apply + $runner->apply(); + + // Second apply - should skip + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + $this->assertEquals('Already applied', $result->getSkipped()[0]['reason']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksFailed() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(FailingMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getFailed()); + $this->assertFalse($result->isSuccessful()); + $this->assertInstanceOf(\Throwable::class, $result->getFailed()[0]['error']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testBackwardCompatibilityCount() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + // Old code: count($applied) still works + $this->assertEquals(1, count($result)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testBackwardCompatibilityForeach() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + // Old code: foreach ($applied as $change) still works + $count = 0; + foreach ($result as $change) { + $this->assertInstanceOf(SuccessfulMigration::class, $change); + $count++; + } + $this->assertEquals(1, $count); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php b/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php new file mode 100644 index 00000000..90e71a62 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php @@ -0,0 +1,231 @@ +getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $nextBatch = $runner->getRepository()->getNextBatchNumber(); + $this->assertEquals(1, $nextBatch); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetLastBatchNumberEmpty() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $lastBatch = $runner->getRepository()->getLastBatchNumber(); + $this->assertEquals(0, $lastBatch); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyAssignsSameBatchNumber() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $applied = $runner->apply(); + $this->assertCount(2, $applied); + + // Both should have batch 1 + $records = $runner->getRepository()->getByBatch(1); + $this->assertCount(2, $records); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testMultipleApplyCallsCreateDifferentBatches() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // First apply - batch 1 + $runner->apply(); + + // Register more and apply again - batch 2 + $runner->register(BatchMigrationB::class); + $runner->apply(); + + $batch1 = $runner->getRepository()->getByBatch(1); + $batch2 = $runner->getRepository()->getByBatch(2); + + $this->assertCount(1, $batch1); + $this->assertCount(1, $batch2); + $this->assertEquals(BatchMigrationA::class, $batch1[0]['change_name']); + $this->assertEquals(BatchMigrationB::class, $batch2[0]['change_name']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyOneCreatesNewBatchEachTime() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $runner->applyOne(); // batch 1 + $runner->applyOne(); // batch 2 + + $batch1 = $runner->getRepository()->getByBatch(1); + $batch2 = $runner->getRepository()->getByBatch(2); + + $this->assertCount(1, $batch1); + $this->assertCount(1, $batch2); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackLastBatch() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // Apply batch 1 + $runner->apply(); + + // Register more and apply batch 2 + $runner->register(BatchMigrationB::class); + $runner->register(BatchMigrationC::class); + $runner->apply(); + + // Rollback last batch (should rollback B and C) + $rolled = $runner->rollbackLastBatch(); + + $this->assertCount(2, $rolled); + $this->assertTrue($runner->isApplied(BatchMigrationA::class)); + $this->assertFalse($runner->isApplied(BatchMigrationB::class)); + $this->assertFalse($runner->isApplied(BatchMigrationC::class)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackBatch() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // Apply batch 1 + $runner->apply(); + + // Apply batch 2 + $runner->register(BatchMigrationB::class); + $runner->apply(); + + // Rollback batch 1 specifically + $rolled = $runner->rollbackBatch(1); + + $this->assertCount(1, $rolled); + $this->assertFalse($runner->isApplied(BatchMigrationA::class)); + $this->assertTrue($runner->isApplied(BatchMigrationB::class)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackLastBatchWhenEmpty() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $rolled = $runner->rollbackLastBatch(); + $this->assertEmpty($rolled); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetLastBatchChangeNames() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $runner->apply(); + + $names = $runner->getRepository()->getLastBatchChangeNames(); + + $this->assertCount(2, $names); + $this->assertContains(BatchMigrationA::class, $names); + $this->assertContains(BatchMigrationB::class, $names); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php new file mode 100644 index 00000000..a50d9fa4 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php @@ -0,0 +1,204 @@ +tempDir = sys_get_temp_dir() . '/db_generator_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void { + $this->removeDirectory($this->tempDir); + } + + private function removeDirectory(string $dir): void { + if (!is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($dir); + } + + public function testSetPath() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath('/some/path'); + + $this->assertEquals('/some/path', $generator->getPath()); + } + + public function testSetNamespace() { + $generator = new DatabaseChangeGenerator(); + $generator->setNamespace('App\\Migrations'); + + $this->assertEquals('App\\Migrations', $generator->getNamespace()); + } + + public function testSetNamespaceTrimsSlashes() { + $generator = new DatabaseChangeGenerator(); + $generator->setNamespace('\\App\\Migrations\\'); + + $this->assertEquals('App\\Migrations', $generator->getNamespace()); + } + + public function testUseTimestampPrefix() { + $generator = new DatabaseChangeGenerator(); + + $this->assertFalse($generator->isTimestampPrefixEnabled()); + + $generator->useTimestampPrefix(true); + $this->assertTrue($generator->isTimestampPrefixEnabled()); + } + + public function testCreateMigrationBasic() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->setNamespace('App\\Migrations'); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertStringEndsWith('CreateUsersTable.php', $path); + + $content = file_get_contents($path); + $this->assertStringContainsString('namespace App\\Migrations;', $content); + $this->assertStringContainsString('use WebFiori\Database\Schema\AbstractMigration;', $content); + $this->assertStringContainsString('class CreateUsersTable extends AbstractMigration', $content); + $this->assertStringContainsString('public function up(Database $db): void', $content); + $this->assertStringContainsString('public function down(Database $db): void', $content); + } + + public function testCreateMigrationWithTimestamp() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->useTimestampPrefix(true); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertMatchesRegularExpression('/\d{4}_\d{2}_\d{2}_\d{6}_CreateUsersTable\.php$/', $path); + } + + public function testCreateMigrationWithDependencies() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('AddPostsTable', [ + GeneratorOption::DEPENDENCIES => ['CreateUsersTable', 'App\\Migrations\\CreateCategoriesTable'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getDependencies(): array', $content); + $this->assertStringContainsString('CreateUsersTable::class', $content); + $this->assertStringContainsString('App\\Migrations\\CreateCategoriesTable::class', $content); + } + + public function testCreateMigrationWithTableHint() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('CreateUsersTable', [ + GeneratorOption::TABLE => 'users' + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString("table 'users'", $content); + } + + public function testCreateSeederBasic() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->setNamespace('App\\Seeders'); + + $path = $generator->createSeeder('UsersSeeder'); + + $this->assertFileExists($path); + $this->assertStringEndsWith('UsersSeeder.php', $path); + + $content = file_get_contents($path); + $this->assertStringContainsString('namespace App\\Seeders;', $content); + $this->assertStringContainsString('use WebFiori\Database\Schema\AbstractSeeder;', $content); + $this->assertStringContainsString('class UsersSeeder extends AbstractSeeder', $content); + $this->assertStringContainsString('public function run(Database $db): void', $content); + } + + public function testCreateSeederWithEnvironments() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createSeeder('TestDataSeeder', [ + GeneratorOption::ENVIRONMENTS => ['dev', 'test'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getEnvironments(): array', $content); + $this->assertStringContainsString("'dev'", $content); + $this->assertStringContainsString("'test'", $content); + } + + public function testCreateSeederWithDependencies() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createSeeder('PostsSeeder', [ + GeneratorOption::DEPENDENCIES => ['UsersSeeder'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getDependencies(): array', $content); + $this->assertStringContainsString('UsersSeeder::class', $content); + } + + public function testCreateWithoutNamespace() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('CreateUsersTable'); + + $content = file_get_contents($path); + $this->assertStringNotContainsString('namespace', $content); + } + + public function testCreateWithoutPathThrows() { + $generator = new DatabaseChangeGenerator(); + + $this->expectException(\WebFiori\Database\DatabaseException::class); + $this->expectExceptionMessage('Path not set'); + + $generator->createMigration('CreateUsersTable'); + } + + public function testCreatesDirectoryIfNotExists() { + $generator = new DatabaseChangeGenerator(); + $newDir = $this->tempDir . '/nested/path'; + $generator->setPath($newDir); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertDirectoryExists($newDir); + } + + public function testFluentInterface() { + $generator = new DatabaseChangeGenerator(); + + $result = $generator + ->setPath($this->tempDir) + ->setNamespace('App\\Migrations') + ->useTimestampPrefix(true); + + $this->assertSame($generator, $result); + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php new file mode 100644 index 00000000..d279582c --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php @@ -0,0 +1,113 @@ +assertEmpty($result->getApplied()); + $this->assertEmpty($result->getSkipped()); + $this->assertEmpty($result->getFailed()); + $this->assertEquals(0, $result->getTotalTime()); + $this->assertTrue($result->isSuccessful()); + $this->assertEquals(0, count($result)); + } + + public function testAddApplied() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + + $result->addApplied($change); + + $this->assertCount(1, $result->getApplied()); + $this->assertSame($change, $result->getApplied()[0]); + $this->assertEquals(1, count($result)); + } + + public function testAddSkipped() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + + $result->addSkipped($change, 'Already applied'); + + $this->assertCount(1, $result->getSkipped()); + $this->assertSame($change, $result->getSkipped()[0]['change']); + $this->assertEquals('Already applied', $result->getSkipped()[0]['reason']); + } + + public function testAddFailed() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + $error = new \Exception('Test error'); + + $result->addFailed($change, $error); + + $this->assertCount(1, $result->getFailed()); + $this->assertSame($change, $result->getFailed()[0]['change']); + $this->assertSame($error, $result->getFailed()[0]['error']); + $this->assertFalse($result->isSuccessful()); + } + + public function testTotalTime() { + $result = new DatabaseChangeResult(); + + $result->setTotalTime(123.45); + + $this->assertEquals(123.45, $result->getTotalTime()); + } + + public function testCountableInterface() { + $result = new DatabaseChangeResult(); + $result->addApplied(new SuccessfulMigration()); + $result->addApplied(new DevOnlySeeder()); + + $this->assertEquals(2, count($result)); + } + + public function testIteratorInterface() { + $result = new DatabaseChangeResult(); + $m1 = new SuccessfulMigration(); + $m2 = new DevOnlySeeder(); + $result->addApplied($m1); + $result->addApplied($m2); + + $iterated = []; + foreach ($result as $change) { + $iterated[] = $change; + } + + $this->assertCount(2, $iterated); + $this->assertSame($m1, $iterated[0]); + $this->assertSame($m2, $iterated[1]); + } + + public function testIsSuccessfulWithMixedResults() { + $result = new DatabaseChangeResult(); + $result->addApplied(new SuccessfulMigration()); + $result->addSkipped(new DevOnlySeeder(), 'Environment'); + + $this->assertTrue($result->isSuccessful()); + + $result->addFailed(new FailingMigration(), new \Exception('Error')); + + $this->assertFalse($result->isSuccessful()); + } + + public function testConnectionInfo() { + $result = new DatabaseChangeResult(); + + $this->assertNull($result->getConnectionInfo()); + $this->assertNull($result->getDatabaseName()); + + $connInfo = new \WebFiori\Database\ConnectionInfo('mysql', 'root', '123456', 'test_db'); + $result->setConnectionInfo($connInfo); + + $this->assertSame($connInfo, $result->getConnectionInfo()); + $this->assertEquals('test_db', $result->getDatabaseName()); + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php b/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php new file mode 100644 index 00000000..f1a5db87 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php @@ -0,0 +1,13 @@ +tempDir = sys_get_temp_dir() . '/schema_discover_test_' . uniqid(); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void { + $this->removeDirectory($this->tempDir); + gc_collect_cycles(); + } + + private function removeDirectory(string $dir): void { + if (!is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($dir); + } + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testDiscoverFromEmptyDirectory() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(0, $count); + $this->assertEmpty($runner->getChanges()); + } + + public function testDiscoverFromNonExistentDirectory() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $count = $runner->discoverFromPath('/non/existent/path', 'TestMigrations'); + + $this->assertEquals(0, $count); + } + + public function testDiscoverMigrationClass() { + $this->createMigrationFile($this->tempDir, 'TestMigrationA', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + $this->assertCount(1, $runner->getChanges()); + $this->assertTrue($runner->hasChange('TestMigrations\\TestMigrationA')); + } + + public function testDiscoverMultipleClasses() { + $this->createMigrationFile($this->tempDir, 'MigrationOne', 'TestMigrations'); + $this->createMigrationFile($this->tempDir, 'MigrationTwo', 'TestMigrations'); + $this->createSeederFile($this->tempDir, 'SeederOne', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(3, $count); + $this->assertCount(3, $runner->getChanges()); + } + + public function testDiscoverIgnoresNonPhpFiles() { + $this->createMigrationFile($this->tempDir, 'ValidMigration', 'TestMigrations'); + file_put_contents($this->tempDir . '/readme.txt', 'This is not a PHP file'); + file_put_contents($this->tempDir . '/config.json', '{}'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverIgnoresNonDatabaseChangeClasses() { + $this->createMigrationFile($this->tempDir, 'ValidMigration', 'TestMigrations'); + $this->createNonChangeClass($this->tempDir, 'SomeHelper', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverIgnoresAbstractClasses() { + $this->createMigrationFile($this->tempDir, 'ConcreteMigration', 'TestMigrations'); + $this->createAbstractMigrationFile($this->tempDir, 'BaseMigration', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverNonRecursiveIgnoresSubdirectories() { + $this->createMigrationFile($this->tempDir, 'RootMigration', 'TestMigrations'); + + $subDir = $this->tempDir . '/SubDir'; + mkdir($subDir); + $this->createMigrationFile($subDir, 'SubMigration', 'TestMigrations\\SubDir'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations', recursive: false); + + $this->assertEquals(1, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\RootMigration')); + $this->assertFalse($runner->hasChange('TestMigrations\\SubDir\\SubMigration')); + } + + public function testDiscoverRecursiveIncludesSubdirectories() { + $this->createMigrationFile($this->tempDir, 'RootMigration', 'TestMigrations'); + + $subDir = $this->tempDir . '/SubDir'; + mkdir($subDir); + $this->createMigrationFile($subDir, 'SubMigration', 'TestMigrations\\SubDir'); + + $deepDir = $subDir . '/Deep'; + mkdir($deepDir); + $this->createMigrationFile($deepDir, 'DeepMigration', 'TestMigrations\\SubDir\\Deep'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations', recursive: true); + + $this->assertEquals(3, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\RootMigration')); + $this->assertTrue($runner->hasChange('TestMigrations\\SubDir\\SubMigration')); + $this->assertTrue($runner->hasChange('TestMigrations\\SubDir\\Deep\\DeepMigration')); + } + + public function testDiscoverWithTrailingSlashInNamespace() { + $this->createMigrationFile($this->tempDir, 'TestMigration', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations\\'); + + $this->assertEquals(1, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\TestMigration')); + } + + private function createMigrationFile(string $dir, string $className, string $namespace): void { + $content = <<createBlueprint('dry_run_test')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + } + + public function down(Database $db): void { + $db->table('dry_run_test')->drop()->execute(); + } +} + +class DryRunTest extends TestCase { + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testSetDryRunMode() { + $db = new Database($this->getConnectionInfo()); + + $this->assertFalse($db->isDryRun()); + + $db->setDryRun(true); + $this->assertTrue($db->isDryRun()); + + $db->setDryRun(false); + $this->assertFalse($db->isDryRun()); + } + + public function testDryRunCapturesQueries() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + + $captured = $db->getCapturedQueries(); + + $this->assertNotEmpty($captured); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $captured[0]); + } + + public function testDryRunReturnsEmptyResultSet() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $result = $db->execute(); + + $this->assertInstanceOf('WebFiori\Database\ResultSet', $result); + $this->assertEquals(0, $result->getRowsCount()); + } + + public function testDryRunClearsOnEnable() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test1')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + + $this->assertNotEmpty($db->getCapturedQueries()); + + // Re-enable should clear + $db->setDryRun(true); + $this->assertEmpty($db->getCapturedQueries()); + } + + public function testGetPendingChangesWithoutQueries() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + + $pending = $runner->getPendingChanges(false); + + $this->assertCount(1, $pending); + $this->assertInstanceOf(DryRunMigration::class, $pending[0]['change']); + $this->assertEmpty($pending[0]['queries']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetPendingChangesWithQueries() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + + $pending = $runner->getPendingChanges(true); + + $this->assertCount(1, $pending); + $this->assertNotEmpty($pending[0]['queries']); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $pending[0]['queries'][0]); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetPendingChangesExcludesApplied() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + + // Ensure clean state + $runner->rollbackUpTo(null); + + // Check if we have any changes to work with + $allChanges = $runner->getChanges(); + if (empty($allChanges)) { + $this->markTestSkipped('No changes registered for testing'); + return; + } + + // Apply changes + $result = $runner->apply(); + + // After apply, pending changes should exclude applied ones + $pending = $runner->getPendingChanges(); + $applied = $result->getApplied(); + + // If we applied changes, pending should be empty or reduced + if (!empty($applied)) { + $this->assertEmpty($pending, 'Pending changes should exclude applied changes'); + } else { + $this->markTestSkipped('No changes were applied to test exclusion'); + } + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\WebFiori\Database\DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testDryRunDoesNotExecuteQueries() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + // Build a query that would fail if executed (table doesn't exist) + $db->createBlueprint('nonexistent_dry_run_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + + // Should not throw - query is captured, not executed + $result = $db->execute(); + + $this->assertNotEmpty($db->getCapturedQueries()); + $this->assertInstanceOf('WebFiori\Database\ResultSet', $result); + } +} + diff --git a/tests/WebFiori/Tests/Database/Schema/FailingMigration.php b/tests/WebFiori/Tests/Database/Schema/FailingMigration.php new file mode 100644 index 00000000..deb00540 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/FailingMigration.php @@ -0,0 +1,13 @@ +apply(); - $this->assertIsArray($applied); + $this->assertInstanceOf(\WebFiori\Database\Schema\DatabaseChangeResult::class, $applied); } catch (DatabaseException $ex) { $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php index 9656f9e4..87f99817 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php @@ -32,7 +32,7 @@ public function testRollbackErrorStopsExecution() { // Apply changes first $applied = $runner->apply(); - if (!empty($applied)) { + if (!empty($applied->getApplied())) { $errorCaught = false; $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { $errorCaught = true; @@ -156,7 +156,9 @@ public function testDatabaseConnectionFailureDuringExecution() { $this->expectException(DatabaseException::class); $this->expectExceptionMessage('Unable to connect to database'); - $runner = new SchemaRunner($badConnection); + $runner = new SchemaRunner($badConnection); + // Trigger actual database connection attempt + $runner->createSchemaTable(); } // Type Safety and Validation Issues diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php index 8630e4ce..a2985d6e 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php @@ -1,355 +1,363 @@ -getConnectionInfo()); - - $this->assertEquals('dev', $runner->getEnvironment()); - $this->assertIsArray($runner->getChanges()); - } - - public function testConstructWithEnvironment() { - $runner = new SchemaRunner($this->getConnectionInfo(), 'test'); - - $this->assertEquals('test', $runner->getEnvironment()); - } - - public function testGetChanges() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $this->assertIsArray($runner->getChanges()); - $this->assertEmpty($runner->getChanges()); - } - - public function testInvalidPath() { - // Test registration-based approach doesn't need path validation - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertCount(1, $runner->getChanges()); - } - - public function testAddOnErrorCallback() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $callbackCalled = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$callbackCalled) { - $callbackCalled = true; - }); - - // Callback should be added (we can't directly test this without triggering an error) - $this->assertTrue(true); - } - - public function testAddOnRegisterErrorCallback() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $callbackCalled = false; - $runner->addOnRegisterErrorCallback(function($err) use (&$callbackCalled) { - $callbackCalled = true; - }); - - // Callback should be added - $this->assertTrue(true); - } - - public function testClearErrorCallbacks() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $runner->addOnErrorCallback(function($err, $change, $schema) {}); - $runner->clearErrorCallbacks(); - - // Should clear callbacks without error - $this->assertTrue(true); - } - - public function testClearRegisterErrorCallbacks() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $runner->addOnRegisterErrorCallback(function($err) {}); - $runner->clearRegisterErrorCallbacks(); - - // Should clear callbacks without error - $this->assertTrue(true); - } - - public function testHasChange() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - $this->assertFalse($runner->hasChange('NonExistentChange')); - } - - public function testApplyOne() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $change = $runner->applyOne(); - - if ($change !== null) { - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $change); - $this->assertTrue($runner->isApplied($change->getName())); - } else { - // If no changes to apply, that's also valid - $this->assertTrue(true, 'No changes to apply'); - } - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testApplyOneWithNoChanges() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - // Apply all changes first - $runner->apply(); - - // Now applyOne should return null - $change = $runner->applyOne(); - $this->assertNull($change); - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testRollbackUpToSpecificChange() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $applied = $runner->apply(); - - if (!empty($applied)) { - $lastChange = end($applied); - $rolled = $runner->rollbackUpTo($lastChange->getName()); - - $this->assertIsArray($rolled); - $this->assertCount(1, $rolled); - $this->assertEquals($lastChange->getName(), $rolled[0]->getName()); - $this->assertFalse($runner->isApplied($lastChange->getName())); - } else { - $this->assertTrue(true, 'No changes were applied to rollback'); - } - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testRollbackUpToNonExistentChange() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $rolled = $runner->rollbackUpTo('NonExistentChange'); - $this->assertIsArray($rolled); - $this->assertEmpty($rolled); - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testErrorCallbackOnExecutionFailure() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - $this->assertInstanceOf('Throwable', $err); - }); - - // Simulate error by accessing private property and triggering callbacks - $reflection = new \ReflectionClass($runner); - $property = $reflection->getProperty('onErrCallbacks'); - $property->setAccessible(true); - $callbacks = $property->getValue($runner); - - // Manually trigger callbacks to test - foreach ($callbacks as $callback) { - call_user_func_array($callback, [new \Exception('test'), null, null]); - } - - $this->assertTrue($errorCaught); - } - - // File System Scanning Issues - public function testSubdirectoryMigrationsNotDetected() { - // Test that registration approach doesn't have subdirectory issues - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - public function testFileExtensionAssumptions() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create file with multiple dots - file_put_contents($tempDir . '/Migration.backup.php', 'getConnectionInfo()); - - // Should handle file with multiple dots gracefully - $this->assertIsArray($runner->getChanges()); - - // Cleanup - unlink($tempDir . '/Migration.backup.php'); - rmdir($tempDir); - } - - public function testPermissionIssuesOnDirectory() { - // Test that registration approach doesn't have permission issues - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - } - - // Class Loading Issues - public function testNamespaceMismatch() { - // Test registration with invalid class name - $runner = new SchemaRunner($this->getConnectionInfo()); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Class does not exist'); - - $runner->register('InvalidNamespace\\NonExistentClass'); - } - - public function testConstructorDependencies() { - // Test registration handles constructor requirements properly - $runner = new SchemaRunner($this->getConnectionInfo()); - $result = $runner->register(TestMigration::class); - - $this->assertTrue($result); - $this->assertCount(1, $runner->getChanges()); - } - - // Dependency Resolution Issues - public function testMissingDependency() { - // Test dependency validation with registration - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - - // Test that changes are registered properly - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); - } - - public function testCircularDependency() { - // Test circular dependency detection with registration - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->registerAll([TestMigration::class, TestSeeder::class]); - - // Registration succeeds, circular dependencies detected during execution - $this->assertCount(2, $runner->getChanges()); - } - - // Schema Tracking Issues - public function testSchemaTableNotExists() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); // Ensure table exists first - - // Test that we can check if changes are applied - $this->assertFalse($runner->isApplied('NonExistentMigration')); - - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testDuplicateChangeDetection() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - $runner->register(TestMigration::class); // Register same class twice - - $changes = $runner->getChanges(); - $this->assertCount(2, $changes); // Both instances are registered - } - - public function testNameCollisionInFindChangeByName() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->registerAll([TestMigration::class, TestSeeder::class]); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - $this->assertTrue($runner->hasChange(TestSeeder::class)); - } - - // Error Handling Issues - public function testSilentFailureInApply() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - }); - - $runner->register(TestMigration::class); - - // Test that errors are properly caught - $this->assertCount(1, $runner->getChanges()); - } - - public function testRollbackFailureContinuesExecution() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - }); - - $runner->register(TestMigration::class); - $changes = $runner->getChanges(); - - // Test that rollback handling works - $this->assertCount(1, $changes); - $this->assertIsCallable([$runner, 'rollbackUpTo']); - - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } -} +getConnectionInfo()); + + $this->assertEquals('dev', $runner->getEnvironment()); + $this->assertIsArray($runner->getChanges()); + } + + public function testConstructWithEnvironment() { + $runner = new SchemaRunner($this->getConnectionInfo(), 'test'); + + $this->assertEquals('test', $runner->getEnvironment()); + } + + public function testGetChanges() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $this->assertIsArray($runner->getChanges()); + $this->assertEmpty($runner->getChanges()); + } + + public function testInvalidPath() { + // Test registration-based approach doesn't need path validation + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertCount(1, $runner->getChanges()); + } + + public function testAddOnErrorCallback() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $callbackCalled = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$callbackCalled) { + $callbackCalled = true; + }); + + // Callback should be added (we can't directly test this without triggering an error) + $this->assertTrue(true); + } + + public function testAddOnRegisterErrorCallback() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $callbackCalled = false; + $runner->addOnRegisterErrorCallback(function($err) use (&$callbackCalled) { + $callbackCalled = true; + }); + + // Callback should be added + $this->assertTrue(true); + } + + public function testClearErrorCallbacks() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $runner->addOnErrorCallback(function($err, $change, $schema) {}); + $runner->clearErrorCallbacks(); + + // Should clear callbacks without error + $this->assertTrue(true); + } + + public function testClearRegisterErrorCallbacks() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $runner->addOnRegisterErrorCallback(function($err) {}); + $runner->clearRegisterErrorCallbacks(); + + // Should clear callbacks without error + $this->assertTrue(true); + } + + public function testHasChange() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + $this->assertFalse($runner->hasChange('NonExistentChange')); + } + + public function testApplyOne() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $change = $runner->applyOne(); + + if ($change !== null) { + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $change); + $this->assertTrue($runner->isApplied($change->getName())); + } else { + // If no changes to apply, that's also valid + $this->assertTrue(true, 'No changes to apply'); + } + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyOneWithNoChanges() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + // Apply all changes first + $runner->apply(); + + // Now applyOne should return null + $change = $runner->applyOne(); + $this->assertNull($change); + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackUpToSpecificChange() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $applied = $runner->apply(); + $appliedChanges = $applied->getApplied(); + + if (!empty($appliedChanges)) { + $lastChange = end($appliedChanges); + $rolled = $runner->rollbackUpTo($lastChange->getName()); + + $this->assertIsArray($rolled); + $this->assertCount(1, $rolled); + $this->assertEquals($lastChange->getName(), $rolled[0]->getName()); + $this->assertFalse($runner->isApplied($lastChange->getName())); + } else { + $this->assertTrue(true, 'No changes were applied to rollback'); + } + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackUpToNonExistentChange() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $rolled = $runner->rollbackUpTo('NonExistentChange'); + $this->assertIsArray($rolled); + $this->assertEmpty($rolled); + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testErrorCallbackOnExecutionFailure() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + $this->assertInstanceOf('Throwable', $err); + }); + + // Simulate error by accessing private property and triggering callbacks + $reflection = new \ReflectionClass($runner); + $property = $reflection->getProperty('onErrCallbacks'); + $property->setAccessible(true); + $callbacks = $property->getValue($runner); + + // Manually trigger callbacks to test + foreach ($callbacks as $callback) { + call_user_func_array($callback, [new \Exception('test'), null, null]); + } + + $this->assertTrue($errorCaught); + } + + // File System Scanning Issues + public function testSubdirectoryMigrationsNotDetected() { + // Test that registration approach doesn't have subdirectory issues + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testFileExtensionAssumptions() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create file with multiple dots + file_put_contents($tempDir . '/Migration.backup.php', 'getConnectionInfo()); + + // Should handle file with multiple dots gracefully + $this->assertIsArray($runner->getChanges()); + + // Cleanup + unlink($tempDir . '/Migration.backup.php'); + rmdir($tempDir); + } + + public function testPermissionIssuesOnDirectory() { + // Test that registration approach doesn't have permission issues + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + } + + // Class Loading Issues + public function testNamespaceMismatch() { + // Test registration with invalid class name + $runner = new SchemaRunner($this->getConnectionInfo()); + + $errorCaught = false; + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + $this->assertStringContainsString('Class does not exist', $err->getMessage()); + }); + + // Should return false for non-existent class + $result = $runner->register('InvalidNamespace\\NonExistentClass'); + $this->assertFalse($result); + $this->assertTrue($errorCaught, 'Error callback should have been called'); + } + + public function testConstructorDependencies() { + // Test registration handles constructor requirements properly + $runner = new SchemaRunner($this->getConnectionInfo()); + $result = $runner->register(TestMigration::class); + + $this->assertTrue($result); + $this->assertCount(1, $runner->getChanges()); + } + + // Dependency Resolution Issues + public function testMissingDependency() { + // Test dependency validation with registration + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + + // Test that changes are registered properly + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); + } + + public function testCircularDependency() { + // Test circular dependency detection with registration + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->registerAll([TestMigration::class, TestSeeder::class]); + + // Registration succeeds, circular dependencies detected during execution + $this->assertCount(2, $runner->getChanges()); + } + + // Schema Tracking Issues + public function testSchemaTableNotExists() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); // Ensure table exists first + + // Test that we can check if changes are applied + $this->assertFalse($runner->isApplied('NonExistentMigration')); + + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testDuplicateChangeDetection() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $first = $runner->register(TestMigration::class); + $second = $runner->register(TestMigration::class); // Register same class twice + + $this->assertTrue($first); + $this->assertFalse($second); // Second registration returns false + $this->assertCount(1, $runner->getChanges()); // Only one instance registered + } + + public function testNameCollisionInFindChangeByName() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->registerAll([TestMigration::class, TestSeeder::class]); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + $this->assertTrue($runner->hasChange(TestSeeder::class)); + } + + // Error Handling Issues + public function testSilentFailureInApply() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + }); + + $runner->register(TestMigration::class); + + // Test that errors are properly caught + $this->assertCount(1, $runner->getChanges()); + } + + public function testRollbackFailureContinuesExecution() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + }); + + $runner->register(TestMigration::class); + $changes = $runner->getChanges(); + + // Test that rollback handling works + $this->assertCount(1, $changes); + $this->assertIsCallable([$runner, 'rollbackUpTo']); + + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php index ea38390f..3c7f3122 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php @@ -1,231 +1,233 @@ -getConnectionInfo()); - $changes = $runner->getChanges(); - - // Should ignore non-DatabaseChange classes - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/NotAMigration.php'); - rmdir($tempDir); - } - - public function testAbstractClassInstantiationError() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create abstract migration class - file_put_contents($tempDir . '/AbstractTestMigration.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle abstract class error - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/AbstractTestMigration.php'); - rmdir($tempDir); - } - - public function testIncompleteClassImplementation() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create migration missing required methods - file_put_contents($tempDir . '/IncompleteMigration.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - try { - $runner->createSchemaTable(); - $applied = $runner->apply(); - - // Should handle incomplete implementation - $this->assertIsArray($applied); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - - // Cleanup - unlink($tempDir . '/IncompleteMigration.php'); - rmdir($tempDir); - } - - public function testReturnTypeInconsistencies() { - // Test registration handles type validation properly - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); - } - - public function testInterfaceValidationMissing() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 1; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - // Performance and Scalability Issues - public function testMemoryUsageWithManyMigrations() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 50; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(50, $changes); - } - - public function testRepeatedDirectoryScanningOverhead() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 1; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - public function testTopologicalSortPerformance() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 20; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(20, $changes); - } - - // File System Edge Cases - public function testEmptyFileHandling() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create empty PHP file - file_put_contents($tempDir . '/EmptyFile.php', ''); - - $errorCaught = false; - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle empty file gracefully - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/EmptyFile.php'); - rmdir($tempDir); - } - - public function testInvalidPhpSyntaxHandling() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create file with invalid PHP syntax - file_put_contents($tempDir . '/InvalidSyntax.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle syntax errors gracefully - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/InvalidSyntax.php'); - rmdir($tempDir); - } - - public function testNonPhpFileIgnored() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create non-PHP files - file_put_contents($tempDir . '/README.txt', 'This is not a PHP file'); - file_put_contents($tempDir . '/config.json', '{"key": "value"}'); - - $runner = new SchemaRunner($this->getConnectionInfo()); - $changes = $runner->getChanges(); - - // Should ignore non-PHP files - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/README.txt'); - unlink($tempDir . '/config.json'); - rmdir($tempDir); - } -} +getConnectionInfo()); + $changes = $runner->getChanges(); + + // Should ignore non-DatabaseChange classes + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/NotAMigration.php'); + rmdir($tempDir); + } + + public function testAbstractClassInstantiationError() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create abstract migration class + file_put_contents($tempDir . '/AbstractTestMigration.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle abstract class error + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/AbstractTestMigration.php'); + rmdir($tempDir); + } + + public function testIncompleteClassImplementation() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create migration missing required methods + file_put_contents($tempDir . '/IncompleteMigration.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + try { + $runner->createSchemaTable(); + $applied = $runner->apply(); + + // Should handle incomplete implementation + $this->assertInstanceOf(\WebFiori\Database\Schema\DatabaseChangeResult::class, $applied); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + + // Cleanup + unlink($tempDir . '/IncompleteMigration.php'); + rmdir($tempDir); + } + + public function testReturnTypeInconsistencies() { + // Test registration handles type validation properly + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); + } + + public function testInterfaceValidationMissing() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the expected number of migrations + for ($i = 0; $i < 1; $i++) { + $runner->register(TestMigration::class); + } + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + // Performance and Scalability Issues + public function testMemoryUsageWithManyMigrations() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the same class multiple times - duplicates are now prevented + for ($i = 0; $i < 50; $i++) { + $runner->register(TestMigration::class); + } + + // Only 1 should be registered due to duplicate prevention + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testRepeatedDirectoryScanningOverhead() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the expected number of migrations + for ($i = 0; $i < 1; $i++) { + $runner->register(TestMigration::class); + } + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testTopologicalSortPerformance() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the same class multiple times - duplicates are now prevented + for ($i = 0; $i < 20; $i++) { + $runner->register(TestMigration::class); + } + + // Only 1 should be registered due to duplicate prevention + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + // File System Edge Cases + public function testEmptyFileHandling() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create empty PHP file + file_put_contents($tempDir . '/EmptyFile.php', ''); + + $errorCaught = false; + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle empty file gracefully + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/EmptyFile.php'); + rmdir($tempDir); + } + + public function testInvalidPhpSyntaxHandling() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create file with invalid PHP syntax + file_put_contents($tempDir . '/InvalidSyntax.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle syntax errors gracefully + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/InvalidSyntax.php'); + rmdir($tempDir); + } + + public function testNonPhpFileIgnored() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create non-PHP files + file_put_contents($tempDir . '/README.txt', 'This is not a PHP file'); + file_put_contents($tempDir . '/config.json', '{"key": "value"}'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $changes = $runner->getChanges(); + + // Should ignore non-PHP files + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/README.txt'); + unlink($tempDir . '/config.json'); + rmdir($tempDir); + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/SuccessfulMigration.php b/tests/WebFiori/Tests/Database/Schema/SuccessfulMigration.php new file mode 100644 index 00000000..964952ce --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/SuccessfulMigration.php @@ -0,0 +1,11 @@ +getConnectionInfo()->getDatabaseType(); + return self::$detectedDbType !== 'mysql'; + } +} + +class TransactionWrapperTest extends TestCase { + + protected function setUp(): void { + TransactionEnabledMigration::$executed = false; + TransactionDisabledMigration::$executed = false; + DbmsAwareMigration::$executed = false; + DbmsAwareMigration::$detectedDbType = null; + } + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testUseTransactionDefaultsToTrue() { + $migration = new TransactionEnabledMigration(); + $db = new Database($this->getConnectionInfo()); + + $this->assertTrue($migration->useTransaction($db)); + } + + public function testUseTransactionCanBeDisabled() { + $migration = new TransactionDisabledMigration(); + $db = new Database($this->getConnectionInfo()); + + $this->assertFalse($migration->useTransaction($db)); + } + + public function testDbmsAwareTransaction() { + $migration = new DbmsAwareMigration(); + + $mysqlDb = new Database(new ConnectionInfo('mysql', 'root', '123456', 'test')); + $this->assertFalse($migration->useTransaction($mysqlDb)); + $this->assertEquals('mysql', DbmsAwareMigration::$detectedDbType); + + $mssqlDb = new Database(new ConnectionInfo('mssql', 'sa', '123456', 'test')); + $this->assertTrue($migration->useTransaction($mssqlDb)); + $this->assertEquals('mssql', DbmsAwareMigration::$detectedDbType); + } + + public function testApplyUsesTransactionWhenEnabled() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TransactionEnabledMigration::class); + + try { + $runner->createSchemaTable(); + $result = $runner->apply(); + + $this->assertTrue(TransactionEnabledMigration::$executed); + $this->assertCount(1, $result->getApplied()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplySkipsTransactionWhenDisabled() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TransactionDisabledMigration::class); + + try { + $runner->createSchemaTable(); + $result = $runner->apply(); + + $this->assertTrue(TransactionDisabledMigration::$executed); + $this->assertCount(1, $result->getApplied()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +}