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..4bf219cc --- /dev/null +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -0,0 +1,151 @@ +getAttributes(Table::class)[0] ?? null; + + if (!$tableAttr) { + throw new \RuntimeException("Class $entityClass must have #[Table] attribute"); + } + + $tableConfig = $tableAttr->newInstance(); + + $table = $dbType === 'mysql' + ? new MySQLTable($tableConfig->name) + : new MSSQLTable($tableConfig->name); + + if ($tableConfig->comment) { + $table->setComment($tableConfig->comment); + } + + $columns = []; + $foreignKeys = []; + + // Check for class-level Column attributes + $classColumnAttrs = $reflection->getAttributes(Column::class); + + if (!empty($classColumnAttrs)) { + // Class-level approach: columns defined at class level + foreach ($classColumnAttrs as $columnAttr) { + $columnConfig = $columnAttr->newInstance(); + $columnKey = $columnConfig->name ?? throw new \RuntimeException("Column name is required for class-level attributes"); + + $columns[$columnKey] = [ + ColOption::TYPE => $columnConfig->type, + ColOption::NAME => $columnConfig->name, + ColOption::SIZE => $columnConfig->size, + ColOption::SCALE => $columnConfig->scale, + ColOption::PRIMARY => $columnConfig->primary, + ColOption::UNIQUE => $columnConfig->unique, + ColOption::NULL => $columnConfig->nullable, + ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, + ColOption::IDENTITY => $columnConfig->identity, + ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, + ColOption::DEFAULT => $columnConfig->default, + ColOption::COMMENT => $columnConfig->comment, + ColOption::VALIDATOR => $columnConfig->callback + ]; + } + + // Check for class-level ForeignKey attributes + $classFkAttrs = $reflection->getAttributes(ForeignKey::class); + + foreach ($classFkAttrs as $fkAttr) { + $fkConfig = $fkAttr->newInstance(); + $foreignKeys[] = [ + 'property' => $fkConfig->column, + 'config' => $fkConfig + ]; + } + } else { + // Property-level approach: columns defined on properties + 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] = [ + ColOption::TYPE => $columnConfig->type, + ColOption::NAME => $columnConfig->name, + ColOption::SIZE => $columnConfig->size, + ColOption::SCALE => $columnConfig->scale, + ColOption::PRIMARY => $columnConfig->primary, + ColOption::UNIQUE => $columnConfig->unique, + ColOption::NULL => $columnConfig->nullable, + ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, + ColOption::IDENTITY => $columnConfig->identity, + ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, + ColOption::DEFAULT => $columnConfig->default, + ColOption::COMMENT => $columnConfig->comment, + ColOption::VALIDATOR => $columnConfig->callback + ]; + + $fkAttrs = $property->getAttributes(ForeignKey::class); + + foreach ($fkAttrs as $fkAttr) { + $fkConfig = $fkAttr->newInstance(); + $foreignKeys[] = [ + 'property' => $columnKey, + 'config' => $fkConfig + ]; + } + } + } + + $table->addColumns($columns); + + // Store table references for foreign keys + $tableRegistry = []; + + foreach ($foreignKeys as $fk) { + $refTableName = $fk['config']->table; + $refColName = $fk['config']->column; + + // Create a minimal table reference if not exists + if (!isset($tableRegistry[$refTableName])) { + $refTable = $dbType === 'mysql' + ? new MySQLTable($refTableName) + : new MSSQLTable($refTableName); + + // Add the referenced column to make FK work + $refTable->addColumns([ + $refColName => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true + ] + ]); + + $tableRegistry[$refTableName] = $refTable; + } + + $table->addReference( + $tableRegistry[$refTableName], + [$fk['property'] => $refColName], + $fk['config']->name ?? 'fk_'.$fk['property'], + $fk['config']->onUpdate, + $fk['config']->onDelete + ); + } + + return $table; + } + + 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 @@ +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..a82353f5 --- /dev/null +++ b/WebFiori/Database/Entity/EntityGenerator.php @@ -0,0 +1,220 @@ +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()) { + return ' = null'; + } + + if ($col->isNull()) { + return ' = null'; + } + + $default = $col->getDefault(); + + if ($default !== null) { + $phpType = $col->getPHPType(); + + if ($phpType === 'string') { + return " = '".addslashes($default)."'"; + } + + if ($phpType === 'int' || $phpType === 'float') { + return " = {$default}"; + } + + if ($phpType === 'bool') { + return $default ? ' = true' : ' = false'; + } + } + + // Required field with no default + $phpType = $col->getPHPType(); + + if ($phpType === 'string') { + return " = ''"; + } + + if ($phpType === 'int') { + return ' = 0'; + } + + if ($phpType === 'float') { + return ' = 0.0'; + } + + if ($phpType === 'bool') { + return ' = false'; + } + + return ''; + } + + /** + * 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 97% rename from WebFiori/Database/RecordMapper.php rename to WebFiori/Database/Entity/RecordMapper.php index 83c6ba35..ad61e6a7 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. 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 88% rename from WebFiori/Database/TableFactory.php rename to WebFiori/Database/Factory/TableFactory.php index cefd010c..d2027ed3 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; /** * 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 98% rename from WebFiori/Database/InsertBuilder.php rename to WebFiori/Database/Query/InsertBuilder.php index 614ae28c..6fb4db66 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. 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..2dd8c642 --- /dev/null +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -0,0 +1,150 @@ +db = $db; + } + + public function count(): int { + $result = $this->db->table($this->getTableName()) + ->selectCount(null, 'total') + ->execute(); + + return (int) $result->fetch()['total']; + } + + public function deleteAll(): void { + $this->db->table($this->getTableName()) + ->delete() + ->execute(); + } + + public function deleteById(mixed $id): void { + $this->db->table($this->getTableName()) + ->delete() + ->where($this->getIdField(), $id) + ->execute(); + } + + /** @return T[] */ + public function findAll(): array { + $result = $this->db->table($this->getTableName()) + ->select() + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + /** @return T|null */ + public function findById(mixed $id): ?object { + $result = $this->db->table($this->getTableName()) + ->select() + ->where($this->getIdField(), $id) + ->execute(); + + return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; + } + + /** @return Page */ + 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); + } + + /** @return CursorPage */ + 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); + } + + /** @param T $entity */ + public function save(object $entity): void { + $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(); + } + } + + protected function createQuery(): \WebFiori\Database\AbstractQuery { + return $this->db->table($this->getTableName())->select(); + } + + protected function getDatabase(): Database { + return $this->db; + } + abstract protected function getIdField(): string; + + abstract protected function getTableName(): string; + abstract protected function toArray(object $entity): array; + 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/ResultSet.php b/WebFiori/Database/ResultSet.php index 97a13496..6268f5ce 100644 --- a/WebFiori/Database/ResultSet.php +++ b/WebFiori/Database/ResultSet.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 @@ -83,6 +83,16 @@ public function count() : int { public function current() { return $this->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..6f0abf07 --- /dev/null +++ b/WebFiori/Database/Schema/DatabaseChangeGenerator.php @@ -0,0 +1,254 @@ +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 \RuntimeException('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 @@ +deleteAll(); + } + + /** + * 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/SchemaMigrationsTable.php b/WebFiori/Database/Schema/SchemaMigrationsTable.php new file mode 100644 index 00000000..b93fe3bc --- /dev/null +++ b/WebFiori/Database/Schema/SchemaMigrationsTable.php @@ -0,0 +1,63 @@ +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 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); + } + } + + /** + * 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..a27355df 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,11 @@ */ namespace WebFiori\Database; +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 +366,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 +402,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/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/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/UserTable.php b/examples/03-table-blueprints/UserTable.php index 57ff9cea..deb748e5 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..dda84c06 100644 --- a/examples/03-table-blueprints/example.php +++ b/examples/03-table-blueprints/example.php @@ -1,183 +1,183 @@ -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 +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"; + ")->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"; diff --git a/examples/04-entity-mapping/example.php b/examples/04-entity-mapping/example.php index 86f98968..e2190abc 100644 --- a/examples/04-entity-mapping/example.php +++ b/examples/04-entity-mapping/example.php @@ -1,167 +1,167 @@ -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::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"; 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..5d723f51 100644 --- a/examples/06-migrations/AddEmailIndexMigration.php +++ b/examples/06-migrations/AddEmailIndexMigration.php @@ -1,52 +1,51 @@ -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(); - } -} +setQuery("ALTER TABLE users DROP INDEX idx_users_email")->execute(); + } + + /** + * Get the list of migration dependencies. + * + * This migration requires the users table to exist before + * it can add an index to the email column. + * + * @return array Array of migration names this migration depends on. + */ + public function getDependencies(): array { + return ['CreateUsersTableMigration']; + } + + /** + * Apply the migration changes to the database. + * + * Adds a unique index on the email column to enforce + * email uniqueness and improve query performance. + * + * @param Database $db The database instance to execute changes on. + */ + public function up(Database $db): void { + // Add unique index on email column + $db->setQuery("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..d04800b2 100644 --- a/examples/06-migrations/CreateUsersTableMigration.php +++ b/examples/06-migrations/CreateUsersTableMigration.php @@ -1,71 +1,70 @@ -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(); - } -} +setQuery("DROP TABLE IF EXISTS users")->execute(); + } + + /** + * Apply the migration changes to the database. + * + * Creates the users table with columns for user authentication + * and basic profile information. + * + * @param Database $db The database instance to execute changes on. + */ + public function up(Database $db): void { + // Create users table + $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->createTables(); + $db->execute(); + } +} diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index 953603a8..d316f31b 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -1,161 +1,168 @@ -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"; +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"; diff --git a/examples/07-seeders/CategoriesSeeder.php b/examples/07-seeders/CategoriesSeeder.php index e21d1aed..bfda6455 100644 --- a/examples/07-seeders/CategoriesSeeder.php +++ b/examples/07-seeders/CategoriesSeeder.php @@ -1,64 +1,64 @@ - '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 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(); + } + } +} diff --git a/examples/07-seeders/UsersSeeder.php b/examples/07-seeders/UsersSeeder.php index e9bfeccc..332a00d4 100644 --- a/examples/07-seeders/UsersSeeder.php +++ b/examples/07-seeders/UsersSeeder.php @@ -1,55 +1,55 @@ - '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..de32545b 100644 --- a/examples/07-seeders/example.php +++ b/examples/07-seeders/example.php @@ -1,207 +1,211 @@ -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"; +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"; 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/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/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/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..38d25762 --- /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(\RuntimeException::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()); + } + } +}