diff --git a/.github/workflows/prado.yml b/.github/workflows/prado.yml index 20050d700..94a9013ff 100644 --- a/.github/workflows/prado.yml +++ b/.github/workflows/prado.yml @@ -35,7 +35,7 @@ jobs: uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php with: php-version: ${{ matrix.php-versions }} - extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib + extensions: ctype, dom, intl, json, mbstring, memcached, pdo_mysql, pdo_pgsql, pdo_sqlite, openssl, pcre, spl, zlib, :pdo_firebird, :pdo_sqlsrv tools: php-cs-fixer, phpstan, cs2pr - name: Validate composer.json and composer.lock @@ -219,11 +219,14 @@ jobs: & "$env:PGBIN\pg_ctl.exe" start -D $env:PGDATA -w & "$env:PGBIN\createdb.exe" -h 127.0.0.1 -U postgres prado_unitest & "$env:PGBIN\psql.exe" -h 127.0.0.1 -U postgres prado_unitest -f .\tests\initdb_pgsql.sql + # Windows PostgreSQL uses scram-sha-256 (no trust); set the password so + # setupPgsqlConnection('prado_unitest', 'prado_unitest') can authenticate. + & "$env:PGBIN\psql.exe" -h 127.0.0.1 -U postgres -c "ALTER ROLE prado_unitest WITH PASSWORD 'prado_unitest';" - name: Run Unit Tests run: composer unittest - db-mssql: + db-sqlsrv: name: Microsoft SQL Server runs-on: ubuntu-latest services: @@ -274,10 +277,10 @@ jobs: run: >- /opt/mssql-tools18/bin/sqlcmd -C -U sa -P Prado_Unitest1 - -i ./tests/initdb_mssql.sql + -i ./tests/initdb_sqlsrv.sql - name: Run SQL Server tests - run: php vendor/bin/phpunit tests/unit/Data/DbSpecific/Mssql/ + run: php vendor/bin/phpunit tests/unit/Data/DbSpecific/SqlSrv/ db-firebird: name: Firebird diff --git a/framework/Caching/TDbCache.php b/framework/Caching/TDbCache.php index 6cab31c95..504533ffa 100644 --- a/framework/Caching/TDbCache.php +++ b/framework/Caching/TDbCache.php @@ -13,6 +13,7 @@ use Prado\Prado; use Prado\Data\TDataSourceConfig; use Prado\Data\TDbConnection; +use Prado\Data\TDbDriver; use Prado\Data\TDbPropertiesTrait; use Prado\Exceptions\TConfigurationException; use Prado\TPropertyValue; @@ -129,8 +130,11 @@ class TDbCache extends TCache implements \Prado\Util\IDbModule */ public function init($config) { - $this->getApplication()->attachEventHandler('OnLoadStateComplete', [$this, 'doInitializeCache']); - $this->getApplication()->attachEventHandler('OnSaveState', [$this, 'doFlushCacheExpired']); + $app = $this->getApplication(); + if ($app) { + $app->attachEventHandler('OnLoadStateComplete', [$this, 'doInitializeCache']); + $app->attachEventHandler('OnSaveState', [$this, 'doFlushCacheExpired']); + } parent::init($config); } @@ -194,9 +198,9 @@ protected function initializeCache($force = false) Prado::trace('Autocreate: ' . $this->_cacheTable, TDbCache::class); $driver = $db->getDriverName(); - if ($driver === 'mysql') { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI])) { $blob = 'LONGBLOB'; - } elseif ($driver === 'pgsql') { + } elseif ($driver === TDbDriver::DRIVER_PGSQL) { $blob = 'BYTEA'; } else { $blob = 'BLOB'; @@ -456,11 +460,11 @@ protected function setValue($key, $value, $expire) } $db = $this->getDbConnection(); $driver = $db->getDriverName(); - if (in_array($driver, ['mysql', 'mysqli', 'sqlite', 'ibm', 'oci', 'sqlsrv', 'mssql', 'dblib', 'pgsql'])) { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI, TDbDriver::DRIVER_PGSQL, TDbDriver::DRIVER_SQLITE, TDbDriver::DRIVER_SQLSRV, TDbDriver::DRIVER_DBLIB, TDbDriver::DRIVER_OCI, TDbDriver::DRIVER_IBM])) { $expire = ($expire <= 0) ? 0 : time() + $expire; - if (in_array($driver, ['mysql', 'mysqli', 'sqlite'])) { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI, TDbDriver::DRIVER_SQLITE])) { $sql = "REPLACE INTO {$this->_cacheTable} (itemkey,value,expire) VALUES (:key,:value,$expire)"; - } elseif ($driver === 'pgsql') { + } elseif ($driver === TDbDriver::DRIVER_PGSQL) { $sql = "INSERT INTO {$this->_cacheTable} (itemkey, value, expire) VALUES (:key, :value, :expire) " . "ON CONFLICT (itemkey) DO UPDATE SET value = EXCLUDED.value, expire = EXCLUDED.expire"; } else { diff --git a/framework/Data/ActiveRecord/Exceptions/TActiveRecordConfigurationException.php b/framework/Data/ActiveRecord/Exceptions/TActiveRecordConfigurationException.php index 44d4c6077..b4ff75a8d 100644 --- a/framework/Data/ActiveRecord/Exceptions/TActiveRecordConfigurationException.php +++ b/framework/Data/ActiveRecord/Exceptions/TActiveRecordConfigurationException.php @@ -11,7 +11,7 @@ namespace Prado\Data\ActiveRecord\Exceptions; /** - * TActiveRecordConfigurationException class. + * TActiveRecordConfigurationException class * * @author Wei Zhuo * @since 3.1 diff --git a/framework/Data/ActiveRecord/Exceptions/TActiveRecordException.php b/framework/Data/ActiveRecord/Exceptions/TActiveRecordException.php index febad280d..446b562e5 100644 --- a/framework/Data/ActiveRecord/Exceptions/TActiveRecordException.php +++ b/framework/Data/ActiveRecord/Exceptions/TActiveRecordException.php @@ -14,6 +14,8 @@ use Prado\Prado; /** + * TActiveRecordException class + * * Base exception class for Active Records. * * @author Wei Zhuo diff --git a/framework/Data/ActiveRecord/Exceptions/messages.txt b/framework/Data/ActiveRecord/Exceptions/messages.txt index b79786d83..13d22b5db 100644 --- a/framework/Data/ActiveRecord/Exceptions/messages.txt +++ b/framework/Data/ActiveRecord/Exceptions/messages.txt @@ -24,3 +24,6 @@ ar_relations_undefined = Unable to determine Active Record relationships be ar_undefined_relation_prop = Unable to find {1}::${2}['{0}'], Active Record relationship definition for property "{0}" not found in entries of {1}::${2}. ar_invalid_relationship = Invalid active record relationship. ar_relations_missing_fk = Unable to find foreign key relationships in table '{0}' that corresponds to table '{1}'. +ar_belongs_to_multiple_result = BelongsTo/HasOne relationship returned more than one result but exactly one was expected. +scaffold_unable_to_find_edit_view = Unable to find scaffold edit view control with ID '{0}'. +scaffold_unable_to_find_list_view = Unable to find scaffold list view control with ID '{0}'. diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordBelongsTo.php b/framework/Data/ActiveRecord/Relations/TActiveRecordBelongsTo.php index 6031ea285..a953b0dce 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordBelongsTo.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordBelongsTo.php @@ -16,6 +16,8 @@ use Prado\Data\ActiveRecord\Exceptions\TActiveRecordException; /** + * TActiveRecordBelongsTo class + * * Implements the foreign key relationship (TActiveRecord::BELONGS_TO) between * the source objects and the related foreign object. Consider the * entity relationship between a Team and a Player. diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordHasMany.php b/framework/Data/ActiveRecord/Relations/TActiveRecordHasMany.php index fb061f452..edcc9cf3f 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordHasMany.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordHasMany.php @@ -11,6 +11,8 @@ namespace Prado\Data\ActiveRecord\Relations; /** + * TActiveRecordHasMany class + * * Implements TActiveRecord::HAS_MANY relationship between the source object having zero or * more foreign objects. Consider the entity relationship between a Team and a Player. * ```php diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordHasManyAssociation.php b/framework/Data/ActiveRecord/Relations/TActiveRecordHasManyAssociation.php index f35be7da7..f7b0edcab 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordHasManyAssociation.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordHasManyAssociation.php @@ -17,6 +17,8 @@ use Prado\Prado; /** + * TActiveRecordHasManyAssociation class + * * Implements the M-N (many to many) relationship via association table. * Consider the entity relationship between Articles and Categories * via the association table Article_Category. diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordHasOne.php b/framework/Data/ActiveRecord/Relations/TActiveRecordHasOne.php index 31cc9bf42..36f937b99 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordHasOne.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordHasOne.php @@ -17,6 +17,8 @@ use Prado\Prado; /** + * TActiveRecordHasOne class + * * TActiveRecordHasOne models the object relationship that a record (the source object) * property is an instance of foreign record object having a foreign key * related to the source object. The HAS_ONE relation is very similar to the diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordRelation.php b/framework/Data/ActiveRecord/Relations/TActiveRecordRelation.php index 3951c4f73..569f0af93 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordRelation.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordRelation.php @@ -18,7 +18,67 @@ use Prado\Prado; /** - * Base class for active record relationships. + * TActiveRecordRelation class + * + * Abstract base class for all Active Record relationship handlers. + * + * Active Record relationships are declared via the static `$RELATIONS` array on + * each {@see TActiveRecord} subclass. Each entry maps a property name to an + * array whose first element is one of the four relationship constants and whose + * second element is the foreign record class name: + * + * ```php + * public static $RELATIONS = [ + * 'profile' => [self::HAS_ONE, 'ProfileRecord'], + * 'orders' => [self::HAS_MANY, 'OrderRecord'], + * 'team' => [self::BELONGS_TO, 'TeamRecord'], + * 'categories' => [self::HAS_MANY_ASSOC, 'CategoryRecord', 'Article_Category'], + * ]; + * ``` + * + * The four relationship types are: + * + * - **HAS_ONE** ({@see TActiveRecordHasOne}) — the foreign table carries a foreign key + * that points back to this record's primary key. The related property is a single + * object (or `null`). + * - **HAS_MANY** ({@see TActiveRecordHasMany}) — same foreign-key direction as HAS_ONE, + * but the related property is a collection (array) of foreign objects. + * - **BELONGS_TO** ({@see TActiveRecordBelongsTo}) — this record's table carries the + * foreign key that points to the related record's primary key. The related + * property is a single object (or `null`). + * - **HAS_MANY_ASSOC** ({@see TActiveRecordHasManyAssociation}) — a many-to-many + * relationship resolved through an intermediate association table. A third + * element in the `$RELATIONS` entry names the association table. + * + * ## Fetching related objects + * + * Related objects are fetched lazily by chaining a relationship call onto a + * finder method call: + * + * ```php + * // Fetch all teams with their players eagerly loaded. + * $teams = TeamRecord::finder()->withPlayers()->findAll(); + * + * // Chain multiple relationships. + * $articles = ArticleRecord::finder()->withCategories()->withAuthor()->findAll(); + * ``` + * + * The {@see __call()} method intercepts `with()` calls, delegates the + * underlying finder call to the source record, then calls + * {@see collectForeignObjects()} to populate each result's relationship property. + * Multiple chained `with*()` calls are queued via a static stack so that all + * relationships are applied to the same result set. + * + * ## Implementing a new relationship type + * + * Subclasses must implement: + * - {@see collectForeignObjects()} — given the source results, fetch and assign + * the corresponding foreign objects to each source record's relationship + * property. + * - {@see getRelationForeignKeys()} — return the foreign key mapping (FK field + * names as keys, source property names as values) used by this relationship. + * - {@see updateAssociatedRecords()} — persist any changes to the associated + * foreign objects (e.g. insert/delete rows in an association table). * * @author Wei Zhuo * @since 3.1 diff --git a/framework/Data/ActiveRecord/Relations/TActiveRecordRelationContext.php b/framework/Data/ActiveRecord/Relations/TActiveRecordRelationContext.php index 27bd51c6b..4dfaaec0f 100644 --- a/framework/Data/ActiveRecord/Relations/TActiveRecordRelationContext.php +++ b/framework/Data/ActiveRecord/Relations/TActiveRecordRelationContext.php @@ -17,6 +17,8 @@ use Prado\Prado; /** + * TActiveRecordRelationContext class + * * TActiveRecordRelationContext holds information regarding record relationships * such as record relation property name, query criteria and foreign object record * class names. diff --git a/framework/Data/ActiveRecord/Scaffold/InputBuilder/IScaffoldInput.php b/framework/Data/ActiveRecord/Scaffold/InputBuilder/IScaffoldInput.php new file mode 100644 index 000000000..6e76d3884 --- /dev/null +++ b/framework/Data/ActiveRecord/Scaffold/InputBuilder/IScaffoldInput.php @@ -0,0 +1,75 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\ActiveRecord\Scaffold\InputBuilder; + +/** + * IScaffoldInput interface. + * + * IScaffoldInput defines the public contract for database-specific scaffold + * input builders. Implementations map database column types to appropriate + * Prado web controls (TTextBox, TCheckBox, TDropDownList, TDatePicker, etc.) + * and read the submitted value back into the active record. + * + * The built-in driver-specific classes (`TMysqlScaffoldInput`, + * `TSqliteScaffoldInput`, etc.) all implement this interface by inheriting + * from {@see TScaffoldInputBase}. + * + * Custom implementations for unsupported drivers may be registered by + * handling the **`fxActiveRecordScaffoldInputClass`** global event raised by + * {@see \Prado\Data\TDbDriverCapabilities::createScaffoldInput}. The sender + * is the connection and the parameter is the driver name string. Handlers + * must return the **fully-qualified class name** of a class that implements + * this interface; the first returned value is used. + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IScaffoldInput +{ + /** + * Default ID assigned to the primary input control within each scaffold item. + * + * Implementations must honour this constant so that + * {@see TScaffoldInputBase::createScaffoldInput} can locate the control + * when generating the associated label. + */ + public const DEFAULT_ID = 'scaffold_input'; + + /** + * Creates the appropriate input control(s) for a column and attaches them + * to the scaffold item container. + * + * Implementations should call `createControl` to build the control and + * `createControlLabel` (via the base class) when the primary control with + * {@see DEFAULT_ID} is found inside the item. + * + * @param mixed $parent the parent scaffold configuration. + * @param mixed $item the scaffold input item container. + * @param \Prado\Data\Common\TDbTableColumn $column the column metadata. + * @param \Prado\Data\ActiveRecord\TActiveRecord $record the active record instance. + */ + public function createScaffoldInput($parent, $item, $column, $record); + + /** + * Reads the submitted input control value and stores it back into the + * active record column. + * + * Called during post-back to transfer user input into the record before + * save. Implementations should skip read-only columns (primary keys with + * sequences) via `getIsEnabled`. + * + * @param mixed $parent the parent scaffold configuration. + * @param mixed $item the scaffold input item container. + * @param \Prado\Data\Common\TDbTableColumn $column the column metadata. + * @param \Prado\Data\ActiveRecord\TActiveRecord $record the active record instance. + */ + public function loadScaffoldInput($parent, $item, $column, $record); +} diff --git a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TMssqlScaffoldInput.php b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TMssqlScaffoldInput.php index 0eac87c37..c26db5f5e 100644 --- a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TMssqlScaffoldInput.php +++ b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TMssqlScaffoldInput.php @@ -14,46 +14,11 @@ /** * TMssqlScaffoldInput class. * + * * @link https://github.com/pradosoft/prado + * @todo v4.4 remove, replaced by TSqlSrvScaffoldInput + * @deprecated */ -class TMssqlScaffoldInput extends TScaffoldInputCommon +class TMssqlScaffoldInput extends TSqlSrvScaffoldInput { - protected function createControl($container, $column, $record) - { - switch (strtolower($column->getDbType())) { - case 'bit': - return $this->createBooleanControl($container, $column, $record); - case 'text': - return $this->createMultiLineControl($container, $column, $record); - case 'smallint': case 'int': case 'bigint': case 'tinyint': - return $this->createIntegerControl($container, $column, $record); - case 'decimal': case 'float': case 'money': case 'numeric': case 'real': case 'smallmoney': - return $this->createFloatControl($container, $column, $record); - case 'datetime': case 'smalldatetime': - return $this->createDateTimeControl($container, $column, $record); - default: - $control = $this->createDefaultControl($container, $column, $record); - if ($column->getIsExcluded()) { - $control->setEnabled(false); - } - return $control; - } - } - - protected function getControlValue($container, $column, $record) - { - switch (strtolower($column->getDbType())) { - case 'boolean': - return $container->findControl(self::DEFAULT_ID)->getChecked(); - case 'datetime': case 'smalldatetime': - return $this->getDateTimeValue($container, $column, $record); - default: - $value = $this->getDefaultControlValue($container, $column, $record); - if (trim($value) === '' && $column->getAllowNull()) { - return null; - } else { - return $value; - } - } - } } diff --git a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TOracleScaffoldInput.php b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TOracleScaffoldInput.php new file mode 100644 index 000000000..2020c3fe2 --- /dev/null +++ b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TOracleScaffoldInput.php @@ -0,0 +1,128 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\ActiveRecord\Scaffold\InputBuilder; + +/** + * TOracleScaffoldInput class. + * + * Maps Oracle column types (as reported by ALL_TAB_COLUMNS.DATA_TYPE) to + * appropriate Prado scaffold input controls. + * + * Oracle type notes: + * - NUMBER appears with a precision/scale suffix from the metadata query, + * e.g. 'NUMBER(10,2)' or 'NUMBER(38,0)'. The prefix match handles all + * variants; integer-scale (,0) columns are mapped to integer controls and + * all others to float. + * - DATE in Oracle stores both date and time components (year, month, day, + * hour, minute, second); it is mapped to a datetime control. + * - TIMESTAMP variants (with/without time zone, with local time zone) all + * carry a datetime value and are mapped to datetime controls. + * - INTERVAL types have no generic scalar input and fall through to the + * default text control. + * - CLOB / NCLOB / LONG are mapped to multiline text controls. + * - BLOB / RAW / LONG RAW / BFILE are binary types; the default text control + * is used as a placeholder (binary data is not editable in a scaffold). + * - XMLTYPE falls through to the default text control. + * - ROWID / UROWID fall through to the default text control. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TOracleScaffoldInput extends TScaffoldInputCommon +{ + protected function createControl($container, $column, $record) + { + $type = strtoupper($column->getDbType()); + + // NUMBER(p,0) or NUMBER(p) — integer-scale, treat as integer. + // NUMBER(p,s) with s > 0, or bare NUMBER — treat as float. + if (str_starts_with($type, 'NUMBER')) { + if (preg_match('/NUMBER\s*\(\s*\d+\s*,\s*0\s*\)/', $type) + || preg_match('/NUMBER\s*\(\s*\d+\s*\)/', $type)) { + return $this->createIntegerControl($container, $column, $record); + } + return $this->createFloatControl($container, $column, $record); + } + + switch ($type) { + // ---- integer types -------------------------------------------------- + case 'INTEGER': // alias for NUMBER(38,0) + case 'INT': + case 'SMALLINT': + return $this->createIntegerControl($container, $column, $record); + + // ---- float / decimal types ------------------------------------------ + case 'FLOAT': // FLOAT(p) — binary-precision float + case 'BINARY_FLOAT': // 32-bit IEEE 754 + case 'BINARY_DOUBLE': // 64-bit IEEE 754 + case 'REAL': // alias for FLOAT(63) + case 'DECIMAL': + case 'NUMERIC': + return $this->createFloatControl($container, $column, $record); + + // ---- date / time types ---------------------------------------------- + // Oracle DATE holds year-month-day + hour-minute-second. + case 'DATE': + return $this->createDateTimeControl($container, $column, $record); + + // TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE + // — prefix match covers all three variants plus optional (p) suffix. + default: + if (str_starts_with($type, 'TIMESTAMP')) { + return $this->createDateTimeControl($container, $column, $record); + } + // INTERVAL YEAR TO MONTH, INTERVAL DAY TO SECOND — fall through. + // FLOAT(p) with explicit precision also reaches here via the switch + // default; the prefix match below catches it. + if (str_starts_with($type, 'FLOAT')) { + return $this->createFloatControl($container, $column, $record); + } + break; + } + + switch ($type) { + // ---- character / large-object types --------------------------------- + case 'CHAR': + case 'NCHAR': + case 'VARCHAR2': + case 'NVARCHAR2': + case 'VARCHAR': // synonym for VARCHAR2 + return $this->createDefaultControl($container, $column, $record); + + case 'CLOB': + case 'NCLOB': + case 'LONG': + return $this->createMultiLineControl($container, $column, $record); + + // ---- binary / opaque types — not editable in a scaffold ------------- + case 'BLOB': + case 'RAW': + case 'LONG RAW': + case 'BFILE': + case 'XMLTYPE': + case 'ROWID': + case 'UROWID': + default: + return $this->createDefaultControl($container, $column, $record); + } + } + + protected function getControlValue($container, $column, $record) + { + $type = strtoupper($column->getDbType()); + + if (str_starts_with($type, 'TIMESTAMP') || $type === 'DATE') { + return $this->getDateTimeValue($container, $column, $record); + } + + return $this->getDefaultControlValue($container, $column, $record); + } +} diff --git a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TScaffoldInputBase.php b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TScaffoldInputBase.php index f1772e621..16134ebf0 100644 --- a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TScaffoldInputBase.php +++ b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TScaffoldInputBase.php @@ -10,6 +10,8 @@ namespace Prado\Data\ActiveRecord\Scaffold\InputBuilder; use Prado\Data\Common\TDbTableColumn; +use Prado\Data\TDbConnection; +use Prado\Data\TDbDriverCapabilities; use Prado\Exceptions\TConfigurationException; /** @@ -17,17 +19,18 @@ * * TScaffoldInputBase is the base class for creating scaffold input builders * that generate appropriate input controls for active record columns based on - * the database driver. + * the database driver. It implements {@see IScaffoldInput}, the common + * interface that all scaffold input builders must satisfy. * - * This class provides the foundation for database-specific input builder implementations - * (e.g., TMysqlScaffoldInput, TSqliteScaffoldInput, etc.) that map database - * column types to appropriate Prado web controls like TTextBox, TCheckBox, TDropDownList, - * TDatePicker, etc. + * This class provides the foundation for database-specific input builder + * implementations (e.g., TMysqlScaffoldInput, TSqliteScaffoldInput) that map + * database column types to appropriate Prado web controls like TTextBox, + * TCheckBox, TDropDownList, TDatePicker, etc. * - * The input builders are created via the static {@see createInputBuilder} method which - * determines the appropriate builder based on the database driver. When no built-in driver - * matches, the {@see fxActiveRecordCreateScaffoldInput()} global event is raised to allow - * for extensibility through custom implementations. + * The input builders are created via the static {@see createInputBuilder} + * method which delegates all driver resolution — including the + * `fxActiveRecordScaffoldInputClass` global event for unknown drivers — to + * {@see TDbDriverCapabilities::createScaffoldInput}. * * Example usage: * ```php @@ -35,9 +38,9 @@ * $builder->createScaffoldInput($parent, $item, $column, $record); * ``` */ -class TScaffoldInputBase +class TScaffoldInputBase implements IScaffoldInput { - public const DEFAULT_ID = 'scaffold_input'; + public const DEFAULT_ID = IScaffoldInput::DEFAULT_ID; private $_parent; /** @@ -51,56 +54,36 @@ protected function getParent() } /** - * Creates a database-specific scaffold input builder based on the active record's database driver. + * Creates a database-specific scaffold input builder based on the active + * record's database driver. * - * This method determines the appropriate input builder for the given database driver. - * If no built-in driver is found, the {@see fxActiveRecordCreateScaffoldInput()} global event - * is raised to allow custom implementations to provide a builder. + * For built-in drivers the appropriate builder is loaded and returned + * directly. For unknown drivers, + * {@see TDbDriverCapabilities::createScaffoldInput} raises the + * **`fxActiveRecordScaffoldInputClass`** global event on the connection + * with the driver name as the parameter. Event handlers must return the + * fully-qualified **class name** of a class that implements + * {@see IScaffoldInput}; the class is then instantiated here and validated. + * + * All driver resolution and event raising is encapsulated in + * {@see TDbDriverCapabilities::createScaffoldInput}; this method does not + * call `raiseEvent` directly. * * @param \Prado\Data\ActiveRecord\TActiveRecord $record the active record instance. - * @throws TConfigurationException if no builder can be created for the driver. - * @return self the appropriate input builder for the database driver. + * @throws TConfigurationException if no builder can be created for the + * driver, or if the returned instance does not implement {@see IScaffoldInput}. + * @return IScaffoldInput the appropriate input builder for the database driver. */ public static function createInputBuilder($record) { $connection = $record->getDbConnection(); $connection->setActive(true); //must be connected before retrieving driver name! - $driver = $connection->getDriverName(); - switch (strtolower($driver)) { - case 'sqlite': //sqlite 3 - case 'sqlite2': //sqlite 2 - require_once(__DIR__ . '/TSqliteScaffoldInput.php'); - return new TSqliteScaffoldInput(); - case 'mysqli': - case 'mysql': - require_once(__DIR__ . '/TMysqlScaffoldInput.php'); - return new TMysqlScaffoldInput(); - case 'pgsql': - require_once(__DIR__ . '/TPgsqlScaffoldInput.php'); - return new TPgsqlScaffoldInput(); - case 'mssql': - require_once(__DIR__ . '/TMssqlScaffoldInput.php'); - return new TMssqlScaffoldInput(); - case 'ibm': - require_once(__DIR__ . '/TIbmScaffoldInput.php'); - return new TIbmScaffoldInput(); - case 'firebird': - case 'interbase': - require_once(__DIR__ . '/TFirebirdScaffoldInput.php'); - return new TFirebirdScaffoldInput(); - default: - $instances = $record->getDbConnection()->raiseEvent('fxActiveRecordCreateScaffoldInput', self::class, $record->getDbConnection()); - if (empty($instances)) { - // @todo v4.4 TActiveRecordConfigurationException, move message - throw new TConfigurationException('ar_invalid_database_driver', $driver); - } - $scaffoldInput = $instances[0]; - if ($scaffoldInput instanceof static) { - // @todo v4.4 TActiveRecordConfigurationException, move message - throw new TConfigurationException('ar_not_input_base', $scaffoldInput::class, static::class); - } - return $scaffoldInput; + $scaffoldInput = TDbDriverCapabilities::createScaffoldInput($connection); + if (!($scaffoldInput instanceof IScaffoldInput)) { + // @todo v4.4 TActiveRecordConfigurationException, move message + throw new TConfigurationException('ar_not_input_base', $scaffoldInput::class, IScaffoldInput::class); } + return $scaffoldInput; } /** diff --git a/framework/Data/ActiveRecord/Scaffold/InputBuilder/TSqlSrvScaffoldInput.php b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TSqlSrvScaffoldInput.php new file mode 100644 index 000000000..41a4620df --- /dev/null +++ b/framework/Data/ActiveRecord/Scaffold/InputBuilder/TSqlSrvScaffoldInput.php @@ -0,0 +1,59 @@ +getDbType())) { + case 'bit': + return $this->createBooleanControl($container, $column, $record); + case 'text': + return $this->createMultiLineControl($container, $column, $record); + case 'smallint': case 'int': case 'bigint': case 'tinyint': + return $this->createIntegerControl($container, $column, $record); + case 'decimal': case 'float': case 'money': case 'numeric': case 'real': case 'smallmoney': + return $this->createFloatControl($container, $column, $record); + case 'datetime': case 'smalldatetime': + return $this->createDateTimeControl($container, $column, $record); + default: + $control = $this->createDefaultControl($container, $column, $record); + if ($column->getIsExcluded()) { + $control->setEnabled(false); + } + return $control; + } + } + + protected function getControlValue($container, $column, $record) + { + switch (strtolower($column->getDbType())) { + case 'boolean': + return $container->findControl(self::DEFAULT_ID)->getChecked(); + case 'datetime': case 'smalldatetime': + return $this->getDateTimeValue($container, $column, $record); + default: + $value = $this->getDefaultControlValue($container, $column, $record); + if (trim($value) === '' && $column->getAllowNull()) { + return null; + } else { + return $value; + } + } + } +} diff --git a/framework/Data/ActiveRecord/TActiveRecord.php b/framework/Data/ActiveRecord/TActiveRecord.php index f87c295a5..b602eae58 100644 --- a/framework/Data/ActiveRecord/TActiveRecord.php +++ b/framework/Data/ActiveRecord/TActiveRecord.php @@ -20,6 +20,8 @@ use ReflectionClass; /** + * TActiveRecord class + * * Base class for active records. * * An active record creates an object that wraps a row in a database table @@ -143,6 +145,35 @@ * } * ``` * + * Since v4.3.3, TActiveRecord supports {@see insertOrIgnore()} and {@see upsert()} + * methods for handling duplicate key conflicts: + * ```php + * class UserRecord extends TActiveRecord + * { + * const TABLE = 'users'; + * public $user_id; + * public $username; + * public $email; + * } + * + * // Insert or ignore - silently ignores duplicate key conflicts + * $user = new UserRecord(); + * $user->user_id = 1; + * $user->username = 'admin'; + * $user->email = 'admin@example.com'; + * $result = $user->insertOrIgnore(); // returns last insert id, true, or false if ignored + * + * // Upsert - insert or update on conflict (default: primary key) + * $user = new UserRecord(); + * $user->user_id = 1; + * $user->username = 'admin'; + * $user->email = 'newemail@example.com'; + * $result = $user->upsert(); // updates email where user_id = 1 + * + * // Upsert with custom conflict columns + * $result = $user->upsert(['email' => 'updated@example.com'], ['username']); + * ``` + * * @author Wei Zhuo * @since 3.1 */ @@ -190,7 +221,7 @@ abstract class TActiveRecord extends \Prado\TComponent protected $_relationsObjs = []; /** - * @var TDbConnection database connection object. + * @var \Prado\Data\IDataConnection database connection object. */ protected $_connection; // use protected so that serialization is fine @@ -203,12 +234,10 @@ abstract class TActiveRecord extends \Prado\TComponent */ protected $_invalidFinderResult; // use protected so that serialization is fine - /** - * Prevent __call() method creating __sleep() when serializing. - */ - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - return array_diff(parent::__sleep(), ["\0*\0_connection"]); + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0*\0_connection"; } /** @@ -226,7 +255,7 @@ public function __wakeup() * can be saved to the database specified by the $connection object. * * @param array $data optional name value pair record data. - * @param null|TDbConnection $connection optional database connection this object record use. + * @param null|\Prado\Data\IDataConnection $connection optional database connection this object record use. */ public function __construct($data = [], $connection = null) { @@ -260,7 +289,7 @@ public function __get($name) /** * Magic method for writing properties. - * This method is overriden to provide write access to the foreign objects via + * This method is overridden to provide write access to the foreign objects via * the key names declared in the RELATIONS array. * @param string $name property name * @param mixed $value property value. @@ -304,9 +333,11 @@ private function setupRelations() } /** - * Copies data from an array or another object. - * @param mixed $data - * @throws TActiveRecordException if data is not array or not object. + * Copies data from an array or another object into the record. + * If $data is an object, its public properties are extracted. + * Each key-value pair is set using {@see setColumnValue()}. + * @param mixed $data associative array or object with public properties. + * @throws TActiveRecordException if data is not array or object. */ public function copyFrom($data) { @@ -321,7 +352,11 @@ public function copyFrom($data) } } - + /* + * Gets the database connection active for all ActiveRecord classes. + * This static method returns the default connection from TActiveRecordManager. + * @return \Prado\Data\IDataConnection current db connection. + */ public static function getActiveDbConnection() { if (($db = self::getRecordManager()->getDbConnection()) !== null) { @@ -333,7 +368,7 @@ public static function getActiveDbConnection() /** * Gets the current Db connection, the connection object is obtained from * the TActiveRecordManager if connection is currently null. - * @return \Prado\Data\TDbConnection current db connection for this object. + * @return \Prado\Data\IDataConnection current db connection for this object. */ public function getDbConnection() { @@ -344,7 +379,7 @@ public function getDbConnection() } /** - * @param \Prado\Data\TDbConnection $connection db connection object for this record. + * @param \Prado\Data\IDataConnection $connection db connection object for this record. */ public function setDbConnection($connection) { @@ -474,6 +509,51 @@ public function delete() return false; } + /** + * Inserts the current record, silently ignoring if a duplicate key conflict occurs. + * Fires the OnInsert event only when the row is actually inserted. + * @return mixed last insert ID, true on ignore, or false on failure. + * @since 4.3.3 + */ + public function insertOrIgnore(): mixed + { + $gateway = $this->getRecordGateway(); + $param = new TActiveRecordChangeEventParameter(); + $this->onInsert($param); + if ($param->getIsValid()) { + $result = $gateway->insertOrIgnore($this); + if ($result !== false) { + $this->_recordState = self::STATE_LOADED; + return $result; + } + } + return false; + } + + /** + * Inserts or updates the current record. + * On conflict with $conflictColumns (defaults to primary key), updates $updateData columns + * (defaults to all non-PK columns). Fires the OnInsert event. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns. + * @param null|array $conflictColumns conflict target columns; null = primary key. + * @return mixed last insert ID, true on update, or false on failure. + * @since 4.3.3 + */ + public function upsert(?array $updateData = null, ?array $conflictColumns = null): mixed + { + $gateway = $this->getRecordGateway(); + $param = new TActiveRecordChangeEventParameter(); + $this->onInsert($param); + if ($param->getIsValid()) { + $result = $gateway->upsert($this, $updateData, $conflictColumns); + if ($result !== false) { + $this->_recordState = self::STATE_LOADED; + return $result; + } + } + return false; + } + /** * Delete records by primary key. Usage: * @@ -587,7 +667,7 @@ public static function createRecord($type, $data) * ``` * * @param string|TActiveRecordCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return TActiveRecord matching record object. Null if no result is found. */ public function find($criteria, $parameters = []) @@ -603,7 +683,7 @@ public function find($criteria, $parameters = []) * Same as find() but returns an array of objects. * * @param string|TActiveRecordCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return array matching record objects. Empty array if no result is found. */ public function findAll($criteria = null, $parameters = []) @@ -720,7 +800,7 @@ public function findAllByIndex($criteria, $fields, $values) /** * Find the number of records. * @param string|TActiveRecordCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return int number of records. */ public function count($criteria = null, $parameters = []) @@ -1054,8 +1134,9 @@ public function hasRecordRelation($property) } /** - * Return record data as array - * @return array of column name and column values + * Returns record data as an associative array. + * Keys are column names (lowercase) and values are the corresponding column values. + * @return array associative array of column name => column value. * @since 3.2.4 */ public function toArray() @@ -1069,8 +1150,8 @@ public function toArray() } /** - * Return record data as JSON - * @return false|string json + * Returns record data as a JSON string. + * @return false|string JSON string, or false on failure. * @since 3.2.4 */ public function toJSON() diff --git a/framework/Data/ActiveRecord/TActiveRecordCriteria.php b/framework/Data/ActiveRecord/TActiveRecordCriteria.php index 3ca41300a..31e794a05 100644 --- a/framework/Data/ActiveRecord/TActiveRecordCriteria.php +++ b/framework/Data/ActiveRecord/TActiveRecordCriteria.php @@ -13,6 +13,8 @@ use Prado\Data\DataGateway\TSqlCriteria; /** + * TActiveRecordCriteria class + * * Search criteria for Active Record. * * Criteria object for active record finder methods. Usage: diff --git a/framework/Data/ActiveRecord/TActiveRecordGateway.php b/framework/Data/ActiveRecord/TActiveRecordGateway.php index 2804260e9..ebe2cbcfe 100644 --- a/framework/Data/ActiveRecord/TActiveRecordGateway.php +++ b/framework/Data/ActiveRecord/TActiveRecordGateway.php @@ -22,8 +22,43 @@ use ReflectionClass; /** - * TActiveRecordGateway excutes the SQL command queries and returns the data - * record as arrays (for most finder methods). + * TActiveRecordGateway class + * + * TActiveRecordGateway executes SQL command queries and returns the data as arrays for finder methods. + * + * This gateway acts as the bridge between TActiveRecord models and the underlying database. + * It handles all CRUD operations (Create, Read, Update, Delete) and provides methods for + * finding records by various criteria. + * + * Each TActiveRecord subclass has a corresponding gateway instance managed by TActiveRecordManager. + * The gateway uses TDataGatewayCommand to build and execute database-specific SQL commands. + * + * Example: + * ```php + * // Get the gateway from a record instance + * $user = new UserRecord(); + * $gateway = $user->getRecordGateway(); + * + * // Find a record by primary key + * $data = $gateway->findRecordByPK($user, ['username' => 'admin']); + * + * // Insert a new record + * $newUser = new UserRecord(); + * $newUser->username = 'newuser'; + * $newUser->email = 'new@example.com'; + * $gateway->insert($newUser); + * + * // Update existing record + * $user->email = 'updated@example.com'; + * $gateway->update($user); + * + * // Delete a record + * $gateway->delete($user); + * ``` + * + * Since v4.3.3, TActiveRecordGateway supports insertion conflicts with: + * - {@see insertOrIgnore()}: Insert silently ignoring duplicate key conflicts + * - {@see upsert()}: Insert or update on conflict * * @author Wei Zhuo * @since 3.1 @@ -284,6 +319,15 @@ public function findRecordsBySql(TActiveRecord $record, $criteria) return $this->getCommand($record)->findAllBySql($criteria); } + /** + * Returns the number of records matching the given index fields and values. + * Uses SQL clause "(fields) IN (values)" for matching. + * @param TActiveRecord $record active record finder instance. + * @param TActiveRecordCriteria $criteria search criteria. + * @param array $fields field names to match. + * @param array $values matching field values. + * @return int number of records. + */ public function findRecordsByIndex(TActiveRecord $record, $criteria, $fields, $values) { return $this->getCommand($record)->findAllByIndex($criteria, $fields, $values); @@ -359,6 +403,39 @@ protected function getInsertValues(TActiveRecord $record) return $values; } + /** + * Insert a new record, silently ignoring if a duplicate key conflict occurs. + * @param TActiveRecord $record new record. + * @return mixed last insert id, true on ignore, or false on failure. + * @since 4.3.3 + */ + public function insertOrIgnore(TActiveRecord $record): mixed + { + $result = $this->getCommand($record)->insertOrIgnore($this->getInsertValues($record)); + if ($result) { + $this->updatePostInsert($record); + } + return $result; + } + + /** + * Insert or update a record. + * On conflict with $conflictColumns (defaults to primary key), updates $updateData columns. + * @param TActiveRecord $record record to insert or update. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns. + * @param null|array $conflictColumns conflict target columns; null = primary key. + * @return mixed last insert id, true on update, or false on failure. + * @since 4.3.3 + */ + public function upsert(TActiveRecord $record, ?array $updateData = null, ?array $conflictColumns = null): mixed + { + $result = $this->getCommand($record)->upsert($this->getInsertValues($record), $updateData, $conflictColumns); + if ($result) { + $this->updatePostInsert($record); + } + return $result; + } + /** * Update the record. * @param TActiveRecord $record dirty record. diff --git a/framework/Data/ActiveRecord/TActiveRecordInvalidFinderResult.php b/framework/Data/ActiveRecord/TActiveRecordInvalidFinderResult.php index fb47165d3..c6d10424d 100644 --- a/framework/Data/ActiveRecord/TActiveRecordInvalidFinderResult.php +++ b/framework/Data/ActiveRecord/TActiveRecordInvalidFinderResult.php @@ -11,7 +11,8 @@ namespace Prado\Data\ActiveRecord; /** - * TActiveRecordInvalidFinderResult class. + * TActiveRecordInvalidFinderResult class + * * TActiveRecordInvalidFinderResult defines the enumerable type for possible results * if an invalid {@see \Prado\Data\ActiveRecord\TActiveRecord::__call magic-finder} invoked. * diff --git a/framework/Data/ActiveRecord/TActiveRecordManager.php b/framework/Data/ActiveRecord/TActiveRecordManager.php index 4d5739ac6..6a0eff931 100644 --- a/framework/Data/ActiveRecord/TActiveRecordManager.php +++ b/framework/Data/ActiveRecord/TActiveRecordManager.php @@ -15,6 +15,8 @@ use Prado\TPropertyValue; /** + * TActiveRecordManager class + * * TActiveRecordManager provides the default DB connection, * default active record gateway, and table meta data inspector. * diff --git a/framework/Data/Common/Firebird/TFirebirdCommandBuilder.php b/framework/Data/Common/Firebird/TFirebirdCommandBuilder.php index 252b13a21..bfe5687d6 100644 --- a/framework/Data/Common/Firebird/TFirebirdCommandBuilder.php +++ b/framework/Data/Common/Firebird/TFirebirdCommandBuilder.php @@ -11,6 +11,7 @@ namespace Prado\Data\Common\Firebird; use Prado\Data\Common\TDbCommandBuilder; +use Prado\Data\TDbCommand; /** * TFirebirdCommandBuilder provides Firebird-specific LIMIT/OFFSET and last-insert-ID support. @@ -28,6 +29,80 @@ */ class TFirebirdCommandBuilder extends TDbCommandBuilder { + /** + * Creates a Firebird MERGE ... WHEN NOT MATCHED THEN INSERT command (insertOrIgnore). + * Requires an active transaction; throws TDbException otherwise. + * Uses Firebird MERGE with USING (SELECT ... FROM RDB$DATABASE) and no AS keyword for aliases. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore MERGE command. + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns(null); + return $this->buildMergeStatement($data, [], $conflictColumns, 'FROM RDB$DATABASE', false); + } + + /** + * Creates a Firebird MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT command. + * Requires an active transaction; throws TDbException otherwise. + * Uses Firebird MERGE with USING (SELECT ... FROM RDB$DATABASE) and no AS keyword for aliases. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert MERGE command. + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + return $this->buildMergeStatement($data, $updateData, $conflictColumns, 'FROM RDB$DATABASE', false); + } + + /** + * Children override this if there is something specific about the column Name. + * @param string $columnName The name of the column to place in the sql. + * @return string null if no change, or a string if there is a change. + */ + protected function processMergeColumn(string $columnName): string + { + $castType = $this->getFirebirdCastType($columnName); + return 'CAST(:' . $columnName . ' AS ' . $castType . ') AS ' . $columnName; + } + + /** + * Builds a Firebird-compatible CAST type string for the named column. + * + * Firebird requires explicit type annotations in CAST() expressions. This helper + * maps the column's DbType (and ColumnSize / NumericPrecision / NumericScale where + * applicable) to the correct SQL type string. + * + * @param string $name logical column name (PHP array key from $data). + * @return string SQL type string suitable for use in CAST(:name AS ). + */ + private function getFirebirdCastType(string $name): string + { + $column = $this->getTableInfo()->getColumn($name); + if ($column === null) { + return 'VARCHAR(255)'; + } + + $dbType = strtoupper(trim($column->getDbType())); + $size = (int) $column->getColumnSize(); + $prec = (int) $column->getNumericPrecision(); + $scale = (int) $column->getNumericScale(); + + if (in_array($dbType, ['VARCHAR', 'CHAR'], true) && $size > 0) { + return $dbType . '(' . $size . ')'; + } + if (in_array($dbType, ['DECIMAL', 'NUMERIC'], true) && $prec > 0) { + return $dbType . '(' . $prec . ($scale > 0 ? ',' . $scale : '') . ')'; + } + // Fixed-length types and all others: return as-is. + return $dbType; + } + /** * Overrides parent implementation. Retrieves last identity value (Firebird 3+). * @return null|int last inserted identity value, null if no identity column. diff --git a/framework/Data/Common/IDataColumn.php b/framework/Data/Common/IDataColumn.php new file mode 100644 index 000000000..fd206acd0 --- /dev/null +++ b/framework/Data/Common/IDataColumn.php @@ -0,0 +1,107 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common; + +/** + * IDataColumn interface + * + * IDataColumn defines the minimum driver-agnostic contract for a column (field) + * metadata object. + * + * The interface is shaped after the core accessors of {@see TDbTableColumn}, which + * is the canonical SQL implementation, but is intentionally decoupled from it so + * that application code and third-party plugins can supply custom implementations + * without coupling to the SQL class hierarchy. For example, a MongoDB field + * descriptor or a spreadsheet column descriptor may implement this interface + * without inheriting from `TDbTableColumn`. + * + * The interface covers identity ({@see getColumnName()}, {@see getColumnId()}), + * nullability ({@see getAllowNull()}), the raw database type ({@see getDbType()}), + * and the PHP primitive type ({@see getPHPType()}). All of these are meaningful + * to any data-store driver. + * + * PDO-specific binding ({@see IDbColumn::getPdoType()}) lives on the sub-interface + * {@see IDbColumn}, following the same layering pattern as + * {@see \Prado\Data\IDataConnection} / {@see \Prado\Data\IDbConnection}. + * Code that works exclusively with SQL/PDO drivers should type-hint against + * {@see IDbColumn}; code that must remain driver-agnostic uses this interface. + * + * Driver-specific concerns — default values, ordinal position, sequence names, + * auto-increment flags — remain on the concrete implementation class. + * Code that needs those details should check `instanceof TDbTableColumn` + * explicitly, following the same marker-interface pattern used by + * {@see IDataHasSchema}. + * + * Concrete SQL implementations: {@see TDbTableColumn} and its driver-specific + * subclasses ({@see TMysqlTableColumn}, {@see TSqliteTableColumn}, + * {@see TPgsqlTableColumn}, {@see TOracleTableColumn}, {@see TIbmTableColumn}, + * {@see TFirebirdTableColumn}). + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDataColumn +{ + /** + * Returns the identifier-quoted column name as it should appear in SQL. + * + * For SQL drivers this is the driver-specific quoted form, e.g. `` `id` `` + * for MySQL or `"id"` for PostgreSQL. For non-SQL implementations the + * value is driver-defined but must uniquely identify the column within + * the context of its containing collection. + * + * @return string the identifier-quoted column name. + */ + public function getColumnName(); + + /** + * Returns the bare (unquoted) column identifier. + * + * This is the canonical key used to look up the column in the column map + * and to reference it in ORDER BY clauses where quoting is not needed. + * + * @return string the bare column identifier. + */ + public function getColumnId(); + + /** + * Returns the native database type string for this column. + * + * The value is driver-specific (e.g. `'varchar'`, `'integer'`, `'text'` + * for SQL drivers; `'string'`, `'int32'` for document stores). + * + * @return ?string the native type string, or null if not available. + */ + public function getDbType(); + + /** + * Returns whether null is a legal value for this column. + * + * @return bool true if null is allowed; false otherwise. + */ + public function getAllowNull(); + + /** + * Returns the PHP primitive type that best represents this column's declared + * database type. + * + * The returned string is one of the PHP primitive type names: `'string'`, + * `'integer'`, `'boolean'`, or `'double'`. It is used by + * {@see \Prado\Shell\Actions\TActiveRecordAction} for code generation + * and is the driver-agnostic counterpart to {@see IDbColumn::getPdoType()}. + * + * Non-SQL drivers should return `'string'` as the safe default when no + * more specific type can be determined. + * + * @return string the PHP primitive type name for this column. + */ + public function getPHPType(); +} diff --git a/framework/Data/Common/IDataCommandBuilder.php b/framework/Data/Common/IDataCommandBuilder.php new file mode 100644 index 000000000..d012d106c --- /dev/null +++ b/framework/Data/Common/IDataCommandBuilder.php @@ -0,0 +1,230 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common; + +use Prado\Data\IDataCommand; +use Prado\Data\IDataConnection; + +/** + * IDataCommandBuilder interface + * + * IDataCommandBuilder defines the interface for building command objects for + * CRUD operations on a single database table. + * + * The interface is shaped after {@see TDbCommandBuilder}, which is the canonical + * SQL implementation, but is intentionally decoupled from it so that application + * code and third-party PRADO plugins can supply entirely custom builders (e.g. + * for NoSQL or time-series data stores) without inheriting from the SQL class + * hierarchy. + * + * All method signatures are SQL-centric: WHERE clause strings, column-name → + * value parameter arrays, column-name → direction ordering arrays, integer + * limit/offset values, and a column-select descriptor. + * + * Concrete implementations: {@see TDbCommandBuilder} and its driver-specific + * subclasses ({@see TMysqlCommandBuilder}, {@see TSqliteCommandBuilder}, + * {@see TPgsqlCommandBuilder}, {@see TMssqlCommandBuilder}, + * {@see TOracleCommandBuilder}, {@see TIbmCommandBuilder}, + * {@see TFirebirdCommandBuilder}). + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDataCommandBuilder +{ + // ----------------------------------------------------------------------- + // Accessors + // ----------------------------------------------------------------------- + + /** + * @return IDataConnection the connection this builder operates on. + */ + public function getDbConnection(); + + /** + * @return IDataTableInfo the table metadata this builder targets. + */ + public function getTableInfo(); + + /** + * Returns the last inserted ID for the table, using any sequence column + * defined in the table metadata. + * + * @return mixed the last inserted ID or sequence value, or null if the table + * has no sequence column. + */ + public function getLastInsertID(); + + // ----------------------------------------------------------------------- + // Query-building helpers + // ----------------------------------------------------------------------- + + /** + * Appends LIMIT and OFFSET clauses to a SQL string. + * + * @param string $sql the SQL string to modify. + * @param int $limit maximum rows to return; negative means no limit. + * @param int $offset number of rows to skip; negative means no offset. + * @return string the SQL string with LIMIT/OFFSET applied. + */ + public function applyLimitOffset($sql, $limit = -1, $offset = -1); + + /** + * Appends an ORDER BY clause to a SQL string. + * + * @param string $sql the SQL string to modify. + * @param array $ordering column-name → direction ('asc'|'desc') pairs. + * @return string the SQL string with ORDER BY applied. + */ + public function applyOrdering($sql, $ordering); + + /** + * Builds a SQL WHERE expression that searches a set of columns for keywords. + * + * @param array $fields column IDs to search. + * @param string $keywords space-separated search terms. + * @return string a SQL condition string (may be empty if no terms or fields given). + */ + public function getSearchExpression($fields, $keywords); + + /** + * Returns the list of column expressions to use in a SELECT clause. + * + * @param mixed $data '*' for all columns, null for the table's default column + * list, a comma-separated column name string, or an associative data array + * whose keys are column names. + * @return string[] fully-quoted column expressions suitable for SELECT. + */ + public function getSelectFieldList($data = '*'); + + /** + * Applies ordering, limit, offset, and bound parameters to a SQL string and + * returns the resulting command. + * + * @param string $sql the base SQL string. + * @param array $parameters name-value pairs (or positional values) to bind. + * @param array $ordering column → direction pairs. + * @param int $limit maximum rows; negative means no limit. + * @param int $offset rows to skip; negative means no offset. + * @return IDataCommand the command ready for execution. + */ + public function applyCriterias($sql, $parameters = [], $ordering = [], $limit = -1, $offset = -1); + + // ----------------------------------------------------------------------- + // Command factories + // ----------------------------------------------------------------------- + + /** + * Creates a SELECT command for the table. + * + * @param string $where WHERE clause (without the keyword); defaults to '1=1'. + * @param array $parameters name-value pairs to bind. + * @param array $ordering column → direction pairs. + * @param int $limit maximum rows; negative means no limit. + * @param int $offset rows to skip; negative means no offset. + * @param string $select columns to select; '*' means all columns. + * @return IDataCommand the SELECT command. + */ + public function createFindCommand($where = '1=1', $parameters = [], $ordering = [], $limit = -1, $offset = -1, $select = '*'); + + /** + * Creates a COUNT(*) command for the table. + * + * @param string $where WHERE clause; defaults to '1=1'. + * @param array $parameters name-value pairs to bind. + * @param array $ordering column → direction pairs. + * @param int $limit maximum rows; negative means no limit. + * @param int $offset rows to skip; negative means no offset. + * @return IDataCommand the COUNT command. + */ + public function createCountCommand($where = '1=1', $parameters = [], $ordering = [], $limit = -1, $offset = -1); + + /** + * Creates an INSERT command for the table. + * + * @param array $data column-name → value pairs to insert. + * @return IDataCommand the INSERT command. + */ + public function createInsertCommand($data); + + /** + * Creates an INSERT OR IGNORE command for the table. + * + * The base implementation throws {@see TDbException}; driver-specific + * subclasses that support this operation must override this method. + * + * @param array $data column-name → value pairs to insert. + * @return IDataCommand the INSERT OR IGNORE command. + */ + public function createInsertOrIgnoreCommand(array $data): IDataCommand; + + /** + * Creates an UPSERT (INSERT … ON CONFLICT … UPDATE) command for the table. + * + * The base implementation throws {@see TDbException}; driver-specific + * subclasses that support this operation must override this method. + * + * @param array $data column-name → value pairs to insert. + * @param null|array $updateData column → value pairs to use on conflict; null + * means all non-primary-key columns from $data. + * @param null|array $conflictColumns columns that define the conflict target; + * null means the table's primary key columns. + * @return IDataCommand the UPSERT command. + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): IDataCommand; + + /** + * Creates an UPDATE command for the table. + * + * @param array $data column-name → value pairs to set. + * @param string $where WHERE clause identifying rows to update. + * @param array $parameters additional name-value pairs to bind for the WHERE clause. + * @return IDataCommand the UPDATE command. + */ + public function createUpdateCommand($data, $where, $parameters = []); + + /** + * Creates a DELETE command for the table. + * + * @param string $where WHERE clause identifying rows to delete. + * @param array $parameters name-value pairs to bind. + * @return IDataCommand the DELETE command. + */ + public function createDeleteCommand($where, $parameters = []); + + // ----------------------------------------------------------------------- + // Utilities + // ----------------------------------------------------------------------- + + /** + * Creates a raw SQL command on the underlying connection. + * + * @param string $sql the SQL statement. + * @return IDataCommand the new command. + */ + public function createCommand($sql); + + /** + * Binds column-name → value pairs to a command, using each column's native type. + * + * @param IDataCommand $command the command to bind into. + * @param array $values column-name → value pairs. + */ + public function bindColumnValues($command, $values); + + /** + * Binds an array of values (positional or named) to a command. + * + * @param IDataCommand $command the command to bind into. + * @param array $values positional values or name → value pairs. + */ + public function bindArrayValues($command, $values); +} diff --git a/framework/Data/Common/IDbHasSchema.php b/framework/Data/Common/IDataHasSchema.php similarity index 73% rename from framework/Data/Common/IDbHasSchema.php rename to framework/Data/Common/IDataHasSchema.php index 8ae6621e0..35e9c559d 100644 --- a/framework/Data/Common/IDbHasSchema.php +++ b/framework/Data/Common/IDataHasSchema.php @@ -1,7 +1,7 @@ * @link https://github.com/pradosoft/prado @@ -11,11 +11,13 @@ namespace Prado\Data\Common; /** - * IDbHasSchema is a marker interface for database table-info classes whose + * IDataHasSchema interface + * + * IDataHasSchema is a marker interface for database table-info classes whose * underlying database engine supports the concept of a schema (also called an * owner or namespace that groups tables within a database). * - * Drivers that implement this interface: MySQL, PostgreSQL, MSSQL, IBM DB2, Oracle. + * Drivers that implement this interface: MySQL, PostgreSQL, SQL Server, IBM DB2, Oracle. * Drivers that do NOT: SQLite, Firebird (neither has a schema namespace). * * TDbTableInfo::getSchemaName() returns a non-null value only when the concrete @@ -24,12 +26,12 @@ * The interface is intentionally empty — it serves as a capability declaration * rather than a method contract, following the marker-interface pattern used * elsewhere in the framework (e.g. IDbModule). Future NoSQL metadata classes - * may introduce analogous markers (IDbHasKeyspace, IDbHasCollection, etc.) + * may introduce analogous markers (IDataHasKeyspace, IDataHasCollection, etc.) * following the same convention. * * @author Brad Anderson * @since 4.3.3 */ -interface IDbHasSchema +interface IDataHasSchema { } diff --git a/framework/Data/Common/IDataMetaData.php b/framework/Data/Common/IDataMetaData.php new file mode 100644 index 000000000..e16cf2012 --- /dev/null +++ b/framework/Data/Common/IDataMetaData.php @@ -0,0 +1,95 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common; + +use Prado\Data\IDataConnection; + +/** + * IDataMetaData interface + * + * IDataMetaData defines the interface for retrieving schema metadata from a + * data store. + * + * The interface provides a common abstraction over driver-specific metadata + * implementations so that application code and PRADO plugins can work with any + * supported data store through a single, stable API — including future NoSQL or + * third-party implementations that do not extend {@see TDbMetaData}. + * + * The interface covers four areas: + * - **Table introspection** — {@see getTableInfo()} returns a structured + * {@see IDataTableInfo} describing the columns, keys, and constraints of a + * named table. + * - **Command builder factory** — {@see createCommandBuilder()} returns an + * {@see IDataCommandBuilder} ready to generate CRUD commands for a table. + * - **Identifier quoting** — {@see quoteTableName()}, {@see quoteColumnName()}, + * and {@see quoteColumnAlias()} wrap identifiers in driver-specific delimiters. + * - **Table discovery** — {@see findTableNames()} enumerates all tables in a + * schema. + * + * Concrete implementations: {@see TDbMetaData} and its driver-specific + * subclasses ({@see TMysqlMetaData}, {@see TSqliteMetaData}, + * {@see TPgsqlMetaData}, {@see TMssqlMetaData}, {@see TOracleMetaData}, + * {@see TIbmMetaData}, {@see TFirebirdMetaData}). + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDataMetaData +{ + /** + * Returns the database connection associated with this metadata instance. + * @return IDataConnection the database connection. + */ + public function getDbConnection(); + + /** + * Retrieves metadata for a specific table or view. + * @param ?string $tableName the table or view name. If null, returns metadata for the current database. + * @return IDataTableInfo the table metadata. + */ + public function getTableInfo($tableName = null); + + /** + * Creates a command builder for performing CRUD operations on a specific table. + * @param ?string $tableName the table name. + * @return IDataCommandBuilder the command builder instance for the given table. + */ + public function createCommandBuilder($tableName = null); + + /** + * Quotes a table name for use in SQL queries. + * @param string $name the table name to quote. + * @return string the properly quoted table name. + */ + public function quoteTableName($name); + + /** + * Quotes a column name for use in SQL queries. + * @param string $name the column name to quote. + * @return string the properly quoted column name. + */ + public function quoteColumnName($name); + + /** + * Quotes a column alias for use in SQL queries. + * @param string $name the column alias to quote. + * @return string the properly quoted column alias. + */ + public function quoteColumnAlias($name); + + /** + * Returns all table names in the database or schema. + * @param string $schema the schema name. Defaults to empty string, meaning the current or default schema. + * If not empty, the returned table names will be prefixed with the schema name. + * @return array all table names in the database. + */ + public function findTableNames($schema = ''); +} diff --git a/framework/Data/Common/IDataTableInfo.php b/framework/Data/Common/IDataTableInfo.php new file mode 100644 index 000000000..126453669 --- /dev/null +++ b/framework/Data/Common/IDataTableInfo.php @@ -0,0 +1,97 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common; + +use Prado\Data\IDataConnection; + +/** + * IDataTableInfo interface + * + * IDataTableInfo defines the interface for table (or view) metadata. + * + * The interface is shaped after {@see TDbTableInfo}, which is the canonical SQL + * implementation, but is intentionally decoupled from it so that application + * code and third-party plugins can supply custom implementations without + * coupling to the SQL class hierarchy. Terminology is relational (columns, + * primary keys, foreign keys) rather than document-store-centric. + * + * Concrete implementations: {@see TDbTableInfo} and its driver-specific + * subclasses ({@see TMysqlTableInfo}, {@see TSqliteTableInfo}, + * {@see TPgsqlTableInfo}, {@see TMssqlTableInfo}, {@see TOracleTableInfo}, + * {@see TIbmTableInfo}, {@see TFirebirdTableInfo}). + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDataTableInfo +{ + /** + * @return string the unqualified table or view name. + */ + public function getTableName(); + + /** + * @return string the fully-qualified table name (schema + table where applicable). + */ + public function getTableFullName(); + + /** + * @return bool whether this metadata describes a view rather than a base table. + */ + public function getIsView(); + + /** + * Returns all column metadata objects for the table or collection, keyed by column name. + * + * @return IDataColumn[] the column metadata objects. + */ + public function getColumns(); + + /** + * Returns the column metadata for a specific column, or null if not found. + * + * @param string $name the column name. + * @return ?IDataColumn the column metadata, or null. + */ + public function getColumn($name); + + /** + * Returns the names of all columns defined for the table. + * + * @return string[] the column names. + */ + public function getColumnNames(); + + /** + * Returns the names of the primary-key columns. + * + * @return string[] primary-key column names; empty array if none defined. + */ + public function getPrimaryKeys(); + + /** + * Returns the foreign-key descriptors for the table. + * + * The exact structure of each descriptor is driver-specific, but each entry + * describes a foreign-key relationship for one or more columns. + * + * @return array foreign-key descriptors; empty array if none defined. + */ + public function getForeignKeys(); + + /** + * Creates a command builder for CRUD operations on this table. + * + * @param IDataConnection $connection the connection to use. + * @return IDataCommandBuilder a new command builder for this table. + */ + public function createCommandBuilder($connection); +} diff --git a/framework/Data/Common/IDbColumn.php b/framework/Data/Common/IDbColumn.php new file mode 100644 index 000000000..38fd2f376 --- /dev/null +++ b/framework/Data/Common/IDbColumn.php @@ -0,0 +1,50 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common; + +/** + * IDbColumn interface + * + * IDbColumn extends {@see IDataColumn} with PDO-specific binding support, + * providing access to the PDO parameter-type token for this column. + * + * This interface is implemented by {@see TDbTableColumn} and should be used + * as the type hint wherever code needs to call PDO-specific methods directly + * (e.g. {@see TDbCommandBuilder::bindColumnValues()}). + * + * Code that does not require PDO parameter binding should use {@see IDataColumn} + * so that non-PDO driver implementations remain compatible. + * + * This follows the same layering pattern as + * {@see \Prado\Data\IDataConnection} / {@see \Prado\Data\IDbConnection}: + * the driver-agnostic interface carries the portable contract; the Db-prefixed + * sub-interface adds the PDO-specific extension. + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDbColumn extends IDataColumn +{ + /** + * Returns a PDO parameter-type token that best represents this column's + * declared database type, for use when binding parameter values. + * + * The returned integer is one of the stable PDO type constants: + * `PDO::PARAM_BOOL` (5), `PDO::PARAM_INT` (1), `PDO::PARAM_STR` (2). + * Used by {@see \Prado\Data\Common\TDbCommandBuilder::bindColumnValues()} + * when constructing INSERT and UPDATE commands. + * + * Prefer {@see getPHPType()} for new code that must remain driver-agnostic. + * + * @return int the PDO parameter-type token. + */ + public function getPdoType(); +} diff --git a/framework/Data/Common/Ibm/TIbmCommandBuilder.php b/framework/Data/Common/Ibm/TIbmCommandBuilder.php index 766629feb..4f4ba005c 100644 --- a/framework/Data/Common/Ibm/TIbmCommandBuilder.php +++ b/framework/Data/Common/Ibm/TIbmCommandBuilder.php @@ -11,6 +11,7 @@ namespace Prado\Data\Common\Ibm; use Prado\Data\Common\TDbCommandBuilder; +use Prado\Data\TDbCommand; /** * TIbmCommandBuilder provides DB2-specific LIMIT/OFFSET and last-insert-ID support. @@ -27,6 +28,37 @@ */ class TIbmCommandBuilder extends TDbCommandBuilder { + /** + * Creates a DB2 MERGE ... WHEN NOT MATCHED THEN INSERT command (insertOrIgnore). + * Requires an active transaction; throws TDbException otherwise. + * Uses DB2 MERGE with USING (SELECT ... FROM SYSIBM.SYSDUMMY1) AS s syntax. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore MERGE command. + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns(null); + return $this->buildMergeStatement($data, [], $conflictColumns, 'FROM SYSIBM.SYSDUMMY1', true); + } + + /** + * Creates a DB2 MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT command. + * Requires an active transaction; throws TDbException otherwise. + * Uses DB2 MERGE with USING (SELECT ... FROM SYSIBM.SYSDUMMY1) AS s syntax. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert MERGE command. + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + return $this->buildMergeStatement($data, $updateData, $conflictColumns, 'FROM SYSIBM.SYSDUMMY1', true); + } + /** * Overrides parent implementation. Retrieves last identity value via DB2 function. * @return null|int last inserted identity value, null if no identity column. diff --git a/framework/Data/Common/Ibm/TIbmTableInfo.php b/framework/Data/Common/Ibm/TIbmTableInfo.php index 7ad936c3e..830017d7d 100644 --- a/framework/Data/Common/Ibm/TIbmTableInfo.php +++ b/framework/Data/Common/Ibm/TIbmTableInfo.php @@ -10,7 +10,7 @@ namespace Prado\Data\Common\Ibm; -use Prado\Data\Common\IDbHasSchema; +use Prado\Data\Common\IDataHasSchema; use Prado\Data\Common\TDbTableInfo; /** @@ -19,7 +19,7 @@ * @author Brad Anderson * @since 4.3.3 */ -class TIbmTableInfo extends TDbTableInfo implements IDbHasSchema +class TIbmTableInfo extends TDbTableInfo implements IDataHasSchema { /** * @return string fully qualified table name (schema + table), double-quote delimited. diff --git a/framework/Data/Common/Mssql/TMssqlCommandBuilder.php b/framework/Data/Common/Mssql/TMssqlCommandBuilder.php index 7ebd76379..edc8570b6 100644 --- a/framework/Data/Common/Mssql/TMssqlCommandBuilder.php +++ b/framework/Data/Common/Mssql/TMssqlCommandBuilder.php @@ -10,8 +10,7 @@ namespace Prado\Data\Common\Mssql; -use Prado\Data\Common\TDbCommandBuilder; -use Prado\Prado; +use Prado\Data\Common\SqlSrv\TSqlSrvCommandBuilder; /** * TMssqlCommandBuilder provides specifics methods to create limit/offset query commands @@ -19,152 +18,9 @@ * * @author Wei Zhuo * @since 3.1 + * @todo v4.4 remove, replaced by TSqlSrvCommandBuilder + * @deprecated */ -class TMssqlCommandBuilder extends TDbCommandBuilder +class TMssqlCommandBuilder extends TSqlSrvCommandBuilder { - /** - * Overrides parent implementation. Uses "SELECT @@Identity". - * @return null|int last insert id, null if none is found. - */ - public function getLastInsertID() - { - foreach ($this->getTableInfo()->getColumns() as $column) { - if ($column->hasSequence()) { - $command = $this->getDbConnection()->createCommand('SELECT @@Identity'); - return (int) ($command->queryScalar()); - } - } - return null; - } - - /** - * Overrides parent implementation. Alters the sql to apply $limit and $offset. - * The idea for limit with offset is done by modifying the sql on the fly - * with numerous assumptions on the structure of the sql string. - * The modification is done with reference to the notes from - * http://troels.arvin.dk/db/rdbms/#select-limit-offset - * - * ```sql - * SELECT * FROM ( - * SELECT TOP n * FROM ( - * SELECT TOP z columns -- (z=n+skip) - * FROM tablename - * ORDER BY key ASC - * ) AS FOO ORDER BY key DESC -- ('FOO' may be anything) - * ) AS BAR ORDER BY key ASC -- ('BAR' may be anything) - * ``` - * - * Regular expressions are used to alter the SQL query. The resulting SQL query - * may be malformed for complex queries. The following restrictions apply - * - *
    - *
  • - * In particular, commas should NOT - * be used as part of the ordering expression or identifier. Commas must only be - * used for separating the ordering clauses. - *
  • - *
  • - * In the ORDER BY clause, the column name should NOT be be qualified - * with a table name or view name. Alias the column names or use column index. - *
  • - *
  • - * No clauses should follow the ORDER BY clause, e.g. no COMPUTE or FOR clauses. - *
  • - *
- * - * @param string $sql SQL query string. - * @param int $limit maximum number of rows, -1 to ignore limit. - * @param int $offset row offset, -1 to ignore offset. - * @return string SQL with limit and offset. - */ - public function applyLimitOffset($sql, $limit = -1, $offset = -1) - { - $limit = $limit !== null ? (int) $limit : -1; - $offset = $offset !== null ? (int) $offset : -1; - if ($limit > 0 && $offset <= 0) { //just limit - $sql = preg_replace('/^([\s(])*SELECT( DISTINCT)?(?!\s*TOP\s*\()/i', "\\1SELECT\\2 TOP $limit", $sql); - } elseif ($limit > 0 && $offset > 0) { - $sql = $this->rewriteLimitOffsetSql($sql, $limit, $offset); - } - return $sql; - } - - /** - * Rewrite sql to apply $limit > and $offset > 0 for MSSQL database. - * See http://troels.arvin.dk/db/rdbms/#select-limit-offset - * @param string $sql sql query - * @param int $limit > 0 - * @param int $offset > 0 - * @return string sql modified sql query applied with limit and offset. - */ - protected function rewriteLimitOffsetSql($sql, $limit, $offset) - { - $fetch = $limit + $offset; - $sql = preg_replace('/^([\s(])*SELECT( DISTINCT)?(?!\s*TOP\s*\()/i', "\\1SELECT\\2 TOP $fetch", $sql); - $ordering = $this->findOrdering($sql); - - $orginalOrdering = $this->joinOrdering($ordering); - $reverseOrdering = $this->joinOrdering($this->reverseDirection($ordering)); - $sql = "SELECT * FROM (SELECT TOP {$limit} * FROM ($sql) as [__inner top table__] {$reverseOrdering}) as [__outer top table__] {$orginalOrdering}"; - return $sql; - } - - /** - * Base on simplified syntax http://msdn2.microsoft.com/en-us/library/aa259187(SQL.80).aspx - * - * @param string $sql $sql - * @return array ordering expression as key and ordering direction as value - */ - protected function findOrdering($sql) - { - if (!preg_match('/ORDER BY/i', $sql)) { - return []; - } - $matches = []; - $ordering = []; - preg_match_all('/(ORDER BY)[\s"\[](.*)(ASC|DESC)?(?:[\s"\[]|$|COMPUTE|FOR)/i', $sql, $matches); - if (count($matches) > 1 && count($matches[2]) > 0) { - $parts = explode(',', $matches[2][0]); - foreach ($parts as $part) { - $subs = []; - if (preg_match_all('/(.*)[\s"\]](ASC|DESC)$/i', trim($part), $subs)) { - if (count($subs) > 1 && count($subs[2]) > 0) { - $ordering[$subs[1][0]] = $subs[2][0]; - } - //else what? - } else { - $ordering[trim($part)] = 'ASC'; - } - } - } - return $ordering; - } - - /** - * @param array $orders ordering obtained from findOrdering() - * @return string concat the orderings - */ - protected function joinOrdering($orders) - { - if (count($orders) > 0) { - $str = []; - foreach ($orders as $column => $direction) { - $str[] = $column . ' ' . $direction; - } - return 'ORDER BY ' . implode(', ', $str); - } - return ''; - } - - /** - * @param array $orders original ordering - * @return array ordering with reversed direction. - */ - protected function reverseDirection($orders) - { - foreach ($orders as $column => $direction) { - $orders[$column] = strtolower(trim($direction)) === 'desc' ? 'ASC' : 'DESC'; - } - return $orders; - } } diff --git a/framework/Data/Common/Mssql/TMssqlMetaData.php b/framework/Data/Common/Mssql/TMssqlMetaData.php index 9f97cce92..e780024df 100644 --- a/framework/Data/Common/Mssql/TMssqlMetaData.php +++ b/framework/Data/Common/Mssql/TMssqlMetaData.php @@ -10,310 +10,16 @@ namespace Prado\Data\Common\Mssql; -/** - * Load the base TDbMetaData class. - */ -use Prado\Data\Common\TDbMetaData; -use Prado\Exceptions\TDbException; -use Prado\Prado; +use Prado\Data\Common\SqlSrv\TSqlSrvMetaData; /** * TMssqlMetaData loads MSSQL database table and column information. * * @author Wei Zhuo * @since 3.1 + * @todo v4.4 remove, replaced by TSqlSrvMetaData + * @deprecated */ -class TMssqlMetaData extends TDbMetaData +class TMssqlMetaData extends TSqlSrvMetaData { - public const DEFAULT_SCHEMA = 'dbo'; - - /** - * @return string TDbTableInfo class name. - */ - protected function getTableInfoClass() - { - return \Prado\Data\Common\Mssql\TMssqlTableInfo::class; - } - - /** - * Quotes a table name for use in a query. - * @param string $name $name table name - * @return string the properly quoted table name - */ - public function quoteTableName($name) - { - return parent::quoteTableName($name, '[', ']'); - } - - /** - * Quotes a column name for use in a query. - * @param string $name $name column name - * @return string the properly quoted column name - */ - public function quoteColumnName($name) - { - return parent::quoteColumnName($name, '[', ']'); - } - - /** - * Quotes a column alias for use in a query. - * @param string $name $name column alias - * @return string the properly quoted column alias - */ - public function quoteColumnAlias($name) - { - return parent::quoteColumnAlias($name, '"', '"'); - } - - /** - * Get the column definitions for given table. - * @param string $table table name. - * @return TMssqlTableInfo table information. - */ - protected function createTableInfo($table) - { - [$catalogName, $schemaName, $tableName] = $this->getCatalogSchemaTableName($table); - $this->getDbConnection()->setActive(true); - $sql = -<<getDbConnection()->createCommand($sql); - $command->bindValue(':table', $tableName); - if ($schemaName !== null) { - $command->bindValue(':schema', $schemaName); - } - if ($catalogName !== null) { - $command->bindValue(':catalog', $catalogName); - } - - $tableInfo = null; - foreach ($command->query() as $col) { - if ($tableInfo === null) { - $tableInfo = $this->createNewTableInfo($col); - } - $this->processColumn($tableInfo, $col); - } - if ($tableInfo === null) { - throw new TDbException('dbmetadata_invalid_table_view', $table); - } - return $tableInfo; - } - - /** - * @param string $table table name - * @return array tuple($catalogName,$schemaName,$tableName) - */ - protected function getCatalogSchemaTableName($table) - { - //remove possible delimiters - $result = explode('.', preg_replace('/\[|\]|"/', '', $table)); - if (count($result) === 1) { - return [null, null, $result[0]]; - } - if (count($result) === 2) { - return [null, $result[0], $result[1]]; - } - if (count($result) > 2) { - return [$result[0], $result[1], $result[2]]; - } - return [$result[0], $result[1], $result[2]]; - } - - /** - * @param TMssqlTableInfo $tableInfo table information. - * @param array $col column information. - */ - protected function processColumn($tableInfo, $col) - { - $columnId = $col['COLUMN_NAME']; - - $info['ColumnName'] = "[$columnId]"; //quote the column names! - $info['ColumnId'] = $columnId; - $info['ColumnIndex'] = (int) ($col['ORDINAL_POSITION']) - 1; //zero-based index - if ($col['IS_NULLABLE'] !== 'NO') { - $info['AllowNull'] = true; - } - if ($col['COLUMN_DEFAULT'] !== null) { - $info['DefaultValue'] = $col['COLUMN_DEFAULT']; - } - - if (in_array($columnId, $tableInfo->getPrimaryKeys())) { - $info['IsPrimaryKey'] = true; - } - if ($this->isForeignKeyColumn($columnId, $tableInfo)) { - $info['IsForeignKey'] = true; - } - - if ($col['IsIdentity'] === '1') { - $info['AutoIncrement'] = true; - } - $info['DbType'] = $col['DATA_TYPE']; - if ($col['CHARACTER_MAXIMUM_LENGTH'] !== null) { - $info['ColumnSize'] = (int) ($col['CHARACTER_MAXIMUM_LENGTH']); - } - if ($col['NUMERIC_PRECISION'] !== null) { - $info['NumericPrecision'] = (int) ($col['NUMERIC_PRECISION']); - } - if ($col['NUMERIC_SCALE'] !== null) { - $info['NumericScale'] = (int) ($col['NUMERIC_SCALE']); - } - $tableInfo->getColumns()[$columnId] = new TMssqlTableColumn($info); - } - - /** - * @param array $col table informations - * @return TMssqlTableInfo - */ - protected function createNewTableInfo($col) - { - $info['CatalogName'] = $col['TABLE_CATALOG']; - $info['SchemaName'] = $col['TABLE_SCHEMA']; - $info['TableName'] = $col['TABLE_NAME']; - if ($col['TABLE_TYPE'] === 'VIEW') { - $info['IsView'] = true; - } - [$primary, $foreign] = $this->getConstraintKeys($col); - $class = $this->getTableInfoClass(); - return new $class($info, $primary, $foreign); - } - - /** - * Gets the primary and foreign key column details for the given table. - * @param array $col table informations - * @return array tuple ($primary, $foreign) - */ - protected function getConstraintKeys($col) - { - $sql = -<<getDbConnection()->createCommand($sql); - $command->bindValue(':table', $col['TABLE_NAME']); - $primary = []; - foreach ($command->query()->readAll() as $field) { - $primary[] = $field['field_name']; - } - $foreign = $this->getForeignConstraints($col); - return [$primary, $foreign]; - } - - /** - * Gets foreign relationship constraint keys and table name - * @param array $col table informations - * @return array foreign relationship table name and keys. - */ - protected function getForeignConstraints($col) - { - //From http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx - $sql = -<<getDbConnection()->createCommand($sql); - $command->bindValue(':table', $col['TABLE_NAME']); - $fkeys = []; - $catalogSchema = "[{$col['TABLE_CATALOG']}].[{$col['TABLE_SCHEMA']}]"; - foreach ($command->query() as $info) { - $fkeys[$info['FK_CONSTRAINT_NAME']]['keys'][$info['FK_COLUMN_NAME']] = $info['UQ_COLUMN_NAME']; - $fkeys[$info['FK_CONSTRAINT_NAME']]['table'] = $info['UQ_TABLE_NAME']; - } - return count($fkeys) > 0 ? array_values($fkeys) : $fkeys; - } - - /** - * @param string $columnId column name. - * @param TMssqlTableInfo $tableInfo table information. - * @return bool true if column is a foreign key. - */ - protected function isForeignKeyColumn($columnId, $tableInfo) - { - foreach ($tableInfo->getForeignKeys() as $fk) { - if (in_array($columnId, array_keys($fk['keys']))) { - return true; - } - } - return false; - } - - /** - * Returns all table names in the database. - * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema. - * If not empty, the returned table names will be prefixed with the schema name. - * @return array all table names in the database. - */ - public function findTableNames($schema = 'dbo') - { - $condition = "TABLE_TYPE='BASE TABLE'"; - $sql = -<<getDbConnection()->createCommand($sql); - $command->bindParameter(":schema", $schema); - $rows = $command->query(); - $names = []; - foreach ($rows as $row) { - if ($schema == self::DEFAULT_SCHEMA) { - $names[] = $row['TABLE_NAME']; - } else { - $names[] = $schema . '.' . $row['TABLE_SCHEMA'] . '.' . $row['TABLE_NAME']; - } - } - - return $names; - } } diff --git a/framework/Data/Common/Mssql/TMssqlTableColumn.php b/framework/Data/Common/Mssql/TMssqlTableColumn.php index a8a592c99..278e277d4 100644 --- a/framework/Data/Common/Mssql/TMssqlTableColumn.php +++ b/framework/Data/Common/Mssql/TMssqlTableColumn.php @@ -10,52 +10,16 @@ namespace Prado\Data\Common\Mssql; -/** - * Load common TDbTableCommon class. - */ -use Prado\Data\Common\TDbTableColumn; -use Prado\Prado; +use Prado\Data\Common\SqlSrv\TSqlSrvTableColumn; /** * Describes the column metadata of the schema for a Mssql database table. * * @author Wei Zhuo * @since 3.1 + * @todo v4.4 remove, replaced by TSqlSrvTableColumn + * @deprecated */ -class TMssqlTableColumn extends TDbTableColumn +class TMssqlTableColumn extends TSqlSrvTableColumn { - private static $types = []; - - /** - * Overrides parent implementation, returns PHP type from the db type. - * @return bool derived PHP primitive type from the column db type. - */ - public function getPHPType() - { - return 'string'; - } - - /** - * @return bool true if the column has identity (auto-increment) - */ - public function getAutoIncrement() - { - return $this->getInfo('AutoIncrement', false); - } - - /** - * @return bool true if auto increments. - */ - public function hasSequence() - { - return $this->getAutoIncrement(); - } - - /** - * @return bool true if db type is 'timestamp'. - */ - public function getIsExcluded() - { - return strtolower($this->getDbType()) === 'timestamp'; - } } diff --git a/framework/Data/Common/Mssql/TMssqlTableInfo.php b/framework/Data/Common/Mssql/TMssqlTableInfo.php index d541bd532..5966587b6 100644 --- a/framework/Data/Common/Mssql/TMssqlTableInfo.php +++ b/framework/Data/Common/Mssql/TMssqlTableInfo.php @@ -10,44 +10,16 @@ namespace Prado\Data\Common\Mssql; -/** - * Loads the base TDbTableInfo class and TMssqlTableColumn class. - */ -use Prado\Data\Common\IDbHasSchema; -use Prado\Data\Common\TDbTableInfo; -use Prado\Prado; +use Prado\Data\Common\SqlSrv\TSqlSrvTableInfo; /** * TMssqlTableInfo class provides additional table information for Mssql database. * * @author Wei Zhuo * @since 3.1 + * @todo v4.4 remove, replaced by TSqlSrvTableInfo + * @deprecated */ -class TMssqlTableInfo extends TDbTableInfo implements IDbHasSchema +class TMssqlTableInfo extends TSqlSrvTableInfo { - /** - * @return string catalog name (database name) - */ - public function getCatalogName() - { - return $this->getInfo('CatalogName'); - } - - /** - * @return string full name of the table, database dependent. - */ - public function getTableFullName() - { - //MSSQL alway returns the catalog, schem and table names. - return '[' . $this->getCatalogName() . '].[' . $this->getSchemaName() . '].[' . $this->getTableName() . ']'; - } - - /** - * @param \Prado\Data\TDbConnection $connection database connection. - * @return \Prado\Data\Common\TDbCommandBuilder new command builder - */ - public function createCommandBuilder($connection) - { - return new TMssqlCommandBuilder($connection, $this); - } } diff --git a/framework/Data/Common/Mysql/TMysqlCommandBuilder.php b/framework/Data/Common/Mysql/TMysqlCommandBuilder.php index 2ef182f8d..1557eb3ad 100644 --- a/framework/Data/Common/Mysql/TMysqlCommandBuilder.php +++ b/framework/Data/Common/Mysql/TMysqlCommandBuilder.php @@ -11,14 +11,67 @@ namespace Prado\Data\Common\Mysql; use Prado\Data\Common\TDbCommandBuilder; -use Prado\Prado; +use Prado\Data\TDbCommand; /** - * TMysqlCommandBuilder implements default TDbCommandBuilder + * TMysqlCommandBuilder implements TDbCommandBuilder with MySQL-specific syntax. + * + * Adds support for MySQL-specific insertOrIgnore (INSERT IGNORE) and + * upsert (INSERT ... ON DUPLICATE KEY UPDATE) statements. * * @author Wei Zhuo * @since 3.1 */ class TMysqlCommandBuilder extends TDbCommandBuilder { + /** + * Creates a MySQL INSERT IGNORE command. + * Silently skips the insert when a duplicate key constraint is violated. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + $command = $this->createCommand("INSERT IGNORE INTO {$table}({$fields}) VALUES ({$bindings})"); + $this->bindColumnValues($command, $data); + return $command; + } + + /** + * Creates a MySQL INSERT ... ON DUPLICATE KEY UPDATE command. + * On duplicate key conflict, updates the non-PK columns using the VALUES() function + * for broad compatibility with MySQL 5.x through 8.x. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + + $updateParts = []; + foreach (array_keys($updateData) as $name) { + $quoted = $this->getTableInfo()->getColumn($name)->getColumnName(); + $updateParts[] = $quoted . '=VALUES(' . $quoted . ')'; + } + + if (!empty($updateParts)) { + $sql = "INSERT INTO {$table}({$fields}) VALUES ({$bindings}) ON DUPLICATE KEY UPDATE " . implode(', ', $updateParts); + } else { + $sql = "INSERT IGNORE INTO {$table}({$fields}) VALUES ({$bindings})"; + } + + $command = $this->createCommand($sql); + $this->bindColumnValues($command, $data); + return $command; + } } diff --git a/framework/Data/Common/Mysql/TMysqlTableInfo.php b/framework/Data/Common/Mysql/TMysqlTableInfo.php index f3a72fd19..3b7cbd3c8 100644 --- a/framework/Data/Common/Mysql/TMysqlTableInfo.php +++ b/framework/Data/Common/Mysql/TMysqlTableInfo.php @@ -13,7 +13,7 @@ /** * Loads the base TDbTableInfo class and TMysqlTableColumn class. */ -use Prado\Data\Common\IDbHasSchema; +use Prado\Data\Common\IDataHasSchema; use Prado\Data\Common\TDbTableInfo; use Prado\Prado; @@ -23,7 +23,7 @@ * @author Wei Zhuo * @since 3.1 */ -class TMysqlTableInfo extends TDbTableInfo implements IDbHasSchema +class TMysqlTableInfo extends TDbTableInfo implements IDataHasSchema { /** * @return string full name of the table, database dependent. diff --git a/framework/Data/Common/Oracle/TOracleCommandBuilder.php b/framework/Data/Common/Oracle/TOracleCommandBuilder.php index 36fe51d81..835e5480e 100644 --- a/framework/Data/Common/Oracle/TOracleCommandBuilder.php +++ b/framework/Data/Common/Oracle/TOracleCommandBuilder.php @@ -11,7 +11,7 @@ namespace Prado\Data\Common\Oracle; use Prado\Data\Common\TDbCommandBuilder; -use Prado\Prado; +use Prado\Data\TDbCommand; /** * TOracleCommandBuilder provides specifics methods to create limit/offset query commands @@ -22,6 +22,39 @@ */ class TOracleCommandBuilder extends TDbCommandBuilder { + /** + * Creates an Oracle MERGE ... WHEN NOT MATCHED THEN INSERT command (insertOrIgnore). + * Requires an active transaction; throws TDbException otherwise. + * Uses Oracle MERGE with USING (SELECT ... FROM DUAL) and no AS keyword for aliases. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore MERGE command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns(null); + return $this->buildMergeStatement($data, [], $conflictColumns, 'FROM DUAL', false); + } + + /** + * Creates an Oracle MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT command. + * Requires an active transaction; throws TDbException otherwise. + * Uses Oracle MERGE with USING (SELECT ... FROM DUAL) and no AS keyword for aliases. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert MERGE command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + return $this->buildMergeStatement($data, $updateData, $conflictColumns, 'FROM DUAL', false); + } + /** * Overrides parent implementation. Only column of type text or character (and its variants) * accepts the LIKE criteria. diff --git a/framework/Data/Common/Oracle/TOracleDbCommand.php b/framework/Data/Common/Oracle/TOracleDbCommand.php new file mode 100644 index 000000000..d9a7b79e9 --- /dev/null +++ b/framework/Data/Common/Oracle/TOracleDbCommand.php @@ -0,0 +1,243 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common\Oracle; + +use Exception; +use PDO; +use Prado\Data\TDbCommand; +use Prado\Data\TDbDataReader; +use Prado\Exceptions\TDbException; + +/** + * TOracleDbCommand class + * + * TOracleDbCommand is a {@see TDbCommand} specialisation for Oracle (pdo_oci) + * connections. + * + * PHP 8.2 pdo_oci has a known bug: calling {@see PDOStatement::prepare()} or + * {@see PDOStatement::bindParam()} on an oci connection can trigger a + * process-level segfault. The workaround is to skip the prepared-statement + * path entirely and substitute bound values directly into the SQL text via + * {@see PDO::quote()} at execution time. + * + * This class accumulates parameters bound via {@see bindParameter()} / + * {@see bindValue()} in an internal array ({@see $_ociParams}), then + * {@see buildOciSql()} substitutes them at execution time using + * {@see PDO::quote()} before delegating to {@see PDO::query()} / + * {@see PDO::exec()}. + * + * {@see TDbConnection::createCommand()} returns an instance of this class + * automatically for pdo_oci connections. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TOracleDbCommand extends TDbCommand +{ + /** + * @var array Parameter values accumulated via + * {@see bindParameter} or {@see bindValue}. Keys are either named + * placeholders (`:name`) or 1-based integer positions for `?` placeholders. + */ + private array $_ociParams = []; + + // ----------------------------------------------------------------------- + // Serialization — exclude runtime-only state + // ----------------------------------------------------------------------- + + /** + * Excludes the accumulated OCI parameter bindings from serialization; they + * are always empty at the start of a new request and need not be persisted. + * @param array $exprops by reference, list of property names to exclude. + */ + protected function _getZappableSleepProps(&$exprops) + { + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . TOracleDbCommand::class . "\0_ociParams"; + } + + // ----------------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------------- + + /** + * {@inheritdoc} + * Also resets accumulated OCI parameter bindings. + */ + public function cancel() + { + $this->_ociParams = []; + parent::cancel(); + } + + // ----------------------------------------------------------------------- + // OCI SQL builder + // ----------------------------------------------------------------------- + + /** + * Builds the final SQL string by substituting bound values via + * {@see PDO::quote()}, bypassing the prepared-statement path entirely. + * + * Returns null when no parameters have been accumulated (i.e. the caller + * is executing a plain SQL string with no bound parameters), in which case + * the standard {@see PDO::query()} / {@see PDO::exec()} path is used + * without parameter substitution. + * + * Both positional (`?`) and named (`:name`) placeholders are supported. + * NULL values are rendered as the literal SQL NULL. + * + * @return ?string Substituted SQL ready for direct execution, or null + * if no parameters have been bound. + */ + private function buildOciSql(): ?string + { + if ($this->_ociParams === []) { + return null; + } + $pdo = $this->getConnection()->getPdoInstance(); + $sql = $this->getText(); + $firstKey = array_key_first($this->_ociParams); + if (is_int($firstKey)) { + // Positional '?' placeholders — replace left-to-right. + $values = array_values($this->_ociParams); + $i = 0; + $sql = preg_replace_callback('/\?/', static function () use ($pdo, $values, &$i) { + $value = $values[$i++] ?? null; + return $value === null ? 'NULL' : $pdo->quote((string) $value); + }, $sql); + } else { + // Named placeholders (:name) — substitute by name. + foreach ($this->_ociParams as $placeholder => $value) { + $quoted = $value === null ? 'NULL' : $pdo->quote((string) $value); + $sql = str_replace((string) $placeholder, $quoted, $sql); + } + } + return $sql; + } + + // ----------------------------------------------------------------------- + // Parameter binding — accumulate instead of preparing + // ----------------------------------------------------------------------- + + /** + * {@inheritdoc} + * + * For pdo_oci the value is captured at bind time and substituted into the + * SQL via {@see PDO::quote()} at execution time, avoiding the PHP 8.2 + * pdo_oci segfault that occurs in the prepared-statement path. + */ + public function bindParameter($name, &$value, $dataType = null, $length = null) + { + $this->_ociParams[$name] = $value; + } + + /** + * {@inheritdoc} + * + * For pdo_oci the value is captured here and substituted into the SQL via + * {@see PDO::quote()} at execution time. + */ + public function bindValue($name, $value, $dataType = null) + { + $this->_ociParams[$name] = $value; + } + + // ----------------------------------------------------------------------- + // Execution + // ----------------------------------------------------------------------- + + /** + * {@inheritdoc} + * + * When parameters have been accumulated via {@see bindParameter} / + * {@see bindValue}, the SQL is built via {@see buildOciSql()} and executed + * with {@see PDO::exec()}. Otherwise the base implementation is used. + */ + public function execute() + { + if (($ociSql = $this->buildOciSql()) !== null) { + try { + return $this->getConnection()->getPdoInstance()->exec($ociSql); + } catch (Exception $e) { + throw new TDbException('dbcommand_execute_failed', $e->getMessage(), $this->getDebugStatementText()); + } + } + return parent::execute(); + } + + /** + * {@inheritdoc} + * + * When parameters have been accumulated the SQL is built via + * {@see buildOciSql()} and executed with {@see PDO::query()}, assigning + * the resulting {@see PDOStatement} so that {@see TDbDataReader} can + * consume it. Otherwise the base implementation is used. + */ + public function query(): TDbDataReader + { + if (($ociSql = $this->buildOciSql()) !== null) { + try { + $this->setPdoStatement($this->getConnection()->getPdoInstance()->query($ociSql)); + return new TDbDataReader($this); + } catch (Exception $e) { + throw new TDbException('dbcommand_query_failed', $e->getMessage(), $this->getDebugStatementText()); + } + } + return parent::query(); + } + + /** + * {@inheritdoc} + * + * When parameters have been accumulated, builds the OCI SQL, executes it + * with {@see PDO::query()}, fetches the first row, and closes the cursor. + * Otherwise the base implementation is used. + */ + public function queryRow($fetchAssociative = true) + { + if (($ociSql = $this->buildOciSql()) !== null) { + try { + $stmt = $this->getConnection()->getPdoInstance()->query($ociSql); + $result = $stmt->fetch($fetchAssociative ? PDO::FETCH_ASSOC : PDO::FETCH_NUM); + $stmt->closeCursor(); + return $result; + } catch (Exception $e) { + throw new TDbException('dbcommand_query_failed', $e->getMessage(), $this->getDebugStatementText()); + } + } + return parent::queryRow($fetchAssociative); + } + + /** + * {@inheritdoc} + * + * When parameters have been accumulated, builds the OCI SQL, executes it + * with {@see PDO::query()}, fetches the first column of the first row, and + * closes the cursor. Otherwise the base implementation is used. + */ + public function queryScalar() + { + if (($ociSql = $this->buildOciSql()) !== null) { + try { + $stmt = $this->getConnection()->getPdoInstance()->query($ociSql); + $result = $stmt->fetchColumn(); + $stmt->closeCursor(); + if (is_resource($result) && get_resource_type($result) === 'stream') { + return stream_get_contents($result); + } + return $result; + } catch (Exception $e) { + throw new TDbException('dbcommand_query_failed', $e->getMessage(), $this->getDebugStatementText()); + } + } + return parent::queryScalar(); + } +} diff --git a/framework/Data/Common/Oracle/TOracleMetaData.php b/framework/Data/Common/Oracle/TOracleMetaData.php index 7fda744ec..f4ca40e14 100644 --- a/framework/Data/Common/Oracle/TOracleMetaData.php +++ b/framework/Data/Common/Oracle/TOracleMetaData.php @@ -26,7 +26,14 @@ */ class TOracleMetaData extends TDbMetaData { - private $_defaultSchema = 'system'; + /** + * @var ?string Default schema (owner). null = not yet resolved; + * resolved lazily from {@see SELECT USER FROM DUAL} on + * first use so that unquoted table names are found under + * the connected user's schema rather than the hardcoded + * 'system' schema that existed in earlier versions. + */ + private $_defaultSchema; /** @@ -46,10 +53,24 @@ public function setDefaultSchema($schema) } /** - * @return string default schema. + * Returns the default schema (owner) used when no explicit schema is given. + * + * The value is resolved lazily from {@see SELECT USER FROM DUAL} on first + * use so that unquoted table names resolve to the connected user's schema. + * Call {@see setDefaultSchema()} to override before any table lookup. + * + * @return string default schema (lowercase; callers uppercase it for SQL). */ public function getDefaultSchema() { + if ($this->_defaultSchema === null) { + try { + $user = $this->getDbConnection()->createCommand('SELECT USER FROM DUAL')->queryScalar(); + $this->_defaultSchema = $user !== false ? strtolower((string) $user) : 'system'; + } catch (\Exception $e) { + $this->_defaultSchema = 'system'; + } + } return $this->_defaultSchema; } @@ -235,7 +256,7 @@ protected function processColumn($tableInfo, $col) /** * @param mixed $tableInfo * @param mixed $src - * @return null|string serial name if found, null otherwise. + * @return ?string serial name if found, null otherwise. */ protected function getSequenceName($tableInfo, $src) { @@ -387,7 +408,7 @@ public function findTableNames($schema = '') WHERE object_type = 'TABLE' AND owner=:schema EOD; $command = $this->getDbConnection()->createCommand($sql); - $command->bindParameter(':schema', $schema); + $command->bindValue(':schema', $schema); } $rows = $command->query(); diff --git a/framework/Data/Common/Oracle/TOracleTableInfo.php b/framework/Data/Common/Oracle/TOracleTableInfo.php index aa42c031e..8eec5c13c 100644 --- a/framework/Data/Common/Oracle/TOracleTableInfo.php +++ b/framework/Data/Common/Oracle/TOracleTableInfo.php @@ -10,7 +10,7 @@ namespace Prado\Data\Common\Oracle; -use Prado\Data\Common\IDbHasSchema; +use Prado\Data\Common\IDataHasSchema; use Prado\Data\Common\TDbTableInfo; use Prado\Prado; @@ -20,7 +20,7 @@ * @author Wei Zhuo * @since 3.1 */ -class TOracleTableInfo extends TDbTableInfo implements IDbHasSchema +class TOracleTableInfo extends TDbTableInfo implements IDataHasSchema { /** * @return string full name of the table, schema-qualified. diff --git a/framework/Data/Common/Pgsql/TPgsqlCommandBuilder.php b/framework/Data/Common/Pgsql/TPgsqlCommandBuilder.php index 0772ec07b..17c9e5027 100644 --- a/framework/Data/Common/Pgsql/TPgsqlCommandBuilder.php +++ b/framework/Data/Common/Pgsql/TPgsqlCommandBuilder.php @@ -11,7 +11,7 @@ namespace Prado\Data\Common\Pgsql; use Prado\Data\Common\TDbCommandBuilder; -use Prado\Prado; +use Prado\Data\TDbCommand; /** * TPgsqlCommandBuilder provides specifics methods to create limit/offset query commands @@ -22,6 +22,65 @@ */ class TPgsqlCommandBuilder extends TDbCommandBuilder { + /** + * Creates a PostgreSQL INSERT ... ON CONFLICT DO NOTHING command. + * Silently skips the insert when a unique/PK constraint is violated. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + $command = $this->createCommand("INSERT INTO {$table}({$fields}) VALUES ({$bindings}) ON CONFLICT DO NOTHING"); + $this->bindColumnValues($command, $data); + return $command; + } + + /** + * Creates a PostgreSQL INSERT ... ON CONFLICT (pk,...) DO UPDATE SET command. + * On conflict with $conflictColumns (defaults to primary keys), updates $updateData columns + * (defaults to all non-PK columns), referencing the EXCLUDED pseudo-table for new values. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + + // Build ON CONFLICT (pk1, pk2, ...) clause + $conflictParts = []; + foreach ($conflictColumns as $pk) { + $conflictParts[] = $this->getTableInfo()->getColumn($pk)->getColumnName(); + } + $conflictClause = '(' . implode(', ', $conflictParts) . ')'; + + $sql = "INSERT INTO {$table}({$fields}) VALUES ({$bindings}) ON CONFLICT {$conflictClause}"; + + if (!empty($updateData)) { + $updateParts = []; + foreach (array_keys($updateData) as $name) { + $quoted = $this->getTableInfo()->getColumn($name)->getColumnName(); + $updateParts[] = $quoted . ' = EXCLUDED.' . $quoted; + } + $sql .= ' DO UPDATE SET ' . implode(', ', $updateParts); + } else { + $sql .= ' DO NOTHING'; + } + + $command = $this->createCommand($sql); + $this->bindColumnValues($command, $data); + return $command; + } + /** * Overrides parent implementation. Only column of type text or character (and its variants) * accepts the LIKE criteria. diff --git a/framework/Data/Common/Pgsql/TPgsqlMetaData.php b/framework/Data/Common/Pgsql/TPgsqlMetaData.php index bbe5e5b29..e96d60c25 100644 --- a/framework/Data/Common/Pgsql/TPgsqlMetaData.php +++ b/framework/Data/Common/Pgsql/TPgsqlMetaData.php @@ -257,7 +257,7 @@ protected function processColumn($tableInfo, $col) /** * @param TPgsqlTableInfo $tableInfo * @param mixed $src - * @return null|string serial name if found, null otherwise. + * @return ?string serial name if found, null otherwise. */ protected function getSequenceName($tableInfo, $src) { diff --git a/framework/Data/Common/Pgsql/TPgsqlTableInfo.php b/framework/Data/Common/Pgsql/TPgsqlTableInfo.php index a4021aad5..8396fa47c 100644 --- a/framework/Data/Common/Pgsql/TPgsqlTableInfo.php +++ b/framework/Data/Common/Pgsql/TPgsqlTableInfo.php @@ -13,7 +13,7 @@ /** * Loads the base TDbTableInfo class and TPgsqlTableColumn class. */ -use Prado\Data\Common\IDbHasSchema; +use Prado\Data\Common\IDataHasSchema; use Prado\Data\Common\TDbTableInfo; use Prado\Prado; @@ -23,7 +23,7 @@ * @author Wei Zhuo * @since 3.1 */ -class TPgsqlTableInfo extends TDbTableInfo implements IDbHasSchema +class TPgsqlTableInfo extends TDbTableInfo implements IDataHasSchema { /** * @return string full name of the table, database dependent. diff --git a/framework/Data/Common/SqlSrv/TSqlSrvCommandBuilder.php b/framework/Data/Common/SqlSrv/TSqlSrvCommandBuilder.php new file mode 100644 index 000000000..1dadcc016 --- /dev/null +++ b/framework/Data/Common/SqlSrv/TSqlSrvCommandBuilder.php @@ -0,0 +1,215 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common\SqlSrv; + +use Prado\Data\Common\TDbCommandBuilder; +use Prado\Data\TDbCommand; + +/** + * TSqlSrvCommandBuilder class + * + * TSqlSrvCommandBuilder provides specifics methods to create limit/offset query commands + * for SQL Server. + * + * @author Wei Zhuo + * @since 3.1 + */ +class TSqlSrvCommandBuilder extends TDbCommandBuilder +{ + /** + * Creates a SQL Server MERGE ... WHEN NOT MATCHED THEN INSERT command (insertOrIgnore). + * Requires an active transaction; throws TDbException otherwise. + * Uses the MERGE statement since SQL Server has no native INSERT OR IGNORE. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore MERGE command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns(null); + return $this->buildMergeStatement($data, [], $conflictColumns, '', true); + } + + /** + * Creates a SQL Server MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT command. + * Requires an active transaction; throws TDbException otherwise. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @return TDbCommand upsert MERGE command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $this->assertActiveTransaction(); + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + return $this->buildMergeStatement($data, $updateData, $conflictColumns, '', true); + } + + /** + * SQL Server has a ';' at the end of a merge. + * @param string $sql the sql to change before creating the command. + * @return ?string null if no change, or a string if there is a change. + * @since 4.3.3 + */ + protected function postProcessMerge($sql): ?string + { + return $sql . ';'; + } + + /** + * Overrides parent implementation. Uses "SELECT @@Identity". + * @return null|int last insert id, null if none is found. + */ + public function getLastInsertID() + { + foreach ($this->getTableInfo()->getColumns() as $column) { + if ($column->hasSequence()) { + $command = $this->getDbConnection()->createCommand('SELECT @@Identity'); + return (int) ($command->queryScalar()); + } + } + return null; + } + + /** + * Overrides parent implementation. Alters the sql to apply $limit and $offset. + * The idea for limit with offset is done by modifying the sql on the fly + * with numerous assumptions on the structure of the sql string. + * The modification is done with reference to the notes from + * http://troels.arvin.dk/db/rdbms/#select-limit-offset + * + * ```sql + * SELECT * FROM ( + * SELECT TOP n * FROM ( + * SELECT TOP z columns -- (z=n+skip) + * FROM tablename + * ORDER BY key ASC + * ) AS FOO ORDER BY key DESC -- ('FOO' may be anything) + * ) AS BAR ORDER BY key ASC -- ('BAR' may be anything) + * ``` + * + * Regular expressions are used to alter the SQL query. The resulting SQL query + * may be malformed for complex queries. The following restrictions apply + * + *
    + *
  • + * In particular, commas should NOT + * be used as part of the ordering expression or identifier. Commas must only be + * used for separating the ordering clauses. + *
  • + *
  • + * In the ORDER BY clause, the column name should NOT be be qualified + * with a table name or view name. Alias the column names or use column index. + *
  • + *
  • + * No clauses should follow the ORDER BY clause, e.g. no COMPUTE or FOR clauses. + *
  • + *
+ * + * @param string $sql SQL query string. + * @param int $limit maximum number of rows, -1 to ignore limit. + * @param int $offset row offset, -1 to ignore offset. + * @return string SQL with limit and offset. + */ + public function applyLimitOffset($sql, $limit = -1, $offset = -1) + { + $limit = $limit !== null ? (int) $limit : -1; + $offset = $offset !== null ? (int) $offset : -1; + if ($limit > 0 && $offset <= 0) { //just limit + $sql = preg_replace('/^([\s(])*SELECT( DISTINCT)?(?!\s*TOP\s*\()/i', "\\1SELECT\\2 TOP $limit", $sql); + } elseif ($limit > 0 && $offset > 0) { + $sql = $this->rewriteLimitOffsetSql($sql, $limit, $offset); + } + return $sql; + } + + /** + * Rewrite sql to apply $limit > and $offset > 0 for SQL Server database. + * See http://troels.arvin.dk/db/rdbms/#select-limit-offset + * @param string $sql sql query + * @param int $limit > 0 + * @param int $offset > 0 + * @return string sql modified sql query applied with limit and offset. + */ + protected function rewriteLimitOffsetSql($sql, $limit, $offset) + { + $fetch = $limit + $offset; + $sql = preg_replace('/^([\s(])*SELECT( DISTINCT)?(?!\s*TOP\s*\()/i', "\\1SELECT\\2 TOP $fetch", $sql); + $ordering = $this->findOrdering($sql); + + $orginalOrdering = $this->joinOrdering($ordering); + $reverseOrdering = $this->joinOrdering($this->reverseDirection($ordering)); + $sql = "SELECT * FROM (SELECT TOP {$limit} * FROM ($sql) as [__inner top table__] {$reverseOrdering}) as [__outer top table__] {$orginalOrdering}"; + return $sql; + } + + /** + * Base on simplified syntax http://msdn2.microsoft.com/en-us/library/aa259187(SQL.80).aspx + * + * @param string $sql $sql + * @return array ordering expression as key and ordering direction as value + */ + protected function findOrdering($sql) + { + if (!preg_match('/ORDER BY/i', $sql)) { + return []; + } + $matches = []; + $ordering = []; + preg_match_all('/(ORDER BY)[\s"\[](.*)(ASC|DESC)?(?:[\s"\[]|$|COMPUTE|FOR)/i', $sql, $matches); + if (count($matches) > 1 && count($matches[2]) > 0) { + $parts = explode(',', $matches[2][0]); + foreach ($parts as $part) { + $subs = []; + if (preg_match_all('/(.*)[\s"\]](ASC|DESC)$/i', trim($part), $subs)) { + if (count($subs) > 1 && count($subs[2]) > 0) { + $ordering[$subs[1][0]] = $subs[2][0]; + } + //else what? + } else { + $ordering[trim($part)] = 'ASC'; + } + } + } + return $ordering; + } + + /** + * @param array $orders ordering obtained from findOrdering() + * @return string concat the orderings + */ + protected function joinOrdering($orders) + { + if (count($orders) > 0) { + $str = []; + foreach ($orders as $column => $direction) { + $str[] = $column . ' ' . $direction; + } + return 'ORDER BY ' . implode(', ', $str); + } + return ''; + } + + /** + * @param array $orders original ordering + * @return array ordering with reversed direction. + */ + protected function reverseDirection($orders) + { + foreach ($orders as $column => $direction) { + $orders[$column] = strtolower(trim($direction)) === 'desc' ? 'ASC' : 'DESC'; + } + return $orders; + } +} diff --git a/framework/Data/Common/SqlSrv/TSqlSrvMetaData.php b/framework/Data/Common/SqlSrv/TSqlSrvMetaData.php new file mode 100644 index 000000000..ef6a600b9 --- /dev/null +++ b/framework/Data/Common/SqlSrv/TSqlSrvMetaData.php @@ -0,0 +1,319 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common\SqlSrv; + +use Prado\Data\Common\SqlSrv\TSqlSrvTableColumn; +use Prado\Data\Common\SqlSrv\TSqlSrvTableInfo; +use Prado\Data\Common\TDbMetaData; +use Prado\Exceptions\TDbException; + +/** + * TSqlSrvMetaData class + * + * TSqlSrvMetaData loads SQL Server database table and column information. + * + * @author Wei Zhuo + * @since 3.1 + */ +class TSqlSrvMetaData extends TDbMetaData +{ + public const DEFAULT_SCHEMA = 'dbo'; + + /** + * @return string TDbTableInfo class name. + */ + protected function getTableInfoClass() + { + return TSqlSrvTableInfo::class; + } + + /** + * Quotes a table name for use in a query. + * @param string $name $name table name + * @return string the properly quoted table name + */ + public function quoteTableName($name) + { + return parent::quoteTableName($name, '[', ']'); + } + + /** + * Quotes a column name for use in a query. + * @param string $name $name column name + * @return string the properly quoted column name + */ + public function quoteColumnName($name) + { + return parent::quoteColumnName($name, '[', ']'); + } + + /** + * Quotes a column alias for use in a query. + * @param string $name $name column alias + * @return string the properly quoted column alias + */ + public function quoteColumnAlias($name) + { + return parent::quoteColumnAlias($name, '"', '"'); + } + + /** + * Get the column definitions for given table. + * @param string $table table name. + * @return TSqlSrvTableInfo table information. + */ + protected function createTableInfo($table) + { + [$catalogName, $schemaName, $tableName] = $this->getCatalogSchemaTableName($table); + $this->getDbConnection()->setActive(true); + $sql = +<<getDbConnection()->createCommand($sql); + $command->bindValue(':table', $tableName); + if ($schemaName !== null) { + $command->bindValue(':schema', $schemaName); + } + if ($catalogName !== null) { + $command->bindValue(':catalog', $catalogName); + } + + $tableInfo = null; + foreach ($command->query() as $col) { + if ($tableInfo === null) { + $tableInfo = $this->createNewTableInfo($col); + } + $this->processColumn($tableInfo, $col); + } + if ($tableInfo === null) { + throw new TDbException('dbmetadata_invalid_table_view', $table); + } + return $tableInfo; + } + + /** + * @param string $table table name + * @return array tuple($catalogName,$schemaName,$tableName) + */ + protected function getCatalogSchemaTableName($table) + { + //remove possible delimiters + $result = explode('.', preg_replace('/\[|\]|"/', '', $table)); + if (count($result) === 1) { + return [null, null, $result[0]]; + } + if (count($result) === 2) { + return [null, $result[0], $result[1]]; + } + if (count($result) > 2) { + return [$result[0], $result[1], $result[2]]; + } + return [$result[0], $result[1], $result[2]]; + } + + /** + * @param TSqlSrvTableInfo $tableInfo table information. + * @param array $col column information. + */ + protected function processColumn($tableInfo, $col) + { + $columnId = $col['COLUMN_NAME']; + + $info['ColumnName'] = "[$columnId]"; //quote the column names! + $info['ColumnId'] = $columnId; + $info['ColumnIndex'] = (int) ($col['ORDINAL_POSITION']) - 1; //zero-based index + if ($col['IS_NULLABLE'] !== 'NO') { + $info['AllowNull'] = true; + } + if ($col['COLUMN_DEFAULT'] !== null) { + $info['DefaultValue'] = $col['COLUMN_DEFAULT']; + } + + if (in_array($columnId, $tableInfo->getPrimaryKeys())) { + $info['IsPrimaryKey'] = true; + } + if ($this->isForeignKeyColumn($columnId, $tableInfo)) { + $info['IsForeignKey'] = true; + } + + if ($col['IsIdentity'] === '1') { + $info['AutoIncrement'] = true; + } + $info['DbType'] = $col['DATA_TYPE']; + if ($col['CHARACTER_MAXIMUM_LENGTH'] !== null) { + $info['ColumnSize'] = (int) ($col['CHARACTER_MAXIMUM_LENGTH']); + } + if ($col['NUMERIC_PRECISION'] !== null) { + $info['NumericPrecision'] = (int) ($col['NUMERIC_PRECISION']); + } + if ($col['NUMERIC_SCALE'] !== null) { + $info['NumericScale'] = (int) ($col['NUMERIC_SCALE']); + } + $tableInfo->getColumns()[$columnId] = new TSqlSrvTableColumn($info); + } + + /** + * @param array $col table informations + * @return TSqlSrvTableInfo + */ + protected function createNewTableInfo($col) + { + $info['CatalogName'] = $col['TABLE_CATALOG']; + $info['SchemaName'] = $col['TABLE_SCHEMA']; + $info['TableName'] = $col['TABLE_NAME']; + if ($col['TABLE_TYPE'] === 'VIEW') { + $info['IsView'] = true; + } + [$primary, $foreign] = $this->getConstraintKeys($col); + $class = $this->getTableInfoClass(); + return new $class($info, $primary, $foreign); + } + + /** + * Gets the primary and foreign key column details for the given table. + * @param array $col table informations + * @return array tuple ($primary, $foreign) + */ + protected function getConstraintKeys($col) + { + $sql = +<<getDbConnection()->createCommand($sql); + $command->bindValue(':table', $col['TABLE_NAME']); + $primary = []; + foreach ($command->query()->readAll() as $field) { + $primary[] = $field['field_name']; + } + $foreign = $this->getForeignConstraints($col); + return [$primary, $foreign]; + } + + /** + * Gets foreign relationship constraint keys and table name + * @param array $col table informations + * @return array foreign relationship table name and keys. + */ + protected function getForeignConstraints($col) + { + //From http://msdn2.microsoft.com/en-us/library/aa175805(SQL.80).aspx + $sql = +<<getDbConnection()->createCommand($sql); + $command->bindValue(':table', $col['TABLE_NAME']); + $fkeys = []; + $catalogSchema = "[{$col['TABLE_CATALOG']}].[{$col['TABLE_SCHEMA']}]"; + foreach ($command->query() as $info) { + $fkeys[$info['FK_CONSTRAINT_NAME']]['keys'][$info['FK_COLUMN_NAME']] = $info['UQ_COLUMN_NAME']; + $fkeys[$info['FK_CONSTRAINT_NAME']]['table'] = $info['UQ_TABLE_NAME']; + } + return count($fkeys) > 0 ? array_values($fkeys) : $fkeys; + } + + /** + * @param string $columnId column name. + * @param TSqlSrvTableInfo $tableInfo table information. + * @return bool true if column is a foreign key. + */ + protected function isForeignKeyColumn($columnId, $tableInfo) + { + foreach ($tableInfo->getForeignKeys() as $fk) { + if (in_array($columnId, array_keys($fk['keys']))) { + return true; + } + } + return false; + } + + /** + * Returns all table names in the database. + * @param string $schema the schema of the tables. Defaults to empty string, meaning the current or default schema. + * If not empty, the returned table names will be prefixed with the schema name. + * @return array all table names in the database. + */ + public function findTableNames($schema = 'dbo') + { + $condition = "TABLE_TYPE='BASE TABLE'"; + $sql = +<<getDbConnection()->createCommand($sql); + $command->bindParameter(":schema", $schema); + $rows = $command->query(); + $names = []; + foreach ($rows as $row) { + if ($schema == self::DEFAULT_SCHEMA) { + $names[] = $row['TABLE_NAME']; + } else { + $names[] = $schema . '.' . $row['TABLE_SCHEMA'] . '.' . $row['TABLE_NAME']; + } + } + + return $names; + } +} diff --git a/framework/Data/Common/SqlSrv/TSqlSrvTableColumn.php b/framework/Data/Common/SqlSrv/TSqlSrvTableColumn.php new file mode 100644 index 000000000..bda701e0e --- /dev/null +++ b/framework/Data/Common/SqlSrv/TSqlSrvTableColumn.php @@ -0,0 +1,63 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common\SqlSrv; + +/** + * Load common TDbTableCommon class. + */ +use Prado\Data\Common\TDbTableColumn; +use Prado\Prado; + +/** + * TSqlSrvTableColumn class + * + * Describes the column metadata of the schema for a SqlSrv database table. + * + * @author Wei Zhuo + * @since 3.1 + */ +class TSqlSrvTableColumn extends TDbTableColumn +{ + private static $types = []; + + /** + * Overrides parent implementation, returns PHP type from the db type. + * @return bool derived PHP primitive type from the column db type. + */ + public function getPHPType() + { + return 'string'; + } + + /** + * @return bool true if the column has identity (auto-increment) + */ + public function getAutoIncrement() + { + return $this->getInfo('AutoIncrement', false); + } + + /** + * @return bool true if auto increments. + */ + public function hasSequence() + { + return $this->getAutoIncrement(); + } + + /** + * @return bool true if db type is 'timestamp'. + */ + public function getIsExcluded() + { + return strtolower($this->getDbType()) === 'timestamp'; + } +} diff --git a/framework/Data/Common/SqlSrv/TSqlSrvTableInfo.php b/framework/Data/Common/SqlSrv/TSqlSrvTableInfo.php new file mode 100644 index 000000000..aae3ab051 --- /dev/null +++ b/framework/Data/Common/SqlSrv/TSqlSrvTableInfo.php @@ -0,0 +1,52 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\Common\SqlSrv; + +use Prado\Data\Common\IDataHasSchema; +use Prado\Data\Common\SqlSrv\TSqlSrvCommandBuilder; +use Prado\Data\Common\TDbTableInfo; + +/** + * TSqlSrvTableInfo class + * + * TSqlSrvTableInfo class provides additional table information for SqlSrv database. + * + * @author Wei Zhuo + * @since 3.1 + */ +class TSqlSrvTableInfo extends TDbTableInfo implements IDataHasSchema +{ + /** + * @return string catalog name (database name) + */ + public function getCatalogName() + { + return $this->getInfo('CatalogName'); + } + + /** + * @return string full name of the table, database dependent. + */ + public function getTableFullName() + { + //SQL Server always returns the catalog, schema and table names. + return '[' . $this->getCatalogName() . '].[' . $this->getSchemaName() . '].[' . $this->getTableName() . ']'; + } + + /** + * @param \Prado\Data\TDbConnection $connection database connection. + * @return \Prado\Data\Common\TDbCommandBuilder new command builder + */ + public function createCommandBuilder($connection) + { + return new TSqlSrvCommandBuilder($connection, $this); + } +} diff --git a/framework/Data/Common/Sqlite/TSqliteCommandBuilder.php b/framework/Data/Common/Sqlite/TSqliteCommandBuilder.php index 144df90ba..0fba78291 100644 --- a/framework/Data/Common/Sqlite/TSqliteCommandBuilder.php +++ b/framework/Data/Common/Sqlite/TSqliteCommandBuilder.php @@ -11,17 +11,80 @@ namespace Prado\Data\Common\Sqlite; use Prado\Data\Common\TDbCommandBuilder; -use Prado\Prado; +use Prado\Data\TDbCommand; /** - * TSqliteCommandBuilder provides specifics methods to create limit/offset query commands - * for Sqlite database. + * TSqliteCommandBuilder class + * + * TSqliteCommandBuilder provides SQLite-specific methods to create query + * commands, including LIMIT/OFFSET, ORDER BY, INSERT OR IGNORE, and UPSERT. * * @author Wei Zhuo * @since 3.1 */ class TSqliteCommandBuilder extends TDbCommandBuilder { + /** + * Creates a SQLite INSERT OR IGNORE command. + * Silently skips the insert when a unique/PK constraint is violated. + * @param array $data name-value pairs of data to be inserted. + * @return TDbCommand insert-or-ignore command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + $command = $this->createCommand("INSERT OR IGNORE INTO {$table}({$fields}) VALUES ({$bindings})"); + $this->bindColumnValues($command, $data); + return $command; + } + + /** + * Creates a SQLite INSERT ... ON CONFLICT(pk,...) DO UPDATE SET command. + * On conflict with $conflictColumns (defaults to primary keys), updates + * $updateData columns (defaults to all non-PK columns), referencing the + * excluded pseudo-table for new values. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; + * null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; + * null = primary key columns. + * @return TDbCommand upsert command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + $conflictColumns = $this->resolveConflictColumns($conflictColumns); + $updateData = $this->resolveUpdateData($data, $updateData, $conflictColumns); + + $table = $this->getTableInfo()->getTableFullName(); + [$fields, $bindings] = $this->getInsertFieldBindings($data); + + $conflictParts = []; + foreach ($conflictColumns as $pk) { + $conflictParts[] = $this->getTableInfo()->getColumn($pk)->getColumnName(); + } + $conflictClause = '(' . implode(', ', $conflictParts) . ')'; + + $sql = "INSERT INTO {$table}({$fields}) VALUES ({$bindings}) ON CONFLICT{$conflictClause}"; + + if (!empty($updateData)) { + $updateParts = []; + foreach (array_keys($updateData) as $name) { + $quoted = $this->getTableInfo()->getColumn($name)->getColumnName(); + $updateParts[] = $quoted . ' = excluded.' . $quoted; + } + $sql .= ' DO UPDATE SET ' . implode(', ', $updateParts); + } else { + $sql .= ' DO NOTHING'; + } + + $command = $this->createCommand($sql); + $this->bindColumnValues($command, $data); + return $command; + } + /** * Alters the sql to apply $limit and $offset. * @param string $sql SQL query string. diff --git a/framework/Data/Common/Sqlite/TSqliteMetaData.php b/framework/Data/Common/Sqlite/TSqliteMetaData.php index 9face8ecc..68effa131 100644 --- a/framework/Data/Common/Sqlite/TSqliteMetaData.php +++ b/framework/Data/Common/Sqlite/TSqliteMetaData.php @@ -35,12 +35,16 @@ protected function getTableInfoClass() /** * Quotes a table name for use in a query. + * SQLite uses double-quote delimiters for identifiers (SQL standard). + * Single-quote delimiters produce string literals, which cause + * SQLITE_RANGE (error 25) when ORDER BY references quoted column names + * against a single-quoted table source. * @param string $name $name table name * @return string the properly quoted table name */ public function quoteTableName($name) { - return parent::quoteTableName($name, "'", "'"); + return parent::quoteTableName($name, '"', '"'); } /** diff --git a/framework/Data/Common/Sqlite/TSqliteTableInfo.php b/framework/Data/Common/Sqlite/TSqliteTableInfo.php index f83a1d42e..fc5495dff 100644 --- a/framework/Data/Common/Sqlite/TSqliteTableInfo.php +++ b/framework/Data/Common/Sqlite/TSqliteTableInfo.php @@ -10,13 +10,11 @@ namespace Prado\Data\Common\Sqlite; -/** - * Loads the base TDbTableInfo class and TSqliteTableColumn class. - */ use Prado\Data\Common\TDbTableInfo; -use Prado\Prado; /** + * TSqliteTableInfo class + * * TSqliteTableInfo class provides additional table information for PostgreSQL database. * * @author Wei Zhuo @@ -26,10 +24,14 @@ class TSqliteTableInfo extends TDbTableInfo { /** * @return string full name of the table, database dependent. + * Double-quote delimiters are used (SQL standard identifier quoting). + * Single-quotes are string literals in SQL and cause SQLITE_RANGE (error 25) + * when ORDER BY references double-quoted column names against a + * single-quoted table source. */ public function getTableFullName() { - return "'" . $this->getTableName() . "'"; + return '"' . $this->getTableName() . '"'; } /** diff --git a/framework/Data/Common/TDbCommandBuilder.php b/framework/Data/Common/TDbCommandBuilder.php index 6a30fd5da..fa9b59fe2 100644 --- a/framework/Data/Common/TDbCommandBuilder.php +++ b/framework/Data/Common/TDbCommandBuilder.php @@ -13,15 +13,90 @@ use PDO; use Traversable; use Prado\Data\TDbCommand; +use Prado\Exceptions\TDbException; /** - * TDbCommandBuilder provides basic methods to create query commands for tables - * giving by {@see setTableInfo TableInfo} the property. + * TDbCommandBuilder class + * + * TDbCommandBuilder is the base class for SQL command builders that generate + * {@see TDbCommand} objects for CRUD operations on a single database table. + * + * Each instance is bound to a {@see TDbConnection} and a {@see TDbTableInfo} + * that describes the target table. The builder consults the column metadata to + * quote identifiers correctly and to bind parameter values with the right PDO + * type. Instances are obtained via {@see TDbMetaData::createCommandBuilder()} + * or {@see TDbTableInfo::createCommandBuilder()}. + * + * ## Command factory methods + * + * | Method | SQL generated | + * |---------------------------------------|--------------------------------------------------| + * | {@see createFindCommand()} | `SELECT … FROM … WHERE … ORDER BY … LIMIT …` | + * | {@see createCountCommand()} | `SELECT COUNT(*) FROM … WHERE …` | + * | {@see createInsertCommand()} | `INSERT INTO … (cols) VALUES (:cols)` | + * | {@see createUpdateCommand()} | `UPDATE … SET col = :col … WHERE …` | + * | {@see createDeleteCommand()} | `DELETE FROM … WHERE …` | + * | {@see createInsertOrIgnoreCommand()} | driver-specific; base throws {@see TDbException} | + * | {@see createUpsertCommand()} | driver-specific; base throws {@see TDbException} | + * + * {@see applyCriterias()} is the central assembly method: it applies ORDER BY + * via {@see applyOrdering()}, LIMIT/OFFSET via {@see applyLimitOffset()}, and + * then binds parameters via {@see bindArrayValues()}. + * + * ## Driver-specific subclasses + * + * Subclasses override only the methods that differ from the ANSI SQL baseline: + * + * - {@see applyLimitOffset()} — SQL Server uses `TOP` / `OFFSET … FETCH NEXT`; + * Oracle wraps in a `ROWNUM` subquery. + * - {@see createInsertOrIgnoreCommand()} — MySQL (`INSERT IGNORE`), SQLite / + * PostgreSQL (`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`); MERGE-based + * drivers use {@see buildMergeStatement()} with an empty update set. + * - {@see createUpsertCommand()} — MySQL (`ON DUPLICATE KEY UPDATE`), SQLite / + * PostgreSQL (`ON CONFLICT … DO UPDATE`); MERGE-based drivers use + * {@see buildMergeStatement()}. + * + * ## MERGE helper (SQL Server / Oracle / Firebird / IBM DB2) + * + * {@see buildMergeStatement()} assembles a portable + * `MERGE INTO … USING (SELECT …) ON … WHEN MATCHED … WHEN NOT MATCHED …` + * statement. Subclasses tune its output via two extension hooks: + * + * - {@see processMergeColumn()} — controls the `:col AS col` fragment in the + * USING sub-select (e.g. Oracle uses positional `? AS col` bindings). + * - {@see postProcessMerge()} — post-processes the assembled SQL string before + * the command is created (e.g. SQL Server appends a semicolon). + * + * MERGE-based upserts always require an active transaction; call + * {@see assertActiveTransaction()} at the start of those overrides. + * + * ## Parameter binding + * + * Two binding helpers are provided: + * + * - {@see bindColumnValues()} — binds a column-name → value map using each + * column's declared PDO type from the table metadata; uses `PDO::PARAM_NULL` + * for `null` values on nullable columns. + * - {@see bindArrayValues()} — binds a plain value array; if any key is an + * integer the array is treated as positional (`?` placeholders, 1-based), + * otherwise as named (`:name` placeholders). The PHP value type is inferred + * via {@see \Prado\Data\TDbCommand::getColumnTypeFromValue()}. + * + * ## SELECT field list + * + * {@see getSelectFieldList()} resolves the `$select` argument of + * {@see createFindCommand()} into an array of SQL column expressions: + * + * - **`'*'` or comma-separated string** — returned as-is (split on commas). + * - **`null`** — expands to all quoted column names from the table metadata. + * - **array** — supports column aliasing, computed expressions (`COUNT(*)`), + * literal values, and the `'*'` wildcard to mix explicit columns with the + * full column list. * * @author Wei Zhuo * @since 3.1 */ -class TDbCommandBuilder extends \Prado\TComponent +class TDbCommandBuilder extends \Prado\TComponent implements IDataCommandBuilder { private $_connection; private $_tableInfo; @@ -308,14 +383,14 @@ public function getSelectFieldList($data = '*') } /** - * Appends the $where condition to the string "SELECT * FROM tableName WHERE ". - * The tableName is obtained from the {@see setTableInfo TableInfo} property. - * @param string $where query condition + * Creates a SELECT command for the table. + * The table name is obtained from the {@see setTableInfo TableInfo} property. + * @param string $where query condition. * @param array $parameters condition parameters. - * @param array $ordering - * @param int $limit - * @param int $offset - * @param string $select + * @param array $ordering ORDER BY clause. + * @param int $limit maximum rows. + * @param int $offset row offset. + * @param string $select columns to select. * @return TDbCommand query command. */ public function createFindCommand($where = '1=1', $parameters = [], $ordering = [], $limit = -1, $offset = -1, $select = '*') @@ -329,6 +404,15 @@ public function createFindCommand($where = '1=1', $parameters = [], $ordering = return $this->applyCriterias($sql, $parameters, $ordering, $limit, $offset); } + /** + * Applies ordering, limit, and offset to the SQL and binds parameters. + * @param string $sql SQL query. + * @param array $parameters binding parameters. + * @param array $ordering ORDER BY clause. + * @param int $limit maximum rows. + * @param int $offset row offset. + * @return TDbCommand command with criteria applied. + */ public function applyCriterias($sql, $parameters = [], $ordering = [], $limit = -1, $offset = -1) { if (count($ordering) > 0) { @@ -343,12 +427,12 @@ public function applyCriterias($sql, $parameters = [], $ordering = [], $limit = } /** - * Creates a count(*) command for the table described in {@see setTableInfo TableInfo}. + * Creates a COUNT(*) command for the table. * @param string $where count condition. * @param array $parameters binding parameters. - * @param array $ordering - * @param int $limit - * @param int $offset + * @param array $ordering ORDER BY clause. + * @param int $limit maximum rows. + * @param int $offset row offset. * @return TDbCommand count command. */ public function createCountCommand($where = '1=1', $parameters = [], $ordering = [], $limit = -1, $offset = -1) @@ -392,6 +476,162 @@ public function createInsertCommand($data) return $command; } + /** + * Creates an INSERT OR IGNORE command for the table. + * Base implementation always throws TDbException; driver-specific subclasses must override. + * @param array $data name-value pairs of data to be inserted. + * @throws TDbException always, in the base implementation. + * @return TDbCommand insert-or-ignore command. + * @since 4.3.3 + */ + public function createInsertOrIgnoreCommand(array $data): TDbCommand + { + throw new TDbException('dbcommandbuilder_insertorignore_not_supported'); + } + + /** + * Creates an UPSERT (insert-or-update) command for the table. + * Base implementation always throws TDbException; driver-specific subclasses must override. + * @param array $data name-value pairs of data to insert. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns from $data. + * @param null|array $conflictColumns conflict target columns; null = primary key columns. + * @throws TDbException always, in the base implementation. + * @return TDbCommand upsert command. + * @since 4.3.3 + */ + public function createUpsertCommand(array $data, ?array $updateData = null, ?array $conflictColumns = null): TDbCommand + { + throw new TDbException('dbcommandbuilder_upsert_not_supported'); + } + + /** + * Resolves the conflict columns, defaulting to the table's primary keys. + * @param null|array $conflictColumns explicit conflict columns, or null to use primary keys. + * @return array resolved conflict column names. + * @since 4.3.3 + */ + protected function resolveConflictColumns(?array $conflictColumns): array + { + return $conflictColumns ?? $this->getTableInfo()->getPrimaryKeys(); + } + + /** + * Resolves the update data for upsert, defaulting to all non-PK columns from $data. + * @param array $data full insert data. + * @param null|array $updateData explicit update data, or null to use all non-PK columns. + * @param array $conflictColumns the resolved conflict columns (primary keys). + * @return array resolved update data. + * @since 4.3.3 + */ + protected function resolveUpdateData(array $data, ?array $updateData, array $conflictColumns): array + { + return $updateData ?? array_diff_key($data, array_flip($conflictColumns)); + } + + /** + * Checks that an active transaction exists on the current connection. + * Called by MERGE-based drivers (SQL Server, Oracle, DB2, Firebird) before building MERGE statements. + * @throws TDbException if no active transaction is found. + * @since 4.3.3 + */ + protected function assertActiveTransaction(): void + { + if ($this->getDbConnection()->getCurrentTransaction() === null) { + throw new TDbException('dbcommandbuilder_upsert_requires_transaction', $this::class); + } + } + + /** + * Builds a MERGE INTO statement for MERGE-based drivers (SQL Server, Oracle, DB2, Firebird). + * + * The USING SELECT uses raw column names (from array_keys($data)) as aliases. + * Table column references use getColumnName() (quoted) from the table metadata. + * When $updateData is empty, the WHEN MATCHED branch is omitted (insertOrIgnore behaviour). + * + * @param array $data full row data (all columns). + * @param array $updateData columns to update on match (empty = insertOrIgnore, no UPDATE branch). + * @param array $conflictColumns primary/conflict key column names. + * @param string $dualSource dual/dummy table source, e.g. 'FROM DUAL', 'FROM SYSIBM.SYSDUMMY1', '' for SQL Server. + * @param bool $useAsAlias true to emit 'AS t'/'AS s'; false to emit bare 't'/'s' (Oracle, Firebird). + * @return TDbCommand prepared MERGE command with bound parameters. + * @since 4.3.3 + */ + protected function buildMergeStatement(array $data, array $updateData, array $conflictColumns, string $dualSource, bool $useAsAlias): TDbCommand + { + $table = $this->getTableInfo()->getTableFullName(); + $tableAlias = $useAsAlias ? 'AS t' : 't'; + $sourceAlias = $useAsAlias ? 'AS s' : 's'; + + // To build: SELECT :col1 AS col1, :col2 AS col2, ... [FROM dual] + $usingParts = []; + foreach (array_keys($data) as $name) { + $usingParts[] = $this->processMergeColumn($name); + } + $usingSelect = 'SELECT ' . implode(', ', $usingParts); + if ($dualSource !== '') { + $usingSelect .= ' ' . $dualSource; + } + + // Build ON clause: t.{pk_quoted} = s.pk AND ... + $onParts = []; + foreach ($conflictColumns as $pk) { + $quoted = $this->getTableInfo()->getColumn($pk)->getColumnName(); + $onParts[] = 't.' . $quoted . ' = s.' . $pk; + } + $onClause = implode(' AND ', $onParts); + + // Build MERGE statement + $sql = "MERGE INTO {$table} {$tableAlias} USING ({$usingSelect}) {$sourceAlias} ON ({$onClause})"; + + // WHEN MATCHED branch (omit for insertOrIgnore when $updateData is empty) + if (!empty($updateData)) { + $updateParts = []; + foreach (array_keys($updateData) as $name) { + $quoted = $this->getTableInfo()->getColumn($name)->getColumnName(); + $updateParts[] = 't.' . $quoted . ' = s.' . $name; + } + $sql .= ' WHEN MATCHED THEN UPDATE SET ' . implode(', ', $updateParts); + } + + // WHEN NOT MATCHED branch + $insertCols = []; + $insertVals = []; + foreach (array_keys($data) as $name) { + $insertCols[] = $this->getTableInfo()->getColumn($name)->getColumnName(); + $insertVals[] = 's.' . $name; + } + $sql .= ' WHEN NOT MATCHED THEN INSERT (' . implode(', ', $insertCols) . ') VALUES (' . implode(', ', $insertVals) . ')'; + + if (($newSql = $this->postProcessMerge($sql)) !== null) { + $sql = $newSql; + } + $command = $this->createCommand($sql); + $this->bindColumnValues($command, $data); + return $command; + } + + /** + * Children override this if there is something specific about the column Name. + * @param string $columnName The name of the column to place in the sql. + * @return string null if no change, or a string if there is a change. + * @since 4.3.3 + */ + protected function processMergeColumn(string $columnName): string + { + return ':' . $columnName . ' AS ' . $columnName; + } + + /** + * Children override this if there is something specific about the sql, eg adding a ';' to the end for SQL Server. + * @param string $sql the sql to change before creating the command. + * @return ?string null if no change, or a string if there is a change. + * @since 4.3.3 + */ + protected function postProcessMerge(string $sql): ?string + { + return null; + } + /** * Creates an update command for the table described in {@see setTableInfo TableInfo} for the given data. * Each array key in the $data array must correspond to the column name to be updated with the corresponding array value. @@ -485,19 +725,23 @@ public function bindArrayValues($command, $values) if ($this->hasIntegerKey($values)) { $values = array_values($values); for ($i = 0, $max = count($values); $i < $max; $i++) { - $command->bindValue($i + 1, $values[$i], $this->getPdoType($values[$i])); + $command->bindValue($i + 1, $values[$i], $command->getColumnTypeFromValue($values[$i])); } } else { foreach ($values as $name => $value) { $prop = $name[0] === ':' ? $name : ':' . $name; - $command->bindValue($prop, $value, $this->getPdoType($value)); + $command->bindValue($prop, $value, $command->getColumnTypeFromValue($value)); } } } /** + * Maps a PHP value's runtime type to the corresponding PDO parameter-type constant. + * * @param mixed $value PHP value - * @return null|int PDO parameter types. + * @return null|int PDO::PARAM_* constant, or null for unconvertible types. + * @deprecated since 4.3.3 — use {@see \Prado\Data\TDbCommand::getColumnTypeFromValue()} instead. + * @todo 4.4 — remove this static method. */ public static function getPdoType($value) { diff --git a/framework/Data/Common/TDbMetaData.php b/framework/Data/Common/TDbMetaData.php index 7a744ae1a..d88b2fe02 100644 --- a/framework/Data/Common/TDbMetaData.php +++ b/framework/Data/Common/TDbMetaData.php @@ -10,39 +10,76 @@ namespace Prado\Data\Common; -use Prado\Data\Common\Firebird\TFirebirdMetaData; -use Prado\Data\Common\Ibm\TIbmMetaData; -use Prado\Data\Common\Mssql\TMssqlMetaData; -use Prado\Data\Common\Mysql\TMysqlMetaData; -use Prado\Data\Common\Oracle\TOracleMetaData; -use Prado\Data\Common\Pgsql\TPgsqlMetaData; -use Prado\Data\Common\Sqlite\TSqliteMetaData; +use Prado\Data\IDataConnection; +use Prado\Data\TDbConnection; +use Prado\Data\TDbDriverCapabilities; use Prado\Exceptions\TDbException; use Prado\Prado; /** - * TDbMetaData is the base class for retrieving metadata information, such as - * table and columns information, from a database connection. + * TDbMetaData class * - * This class provides the foundation for database-specific metadata implementations - * (e.g., TMysqlMetaData, TSqliteMetaData, TPgsqlMetaData, etc.) that retrieve - * table and column information from the database. + * TDbMetaData is the abstract base class for all driver-specific database + * metadata handlers. * - * The metadata instances are created via the static {@see getInstance} method which - * determines the appropriate metadata handler based on the database driver. When no built-in driver - * matches, the {@see fxDataGetMetaDataInstance()} global event is raised to allow - * for extensibility through custom implementations. + * A metadata handler interrogates a live {@see IDataConnection} and returns + * structured {@see TDbTableInfo} objects that describe tables, views, and their + * columns. It also provides identifier-quoting helpers and a factory for + * {@see TDbCommandBuilder} instances. * - * Example usage: - * ```php - * $metaData = TDbMetaData::getInstance($connection); - * $tableInfo = $metaData->getTableInfo('my_table'); - * ``` + * ## Driver selection + * + * {@see getInstance()} is the normal entry point. It activates the connection, + * reads the driver name, and delegates to + * {@see TDbDriverCapabilities::getMetaDataClass()} to resolve the matching + * concrete class. Built-in drivers and their metadata classes: + * + * | PDO driver | Metadata class | + * |------------------|------------------------| + * | `mysql` | `TMysqlMetaData` | + * | `sqlite` | `TSqliteMetaData` | + * | `pgsql` | `TPgsqlMetaData` | + * | `sqlsrv`, `dblib`| `TSqlSrvMetaData` | + * | `oci` | `TOracleMetaData` | + * | `ibm`/`db2` | `TIbmMetaData` | + * | `firebird` | `TFirebirdMetaData` | + * + * When no built-in driver matches, the **`fxDataGetMetaDataClass`** global event + * is raised on the connection so that third-party extensions can supply a + * custom handler class. + * + * ## Table-info caching + * + * {@see getTableInfo()} caches each resolved {@see TDbTableInfo} in a + * per-instance array for the lifetime of the metadata object, keyed by table + * name. Passing `null` as the table name uses the connection string as the + * cache key and returns an empty table-info object (used in schema-less + * introspection scenarios). + * + * ## Identifier quoting + * + * {@see quoteTableName()}, {@see quoteColumnName()}, and + * {@see quoteColumnAlias()} strip any pre-existing quote characters from the + * `$delimiterIdentifier` set (`` ` ``, `"`, `'`, `[`, `]`) before wrapping the + * name in the driver-specific delimiters. Subclasses pass their delimiter pair + * as the second and third arguments; the base signatures receive them via + * `func_get_args()` for backward compatibility. + * + * ## Subclass contract + * + * Concrete subclasses must implement: + * - {@see createTableInfo()} — query the live schema and build a fully + * populated {@see TDbTableInfo} with all column objects added. + * - {@see findTableNames()} — return all table names for a given schema. + * + * They may also override {@see getTableInfoClass()} to return their driver's + * {@see TDbTableInfo} subclass name, which {@see getTableInfo()} instantiates + * when called with `null`. * * @author Wei Zhuo * @since 3.1 */ -abstract class TDbMetaData extends \Prado\TComponent +abstract class TDbMetaData extends \Prado\TComponent implements IDataMetaData { private $_tableInfoCache = []; private $_connection; @@ -53,7 +90,7 @@ abstract class TDbMetaData extends \Prado\TComponent protected static $delimiterIdentifier = ['[', ']', '"', '`', "'"]; /** - * @param \Prado\Data\TDbConnection $conn database connection. + * @param \Prado\Data\IDataConnection $conn database connection. */ public function __construct($conn) { @@ -62,7 +99,7 @@ public function __construct($conn) } /** - * @return \Prado\Data\TDbConnection database connection. + * @return \Prado\Data\IDataConnection database connection. */ public function getDbConnection() { @@ -70,11 +107,13 @@ public function getDbConnection() } /** - * Obtains a database-specific TDbMetaData class based on the database connection driver. + * Obtains a driver-specific TDbMetaData instance for the given connection. * - * This method determines the appropriate metadata handler for the given database driver. - * If no built-in driver is found, the {@see fxDataGetMetaDataInstance} global event - * is raised to allow custom implementations to provide a metadata handler. + * This method activates the connection, resolves the driver name, and delegates to + * {@see TDbDriverCapabilities::getMetaDataClass()} to find the matching handler class. + * If no built-in driver is found, the **`fxDataGetMetaDataClass`** global event is + * raised on the connection (with the driver name as the parameter) to allow + * third-party extensions to supply a custom metadata handler class. * * @param \Prado\Data\TDbConnection $conn database connection. * @throws TDbException if no metadata handler can be created for the driver. @@ -83,43 +122,20 @@ public function getDbConnection() public static function getInstance($conn) { $conn->setActive(true); //must be connected before retrieving driver name - $driver = $conn->getDriverName(); - switch (strtolower($driver)) { - case 'pgsql': - return new TPgsqlMetaData($conn); - case 'mysqli': - case 'mysql': - return new TMysqlMetaData($conn); - case 'sqlite': //sqlite 3 - case 'sqlite2': //sqlite 2 - return new TSqliteMetaData($conn); - case 'mssql': // Mssql driver on windows hosts - case 'sqlsrv': // sqlsrv driver on windows hosts - case 'dblib': // dblib drivers on linux (and maybe others os) hosts - return new TMssqlMetaData($conn); - case 'oci': - return new TOracleMetaData($conn); - case 'ibm': - return new TIbmMetaData($conn); - case 'firebird': - case 'interbase': - return new TFirebirdMetaData($conn); - default: - $instances = $conn->raiseEvent('fxDataGetMetaDataInstance', self::class, $conn); - if (empty($instances)) { - throw new TDbException('dbmetadata_invalid_database_driver', $driver); - } - $metaData = $instances[0]; - if ($metaData instanceof static) { - throw new TDbException('dbmetadata_not_meta_data', $metaData::class, static::class); - } - return $metaData; + $class = TDbDriverCapabilities::getMetaDataClass($conn); + if ($class === null) { + return null; + } + $instance = new $class($conn); + if (!($instance instanceof IDataMetaData)) { + throw new TDbException('dbmetadata_not_meta_data', $class, IDataMetaData::class); } + return $instance; } /** * Obtains table meta data information for the current connection and given table name. - * @param null|string $tableName table or view name + * @param ?string $tableName table or view name * @return TDbTableInfo table information. */ public function getTableInfo($tableName = null) @@ -135,7 +151,7 @@ public function getTableInfo($tableName = null) /** * Creates a command builder for a given table name. - * @param null|string $tableName table name. + * @param ?string $tableName table name. * @return TDbCommandBuilder command builder instance for the given table. */ public function createCommandBuilder($tableName = null) diff --git a/framework/Data/Common/TDbTableColumn.php b/framework/Data/Common/TDbTableColumn.php index 4ec9d345a..02d53c46a 100644 --- a/framework/Data/Common/TDbTableColumn.php +++ b/framework/Data/Common/TDbTableColumn.php @@ -13,12 +13,67 @@ use PDO; /** - * TDbTableColumn class describes the column meta data of the schema for a database table. + * TDbTableColumn class + * + * TDbTableColumn describes the metadata of a single column in a database table. + * + * Each instance wraps a flat associative info array that is populated by the + * driver-specific {@see TDbMetaData} subclass when it introspects the live + * schema. The info array is passed to the constructor and accessed internally + * through {@see getInfo()} / {@see setInfo()}. Driver subclasses + * (e.g. {@see TMysqlTableColumn}, {@see TSqliteTableColumn}) extend this class + * to map native database types to PHP primitives and to expose any + * engine-specific column attributes. + * + * ## Info array keys + * + * The following keys are recognized by the base class; each has a getter: + * + * | Key | Getter | Notes | + * |----------------------|---------------------------------|---------------------------------------------------| + * | `ColumnName` | {@see getColumnName()} | Identifier-quoted name, e.g. `"id"` or `` `id` `` | + * | `ColumnId` | {@see getColumnId()} | Bare (unquoted) column name used in ORDER BY | + * | `ColumnSize` | {@see getColumnSize()} | Maximum character or byte length, if applicable | + * | `ColumnIndex` | {@see getColumnIndex()} | Zero-based ordinal position in the table | + * | `DbType` | {@see getDbType()} | Native type string, e.g. `'varchar'`, `'integer'` | + * | `AllowNull` | {@see getAllowNull()} | `true` when NULL is a legal value; default `false` | + * | `DefaultValue` | {@see getDefaultValue()} | Column default; {@see UNDEFINED_VALUE} when absent | + * | `NumericPrecision` | {@see getNumericPrecision()} | Total significant-digit count for numeric types | + * | `NumericScale` | {@see getNumericScale()} | Decimal digits after the point for numeric types | + * | `IsPrimaryKey` | {@see getIsPrimaryKey()} | `true` when the column is part of the primary key | + * | `IsForeignKey` | {@see getIsForeignKey()} | `true` when the column is a foreign key | + * | `SequenceName` | {@see getSequenceName()} | Auto-increment sequence name, or null if none | + * + * ## UNDEFINED_VALUE + * + * The sentinel {@see UNDEFINED_VALUE} is PHP's `INF`. It is returned by + * {@see getDefaultValue()} when the column has no declared default, so callers + * can distinguish "default is `null`" from "no default defined": + * ```php + * if ($col->getDefaultValue() === TDbTableColumn::UNDEFINED_VALUE) { + * // no default — column must be supplied on INSERT + * } + * ``` + * + * ## Type mapping + * + * {@see getPHPType()} returns the PHP primitive type that best represents the + * column's database type: `'string'` (default), `'integer'`, or `'boolean'`. + * Driver subclasses override this to implement their specific type maps. + * {@see getPdoType()} translates the PHP type to a `PDO::PARAM_*` constant and + * is used by {@see TDbCommandBuilder::bindColumnValues()} when constructing + * INSERT and UPDATE commands. + * + * ## Exclusion + * + * {@see getIsExcluded()} returns `false` in the base class. Driver subclasses + * may override it to mark computed or auto-generated columns that should be + * omitted from INSERT and UPDATE statements. * * @author Wei Zhuo * @since 3.1 */ -class TDbTableColumn extends \Prado\TComponent +class TDbTableColumn extends \Prado\TComponent implements IDbColumn { public const UNDEFINED_VALUE = INF; //use infinity for undefined value diff --git a/framework/Data/Common/TDbTableInfo.php b/framework/Data/Common/TDbTableInfo.php index 9f900a57b..149f0fba2 100644 --- a/framework/Data/Common/TDbTableInfo.php +++ b/framework/Data/Common/TDbTableInfo.php @@ -15,12 +15,65 @@ use Prado\Prado; /** - * TDbTableInfo class describes the meta data of a database table. + * TDbTableInfo class + * + * TDbTableInfo describes the metadata of a single database table or view. + * + * Each instance holds a flat info array for table-level attributes and a + * {@see TMap} of {@see TDbTableColumn} objects (one per column, keyed by the + * bare column ID). It also stores the primary-key and foreign-key column name + * lists that were discovered during schema introspection. + * + * Instances are created by driver-specific {@see TDbMetaData} subclasses and + * returned — with results cached — by {@see TDbMetaData::getTableInfo()}. + * + * ## Info array keys + * + * The following keys are recognised by the base class; each has a getter: + * + * | Key | Getter | Notes | + * |---------------|---------------------------|------------------------------------------------| + * | `TableName` | {@see getTableName()} | Unqualified table or view name | + * | `IsView` | {@see getIsView()} | `true` when the object is a view | + * | `SchemaName` | {@see getSchemaName()} | Schema/owner name; returned only when the | + * | | | concrete class also implements {@see IDataHasSchema} | + * + * ## Full name and schema gating + * + * {@see getTableFullName()} returns the table name as it should appear in SQL. + * The base implementation returns the bare table name; schema-aware subclasses + * (MySQL, PostgreSQL, SQL Server, Oracle, IBM DB2) override this to prepend the + * quoted schema name so that queries reference `"schema"."table"`. + * + * {@see getSchemaName()} is gated by an `instanceof IDataHasSchema` check: even + * if a value were written to the info array, schema-less engines (SQLite, + * Firebird) will always receive `null`. + * + * ## Column map + * + * Columns are added to the internal {@see TMap} during schema introspection by + * the driver metadata class. The map is keyed by the bare (unquoted) column + * ID. Key accessors: + * - {@see getColumns()} — the full TMap of {@see TDbTableColumn} objects. + * - {@see getColumn(string $name)} — a single column by ID; throws + * {@see TDbException} when the column does not exist. + * - {@see getColumnNames()} — quoted column names for all columns (used to + * expand `SELECT *` into an explicit column list). + * - {@see getLowerCaseColumnNames()} — case-insensitive lookup table mapping + * `strtolower($id)` to the canonical column ID. + * + * ## Command builder + * + * {@see createCommandBuilder()} instantiates the appropriate + * {@see TDbCommandBuilder} subclass for the driver. The base implementation + * returns a plain {@see TDbCommandBuilder}; driver subclasses override this + * to return their specialised builder (e.g. {@see TSqliteCommandBuilder}, + * {@see TMysqlCommandBuilder}). * * @author Wei Zhuo * @since 3.1 */ -class TDbTableInfo extends \Prado\TComponent +class TDbTableInfo extends \Prado\TComponent implements IDataTableInfo { private $_info = []; @@ -84,7 +137,7 @@ protected function setInfo($name, $value) * Returns the schema (owner/namespace) name for database engines that support * schemas. Returns null for schema-less engines (SQLite, Firebird). * - * The concrete class must implement {@see IDbHasSchema} for a non-null value + * The concrete class must implement {@see IDataHasSchema} for a non-null value * to be returned; this prevents schema-less drivers from accidentally exposing * a stored value if one were ever written to the info array. * @@ -93,7 +146,7 @@ protected function setInfo($name, $value) */ public function getSchemaName(): ?string { - return $this instanceof IDbHasSchema ? $this->getInfo('SchemaName') : null; + return $this instanceof IDataHasSchema ? $this->getInfo('SchemaName') : null; } /** diff --git a/framework/Data/DataGateway/TDataGatewayCommand.php b/framework/Data/DataGateway/TDataGatewayCommand.php index 18289870c..4551d786b 100644 --- a/framework/Data/DataGateway/TDataGatewayCommand.php +++ b/framework/Data/DataGateway/TDataGatewayCommand.php @@ -17,6 +17,8 @@ use Prado\Exceptions\TDbException; /** + * TDataGatewayCommand class + * * TDataGatewayCommand is command builder and executor class for * TTableGateway and TActiveRecordGateway. * @@ -35,6 +37,10 @@ * {@see OnExecuteCommand} event is raised after the command is executed and resulting * data is set in the TDataGatewayResultEventParameter object's Result property. * + * Since v4.3.3, TDataGatewayCommand supports insertion conflicts with: + * - {@see insertOrIgnore()}: Insert silently ignoring duplicate key conflicts + * - {@see upsert()}: Insert or update on conflict + * * @author Wei Zhuo * @since 3.1 */ @@ -372,6 +378,46 @@ public function insert($data) return false; } + /** + * Inserts a new record, silently ignoring if a duplicate key conflict occurs. + * @param array $data new record data. + * @return mixed last insert id, true on ignore, or false on failure. + * @since 4.3.3 + */ + public function insertOrIgnore(array $data): mixed + { + $command = $this->getBuilder()->createInsertOrIgnoreCommand($data); + $this->onCreateCommand($command, new TSqlCriteria(null, $data)); + $command->prepare(); + if ($this->onExecuteCommand($command, $command->execute()) > 0) { + $value = $this->getLastInsertID(); + return $value !== null ? $value : true; + } + return false; + } + + /** + * Inserts or updates a record. + * On conflict with $conflictColumns (defaults to primary key), updates $updateData columns + * (defaults to all non-PK columns). + * @param array $data new record data. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns. + * @param null|array $conflictColumns conflict target columns; null = primary key. + * @return mixed last insert id, true on update, or false on failure. + * @since 4.3.3 + */ + public function upsert(array $data, ?array $updateData = null, ?array $conflictColumns = null): mixed + { + $command = $this->getBuilder()->createUpsertCommand($data, $updateData, $conflictColumns); + $this->onCreateCommand($command, new TSqlCriteria(null, $data)); + $command->prepare(); + if ($this->onExecuteCommand($command, $command->execute()) > 0) { + $value = $this->getLastInsertID(); + return $value !== null ? $value : true; + } + return false; + } + /** * Iterate through all the columns and returns the last insert id of the * first column that has a sequence or serial. diff --git a/framework/Data/DataGateway/TDataGatewayEventParameter.php b/framework/Data/DataGateway/TDataGatewayEventParameter.php index ef8b6c97c..6447c5de6 100644 --- a/framework/Data/DataGateway/TDataGatewayEventParameter.php +++ b/framework/Data/DataGateway/TDataGatewayEventParameter.php @@ -11,6 +11,8 @@ namespace Prado\Data\DataGateway; /** + * TDataGatewayEventParameter class + * * TDataGatewayEventParameter class contains the TDbCommand to be executed as * well as the criteria object. * diff --git a/framework/Data/DataGateway/TDataGatewayResultEventParameter.php b/framework/Data/DataGateway/TDataGatewayResultEventParameter.php index 10f8fcc1d..5852607a8 100644 --- a/framework/Data/DataGateway/TDataGatewayResultEventParameter.php +++ b/framework/Data/DataGateway/TDataGatewayResultEventParameter.php @@ -11,6 +11,8 @@ namespace Prado\Data\DataGateway; /** + * TDataGatewayResultEventParameter class + * * TDataGatewayResultEventParameter contains the TDbCommand executed and the resulting * data returned from the database. The data can be changed by changing the * {@see setResult Result} property. diff --git a/framework/Data/DataGateway/TSqlCriteria.php b/framework/Data/DataGateway/TSqlCriteria.php index 01b0610bb..b38256079 100644 --- a/framework/Data/DataGateway/TSqlCriteria.php +++ b/framework/Data/DataGateway/TSqlCriteria.php @@ -15,9 +15,59 @@ use Traversable; /** - * Search criteria for TDbDataGateway. + * TSqlCriteria class + * + * Search criteria for {@see TDbDataGateway} and {@see TTableGateway} finder methods. + * + * TSqlCriteria encapsulates a SQL WHERE condition together with its bound + * parameters, an ORDER BY specification, and LIMIT / OFFSET values. It is + * the primary object passed to `find()`, `findAll()`, `count()`, `update()`, + * and `deleteAll()`. + * + * ## Constructor forms + * + * The constructor accepts the condition string and parameters in several + * equivalent ways: + * + * ```php + * // No arguments — empty criteria (matches all rows). + * $c = new TSqlCriteria(); + * + * // Condition only — no bound parameters. + * $c = new TSqlCriteria('active = 1'); + * + * // Condition with a named-parameter array. + * $c = new TSqlCriteria('name = :name', [':name' => 'Alice']); + * + * // Condition with positional parameters as an indexed array. + * $c = new TSqlCriteria('id = ?', [42]); + * + * // Condition with positional parameters passed as individual varargs + * // (any scalar value after the condition string is collected into an + * // indexed array automatically). + * $c = new TSqlCriteria('id = ?', 42); + * $c = new TSqlCriteria('id = ? AND active = ?', 42, 1); + * ``` + * + * > **Note:** passing `null` sets the first SQL parameter to null, not an empty + * > list; use `[]` or omit parameters for no parameters. + * + * ## Condition shorthand + * + * ORDER BY, LIMIT, and OFFSET clauses embedded directly in the condition + * string are parsed out and applied to the respective properties: + * + * ```php + * $c = new TSqlCriteria('active = 1 ORDER BY name ASC LIMIT 10 OFFSET 20'); + * // Equivalent to: + * $c = new TSqlCriteria('active = 1'); + * $c->OrdersBy['name'] = 'asc'; + * $c->Limit = 10; + * $c->Offset = 20; + * ``` + * + * ## Typical property-based usage * - * Criteria object for data gateway finder methods. Usage: * ```php * $criteria = new TSqlCriteria(); * $criteria->Parameters[':name'] = 'admin'; @@ -45,9 +95,25 @@ class TSqlCriteria extends \Prado\TComponent private $_offset; /** - * Creates a new criteria with given condition; - * @param null|string $condition sql string after the WHERE stanza - * @param mixed $parameters named or indexed parameters, accepts as multiple arguments. + * Creates a new criteria with an optional condition and parameters. + * + * `$parameters` is resolved as follows: + * - **omitted** — no parameters are bound; + * - **array** — used as-is; named (`:key => value`) or positional + * (`0 => value`) arrays are both accepted. + * - **scalars** — activates varargs collection: every argument + * after `$condition` is gathered into a positional array, so + * `new TSqlCriteria('id = ?', 42)` and + * `new TSqlCriteria('a = ? AND b = ?', 1, 2)` both work. + * This includes `null`, which will be bound as the first parameter. + * + * @param ?string $condition SQL fragment placed after WHERE; may + * embed ORDER BY, LIMIT, and OFFSET clauses which are parsed out + * automatically. + * @param array|mixed $parameters bound parameters: omitted for none, + * an array for named/positional params, or the first of multiple + * varargs scalar values. Passing `null` sets the first SQL parameter + * to null, not an empty list; use `[]` or omit to pass no parameters. */ public function __construct($condition = null, $parameters = []) { diff --git a/framework/Data/DataGateway/TTableGateway.php b/framework/Data/DataGateway/TTableGateway.php index 0c92a48f0..ad8a3ae95 100644 --- a/framework/Data/DataGateway/TTableGateway.php +++ b/framework/Data/DataGateway/TTableGateway.php @@ -14,12 +14,14 @@ * Loads the data gateway command builder and sql criteria. */ use Prado\Data\TDbDataReader; +use Prado\Data\Common\IDataTableInfo; use Prado\Data\Common\TDbMetaData; -use Prado\Data\Common\TDbTableInfo; use Prado\Exceptions\TDbException; use Prado\Prado; /** + * TTableGateway class + * * TTableGateway class provides several find methods to get data from the database * and update, insert, and delete methods. * @@ -29,29 +31,41 @@ * * Example usage: * ```php - * //create a connection + * // Create a connection * $dsn = 'pgsql:host=localhost;dbname=test'; - * $conn = new TDbConnection($dsn, 'dbuser','dbpass'); + * $conn = new TDbConnection($dsn, 'dbuser','dbpass'); // TDbConnection implements IDataConnection * - * //create a table gateway for table/view named 'address' + * // Create a table gateway for table/view named 'address' * $table = new TTableGateway('address', $conn); * - * //insert a new row, returns last insert id (if applicable) + * // Table Presence + * $hasTable = $table->getTableExists(); + * + * // Insert a new row, returns last insert id (if applicable) * $id = $table->insert(array('name'=>'wei', 'phone'=>'111111')); * * $record1 = $table->findByPk($id); //find inserted record * - * //finds all records, returns an iterator + * // Finds all records, returns an iterator * $records = $table->findAll(); * print_r($records->readAll()); * - * //update the row + * // Update the row * $table->updateByPk($record1, $id); + * $table->update(array('name'=>'Updated Name'), 'id = ?', $id); + * + * // Delete a record by primary key + * $table->deleteByPk($id); + * + * // Delete multiple records by criteria + * $table->deleteAll('age > ? AND status = ?', 25, 'inactive'); * ``` * * All methods that may return more than one row of data will return an * TDbDataReader iterator. * + * As of v4.3.3, use {@see getTableExists()} to check for the presence of the table. + * * The OnCreateCommand event is raised when a command is prepared and parameter * binding is completed. The parameter object is a TDataGatewayEventParameter of which the * {@see \Prado\Data\DataGateway\TDataGatewayEventParameter::getCommand Command} property can be @@ -74,6 +88,10 @@ * } * ``` * + * Since v4.3.3, TTableGateway supports insertion conflicts with: + * - {@see insertOrIgnore()}: Insert silently ignoring duplicate key conflicts + * - {@see upsert()}: Insert or update on conflict + * * @author Wei Zhuo * @since 3.1 */ @@ -85,15 +103,15 @@ class TTableGateway extends \Prado\TComponent /** * Creates a new generic table gateway for a given table or view name * and a database connection. - * @param string|TDbTableInfo $table table or view name or table information. - * @param \Prado\Data\TDbConnection $connection database connection. + * @param \Prado\Data\Common\IDataTableInfo|string $table table or view name or table information. + * @param \Prado\Data\IDataConnection $connection database connection. */ public function __construct($table, $connection) { $this->_connection = $connection; if (is_string($table)) { $this->setTableName($table); - } elseif ($table instanceof TDbTableInfo) { + } elseif ($table instanceof IDataTableInfo) { $this->setTableInfo($table); } else { throw new TDbException('dbtablegateway_invalid_table_info'); @@ -102,7 +120,7 @@ public function __construct($table, $connection) } /** - * @param TDbTableInfo $tableInfo table or view information. + * @param \Prado\Data\Common\IDataTableInfo $tableInfo table or view information. */ protected function setTableInfo($tableInfo) { @@ -116,7 +134,7 @@ protected function setTableInfo($tableInfo) */ protected function setTableName($tableName) { - $meta = TDbMetaData::getInstance($this->getDbConnection()); + $meta = $this->getDbConnection()->getDbMetaData(); $this->initCommandBuilder($meta->createCommandBuilder($tableName)); } @@ -130,6 +148,31 @@ public function getTableName() return $this->getTableInfo()->getTableName(); } + /** + * Checks whether the table this gateway manages actually exists and is accessible + * in the current database connection. + * + * Uses a lightweight probe query — `SELECT * FROM {table} WHERE 0=1` — rather than + * driver-specific metadata tables, so the check works uniformly across all supported + * drivers and returns no rows even on large tables. + * + * {@see TDbCommand::query()} wraps all PDO-level errors as {@see TDbException}, so + * a missing or inaccessible table is caught as `TDbException` and returns `false`. + * + * @return bool true if the table (or view) exists and is accessible, false otherwise. + * @since 4.3.3 + */ + public function getTableExists(): bool + { + $sql = 'SELECT * FROM ' . $this->getTableInfo()->getTableFullName() . ' WHERE 0=1'; + try { + $this->getDbConnection()->createCommand($sql)->query()->close(); + return true; + } catch (TDbException $e) { + return false; + } + } + /** * @param \Prado\Data\Common\TDbCommandBuilder $builder database specific command builder. */ @@ -176,7 +219,7 @@ protected function getCommand() } /** - * @return \Prado\Data\TDbConnection database connection. + * @return \Prado\Data\IDataConnection database connection. */ public function getDbConnection() { @@ -223,7 +266,7 @@ public function findAllBySql($sql, $parameters = []) * ``` * * @param string|TSqlCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return array matching record object. */ public function find($criteria, $parameters = []) @@ -236,7 +279,7 @@ public function find($criteria, $parameters = []) /** * Accepts same parameters as find(), but returns TDbDataReader instead. * @param string|TSqlCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return TDbDataReader matching records. */ public function findAll($criteria = null, $parameters = []) @@ -301,7 +344,7 @@ public function findAllByPks($keys) * $table->delete('age > ? AND location = ?', $age, $location); * ``` * @param string $criteria delete condition. - * @param array $parameters condition parameters. + * @param array $parameters condition parameters; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return int number of records deleted. */ public function deleteAll($criteria, $parameters = []) @@ -357,7 +400,7 @@ public function deleteAllByPks($keys) /** * Find the number of records. * @param string|TSqlCriteria $criteria SQL condition or criteria object. - * @param mixed $parameters parameter values. + * @param mixed $parameters parameter values; passing `null` sets the first SQL parameter to null, not an empty list; use `[]` or omit to pass no parameters. * @return int number of records. */ public function count($criteria = null, $parameters = []) @@ -402,6 +445,32 @@ public function insert($data) return $this->getCommand()->insert($data); } + /** + * Inserts a new record, silently ignoring if a duplicate key conflict occurs. + * @param array $data new record data. + * @return mixed last insert id, true on ignore, or false on failure. + * @since 4.3.3 + */ + public function insertOrIgnore(array $data): mixed + { + return $this->getCommand()->insertOrIgnore($data); + } + + /** + * Inserts or updates a record. + * On conflict with $conflictColumns (defaults to primary key), updates $updateData columns + * (defaults to all non-PK columns). + * @param array $data new record data. + * @param null|array $updateData column=>value pairs to update on conflict; null = all non-PK columns. + * @param null|array $conflictColumns conflict target columns; null = primary key. + * @return mixed last insert id, true on update, or false on failure. + * @since 4.3.3 + */ + public function upsert(array $data, ?array $updateData = null, ?array $conflictColumns = null): mixed + { + return $this->getCommand()->upsert($data, $updateData, $conflictColumns); + } + /** * @return mixed last insert id, null if none is found. */ diff --git a/framework/Data/IDataCommand.php b/framework/Data/IDataCommand.php index ec32aa6db..f4f28be4a 100644 --- a/framework/Data/IDataCommand.php +++ b/framework/Data/IDataCommand.php @@ -11,6 +11,8 @@ namespace Prado\Data; /** + * IDataCommand interface + * * IDataCommand defines the interface for a data-store command. * * Implementations include {@see TDbCommand} for SQL/PDO databases. @@ -64,4 +66,97 @@ public function queryColumn(); * @return array all result rows. */ public function queryAll(); + + /** + * Returns the driver-specific type token for a given PHP value, inferred from + * the value's runtime type. + * + * The return value is driver-defined. For PDO-backed commands + * ({@see TDbCommand}) this is a `PDO::PARAM_*` integer constant. + * Non-SQL driver implementations may return any type representation + * meaningful to their binding layer. + * + * This is the abstract successor to the deprecated static + * {@see \Prado\Data\Common\TDbCommandBuilder::getPdoType()}. + * + * @param mixed $value the PHP value to inspect. + * @return mixed the driver-native type token, or null if the PHP type has no + * direct mapping in this driver. + */ + public function getColumnTypeFromValue($value); + + // ------------------------------------------------------------------------- + // SQL/PDO-oriented methods. + // SQL drivers implement these fully. Non-SQL drivers should provide no-op + // stubs (return null or a sensible default) for any method that does not + // apply to their underlying store. + // ------------------------------------------------------------------------- + + /** + * Returns the query text of this command. + * + * For SQL drivers this is the SQL statement string. Non-SQL drivers may + * return a serialised query representation or an empty string. + * + * @return string the query text. + */ + public function getText(); + + /** + * Sets the query text of this command. + * + * For SQL drivers, setting the text cancels any active prepared statement. + * Non-SQL drivers may no-op this method or use it to update the internal + * query representation. + * + * @param string $value the query text. + */ + public function setText($value); + + /** + * Prepares the command for repeated execution. + * + * For SQL/PDO drivers this compiles the statement and caches the result + * until {@see cancel()} or {@see setText()} is called. Calling this + * explicitly is optional; parameter binding triggers it automatically. + * Non-SQL drivers may no-op this method. + */ + public function prepare(); + + /** + * Cancels the prepared statement, releasing its resources. + * + * The next call to {@see execute()} or {@see query()} will re-prepare. + * Non-SQL drivers may no-op this method. + */ + public function cancel(); + + /** + * Binds a value to a named or positional parameter. + * + * The statement is prepared automatically on the first bind call for SQL + * drivers. Non-SQL drivers should map this to the equivalent binding + * operation for their store, or no-op if binding is not applicable. + * + * @param mixed $name parameter identifier — `:name` string for named + * placeholders, or a 1-based integer for positional (`?`) placeholders. + * @param mixed $value the value to bind. + * @param ?int $dataType a type hint for the driver (e.g. a PDO::PARAM_* + * constant for SQL drivers); null lets the driver infer the type. + */ + public function bindValue($name, $value, $dataType = null); + + /** + * Binds a PHP variable to a named or positional parameter by reference. + * + * Unlike {@see bindValue()}, the variable is evaluated at execution time, + * not at bind time. Non-SQL drivers should map this to the equivalent + * late-binding operation, or no-op if not applicable. + * + * @param mixed $name parameter identifier — `:name` string or 1-based integer. + * @param mixed $value the variable to bind by reference. + * @param ?int $dataType a type hint for the driver; null lets it infer. + * @param ?int $length maximum length hint for output parameters. + */ + public function bindParameter($name, &$value, $dataType = null, $length = null); } diff --git a/framework/Data/IDataConnection.php b/framework/Data/IDataConnection.php index 1bfa4d579..eb06493df 100644 --- a/framework/Data/IDataConnection.php +++ b/framework/Data/IDataConnection.php @@ -10,12 +10,16 @@ namespace Prado\Data; +use Prado\Data\Common\IDataMetaData; + /** + * IDataConnection interface + * * IDataConnection defines the interface for a data-store connection. * * This interface provides a common abstraction over SQL connections - * ({@see TDbConnection} via PDO), allowing application code to work - * with either store type through a unified API. + * ({@see TDbConnection} via PDO), allowing PRADO plugins to supply their own + * connection implementations through a unified API. * * For SQL drivers the $query argument to {@see createCommand} is a SQL string. * @@ -25,17 +29,18 @@ interface IDataConnection { /** - * @return string name of the Data driver + * @return string the driver name (e.g. 'mysql', 'pgsql', 'sqlite'). */ public function getDriverName(); /** - * @return bool whether the connection is open. + * @return bool whether the connection is currently open. */ public function getActive(); /** * Opens or closes the connection. + * * @param bool $value true to open, false to close. */ public function setActive($value); @@ -45,20 +50,178 @@ public function setActive($value); * * For SQL connections ({@see TDbConnection}), $query is a SQL string. * - * @param mixed $query the query specification (SQL string or collection name). + * @param mixed $query the query specification (SQL string or equivalent). * @return IDataCommand the new command object. */ public function createCommand($query); /** - * Begins a transaction. - * @return IDataTransaction the transaction object. + * Begins a new transaction. + * + * Each call allocates a **new** {@see IDataTransaction} object. Any + * previously returned transaction object is superseded: calling + * {@see IDataTransaction::beginTransaction()} on it will throw because it is + * no longer the connection's current transaction. + * + * Throws an exception if a transaction is already active. Commit or roll back + * the current transaction before starting a new one. + * + * To reuse the same transaction object for sequential work units without + * allocating a new one, call {@see IDataTransaction::beginTransaction()} + * directly on the returned object after commit or rollback. + * + * @return IDataTransaction the transaction object for the new work unit. */ public function beginTransaction(); /** - * Returns the currently active transaction, if any. - * @return null|IDataTransaction the active transaction, or null if none. + * Returns the currently active transaction, or null if none is open. + * If a transaction is not active (as in, the transaction has been completed), + * then this returns null. + * + * @return null|IDataTransaction the active transaction, or null. */ public function getCurrentTransaction(); + + /** + * Returns the last {@see IDataTransaction} object associated with this + * connection, whether or not it is still active. + * + * Differs from {@see getCurrentTransaction()}, which returns non-null only + * while a transaction is open. This method returns the object stored when + * {@see beginTransaction()} was last called, regardless of its state. + * + * The primary use case is the supersession guard inside + * {@see IDataTransaction::beginTransaction()}: before reactivating a + * completed transaction object the implementation checks that it is still + * the last one on the connection. If {@see beginTransaction()} has been + * called again since, a newer object is stored here and the old one is + * considered superseded. + * + * @return null|IDataTransaction the last transaction object, or null if + * {@see beginTransaction()} has never been called on this connection. + */ + public function getLastTransaction(): ?IDataTransaction; + + /** + * Commits the currently active transaction on this connection. + * + * A convenience method for cases where the caller does not hold a reference + * to the transaction object. Returns false (and is a no-op) when no + * transaction is active. + * + * @return ?bool true if a transaction was committed, false if none was active. + */ + public function commit(): ?bool; + + /** + * Rolls back the currently active transaction on this connection. + * + * A convenience method for cases where the caller does not hold a reference + * to the transaction object. Returns false (and is a no-op) when no + * transaction is active. + * + * @return ?bool true if a transaction was rolled back, false if none was active. + */ + public function rollback(): ?bool; + + /** + * Returns the ID of the last inserted row or sequence value. + * + * For SQL/PDO drivers this wraps `PDO::lastInsertId()`. Non-SQL drivers + * should return the equivalent last-insert identifier for their store, or + * an empty string if the concept does not apply. + * + * @param string $sequenceName name of the sequence object (required by some DBMS). + * @return string the row ID of the last inserted row, or the last value retrieved + * from the sequence object. + */ + public function getLastInsertID($sequenceName = ''); + + /** + * Returns the metadata helper for this connection. + * + * The metadata object provides schema introspection (table and column info) + * and identifier quoting. For SQL connections this returns the appropriate + * {@see \Prado\Data\Common\TDbMetaData} subclass for the active driver. + * + * @return IDataMetaData the metadata helper for this connection. + * @since 4.3.3 + */ + public function getDbMetaData(); + + // ------------------------------------------------------------------------- + // SQL/PDO-oriented methods. + // SQL drivers implement these fully. Non-SQL drivers should provide no-op + // stubs (return null, empty string, or a sensible default) for any method + // that does not apply to their underlying store. + // ------------------------------------------------------------------------- + + /** + * Returns the connection string (DSN) used to open this connection. + * + * Non-SQL drivers that do not use a DSN may return an empty string or a + * driver-defined connection descriptor. + * + * @return string the connection string / DSN. + */ + public function getConnectionString(); + + /** + * Quotes a string for safe inclusion in a SQL query. + * + * Wraps the underlying driver's quoting function (e.g. PDO::quote for SQL + * drivers). The connection must be open before calling this method. + * Non-SQL drivers should return the string unmodified. + * + * @param string $str the string to quote. + * @return string the properly quoted string. + */ + public function quoteString($str); + + /** + * Returns the current column-name case mode for this connection. + * + * Wraps PDO::ATTR_CASE for SQL drivers. Returns a + * {@see \Prado\Data\TDbColumnCaseMode} value. Non-SQL drivers may return + * null or a driver-defined default. + * + * @return mixed the current column case mode (TDbColumnCaseMode enum value). + */ + public function getColumnCase(); + + /** + * Sets the column-name case mode for this connection. + * + * Wraps PDO::ATTR_CASE for SQL drivers. Accepts a + * {@see \Prado\Data\TDbColumnCaseMode} value. Non-SQL drivers may no-op + * this method. + * + * @param mixed $value the column case mode (TDbColumnCaseMode enum value). + */ + public function setColumnCase($value); + + /** + * Returns the value of a driver connection attribute. + * + * For SQL drivers the attribute name is a PDO attribute constant + * (e.g. PDO::ATTR_CASE). Non-SQL drivers may return null for unknown + * attribute names. + * + * @param int $name the attribute identifier. + * @return mixed the attribute value, or null if not supported. + */ + public function getAttribute($name); + + /** + * Sets a driver connection attribute. + * + * For SQL drivers the attribute name is a PDO attribute constant + * (e.g. PDO::ATTR_CASE). Non-SQL drivers may no-op this method. + * + * @param int $name the attribute identifier. + * @param mixed $value the attribute value to set. + */ + public function setAttribute($name, $value); + } diff --git a/framework/Data/IDataReader.php b/framework/Data/IDataReader.php index 91405c084..85ec2e1b9 100644 --- a/framework/Data/IDataReader.php +++ b/framework/Data/IDataReader.php @@ -11,6 +11,8 @@ namespace Prado\Data; /** + * IDataReader interface + * * IDataReader defines the interface for a forward-only data result reader. * * Implementations include {@see TDbDataReader} for SQL/PDO result sets. diff --git a/framework/Data/IDataTransaction.php b/framework/Data/IDataTransaction.php index 8b486db6b..e076a1c6e 100644 --- a/framework/Data/IDataTransaction.php +++ b/framework/Data/IDataTransaction.php @@ -11,8 +11,14 @@ namespace Prado\Data; /** + * IDataTransaction interface + * * IDataTransaction defines the interface for a data-store transaction. * + * This interface provides a common abstraction over database-specific transaction + * implementations, allowing PRADO plugins to supply their own implementations + * without coupling to a concrete class. + * * Implementations include {@see TDbTransaction} for SQL/PDO databases. * * @author Brad Anderson @@ -21,22 +27,60 @@ interface IDataTransaction { /** - * Commits the transaction. + * @return IDataConnection the connection associated with this transaction. */ - public function commit(); + public function getConnection(); /** - * Rolls back (aborts) the transaction. + * @return bool whether the transaction is currently active. */ - public function rollback(); + public function getActive(); /** - * @return bool whether the transaction is currently active. + * Creates a command for execution within this transaction's connection. + * + * This is a convenience method equivalent to + * `$transaction->getConnection()->createCommand($query)`. + * + * @param mixed $query the query specification (SQL string or equivalent). + * @return IDataCommand the new command object. */ - public function getActive(); + public function createCommand($query); /** - * @return IDataConnection the connection associated with this transaction. + * Starts a new transaction on this transaction's connection, reactivating + * this transaction object for a new work unit. + * + * This is the reuse-pattern counterpart to + * {@see IDataConnection::beginTransaction()}: it reactivates the existing + * object rather than allocating a new one, which avoids unnecessary + * object allocation for sequential work units. + * + * Implementations must guard against supersession: if + * {@see IDataConnection::beginTransaction()} was called after this + * transaction completed, this object has been superseded and restarting + * it must throw an exception rather than silently bypassing the newer + * transaction's lifecycle. + * + * @return static */ - public function getConnection(); + public function beginTransaction(): static; + + /** + * Commits the transaction. + * + * The transaction becomes inactive after commit completes. To start another + * work unit, call {@see beginTransaction()} on this object (reuse pattern) + * or call {@see IDataConnection::beginTransaction()} for a fresh object. + */ + public function commit(); + + /** + * Rolls back (aborts) the transaction. + * + * The transaction becomes inactive after rollback completes. To start another + * work unit, call {@see beginTransaction()} on this object (reuse pattern) + * or call {@see IDataConnection::beginTransaction()} for a fresh object. + */ + public function rollback(); } diff --git a/framework/Data/IDbConnection.php b/framework/Data/IDbConnection.php new file mode 100644 index 000000000..32973b981 --- /dev/null +++ b/framework/Data/IDbConnection.php @@ -0,0 +1,39 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data; + +/** + * IDbConnection interface + * + * IDbConnection extends {@see IDataConnection} with PDO-specific access, + * providing direct access to the underlying {@see \PDO} instance. + * + * This interface is implemented by {@see TDbConnection} and should be used + * as the type hint wherever code needs to call PDO-specific methods directly + * (e.g. `getPdoInstance()->lastInsertId()`, `getPdoInstance()->prepare()`). + * + * Code that does not require PDO access should use {@see IDataConnection} + * so that non-PDO driver implementations remain compatible. + * + * @author Brad Anderson + * @since 4.3.3 + */ +interface IDbConnection extends IDataConnection +{ + /** + * Returns the underlying PDO instance for this connection. + * + * Returns null if the connection has not been opened yet. + * + * @return null|\PDO the PDO instance, or null if not yet connected. + */ + public function getPdoInstance(); +} diff --git a/framework/Data/SqlMap/Configuration/TDiscriminator.php b/framework/Data/SqlMap/Configuration/TDiscriminator.php index 62f6d10cc..53e39b636 100644 --- a/framework/Data/SqlMap/Configuration/TDiscriminator.php +++ b/framework/Data/SqlMap/Configuration/TDiscriminator.php @@ -1,7 +1,7 @@ * @link https://github.com/pradosoft/prado @@ -14,6 +14,8 @@ use Prado\Data\TSqlMapManager; /** + * TDiscriminator class + * * The TDiscriminator corresponds to the tag within a . * * TDiscriminator allows inheritance logic in SqlMap result mappings. @@ -25,6 +27,7 @@ * * @author Wei Zhuo * @since 3.1 + * @see TSubMap */ class TDiscriminator extends \Prado\TComponent { diff --git a/framework/Data/SqlMap/Configuration/TParameterProperty.php b/framework/Data/SqlMap/Configuration/TParameterProperty.php index 81b0a82b0..c4190b1c0 100644 --- a/framework/Data/SqlMap/Configuration/TParameterProperty.php +++ b/framework/Data/SqlMap/Configuration/TParameterProperty.php @@ -133,10 +133,10 @@ public function setNullValue($value) $this->_nullValue = $value; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; - $cn = 'TParameterProperty'; + parent::_getZappableSleepProps($exprops); + $cn = __CLASS__; if ($this->_typeHandler === null) { $exprops[] = "\0$cn\0_typeHandler"; } @@ -155,6 +155,5 @@ public function __sleep() if ($this->_nullValue === null) { $exprops[] = "\0$cn\0_nullValue"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Configuration/TResultProperty.php b/framework/Data/SqlMap/Configuration/TResultProperty.php index b03c10714..4c304f772 100644 --- a/framework/Data/SqlMap/Configuration/TResultProperty.php +++ b/framework/Data/SqlMap/Configuration/TResultProperty.php @@ -50,7 +50,7 @@ class TResultProperty extends \Prado\TComponent private $_isLazyLoad = false; private $_select; - private $_hostResultMapID = 'inplicit internal mapping'; + private $_hostResultMapID = 'implicit internal mapping'; public const LIST_TYPE = 0; public const ARRAY_TYPE = 1; @@ -340,15 +340,15 @@ public function instanceOfArrayType($target) return $this->getPropertyValueType() == self::ARRAY_TYPE; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; - $cn = 'TResultProperty'; + parent::_getZappableSleepProps($exprops); + $cn = __CLASS__; if ($this->_nullValue === null) { $exprops[] = "\0$cn\0_nullValue"; } if ($this->_propertyName === null) { - $exprops[] = "\0$cn\0_propertyNama"; + $exprops[] = "\0$cn\0_propertyName"; } if ($this->_columnName === null) { $exprops[] = "\0$cn\0_columnName"; @@ -374,6 +374,5 @@ public function __sleep() if ($this->_select === null) { $exprops[] = "\0$cn\0_select"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php b/framework/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php new file mode 100644 index 000000000..c51538292 --- /dev/null +++ b/framework/Data/SqlMap/Configuration/TSqlMapInsertOrIgnore.php @@ -0,0 +1,24 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Configuration; + +/** + * TSqlMapInsertOrIgnore corresponds to the element. + * + * Behaves identically to TSqlMapInsert but executes via insertOrIgnore(), + * silently ignoring duplicate key conflicts. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TSqlMapInsertOrIgnore extends TSqlMapInsert +{ +} diff --git a/framework/Data/SqlMap/Configuration/TSqlMapStatement.php b/framework/Data/SqlMap/Configuration/TSqlMapStatement.php index c97111ccd..61de3dde0 100644 --- a/framework/Data/SqlMap/Configuration/TSqlMapStatement.php +++ b/framework/Data/SqlMap/Configuration/TSqlMapStatement.php @@ -301,10 +301,11 @@ public function createInstanceOfResultClass($registry, $row) } } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; - $exprops = ["\0$cn\0_resultMap"]; + $exprops[] = "\0$cn\0_resultMap"; if (!$this->_parameterMapName) { $exprops[] = "\0$cn\0_parameterMapName"; } @@ -317,9 +318,6 @@ public function __sleep() if (!$this->_resultMapName) { $exprops[] = "\0$cn\0_resultMapName"; } - if (!$this->_resultMap) { - $exprops[] = "\0$cn\0_resultMap"; - } if (!$this->_resultClassName) { $exprops[] = "\0$cn\0_resultClassName"; } @@ -341,7 +339,5 @@ public function __sleep() if (!$this->_cache) { $exprops[] = "\0$cn\0_cache"; } - - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Configuration/TSqlMapUpsert.php b/framework/Data/SqlMap/Configuration/TSqlMapUpsert.php new file mode 100644 index 000000000..1fabd2901 --- /dev/null +++ b/framework/Data/SqlMap/Configuration/TSqlMapUpsert.php @@ -0,0 +1,58 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Configuration; + +/** + * TSqlMapUpsert corresponds to the element. + * + * Supports optional updateColumns and conflictColumns attributes to control + * which columns are updated on conflict and which columns identify the conflict. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TSqlMapUpsert extends TSqlMapInsert +{ + private ?array $_updateColumns = null; + private ?array $_conflictColumns = null; + + /** + * @return null|array the columns to update on conflict, or null to use all non-PK columns. + */ + public function getUpdateColumns(): ?array + { + return $this->_updateColumns; + } + + /** + * @param array|string $value column names as comma-separated string or array. + */ + public function setUpdateColumns($value): void + { + $this->_updateColumns = is_string($value) ? array_map('trim', explode(',', $value)) : $value; + } + + /** + * @return null|array the conflict target columns, or null to use primary key columns. + */ + public function getConflictColumns(): ?array + { + return $this->_conflictColumns; + } + + /** + * @param array|string $value column names as comma-separated string or array. + */ + public function setConflictColumns($value): void + { + $this->_conflictColumns = is_string($value) ? array_map('trim', explode(',', $value)) : $value; + } +} diff --git a/framework/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php b/framework/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php index 4f439102b..c9d885e66 100644 --- a/framework/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php +++ b/framework/Data/SqlMap/Configuration/TSqlMapXmlConfiguration.php @@ -14,7 +14,7 @@ use Prado\Data\SqlMap\DataMapper\TSqlMapConfigurationException; /** - * TSqlMapXmlConfig class. + * TSqlMapXmlConfig class * * Configures the TSqlMapManager using xml configuration file. * diff --git a/framework/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php b/framework/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php index 3987b3120..e46e557d4 100644 --- a/framework/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php +++ b/framework/Data/SqlMap/Configuration/TSqlMapXmlMappingConfiguration.php @@ -1,7 +1,7 @@ * @link https://github.com/pradosoft/prado @@ -16,16 +16,62 @@ use Prado\Data\SqlMap\Statements\TCachingStatement; use Prado\Data\SqlMap\Statements\TDeleteMappedStatement; use Prado\Data\SqlMap\Statements\TInsertMappedStatement; +use Prado\Data\SqlMap\Statements\TInsertOrIgnoreMappedStatement; use Prado\Data\SqlMap\Statements\TMappedStatement; use Prado\Data\SqlMap\Statements\TSimpleDynamicSql; use Prado\Data\SqlMap\Statements\TStaticSql; use Prado\Data\SqlMap\Statements\TUpdateMappedStatement; +use Prado\Data\SqlMap\Statements\TUpsertMappedStatement; use Prado\Prado; /** - * Loads the statements, result maps, parameters maps from xml configuration. + * TSqlMapXmlMappingConfiguration class * - * description + * TSqlMapXmlMappingConfiguration loads statements, result maps, and parameter maps from XML mapping files. + * + * This builder parses XML mapping files and registers them with the SqlMap manager. + * It handles loading of resultMap, parameterMap, select, insert, insertOrIgnore, upsert, update, + * delete, statement, and cacheModel elements from the XML configuration. + * + * The XML mapping file follows the IBATIS SQL Map format: + * ```xml + * + * + * + * + * + * + * + * + * + * + * INSERT INTO users (username) VALUES (#username#) + * + * + * + * INSERT OR IGNORE INTO users (username) VALUES (#username#) + * + * + * + * INSERT INTO users (user_id, username) VALUES (#userId#, #username#) + * ON CONFLICT (user_id) DO UPDATE SET username = #username# + * + * + * + * UPDATE users SET username = #username# WHERE user_id = #userId# + * + * + * + * DELETE FROM users WHERE user_id = #id# + * + * + * ``` + * + * The configuration uses constants for parameter markers: + * - {@see SIMPLE_MARK} ($) for literal value substitution + * {@see INLINE_SYMBOL} (#) for parameterized queries (used with parameter maps) * * @author Wei Zhuo * @since 3.1 @@ -59,13 +105,28 @@ public function __construct(TSqlMapXmlConfiguration $xmlConfig) $this->_manager = $xmlConfig->getManager(); } + /** + * @return string the configured XML mapping file path. + */ protected function getConfigFile() { return $this->_configFile; } /** - * Configure an XML mapping. + * Configures the XML mapping by loading and parsing the XML file. + * + * This method loads the XML mapping file and registers all defined elements + * with the SqlMap manager: + * - resultMap elements for mapping query results to objects + * - parameterMap elements for mapping input parameters + * - select, insert, insertOrIgnore, upsert, update, delete statements + * - statement elements for generic statements + * - procedure elements (not yet implemented) + * - cacheModel elements for query caching + * + * Cache dependencies are automatically registered for cache invalidation. + * * @param string $filename xml mapping filename. */ public function configure($filename) @@ -105,6 +166,14 @@ public function configure($filename) $this->loadInsertTag($node); } + foreach ($document->xpath('//insertOrIgnore') as $node) { + $this->loadInsertOrIgnoreTag($node); + } + + foreach ($document->xpath('//upsert') as $node) { + $this->loadUpsertTag($node); + } + foreach ($document->xpath('//update') as $node) { $this->loadUpdateTag($node); } @@ -125,7 +194,8 @@ public function configure($filename) } /** - * Load the result maps. + * Loads the result map from XML node. + * Handles inheritance from parent resultMaps if specified. * @param \SimpleXmlElement $node result map node. */ protected function loadResultMap($node) @@ -209,8 +279,8 @@ protected function createResultMap($node) } /** - * Load parameter map from xml. - * + * Loads parameter map from XML node. + * Handles inheritance from parent parameterMaps if specified. * @param \SimpleXmlElement $node parameter map node. */ protected function loadParameterMap($node) @@ -260,7 +330,8 @@ protected function createParameterMap($node) } /** - * Load statement mapping from xml configuration file. + * Loads generic statement mapping from XML configuration file. + * Processes SQL text, handles inheritance, applies inline parameters. * @param \SimpleXmlElement $node statement node. */ protected function loadStatementTag($node) @@ -356,7 +427,8 @@ protected function prepareSql($statement, $sqlStatement, $node) } /** - * Load select statement from xml mapping. + * Loads select statement from XML mapping. + * Supports optional cacheModel for query caching. * @param \SimpleXmlElement $node select node. */ protected function loadSelectTag($node) @@ -374,7 +446,8 @@ protected function loadSelectTag($node) } /** - * Load insert statement from xml mapping. + * Loads insert statement from XML mapping. + * Supports selectKey for auto-increment value retrieval. * @param \SimpleXmlElement $node insert node. */ protected function loadInsertTag($node) @@ -385,6 +458,32 @@ protected function loadInsertTag($node) $this->_manager->addMappedStatement($mappedStatement); } + /** + * Load insertOrIgnore statement from xml mapping. + * @param \SimpleXmlElement $node insertOrIgnore node. + * @since 4.3.3 + */ + protected function loadInsertOrIgnoreTag($node) + { + $insert = $this->createInsertOrIgnoreStatement($node); + $this->processSqlStatement($insert, $node); + $mappedStatement = new TInsertOrIgnoreMappedStatement($this->_manager, $insert); + $this->_manager->addMappedStatement($mappedStatement); + } + + /** + * Load upsert statement from xml mapping. + * @param \SimpleXmlElement $node upsert node. + * @since 4.3.3 + */ + protected function loadUpsertTag($node) + { + $insert = $this->createUpsertStatement($node); + $this->processSqlStatement($insert, $node); + $mappedStatement = new TUpsertMappedStatement($this->_manager, $insert); + $this->_manager->addMappedStatement($mappedStatement); + } + /** * Create new insert statement from xml node. * @param \SimpleXmlElement $node insert node. @@ -400,6 +499,38 @@ protected function createInsertStatement($node) return $insert; } + /** + * Create new insertOrIgnore statement from xml node. + * @param \SimpleXmlElement $node insertOrIgnore node. + * @return TSqlMapInsertOrIgnore insertOrIgnore statement. + * @since 4.3.3 + */ + protected function createInsertOrIgnoreStatement($node) + { + $insert = new TSqlMapInsertOrIgnore(); + $this->setObjectPropFromNode($insert, $node); + if (isset($node->selectKey)) { + $this->loadSelectKeyTag($insert, $node->selectKey); + } + return $insert; + } + + /** + * Create new upsert statement from xml node. + * @param \SimpleXmlElement $node upsert node. + * @return TSqlMapUpsert upsert statement. + * @since 4.3.3 + */ + protected function createUpsertStatement($node) + { + $insert = new TSqlMapUpsert(); + $this->setObjectPropFromNode($insert, $node); + if (isset($node->selectKey)) { + $this->loadSelectKeyTag($insert, $node->selectKey); + } + return $insert; + } + /** * Load the selectKey statement from xml mapping. * @param mixed $insert @@ -431,7 +562,7 @@ protected function loadUpdateTag($node) } /** - * Load delete statement from xml mapping. + * Loads delete statement from XML mapping. * @param \SimpleXmlElement $node delete node. */ protected function loadDeleteTag($node) @@ -444,7 +575,7 @@ protected function loadDeleteTag($node) } /** - * Load procedure statement from xml mapping. + * Loads procedure statement from XML mapping. * @todo Implement loading procedure * @param \SimpleXmlElement $node procedure node */ diff --git a/framework/Data/SqlMap/Configuration/TSubMap.php b/framework/Data/SqlMap/Configuration/TSubMap.php index d5517972e..2923ebb1c 100644 --- a/framework/Data/SqlMap/Configuration/TSubMap.php +++ b/framework/Data/SqlMap/Configuration/TSubMap.php @@ -11,6 +11,8 @@ namespace Prado\Data\SqlMap\Configuration; /** + * TSubMap class + * * TSubMap class defines a submapping value and the corresponding * * The {@see Value setValue()} property is used for comparison with the @@ -20,6 +22,7 @@ * * @author Wei Zhuo * @since 3.1 + * @see TDiscriminator */ class TSubMap extends \Prado\TComponent { diff --git a/framework/Data/SqlMap/DataMapper/messages.txt b/framework/Data/SqlMap/DataMapper/messages.txt index 0923d606b..e71daa78f 100644 --- a/framework/Data/SqlMap/DataMapper/messages.txt +++ b/framework/Data/SqlMap/DataMapper/messages.txt @@ -63,4 +63,8 @@ sqlmap_query_execution_error = Error in executing SQLMap statement '{0}' : '{1 sqlmap_invalid_delegate = Invalid callback row delegate '{1}' in mapped statement '{0}'. sqlmap_invalid_prado_cache = Unable to find Prado cache module for SQLMap cache '{0}'. -sqlmap_non_groupby_array_list_type = Expecting GroupBy property in result map '{0}' since {1}::{2} is an array or TList type. \ No newline at end of file +sqlmap_non_groupby_array_list_type = Expecting GroupBy property in result map '{0}' since {1}::{2} is an array or TList type. +sqlmap_can_not_extend_select_key = SelectKey statements do not support inheritance via the 'extends' attribute. +sqlmap_configfile_invalid = SQLMap configuration file or namespace '{0}' is invalid or does not exist. +sqlmap_must_enable_custom_paging = Custom paging is not enabled; set CustomPaging to true before accessing the PagedList. +sqlmap_use_set_to_store_cache = Use set() to store items in the SQLMap cache; add() is not supported. \ No newline at end of file diff --git a/framework/Data/SqlMap/Statements/IMappedStatement.php b/framework/Data/SqlMap/Statements/IMappedStatement.php index 20428be2e..6761f1c72 100644 --- a/framework/Data/SqlMap/Statements/IMappedStatement.php +++ b/framework/Data/SqlMap/Statements/IMappedStatement.php @@ -8,7 +8,11 @@ namespace Prado\Data\SqlMap\Statements; +use Prado\Data\IDataConnection; + /** + * IMappedStatement interface + * * Interface for all mapping statements. * * @author Wei Zhuo @@ -37,7 +41,7 @@ public function getManager(); * each key will be the value of the property specified in the * $valueProperty parameter. If $valueProperty is * null, the entire result object will be entered. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param string $keyProperty The property of the result object to be used as the key. * @param string $valueProperty The property of the result object to be used as the value (or null) @@ -51,7 +55,7 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value /** * Execute an update statement. Also used for delete statement. Return the * number of row effected. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @return int The number of row effected. */ @@ -60,7 +64,7 @@ public function executeUpdate($connection, $parameter); /** * Executes the SQL and retuns a subset of the rows selected. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param null|\Prado\Collections\TList $result A list to populate the result with. * @param int $skip The number of rows to skip over. @@ -73,7 +77,7 @@ public function executeQueryForList($connection, $parameter, $result = null, $sk /** * Executes an SQL statement that returns a single row as an object * of the type of the $result passed in as a parameter. - * @param \Prado\Data\TDbConnection $connection database connection to execute the query + * @param \Prado\Data\IDataConnection $connection database connection to execute the query * @param mixed $parameter The object used to set the parameters in the SQL. * @param object $result The result object. * @return object result. @@ -83,7 +87,7 @@ public function executeQueryForObject($connection, $parameter, $result = null); /** * Execute an insert statement. Fill the parameter object with the ouput * parameters if any, also could return the insert generated key. - * @param \Prado\Data\TDbConnection $connection database connection + * @param \Prado\Data\IDataConnection $connection database connection * @param mixed $parameter The parameter object used to fill the statement. * @return string the insert generated key. */ diff --git a/framework/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php b/framework/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php new file mode 100644 index 000000000..d12f7108a --- /dev/null +++ b/framework/Data/SqlMap/Statements/TInsertOrIgnoreMappedStatement.php @@ -0,0 +1,26 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Statements; + +/** + * TInsertOrIgnoreMappedStatement executes insertOrIgnore mapped statements. + * + * Corresponds to the SqlMap XML element. Behaves identically to + * TInsertMappedStatement but signals to the framework that this is an + * insert-or-ignore operation. The driver-specific SQL (e.g. INSERT IGNORE INTO + * or INSERT OR IGNORE INTO) is written directly in the XML mapping. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TInsertOrIgnoreMappedStatement extends TInsertMappedStatement +{ +} diff --git a/framework/Data/SqlMap/Statements/TMappedStatement.php b/framework/Data/SqlMap/Statements/TMappedStatement.php index c4a733549..ede85fcd0 100644 --- a/framework/Data/SqlMap/Statements/TMappedStatement.php +++ b/framework/Data/SqlMap/Statements/TMappedStatement.php @@ -27,7 +27,7 @@ * TMappedStatement class executes SQL mapped statements. Mapped Statements can * hold any SQL statement and use Parameter Maps and Result Maps for input and output. * - * This class is usualy instantiated during SQLMap configuration by TSqlDomBuilder. + * This class is usually instantiated during SQLMap configuration by TSqlDomBuilder. * * @author Wei Zhuo * @since 3.0 @@ -89,7 +89,7 @@ public function getID() } /** - * @return TSqlMapStatement The SQL statment used by this MappedStatement + * @return TSqlMapStatement The SQL statement used by this MappedStatement */ public function getStatement() { @@ -160,7 +160,7 @@ protected function executeSQLQueryLimit($connection, $command, $max, $skip) } /** - * Executes the SQL and retuns a List of result objects. + * Executes the SQL and returns a List of result objects. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The object used to set the parameters in the SQL. * @param null|object $result result collection object. @@ -177,7 +177,7 @@ public function executeQueryForList($connection, $parameter, $result = null, $sk } /** - * Executes the SQL and retuns a List of result objects. + * Executes the SQL and returns a List of result objects. * * This method should only be called by internal developers, consider using * executeQueryForList() first. @@ -221,14 +221,14 @@ public function runQueryForList($connection, $parameter, $sql, $result, $delegat } /** - * Executes the SQL and retuns all rows selected in a map that is keyed on + * Executes the SQL and returns all rows selected in a map that is keyed on * the property named in the keyProperty parameter. The value at each key * will be the value of the property specified in the valueProperty parameter. * If valueProperty is null, the entire result object will be entered. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The object used to set the parameters in the SQL. * @param string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value (or null). + * @param ?string $valueProperty The property of the result object to be used as the value (or null). * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @param null|callable $delegate row delegate handler @@ -241,7 +241,7 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value } /** - * Executes the SQL and retuns all rows selected in a map that is keyed on + * Executes the SQL and returns all rows selected in a map that is keyed on * the property named in the keyProperty parameter. The value at each key * will be the value of the property specified in the valueProperty parameter. * If valueProperty is null, the entire result object will be entered. @@ -253,8 +253,8 @@ public function executeQueryForMap($connection, $parameter, $keyProperty, $value * @param mixed $parameter The object used to set the parameters in the SQL. * @param mixed $command * @param string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value (or null). - * @param null|callable $delegate row delegate, a callback function + * @param ?string $valueProperty The property of the result object to be used as the value (or null). + * @param ?callable $delegate row delegate, a callback function * @return array An array of object containing the rows keyed by keyProperty. * @see executeQueryForMap() */ @@ -360,7 +360,7 @@ public function runQueryForObject($connection, $command, &$result) } /** - * Execute an insert statement. Fill the parameter object with the ouput + * Execute an insert statement. Fill the parameter object with the output * parameters if any, also could return the insert generated key. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter The parameter object used to fill the statement. @@ -386,7 +386,7 @@ public function executeInsert($connection, $parameter) * Gets the insert generated ID before executing an insert statement. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter insert statement parameter. - * @return null|string new insert ID if pre-select key statement was executed, null otherwise. + * @return ?string new insert ID if pre-select key statement was executed, null otherwise. */ protected function getPreGeneratedSelectKey($connection, $parameter) { @@ -403,7 +403,7 @@ protected function getPreGeneratedSelectKey($connection, $parameter) * Gets the inserted row ID after executing an insert statement. * @param \Prado\Data\TDbConnection $connection database connection * @param mixed $parameter insert statement parameter. - * @return null|string last insert ID, null otherwise. + * @return ?string last insert ID, null otherwise. */ protected function getPostGeneratedSelectKey($connection, $parameter) { @@ -483,7 +483,7 @@ protected function executePostSelect($connection) /** * Raise the execute query event. - * @param array $sql prepared SQL statement and subsititution parameters + * @param array $sql prepared SQL statement and substitution parameters */ public function onExecuteQuery($sql) { @@ -669,10 +669,10 @@ protected function addResultMapGroupBy($resultMap, $row, $parent, &$resultObject } /** - * Gets the result 'group by' groupping key for each row. + * Gets the result 'group by' grouping key for each row. * @param TResultMap $resultMap result mapping details. * @param array $row a result set row retrieved from the database - * @return string groupping key. + * @return string grouping key. */ protected function getResultMapGroupKey($resultMap, $row) { @@ -884,9 +884,9 @@ public function __wakeup() parent::__wakeup(); } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!count($this->_selectQueue)) { $exprops[] = "\0$cn\0_selectQueue"; @@ -897,6 +897,5 @@ public function __sleep() if (!$this->_IsRowDataFound) { $exprops[] = "\0$cn\0_IsRowDataFound"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Statements/TPreparedCommand.php b/framework/Data/SqlMap/Statements/TPreparedCommand.php index 262de053c..71a52fd4d 100644 --- a/framework/Data/SqlMap/Statements/TPreparedCommand.php +++ b/framework/Data/SqlMap/Statements/TPreparedCommand.php @@ -56,7 +56,7 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, $value = $statement->parameterMap()->getPropertyValue($registry, $property, $parameterObject); $dbType = $property->getDbType(); if ($dbType == '') { //relies on PHP lax comparison - $command->bindValue($i + 1, $value, TDbCommandBuilder::getPdoType($value)); + $command->bindValue($i + 1, $value, $command->getColumnTypeFromValue($value)); } elseif (strpos($dbType, 'PDO::') === 0) { $command->bindValue($i + 1, $value, constant($property->getDbType())); } //assumes PDO types, e.g. PDO::PARAM_INT diff --git a/framework/Data/SqlMap/Statements/TPreparedStatement.php b/framework/Data/SqlMap/Statements/TPreparedStatement.php index 78febcce4..3e338727b 100644 --- a/framework/Data/SqlMap/Statements/TPreparedStatement.php +++ b/framework/Data/SqlMap/Statements/TPreparedStatement.php @@ -60,9 +60,9 @@ public function setParameterValues($value) $this->_parameterValues = $value; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!$this->_parameterNames || !$this->_parameterNames->getCount()) { $exprops[] = "\0$cn\0_parameterNames"; @@ -70,6 +70,5 @@ public function __sleep() if (!$this->_parameterValues || !$this->_parameterValues->getCount()) { $exprops[] = "\0$cn\0_parameterValues"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php b/framework/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php index a62108168..3dc055fe1 100644 --- a/framework/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php +++ b/framework/Data/SqlMap/Statements/TSqlMapObjectCollectionTree.php @@ -195,9 +195,9 @@ protected function getCollection() return $this->_list; } - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - $exprops = []; + parent::_getZappableSleepProps($exprops); $cn = __CLASS__; if (!count($this->_tree)) { $exprops[] = "\0$cn\0_tree"; @@ -208,6 +208,5 @@ public function __sleep() if (!count($this->_list)) { $exprops[] = "\0$cn\0_list"; } - return array_diff(parent::__sleep(), $exprops); } } diff --git a/framework/Data/SqlMap/Statements/TUpsertMappedStatement.php b/framework/Data/SqlMap/Statements/TUpsertMappedStatement.php new file mode 100644 index 000000000..8c5e22ba4 --- /dev/null +++ b/framework/Data/SqlMap/Statements/TUpsertMappedStatement.php @@ -0,0 +1,28 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data\SqlMap\Statements; + +/** + * TUpsertMappedStatement executes upsert mapped statements. + * + * Corresponds to the SqlMap XML element. Behaves identically to + * TInsertMappedStatement but signals to the framework that this is an + * insert-or-update (upsert) operation. The driver-specific SQL (e.g. + * INSERT ... ON DUPLICATE KEY UPDATE or INSERT ... ON CONFLICT ... DO UPDATE SET) + * is written directly in the XML mapping. Optional updateColumns and conflictColumns + * attributes in the element are stored on the TSqlMapUpsert configuration object. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TUpsertMappedStatement extends TInsertMappedStatement +{ +} diff --git a/framework/Data/SqlMap/TSqlMapGateway.php b/framework/Data/SqlMap/TSqlMapGateway.php index 2b3ddc382..2f8f07625 100644 --- a/framework/Data/SqlMap/TSqlMapGateway.php +++ b/framework/Data/SqlMap/TSqlMapGateway.php @@ -17,7 +17,9 @@ use Prado\Prado; /** - * DataMapper client, a fascade to provide access the rest of the DataMapper + * TSqlMapGateway class + * + * DataMapper client, a façade to provide access the rest of the DataMapper * framework. It provides three core functions: * * # execute an update query (including insert and delete). @@ -164,8 +166,8 @@ public function queryForPagedListWithRowDelegate($statementName, $delegate, $par * entered. * @param string $statementName The name of the sql statement to execute. * @param null|mixed $parameter The object used to set the parameters in the SQL. - * @param null|string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value. + * @param ?string $keyProperty The property of the result object to be used as the key. + * @param ?string $valueProperty The property of the result object to be used as the value. * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @return TMap Array object containing the rows keyed by keyProperty. @@ -185,8 +187,8 @@ public function queryForMap($statementName, $parameter = null, $keyProperty = nu * @param string $statementName The name of the sql statement to execute. * @param callable $delegate Row delegate handler, a valid callback required. * @param null|mixed $parameter The object used to set the parameters in the SQL. - * @param null|string $keyProperty The property of the result object to be used as the key. - * @param null|string $valueProperty The property of the result object to be used as the value. + * @param ?string $keyProperty The property of the result object to be used as the key. + * @param ?string $valueProperty The property of the result object to be used as the value. * @param int $skip The number of rows to skip over. * @param int $max The maximum number of rows to return. * @return TMap Array object containing the rows keyed by keyProperty. @@ -208,7 +210,7 @@ public function queryForMapWithRowDelegate($statementName, $delegate, $parameter * INSERT values. * * @param string $statementName The name of the statement to execute. - * @param null|string $parameter The parameter object. + * @param ?string $parameter The parameter object. * @return mixed The primary key of the newly inserted row. * This might be automatically generated by the RDBMS, * or selected from a sequence table or other source. diff --git a/framework/Data/SqlMap/TSqlMapManager.php b/framework/Data/SqlMap/TSqlMapManager.php index 91fe8a93d..d71861ae3 100644 --- a/framework/Data/SqlMap/TSqlMapManager.php +++ b/framework/Data/SqlMap/TSqlMapManager.php @@ -24,6 +24,8 @@ use Prado\Prado; /** + * TSqlMapManager class + * * TSqlMapManager class holds the sqlmap configuation result maps, statements * parameter maps and a type handler factory. * diff --git a/framework/Data/TDataCharset.php b/framework/Data/TDataCharset.php new file mode 100644 index 000000000..2e244f4b3 --- /dev/null +++ b/framework/Data/TDataCharset.php @@ -0,0 +1,99 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data; + +use Prado\TEnumerable; + +/** + * TDataCharset class + * + * TDataCharset enumerates the generic PRADO charset identifiers using + * IANA-registered charset names that can be resolved to driver-specific + * charset names and unresolved back from database-reported charsets. + * + * All constants in this class use the IANA-registered charset name as their + * value (e.g., "UTF-8", "ISO-8859-1", "windows-1252", "US-ASCII"). These are + * the preferred MIME charset names from the IANA Character Sets registry and + * are suitable for use when setting {@see \Prado\Data\TDbConnection::setCharset}. + * + * The mapping between these generic charsets and driver-specific charsets + * is handled by {@see TDbDriverCapabilities::resolveCharset} and + * {@see TDbDriverCapabilities::unresolveCharset}. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TDataCharset extends TEnumerable +{ + /** + * UTF-8 charset (IANA: "UTF-8") + */ + public const UTF8 = 'UTF-8'; + + /** + * UTF-16 charset (IANA: "UTF-16"), native byte order. + * Use {@see UTF16LE} or {@see UTF16BE} when the endianness must be explicit. + */ + public const UTF16 = 'UTF-16'; + + /** + * UTF-16 little-endian charset (IANA: "UTF-16LE"). + * Supported by MySQL (utf16le) and SQLite (UTF-16le PRAGMA encoding). + */ + public const UTF16LE = 'UTF-16LE'; + + /** + * UTF-16 big-endian charset (IANA: "UTF-16BE"). + * Supported by MySQL (utf16), SQLite (UTF-16be PRAGMA encoding), + * Firebird (UTF16BE), and Oracle (AL16UTF16). + */ + public const UTF16BE = 'UTF-16BE'; + + /** + * Latin-1 / ISO-8859-1 charset (IANA: "ISO-8859-1") + */ + public const Latin1 = 'ISO-8859-1'; + + /** + * Latin-2 / ISO-8859-2 charset (IANA: "ISO-8859-2") + */ + public const Latin2 = 'ISO-8859-2'; + + /** + * ASCII / US-ASCII charset (IANA preferred MIME name: "US-ASCII") + */ + public const ASCII = 'US-ASCII'; + + /** + * Windows-1250 (Central European) charset (IANA: "windows-1250") + */ + public const Win1250 = 'windows-1250'; + + /** + * Windows-1251 (Cyrillic) charset (IANA: "windows-1251") + */ + public const Win1251 = 'windows-1251'; + + /** + * Windows-1252 (Western European) charset (IANA: "windows-1252") + */ + public const Win1252 = 'windows-1252'; + + /** + * KOI8-R charset (IANA: "KOI8-R") + */ + public const KOI8R = 'KOI8-R'; + + /** + * KOI8-U charset (IANA: "KOI8-U") + */ + public const KOI8U = 'KOI8-U'; +} diff --git a/framework/Data/TDataSourceConfig.php b/framework/Data/TDataSourceConfig.php index 009aa85bc..7d5555f1e 100644 --- a/framework/Data/TDataSourceConfig.php +++ b/framework/Data/TDataSourceConfig.php @@ -16,6 +16,8 @@ use Prado\TModule; /** + * TDataSourceConfig class + * * TDataSourceConfig module class provides configuration for database connections. * * Example usage: mysql connection diff --git a/framework/Data/TDbColumnCaseMode.php b/framework/Data/TDbColumnCaseMode.php index e6c6b79b4..9bb804f50 100644 --- a/framework/Data/TDbColumnCaseMode.php +++ b/framework/Data/TDbColumnCaseMode.php @@ -11,7 +11,7 @@ namespace Prado\Data; /** - * TDbColumnCaseMode + * TDbColumnCaseMode class * * @author Qiang Xue * @since 3.0 diff --git a/framework/Data/TDbCommand.php b/framework/Data/TDbCommand.php index 963d98516..397c823f7 100644 --- a/framework/Data/TDbCommand.php +++ b/framework/Data/TDbCommand.php @@ -17,9 +17,9 @@ use Prado\Prado; /** - * TDbCommand class. + * TDbCommand class * - * TDbCommand represents an SQL statement to execute against a database. + * TDbCommand represents a PHP PDO SQL statement to execute against a database. * It is usually created by calling {@see \Prado\Data\TDbConnection::createCommand}. * The SQL statement to be executed may be set via {@see setText Text}. * @@ -42,8 +42,11 @@ */ class TDbCommand extends \Prado\TComponent implements IDataCommand { + /** @var TDbConnection The connection of the command. */ private $_connection; + /** @var string The sql command. */ private $_text = ''; + /** @var ?PDOStatement The command statement. */ private $_statement; /** @@ -53,17 +56,22 @@ class TDbCommand extends \Prado\TComponent implements IDataCommand */ public function __construct(TDbConnection $connection, $text) { - $this->_connection = $connection; + $this->setConnection($connection); $this->setText($text); parent::__construct(); } /** - * Set the statement to null when serializing. + * Excludes the prepared {@see PDOStatement} from serialization. + * The statement is not serializable and will be recreated on demand + * by {@see prepare()} after deserialization. + * @param array $exprops by reference, list of property names to exclude. + * @since 4.3.3 */ - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - return array_diff(parent::__sleep(), ["\0TDbCommand\0_statement"]); + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . TDbCommand::class . "\0_statement"; } /** @@ -94,7 +102,16 @@ public function getConnection() } /** - * @return PDOStatement the underlying PDOStatement for this command + * @param \Prado\Data\TDbConnection $value the connection associated with this command + * @since 4.3.3 + */ + protected function setConnection($value) + { + $this->_connection = $value; + } + + /** + * @return ?PDOStatement the underlying PDOStatement for this command * It could be null if the statement is not prepared yet. */ public function getPdoStatement() @@ -102,6 +119,15 @@ public function getPdoStatement() return $this->_statement; } + /** + * @param ?PDOStatement $value the underlying PDOStatement for this command + * @since 4.3.3 + */ + protected function setPdoStatement($value) + { + $this->_statement = $value; + } + /** * Prepares the SQL statement to be executed. * For complex SQL statement that is to be executed multiple times, @@ -111,9 +137,10 @@ public function getPdoStatement() */ public function prepare() { - if ($this->_statement == null) { + if ($this->getPdoStatement() == null) { try { - $this->_statement = $this->getConnection()->getPdoInstance()->prepare($this->getText()); + $statement = $this->getConnection()->getPdoInstance()->prepare($this->getText()); + $this->setPdoStatement($statement); } catch (Exception $e) { throw new TDbException('dbcommand_prepare_failed', $e->getMessage(), $this->getText()); } @@ -125,7 +152,7 @@ public function prepare() */ public function cancel() { - $this->_statement = null; + $this->setPdoStatement(null); } /** @@ -145,11 +172,11 @@ public function bindParameter($name, &$value, $dataType = null, $length = null) { $this->prepare(); if ($dataType === null) { - $this->_statement->bindParam($name, $value); + $this->getPdoStatement()->bindParam($name, $value); } elseif ($length === null) { - $this->_statement->bindParam($name, $value, $dataType); + $this->getPdoStatement()->bindParam($name, $value, $dataType); } else { - $this->_statement->bindParam($name, $value, $dataType, $length); + $this->getPdoStatement()->bindParam($name, $value, $dataType, $length); } } @@ -167,10 +194,46 @@ public function bindValue($name, $value, $dataType = null) { $this->prepare(); if ($dataType === null) { - $this->_statement->bindValue($name, $value); + $this->getPdoStatement()->bindValue($name, $value); } else { - $this->_statement->bindValue($name, $value, $dataType); + $this->getPdoStatement()->bindValue($name, $value, $dataType); + } + } + + /** + * Returns the driver-specific type token for a given PHP value, inferred from + * the value's runtime type. + * + * For PDO-backed commands this maps PHP types to `PDO::PARAM_*` constants: + * + * | PHP type | PDO constant | + * |-------------|-------------------| + * | `boolean` | `PDO::PARAM_BOOL` | + * | `integer` | `PDO::PARAM_INT` | + * | `string` | `PDO::PARAM_STR` | + * | `NULL` | `PDO::PARAM_NULL` | + * | other | `null` | + * + * Non-SQL driver implementations may return a different type representation; + * the return type on {@see IDataCommand} is therefore `mixed`. + * + * This method supersedes the deprecated static + * {@see \Prado\Data\Common\TDbCommandBuilder::getPdoType()}. + * + * @param mixed $value the PHP value to inspect. + * @return mixed the PDO::PARAM_* constant for this driver, or null when the + * PHP type has no direct mapping. + * @since 4.3.3 + */ + public function getColumnTypeFromValue($value) + { + switch (gettype($value)) { + case 'boolean': return PDO::PARAM_BOOL; + case 'integer': return PDO::PARAM_INT; + case 'string': return PDO::PARAM_STR; + case 'NULL': return PDO::PARAM_NULL; } + return null; } /** @@ -183,11 +246,12 @@ public function bindValue($name, $value, $dataType = null) public function execute() { try { + $statement = $this->getPdoStatement(); // Do not trace because it will remain even in Performance mode // Prado::trace('Execute Command: '.$this->getDebugStatementText(), TDbCommand::class); - if ($this->_statement instanceof PDOStatement) { - $this->_statement->execute(); - return $this->_statement->rowCount(); + if ($statement instanceof PDOStatement) { + $statement->execute(); + return $statement->rowCount(); } else { return $this->getConnection()->getPdoInstance()->exec($this->getText()); } @@ -201,9 +265,10 @@ public function execute() */ public function getDebugStatementText() { + $statement = $this->getPdoStatement(); //if(Prado::getApplication()->getMode() === TApplicationMode::Debug) - return $this->_statement instanceof PDOStatement ? - $this->_statement->queryString + return $statement instanceof PDOStatement ? + $statement->queryString : $this->getText(); } @@ -216,11 +281,13 @@ public function getDebugStatementText() public function query() { try { + $statement = $this->getPdoStatement(); // Prado::trace('Query: '.$this->getDebugStatementText(), TDbCommand::class); - if ($this->_statement instanceof PDOStatement) { - $this->_statement->execute(); + if ($statement instanceof PDOStatement) { + $statement->execute(); } else { - $this->_statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $this->setPdoStatement($statement); } return new TDbDataReader($this); } catch (Exception $e) { @@ -239,14 +306,16 @@ public function query() public function queryRow($fetchAssociative = true) { try { + $statement = $this->getPdoStatement(); // Prado::trace('Query Row: '.$this->getDebugStatementText(), TDbCommand::class); - if ($this->_statement instanceof PDOStatement) { - $this->_statement->execute(); + if ($statement instanceof PDOStatement) { + $statement->execute(); } else { - $this->_statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $this->setPdoStatement($statement); } - $result = $this->_statement->fetch($fetchAssociative ? PDO::FETCH_ASSOC : PDO::FETCH_NUM); - $this->_statement->closeCursor(); + $result = $statement->fetch($fetchAssociative ? PDO::FETCH_ASSOC : PDO::FETCH_NUM); + $statement->closeCursor(); return $result; } catch (Exception $e) { throw new TDbException('dbcommand_query_failed', $e->getMessage(), $this->getDebugStatementText()); @@ -263,14 +332,16 @@ public function queryRow($fetchAssociative = true) public function queryScalar() { try { + $statement = $this->getPdoStatement(); // Prado::trace('Query Scalar: '.$this->getDebugStatementText(), TDbCommand::class); - if ($this->_statement instanceof PDOStatement) { - $this->_statement->execute(); + if ($statement instanceof PDOStatement) { + $statement->execute(); } else { - $this->_statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $statement = $this->getConnection()->getPdoInstance()->query($this->getText()); + $this->setPdoStatement($statement); } - $result = $this->_statement->fetchColumn(); - $this->_statement->closeCursor(); + $result = $statement->fetchColumn(); + $statement->closeCursor(); if (is_resource($result) && get_resource_type($result) === 'stream') { return stream_get_contents($result); } else { diff --git a/framework/Data/TDbConnection.php b/framework/Data/TDbConnection.php index 2d60a6736..eadceeccf 100644 --- a/framework/Data/TDbConnection.php +++ b/framework/Data/TDbConnection.php @@ -13,6 +13,7 @@ use PDO; use PDOException; use Prado\Data\Common\TDbMetaData; +use Prado\Data\IDbConnection; use Prado\Exceptions\TDbException; use Prado\Prado; use Prado\TPropertyValue; @@ -20,7 +21,7 @@ /** * TDbConnection class * - * TDbConnection represents a connection to a database. + * TDbConnection represents a PHP PDO connection to a database. * * TDbConnection works together with {@see \Prado\Data\TDbCommand}, * {@see \Prado\Data\TDbDataReader} and {@see \Prado\Data\TDbTransaction} to @@ -32,26 +33,30 @@ * specifying {@see setConnectionString ConnectionString}, * {@see setUsername Username} and {@see setPassword Password}. * - * Since 4.3.3, the connection charset could be set (for PDO databases, except - * IBM) using the {@see setCharset Charset} property. The value of this property - * was database **independent**. + * Since 4.3.3, the connection charset can be set (for all PDO drivers except + * IBM DB2) via the {@see setCharset Charset} property using driver-independent + * IANA-style names such as 'UTF-8' or 'ISO-8859-1'; the value is translated to + * the driver-specific format automatically. * - * Firebird (firebird), MSSQL (mssql, sqlsrv, dblib), IBM DB2 (ibm), and - * Oracle (oci) do not support runtime charset switching via SQL; configure - * their charset at the DSN or with {@see setCharset Charset} property before - * activating the connection. + * Firebird (firebird), SQL Server (sqlsrv, dblib), and Oracle (oci) do not + * support runtime charset switching via SQL; their charset must be configured + * before the connection is opened (it is injected into the DSN automatically). + * IBM DB2 (ibm) has no charset support at all. * - * Most formats of the Charset are supported and translated to the proper - * database specific charset. The database specific format gat be retrieved - * on active connections with the method {@see getDatabaseCharset()}. - * Only mysql, pgsql, sqlite and firebird support discovery of the database - * charset. + * The driver-specific charset name in use can be retrieved from an active + * connection via {@see getDatabaseCharset()}. Live charset discovery (by + * querying the server) is supported for mysql, pgsql, sqlite, and firebird; + * for other drivers the resolved charset property value is returned. These + * charsets inspect the dns for overriding charset to retrieve it for the + * property, or sets the charset in the dns from the property. * - * Pgsql, sqlite, ibm databases do not support DSN charset. - * Pgsql must set the charset after the connection is established. - * sqlite only supports UTF-8 and UTF-16, set before tables are created. - * When a table is present in sqlite, {@see setCharset()} becomes no-op. - * Ibm Db2 has no charset support. + * PostgreSQL and SQLite do not support DSN-level charset; both apply their + * charset via a post-connect command. PostgreSQL issues `SET client_encoding TO ?` + * unconditionally. SQLite issues `PRAGMA encoding = `, which only + * takes effect on a brand-new database with no tables; on existing databases + * it is silently ignored and the encoding established at creation time is + * preserved. In either case the connection's Charset property is synced to + * the database's actual encoding after connect. * * The following example shows how to create a TDbConnection instance and * establish the actual connection: @@ -100,13 +105,12 @@ * of certain DBMS attributes, such as {@see getNullConversion NullConversion}. * * @author Qiang Xue - * @author Brad Anderson Charset. + * @author Brad Anderson Charset, TDbDriverCapabilities * @since 3.0 */ -class TDbConnection extends \Prado\TComponent implements IDataConnection +class TDbConnection extends \Prado\TComponent implements IDbConnection { /** - * * @since 3.1.7 */ public const DEFAULT_TRANSACTION_CLASS = \Prado\Data\TDbTransaction::class; @@ -117,6 +121,7 @@ class TDbConnection extends \Prado\TComponent implements IDataConnection private $_charset = ''; private $_attributes = []; private $_active = false; + private $_pdo; private $_transaction; @@ -126,25 +131,27 @@ class TDbConnection extends \Prado\TComponent implements IDataConnection private $_dbMeta; /** - * @var string + * @var string Fully-qualified class name used to allocate transaction objects. + * Defaults to {@see DEFAULT_TRANSACTION_CLASS} (TDbTransaction). + * Never null: {@see setTransactionClass} resets to the default on empty/null input. * @since 3.1.7 */ private $_transactionClass = self::DEFAULT_TRANSACTION_CLASS; /** * Constructor. - * Note, the DB connection is not established when this connection - * instance is created. Set {@see setActive Active} property to true - * to establish the connection. - * Since 3.1.2, you can set the charset for MySql connection * - * @param string $dsn The Data Source Name, or DSN, contains the information required to connect to the database. + * The DB connection is not established until {@see setActive Active} is set + * to true. + * + * @param string $dsn The Data Source Name containing the information required + * to connect to the database. * @param string $username The user name for the DSN string. * @param string $password The password for the DSN string. - * @param string $charset Charset used for DB Connection; except IBM DB2 (ibm). - * MSSQL (mssql, sqlsrv, dblib), and Oracle (oci) require configuration - * of the charset before opening. - * If not set, will use the default charset of your database server. + * @param string $charset Charset for the connection (driver-independent name, + * e.g. 'UTF-8'). Not supported for IBM DB2 (ibm). For SQL Server and Oracle + * the value is applied at DSN level before the connection opens; for other + * drivers it is applied after connect. Defaults to empty (server default). * @see http://www.php.net/manual/en/function.PDO-construct.php */ public function __construct($dsn = '', $username = '', #[\SensitiveParameter] $password = '', $charset = '') @@ -157,16 +164,33 @@ public function __construct($dsn = '', $username = '', #[\SensitiveParameter] $p } /** - * Close the connection when serializing. + * Excludes non-serializable and connection-runtime state from serialization. + * + * `_pdo` is excluded because PDO instances are never serializable. + * `_active` is excluded because the connection cannot survive serialization; + * it will be `false` (the declared default) after deserialization and the + * caller is responsible for reopening it. + * `_transaction` is excluded because an in-flight transaction requires a + * live PDO; without one it would be inconsistent after deserialization. + * `_dbMeta` is excluded when null because it is a lazy-loaded cache that + * will be repopulated on first use; a populated instance is worth keeping. + * + * Note: the connection is intentionally NOT closed during serialization + * because serializing does not necessarily mean the connection is no longer + * needed in the current process. + * + * @param array $exprops by reference, list of property names to exclude. + * @since 4.3.3 */ - public function __sleep() + protected function _getZappableSleepProps(&$exprops) { - /* - * $this->close(); - * DO NOT CLOSE the current connection as serializing doesn't necessarily mean - * we don't this connection anymore in the current session - */ - return array_diff(parent::__sleep(), ["\0Prado\Data\TDbConnection\0_pdo", "\0Prado\Data\TDbConnection\0_active"]); + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . TDbConnection::class . "\0_pdo"; + $exprops[] = "\0" . TDbConnection::class . "\0_active"; + $exprops[] = "\0" . TDbConnection::class . "\0_transaction"; + if ($this->_dbMeta === null) { + $exprops[] = "\0" . TDbConnection::class . "\0_dbMeta"; + } } /** @@ -209,31 +233,112 @@ public function setActive($value) */ protected function open() { - if ($this->_pdo === null) { - try { - $this->_pdo = new PDO( - $this->applyCharsetToDsn($this->getConnectionString()), - $this->getUsername(), - $this->getPassword(), - $this->_attributes - ); - // This attribute is only useful for PDO::MySql driver. - // Ignore the warning if a driver doesn't understand this. - @$this->_pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); + $pdo = $this->getPdoInstance(); + + if ($pdo !== null) { + return; + } + + $dsn = $this->getConnectionString(); + $charsetInDsn = $this->extractCharsetFromDsn($dsn); + + try { + $pdo = $this->_pdo = new PDO( + $this->applyCharsetToDsn($dsn), + $this->getUsername(), + $this->getPassword(), + $this->_attributes + ); + + { // For Mysql, ignore otherwise + @$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, true); // This attribute is only useful for PDO::MySql driver since PHP 8.1 // This ensures integers are returned as strings (needed eg. for ZEROFILL columns) - @$this->_pdo->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); - $this->_pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->_active = true; - if ($this->getCanCharsetChange()) { - $this->setConnectionCharset(); + @$pdo->setAttribute(PDO::ATTR_STRINGIFY_FETCHES, true); + } + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->_active = true; + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + // If DSN had a charset, it takes precedence -> reset charset property if different + if ($charsetInDsn !== null) { + $newPropCharset = TDbDriverCapabilities::unresolveCharset($charsetInDsn, $driver); + if (TDbDriverCapabilities::canonicalizeCharset($this->_charset) !== + TDbDriverCapabilities::canonicalizeCharset($newPropCharset)) { + $this->_charset = $newPropCharset; + } + } elseif ($this->_charset !== '') { + //allow only certain charsets (ahem: sqlsrv) + $accepted = TDbDriverCapabilities::getDsnAcceptedCharsets($driver); + if ($accepted !== null) { + $resolved = TDbDriverCapabilities::resolveCharset($this->_charset, $driver); + if (!in_array($resolved, $accepted, true)) { + $this->_charset = ''; + } + } + } + + if (TDbDriverCapabilities::requiresPostConnectCharset($driver)) { + // PostgreSQL: no DSN charset parameter; charset applied via SET client_encoding TO ? + $this->setConnectionCharset($this->getCharset()); + } + + if (TDbDriverCapabilities::requiresPostConnectCharsetReadback($driver)) { + // SQLite: apply PRAGMA encoding first (silently ignored when tables exist), + // then always read back the actual encoding so _charset reflects what the + // database really has rather than what was requested. + if ($this->getCharset() !== '') { + $this->setConnectionCharset($this->getCharset()); + } + $charsetQuerySql = TDbDriverCapabilities::getCharsetQuerySql($driver); + if ($charsetQuerySql !== null) { + $actual = $pdo->query($charsetQuerySql)->fetchColumn(); + if ($actual !== false && $actual !== '') { + $this->_charset = TDbDriverCapabilities::unresolveCharset((string) $actual, $driver); + } } - } catch (PDOException $e) { - throw new TDbException('dbconnection_open_failed', $e->getMessage()); } + } catch (PDOException $e) { + throw new TDbException('dbconnection_open_failed', $e->getMessage()); } } + /** + * Extracts the charset value from a DSN string, if present. + * + * Uses the driver-specific DSN pattern from + * {@see TDbDriverCapabilities::getCharsetDsnPattern} to detect a charset + * directive in the DSN, and returns the value if found. + * + * This is used during connection opening to capture any charset that was + * embedded in the DSN so it can be unresolved back to the PRADO charset + * via {@see TDbDriverCapabilities::unresolveCharset}. + * + * @param string $dsn the DSN string to inspect + * @return ?string the charset value from the DSN, or null if not present + * @since 4.3.3 + */ + protected function extractCharsetFromDsn(string $dsn): ?string + { + $driver = $this->extractDriverFromDsn($dsn); + if ($driver === null) { + return null; + } + + $pattern = TDbDriverCapabilities::getCharsetDsnPattern($driver); + + if ($pattern === null) { + return null; + } + + $existingPattern = TDbDriverCapabilities::getCharsetDsnPattern($driver); + if ($existingPattern !== null && preg_match($existingPattern, $dsn, $matches)) { + return trim($matches[1]); + } + + return null; + } + /** * Closes the currently active DB connection. * It does nothing if the connection is already closed. @@ -244,7 +349,7 @@ protected function close() $this->_active = false; } - /* + /** * Apply the connection charset via a driver-appropriate SQL command. * * MySQL uses SET NAMES . @@ -252,196 +357,58 @@ protected function close() * SQLite uses PRAGMA encoding = which can only take effect * before any tables are created; errors are silently ignored so the method * is safe to call on any SQLite connection regardless of state. - * Firebird, Oracle (oci), MSSQL (mssql, sqlsrv, dblib), and IBM DB2 (ibm) do not + * Firebird, Oracle (oci), SQL Server (sqlsrv, dblib), and IBM DB2 (ibm) do not * support runtime charset switching via SQL; their charset is injected into * the DSN before the connection opens by {@see applyCharsetToDsn}. * Changing Charset after the connection is already active has no effect for * those drivers. * - * All charset values are resolved through {@see resolveCharsetForDriver} + * All charset values are resolved through {@see TDbDriverCapabilities::resolveCharset} * before being sent to the database, so universal names like 'UTF-8' or * 'ISO-8859-1' work across all supported drivers without any * driver-specific knowledge from the caller. + * @param ?string $charset * @since 3.1.2 */ - protected function setConnectionCharset() + protected function setConnectionCharset(?string $charset = null) { - if ($this->_charset === '' || $this->_active === false) { + if ($charset === null) { + $charset = $this->getCharset(); + } + + if ($charset === '' || $this->getActive() === false) { return; } - $driver = $this->_pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - $charset = $this->resolveCharsetForDriver($this->_charset, $driver); - switch ($driver) { - case 'mysql': - $stmt = $this->_pdo->prepare('SET NAMES ?'); - break; - case 'pgsql': - $stmt = $this->_pdo->prepare('SET client_encoding TO ?'); - break; - case 'sqlite': - // PRAGMA encoding sets the internal storage encoding, but only takes - // effect before any tables are created. PRAGMA does not support - // parameterised values, so PDO::quote is used to safely embed the - // resolved charset name. - try { - $this->_pdo->exec('PRAGMA encoding = ' . $this->_pdo->quote($charset)); - } catch (\Exception $e) { - // Silently ignored. - } - return; - case 'firebird': - case 'mssql': - case 'sqlsrv': - case 'dblib': - case 'ibm': - case 'oci': - // These drivers do not support runtime charset switching via SQL. - return; - default: - throw new TDbException('dbconnection_unsupported_driver_charset', $driver); + $pdo = $this->getPdoInstance(); + $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $charset = TDbDriverCapabilities::resolveCharset($charset, $driver); + + if (($pragmaSql = TDbDriverCapabilities::getCharsetPragmaSql($driver)) !== null) { + try { // SQLite, and only before tables are created. + $pdo->exec(sprintf($pragmaSql, $pdo->quote($charset))); + } catch (PDOException $e) { + // Silently ignored. + } + return; } - $stmt->execute([$charset]); - } - /** - * Resolves a charset name to its driver-specific equivalent, allowing callers to - * use universal IANA-style names like 'UTF-8' or 'ISO-8859-1' regardless of the - * underlying database driver. - * - * The lookup key is derived by lowercasing $charset and stripping all hyphens, - * underscores, and spaces, so 'UTF-8', 'utf8', 'UTF_8', and 'Utf 8' all resolve - * to the same entry. If no mapping is found the original $charset string is - * returned unchanged, preserving backward compatibility with driver-specific names. - * - * The same table is used by both {@see setConnectionCharset} (SQL-level charset - * commands) and {@see applyCharsetToDsn} (DSN parameter injection), so driver - * columns for oci, sqlsrv, mssql, and dblib resolve to their DSN charset values. - * - * Override this method to add or change mappings for custom database configurations. - * - * @param string $charset the charset name as supplied by the caller (e.g. 'UTF-8') - * @param string $driver PDO driver name (e.g. 'mysql', 'pgsql', 'firebird', 'oci') - * @return string the charset name appropriate for $driver - * @since 4.3.3 - */ - protected function resolveCharsetForDriver(string $charset, string $driver): string - { - static $aliases = [ - // canonical_key => [driver => resolved_name, ...] - // Key = charset lowercased with hyphens, underscores, and spaces removed. - // Drivers mysql/pgsql/firebird: SQL-level charset names. - // Drivers sqlite: PRAGMA encoding values (only UTF-8 and UTF-16 variants - // are valid; unsupported values are passed through and silently ignored). - // Drivers oci/sqlsrv/mssql/dblib: DSN-parameter charset names. - 'utf8' => [ - 'mysql' => 'utf8mb4', - 'sqlite' => 'UTF-8', - 'pgsql' => 'UTF8', - 'firebird' => 'UTF8', - 'oci' => 'AL32UTF8', - 'sqlsrv' => 'UTF-8', - 'mssql' => 'UTF-8', - 'dblib' => 'UTF-8', - ], - 'utf8mb4' => [ - 'mysql' => 'utf8mb4', - 'sqlite' => 'UTF-8', - 'pgsql' => 'UTF8', - 'firebird' => 'UTF8', - 'oci' => 'AL32UTF8', - 'sqlsrv' => 'UTF-8', - 'mssql' => 'UTF-8', - 'dblib' => 'UTF-8', - ], - 'utf16' => [ - 'mysql' => 'utf16', - 'sqlite' => 'UTF-16', - 'firebird' => 'UTF16BE', - 'oci' => 'AL16UTF16', - ], - 'latin1' => [ - 'mysql' => 'latin1', - // sqlite: no PRAGMA encoding support for latin1 — pass-through and - // silently ignored; SQLite stores all text internally as UTF-8/UTF-16. - 'pgsql' => 'LATIN1', - 'firebird' => 'ISO8859_1', - 'oci' => 'WE8ISO8859P1', - 'mssql' => 'ISO-8859-1', - 'dblib' => 'ISO-8859-1', - ], - 'iso88591' => 'latin1', - 'latin2' => [ - 'mysql' => 'latin2', - 'pgsql' => 'LATIN2', - 'firebird' => 'ISO8859_2', - 'oci' => 'EE8ISO8859P2', - 'mssql' => 'ISO-8859-2', - 'dblib' => 'ISO-8859-2', - ], - 'iso88592' => 'latin2', - 'ascii' => [ - 'mysql' => 'ascii', - 'pgsql' => 'SQL_ASCII', - 'firebird' => 'ASCII', - 'oci' => 'US7ASCII', - 'mssql' => 'ASCII', - 'dblib' => 'ASCII', - ], - 'win1250' => [ - 'mysql' => 'cp1250', - 'pgsql' => 'WIN1250', - 'firebird' => 'WIN1250', - 'oci' => 'EE8MSWIN1250', - 'mssql' => 'CP1250', - 'dblib' => 'CP1250', - ], - 'windows1250' => 'win1250', - 'cp1250' => 'win1250', - 'win1251' => [ - 'mysql' => 'cp1251', - 'pgsql' => 'WIN1251', - 'firebird' => 'WIN1251', - 'oci' => 'CL8MSWIN1251', - 'mssql' => 'CP1251', - 'dblib' => 'CP1251', - ], - 'windows1251' => 'win1251', - 'cp1251' => 'win1251', - 'win1252' => [ - 'mysql' => 'cp1252', - 'pgsql' => 'WIN1252', - 'firebird' => 'WIN1252', - 'oci' => 'WE8MSWIN1252', - 'mssql' => 'CP1252', - 'dblib' => 'CP1252', - ], - 'windows1252' => 'win1252', - 'cp1252' => 'win1252', - 'koi8r' => [ - 'mysql' => 'koi8r', - 'pgsql' => 'KOI8R', - 'firebird' => 'KOI8R', - 'oci' => 'CL8KOI8R', - 'mssql' => 'KOI8-R', - 'dblib' => 'KOI8-R', - ], - 'koi8u' => [ - 'mysql' => 'koi8u', - 'pgsql' => 'KOI8U', - 'firebird' => 'KOI8U', - 'oci' => 'CL8KOI8U', - 'mssql' => 'KOI8-U', - 'dblib' => 'KOI8-U', - ], - ]; - - $key = strtolower(preg_replace('/[-_ ]+/', '', $charset)); - - if (isset($aliases[$key]) && is_string($aliases[$key])) { - $key = $aliases[$key]; - } - - return $aliases[$key][$driver] ?? $charset; + if (($sql = TDbDriverCapabilities::getCharsetSetSql($driver)) !== null) { + $pdo->prepare($sql)->execute([$charset]); + return; + } + + if (TDbDriverCapabilities::getCharsetDsnParam($driver) !== null) { + // Driver configures charset via DSN (Firebird, Oracle, SQL Server); + // runtime switching via SQL is not supported. + return; + } + + if (!TDbDriverCapabilities::supportsCharset($driver)) { + // Driver has no charset support at all (IBM DB2); silently ignore. + return; + } + + throw new TDbException('dbconnection_unsupported_driver_charset', $driver); } /** @@ -451,23 +418,23 @@ protected function resolveCharsetForDriver(string $charset, string $driver): str * * This method is called by {@see open} before the PDO instance is created so * that drivers which only support charset configuration at connection time - * (Oracle, MSSQL family) receive the correct encoding without requiring the + * (Oracle, SQL Server) receive the correct encoding without requiring the * caller to embed a driver-specific parameter in the DSN manually. * * The internal {@see $_dsn} field is never mutated; the method returns a * (potentially modified) copy. DSN charset takes priority: if the caller * already included a charset directive in the DSN it is left unchanged. * - * Drivers handled (DSN parameter name): - * mysql, firebird → charset= - * oci → charset= - * sqlsrv → CharacterSet= - * mssql, dblib → charset= - * - * PostgreSQL has no standard DSN charset parameter (charset is applied via - * {@see setConnectionCharset} after the connection opens). SQLite is always - * UTF-8. IBM DB2 (ibm) has no reliable DSN charset parameter. These drivers - * are returned unchanged. + * Driver capabilities (parameter name, detection pattern, and accepted values) + * are provided by {@see TDbDriverCapabilities::getCharsetDsnParam}, + * {@see TDbDriverCapabilities::getCharsetDsnPattern}, and + * {@see TDbDriverCapabilities::getDsnAcceptedCharsets}. + * PostgreSQL, SQLite, and IBM DB2 have no DSN charset parameter and are + * returned unchanged. For drivers with a restricted allowlist (e.g. pdo_sqlsrv, + * which only accepts 'UTF-8' or 'SQLSRV_ENC_CHAR'), the charset is silently + * omitted from the DSN when the resolved value is not in the allowlist; {@see open} + * then clears the Charset property so {@see getDatabaseCharset} reflects the + * actual connection state rather than the unmet user intent. * * @param string $dsn the raw DSN string as set by the caller * @return string the DSN, with a charset parameter appended if required @@ -475,36 +442,35 @@ protected function resolveCharsetForDriver(string $charset, string $driver): str */ protected function applyCharsetToDsn(string $dsn): string { - if ($this->_charset === '' || $dsn === '') { + $charset = $this->getCharset(); + if ($charset === '' || $dsn === '') { return $dsn; } $driver = $this->getDriverName(); + $paramName = TDbDriverCapabilities::getCharsetDsnParam($driver); - // Maps each supported driver to [dsn_param_name, regex_detecting_existing_param]. - // Drivers absent from this table (pgsql, sqlite, ibm) are returned unchanged. - $dsnCharsetParams = [ - 'mysql' => ['charset', '/[;?]charset\s*=/i'], - 'firebird' => ['charset', '/[;?]charset\s*=/i'], - 'oci' => ['charset', '/[;?]charset\s*=/i'], - 'sqlsrv' => ['CharacterSet', '/[;?]CharacterSet\s*=/i'], - 'mssql' => ['charset', '/[;?]charset\s*=/i'], - 'dblib' => ['charset', '/[;?]charset\s*=/i'], - ]; - - if (!isset($dsnCharsetParams[$driver])) { + if ($paramName === null) { // Driver does not use a DSN charset parameter (pgsql, sqlite, ibm, …). return $dsn; } - [$paramName, $existingPattern] = $dsnCharsetParams[$driver]; - // If the caller already embedded a charset directive, honour it (DSN wins). - if (preg_match($existingPattern, $dsn)) { + $existingPattern = TDbDriverCapabilities::getCharsetDsnPattern($driver); + if ($existingPattern !== null && preg_match($existingPattern, $dsn)) { return $dsn; } - $resolved = $this->resolveCharsetForDriver($this->_charset, $driver); + $resolved = TDbDriverCapabilities::resolveCharset($charset, $driver); + + // Some drivers only accept a restricted set of values in the DSN charset + // parameter (e.g. pdo_sqlsrv only accepts 'UTF-8' or 'SQLSRV_ENC_CHAR'). + // If the resolved value is not in the allowlist, skip DSN injection to + // avoid a connection failure. + $accepted = TDbDriverCapabilities::getDsnAcceptedCharsets($driver); + if ($accepted !== null && !in_array($resolved, $accepted, true)) { + return $dsn; + } return $dsn . ';' . $paramName . '=' . $resolved; } @@ -523,7 +489,7 @@ public function getConnectionString() */ public function setConnectionString($value) { - $this->_dsn = $value; + $this->_dsn = TPropertyValue::ensureString($value); } /** @@ -539,7 +505,7 @@ public function getUsername() */ public function setUsername($value) { - $this->_username = $value; + $this->_username = TPropertyValue::ensureString($value); } /** @@ -555,7 +521,7 @@ public function getPassword() */ public function setPassword(#[\SensitiveParameter] $value) { - $this->_password = $value; + $this->_password = (string) $value; //Sensitive } /** @@ -573,22 +539,25 @@ public function getCharset() public function setCharset($value) { $driver = $this->getDriverName(); - if (!$this->getCanCharsetChange()) { + if ($this->getActive() && !TDbDriverCapabilities::supportsRuntimeCharsetSet($driver)) { throw new TDbException('dbconnection_charset_unchangeable', $driver); } + $value = TPropertyValue::ensureString($value); $this->_charset = $value; - $this->setConnectionCharset(); - } - - /** - * If the connection is not active or - * @return bool if the charset can change - * @since 4.3.3 - */ - public function getCanCharsetChange(): bool - { - $driver = $this->getDriverName(); - return !$this->getActive() || in_array($driver, ['mysql', 'pgsql', 'sqlite']); + $this->setConnectionCharset($value); + + // SQLite: PRAGMA encoding is silently ignored when tables already exist. + // Read back the actual encoding so _charset reflects what the DB has, + // not what was requested. + if ($this->getActive() && TDbDriverCapabilities::requiresPostConnectCharsetReadback($driver)) { + $charsetQuerySql = TDbDriverCapabilities::getCharsetQuerySql($driver); + if ($charsetQuerySql !== null) { + $actual = $this->getPdoInstance()->query($charsetQuerySql)->fetchColumn(); + if ($actual !== false && $actual !== '') { + $this->_charset = TDbDriverCapabilities::unresolveCharset((string) $actual, $driver); + } + } + } } /** @@ -607,7 +576,7 @@ public function getCanCharsetChange(): bool * firebird — MON$ATTACHMENTS ⋈ RDB$CHARACTER_SETS; falls back to the * resolved Charset property value if the MONITOR privilege is * absent - * oci, mssql, sqlsrv, dblib, ibm — charset is configured at the DSN + * oci, sqlsrv, dblib, ibm — charset is configured at the DSN * level and cannot be queried cheaply; returns the charset * name as resolved for the driver from the Charset property * @@ -620,35 +589,23 @@ public function getCanCharsetChange(): bool */ public function getDatabaseCharset() { - if (!$this->_active || $this->_pdo === null) { - return $this->_charset; + if (!$this->getActive() || $this->getPdoInstance() === null) { + return $this->getCharset(); } $driver = $this->getDriverName(); try { - switch ($driver) { - case 'mysql': - return (string) $this->createCommand('SELECT @@character_set_connection')->queryScalar(); - case 'pgsql': - return (string) $this->createCommand('SELECT pg_client_encoding()')->queryScalar(); - case 'sqlite': - return (string) $this->createCommand('PRAGMA encoding')->queryScalar(); - case 'firebird': - $result = $this->createCommand( - 'SELECT TRIM(c.RDB$CHARACTER_SET_NAME)' . - ' FROM MON$ATTACHMENTS a' . - ' JOIN RDB$CHARACTER_SETS c' . - ' ON c.RDB$CHARACTER_SET_ID = a.MON$CHARACTER_SET_ID' . - ' WHERE a.MON$ATTACHMENT_ID = CURRENT_CONNECTION' - )->queryScalar(); - return ($result !== false && $result !== null) - ? (string) $result - : $this->resolveCharsetForDriver($this->_charset, $driver); - default: - // Drivers that configure charset via DSN (oci, mssql, sqlsrv, dblib, ibm): - // return the charset name as it was resolved for this driver so the caller - // can confirm what was injected into the connection string. - return $this->resolveCharsetForDriver($this->_charset, $driver); + $sql = TDbDriverCapabilities::getCharsetQuerySql($driver); + if ($sql !== null) { + $result = $this->createCommand($sql)->queryScalar(); + if ($result !== false && $result !== null) { + return (string) $result; + } + return TDbDriverCapabilities::resolveCharset($this->getCharset(), $driver); } + // Drivers that configure charset via DSN (oci, sqlsrv, dblib, ibm): + // return the charset name as it was resolved for this driver so the caller + // can confirm what was injected into the connection string. + return TDbDriverCapabilities::resolveCharset($this->getCharset(), $driver); } catch (\Throwable $e) { return $this->_charset; } @@ -664,64 +621,201 @@ public function getPdoInstance() /** * Creates a command for execution. + * + * The concrete {@see TDbCommand} subclass is selected via + * {@see TDbDriverCapabilities::getCommandClass()} so that driver-specific + * behaviour (e.g. the pdo_oci prepared-statement workaround in + * {@see \Prado\Data\Common\Oracle\TOracleDbCommand}) is applied + * automatically without any driver checks in calling code. + * * @param string $sql SQL statement associated with the new command. * @throws TDbException if the connection is not active * @return TDbCommand the DB command */ public function createCommand($sql) { - if ($this->getActive()) { - return new TDbCommand($this, $sql); - } else { - throw new TDbException('dbconnection_connection_inactive'); - } + $this->assertActive(); + $class = TDbDriverCapabilities::getCommandClass($this->getDriverName()); + return new $class($this, $sql); } /** - * @return null|TDbTransaction the currently active transaction. Null if no active transaction. + * Returns the currently active transaction, or null if none is open. + * Use this to check for an active Transaction. + * @return null|TDbTransaction the active transaction, or null. */ public function getCurrentTransaction() { - if ($this->_transaction !== null) { - if ($this->_transaction->getActive()) { - return $this->_transaction; - } + if ($this->_transaction !== null && $this->_transaction->getActive()) { + return $this->_transaction; } return null; } + /** + * Returns the last {@see TDbTransaction} object associated with this + * connection, whether or not it is still active. + * + * This is the transaction stored internally when {@see beginTransaction()} + * was last called. It differs from {@see getCurrentTransaction()}, which + * returns non-null only while the transaction is open. + * + * The primary use case is inside {@see TDbTransaction::beginTransaction()}: + * before reactivating a completed transaction object the method checks that + * the object is still the last one associated with this connection. If a + * caller has since invoked {@see beginTransaction()} again, a new + * {@see TDbTransaction} is stored here and the old object is considered + * superseded — attempting to restart it would silently bypass the new + * transaction's lifecycle. + * + * @return null|TDbTransaction the last transaction object, or null if + * {@see beginTransaction()} has never been called on this connection. + * @since 4.3.3 + */ + public function getLastTransaction(): ?TDbTransaction + { + return $this->_transaction; + } + + /** + * Creates a new {@see IDataTransaction} for this connection. + * + * @return IDataTransaction A new transaction from this connection. + * @since 4.3.3 + */ + protected function createTransaction(): IDataTransaction + { + return Prado::createComponent($this->getTransactionClass(), $this); + } + /** * Starts a transaction. - * @throws TDbException if the connection is not active - * @return TDbTransaction the transaction initiated + * + * Throws {@see TDbException} if the connection is not active, or if a + * transaction is already open (i.e. {@see getCurrentTransaction()} returns + * non-null). Commit or roll back the current transaction before starting + * a new one. + * + * Each call allocates a **new** {@see TDbTransaction} object and stores it + * as the last transaction via {@see getLastTransaction()}. Any previously + * returned transaction object is superseded: calling + * {@see TDbTransaction::beginTransaction()} on it will throw because it is + * no longer the connection's current transaction object. + * + * For pdo_firebird, a pre-begin flush (PDO::commit()) is issued before + * PDO::beginTransaction() to clear Firebird's always-running implicit + * transaction; without this the driver throws "There is already an active + * transaction". + * + * @throws TDbException if the connection is not active, or if a transaction + * is already open with uncommitted work. + * @return TDbTransaction the transaction object for the new work unit. + * @see TDbTransaction::beginTransaction */ public function beginTransaction() { - if ($this->getActive()) { - $this->_pdo->beginTransaction(); - return $this->_transaction = Prado::createComponent($this->getTransactionClass(), $this); - } else { - throw new TDbException('dbconnection_connection_inactive'); + $this->assertActive(); + + if ($this->_transaction !== null && $this->_transaction->getActive()) { + throw new TDbException('dbconnection_active_transaction'); + } + + $pdo = $this->getPdoInstance(); + if (TDbDriverCapabilities::requiresPreBeginTransactionFlush($this->getDriverName())) { + // Firebird keeps an implicit transaction alive at all times; commit it + // before calling PDO::beginTransaction() so the driver does not throw + // "There is already an active transaction". + try { + $pdo->commit(); + } catch (PDOException $e) { + } } + $pdo->beginTransaction(); + $this->_transaction = $this->createTransaction(); + return $this->_transaction; } /** - * @return string Transaction class name to be created by calling {@see \Prado\Data\TDbConnection::beginTransaction}. Defaults to '\Prado\Data\TDbTransaction'. + * Convenience method: commits the current transaction on this connection. + * + * Delegates to the active transaction's {@see TDbTransaction::commit()} method. + * If no transaction is currently active (i.e. {@see getCurrentTransaction()} + * returns null), this method is a safe no-op and returns false. + * + * @return ?bool true if a transaction was committed, false if none was active, + * null if the connection itself is not active. + * @since 4.3.3 + */ + public function commit(): ?bool + { + if (!$this->getActive()) { + return null; + } + $txn = $this->getCurrentTransaction(); + if ($txn === null || !$txn->getActive()) { + return false; + } + $txn->commit(); + return true; + } + + /** + * Convenience method: rolls back the current transaction on this connection. + * + * Delegates to the active transaction's {@see TDbTransaction::rollback()} method. + * If no transaction is currently active (i.e. {@see getCurrentTransaction()} + * returns null), this method is a safe no-op and returns false. + * + * @return ?bool true if a transaction was rolled back, false if none was active, + * null if the connection itself is not active. + * @since 4.3.3 + */ + public function rollback(): ?bool + { + if (!$this->getActive()) { + return null; + } + $txn = $this->getCurrentTransaction(); + if ($txn === null || !$txn->getActive()) { + return false; + } + $txn->rollback(); + return true; + } + + /** + * Returns the fully-qualified class name used to create transaction objects. + * + * The default is {@see DEFAULT_TRANSACTION_CLASS} (`TDbTransaction`). + * The property is never null: passing null or an empty string to + * {@see setTransactionClass} resets it to the default. + * + * @return string fully-qualified transaction class name. * @since 3.1.7 */ - public function getTransactionClass() + public function getTransactionClass(): string { return $this->_transactionClass; } - /** - * @param string $value Transaction class name to be created by calling {@see \Prado\Data\TDbConnection::beginTransaction}. + * Sets the fully-qualified class name used to create transaction objects. + * + * Pass null or an empty string to reset to {@see DEFAULT_TRANSACTION_CLASS}. + * The supplied class must be instantiable with a single {@see TDbConnection} + * argument and should implement {@see IDataTransaction}. + * + * @param ?string $value fully-qualified transaction class name, or null/empty to reset. * @since 3.1.7 */ public function setTransactionClass($value) { - $this->_transactionClass = (string) $value; + if (empty($value)) { + $value = self::DEFAULT_TRANSACTION_CLASS; + } else { + $value = TPropertyValue::ensureString($value); + } + $this->_transactionClass = $value; } /** @@ -732,11 +826,8 @@ public function setTransactionClass($value) */ public function getLastInsertID($sequenceName = '') { - if ($this->getActive()) { - return $this->_pdo->lastInsertId($sequenceName); - } else { - throw new TDbException('dbconnection_connection_inactive'); - } + $this->assertActive(); + return $this->getPdoInstance()->lastInsertId($sequenceName); } /** @@ -747,11 +838,8 @@ public function getLastInsertID($sequenceName = '') */ public function quoteString($str) { - if ($this->getActive()) { - return $this->_pdo->quote($str); - } else { - throw new TDbException('dbconnection_connection_inactive'); - } + $this->assertActive(); + return $this->getPdoInstance()->quote($str); } /** @@ -785,7 +873,7 @@ public function quoteColumnAlias($name) } /** - * @return TDbMetaData + * @return \Prado\Data\Common\TDbMetaData */ public function getDbMetaData() { @@ -866,23 +954,57 @@ public function setNullConversion($value) } /** - * @return bool whether creating or updating a DB record will be automatically committed. - * Some DBMS (such as sqlite) may not support this feature. + * Returns whether DML statements are automatically committed outside an + * explicit transaction. + * + * Reads the live `PDO::ATTR_AUTOCOMMIT` attribute from the connection. + * Returns `false` without querying PDO when the driver does not expose this + * attribute (i.e. when {@see getHasAutoCommit()} is false). + * + * @return bool true if auto-commit is enabled, false otherwise or when the + * driver does not support the `PDO::ATTR_AUTOCOMMIT` attribute. */ public function getAutoCommit() { - return $this->getAttribute(PDO::ATTR_AUTOCOMMIT); + if (!$this->getHasAutoCommit()) { + return false; + } + return (bool) $this->getAttribute(PDO::ATTR_AUTOCOMMIT); } /** - * @param bool $value whether creating or updating a DB record will be automatically committed. - * Some DBMS (such as sqlite) may not support this feature. + * Enables or disables auto-commit on the connection. + * + * When the driver does not expose `PDO::ATTR_AUTOCOMMIT` (i.e. when + * {@see getHasAutoCommit()} is false) this method is a silent no-op. + * + * @param bool $value true to enable auto-commit, false to disable it. */ public function setAutoCommit($value) { + if (!$this->getHasAutoCommit()) { + return; + } $this->setAttribute(PDO::ATTR_AUTOCOMMIT, TPropertyValue::ensureBoolean($value)); } + /** + * Returns whether the current driver exposes the `PDO::ATTR_AUTOCOMMIT` + * attribute. + * + * Delegates to {@see TDbDriverCapabilities::hasAutoCommitAttribute}. When + * this returns false, {@see getAutoCommit()} always returns false and + * {@see setAutoCommit()} is a no-op. Drivers known to expose the attribute + * include mysql, pgsql, oci, sqlsrv, dblib, and ibm. + * + * @return bool true if the driver exposes `PDO::ATTR_AUTOCOMMIT`. + * @since 4.3.3 + */ + public function getHasAutoCommit(): bool + { + return TDbDriverCapabilities::hasAutoCommitAttribute($this->getDriverName()); + } + /** * @return bool whether the connection is persistent or not * Some DBMS (such as sqlite) may not support this feature. @@ -910,14 +1032,12 @@ public function getDriverName() return $this->getAttribute(PDO::ATTR_DRIVER_NAME); } - $connection = $this->getConnectionString(); - - if (is_string($connection) && strpos($connection, ':') !== false) { - [$driver] = explode(':', $connection, 2); - return $driver; + $dsn = $this->getConnectionString(); + $driver = $this->extractDriverFromDsn($dsn); + if ($driver === null) { + throw new TDbException('dbconnection_connection_inactive'); } - - throw new TDbException('dbconnection_connection_inactive'); + return $driver; } /** @@ -977,10 +1097,12 @@ public function getTimeout() */ public function getAttribute($name) { - if ($this->getActive()) { - return $this->_pdo->getAttribute($name); + $pdo = $this->getPdoInstance(); + if ($pdo instanceof PDO) { + $this->assertActive(); + return $pdo->getAttribute($name); } else { - throw new TDbException('dbconnection_connection_inactive'); + return $this->_attributes[$name] ?? null; } } @@ -992,10 +1114,40 @@ public function getAttribute($name) */ public function setAttribute($name, $value) { - if ($this->_pdo instanceof PDO) { - $this->_pdo->setAttribute($name, $value); + $pdo = $this->getPdoInstance(); + if ($pdo instanceof PDO) { + $pdo->setAttribute($name, $value); } else { $this->_attributes[$name] = $value; } } + + /** + * Throws a {@see TDbException} if the connection is not currently active. + * + * Call this at the top of any method that requires an open connection. + * + * @throws TDbException if the connection is not active. + * @since 4.3.3 + */ + public function assertActive() + { + if (!$this->getActive()) { + throw new TDbException('dbconnection_connection_inactive'); + } + } + + /** + * @param string $dsn + * @return ?string Driver name from dsn, or null if invalid or not found. + * @since 4.3.3 + */ + protected function extractDriverFromDsn(string $dsn): ?string + { + if (!is_string($dsn) || strpos($dsn, ':') === false) { + return null; + } + [$driver] = explode(':', $dsn, 2); + return strtolower($driver); + } } diff --git a/framework/Data/TDbDataReader.php b/framework/Data/TDbDataReader.php index 964017325..efd61b1a5 100644 --- a/framework/Data/TDbDataReader.php +++ b/framework/Data/TDbDataReader.php @@ -11,140 +11,213 @@ namespace Prado\Data; use PDO; +use PDOStatement; use Prado\Exceptions\TDbException; /** - * TDbDataReader class. + * TDbDataReader class * - * TDbDataReader represents a forward-only stream of rows from a query result set. + * TDbDataReader represents a forward-only stream of rows from a query result + * set. It implements both {@see IDataReader} and PHP's `Iterator` interface, + * so rows can be consumed either with the fetch methods or in a `foreach` loop. * - * To read the current row of data, call {@see read}. The method {@see readAll} - * returns all the rows in a single array. + * **Fetch methods:** + * ```php + * while ($row = $reader->read()) { + * // process $row + * } + * // or all at once: + * $rows = $reader->readAll(); + * ``` * - * One can also retrieve the rows of data in TDbDataReader by using foreach: + * **Iterator (`foreach`) usage:** * ```php - * foreach($reader as $row) - * // $row represents a row of data + * foreach ($reader as $index => $row) { + * // $index is the 0-based row number, $row is an associative array + * } * ``` - * Since TDbDataReader is a forward-only stream, you can only traverse it once. * - * It is possible to use a specific mode of data fetching by setting - * {@see setFetchMode FetchMode}. See {@see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php} - * for more details. + * TDbDataReader is a **forward-only** cursor; it can be iterated only once. + * Calling `rewind()` (or starting a second `foreach`) after the first row has + * been fetched throws a {@see TDbException}. + * + * The default fetch mode is `PDO::FETCH_ASSOC`. Use + * {@see setFetchMode FetchMode} to change it before reading. * * @author Qiang Xue * @since 3.0 */ class TDbDataReader extends \Prado\TComponent implements IDataReader { + /** @var PDOStatement The PDO statement this reader is consuming. */ private $_statement; + /** @var bool Whether the reader has been closed. */ private $_closed = false; + /** @var array|false The current row fetched for the Iterator interface. */ private $_row; + /** @var int The 0-based index of the current Iterator position; -1 before rewind. */ private $_index = -1; /** * Constructor. - * @param TDbCommand $command the command generating the query result + * @param TDbCommand $command the command whose result set this reader wraps. */ public function __construct(TDbCommand $command) { - $this->_statement = $command->getPdoStatement(); - $this->_statement->setFetchMode(PDO::FETCH_ASSOC); + $statement = $command->getPdoStatement(); + $statement->setFetchMode(PDO::FETCH_ASSOC); + $this->setStatement($statement); parent::__construct(); } /** - * Binds a column to a PHP variable. - * When rows of data are being fetched, the corresponding column value - * will be set in the variable. Note, the fetch mode must include PDO::FETCH_BOUND. - * @param mixed $column Number of the column (1-indexed) or name of the column - * in the result set. If using the column name, be aware that the name - * should match the case of the column, as returned by the driver. - * @param mixed $value Name of the PHP variable to which the column will be bound. - * @param null|int $dataType Data type of the parameter - * @see http://www.php.net/manual/en/function.PDOStatement-bindColumn.php + * Excludes the non-serialisable {@see PDOStatement} from serialization. + * The statement is not reconstructable after deserialization; the reader + * should not be serialized while data is being consumed. + * @param array $exprops by reference, list of property names to exclude. + * @since 4.3.3 + */ + protected function _getZappableSleepProps(&$exprops) + { + parent::_getZappableSleepProps($exprops); + $exprops[] = "\0" . self::class . "\0_statement"; + } + + /** + * Returns the underlying PDO statement. + * + * @return PDOStatement the active PDO statement. + * @since 4.3.3 + */ + public function getStatement(): PDOStatement + { + return $this->_statement; + } + + /** + * Sets the underlying PDO statement. + * + * Called once by the constructor; not intended for external use. + * + * @param null|PDOStatement $statement the PDO statement to wrap. + * @return static + * @since 4.3.3 + */ + protected function setStatement(?PDOStatement $statement): static + { + $this->_statement = $statement; + return $this; + } + + /** + * Binds a column in the result set to a PHP variable. + * + * On each subsequent call to {@see read}, the bound variable is updated + * with the column value. The active fetch mode must include + * `PDO::FETCH_BOUND` for binding to take effect. + * + * @param int|string $column 1-indexed column number or column name. + * Column names are case-sensitive as returned by the driver. + * @param mixed $value the PHP variable to bind. + * @param null|int $dataType PDO data type constant for the column. + * @see https://www.php.net/manual/en/pdostatement.bindcolumn.php */ public function bindColumn($column, &$value, $dataType = null) { if ($dataType === null) { - $this->_statement->bindColumn($column, $value); + $this->getStatement()->bindColumn($column, $value); } else { - $this->_statement->bindColumn($column, $value, $dataType); + $this->getStatement()->bindColumn($column, $value, $dataType); } } /** - * @see http://www.php.net/manual/en/function.PDOStatement-setFetchMode.php - * @param mixed $mode + * Sets the fetch mode for subsequent reads. + * + * All arguments are forwarded directly to `PDOStatement::setFetchMode`. + * The default fetch mode is `PDO::FETCH_ASSOC`, set by the constructor. + * + * @param mixed ...$args arguments forwarded to PDOStatement::setFetchMode. + * @see https://www.php.net/manual/en/pdostatement.setfetchmode.php */ - public function setFetchMode($mode) + public function setFetchMode(...$args) { - $params = func_get_args(); - call_user_func_array([$this->_statement, 'setFetchMode'], $params); + $this->getStatement()->setFetchMode(...$args); } /** * Advances the reader to the next row in a result set. - * @return array|false the current row, false if no more row available + * @return array|false the current row as an associative array, or false + * when no more rows are available. */ public function read() { - return $this->_statement->fetch(); + return $this->getStatement()->fetch(); } /** - * Returns a single column from the next row of a result set. - * @param int $columnIndex zero-based column index - * @return false|mixed the column of the current row, false if no more row available + * Returns a single column value from the next row of a result set. + * @param int $columnIndex 0-based column index. + * @return false|mixed the column value, or false when no more rows are available. */ public function readColumn($columnIndex) { - return $this->_statement->fetchColumn($columnIndex); + return $this->getStatement()->fetchColumn($columnIndex); } /** - * Returns a single column from the next row of a result set. - * @param string $className class name of the object to be created and populated - * @param array $fields list of column names whose values are to be passed as parameters in the constructor of the class being created - * @return false|mixed the populated object, false if no more row of data available + * Fetches the next row as an object of the given class. + * + * The column values are mapped to public properties of the class. Any + * columns that do not correspond to a property are silently discarded. + * + * @param string $className fully-qualified class name to instantiate. + * @param array $fields constructor arguments passed to the class constructor + * before the column properties are populated. + * @return false|object a populated object of type `$className`, or false + * when no more rows are available. */ public function readObject($className, $fields) { - return $this->_statement->fetchObject($className, $fields); + return $this->getStatement()->fetchObject($className, $fields); } /** - * Reads the whole result set into an array. - * @return array the result set (each array element represents a row of data). - * An empty array will be returned if the result contains no row. + * Reads all remaining rows into an array. + * @return array all remaining rows, each as an associative array. An + * empty array is returned when no rows remain. */ public function readAll() { - return $this->_statement->fetchAll(); + return $this->getStatement()->fetchAll(); } /** - * Advances the reader to the next result when reading the results of a batch of statements. - * This method is only useful when there are multiple result sets - * returned by the query. Not all DBMS support this feature. + * Advances the reader to the next result set in a multi-statement batch. + * + * Only useful when the query returned multiple result sets. Not all + * database drivers support this feature. + * + * @return bool true if there is another result set, false otherwise. */ public function nextResult() { - return $this->_statement->nextRowset(); + return $this->getStatement()->nextRowset(); } /** - * Closes the reader. - * Any further data reading will result in an exception. + * Closes the reader and releases the database cursor. + * + * Any further read calls after closing will return false. */ public function close() { - $this->_statement->closeCursor(); - $this->_closed = true; + $this->getStatement()->closeCursor(); + $this->setIsClosed(true); } /** - * @return bool whether the reader is closed or not. + * @return bool whether the reader has been closed. */ public function getIsClosed() { @@ -152,33 +225,52 @@ public function getIsClosed() } /** - * @return int number of rows contained in the result. - * Note, most DBMS may not give a meaningful count. - * In this case, use "SELECT COUNT(*) FROM tableName" to obtain the number of rows. + * Marks the reader as closed or open. + * + * Managed internally by {@see close()}; not intended for external use. + * + * @param bool $value true to mark closed, false to mark open. + * @since 4.3.3 + */ + protected function setIsClosed(bool $value): void + { + $this->_closed = $value; + } + + /** + * @return int number of rows affected by the last DML statement, or the + * number of rows in the result set for SELECT statements (driver-dependent). + * Note: most drivers do not give a reliable count for SELECT results. + * Use `SELECT COUNT(*) FROM tableName` to obtain an accurate row count. */ public function getRowCount() { - return $this->_statement->rowCount(); + return $this->getStatement()->rowCount(); } /** - * @return int the number of columns in the result set. - * Note, even there's no row in the reader, this still gives correct column number. + * @return int the number of columns in the result set. Accurate even + * before any rows are fetched. */ public function getColumnCount() { - return $this->_statement->columnCount(); + return $this->getStatement()->columnCount(); } /** - * Resets the iterator to the initial state. - * This method is required by the interface Iterator. - * @throws TDbException if this method is invoked twice + * Initialises the Iterator by fetching the first row. + * + * This method is required by the `Iterator` interface. Because + * TDbDataReader is a forward-only cursor, `rewind()` may only be called + * once. Calling it a second time (or starting a second `foreach`) throws + * a {@see TDbException}. + * + * @throws TDbException if the reader has already been rewound. */ public function rewind(): void { if ($this->_index < 0) { - $this->_row = $this->_statement->fetch(); + $this->_row = $this->getStatement()->fetch(); $this->_index = 0; } else { throw new TDbException('dbdatareader_rewind_invalid'); @@ -186,9 +278,11 @@ public function rewind(): void } /** - * Returns the index of the current row. - * This method is required by the interface Iterator. - * @return int the index of the current row. + * Returns the 0-based index of the current row. + * + * This method is required by the `Iterator` interface. + * + * @return int the current row index. */ #[\ReturnTypeWillChange] public function key() @@ -198,8 +292,10 @@ public function key() /** * Returns the current row. - * This method is required by the interface Iterator. - * @return mixed the current row. + * + * This method is required by the `Iterator` interface. + * + * @return array|false the current row, or false when exhausted. */ #[\ReturnTypeWillChange] public function current() @@ -208,19 +304,23 @@ public function current() } /** - * Moves the internal pointer to the next row. - * This method is required by the interface Iterator. + * Advances the internal cursor to the next row. + * + * This method is required by the `Iterator` interface. */ public function next(): void { - $this->_row = $this->_statement->fetch(); + $this->_row = $this->getStatement()->fetch(); $this->_index++; } /** - * Returns whether there is a row of data at current position. - * This method is required by the interface Iterator. - * @return bool whether there is a row of data at current position. + * Returns whether the current position holds a valid row. + * + * This method is required by the `Iterator` interface. + * + * @return bool true while rows remain, false when the result set is + * exhausted. */ public function valid(): bool { diff --git a/framework/Data/TDbDriver.php b/framework/Data/TDbDriver.php new file mode 100644 index 000000000..b8ae2c269 --- /dev/null +++ b/framework/Data/TDbDriver.php @@ -0,0 +1,82 @@ +> + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data; + +use Prado\TEnumerable; + +/** + * TDbDriver class + * + * TDbDriver is a static enumeration class that defines PDO database driver constants + * used throughout the PRADO framework for database connectivity. + * + * This class provides standardized string identifiers for all supported PDO database + * drivers, ensuring consistency across the framework. The constants are used by: + * - {@see TDbConnection} for establishing database connections + * - {@see TDbDriverCapabilities} for driver-specific capability lookups + * - {@see \Prado\Data\Common\TDbMetaData} for metadata handler resolution + * - {@see \Prado\Data\ActiveRecord\Scaffold\InputBuilder\TScaffoldInputBase} for scaffold generation + * + * Each constant value matches the driver name expected by PHP's PDO extension. + * The class extends {@see TEnumerable} to allow iteration over all driver constants. + * + * Supported drivers: + * - **MySQL/MariaDB**: {@see DRIVER_MYSQL} + * - **PostgreSQL**: {@see DRIVER_PGSQL} + * - **SQLite**: {@see DRIVER_SQLITE}, {@see DRIVER_SQLITE2} + * - **Microsoft SQL Server**: {@see DRIVER_SQLSRV}, {@see DRIVER_DBLIB} + * - **Oracle**: {@see DRIVER_OCI} + * - **IBM DB2**: {@see DRIVER_IBM} + * - **Firebird/Interbase**: {@see DRIVER_FIREBIRD}, {@see DRIVER_INTERBASE} + * - **MongoDB** (external extension): {@see DRIVER_MONGO} + * + * Unsupported drivers (listed for reference): {@see DRIVER_ODBC}, + * {@see DRIVER_CUBRID}, {@see DRIVER_INFORMIX} + * + * Unsupported database PHP extensions (listed for reference): {@see EXTENSION_MYSQLI}, + * {@see EXTENSION_MSSQL} + * + * Example usage: + * ```php + * // Get all driver constants + * foreach (TDbDriver::getValues() as $driver) { + * echo $driver . "\n"; + * } + * ``` + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TDbDriver extends TEnumerable +{ + public const DRIVER_MYSQL = 'mysql'; // MySQL / MariaDB + public const DRIVER_PGSQL = 'pgsql'; // PostgreSQL (charset after connection is started) + public const DRIVER_SQLITE = 'sqlite'; // SQLite 3 (UTF-8, UTF-16, set charset without tables) + public const DRIVER_SQLITE2 = 'sqlite2'; // SQLite 2 + public const DRIVER_SQLSRV = 'sqlsrv'; // Microsoft SQL Server + public const DRIVER_DBLIB = 'dblib'; // SQL Server / Sybase (via FreeTDS) + public const DRIVER_OCI = 'oci'; // Oracle + public const DRIVER_IBM = 'ibm'; // IBM DB2 (no charset) + public const DRIVER_FIREBIRD = 'firebird'; // Firebird + public const DRIVER_INTERBASE = 'interbase'; // Interbase + + // Unsupported, as of 4.3.3 + public const DRIVER_ODBC = 'odbc'; // Generic ODBC (various databases) + public const DRIVER_CUBRID = 'cubrid'; // CUBRID database + public const DRIVER_INFORMIX = 'informix'; // + + // Common + public const DRIVER_MONGO = 'mongo'; // {@see https://github.com/belisoful/prado-mongo } + + // non-PDO PHP Extensions, included for sql determination. + public const EXTENSION_MYSQLI = 'mysqli'; + public const EXTENSION_MSSQL = 'mssql'; +} diff --git a/framework/Data/TDbDriverCapabilities.php b/framework/Data/TDbDriverCapabilities.php new file mode 100644 index 000000000..0bafe2147 --- /dev/null +++ b/framework/Data/TDbDriverCapabilities.php @@ -0,0 +1,959 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Data; + +use Prado\Exceptions\TConfigurationException; +use Prado\Exceptions\TDbException; +use Prado\Data\ActiveRecord\Scaffold\InputBuilder\IScaffoldInput; +use Prado\Data\Common\Firebird\TFirebirdMetaData; +use Prado\Data\Common\Ibm\TIbmMetaData; +use Prado\Data\Common\IDataMetaData; +use Prado\Data\Common\SqlSrv\TSqlSrvMetaData; +use Prado\Data\Common\Mysql\TMysqlMetaData; +use Prado\Data\Common\Oracle\TOracleDbCommand; +use Prado\Data\Common\Oracle\TOracleMetaData; +use Prado\Data\Common\Pgsql\TPgsqlMetaData; +use Prado\Data\Common\Sqlite\TSqliteMetaData; + +/** + * TDbDriverCapabilities class + * + * TDbDriverCapabilities centralizes all driver-specific knowledge for the PDO + * database drivers supported by Prado. + * + * All methods are static; the class carries no instance state. Driver string + * constants are defined in {@see TDbDriver}. + * + * This class replaces the driver-branching logic that was previously scattered + * across {@see TDbConnection}, {@see TDbTransaction}, + * {@see \Prado\Data\Common\TDbMetaData}, and + * {@see \Prado\Data\ActiveRecord\Scaffold\InputBuilder\TScaffoldInputBase}. + * + * Capability groups: + * - **Charset resolution** — {@see resolveCharset}, {@see getCharsetSetSql}, + * {@see getCharsetPragmaSql}, {@see supportsRuntimeCharsetSet}, + * {@see getCharsetDsnParam}, {@see getCharsetDsnPattern}, + * {@see getCharsetQuerySql} + * - **Transaction flushing** (Firebird implicit-transaction management) — + * {@see requiresPreBeginTransactionFlush}, {@see requiresPostTransactionFlush} + * - **PDO attribute support** — {@see hasAutoCommitAttribute} + * - **MetaData factory** — {@see getMetaDataClass} + * - **Scaffold input factory** — {@see getScaffoldInputFile}, + * {@see getScaffoldInputClass}, {@see createScaffoldInput} + * + * ## Extensibility via global fx events + * + * Two `fx` global events allow third-party code to extend the built-in driver + * tables. Both are raised on the connection with the driver name string as + * the parameter, and the raising logic is fully encapsulated in this class so + * callers never need to call `raiseEvent` themselves: + * + * - **`fxDataGetMetaDataClass`** — raised by {@see getMetaDataClass} when no + * built-in MetaData class is registered for the driver. Sender is the + * connection; parameter is the driver name string. Handlers must return a + * fully-qualified class name implementing {@see \Prado\Data\Common\IDataMetaData}. + * The last returned value wins. + * - **`fxActiveRecordScaffoldInputClass`** — raised by {@see createScaffoldInput} + * when no built-in scaffold input file is registered for the driver. Sender + * is the connection; parameter is the driver name string. Handlers must + * return the **fully-qualified class name** of a class that implements + * {@see \Prado\Data\ActiveRecord\Scaffold\InputBuilder\IScaffoldInput}. + * The first returned value wins. + * + * @author Brad Anderson + * @since 4.3.3 + */ +class TDbDriverCapabilities +{ + // ========================================================================= + // Charset — resolution + // ========================================================================= + + /** + * Resolves a charset name to its driver-specific equivalent, allowing callers + * to use standard PHP charset names (e.g. 'UTF-8', 'ISO-8859-1') regardless + * of the underlying database driver. + * + * The lookup accepts both the standard PHP charset name (e.g., 'UTF-8') and + * the canonical key format (e.g., 'utf8') by normalizing the input. This allows + * both {@see \Prado\Data\TDataCharset} constants and raw strings to be used. + * + * The same table is shared by both SQL-level charset commands + * ({@see getCharsetSetSql}) and DSN-parameter injection + * ({@see getCharsetDsnParam}), so driver columns for oci, sqlsrv, and dblib + * resolve to their DSN-appropriate charset values. + * + * **sqlsrv limitation** — PDO_SQLSRV's `CharacterSet` DSN parameter only + * accepts `'UTF-8'` or `'SQLSRV_ENC_CHAR'` (the system ANSI code page). + * Non-UTF-8 charsets have no sqlsrv entry in the table and pass through + * unchanged; {@see TDbConnection::applyCharsetToDsn} guards against injecting + * an unacceptable value via {@see getDsnAcceptedCharsets}. + * + * **ibm** — IBM DB2 has no charset DSN parameter and is absent from all rows. + * + * @param string $charset the charset name as supplied by the caller (e.g. 'UTF-8') + * @param string $driver PDO driver name (e.g. 'mysql', 'pgsql', 'firebird', 'oci') + * @return string the charset name appropriate for $driver + */ + public static function resolveCharset(string $charset, string $driver): string + { + static $driverAliases = [ + TDbDriver::DRIVER_INTERBASE => TDbDriver::DRIVER_FIREBIRD, + TDbDriver::EXTENSION_MYSQLI => TDbDriver::DRIVER_MYSQL, + TDbDriver::EXTENSION_MSSQL => TDbDriver::DRIVER_SQLSRV, + ]; + + if (isset($driverAliases[$driver])) { + $driver = $driverAliases[$driver]; + } + + static $aliases = [ + // php_charset => [driver => resolved_name, ...] + // Key = standard PHP charset name (e.g., 'UTF-8', 'ISO-8859-1'). + // Also supports canonical key lookup via normalization. + // Drivers mysql/pgsql/firebird: SQL-level charset names. + // Drivers sqlite: PRAGMA encoding values (only UTF-8 and UTF-16 variants + // are valid; unsupported values are passed through and silently ignored). + // Drivers oci/dblib: DSN-parameter charset names. + // Driver sqlsrv: PDO_SQLSRV only accepts 'UTF-8' or 'SQLSRV_ENC_CHAR' + // (system ANSI code page) as the CharacterSet DSN value. Non-UTF-8 + // charsets have no sqlsrv entry and pass through unchanged; DSN injection + // is guarded by getDsnAcceptedCharsets() in TDbConnection::applyCharsetToDsn. + // Driver ibm: IBM DB2 has no charset DSN parameter; absent from all rows. + // Drivers pgsql/dblib/sqlsrv: absent from UTF-16 — PostgreSQL does not + // support UTF-16 as a server encoding; FreeTDS and PDO_SQLSRV have no + // UTF-16 DSN charset option. + 'utf8' => TDataCharset::UTF8, // canonical key alias + 'utf8mb4' => TDataCharset::UTF8, // canonical key alias + TDataCharset::UTF8 => [ + TDbDriver::DRIVER_FIREBIRD => 'UTF8', + TDbDriver::DRIVER_MYSQL => 'utf8mb4', + TDbDriver::DRIVER_OCI => 'AL32UTF8', + TDbDriver::DRIVER_PGSQL => 'UTF8', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_SQLSRV => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'UTF-8', + ], + + 'utf16' => TDataCharset::UTF16, // canonical key alias + TDataCharset::UTF16 => [ + // pgsql, sqlsrv, and dblib intentionally absent — see comment above. + // UTF-16 resolves to the big-endian (or native-endian for SQLite) form + // for drivers that distinguish endianness; use UTF16LE / UTF16BE for + // explicit endianness control. + TDbDriver::DRIVER_FIREBIRD => 'UTF16BE', + TDbDriver::DRIVER_MYSQL => 'utf16', + TDbDriver::DRIVER_OCI => 'AL16UTF16', + TDbDriver::DRIVER_SQLITE => 'UTF-16', + ], + + 'utf16le' => TDataCharset::UTF16LE, // canonical key alias + TDataCharset::UTF16LE => [ + // Only MySQL and SQLite expose explicit little-endian UTF-16. + // Firebird UTF16BE-only; Oracle AL16UTF16 is big-endian only. + // pgsql, sqlsrv, dblib, and ibm do not support UTF-16 at all. + TDbDriver::DRIVER_MYSQL => 'utf16le', + TDbDriver::DRIVER_SQLITE => 'UTF-16le', + ], + + 'utf16be' => TDataCharset::UTF16BE, // canonical key alias + TDataCharset::UTF16BE => [ + // pgsql, sqlsrv, and dblib intentionally absent — see comment above. + TDbDriver::DRIVER_FIREBIRD => 'UTF16BE', + TDbDriver::DRIVER_MYSQL => 'utf16', + TDbDriver::DRIVER_OCI => 'AL16UTF16', + TDbDriver::DRIVER_SQLITE => 'UTF-16be', + ], + + 'latin1' => TDataCharset::Latin1, // canonical key alias + 'iso88591' => TDataCharset::Latin1, // canonical key alias + TDataCharset::Latin1 => [ + TDbDriver::DRIVER_FIREBIRD => 'ISO8859_1', + TDbDriver::DRIVER_MYSQL => 'latin1', + TDbDriver::DRIVER_OCI => 'WE8ISO8859P1', + TDbDriver::DRIVER_PGSQL => 'LATIN1', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'ISO-8859-1', + ], + + 'latin2' => TDataCharset::Latin2, // canonical key alias + 'iso88592' => TDataCharset::Latin2, // canonical key alias + TDataCharset::Latin2 => [ + TDbDriver::DRIVER_FIREBIRD => 'ISO8859_2', + TDbDriver::DRIVER_MYSQL => 'latin2', + TDbDriver::DRIVER_OCI => 'EE8ISO8859P2', + TDbDriver::DRIVER_PGSQL => 'LATIN2', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'ISO-8859-2', + ], + + 'ascii' => TDataCharset::ASCII, // canonical key alias ('ASCII' → 'ascii') + 'usascii' => TDataCharset::ASCII, // canonical key alias ('US-ASCII' → 'usascii') + TDataCharset::ASCII => [ + TDbDriver::DRIVER_FIREBIRD => 'ASCII', + TDbDriver::DRIVER_MYSQL => 'ascii', + TDbDriver::DRIVER_OCI => 'US7ASCII', + TDbDriver::DRIVER_PGSQL => 'SQL_ASCII', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'ASCII', + ], + + 'win1250' => TDataCharset::Win1250, // canonical key alias + 'windows1250' => TDataCharset::Win1250, // canonical key alias + 'cp1250' => TDataCharset::Win1250, // canonical key alias + TDataCharset::Win1250 => [ + TDbDriver::DRIVER_FIREBIRD => 'WIN1250', + TDbDriver::DRIVER_MYSQL => 'cp1250', + TDbDriver::DRIVER_OCI => 'EE8MSWIN1250', + TDbDriver::DRIVER_PGSQL => 'WIN1250', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'CP1250', + ], + + 'win1251' => TDataCharset::Win1251, // canonical key alias + 'windows1251' => TDataCharset::Win1251, // canonical key alias + 'cp1251' => TDataCharset::Win1251, // canonical key alias + TDataCharset::Win1251 => [ + TDbDriver::DRIVER_FIREBIRD => 'WIN1251', + TDbDriver::DRIVER_MYSQL => 'cp1251', + TDbDriver::DRIVER_OCI => 'CL8MSWIN1251', + TDbDriver::DRIVER_PGSQL => 'WIN1251', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'CP1251', + ], + + 'win1252' => TDataCharset::Win1252, // canonical key alias + 'windows1252' => TDataCharset::Win1252, // canonical key alias + 'cp1252' => TDataCharset::Win1252, // canonical key alias + TDataCharset::Win1252 => [ + TDbDriver::DRIVER_FIREBIRD => 'WIN1252', + TDbDriver::DRIVER_MYSQL => 'cp1252', + TDbDriver::DRIVER_OCI => 'WE8MSWIN1252', + TDbDriver::DRIVER_PGSQL => 'WIN1252', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'CP1252', + ], + + 'koi8r' => TDataCharset::KOI8R, // canonical key alias + TDataCharset::KOI8R => [ + TDbDriver::DRIVER_FIREBIRD => 'KOI8R', + TDbDriver::DRIVER_MYSQL => 'koi8r', + TDbDriver::DRIVER_OCI => 'CL8KOI8R', + TDbDriver::DRIVER_PGSQL => 'KOI8R', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'KOI8-R', + ], + + 'koi8u' => TDataCharset::KOI8U, // canonical key alias + TDataCharset::KOI8U => [ + TDbDriver::DRIVER_FIREBIRD => 'KOI8U', + TDbDriver::DRIVER_MYSQL => 'koi8u', + TDbDriver::DRIVER_OCI => 'CL8KOI8U', + TDbDriver::DRIVER_PGSQL => 'KOI8U', + TDbDriver::DRIVER_SQLITE => 'UTF-8', + TDbDriver::DRIVER_DBLIB => 'KOI8-U', + ], + ]; + + // Try direct match first (PHP standard charset name) + if (isset($aliases[$charset])) { + $key = $aliases[$charset]; + if (is_string($key)) { + $charset = $key; + } else { + return $key[$driver] ?? $charset; + } + } + + // Try canonical key format (lowercase, no hyphens/underscores/spaces) + $key = static::canonicalizeCharset($charset); + if (isset($aliases[$key])) { + $charset = is_string($aliases[$key]) ? $aliases[$key] : $key; + } + + return $aliases[$charset][$driver] ?? $charset; + } + + /** + * Canonicalization involves removing the dashes, underscores, and spaces, + * then making the text lower case. This makes charset values more universal. + * @param string $charset The value to canonicalize. + * @return string Canonicalized version of the input charset + */ + public static function canonicalizeCharset($charset) + { + return strtolower(preg_replace('/[-_ ]+/', '', $charset)); + } + + /** + * Unresolves a driver-specific charset name back to the standard PHP charset + * name used by PRADO (e.g., 'UTF-8', 'ISO-8859-1'). + * + * This is the reciprocal operation of {@see resolveCharset}. It takes a + * database-specific charset (e.g., 'utf8mb4' from MySQL, 'AL32UTF8' from Oracle) + * and returns the corresponding standard PHP charset name. + * + * This is useful when the charset is set via DSN and needs to be reflected + * back into the {@see \Prado\Data\TDbConnection::getCharset} property. + * + * @param string $dbCharset the driver-specific charset name (e.g. 'utf8mb4') + * @param string $driver PDO driver name (e.g. 'mysql', 'pgsql', 'oci') + * @return string the standard PHP charset name (e.g. 'UTF-8'), or $dbCharset + * if no mapping exists + */ + public static function unresolveCharset(string $dbCharset, string $driver): string + { + static $driverAliases = [ + TDbDriver::DRIVER_INTERBASE => TDbDriver::DRIVER_FIREBIRD, + TDbDriver::EXTENSION_MYSQLI => TDbDriver::DRIVER_MYSQL, + TDbDriver::EXTENSION_MSSQL => TDbDriver::DRIVER_SQLSRV, + ]; + + if (isset($driverAliases[$driver])) { + $driver = $driverAliases[$driver]; + } + + // Build reverse map with TDataCharset constant values + // Cannot use static variable with class constants in some PHP versions + $reverseMap = [ + // driver => [db_charset => php_charset, ...] + // Keys are database-specific charset names + // Values are TDataCharset constant values (which equal the standard PHP charset name) + TDbDriver::DRIVER_FIREBIRD => [ + 'UTF8' => TDataCharset::UTF8, + 'UTF16BE' => TDataCharset::UTF16BE, + 'ISO8859_1' => TDataCharset::Latin1, + 'ISO8859_2' => TDataCharset::Latin2, + 'ASCII' => TDataCharset::ASCII, + 'WIN1250' => TDataCharset::Win1250, + 'WIN1251' => TDataCharset::Win1251, + 'WIN1252' => TDataCharset::Win1252, + 'KOI8R' => TDataCharset::KOI8R, + 'KOI8U' => TDataCharset::KOI8U, + ], + TDbDriver::DRIVER_MYSQL => [ + 'utf8mb4' => TDataCharset::UTF8, + 'utf8' => TDataCharset::UTF8, + 'utf16' => TDataCharset::UTF16BE, + 'utf16le' => TDataCharset::UTF16LE, + 'latin1' => TDataCharset::Latin1, + 'latin2' => TDataCharset::Latin2, + 'ascii' => TDataCharset::ASCII, + 'cp1250' => TDataCharset::Win1250, + 'cp1251' => TDataCharset::Win1251, + 'cp1252' => TDataCharset::Win1252, + 'koi8r' => TDataCharset::KOI8R, + 'koi8u' => TDataCharset::KOI8U, + ], + TDbDriver::DRIVER_OCI => [ + 'AL32UTF8' => TDataCharset::UTF8, + 'AL16UTF16' => TDataCharset::UTF16BE, + 'WE8ISO8859P1' => TDataCharset::Latin1, + 'EE8ISO8859P2' => TDataCharset::Latin2, + 'US7ASCII' => TDataCharset::ASCII, + 'EE8MSWIN1250' => TDataCharset::Win1250, + 'CL8MSWIN1251' => TDataCharset::Win1251, + 'WE8MSWIN1252' => TDataCharset::Win1252, + 'CL8KOI8R' => TDataCharset::KOI8R, + 'CL8KOI8U' => TDataCharset::KOI8U, + ], + TDbDriver::DRIVER_PGSQL => [ + 'UTF8' => TDataCharset::UTF8, + 'UTF16' => TDataCharset::UTF16, + 'LATIN1' => TDataCharset::Latin1, + 'LATIN2' => TDataCharset::Latin2, + 'SQL_ASCII' => TDataCharset::ASCII, + 'WIN1250' => TDataCharset::Win1250, + 'WIN1251' => TDataCharset::Win1251, + 'WIN1252' => TDataCharset::Win1252, + 'KOI8R' => TDataCharset::KOI8R, + 'KOI8U' => TDataCharset::KOI8U, + ], + TDbDriver::DRIVER_SQLITE => [ + 'UTF-8' => TDataCharset::UTF8, + // PRAGMA encoding = 'UTF-16' stores native-endian; the query + // always returns the specific endian form, never the bare 'UTF-16' token. + // Map to the explicit LE/BE constants for precise round-tripping. + 'UTF-16' => TDataCharset::UTF16, + 'UTF-16le' => TDataCharset::UTF16LE, + 'UTF-16be' => TDataCharset::UTF16BE, + ], + // PDO_SQLSRV's CharacterSet DSN param only accepts 'UTF-8' or + // 'SQLSRV_ENC_CHAR'; getCharsetQuerySql() returns null so this + // table is only reached by external callers. 'SQLSRV_ENC_CHAR' + // cannot be unresolved to a specific charset (system-dependent), + // so it is omitted and will fall through to the raw value. + TDbDriver::DRIVER_SQLSRV => [ + 'UTF-8' => TDataCharset::UTF8, + ], + TDbDriver::DRIVER_DBLIB => [ + 'UTF-8' => TDataCharset::UTF8, + 'ISO-8859-1' => TDataCharset::Latin1, + 'ISO-8859-2' => TDataCharset::Latin2, + 'ASCII' => TDataCharset::ASCII, + 'CP1250' => TDataCharset::Win1250, + 'CP1251' => TDataCharset::Win1251, + 'CP1252' => TDataCharset::Win1252, + 'KOI8-R' => TDataCharset::KOI8R, + 'KOI8-U' => TDataCharset::KOI8U, + ], + ]; + + return $reverseMap[$driver][$dbCharset] ?? $dbCharset; + } + + // ========================================================================= + // Charset — runtime SQL command + // ========================================================================= + + /** + * Returns the parameterised SQL statement used to set the client charset on + * an already-open connection, or null when runtime charset switching is not + * supported via a prepared-statement SQL command for the given driver. + * + * The returned string contains a single positional `?` placeholder for the + * resolved charset name and is intended for use with a prepared statement. + * + * SQLite uses `PRAGMA encoding = ` which does not accept prepared- + * statement parameters; use {@see getCharsetPragmaSql} for that case. + * + * @param string $driver PDO driver name + * @return ?string SQL template with a `?` placeholder, or null + */ + public static function getCharsetSetSql(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL => 'SET NAMES ?', + TDbDriver::DRIVER_PGSQL => 'SET client_encoding TO ?', + default => null, + }; + } + + /** + * Returns the PRAGMA SQL template for setting SQLite's internal encoding, or + * null for all other drivers. + * + * Unlike {@see getCharsetSetSql}, the PRAGMA value cannot use a prepared- + * statement placeholder and must be injected via PDO::quote. The returned + * string contains a `%s` slot for the already-quoted charset value. + * + * Note: `PRAGMA encoding` only takes effect before any tables are created; + * errors are silently ignored so it is safe to call on any SQLite connection. + * + * @param string $driver PDO driver name + * @return ?string SQL template with a `%s` slot, or null + */ + public static function getCharsetPragmaSql(string $driver): ?string + { + return $driver === TDbDriver::DRIVER_SQLITE ? 'PRAGMA encoding = %s' : null; + } + + /** + * Returns true when the driver supports changing the connection charset at + * runtime (after the connection has been opened). + * + * MySQL and PostgreSQL accept a SQL command ({@see getCharsetSetSql}). + * SQLite accepts `PRAGMA encoding` ({@see getCharsetPragmaSql}) but only + * before any tables exist; errors are silently ignored. + * All other drivers require the charset to be embedded in the DSN before the + * connection is opened ({@see getCharsetDsnParam}). + * + * @param string $driver PDO driver name + * @return bool + */ + public static function supportsRuntimeCharsetSet(string $driver): bool + { + return in_array($driver, [ + TDbDriver::DRIVER_MYSQL, + TDbDriver::DRIVER_SQLITE, + TDbDriver::DRIVER_PGSQL, + ], true); + } + + /** + * Returns true when the driver requires a SQL command to be issued + * immediately after the connection opens in order to apply the requested + * charset. + * + * PostgreSQL has no DSN charset parameter; its charset can only be set via + * {@see getCharsetSetSql} (`SET client_encoding TO ?`) after the connection + * is established. + * + * SQLite also falls into this category: it has no DSN charset parameter and + * applies its encoding via `PRAGMA encoding` ({@see getCharsetPragmaSql}). + * The PRAGMA only takes effect on a brand-new database with no tables; on + * existing databases it is silently ignored and the stored encoding is + * preserved. Callers must follow up with {@see requiresPostConnectCharsetReadback} + * to sync the connection's charset property to the database's actual encoding. + * + * All other supported drivers that accept a charset receive it through the + * DSN before the connection opens ({@see getCharsetDsnParam} — MySQL, + * Firebird, Oracle, sqlsrv, dblib). + * + * This method is distinct from {@see supportsRuntimeCharsetSet}, which answers + * the broader question of whether the charset can be changed mid-connection. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function requiresPostConnectCharset(string $driver): bool + { + return $driver === TDbDriver::DRIVER_PGSQL; + } + + /** + * Returns true when the driver's post-connect charset setup requires a + * subsequent read-back query to synchronise the connection's charset + * property to the database's actual encoding. + * + * This is needed for SQLite: `PRAGMA encoding` is silently ignored when + * tables already exist (the encoding was fixed at database creation time), + * so the property must be updated to reflect reality rather than the + * originally requested value. The read-back uses {@see getCharsetQuerySql}. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function requiresPostConnectCharsetReadback(string $driver): bool + { + return $driver === TDbDriver::DRIVER_SQLITE; + } + + // ========================================================================= + // Charset — DSN injection + // ========================================================================= + + /** + * Returns the DSN parameter name used to specify the charset for the given + * driver, or null when the driver does not accept a charset parameter in the + * DSN. + * + * Drivers that do not support a DSN charset parameter: + * pgsql — charset is applied after the connection opens via SQL command. + * sqlite — always UTF-8 internally; charset is set via PRAGMA. + * ibm — IBM DB2 has no charset support via DSN. + * + * @param string $driver PDO driver name + * @return ?string e.g. 'charset', 'CharacterSet', or null + */ + public static function getCharsetDsnParam(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL, + TDbDriver::DRIVER_FIREBIRD, + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_OCI, + TDbDriver::DRIVER_DBLIB => 'charset', + TDbDriver::DRIVER_SQLSRV => 'CharacterSet', + default => null, + }; + } + + /** + * Returns a regex pattern that detects an existing charset directive already + * present in a DSN string for the given driver, or null when the driver has + * no DSN charset parameter. + * + * Intended for use with preg_match to avoid injecting a duplicate directive + * when the caller has already embedded one in the DSN. The regex does need to + * capture the value in the first capture group. + * + * @param string $driver PDO driver name + * @return ?string case-insensitive regex, e.g. '/[;?]charset\s*=\s*([^;]+)/i', or null + */ + public static function getCharsetDsnPattern(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL, + TDbDriver::DRIVER_FIREBIRD, + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_OCI, + TDbDriver::DRIVER_DBLIB => '/[;?]charset\s*=\s*([^;]+)/i', + TDbDriver::DRIVER_SQLSRV => '/[;?]CharacterSet\s*=\s*([^;]+)/i', + default => null, + }; + } + + /** + * Returns the set of charset values that are valid in the DSN `CharacterSet=` + * parameter for the given driver, or null when the driver accepts any resolved + * charset value (i.e. no allowlist is needed). + * + * For pdo_sqlsrv the `CharacterSet` DSN parameter only accepts `'UTF-8'` or + * `'SQLSRV_ENC_CHAR'` (the Windows system default encoding). Any other value + * will cause the connection to fail, so {@see TDbConnection::applyCharsetToDsn} + * must skip injection when the resolved charset is not in this list. + * + * For all other drivers that accept a charset DSN parameter the driver maps + * whatever charset name is returned by {@see resolveCharset}, so no allowlist + * is required and null is returned. + * + * @param string $driver PDO driver name + * @return ?array allowlisted DSN charset values, or null if unrestricted + */ + public static function getDsnAcceptedCharsets(string $driver): ?array + { + return match ($driver) { + TDbDriver::DRIVER_SQLSRV => ['UTF-8', 'SQLSRV_ENC_CHAR'], + default => null, + }; + } + + // ========================================================================= + // Charset — discovery query + // ========================================================================= + + /** + * Returns the SQL statement that retrieves the charset currently in use on + * an active connection, or null when the driver does not support such a query. + * + * Drivers that configure charset via the DSN at connection time (Oracle, SQL Server, + * IBM DB2) cannot be queried cheaply at runtime; null is returned for + * those drivers and callers should fall back to the resolved charset property. + * + * The Firebird query joins MON$ATTACHMENTS with RDB$CHARACTER_SETS and requires + * the MONITOR privilege; callers should catch any exception and fall back to + * the resolved charset property when the privilege is absent. + * + * @param string $driver PDO driver name + * @return ?string SQL query string, or null + */ + public static function getCharsetQuerySql(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL => 'SELECT @@character_set_connection', + TDbDriver::DRIVER_SQLITE => 'PRAGMA encoding', + TDbDriver::DRIVER_PGSQL => 'SELECT pg_client_encoding()', + TDbDriver::DRIVER_FIREBIRD => + 'SELECT TRIM(c.RDB$CHARACTER_SET_NAME)' . + ' FROM MON$ATTACHMENTS a' . + ' JOIN RDB$CHARACTER_SETS c' . + ' ON c.RDB$CHARACTER_SET_ID = a.MON$CHARACTER_SET_ID' . + ' WHERE a.MON$ATTACHMENT_ID = CURRENT_CONNECTION', + default => null, + }; + } + + // ========================================================================= + // Transaction — Firebird implicit-transaction management + // ========================================================================= + + /** + * Returns true when the driver requires that any implicit transaction be + * flushed (committed) before {@see TDbConnection::beginTransaction()} calls + * PDO::beginTransaction(). + * + * pdo_firebird keeps an implicit transaction alive in autocommit mode. Calling + * PDO::beginTransaction() while it is active raises "There is already an + * active transaction". Committing it first is the only way to start an + * explicit one cleanly. This ensures that the snapshot is current. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function requiresPreBeginTransactionFlush(string $driver): bool + { + return $driver === TDbDriver::DRIVER_FIREBIRD; + } + + /** + * Returns true when the driver requires that the implicit transaction started + * automatically after a commit or rollback be flushed (committed) immediately, + * before the next read is issued on the same connection. + * + * pdo_firebird starts a new implicit transaction inside isc_commit_transaction + * and isc_rollback_transaction before Firebird's Transaction Inventory Page is + * fully updated. That implicit transaction's MVCC snapshot can therefore see + * stale data. Committing it right away forces pdo_firebird to open a fresh one + * whose snapshot correctly reflects the completed operation. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function requiresPostTransactionFlush(string $driver): bool + { + return $driver === TDbDriver::DRIVER_FIREBIRD; + } + + // ========================================================================= + // ActiveRecord — table enumeration + // ========================================================================= + + /** + * Returns the SQL statement that lists all user-defined table names for the + * given driver, or null when the driver is not supported. + * + * The query must return a result set whose first column contains the table + * name. Used by the ActiveRecord code-generation action + * ({@see \Prado\Shell\Actions\TActiveRecordAction}). + * + * @param string $driver PDO driver name (lowercase) + * @return ?string SQL query string, or null + */ + public static function getListTablesSql(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL => 'SHOW TABLES', + TDbDriver::DRIVER_SQLITE2, + TDbDriver::DRIVER_SQLITE => "SELECT DISTINCT tbl_name FROM sqlite_master WHERE tbl_name<>'sqlite_sequence'", + TDbDriver::DRIVER_PGSQL => "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'", + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_FIREBIRD => "SELECT TRIM(RDB\$RELATION_NAME) AS tbl_name FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL ORDER BY RDB\$RELATION_NAME", + TDbDriver::DRIVER_DBLIB, + TDbDriver::DRIVER_SQLSRV => "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'", + TDbDriver::DRIVER_OCI => 'SELECT table_name FROM user_tables', + TDbDriver::DRIVER_IBM => "SELECT TABNAME FROM SYSCAT.TABLES WHERE TABSCHEMA = CURRENT SCHEMA AND TYPE = 'T' ORDER BY TABNAME", + default => null, + }; + } + + // ========================================================================= + // PDO attribute support + // ========================================================================= + + /** + * Returns true when the driver has any charset support — either runtime SQL + * commands ({@see getCharsetSetSql}, {@see getCharsetPragmaSql}) or a DSN + * charset parameter ({@see getCharsetDsnParam}). + * + * IBM DB2 (ibm) has no charset support of any kind and returns false. + * All other supported drivers return true. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function supportsCharset(string $driver): bool + { + return $driver !== TDbDriver::DRIVER_IBM; + } + + /** + * Returns true when the driver exposes a meaningful PDO::ATTR_AUTOCOMMIT + * attribute that can be read and written. + * + * SQLite does not implement this attribute. Reading or writing it on a SQLite + * connection has no effect and should be avoided. + * + * @param string $driver PDO driver name + * @return bool + */ + public static function hasAutoCommitAttribute(string $driver): bool + { + return match ($driver) { + TDbDriver::DRIVER_SQLITE, + TDbDriver::DRIVER_PGSQL, + TDbDriver::DRIVER_SQLSRV, + TDbDriver::DRIVER_DBLIB => false, + default => true, + }; + } + + // ========================================================================= + // Command factory + // ========================================================================= + + /** + * Returns the fully-qualified {@see TDbCommand} subclass name appropriate + * for the given driver. + * + * Most drivers use the base {@see TDbCommand} class. Oracle (pdo_oci) uses + * {@see TOracleDbCommand}, which works around the PHP 8.2 pdo_oci segfault + * in the prepared-statement path by accumulating bound values and + * substituting them via {@see \PDO::quote()} at execution time. + * + * {@see \Prado\Data\TDbConnection::createCommand()} delegates to this + * method to select the right class. + * + * @param string $driver PDO driver name (lowercase) + * @return string fully-qualified class name + */ + public static function getCommandClass(string $driver): string + { + return $driver === TDbDriver::DRIVER_OCI ? TOracleDbCommand::class : TDbCommand::class; + } + + // ========================================================================= + // MetaData factory + // ========================================================================= + + /** + * Returns the fully-qualified class name of the {@see \Prado\Data\Common\TDbMetaData} + * subclass appropriate for the given driver. + * + * For built-in drivers the class name is returned immediately. When no + * built-in class exists and a {@see TDbConnection} is passed, the + * **`fxDataGetMetaDataClass`** global event is raised on the connection + * with the driver name string as the parameter. Event handlers must return + * a fully-qualified class name implementing + * {@see \Prado\Data\Common\IDataMetaData}. The last value in the event + * result array is used. + * + * When a plain driver-name string is passed and the driver is unknown, + * `null` is returned so the caller can decide whether to throw or fall back. + * + * This method fully encapsulates the `fxDataGetMetaDataClass` event so + * callers never need to call `raiseEvent` themselves. + * + * @param string|TDbConnection $connection the active connection (driver is + * derived via {@see TDbConnection::getDriverName()}), or a bare PDO driver + * name string when only a static lookup is needed (no event fallback). + * @throws TDbException if the driver is unknown, a connection is provided, + * and no event handler supplies a class name. + * @return ?string fully-qualified class name, or null when a driver string + * was given and the driver is unknown. + */ + public static function getMetaDataClass(TDbConnection|string $connection): ?string + { + if ($connection instanceof TDbConnection) { + $driver = strtolower($connection->getDriverName()); + } else { + $driver = $connection; + $connection = null; + } + + $class = match ($driver) { + TDbDriver::DRIVER_MYSQL => TMysqlMetaData::class, + TDbDriver::DRIVER_SQLITE2, + TDbDriver::DRIVER_SQLITE => TSqliteMetaData::class, + TDbDriver::DRIVER_PGSQL => TPgsqlMetaData::class, + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_FIREBIRD => TFirebirdMetaData::class, + TDbDriver::DRIVER_DBLIB, + TDbDriver::DRIVER_SQLSRV => TSqlSrvMetaData::class, + TDbDriver::DRIVER_OCI => TOracleMetaData::class, + TDbDriver::DRIVER_IBM => TIbmMetaData::class, + default => null, + }; + + if ($class !== null || !$connection) { + return $class; + } + + $driverClasses = $connection->raiseEvent('fxDataGetMetaDataClass', $connection, $driver); + if (empty($driverClasses)) { + throw new TDbException('dbmetadata_invalid_database_driver', $driver); + } + $class = array_pop($driverClasses); + if (!is_string($class) || !is_a($class, IDataMetaData::class, true)) { + throw new TDbException('dbmetadata_not_meta_data', is_string($class) ? $class : $class::class, IDataMetaData::class); + } + return $class; + } + + // ========================================================================= + // Scaffold input factory + // ========================================================================= + + /** + * Returns the relative file path (relative to the InputBuilder directory) for + * the scaffold input class appropriate for the given driver, or null when no + * built-in handler exists. + * + * These files are loaded via `require_once` rather than PSR-4 autoloading. + * {@see createScaffoldInput} uses this path together with + * {@see getScaffoldInputClass} to load and instantiate the driver-specific + * class without going through the `fxActiveRecordScaffoldInputClass` event. + * + * @param string $driver PDO driver name (lowercase) + * @return ?string e.g. '/TMysqlScaffoldInput.php', or null + */ + public static function getScaffoldInputFile(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL => '/TMysqlScaffoldInput.php', + TDbDriver::DRIVER_SQLITE2, + TDbDriver::DRIVER_SQLITE => '/TSqliteScaffoldInput.php', + TDbDriver::DRIVER_PGSQL => '/TPgsqlScaffoldInput.php', + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_FIREBIRD => '/TFirebirdScaffoldInput.php', + TDbDriver::DRIVER_DBLIB, + TDbDriver::DRIVER_SQLSRV => '/TSqlSrvScaffoldInput.php', + TDbDriver::DRIVER_OCI => '/TOracleScaffoldInput.php', + TDbDriver::DRIVER_IBM => '/TIbmScaffoldInput.php', + default => null, + }; + } + + /** + * Returns the unqualified class name of the scaffold input builder appropriate + * for the given driver, or null when no built-in handler exists. + * + * Use {@see createScaffoldInput} to get a complete scaffold input instance, + * including the `fxActiveRecordScaffoldInputClass` event fallback for + * unknown drivers. + * + * @param string $driver PDO driver name (lowercase) + * @return ?string e.g. 'TMysqlScaffoldInput', or null + */ + public static function getScaffoldInputClass(string $driver): ?string + { + return match ($driver) { + TDbDriver::DRIVER_MYSQL => 'TMysqlScaffoldInput', + TDbDriver::DRIVER_SQLITE2, + TDbDriver::DRIVER_SQLITE => 'TSqliteScaffoldInput', + TDbDriver::DRIVER_PGSQL => 'TPgsqlScaffoldInput', + TDbDriver::DRIVER_INTERBASE, + TDbDriver::DRIVER_FIREBIRD => 'TFirebirdScaffoldInput', + TDbDriver::DRIVER_DBLIB, + TDbDriver::DRIVER_SQLSRV => 'TSqlSrvScaffoldInput', + TDbDriver::DRIVER_OCI => 'TOracleScaffoldInput', + TDbDriver::DRIVER_IBM => 'TIbmScaffoldInput', + default => null, + }; + } + + /** + * Creates and returns a scaffold input builder instance for the given connection. + * + * The driver is derived from `$connection->getDriverName()`. For built-in + * drivers, the appropriate file is loaded via `require_once` and a new + * instance of the driver-specific class is returned directly. + * + * For unknown drivers, the **`fxActiveRecordScaffoldInputClass`** global + * event is raised on `$connection` with the driver name as the parameter. + * Event handlers must return the **fully-qualified class name** of a class + * that implements {@see IScaffoldInput}. The first value in the event + * result array is used. + * + * This method fully encapsulates the `fxActiveRecordScaffoldInputClass` + * event so that callers (e.g. + * {@see \Prado\Data\ActiveRecord\Scaffold\InputBuilder\TScaffoldInputBase::createInputBuilder}) + * never need to call `raiseEvent` themselves. + * + * @param TDbConnection $connection the active connection; the driver name is + * derived via {@see TDbConnection::getDriverName()}. + * @throws TConfigurationException if the driver is unknown and no event + * handler provides a class name, or if a handler returns an + * {@see IScaffoldInput} instance instead of a class name string. + * @return IScaffoldInput the scaffold input builder instance. + */ + public static function createScaffoldInput(TDbConnection $connection): IScaffoldInput + { + $driver = strtolower($connection->getDriverName()); + $file = static::getScaffoldInputFile($driver); + $class = static::getScaffoldInputClass($driver); + if ($file !== null && $class !== null) { + require_once(__DIR__ . '/ActiveRecord/Scaffold/InputBuilder' . $file); + return new $class(); + } + $inputClasses = $connection->raiseEvent('fxActiveRecordScaffoldInputClass', $connection, $driver); + if (empty($inputClasses)) { + // @todo v4.4 TActiveRecordConfigurationException, move message + throw new TConfigurationException('ar_invalid_database_driver', $driver); + } + $class = $inputClasses[0]; + if (!is_string($class) || !is_a($class, IScaffoldInput::class, true)) { + // @todo v4.4 TActiveRecordConfigurationException, move message + throw new TConfigurationException('ar_not_input_base', is_string($class) ? $class : $class::class, IScaffoldInput::class); + } + return new $class(); + } +} diff --git a/framework/Data/TDbNullConversionMode.php b/framework/Data/TDbNullConversionMode.php index 4a85c6f38..35d6f77af 100644 --- a/framework/Data/TDbNullConversionMode.php +++ b/framework/Data/TDbNullConversionMode.php @@ -11,7 +11,7 @@ namespace Prado\Data; /** - * TDbNullConversionMode + * TDbNullConversionMode class * * @author Qiang Xue * @since 3.0 diff --git a/framework/Data/TDbPropertiesTrait.php b/framework/Data/TDbPropertiesTrait.php index dbd736938..93054c01f 100644 --- a/framework/Data/TDbPropertiesTrait.php +++ b/framework/Data/TDbPropertiesTrait.php @@ -17,7 +17,7 @@ use Prado\Prado; /** - * TDbPropertiesTrait class. + * TDbPropertiesTrait trait * * This trait provides database connection management functionality for classes * that need to connect to a database. It supports both explicit connection @@ -161,7 +161,7 @@ protected function getDbConnectionActivationType(): ?bool * If no ConnectionID is available, this will try to start a sqlite database * if the subclass has a name via getSqliteDatabaseName(). * - * @param null|string $connectionID the module ID for TDataSourceConfig. If null, uses getConnectionID(). + * @param ?string $connectionID the module ID for TDataSourceConfig. If null, uses getConnectionID(). * @throws TConfigurationException if module ID is invalid or empty without a Sqlite database. * @return TDbConnection the created DB connection */ @@ -225,7 +225,7 @@ protected function getCustomDbConnection(): ?TDbConnection * When the class overrides this method, createDbConnection will try to * start a sqlite database in the PRADO Runtime Path. * - * @return null|string if the using class wants a sqlite db then return the name, otherwise null + * @return ?string if the using class wants a sqlite db then return the name, otherwise null */ protected function getSqliteDatabaseName(): ?string { diff --git a/framework/Data/TDbTransaction.php b/framework/Data/TDbTransaction.php index 709e1ea76..24db0d175 100644 --- a/framework/Data/TDbTransaction.php +++ b/framework/Data/TDbTransaction.php @@ -10,32 +10,60 @@ namespace Prado\Data; +use PDO; +use PDOException; use Prado\Exceptions\TDbException; -use Prado\Prado; -use Prado\TPropertyValue; /** - * TDbTransaction class. + * TDbTransaction class * - * TDbTransaction represents a DB transaction. - * It is usually created by calling {@see \Prado\Data\TDbConnection::beginTransaction}. + * TDbTransaction represents a PDO database transaction. It is created by calling + * {@see TDbConnection::beginTransaction()} and must be explicitly committed or + * rolled back. After either operation the transaction becomes inactive. + * + * **Single-use pattern** — the classic approach, where each work unit gets a + * fresh transaction object from the connection: + * + * ```php + * try { + * $transaction = $connection->beginTransaction(); + * $connection->createCommand($sql1)->execute(); + * $connection->createCommand($sql2)->execute(); + * $transaction->commit(); + * } catch (Exception $e) { + * $transaction->rollback(); + * } + * ``` + * + * **Reuse pattern** — a single `TDbTransaction` instance can be restarted for + * sequential work units by calling {@see beginTransaction()} on the object + * itself after committing or rolling back, avoiding a new object allocation: * - * The following code is a common scenario of using transactions: * ```php - * try - * { - * $transaction=$connection->beginTransaction(); - * $connection->createCommand($sql1)->execute(); - * $connection->createCommand($sql2)->execute(); - * //.... other SQL executions - * $transaction->commit(); + * $tx = $connection->beginTransaction(); + * try { + * $connection->createCommand($sql1)->execute(); + * $tx->commit(); + * } catch (Exception $e) { + * $tx->rollback(); * } - * catch(Exception $e) - * { - * $transaction->rollBack(); + * // Start the next unit of work on the same object. + * $tx->beginTransaction(); + * try { + * $connection->createCommand($sql2)->execute(); + * $tx->commit(); + * } catch (Exception $e) { + * $tx->rollback(); * } * ``` * + * **Supersession:** calling {@see TDbConnection::beginTransaction()} always + * creates a **new** `TDbTransaction` object. If the connection's + * `beginTransaction()` is called after a TDbTransaction completes, that old + * transaction is superseded. Attempting to restart a superseded transaction + * via self {@see TDbTransaction::beginTransaction()} will throw a + * {@see TDbException}. + * * @author Qiang Xue * @since 3.0 */ @@ -46,65 +74,220 @@ class TDbTransaction extends \Prado\TComponent implements IDataTransaction /** * Constructor. - * @param \Prado\Data\TDbConnection $connection the connection associated with this transaction + * @param TDbConnection $connection the connection that owns this transaction. * @see TDbConnection::beginTransaction */ public function __construct(TDbConnection $connection) { - $this->_connection = $connection; + $this->setConnection($connection); $this->setActive(true); parent::__construct(); } + // ----- Getters and Setters ----- + + /** + * Returns the connection that owns this transaction. + * + * @return TDbConnection the connection that created this transaction. + */ + public function getConnection() + { + return $this->_connection; + } + + /** + * Sets the connection that owns this transaction. + * + * Called once by the constructor; not intended for external use. + * + * @param TDbConnection $connection the owning connection. + * @return static + */ + protected function setConnection(TDbConnection $connection): static + { + $this->_connection = $connection; + return $this; + } + /** - * Commits a transaction. - * @throws TDbException if the transaction or the DB connection is not active. + * Returns whether this transaction is currently active (i.e. has been + * started and not yet committed or rolled back). + * + * @return bool true while the transaction is open, false after commit/rollback. + */ + public function getActive() + { + return $this->_active; + } + + /** + * Sets the active state of this transaction. + * + * Managed internally by {@see beginTransaction()}, {@see completeTransaction()}, + * and the constructor; not intended for external use. + * + * @param bool $value true to mark as active, false to mark as inactive. + * @return static + */ + protected function setActive(bool $value): static + { + $this->_active = $value; + return $this; + } + + // ----- Methods ----- + + /** + * Creates a command on this transaction's connection. + * + * Convenience shorthand for `$transaction->getConnection()->createCommand($sql)`. + * + * @param string $sql SQL statement for the new command. + * @return TDbCommand the new command object. + * @since 4.3.3 + */ + public function createCommand($sql) + { + return $this->getConnection()->createCommand($sql); + } + + /** + * Commits the transaction. + * + * The transaction becomes inactive after commit. To start another work unit, + * either call {@see TDbTransaction::beginTransaction()} on this object (reuse + * pattern) or call {@see TDbConnection::beginTransaction()} to obtain a fresh + * transaction object. + * + * @throws TDbException if the transaction or its connection is not active. */ public function commit() { - if ($this->_active && $this->_connection->getActive()) { - $this->_connection->getPdoInstance()->commit(); - $this->_active = false; - } else { - throw new TDbException('dbtransaction_transaction_inactive'); - } + $pdo = $this->assertActive(); + $pdo->commit(); + $this->completeTransaction($pdo); } /** - * Rolls back a transaction. - * @throws TDbException if the transaction or the DB connection is not active. + * Rolls back the transaction. + * + * The transaction becomes inactive after rollback. To start another work unit, + * either call {@see TDbTransaction::beginTransaction()} on this object (reuse + * pattern) or call {@see TDbConnection::beginTransaction()} to obtain a fresh + * transaction object. + * + * @throws TDbException if the transaction or its connection is not active. */ public function rollback() { - if ($this->_active && $this->_connection->getActive()) { - $this->_connection->getPdoInstance()->rollBack(); - $this->_active = false; - } else { - throw new TDbException('dbtransaction_transaction_inactive'); - } + $pdo = $this->assertActive(); + $pdo->rollBack(); + $this->completeTransaction($pdo); } /** - * @return \Prado\Data\TDbConnection the DB connection for this transaction + * Asserts that this transaction and its connection are both active, then + * returns the underlying PDO instance. + * + * @throws TDbException if the transaction or its connection is not active. + * @return PDO the active PDO instance. */ - public function getConnection() + protected function assertActive(): PDO { - return $this->_connection; + $connection = $this->getConnection(); + + if (!$this->getActive() || !$connection->getActive()) { + throw new TDbException('dbtransaction_transaction_inactive'); + } + + return $connection->getPdoInstance(); } /** - * @return bool whether this transaction is active + * Marks the transaction inactive and, for drivers that require it, flushes + * the implicit transaction that the driver opens immediately after a commit + * or rollback. + * + * pdo_firebird starts a new implicit transaction right after every + * `isc_commit_transaction` or `isc_rollback_transaction` call, before the + * completed transaction is fully visible in Firebird's Transaction Inventory + * Page. The implicit transaction's MVCC snapshot can therefore see stale data. + * Committing the empty implicit transaction forces pdo_firebird to open a fresh + * one whose snapshot reflects the completed work. + * + * @param PDO $pdo the PDO instance returned by {@see assertActive()}. */ - public function getActive() + protected function completeTransaction(PDO $pdo): void { - return $this->_active; + if (TDbDriverCapabilities::requiresPostTransactionFlush($pdo->getAttribute(PDO::ATTR_DRIVER_NAME))) { + try { + $pdo->commit(); + } catch (PDOException $e) { + } + } + + $this->setActive(false); } /** - * @param bool $value whether this transaction is active + * Starts a new transaction on this transaction's connection, reactivating + * this transaction object for a new work unit. + * + * This allows a single TDbTransaction instance to span multiple sequential + * work units without allocating a new object each time: + * + * ```php + * $tx = $conn->beginTransaction(); + * $tx->commit(); + * // ... + * $tx->beginTransaction(); // reuse the same object + * $tx->commit(); + * ``` + * + * This is equivalent to calling {@see TDbConnection::beginTransaction()} but + * reactivates this existing object rather than returning a new one. + * + * **Supersession guard:** {@see TDbConnection::beginTransaction()} always + * allocates a **new** transaction object and stores it on the connection. + * If it was called after this transaction completed, this object is + * superseded — the connection now owns a different, newer transaction. + * Calling `beginTransaction()` on a superseded object throws a + * {@see TDbException} to prevent silently bypassing the active transaction's + * lifecycle. Use the new transaction object returned by the last + * {@see TDbConnection::beginTransaction()} call instead, or call it again. + * + * For pdo_firebird a pre-begin flush (`PDO::commit()`) is issued before + * `PDO::beginTransaction()` to clear the implicit transaction that Firebird + * keeps running in autocommit mode. See {@see TDbConnection::beginTransaction()} + * for the full explanation of this requirement. + * + * @throws TDbException if this transaction is already active, if its + * connection is not active, or if this transaction has been superseded by + * a newer transaction on the same connection. + * @return static + * @since 4.3.3 + * @see TDbConnection::beginTransaction */ - protected function setActive($value) + public function beginTransaction(): static { - $this->_active = TPropertyValue::ensureBoolean($value); + if ($this->getActive()) { + throw new TDbException('dbconnection_active_transaction'); + } + $connection = $this->getConnection(); + $connection->assertActive(); + if ($connection->getLastTransaction() !== $this) { + throw new TDbException('dbtransaction_transaction_superseded'); + } + $pdo = $connection->getPdoInstance(); + if (TDbDriverCapabilities::requiresPreBeginTransactionFlush($connection->getDriverName())) { + try { + $pdo->commit(); + } catch (PDOException $e) { + } + } + $pdo->beginTransaction(); + $this->setActive(true); + return $this; } } diff --git a/framework/Exceptions/messages/messages.txt b/framework/Exceptions/messages/messages.txt index 4eedaaf0d..2fde3b9c0 100644 --- a/framework/Exceptions/messages/messages.txt +++ b/framework/Exceptions/messages/messages.txt @@ -485,6 +485,7 @@ dbproperties_property_required = {1}.{0} is a required property. dbconnection_open_failed = TDbConnection failed to establish DB connection: {0} dbconnection_connection_inactive = TDbConnection is inactive. +dbconnection_active_transaction = TDbConnection cannot begin a new transaction: a transaction is already open and has uncommitted work pending. Commit or roll back the existing transaction first. dbconnection_unsupported_driver_charset = Database driver '{0}' doesn't support setting charset. dbconnection_charset_unchangeable = Database driver '{0}' cannot change the charset after opening. Charset is a DSN parameter. @@ -494,7 +495,11 @@ dbcommand_query_failed = TDbCommand failed to execute the query SQL "{1}": { dbcommand_column_empty = TDbCommand returned an empty result and could not obtain the scalar. dbdatareader_rewind_invalid = TDbDataReader is a forward-only stream. It can only be traversed once. dbtransaction_transaction_inactive = TDbTransaction is inactive. +dbtransaction_transaction_superseded = TDbTransaction cannot be restarted: a new transaction was begun on the same PDO connection after this one completed, superseding this transaction object. +dbcommandbuilder_insertorignore_not_supported = insertOrIgnore() is not supported by the base TDbCommandBuilder. Use a driver-specific subclass. +dbcommandbuilder_upsert_not_supported = upsert() is not supported by the base TDbCommandBuilder. Use a driver-specific subclass. +dbcommandbuilder_upsert_requires_transaction = {0} requires an active transaction. Call getDbConnection()->beginTransaction() before invoking insertOrIgnore() or upsert() with this database driver. dbcommandbuilder_value_must_not_be_null = Property {0} must not be null as defined by column '{2}' in table '{1}'. dbcommon_invalid_table_name = Database table '{0}' not found. Error message: {1}. @@ -627,6 +632,7 @@ dbmetadata_invalid_database_driver = Driver '{0}' is unsupported. Please instal dbmetadata_not_meta_data = Expected driver class {1} but got class {0}. datasource_dbconnection_invalid = TDataSourceConfig.DbConnection '{0}' is invalid. Please make sure it points to a valid application module. +datasource_dbconnection_exists = Cannot set ConnectionClass to '{0}': a database connection has already been established. distributeddatasource_child_required = {0} requires one '{1}' child element at minimum. masterslavedbconnection_connection_exists = {0}.{1} connection already exists. masterslavedbconnection_interface_required = {0}.{1} requires an instance implementing {2} interface. diff --git a/framework/Shell/Actions/TActiveRecordAction.php b/framework/Shell/Actions/TActiveRecordAction.php index d0ff98e11..5cb4d8f79 100644 --- a/framework/Shell/Actions/TActiveRecordAction.php +++ b/framework/Shell/Actions/TActiveRecordAction.php @@ -12,6 +12,7 @@ use Prado\Data\ActiveRecord\TActiveRecordConfig; use Prado\Data\ActiveRecord\TActiveRecordManager; +use Prado\Data\TDbDriverCapabilities; use Prado\Prado; use Prado\Shell\TShellAction; @@ -66,38 +67,12 @@ public function actionGenerateAll($args) $manager = TActiveRecordManager::getInstance(); $con = $manager->getDbConnection(); $con->setActive(true); - $command = null; - - switch ($con->getDriverName()) { - case 'mysqli': - case 'mysql': - $command = $con->createCommand("SHOW TABLES"); - break; - case 'sqlite': //sqlite 3 - case 'sqlite2': //sqlite 2 - $command = $con->createCommand("SELECT DISTINCT tbl_name FROM sqlite_master WHERE tbl_name<>'sqlite_sequence'"); - break; - case 'pgsql': - $command = $con->createCommand("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"); - break; - case 'mssql': // Mssql driver on windows hosts - case 'sqlsrv': // sqlsrv driver on windows hosts - case 'dblib': // dblib drivers on linux (and maybe others os) hosts - $command = $con->createCommand("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"); - break; - case 'oci': - $command = $con->createCommand("SELECT table_name FROM user_tables"); - break; - case 'ibm': - $command = $con->createCommand("SELECT TABNAME FROM SYSCAT.TABLES WHERE TABSCHEMA = CURRENT SCHEMA AND TYPE = 'T' ORDER BY TABNAME"); - break; - case 'firebird': - case 'interbase': - $command = $con->createCommand("SELECT TRIM(RDB\$RELATION_NAME) AS tbl_name FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL ORDER BY RDB\$RELATION_NAME"); - break; - default: - $this->_outWriter->writeError("Sorry, generateAll is not implemented for " . $con->getDriverName() . "."); + $sql = TDbDriverCapabilities::getListTablesSql($con->getDriverName()); + if ($sql === null) { + $this->_outWriter->writeError("Sorry, generateAll is not implemented for " . $con->getDriverName() . "."); + return false; } + $command = $con->createCommand($sql); $dataReader = $command->query(); $dataReader->bindColumn(1, $table); diff --git a/framework/Util/Cron/TDbCronModule.php b/framework/Util/Cron/TDbCronModule.php index 1678a2743..e5a91544a 100644 --- a/framework/Util/Cron/TDbCronModule.php +++ b/framework/Util/Cron/TDbCronModule.php @@ -15,7 +15,7 @@ use Prado\Security\Permissions\TPermissionEvent; use Prado\Security\Permissions\TUserOwnerRule; use Prado\Data\TDataSourceConfig; -use Prado\Data\TDbConnection; +use Prado\Data\TDbDriver; use Prado\Data\TDbPropertiesTrait; use Prado\Exceptions\TConfigurationException; use Prado\Exceptions\TInvalidDataValueException; @@ -259,11 +259,11 @@ protected function createDbTable() $driver = $db->getDriverName(); $autotype = 'INTEGER'; $autoidAttributes = ''; - if ($driver === 'mysql') { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI])) { $autoidAttributes = ' AUTO_INCREMENT'; - } elseif ($driver === 'sqlite') { + } elseif ($driver === TDbDriver::DRIVER_SQLITE) { $autoidAttributes = ' AUTOINCREMENT'; - } elseif ($driver === 'postgresql') { + } elseif ($driver === TDbDriver::DRIVER_PGSQL) { $autotype = 'SERIAL'; } $postIndices = '; CREATE INDEX tname ON ' . $this->_tableName . '(`name`);' . @@ -756,7 +756,7 @@ public function removeCronLogItem($taskUID) } /** - * @param null|string $name name of the logs to look for, or null for all + * @param ?string $name name of the logs to look for, or null for all * @return int the number of log items of all or of $name */ public function getCronLogCount($name = null) @@ -782,10 +782,10 @@ public function getCronLogCount($name = null) /** * Gets the cron log table of specific named or all tasks. - * @param null|string $name name of the tasks to get from the log, or null for all + * @param ?string $name name of the tasks to get from the log, or null for all * @param int $pageSize * @param int $offset - * @param null|bool $sortingDesc sort by descending execution time. + * @param ?bool $sortingDesc sort by descending execution time. */ public function getCronLog($name, $pageSize, $offset, $sortingDesc = null) { diff --git a/framework/Util/TDbLogRoute.php b/framework/Util/TDbLogRoute.php index f000ca050..45614bde8 100644 --- a/framework/Util/TDbLogRoute.php +++ b/framework/Util/TDbLogRoute.php @@ -12,7 +12,7 @@ use Exception; use Prado\Data\TDataSourceConfig; -use Prado\Data\TDbConnection; +use Prado\Data\TDbDriver; use Prado\Data\TDbPropertiesTrait; use Prado\Exceptions\TConfigurationException; use Prado\Exceptions\TLogException; @@ -270,10 +270,10 @@ protected function createDbTable() $db = $this->getDbConnection(); $driver = $db->getDriverName(); $autoidAttributes = ''; - if ($driver === 'mysql') { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI])) { $autoidAttributes = 'AUTO_INCREMENT'; } - if ($driver === 'pgsql') { + if ($driver === TDbDriver::DRIVER_PGSQL) { $param = 'SERIAL'; } else { $param = 'INTEGER NOT NULL'; diff --git a/framework/Util/TDbParameterModule.php b/framework/Util/TDbParameterModule.php index a127d09e9..698203780 100644 --- a/framework/Util/TDbParameterModule.php +++ b/framework/Util/TDbParameterModule.php @@ -13,7 +13,7 @@ use Exception; use PDO; use Prado\Data\TDataSourceConfig; -use Prado\Data\TDbConnection; +use Prado\Data\TDbDriver; use Prado\Exceptions\TConfigurationException; use Prado\Exceptions\TInvalidDataTypeException; use Prado\Exceptions\TInvalidOperationException; @@ -410,7 +410,7 @@ public function set($key, $value, $autoLoad = true, $setParameter = true) $db = $this->getDbConnection(); $driver = $db->getDriverName(); $appendix = ''; - if ($driver === 'mysql') { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI])) { $dupl = ($this->_autoLoadField ? ", {$this->_autoLoadField}=values({$this->_autoLoadField})" : ''); $appendix = " ON DUPLICATE KEY UPDATE {$this->_valueField}=values({$this->_valueField}){$dupl}"; } else { @@ -484,7 +484,7 @@ public function remove($key) $db = $this->getDbConnection(); $driver = $db->getDriverName(); $appendix = ''; - if ($driver === 'mysql') { + if (in_array($driver, [TDbDriver::DRIVER_MYSQL, TDbDriver::EXTENSION_MYSQLI])) { $appendix = ' LIMIT 1'; } $cmd = $db->createCommand("DELETE FROM {$this->_tableName} WHERE {$this->_keyField}=:key" . $appendix); diff --git a/framework/classes.php b/framework/classes.php index 9b351fd61..7a0937387 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -67,12 +67,15 @@ 'TFirebirdScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TFirebirdScaffoldInput', 'TIbmScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TIbmScaffoldInput', 'TMssqlScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TMssqlScaffoldInput', +'TSqlSrvScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TSqlSrvScaffoldInput', 'TMysqlScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TMysqlScaffoldInput', +'TOracleScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TOracleScaffoldInput', 'TPgsqlScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TPgsqlScaffoldInput', 'TScaffoldInputBase' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TScaffoldInputBase', 'TScaffoldInputCommon' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TScaffoldInputCommon', 'TSqliteScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\TSqliteScaffoldInput', 'IScaffoldEditRenderer' => 'Prado\Data\ActiveRecord\Scaffold\IScaffoldEditRenderer', +'IScaffoldInput' => 'Prado\Data\ActiveRecord\Scaffold\InputBuilder\IScaffoldInput', 'TScaffoldBase' => 'Prado\Data\ActiveRecord\Scaffold\TScaffoldBase', 'TScaffoldEditView' => 'Prado\Data\ActiveRecord\Scaffold\TScaffoldEditView', 'TScaffoldListView' => 'Prado\Data\ActiveRecord\Scaffold\TScaffoldListView', @@ -93,16 +96,26 @@ 'TIbmMetaData' => 'Prado\Data\Common\Ibm\TIbmMetaData', 'TIbmTableColumn' => 'Prado\Data\Common\Ibm\TIbmTableColumn', 'TIbmTableInfo' => 'Prado\Data\Common\Ibm\TIbmTableInfo', -'IDbHasSchema' => 'Prado\Data\Common\IDbHasSchema', +'IDataColumn' => 'Prado\Data\Common\IDataColumn', +'IDataCommandBuilder' => 'Prado\Data\Common\IDataCommandBuilder', +'IDataMetaData' => 'Prado\Data\Common\IDataMetaData', +'IDataTableInfo' => 'Prado\Data\Common\IDataTableInfo', +'IDataHasSchema' => 'Prado\Data\Common\IDataHasSchema', +'IDbColumn' => 'Prado\Data\Common\IDbColumn', 'TMssqlCommandBuilder' => 'Prado\Data\Common\Mssql\TMssqlCommandBuilder', 'TMssqlMetaData' => 'Prado\Data\Common\Mssql\TMssqlMetaData', 'TMssqlTableColumn' => 'Prado\Data\Common\Mssql\TMssqlTableColumn', 'TMssqlTableInfo' => 'Prado\Data\Common\Mssql\TMssqlTableInfo', +'TSqlSrvCommandBuilder' => 'Prado\Data\Common\SqlSrv\TSqlSrvCommandBuilder', +'TSqlSrvMetaData' => 'Prado\Data\Common\SqlSrv\TSqlSrvMetaData', +'TSqlSrvTableColumn' => 'Prado\Data\Common\SqlSrv\TSqlSrvTableColumn', +'TSqlSrvTableInfo' => 'Prado\Data\Common\SqlSrv\TSqlSrvTableInfo', 'TMysqlCommandBuilder' => 'Prado\Data\Common\Mysql\TMysqlCommandBuilder', 'TMysqlMetaData' => 'Prado\Data\Common\Mysql\TMysqlMetaData', 'TMysqlTableColumn' => 'Prado\Data\Common\Mysql\TMysqlTableColumn', 'TMysqlTableInfo' => 'Prado\Data\Common\Mysql\TMysqlTableInfo', 'TOracleCommandBuilder' => 'Prado\Data\Common\Oracle\TOracleCommandBuilder', +'TOracleDbCommand' => 'Prado\Data\Common\Oracle\TOracleDbCommand', 'TOracleMetaData' => 'Prado\Data\Common\Oracle\TOracleMetaData', 'TOracleTableColumn' => 'Prado\Data\Common\Oracle\TOracleTableColumn', 'TOracleTableInfo' => 'Prado\Data\Common\Oracle\TOracleTableInfo', @@ -123,6 +136,11 @@ 'TDataGatewayResultEventParameter' => 'Prado\Data\DataGateway\TDataGatewayResultEventParameter', 'TSqlCriteria' => 'Prado\Data\DataGateway\TSqlCriteria', 'TTableGateway' => 'Prado\Data\DataGateway\TTableGateway', +'IDataCommand' => 'Prado\Data\IDataCommand', +'IDataConnection' => 'Prado\Data\IDataConnection', +'IDataReader' => 'Prado\Data\IDataReader', +'IDataTransaction' => 'Prado\Data\IDataTransaction', +'IDbConnection' => 'Prado\Data\IDbConnection', 'TDiscriminator' => 'Prado\Data\SqlMap\Configuration\TDiscriminator', 'TInlineParameterMapParser' => 'Prado\Data\SqlMap\Configuration\TInlineParameterMapParser', 'TParameterMap' => 'Prado\Data\SqlMap\Configuration\TParameterMap', @@ -135,10 +153,12 @@ 'TSqlMapCacheTypes' => 'Prado\Data\SqlMap\Configuration\TSqlMapCacheTypes', 'TSqlMapDelete' => 'Prado\Data\SqlMap\Configuration\TSqlMapDelete', 'TSqlMapInsert' => 'Prado\Data\SqlMap\Configuration\TSqlMapInsert', +'TSqlMapInsertOrIgnore' => 'Prado\Data\SqlMap\Configuration\TSqlMapInsertOrIgnore', 'TSqlMapSelect' => 'Prado\Data\SqlMap\Configuration\TSqlMapSelect', 'TSqlMapSelectKey' => 'Prado\Data\SqlMap\Configuration\TSqlMapSelectKey', 'TSqlMapStatement' => 'Prado\Data\SqlMap\Configuration\TSqlMapStatement', 'TSqlMapUpdate' => 'Prado\Data\SqlMap\Configuration\TSqlMapUpdate', +'TSqlMapUpsert' => 'Prado\Data\SqlMap\Configuration\TSqlMapUpsert', 'TSqlMapXmlConfigBuilder' => 'Prado\Data\SqlMap\Configuration\TSqlMapXmlConfigBuilder', 'TSqlMapXmlConfiguration' => 'Prado\Data\SqlMap\Configuration\TSqlMapXmlConfiguration', 'TSqlMapXmlMappingConfiguration' => 'Prado\Data\SqlMap\Configuration\TSqlMapXmlMappingConfiguration', @@ -163,6 +183,7 @@ 'TCachingStatement' => 'Prado\Data\SqlMap\Statements\TCachingStatement', 'TDeleteMappedStatement' => 'Prado\Data\SqlMap\Statements\TDeleteMappedStatement', 'TInsertMappedStatement' => 'Prado\Data\SqlMap\Statements\TInsertMappedStatement', +'TInsertOrIgnoreMappedStatement' => 'Prado\Data\SqlMap\Statements\TInsertOrIgnoreMappedStatement', 'TMappedStatement' => 'Prado\Data\SqlMap\Statements\TMappedStatement', 'TPostSelectBinding' => 'Prado\Data\SqlMap\Statements\TPostSelectBinding', 'TPreparedCommand' => 'Prado\Data\SqlMap\Statements\TPreparedCommand', @@ -175,21 +196,21 @@ 'TSqlMapObjectCollectionTree' => 'Prado\Data\SqlMap\Statements\TSqlMapObjectCollectionTree', 'TStaticSql' => 'Prado\Data\SqlMap\Statements\TStaticSql', 'TUpdateMappedStatement' => 'Prado\Data\SqlMap\Statements\TUpdateMappedStatement', +'TUpsertMappedStatement' => 'Prado\Data\SqlMap\Statements\TUpsertMappedStatement', 'TSqlMapConfig' => 'Prado\Data\SqlMap\TSqlMapConfig', 'TSqlMapGateway' => 'Prado\Data\SqlMap\TSqlMapGateway', 'TSqlMapManager' => 'Prado\Data\SqlMap\TSqlMapManager', 'TDataSourceConfig' => 'Prado\Data\TDataSourceConfig', +'TDataCharset' => 'Prado\Data\TDataCharset', 'TDbColumnCaseMode' => 'Prado\Data\TDbColumnCaseMode', 'TDbCommand' => 'Prado\Data\TDbCommand', 'TDbConnection' => 'Prado\Data\TDbConnection', 'TDbDataReader' => 'Prado\Data\TDbDataReader', +'TDbDriver' => 'Prado\Data\TDbDriver', +'TDbDriverCapabilities' => 'Prado\Data\TDbDriverCapabilities', 'TDbNullConversionMode' => 'Prado\Data\TDbNullConversionMode', 'TDbPropertiesTrait' => 'Prado\Data\TDbPropertiesTrait', 'TDbTransaction' => 'Prado\Data\TDbTransaction', -'IDataCommand' => 'Prado\Data\IDataCommand', -'IDataConnection' => 'Prado\Data\IDataConnection', -'IDataReader' => 'Prado\Data\IDataReader', -'IDataTransaction' => 'Prado\Data\IDataTransaction', 'TApplicationException' => 'Prado\Exceptions\TApplicationException', 'TConfigurationException' => 'Prado\Exceptions\TConfigurationException', 'TDbConnectionException' => 'Prado\Exceptions\TDbConnectionException', diff --git a/tests/initdb_firebird.sql b/tests/initdb_firebird.sql index adfb8f0d7..0c5189969 100644 --- a/tests/initdb_firebird.sql +++ b/tests/initdb_firebird.sql @@ -53,4 +53,12 @@ CREATE TABLE address ( INSERT INTO table1 (name) VALUES ('test'); INSERT INTO address (username, phone, field2_date) VALUES ('wei', '1111111', CURRENT_DATE); +/* Firebird upsert uses MERGE ... USING (SELECT ... FROM RDB$DATABASE) ON the PK. + DEFAULT must come BEFORE NOT NULL in Firebird column definitions. */ +CREATE TABLE upsert_test ( + username VARCHAR(100) NOT NULL, + score INTEGER DEFAULT 0 NOT NULL, + CONSTRAINT pk_upsert_test PRIMARY KEY (username) +); + COMMIT; diff --git a/tests/initdb_ibm.sql b/tests/initdb_ibm.sql index bbb6647c2..251af58c4 100644 --- a/tests/initdb_ibm.sql +++ b/tests/initdb_ibm.sql @@ -53,3 +53,15 @@ CREATE TABLE address ( INSERT INTO table1 (name) VALUES ('test')@ INSERT INTO address (username, phone, field2_date, field4_int) VALUES ('wei', '1111111', CURRENT_DATE, 1)@ + +BEGIN + DECLARE CONTINUE HANDLER FOR SQLSTATE '42704' BEGIN END; + EXECUTE IMMEDIATE 'DROP TABLE upsert_test'; +END@ + +-- DB2 upsert uses MERGE ... USING (SELECT ... FROM SYSIBM.SYSDUMMY1) ON the PK. +CREATE TABLE upsert_test ( + username VARCHAR(100) NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (username) +)@ diff --git a/tests/initdb_mysql.sql b/tests/initdb_mysql.sql index c2740f8a4..4e565dd72 100644 --- a/tests/initdb_mysql.sql +++ b/tests/initdb_mysql.sql @@ -238,3 +238,12 @@ CREATE TABLE `table1` ( PRIMARY KEY (`id`, `name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; +DROP TABLE IF EXISTS `upsert_test`; +CREATE TABLE `upsert_test` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `username` VARCHAR(100) NOT NULL, + `score` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_upsert_test_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci; + diff --git a/tests/initdb_oracle.sql b/tests/initdb_oracle.sql index b534b8262..12b040850 100644 --- a/tests/initdb_oracle.sql +++ b/tests/initdb_oracle.sql @@ -54,4 +54,18 @@ INSERT INTO table1 (name, field1_number, field4_float, field5_number_ps, field8_ VALUES ('test', 1, 1.0, 1.0, SYSTIMESTAMP, 1.0); INSERT INTO address (username, phone, field4_int) VALUES ('wei', '1111111', 0); COMMIT; + +BEGIN + EXECUTE IMMEDIATE 'DROP TABLE upsert_test'; +EXCEPTION WHEN OTHERS THEN NULL; +END; +/ + +-- Oracle upsert uses MERGE ... USING (SELECT ... FROM DUAL) ON the PK column. +CREATE TABLE upsert_test ( + username VARCHAR2(100) NOT NULL, + score NUMBER(10) DEFAULT 0 NOT NULL, + CONSTRAINT pk_upsert_test PRIMARY KEY (username) +); +COMMIT; EXIT diff --git a/tests/initdb_pgsql.sql b/tests/initdb_pgsql.sql index abadce88a..837345fdf 100644 --- a/tests/initdb_pgsql.sql +++ b/tests/initdb_pgsql.sql @@ -20,3 +20,12 @@ CREATE TABLE address ( "int_fk2" INT NOT NULL, PRIMARY KEY ("id") ); + +DROP TABLE IF EXISTS upsert_test; +CREATE TABLE upsert_test ( + "id" SERIAL NOT NULL, + "username" VARCHAR(100) NOT NULL, + "score" INT NOT NULL DEFAULT 0, + PRIMARY KEY ("id"), + UNIQUE ("username") +); diff --git a/tests/initdb_mssql.sql b/tests/initdb_sqlsrv.sql similarity index 89% rename from tests/initdb_mssql.sql rename to tests/initdb_sqlsrv.sql index 5adb5e9c4..57e9bf798 100644 --- a/tests/initdb_mssql.sql +++ b/tests/initdb_sqlsrv.sql @@ -69,3 +69,14 @@ GO INSERT INTO dbo.table1 (name) VALUES ('test'); INSERT INTO dbo.address (username, phone, field2_date) VALUES ('wei', '1111111', '2024-01-01'); GO + +IF OBJECT_ID('dbo.upsert_test', 'U') IS NOT NULL DROP TABLE dbo.upsert_test; +GO + +-- MSSQL upsert uses MERGE ON the PK column; no IDENTITY needed. +CREATE TABLE dbo.upsert_test ( + username NVARCHAR(100) NOT NULL, + score INT NOT NULL DEFAULT 0, + CONSTRAINT pk_upsert_test PRIMARY KEY (username) +); +GO diff --git a/tests/unit/Data/ActiveRecord/ActiveRecordInsertOrIgnoreTest.php b/tests/unit/Data/ActiveRecord/ActiveRecordInsertOrIgnoreTest.php new file mode 100644 index 000000000..5bed2ccd9 --- /dev/null +++ b/tests/unit/Data/ActiveRecord/ActiveRecordInsertOrIgnoreTest.php @@ -0,0 +1,393 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + static::$conn->createCommand('DELETE FROM `upsert_test`')->execute(); + static::$conn->createCommand('ALTER TABLE `upsert_test` AUTO_INCREMENT = 1')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + // New record — auto-increment PK + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_new_record_returns_last_insert_id(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $result = $record->insertOrIgnore(); + + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_insertOrIgnore_populates_pk_field_after_insert(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->insertOrIgnore(); + + $this->assertNotNull($record->id); + $this->assertGreaterThan(0, (int) $record->id); + } + + public function test_insertOrIgnore_new_record_transitions_to_state_loaded(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $this->assertSame(TActiveRecord::STATE_NEW, $record->getRecordState(), 'should start STATE_NEW'); + + $record->insertOrIgnore(); + + $this->assertSame(TActiveRecord::STATE_LOADED, $record->getRecordState()); + } + + public function test_insertOrIgnore_new_record_stores_data_in_db(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 42; + + $record->insertOrIgnore(); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertNotNull($found); + $this->assertSame('alice', $found->username); + $this->assertSame(42, (int) $found->score); + } + + public function test_insertOrIgnore_successive_new_records_return_incrementing_ids(): void + { + $alice = new UpsertTestRecord(); + $alice->username = 'alice'; + $alice->score = 1; + $idAlice = (int) $alice->insertOrIgnore(); + + $bob = new UpsertTestRecord(); + $bob->username = 'bob'; + $bob->score = 2; + $idBob = (int) $bob->insertOrIgnore(); + + $this->assertGreaterThan($idAlice, $idBob); + } + + // ----------------------------------------------------------------------- + // Duplicate key — conflict silently ignored + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_duplicate_username_returns_false(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + + $result = $duplicate->insertOrIgnore(); + + $this->assertFalse($result); + } + + public function test_insertOrIgnore_conflict_leaves_state_new(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + $duplicate->insertOrIgnore(); + + $this->assertSame(TActiveRecord::STATE_NEW, $duplicate->getRecordState()); + } + + public function test_insertOrIgnore_conflict_does_not_populate_pk(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + $duplicate->insertOrIgnore(); + + $this->assertNull($duplicate->id); + } + + public function test_insertOrIgnore_conflict_does_not_overwrite_existing_row(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + $duplicate->insertOrIgnore(); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertSame(10, (int) $found->score, 'original score must be unchanged'); + } + + public function test_insertOrIgnore_conflict_does_not_increase_row_count(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + $duplicate->insertOrIgnore(); + + $count = (int) static::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertSame(1, $count); + } + + // ----------------------------------------------------------------------- + // Mixed: conflict row then new row + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_non_conflicting_insert_after_conflict_succeeds(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $conflict = new UpsertTestRecord(); + $conflict->username = 'alice'; + $conflict->score = 99; + $result1 = $conflict->insertOrIgnore(); + + $bob = new UpsertTestRecord(); + $bob->username = 'bob'; + $bob->score = 20; + $result2 = $bob->insertOrIgnore(); + + $this->assertFalse($result1, 'conflict must return false'); + $this->assertNotFalse($result2, 'new row must succeed'); + $this->assertGreaterThan(0, (int) $result2); + } + + public function test_insertOrIgnore_correct_values_after_mixed_operations(): void + { + $alice = new UpsertTestRecord(); + $alice->username = 'alice'; + $alice->score = 10; + $alice->insertOrIgnore(); + + $aliceDup = new UpsertTestRecord(); + $aliceDup->username = 'alice'; + $aliceDup->score = 99; + $aliceDup->insertOrIgnore(); + + $bob = new UpsertTestRecord(); + $bob->username = 'bob'; + $bob->score = 55; + $bob->insertOrIgnore(); + + $foundAlice = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $foundBob = UpsertTestRecord::finder()->find('username = ?', 'bob'); + + $this->assertSame(10, (int) $foundAlice->score, 'alice score must be unchanged'); + $this->assertSame(55, (int) $foundBob->score, 'bob score must be stored'); + } + + // ----------------------------------------------------------------------- + // OnInsert event + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_fires_oninsert_event(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $eventFired = false; + $record->OnInsert[] = function ($sender, $param) use (&$eventFired): void { + $this->assertInstanceOf(TActiveRecordChangeEventParameter::class, $param); + $eventFired = true; + }; + + $record->insertOrIgnore(); + + $this->assertTrue($eventFired, 'OnInsert event was not fired'); + } + + public function test_insertOrIgnore_fires_oninsert_even_when_conflict_occurs(): void + { + $first = new UpsertTestRecord(); + $first->username = 'alice'; + $first->score = 10; + $first->insertOrIgnore(); + + $duplicate = new UpsertTestRecord(); + $duplicate->username = 'alice'; + $duplicate->score = 99; + + $eventFired = false; + $duplicate->OnInsert[] = function ($sender, $param) use (&$eventFired): void { + $eventFired = true; + }; + + $duplicate->insertOrIgnore(); + + $this->assertTrue($eventFired, 'OnInsert event must fire even when DB ignores the row'); + } + + public function test_insertOrIgnore_oninsert_can_veto_the_operation(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $result = $record->insertOrIgnore(); + + $this->assertFalse($result); + } + + public function test_insertOrIgnore_veto_leaves_state_new(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $record->insertOrIgnore(); + + $this->assertSame(TActiveRecord::STATE_NEW, $record->getRecordState()); + } + + public function test_insertOrIgnore_veto_writes_nothing_to_db(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $record->insertOrIgnore(); + + $count = (int) static::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertSame(0, $count); + } + + // ----------------------------------------------------------------------- + // String (non-auto-increment) PK — uses the existing `Users` table + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_string_pk_new_record_returns_truthy(): void + { + $user = new UserRecord(); + $user->username = 'insertIgnoreTestUser'; + $user->password = md5('pass'); + $user->email = 'test@example.com'; + + $result = $user->insertOrIgnore(); + + $this->assertNotFalse($result); + + // cleanup + UserRecord::finder()->findByPk('insertIgnoreTestUser')?->delete(); + } + + public function test_insertOrIgnore_string_pk_duplicate_returns_false(): void + { + // 'admin' is seeded by initdb_mysql.sql + $user = new UserRecord(); + $user->username = 'admin'; + $user->password = md5('other'); + $user->email = 'other@example.com'; + + $result = $user->insertOrIgnore(); + + $this->assertFalse($result); + } + + public function test_insertOrIgnore_string_pk_duplicate_does_not_overwrite(): void + { + $user = new UserRecord(); + $user->username = 'admin'; + $user->email = 'overwrite@example.com'; + + $user->insertOrIgnore(); + + $found = UserRecord::finder()->findByPk('admin'); + $this->assertNotSame('overwrite@example.com', $found->email, 'original email must be unchanged'); + } +} diff --git a/tests/unit/Data/ActiveRecord/ActiveRecordUpsertTest.php b/tests/unit/Data/ActiveRecord/ActiveRecordUpsertTest.php new file mode 100644 index 000000000..417b6564a --- /dev/null +++ b/tests/unit/Data/ActiveRecord/ActiveRecordUpsertTest.php @@ -0,0 +1,390 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + static::$conn->createCommand('DELETE FROM `upsert_test`')->execute(); + static::$conn->createCommand('ALTER TABLE `upsert_test` AUTO_INCREMENT = 1')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + // Insert new record + // ----------------------------------------------------------------------- + + public function test_upsert_new_record_returns_last_insert_id(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $result = $record->upsert(); + + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_upsert_new_record_populates_pk_field(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->upsert(); + + $this->assertNotNull($record->id); + $this->assertGreaterThan(0, (int) $record->id); + } + + public function test_upsert_new_record_transitions_to_state_loaded(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $this->assertSame(TActiveRecord::STATE_NEW, $record->getRecordState(), 'should start STATE_NEW'); + + $record->upsert(); + + $this->assertSame(TActiveRecord::STATE_LOADED, $record->getRecordState()); + } + + public function test_upsert_new_record_stores_data_in_db(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 42; + + $record->upsert(); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertNotNull($found); + $this->assertSame('alice', $found->username); + $this->assertSame(42, (int) $found->score); + } + + // ----------------------------------------------------------------------- + // Conflict → update existing row + // ----------------------------------------------------------------------- + + public function test_upsert_conflict_updates_existing_row(): void + { + $original = new UpsertTestRecord(); + $original->username = 'alice'; + $original->score = 10; + $original->upsert(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + $update->upsert(); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertSame(99, (int) $found->score); + } + + public function test_upsert_conflict_returns_truthy(): void + { + $original = new UpsertTestRecord(); + $original->username = 'alice'; + $original->score = 10; + $original->upsert(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + + $result = $update->upsert(); + + $this->assertNotFalse($result); + } + + public function test_upsert_conflict_transitions_to_state_loaded(): void + { + $original = new UpsertTestRecord(); + $original->username = 'alice'; + $original->score = 10; + $original->upsert(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + + $this->assertSame(TActiveRecord::STATE_NEW, $update->getRecordState()); + + $update->upsert(); + + $this->assertSame(TActiveRecord::STATE_LOADED, $update->getRecordState()); + } + + public function test_upsert_conflict_does_not_create_duplicate_rows(): void + { + $original = new UpsertTestRecord(); + $original->username = 'alice'; + $original->score = 10; + $original->upsert(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + $update->upsert(); + + $count = (int) static::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertSame(1, $count); + } + + // ----------------------------------------------------------------------- + // $updateData parameter + // ----------------------------------------------------------------------- + + public function test_upsert_null_updateData_updates_all_non_pk_columns(): void + { + static::$conn->createCommand( + "INSERT INTO `upsert_test` (`username`, `score`) VALUES ('alice', 10)" + )->execute(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 88; + $update->upsert(null, ['username']); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertSame(88, (int) $found->score); + } + + public function test_upsert_explicit_updateData_only_updates_listed_columns(): void + { + static::$conn->createCommand( + "INSERT INTO `upsert_test` (`username`, `score`) VALUES ('alice', 10)" + )->execute(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 55; + $update->upsert(['score' => 55], ['username']); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertSame(55, (int) $found->score); + $this->assertSame('alice', $found->username); + } + + public function test_upsert_empty_updateData_does_not_update_on_conflict(): void + { + // Empty updateData degrades to INSERT IGNORE semantics — no update on conflict. + static::$conn->createCommand( + "INSERT INTO `upsert_test` (`username`, `score`) VALUES ('alice', 10)" + )->execute(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + $update->upsert([], ['username']); + + $found = UpsertTestRecord::finder()->find('username = ?', 'alice'); + $this->assertSame(10, (int) $found->score, 'score must not change when updateData is empty'); + } + + // ----------------------------------------------------------------------- + // Unrelated rows are not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_affect_other_rows(): void + { + static::$conn->createCommand( + "INSERT INTO `upsert_test` (`username`, `score`) VALUES ('alice', 10), ('bob', 20)" + )->execute(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + $update->upsert(); + + $bob = UpsertTestRecord::finder()->find('username = ?', 'bob'); + $this->assertSame(20, (int) $bob->score, 'bob must be unaffected'); + } + + // ----------------------------------------------------------------------- + // OnInsert event + // ----------------------------------------------------------------------- + + public function test_upsert_fires_oninsert_event_on_insert(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $eventFired = false; + $record->OnInsert[] = function ($sender, $param) use (&$eventFired): void { + $this->assertInstanceOf(TActiveRecordChangeEventParameter::class, $param); + $eventFired = true; + }; + + $record->upsert(); + + $this->assertTrue($eventFired, 'OnInsert event was not fired on insert path'); + } + + public function test_upsert_fires_oninsert_event_on_conflict_update(): void + { + static::$conn->createCommand( + "INSERT INTO `upsert_test` (`username`, `score`) VALUES ('alice', 10)" + )->execute(); + + $update = new UpsertTestRecord(); + $update->username = 'alice'; + $update->score = 99; + + $eventFired = false; + $update->OnInsert[] = function ($sender, $param) use (&$eventFired): void { + $eventFired = true; + }; + + $update->upsert(); + + $this->assertTrue($eventFired, 'OnInsert event must fire on the update (conflict) path too'); + } + + public function test_upsert_oninsert_can_veto_the_operation(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $result = $record->upsert(); + + $this->assertFalse($result); + } + + public function test_upsert_veto_leaves_state_new(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $record->upsert(); + + $this->assertSame(TActiveRecord::STATE_NEW, $record->getRecordState()); + } + + public function test_upsert_veto_writes_nothing_to_db(): void + { + $record = new UpsertTestRecord(); + $record->username = 'alice'; + $record->score = 10; + + $record->OnInsert[] = function ($sender, $param): void { + $param->setIsValid(false); + }; + + $record->upsert(); + + $count = (int) static::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertSame(0, $count); + } + + // ----------------------------------------------------------------------- + // String (non-auto-increment) PK — uses the existing `Users` table + // ----------------------------------------------------------------------- + + public function test_upsert_string_pk_new_record_returns_truthy(): void + { + $user = new UserRecord(); + $user->username = 'upsertTestUser'; + $user->password = md5('pass'); + $user->email = 'upsert@example.com'; + + $result = $user->upsert(); + + $this->assertNotFalse($result); + + // cleanup + UserRecord::finder()->findByPk('upsertTestUser')?->delete(); + } + + public function test_upsert_string_pk_conflict_updates_row(): void + { + // Upsert over the seeded 'admin' row and verify the email is updated. + $adminOriginal = UserRecord::finder()->findByPk('admin'); + $this->assertNotNull($adminOriginal); + $originalEmail = $adminOriginal->email; + + $user = new UserRecord(); + $user->username = 'admin'; + $user->password = $adminOriginal->password; + $user->email = 'updated_by_upsert@example.com'; + $user->first_name = $adminOriginal->first_name; + $user->last_name = $adminOriginal->last_name; + $user->active = $adminOriginal->active; + $user->department_id = $adminOriginal->department_id; + + $result = $user->upsert(); + + $this->assertNotFalse($result); + + $found = UserRecord::finder()->findByPk('admin'); + $this->assertSame('updated_by_upsert@example.com', $found->email); + + // restore original email + $found->email = $originalEmail; + $found->save(); + } +} diff --git a/tests/unit/Data/ActiveRecord/TActiveRecordSleepTest.php b/tests/unit/Data/ActiveRecord/TActiveRecordSleepTest.php new file mode 100644 index 000000000..52c5a95fd --- /dev/null +++ b/tests/unit/Data/ActiveRecord/TActiveRecordSleepTest.php @@ -0,0 +1,98 @@ +__sleep(); + // Protected property mangled name for _connection + $this->assertNotContains("\0*\0_connection", $props); + } + + public function testConnectionExcludedEvenWhenSet(): void + { + $record = new SleepTestRecord(); + // Set a connection on the record (inactive — no live DB needed) + $conn = new TDbConnection('sqlite::memory:'); + $ref = new \ReflectionProperty(TActiveRecord::class, '_connection'); + $ref->setAccessible(true); + $ref->setValue($record, $conn); + + $props = $record->__sleep(); + $this->assertNotContains("\0*\0_connection", $props); + } + + // ----------------------------------------------------------------------- + // Public fields and non-excluded props survive the round trip + // ----------------------------------------------------------------------- + + public function testPublicFieldsPreservedAfterRoundTrip(): void + { + $record = new SleepTestRecord(); + $record->id = 7; + $record->name = 'Alice'; + + $restored = unserialize(serialize($record)); + + $this->assertSame(7, $restored->id); + $this->assertSame('Alice', $restored->name); + } + + public function testConnectionNullAfterRoundTrip(): void + { + $record = new SleepTestRecord(); + // Set a live-ish connection; it must be gone after unserialize + $conn = new TDbConnection('sqlite::memory:'); + $ref = new \ReflectionProperty(TActiveRecord::class, '_connection'); + $ref->setAccessible(true); + $ref->setValue($record, $conn); + + $restored = unserialize(serialize($record)); + + $resRef = new \ReflectionProperty(TActiveRecord::class, '_connection'); + $resRef->setAccessible(true); + $this->assertNull($resRef->getValue($restored)); + } + + // ----------------------------------------------------------------------- + // __wakeup restores column mapping and relations + // ----------------------------------------------------------------------- + + public function testWakeupDoesNotThrow(): void + { + $record = new SleepTestRecord(); + $record->id = 1; + // __wakeup calls setupColumnMapping() and setupRelations() — must not throw + $restored = unserialize(serialize($record)); + $this->assertInstanceOf(SleepTestRecord::class, $restored); + } +} diff --git a/tests/unit/Data/ActiveRecord/records/UpsertTestRecord.php b/tests/unit/Data/ActiveRecord/records/UpsertTestRecord.php new file mode 100644 index 000000000..5e4a9b539 --- /dev/null +++ b/tests/unit/Data/ActiveRecord/records/UpsertTestRecord.php @@ -0,0 +1,36 @@ +_recordState; + } + + public static function finder($className = __CLASS__) + { + return parent::finder($className); + } +} diff --git a/tests/unit/Data/DbCommon/TDbCommandBuilderTest.php b/tests/unit/Data/DbCommon/TDbCommandBuilderTest.php index b90322ba7..9b41981a1 100644 --- a/tests/unit/Data/DbCommon/TDbCommandBuilderTest.php +++ b/tests/unit/Data/DbCommon/TDbCommandBuilderTest.php @@ -368,6 +368,36 @@ public function test_create_count_command_uses_count_star() $this->assertStringContainsString('COUNT(*)', $cmd->Text); } + // ----------------------------------------------------------------------- + // assertActiveTransaction + // ----------------------------------------------------------------------- + + public function test_assertActiveTransaction_passes_when_transaction_is_active(): void + { + $tx = self::$conn->beginTransaction(); + try { + $method = new \ReflectionMethod(TDbCommandBuilder::class, 'assertActiveTransaction'); + $method->setAccessible(true); + $method->invoke($this->builder()); // must not throw + $this->assertTrue(true); + } finally { + $tx->rollback(); + } + } + + public function test_assertActiveTransaction_throws_without_active_transaction(): void + { + // Ensure no transaction is active (rollback any stray one). + if (self::$conn->getCurrentTransaction() !== null) { + self::$conn->getCurrentTransaction()->rollback(); + } + $method = new \ReflectionMethod(TDbCommandBuilder::class, 'assertActiveTransaction'); + $method->setAccessible(true); + + $this->expectException(\Prado\Exceptions\TDbException::class); + $method->invoke($this->builder()); + } + // ----------------------------------------------------------------------- // applyCriterias // ----------------------------------------------------------------------- diff --git a/tests/unit/Data/DbCommon/TDbMetaDataTest.php b/tests/unit/Data/DbCommon/TDbMetaDataTest.php index 51207464c..54ef8e65f 100644 --- a/tests/unit/Data/DbCommon/TDbMetaDataTest.php +++ b/tests/unit/Data/DbCommon/TDbMetaDataTest.php @@ -7,7 +7,7 @@ /** * Unit tests for TDbMetaData. * - * Tests the getInstance factory method and fxDataGetMetaDataInstance event. + * Tests the getInstance factory method and fxDataGetMetaDataClass event. * Does not require a database connection; uses mocked connections. */ class TDbMetaDataTest extends PHPUnit\Framework\TestCase @@ -62,13 +62,39 @@ public function test_getInstance_throws_for_unknown_driver_with_no_event_handler TDbMetaData::getInstance($conn); } -public function test_getInstance_raises_fxDataGetMetaDataInstance_for_unknown_driver() + public function test_getInstance_throws_when_event_returns_instance_instead_of_class_name() { + // Event handlers must return a class name string, not an object instance. + // TDbDriverCapabilities::getMetaDataClass raises TDbException when an + // IDataMetaData object is returned instead. $conn = $this->createMockConnection('custom_driver'); + $badReturn = $this->createMock(\Prado\Data\Common\IDataMetaData::class); + $conn->method('raiseEvent')->willReturn([$badReturn]); + + $this->expectException(TDbException::class); + TDbMetaData::getInstance($conn); + } + + public function test_getInstance_throws_when_event_class_does_not_implement_IDataMetaData() + { + // If the class name returned by the event does not implement IDataMetaData, + // TDbDriverCapabilities::getMetaDataClass must throw TDbException before + // getInstance attempts instantiation. + $conn = $this->createMockConnection('custom_driver'); + $conn->method('raiseEvent')->willReturn([\stdClass::class]); + + $this->expectException(TDbException::class); + TDbMetaData::getInstance($conn); + } + + public function test_getInstance_raises_fxDataGetMetaDataClass_for_unknown_driver() + { + $driver = 'custom_driver'; + $conn = $this->createMockConnection($driver); $conn->expects($this->once()) ->method('raiseEvent') - ->with('fxDataGetMetaDataInstance', $this->anything(), $conn) + ->with('fxDataGetMetaDataClass', $conn, $driver) ->willReturn([]); $this->expectException(TDbException::class); @@ -92,15 +118,6 @@ public function test_getInstance_valid_pgsql_driver() $this->assertInstanceOf(\Prado\Data\Common\Pgsql\TPgsqlMetaData::class, $result); } - public function test_getInstance_valid_mysql_driver() - { - $conn = $this->createMockConnection('mysqli'); - $conn->expects($this->never())->method('raiseEvent'); - - $result = TDbMetaData::getInstance($conn); - $this->assertInstanceOf(\Prado\Data\Common\Mysql\TMysqlMetaData::class, $result); - } - public function test_getInstance_valid_mysql_old_driver() { $conn = $this->createMockConnection('mysql'); @@ -128,22 +145,13 @@ public function test_getInstance_valid_sqlite2_driver() $this->assertInstanceOf(\Prado\Data\Common\Sqlite\TSqliteMetaData::class, $result); } - public function test_getInstance_valid_mssql_driver() - { - $conn = $this->createMockConnection('mssql'); - $conn->expects($this->never())->method('raiseEvent'); - - $result = TDbMetaData::getInstance($conn); - $this->assertInstanceOf(\Prado\Data\Common\Mssql\TMssqlMetaData::class, $result); - } - public function test_getInstance_valid_sqlsrv_driver() { $conn = $this->createMockConnection('sqlsrv'); $conn->expects($this->never())->method('raiseEvent'); $result = TDbMetaData::getInstance($conn); - $this->assertInstanceOf(\Prado\Data\Common\Mssql\TMssqlMetaData::class, $result); + $this->assertInstanceOf(\Prado\Data\Common\SqlSrv\TSqlSrvMetaData::class, $result); } public function test_getInstance_valid_dblib_driver() @@ -152,7 +160,7 @@ public function test_getInstance_valid_dblib_driver() $conn->expects($this->never())->method('raiseEvent'); $result = TDbMetaData::getInstance($conn); - $this->assertInstanceOf(\Prado\Data\Common\Mssql\TMssqlMetaData::class, $result); + $this->assertInstanceOf(\Prado\Data\Common\SqlSrv\TSqlSrvMetaData::class, $result); } public function test_getInstance_valid_oracle_driver() @@ -182,15 +190,6 @@ public function test_getInstance_valid_firebird_driver() $this->assertInstanceOf(\Prado\Data\Common\Firebird\TFirebirdMetaData::class, $result); } - public function test_getInstance_valid_interbase_driver() - { - $conn = $this->createMockConnection('interbase'); - $conn->expects($this->never())->method('raiseEvent'); - - $result = TDbMetaData::getInstance($conn); - $this->assertInstanceOf(\Prado\Data\Common\Firebird\TFirebirdMetaData::class, $result); - } - public function test_getInstance_driver_name_is_case_insensitive() { $conn = $this->createMockConnection('PGSQL'); diff --git a/tests/unit/Data/DbCommon/TDbTableInfoTest.php b/tests/unit/Data/DbCommon/TDbTableInfoTest.php index 6ecbf1fe9..16c6d798d 100644 --- a/tests/unit/Data/DbCommon/TDbTableInfoTest.php +++ b/tests/unit/Data/DbCommon/TDbTableInfoTest.php @@ -1,6 +1,6 @@ 'public']); $this->assertNull($info->getSchemaName()); @@ -60,14 +60,14 @@ public function test_get_schema_name_returns_null_for_base_class() public function test_get_schema_name_returns_value_when_interface_implemented() { - // An anonymous subclass that declares IDbHasSchema should return the value. - $info = new class(['SchemaName' => 'myschema']) extends TDbTableInfo implements IDbHasSchema {}; + // An anonymous subclass that declares IDataHasSchema should return the value. + $info = new class(['SchemaName' => 'myschema']) extends TDbTableInfo implements IDataHasSchema {}; $this->assertEquals('myschema', $info->getSchemaName()); } public function test_get_schema_name_returns_null_when_interface_implemented_but_not_set() { - $info = new class([]) extends TDbTableInfo implements IDbHasSchema {}; + $info = new class([]) extends TDbTableInfo implements IDataHasSchema {}; $this->assertNull($info->getSchemaName()); } diff --git a/tests/unit/Data/DbCommon/TScaffoldInputBaseTest.php b/tests/unit/Data/DbCommon/TScaffoldInputBaseTest.php index 02f34c313..106edcbe5 100644 --- a/tests/unit/Data/DbCommon/TScaffoldInputBaseTest.php +++ b/tests/unit/Data/DbCommon/TScaffoldInputBaseTest.php @@ -7,7 +7,10 @@ /** * Unit tests for TScaffoldInputBase. * - * Tests the createInputBuilder factory method and fxActiveRecordCreateScaffoldInput event. + * Tests the createInputBuilder factory method. The fxActiveRecordScaffoldInputClass + * global event is managed by TDbDriverCapabilities::createScaffoldInput; these tests + * verify that the event is raised on the connection for unknown drivers (the connection + * mock intercepts the call regardless of which class triggers it). */ class TScaffoldInputBaseTest extends PHPUnit\Framework\TestCase { @@ -23,6 +26,8 @@ private function createMockRecord(string $driver): TActiveRecord public function test_createInputBuilder_throws_for_unknown_driver_with_no_event_handlers() { + // TDbDriverCapabilities::createScaffoldInput raises fxActiveRecordScaffoldInputClass + // on the connection; when handlers return nothing, TConfigurationException is thrown. $record = $this->createMockRecord('unknown_driver'); $conn = $record->getDbConnection(); $conn->expects($this->once()) @@ -33,37 +38,56 @@ public function test_createInputBuilder_throws_for_unknown_driver_with_no_event_ TScaffoldInputBase::createInputBuilder($record); } - public function test_createInputBuilder_raises_fxActiveRecordCreateScaffoldInput_for_unknown_driver() + public function test_createInputBuilder_fxEvent_raised_with_correct_parameters() { + // The fxActiveRecordScaffoldInputClass event must be raised on the connection + // with the connection as sender and the driver name as the parameter. $record = $this->createMockRecord('custom_driver'); $conn = $record->getDbConnection(); $conn->expects($this->once()) ->method('raiseEvent') - ->with('fxActiveRecordCreateScaffoldInput', $this->anything(), $conn) + ->with('fxActiveRecordScaffoldInputClass', $conn, 'custom_driver') ->willReturn([]); $this->expectException(TConfigurationException::class); TScaffoldInputBase::createInputBuilder($record); } - public function test_createInputBuilder_calls_setActive_on_connection() + public function test_createInputBuilder_throws_when_event_returns_instance_instead_of_class_name() { - $record = $this->createMockRecord('sqlite'); + // Event handlers must return a class name string implementing IScaffoldInput. + // If a handler returns an IScaffoldInput instance instead, TDbDriverCapabilities + // throws TConfigurationException before instantiation. + $record = $this->createMockRecord('custom_driver'); $conn = $record->getDbConnection(); - $conn->expects($this->once())->method('setActive')->with(true); + $badReturn = $this->createMock(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\IScaffoldInput::class); + $conn->method('raiseEvent')->willReturn([$badReturn]); + $this->expectException(TConfigurationException::class); TScaffoldInputBase::createInputBuilder($record); } - public function test_createInputBuilder_valid_mysql_driver() + public function test_createInputBuilder_throws_when_event_class_does_not_implement_IScaffoldInput() { - $record = $this->createMockRecord('mysqli'); + // If the class name returned by the event does not implement IScaffoldInput, + // TDbDriverCapabilities::createScaffoldInput must throw TConfigurationException + // before attempting instantiation. + $record = $this->createMockRecord('custom_driver'); $conn = $record->getDbConnection(); - $conn->expects($this->never())->method('raiseEvent'); + $conn->method('raiseEvent')->willReturn([\stdClass::class]); - $result = TScaffoldInputBase::createInputBuilder($record); - $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TMysqlScaffoldInput::class, $result); + $this->expectException(TConfigurationException::class); + TScaffoldInputBase::createInputBuilder($record); + } + + public function test_createInputBuilder_calls_setActive_on_connection() + { + $record = $this->createMockRecord('sqlite'); + $conn = $record->getDbConnection(); + $conn->expects($this->once())->method('setActive')->with(true); + + TScaffoldInputBase::createInputBuilder($record); } public function test_createInputBuilder_valid_mysql_old_driver() @@ -106,16 +130,6 @@ public function test_createInputBuilder_valid_pgsql_driver() $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TPgsqlScaffoldInput::class, $result); } - public function test_createInputBuilder_valid_mssql_driver() - { - $record = $this->createMockRecord('mssql'); - $conn = $record->getDbConnection(); - $conn->expects($this->never())->method('raiseEvent'); - - $result = TScaffoldInputBase::createInputBuilder($record); - $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TMssqlScaffoldInput::class, $result); - } - public function test_createInputBuilder_valid_ibm_driver() { $record = $this->createMockRecord('ibm'); @@ -136,16 +150,6 @@ public function test_createInputBuilder_valid_firebird_driver() $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TFirebirdScaffoldInput::class, $result); } - public function test_createInputBuilder_valid_interbase_driver() - { - $record = $this->createMockRecord('interbase'); - $conn = $record->getDbConnection(); - $conn->expects($this->never())->method('raiseEvent'); - - $result = TScaffoldInputBase::createInputBuilder($record); - $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TFirebirdScaffoldInput::class, $result); - } - public function test_createInputBuilder_driver_name_is_case_insensitive() { $record = $this->createMockRecord('PGSQL'); diff --git a/tests/unit/Data/DbSpecific/Firebird/FirebirdInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Firebird/FirebirdInsertOrIgnoreTest.php new file mode 100644 index 000000000..d93998a1f --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/FirebirdInsertOrIgnoreTest.php @@ -0,0 +1,334 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + // pdo_firebird in autocommit mode always keeps an implicit transaction alive: + // after each statement it auto-commits and immediately starts the next one. + // Calling PDO::beginTransaction() while that implicit transaction is active + // raises "There is already an active transaction". Explicitly committing the + // empty post-DELETE transaction resets the internal handle to NULL so that + // explicit beginTransaction() calls in the test methods succeed. + try { + static::$conn->getPdoInstance()->commit(); + } catch (\Exception $e) { + // No implicit transaction was active — safe to ignore. + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation (build command inside a transaction, then roll back) + // ----------------------------------------------------------------------- + + public function test_sql_uses_merge_when_not_matched_then_insert(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + } + + public function test_sql_using_select_contains_from_rdb_database(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringContainsString('FROM RDB$DATABASE', $capturedSql); + } + + public function test_sql_uses_bare_aliases_without_as_keyword(): void + { + // Firebird MERGE uses bare t / s aliases (useAsAlias=false) + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + // Must contain bare alias references (e.g. ") s ON") + $this->assertMatchesRegularExpression('/USING\s*\(.*\)\s+s\s+ON/si', $capturedSql); + // Must NOT contain AS-keyword aliases for t or s — use word-boundary regex + // to avoid false-positives on column aliases like "CAST(... AS score)". + $this->assertDoesNotMatchRegularExpression('/\bAS\s+t\b/i', $capturedSql); + $this->assertDoesNotMatchRegularExpression('/\)\s+AS\s+s\b/i', $capturedSql); + } + + public function test_sql_has_no_dual_or_sysdummy_source(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringNotContainsString('DUAL', $capturedSql); + $this->assertStringNotContainsString('SYSIBM', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert within transaction + // ----------------------------------------------------------------------- + + public function test_new_row_inserted_within_transaction(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_new_row_returns_true_for_natural_key_table(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + // Natural key table (no identity/sequence) → getLastInsertID()=null → returns true + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: duplicate ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_pk_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertFalse($result); + } + + public function test_duplicate_does_not_increase_row_count(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Mixed inserts + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored_others_inserted(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); + $txn->commit(); + + $this->assertFalse($res2); + $this->assertTrue($res3); + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(2, $count); + } + + public function test_transaction_rollback_undoes_insert(): void + { + $this->skipIfRollbackUnreliable(); + + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + /** + * Probes whether pdo_firebird reliably rolls back DML on this server. + * Some PHP/Firebird combinations have known rollback bugs; skip rather than + * fail when the environment does not support it. + */ + private function skipIfRollbackUnreliable(): void + { + $pdo = self::$conn->getPdoInstance(); + $probe = '__rb_probe_' . getmypid() . '__'; + try { + try { $pdo->commit(); } catch (\Throwable $e) {} + $pdo->beginTransaction(); + self::$conn->createCommand( + "INSERT INTO upsert_test (username, score) VALUES ('$probe', 0)" + )->execute(); + $pdo->rollBack(); + try { $pdo->commit(); } catch (\Throwable $e) {} + $count = (int) self::$conn->createCommand( + "SELECT COUNT(*) FROM upsert_test WHERE username = '$probe'" + )->queryScalar(); + try { $pdo->commit(); } catch (\Throwable $e) {} + if ($count !== 0) { + // Clean up the accidentally-committed probe row. + try { + self::$conn->createCommand( + "DELETE FROM upsert_test WHERE username = '$probe'" + )->execute(); + try { $pdo->commit(); } catch (\Throwable $e) {} + } catch (\Throwable $e) {} + $this->markTestSkipped( + 'pdo_firebird rollback is unreliable in this environment; skipping.' + ); + } + } finally { + // Restore clean state for the actual test. + try { + self::$conn->createCommand('DELETE FROM upsert_test')->execute(); + try { $pdo->commit(); } catch (\Throwable $e) {} + } catch (\Throwable $e) {} + } + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Firebird/FirebirdTableExistsTest.php b/tests/unit/Data/DbSpecific/Firebird/FirebirdTableExistsTest.php new file mode 100644 index 000000000..8d6ff30c2 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/FirebirdTableExistsTest.php @@ -0,0 +1,119 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + $this->dropTempTableIfExists(); + } + + protected function tearDown(): void + { + $this->dropTempTableIfExists(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + private function dropTempTableIfExists(): void + { + if (static::$conn === null) { + return; + } + try { + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + } catch (\Exception $e) { + // Table did not exist — ignore. + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + // Firebird: DEFAULT must precede NOT NULL. + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER DEFAULT 0 NOT NULL PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER DEFAULT 0 NOT NULL PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Firebird/FirebirdUpsertTest.php b/tests/unit/Data/DbSpecific/Firebird/FirebirdUpsertTest.php new file mode 100644 index 000000000..a61bb7f45 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/FirebirdUpsertTest.php @@ -0,0 +1,387 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + // pdo_firebird in autocommit mode always keeps an implicit transaction alive: + // after each statement it auto-commits and immediately starts the next one. + // Calling PDO::beginTransaction() while that implicit transaction is active + // raises "There is already an active transaction". Explicitly committing the + // empty post-DELETE transaction resets the internal handle to NULL so that + // explicit beginTransaction() calls in the test methods succeed. + try { + static::$conn->getPdoInstance()->commit(); + } catch (\Exception $e) { + // No implicit transaction was active — safe to ignore. + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_contains_merge_when_matched_and_when_not_matched(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN MATCHED THEN UPDATE SET', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + public function test_sql_using_contains_from_rdb_database(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + $this->assertStringContainsString('FROM RDB$DATABASE', $capturedSql); + } + + public function test_sql_uses_bare_aliases_without_as_keyword(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + // Must contain bare alias references (e.g. ") s ON") + $this->assertMatchesRegularExpression('/USING\s*\(.*\)\s+s\s+ON/si', $capturedSql); + // Must NOT contain AS-keyword aliases for t or s — use word-boundary regex + // to avoid false-positives on column aliases like "CAST(... AS score)". + $this->assertDoesNotMatchRegularExpression('/\bAS\s+t\b/i', $capturedSql); + $this->assertDoesNotMatchRegularExpression('/\)\s+AS\s+s\b/i', $capturedSql); + } + + public function test_sql_update_set_contains_non_pk_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + // PK = username → updateData = {score}; SCORE appears in WHEN MATCHED branch + $matchedPos = stripos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + // Firebird column name "SCORE" appears in UPDATE SET + $this->assertMatchesRegularExpression('/"?SCORE"?/i', $updatePart); + } + + public function test_sql_empty_updateData_omits_when_matched_branch(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], [], ['username']); + $txn->rollback(); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_upsert_new_row_returns_true(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict → update + // ----------------------------------------------------------------------- + + public function test_conflict_on_pk_updates_non_pk_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(99, (int) $lc['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(55, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → insert-or-ignore behaviour + // ----------------------------------------------------------------------- + + public function test_empty_updateData_does_not_update_on_conflict(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'bob', 'score' => 20]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $bob = self::$gateway->find('username = ?', 'bob'); + $lc = array_change_key_case($bob, CASE_LOWER); + $this->assertEquals(20, (int) $lc['score']); + } + + public function test_transaction_rollback_undoes_upsert(): void + { + $this->skipIfRollbackUnreliable(); + + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + /** + * Probes whether pdo_firebird reliably rolls back DML on this server. + * Some PHP/Firebird combinations have known rollback bugs; skip rather than + * fail when the environment does not support it. + */ + private function skipIfRollbackUnreliable(): void + { + $pdo = self::$conn->getPdoInstance(); + $probe = '__rb_probe_' . getmypid() . '__'; + try { + try { $pdo->commit(); } catch (\Throwable $e) {} + $pdo->beginTransaction(); + self::$conn->createCommand( + "INSERT INTO upsert_test (username, score) VALUES ('$probe', 0)" + )->execute(); + $pdo->rollBack(); + try { $pdo->commit(); } catch (\Throwable $e) {} + $count = (int) self::$conn->createCommand( + "SELECT COUNT(*) FROM upsert_test WHERE username = '$probe'" + )->queryScalar(); + try { $pdo->commit(); } catch (\Throwable $e) {} + if ($count !== 0) { + try { + self::$conn->createCommand( + "DELETE FROM upsert_test WHERE username = '$probe'" + )->execute(); + try { $pdo->commit(); } catch (\Throwable $e) {} + } catch (\Throwable $e) {} + $this->markTestSkipped( + 'pdo_firebird rollback is unreliable in this environment; skipping.' + ); + } + } finally { + try { + self::$conn->createCommand('DELETE FROM upsert_test')->execute(); + try { $pdo->commit(); } catch (\Throwable $e) {} + } catch (\Throwable $e) {} + } + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Firebird/TDbCommandFirebirdIntegrationTest.php b/tests/unit/Data/DbSpecific/Firebird/TDbCommandFirebirdIntegrationTest.php new file mode 100644 index 000000000..c157ecfe9 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/TDbCommandFirebirdIntegrationTest.php @@ -0,0 +1,367 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openFirebird(); + + // Firebird DDL auto-commits; drop any leftover table before creating. + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + // Table may not exist yet — that's fine. + } + $this->_conn->createCommand( + 'CREATE TABLE CMD_TEST (ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(100), SCORE DOUBLE PRECISION, ACTIVE SMALLINT, NOTE VARCHAR(100))' + )->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + try { + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand('CREATE TABLE EXEC_DDL_TEST (X INTEGER)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM EXEC_DDL_TEST')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO CMD_TEST VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', trim($rows[0]['NAME'])); + $this->assertSame('Bob', trim($rows[1]['NAME'])); + $this->assertSame('Carol', trim($rows[2]['NAME'])); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertArrayHasKey('ID', $rows[0]); + $this->assertArrayHasKey('NAME', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', trim($row['NAME'])); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('NAME', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryScalar(); + $this->assertSame('Alice', trim($scalar)); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM CMD_TEST')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertCount(3, $names); + $this->assertSame('Alice', trim($names[0])); + $this->assertSame('Bob', trim($names[1])); + $this->assertSame('Carol', trim($names[2])); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', trim($cmd->queryScalar())); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', trim($cmd->queryScalar())); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', trim($cmd->queryScalar())); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT ID FROM CMD_TEST WHERE NAME = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', trim($cmd->queryScalar())); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', trim($cmd->queryScalar())); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', trim($cmd->queryScalar())); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryRow(); + $this->assertNull($row['NOTE']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $name = $reader->readColumn(1); // second column = NAME + $this->assertSame('Alice', trim($name)); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = trim($row['NAME']); + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME, SCORE FROM CMD_TEST')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['NOTE']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = ID, 1 = NAME. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('ID', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Firebird/TDbConnectionCharsetFirebirdIntegrationTest.php b/tests/unit/Data/DbSpecific/Firebird/TDbConnectionCharsetFirebirdIntegrationTest.php index 06223a2fa..5126a26b7 100644 --- a/tests/unit/Data/DbSpecific/Firebird/TDbConnectionCharsetFirebirdIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Firebird/TDbConnectionCharsetFirebirdIntegrationTest.php @@ -217,4 +217,104 @@ public function testFirebirdGetDatabaseCharsetReturnsDsnCharset(): void $this->assertSame('UTF8', $conn->DatabaseCharset); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // Live connection — requiresPreBeginTransactionFlush behavioral verification + // + // pdo_firebird starts an implicit transaction at connect time and after every + // commit/rollback. PDO::beginTransaction() fails with "There is already an + // active transaction" if that implicit transaction has not been terminated. + // TDbConnection::beginTransaction() calls PDO::commit() first (the "pre-begin + // flush") so that PDO::beginTransaction() always succeeds cleanly. + // ----------------------------------------------------------------------- + + public function testFirebirdBeginTransactionReturnsActiveTransaction(): void + { + // beginTransaction() on a Firebird connection must succeed without throwing + // and return an active TDbTransaction. The pre-begin flush commits the + // always-running implicit transaction before PDO::beginTransaction() is called. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $this->assertTrue( + $tx->getActive(), + 'beginTransaction must return an active TDbTransaction for Firebird.' + ); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + $conn->Active = false; + } + + public function testFirebirdBeginTransactionSucceedsOnFreshConnection(): void + { + // A fresh pdo_firebird connection has an implicit transaction running. + // Without requiresPreBeginTransactionFlush, calling PDO::beginTransaction() + // immediately would throw "There is already an active transaction". + // The pre-begin flush commits the implicit tx first; this must not throw. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $conn->Active = false; + } + + public function testFirebirdPreBeginFlushEnablesRepeatedBeginTransactions(): void + { + // TDbConnection performs a pre-begin flush (PDO::commit()) before each + // PDO::beginTransaction() call to clear Firebird's always-running implicit + // transaction. Multiple beginTransaction/commit cycles on the same connection + // must all succeed without throwing. + $conn = $this->openFirebird('UTF-8'); + for ($i = 0; $i < 4; $i++) { + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), "Cycle $i: transaction must be active."); + if ($i % 2 === 0) { + $tx->commit(); + } else { + $tx->rollBack(); + } + $this->assertFalse($tx->getActive(), "Cycle $i: transaction must be inactive after operation."); + } + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute behavioral verification + // + // Firebird has hasAutoCommitAttribute = true. However, pdo_firebird's + // PDO::ATTR_AUTOCOMMIT always returns 1 (true) — even inside an explicit + // PDO::beginTransaction() transaction. This is a pdo_firebird driver + // quirk: the attribute reflects the session configuration, not whether a + // PDO-managed transaction is currently active. TDbConnection reads the + // attribute via HasAutoCommit/AutoCommit properties. + // ----------------------------------------------------------------------- + + public function testFirebirdHasAutoCommitAttribute(): void + { + $conn = $this->openFirebird('UTF-8'); + $this->assertTrue($conn->HasAutoCommit, 'Firebird must report hasAutoCommitAttribute = true.'); + $conn->Active = false; + } + + public function testFirebirdAutoCommitIsTrueByDefault(): void + { + // pdo_firebird reports ATTR_AUTOCOMMIT = 1 always (even inside a + // PDO-managed explicit transaction). AutoCommit must therefore be true. + $conn = $this->openFirebird('UTF-8'); + $this->assertTrue( + $conn->AutoCommit, + 'Firebird AutoCommit must be true (pdo_firebird ATTR_AUTOCOMMIT is always 1).' + ); + $conn->Active = false; + } + + public function testFirebirdBeginTransactionSucceedsAndRollbackWorks(): void + { + // beginTransaction() and rollback() must both work without throwing. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'Firebird beginTransaction must return an active transaction.'); + $conn->rollback(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + $conn->Active = false; + } } diff --git a/tests/unit/Data/DbSpecific/Firebird/TDbDriverCapabilitiesFirebirdIntegrationTest.php b/tests/unit/Data/DbSpecific/Firebird/TDbDriverCapabilitiesFirebirdIntegrationTest.php new file mode 100644 index 000000000..1af7be691 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/TDbDriverCapabilitiesFirebirdIntegrationTest.php @@ -0,0 +1,813 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openFirebird(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_firebird')) { + $this->markTestSkipped('pdo_firebird extension not available.'); + } + $dbPath = getenv('FIREBIRD_DB_PATH') ?: '/var/lib/firebird/data/prado_unitest.fdb'; + try { + $conn = new TDbConnection( + 'firebird:dbname=localhost:' . $dbPath, + 'SYSDBA', + 'masterkey', + $charset + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to Firebird: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags — firebird + // ----------------------------------------------------------------------- + + public function testFirebirdSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('firebird')); + } + + public function testFirebirdHasAutoCommitAttribute(): void + { + $this->assertTrue(TDbDriverCapabilities::hasAutoCommitAttribute('firebird')); + } + + public function testFirebirdRequiresPreBeginTransactionFlush(): void + { + // Before beginTransaction(), the implicit transaction must be flushed. + $this->assertTrue(TDbDriverCapabilities::requiresPreBeginTransactionFlush('firebird')); + } + + public function testFirebirdRequiresPostTransactionFlush(): void + { + // After commit() or rollBack(), the new implicit transaction must be flushed. + $this->assertTrue(TDbDriverCapabilities::requiresPostTransactionFlush('firebird')); + } + + public function testFirebirdDoesNotSupportRuntimeCharsetSet(): void + { + // Firebird charset is DSN-only; supportsRuntimeCharsetSet must be false. + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('firebird')); + } + + public function testFirebirdRequiresNoPostConnectCharset(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('firebird')); + } + + public function testFirebirdCharsetSetSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('firebird')); + } + + public function testFirebirdCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('firebird')); + } + + public function testFirebirdCharsetDsnParamIsCharset(): void + { + $this->assertSame('charset', TDbDriverCapabilities::getCharsetDsnParam('firebird')); + } + + public function testFirebirdCharsetDsnPatternMatchesCharsetParam(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern('firebird'); + $this->assertNotNull($pattern); + $this->assertSame(1, preg_match($pattern, ';charset=UTF8', $m)); + $this->assertSame('UTF8', $m[1]); + } + + public function testFirebirdCharsetQuerySqlContainsMonAttachments(): void + { + $sql = TDbDriverCapabilities::getCharsetQuerySql('firebird'); + $this->assertNotNull($sql); + $this->assertStringContainsString('MON$ATTACHMENTS', $sql); + $this->assertStringContainsString('RDB$CHARACTER_SETS', $sql); + } + + public function testFirebirdGetListTablesSqlContainsRdbRelations(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('firebird'); + $this->assertNotNull($sql); + $this->assertStringContainsString('RDB$RELATIONS', $sql); + } + + public function testFirebirdMetaDataClassName(): void + { + $this->assertSame(TFirebirdMetaData::class, TDbDriverCapabilities::getMetaDataClass('firebird')); + } + + // ----------------------------------------------------------------------- + // Static capability flags — interbase alias + // + // 'interbase' aliases firebird for charset resolution but is NOT aliased + // for the pre/post flush flags. + // ----------------------------------------------------------------------- + + public function testInterbaseDoesNotRequirePreBeginTransactionFlush(): void + { + // The flush flag is not aliased; only 'firebird' requires the pre-begin flush. + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('interbase')); + } + + public function testInterbaseDoesNotRequirePostTransactionFlush(): void + { + // The flush flag is not aliased; only 'firebird' requires the post-transaction flush. + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('interbase')); + } + + public function testInterbaseCharsetDsnParamIsCharset(): void + { + $this->assertSame('charset', TDbDriverCapabilities::getCharsetDsnParam('interbase')); + } + + public function testInterbaseGetListTablesSqlMatchesFirebird(): void + { + $this->assertSame( + TDbDriverCapabilities::getListTablesSql('firebird'), + TDbDriverCapabilities::getListTablesSql('interbase') + ); + } + + public function testInterbaseMetaDataClassNameMatchesFirebird(): void + { + $this->assertSame(TFirebirdMetaData::class, TDbDriverCapabilities::getMetaDataClass('interbase')); + } + + // ----------------------------------------------------------------------- + // Charset resolution + // ----------------------------------------------------------------------- + + public function testFirebirdResolveUtf8ReturnsUTF8(): void + { + $this->assertSame('UTF8', TDbDriverCapabilities::resolveCharset('UTF-8', 'firebird')); + } + + public function testFirebirdResolveInterbaseUtf8MatchesFirebirdViaAlias(): void + { + // 'interbase' is aliased to 'firebird' for charset resolution. + $this->assertSame('UTF8', TDbDriverCapabilities::resolveCharset('UTF-8', 'interbase')); + } + + public function testFirebirdResolveLatin1ReturnsISO8859_1(): void + { + $this->assertSame('ISO8859_1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'firebird')); + } + + public function testFirebirdResolveLatin2ReturnsISO8859_2(): void + { + $this->assertSame('ISO8859_2', TDbDriverCapabilities::resolveCharset('ISO-8859-2', 'firebird')); + } + + public function testFirebirdResolveAsciiReturnsASCII(): void + { + $this->assertSame('ASCII', TDbDriverCapabilities::resolveCharset('ASCII', 'firebird')); + } + + public function testFirebirdResolveWin1250ReturnsWIN1250(): void + { + $this->assertSame('WIN1250', TDbDriverCapabilities::resolveCharset('Windows-1250', 'firebird')); + } + + public function testFirebirdResolveKoi8rReturnsKOI8R(): void + { + $this->assertSame('KOI8R', TDbDriverCapabilities::resolveCharset('KOI8-R', 'firebird')); + } + + public function testFirebirdUnresolveUTF8ReturnsUtf8Standard(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::unresolveCharset('UTF8', 'firebird')); + } + + public function testFirebirdUnresolveISO8859_1ReturnsLatin1Standard(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::unresolveCharset('ISO8859_1', 'firebird')); + } + + public function testInterbaseUnresolveMatchesFirebird(): void + { + // Charset unresolution also uses the interbase→firebird alias. + $this->assertSame( + TDbDriverCapabilities::unresolveCharset('UTF8', 'firebird'), + TDbDriverCapabilities::unresolveCharset('UTF8', 'interbase') + ); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testFirebirdScaffoldInputClass(): void + { + $this->assertSame('TFirebirdScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('firebird')); + } + + public function testFirebirdScaffoldInputFile(): void + { + $this->assertSame('/TFirebirdScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('firebird')); + } + + public function testInterbaseScaffoldInputMatchesFirebird(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputClass('firebird'), + TDbDriverCapabilities::getScaffoldInputClass('interbase') + ); + } + + // ----------------------------------------------------------------------- + // Live connection — basic connectivity + // ----------------------------------------------------------------------- + + public function testFirebirdDriverNameIsFirebird(): void + { + $conn = $this->openFirebird('UTF-8'); + $this->assertSame('firebird', $conn->getDriverName()); + $conn->Active = false; + } + + public function testFirebirdMetaDataInstanceIsTFirebirdMetaData(): void + { + $conn = $this->openFirebird('UTF-8'); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TFirebirdMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testFirebirdListTablesQueryReturnsArray(): void + { + $conn = $this->openFirebird('UTF-8'); + $sql = TDbDriverCapabilities::getListTablesSql('firebird'); + $result = $conn->createCommand($sql)->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testFirebirdListTablesQueryReturnsCreatedTable(): void + { + // DDL in Firebird auto-commits the current implicit transaction. Create a + // table, query RDB$RELATIONS via getListTablesSql, verify the table name + // appears (Firebird stores identifiers as uppercase unless quoted), then drop it. + $conn = $this->openFirebird('UTF-8'); + + // Drop if exists from a previous run (Firebird has no DROP TABLE IF EXISTS + // before Firebird 5; use a try/catch guard instead). + try { + $conn->createCommand('DROP TABLE CAPS_FB_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand('CREATE TABLE CAPS_FB_LIST_TEST (ID INTEGER NOT NULL PRIMARY KEY)')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('firebird'); + $rows = $conn->createCommand($sql)->queryAll(); + + // The query returns TRIM(RDB$RELATION_NAME) AS tbl_name. + // pdo_firebird returns column aliases in uppercase ('TBL_NAME'), so + // normalise all row keys to lowercase before extracting the column. + // Firebird stores table names in uppercase by default. + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tbl_name'); + $this->assertContains('CAPS_FB_LIST_TEST', $names); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testFirebirdListTablesQueryExcludesSystemTables(): void + { + // The capability SQL filters RDB$SYSTEM_FLAG = 0; Firebird system tables + // (e.g. RDB$RELATIONS itself) must not appear in the result. + $conn = $this->openFirebird('UTF-8'); + $sql = TDbDriverCapabilities::getListTablesSql('firebird'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tbl_name'); + $this->assertNotContains('RDB$RELATIONS', $names); + $conn->Active = false; + } + + public function testFirebirdListTablesQueryExcludesViews(): void + { + // The capability SQL filters RDB$VIEW_BLR IS NULL; views must not appear. + $conn = $this->openFirebird('UTF-8'); + + try { + $conn->createCommand('DROP VIEW CAPS_FB_VIEW_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand('CREATE VIEW CAPS_FB_VIEW_TEST AS SELECT 1 AS N FROM RDB$DATABASE')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('firebird'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tbl_name'); + $this->assertNotContains('CAPS_FB_VIEW_TEST', $names); + + try { + $conn->createCommand('DROP VIEW CAPS_FB_VIEW_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — charset + // ----------------------------------------------------------------------- + + public function testFirebirdDatabaseCharsetReturnsUtf8WhenConfigured(): void + { + // DatabaseCharset queries MON$ATTACHMENTS or falls back to the resolved value. + $conn = $this->openFirebird('UTF-8'); + $charset = $conn->DatabaseCharset; + $this->assertSame('UTF8', $charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — charset query + // ----------------------------------------------------------------------- + + public function testFirebirdCharsetQuerySqlExecutesAndReturnsCharset(): void + { + // getCharsetQuerySql('firebird') returns a MON$ATTACHMENTS JOIN query. + // Execute it directly against a live UTF-8 connection and verify it + // returns the Firebird charset name ('UTF8'). + $conn = $this->openFirebird('UTF-8'); + $sql = TDbDriverCapabilities::getCharsetQuerySql('firebird'); + $this->assertNotNull($sql, 'getCharsetQuerySql must not return null for firebird.'); + $charset = $this->queryScalar($conn, $sql); + $this->assertSame('UTF8', $charset, + 'getCharsetQuerySql must return the charset name the server reports for the current attachment.'); + $conn->Active = false; + } + + public function testFirebirdDsnCharsetParamAppliedOnConnect(): void + { + // Connecting with 'ISO-8859-1' (which resolves to 'ISO8859_1') must be reflected + // in DatabaseCharset. + $conn = $this->openFirebird('ISO-8859-1'); + $this->assertSame('ISO8859_1', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testFirebirdSupportsCharsetFlagMatchesLiveDriver(): void + { + $conn = $this->openFirebird('UTF-8'); + $this->assertTrue(TDbDriverCapabilities::supportsCharset($conn->getDriverName())); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testFirebirdTransactionCommitSucceeds(): void + { + // commit() completes the explicit transaction and deactivates it. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse( + $tx->getActive(), + 'Firebird transaction must be inactive after commit.' + ); + $conn->Active = false; + } + + public function testFirebirdTransactionRollbackSucceeds(): void + { + // rollBack() aborts the explicit transaction and deactivates it. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse( + $tx->getActive(), + 'Firebird transaction must be inactive after rollback.' + ); + $conn->Active = false; + } + + public function testFirebirdMultipleSequentialTransactionsSucceed(): void + { + // Multiple beginTransaction/commit/rollback cycles on the same connection + // must all succeed. Each cycle requires a new beginTransaction() call. + $conn = $this->openFirebird('UTF-8'); + + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertNull($conn->getCurrentTransaction(), 'No active transaction after commit.'); + + $tx2 = $conn->beginTransaction(); + $tx2->rollBack(); + $this->assertNull($conn->getCurrentTransaction(), 'No active transaction after rollback.'); + + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — requiresPostTransactionFlush behavioral verification + // + // pdo_firebird opens a new implicit transaction inside isc_commit_transaction + // before the Transaction Inventory Page (TIP) is updated. That new implicit + // transaction's MVCC snapshot can therefore miss the just-committed data. + // TDbTransaction::commit() issues a second PDO::commit() (the "flush") to + // force pdo_firebird to open a fresh implicit transaction with an up-to-date + // TIP, making committed data immediately visible to subsequent reads on the + // same connection. + // ----------------------------------------------------------------------- + + public function testFirebirdPostTransactionFlushMakesCommittedDataImmediatelyVisible(): void + { + // This test verifies the observable effect of requiresPostTransactionFlush. + // After committing an INSERT, the row must be visible immediately on the + // same connection without re-opening it. + $conn = $this->openFirebird('UTF-8'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_FLUSH_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_FB_FLUSH_TEST (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_FLUSH_TEST VALUES (1)')->execute(); + $tx->commit(); + + // Without the post-transaction flush, the implicit Firebird transaction + // that pdo_firebird opens internally right after PDO::commit() may hold + // a stale MVCC snapshot and return 0 here. With the flush (a second + // PDO::commit()), a fresh implicit transaction with a current snapshot is + // used, so the count must be 1. + $count = (int) $conn->createCommand('SELECT COUNT(*) FROM CAPS_FB_FLUSH_TEST')->queryScalar(); + $this->assertSame( + 1, + $count, + 'requiresPostTransactionFlush must flush the implicit Firebird transaction ' . + 'so that committed data is immediately visible on the same connection.' + ); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_FLUSH_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testFirebirdRollbackDataIsNotVisibleAfterFlush(): void + { + // A rolled-back INSERT must not be visible even with the post-flush. + $conn = $this->openFirebird('UTF-8'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_ROLLBACK_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_FB_ROLLBACK_TEST (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + // Skip if pdo_firebird rollback is not reliable on this server build. + if (!$this->probeFirebirdRollback($conn, 'CAPS_FB_ROLLBACK_TEST')) { + try { $conn->createCommand('DROP TABLE CAPS_FB_ROLLBACK_TEST')->execute(); } catch (\Exception $e) {} + $conn->Active = false; + $this->markTestSkipped('pdo_firebird rollback is unreliable in this environment; skipping.'); + } + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_ROLLBACK_TEST VALUES (1)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand('SELECT COUNT(*) FROM CAPS_FB_ROLLBACK_TEST')->queryScalar(); + $this->assertSame(0, $count, 'Rolled-back data must not be visible after the post-flush.'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_ROLLBACK_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testFirebirdThreeSequentialTransactionsWithDataPersistCorrectly(): void + { + // Verify that the pre-begin flush (clearing the implicit Firebird transaction + // before beginTransaction()) works correctly across three commit/rollback cycles. + // Each cycle calls beginTransaction() afresh; the pre-begin flush clears the + // implicit transaction that pdo_firebird opens after every commit/rollback. + $conn = $this->openFirebird('UTF-8'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_MULTI_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_FB_MULTI_TEST (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + // Skip if pdo_firebird rollback is not reliable on this server build. + if (!$this->probeFirebirdRollback($conn, 'CAPS_FB_MULTI_TEST')) { + try { $conn->createCommand('DROP TABLE CAPS_FB_MULTI_TEST')->execute(); } catch (\Exception $e) {} + $conn->Active = false; + $this->markTestSkipped('pdo_firebird rollback is unreliable in this environment; skipping.'); + } + + // Cycle 1: commit id=1. + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_MULTI_TEST VALUES (1)')->execute(); + $tx->commit(); + $count = (int) $conn->createCommand('SELECT COUNT(*) FROM CAPS_FB_MULTI_TEST')->queryScalar(); + $this->assertSame(1, $count, 'After cycle 1 commit, 1 row expected.'); + + // Cycle 2: rollback (insert id=2, then discard). + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_MULTI_TEST VALUES (2)')->execute(); + $tx->rollBack(); + $count = (int) $conn->createCommand('SELECT COUNT(*) FROM CAPS_FB_MULTI_TEST')->queryScalar(); + $this->assertSame(1, $count, 'After cycle 2 rollback, still only 1 row expected.'); + + // Cycle 3: commit id=3. + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_MULTI_TEST VALUES (3)')->execute(); + $tx->commit(); + $count = (int) $conn->createCommand('SELECT COUNT(*) FROM CAPS_FB_MULTI_TEST')->queryScalar(); + $this->assertSame(2, $count, 'After cycle 3 commit, 2 rows expected (id=1 and id=3).'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_MULTI_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // pdo_firebird exposes PDO::ATTR_AUTOCOMMIT and always returns 1 (true), + // even inside an explicit transaction (the attribute reflects the PHP-level + // session setting, not the live transaction state). TDbConnection::HasAutoCommit + // returns true; AutoCommit reads the attribute and returns true by default. + // ----------------------------------------------------------------------- + + public function testFirebirdHasAutoCommitAttributeViaConnection(): void + { + $conn = $this->openFirebird('UTF-8'); + $this->assertTrue( + $conn->HasAutoCommit, + 'Firebird must report HasAutoCommit = true via TDbConnection.' + ); + $conn->Active = false; + } + + public function testFirebirdAutoCommitIsTrueByDefault(): void + { + $conn = $this->openFirebird('UTF-8'); + $this->assertTrue( + $conn->AutoCommit, + 'Firebird AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // + // Firebird requires requiresPreBeginTransactionFlush = true, so every call + // to beginTransaction() (whether on the connection or on the transaction + // object for reuse) issues a PDO::commit() first to clear the implicit + // transaction that pdo_firebird keeps running. The reuse tests verify this + // path works correctly across multiple cycles on the same object. + // ----------------------------------------------------------------------- + + public function testFirebirdTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + // The pre-begin flush in TDbTransaction::beginTransaction() clears the implicit + // Firebird transaction so pdo_firebird does not throw "active transaction". + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testFirebirdTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openFirebird('UTF-8'); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testFirebirdTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object via reuse: first commits + // (row persists), second rolls back (row discarded). Firebird DDL + // auto-commits, so the CREATE TABLE is outside any explicit transaction. + $conn = $this->openFirebird('UTF-8'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_FB_TX_REUSE (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + // Skip if pdo_firebird rollback is not reliable on this server build. + if (!$this->probeFirebirdRollback($conn, 'CAPS_FB_TX_REUSE')) { + try { $conn->createCommand('DROP TABLE CAPS_FB_TX_REUSE')->execute(); } catch (\Exception $e) {} + $conn->Active = false; + $this->markTestSkipped('pdo_firebird rollback is unreliable in this environment; skipping.'); + } + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_TX_REUSE VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_FB_TX_REUSE VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM CAPS_FB_TX_REUSE' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + + try { + $conn->createCommand('DROP TABLE CAPS_FB_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + /** + * Probes whether this pdo_firebird/Firebird combination reliably rolls back DML. + * + * Inserts one row, rolls back, then checks the row is gone. Returns true when + * rollback works correctly, false when pdo_firebird commits on rollback (a known + * bug in some PHP 8.x pdo_firebird builds). Any accidentally-committed probe + * row is deleted before returning false. + * + * @param TDbConnection $conn active Firebird connection. + * @param string $table table name to use for the probe (must accept an INT column named ID). + * @return bool true = rollback reliable; false = rollback broken, skip the caller. + */ + private function probeFirebirdRollback(\Prado\Data\TDbConnection $conn, string $table): bool + { + $pdo = $conn->getPdoInstance(); + try { $pdo->commit(); } catch (\Throwable $e) {} + $conn->beginTransaction()->commit(); // cycle once to reset internal state + try { $pdo->commit(); } catch (\Throwable $e) {} + + $tx = $conn->beginTransaction(); + $conn->createCommand("INSERT INTO $table VALUES (99999)")->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + "SELECT COUNT(*) FROM $table WHERE ID = 99999" + )->queryScalar(); + + if ($count !== 0) { + try { + $conn->createCommand("DELETE FROM $table WHERE ID = 99999")->execute(); + try { $pdo->commit(); } catch (\Throwable $e) {} + } catch (\Throwable $e) {} + return false; + } + return true; + } + + public function testFirebirdTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openFirebird('UTF-8'); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testFirebirdGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openFirebird('UTF-8'); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Firebird/TDbMetaDataFirebirdIntegrationTest.php b/tests/unit/Data/DbSpecific/Firebird/TDbMetaDataFirebirdIntegrationTest.php new file mode 100644 index 000000000..ebac09c41 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Firebird/TDbMetaDataFirebirdIntegrationTest.php @@ -0,0 +1,276 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openFirebird(); + + // Firebird DDL auto-commits; drop any leftover table first. + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand( + "CREATE TABLE META_TEST (ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(100) NOT NULL, SCORE DOUBLE PRECISION, NOTE VARCHAR(100) DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsFirebirdMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TFirebirdMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertSame('META_TEST', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $names = $info->getColumnNames(); + $this->assertContains('"ID"', $names); + $this->assertContains('"NAME"', $names); + $this->assertContains('"SCORE"', $names); + $this->assertContains('"NOTE"', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('META_TEST'); + $info2 = $meta->getTableInfo('META_TEST'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('NONEXISTENT_TABLE_XYZ'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + $this->assertStringContainsStringIgnoringCase('int', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('varchar', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + // SCORE has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + // TFirebirdMetaData::findTableNames() normalises names to lowercase. + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('META_TEST'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('FOO'); + // Firebird uses double-quote quoting. + $this->assertSame('"FOO"', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('BAR'); + $this->assertSame('"BAR"', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('BAZ'); + $this->assertSame('"BAZ"', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/IbmInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Ibm/IbmInsertOrIgnoreTest.php new file mode 100644 index 000000000..ee66550e8 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/IbmInsertOrIgnoreTest.php @@ -0,0 +1,291 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + // No transaction started — must throw + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_merge_when_not_matched_then_insert(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + } + + public function test_sql_using_select_contains_from_sysibm_sysdummy1(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringContainsString('FROM SYSIBM.SYSDUMMY1', $capturedSql); + } + + public function test_sql_uses_as_alias_keywords(): void + { + // DB2 MERGE uses AS t / AS s aliases (useAsAlias=true) + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringContainsString(' AS t ', $capturedSql); + $this->assertStringContainsString(' AS s ', $capturedSql); + } + + public function test_sql_has_no_dual_or_rdb_source(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringNotContainsString('DUAL', $capturedSql); + $this->assertStringNotContainsString('RDB$', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert within transaction + // ----------------------------------------------------------------------- + + public function test_new_row_inserted_within_transaction(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_new_row_returns_true_for_natural_key_table(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + // Natural key table (no identity) → getLastInsertID()=null → returns true + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: duplicate ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_pk_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertFalse($result); + } + + public function test_duplicate_does_not_increase_row_count(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Mixed inserts + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored_others_inserted(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); + $txn->commit(); + + $this->assertFalse($res2); + $this->assertTrue($res3); + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(2, $count); + } + + public function test_transaction_rollback_undoes_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/IbmTableExistsTest.php b/tests/unit/Data/DbSpecific/Ibm/IbmTableExistsTest.php new file mode 100644 index 000000000..3ae4ffe1d --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/IbmTableExistsTest.php @@ -0,0 +1,115 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + $this->dropTempTableIfExists(); + } + + protected function tearDown(): void + { + $this->dropTempTableIfExists(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + private function dropTempTableIfExists(): void + { + if (static::$conn === null) { + return; + } + try { + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + } catch (\Exception $e) { + // SQLSTATE 42704 — object not found; table did not exist, ignore. + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/IbmUpsertTest.php b/tests/unit/Data/DbSpecific/Ibm/IbmUpsertTest.php new file mode 100644 index 000000000..6170f1a80 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/IbmUpsertTest.php @@ -0,0 +1,367 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_contains_merge_when_matched_and_when_not_matched(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN MATCHED THEN UPDATE SET', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + public function test_sql_using_contains_from_sysibm_sysdummy1(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertStringContainsString('FROM SYSIBM.SYSDUMMY1', $capturedSql); + } + + public function test_sql_uses_as_alias_keywords(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertStringContainsString(' AS t ', $capturedSql); + $this->assertStringContainsString(' AS s ', $capturedSql); + } + + public function test_sql_update_set_contains_non_pk_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + // PK = username → updateData = {score}; "SCORE" appears in WHEN MATCHED branch + $matchedPos = stripos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertMatchesRegularExpression('/"?SCORE"?/i', $updatePart); + // username is PK — must not appear on the left-hand side of the UPDATE SET + $this->assertStringNotContainsString('t."USERNAME" = s.username', $updatePart); + } + + public function test_sql_explicit_updateData_only_those_columns_updated(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], ['score' => 10], ['username']); + $txn->rollback(); + + $matchedPos = stripos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertMatchesRegularExpression('/"?SCORE"?/i', $updatePart); + } + + public function test_sql_empty_updateData_omits_when_matched_branch(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], [], ['username']); + $txn->rollback(); + + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_upsert_new_row_returns_true(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict → update + // ----------------------------------------------------------------------- + + public function test_conflict_on_pk_updates_non_pk_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(99, (int) $lc['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(55, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → insert-or-ignore behaviour + // ----------------------------------------------------------------------- + + public function test_empty_updateData_does_not_update_on_conflict(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'bob', 'score' => 20]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $bob = self::$gateway->find('username = ?', 'bob'); + $lc = array_change_key_case($bob, CASE_LOWER); + $this->assertEquals(20, (int) $lc['score']); + } + + public function test_transaction_rollback_undoes_upsert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/TDbCommandIbmIntegrationTest.php b/tests/unit/Data/DbSpecific/Ibm/TDbCommandIbmIntegrationTest.php new file mode 100644 index 000000000..fc731e36a --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/TDbCommandIbmIntegrationTest.php @@ -0,0 +1,367 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openIbm(); + + // DB2 DDL auto-commits; drop any leftover table before creating. + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + // Table may not exist yet — that's fine. + } + $this->_conn->createCommand( + 'CREATE TABLE CMD_TEST (ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(100), SCORE DOUBLE, ACTIVE SMALLINT, NOTE VARCHAR(100))' + )->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + try { + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand('CREATE TABLE EXEC_DDL_TEST (X INTEGER)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM EXEC_DDL_TEST')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO CMD_TEST VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', rtrim($rows[0]['NAME'])); + $this->assertSame('Bob', rtrim($rows[1]['NAME'])); + $this->assertSame('Carol', rtrim($rows[2]['NAME'])); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertArrayHasKey('ID', $rows[0]); + $this->assertArrayHasKey('NAME', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', rtrim($row['NAME'])); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('NAME', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryScalar(); + $this->assertSame('Alice', rtrim($scalar)); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM CMD_TEST')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertCount(3, $names); + $this->assertSame('Alice', rtrim($names[0])); + $this->assertSame('Bob', rtrim($names[1])); + $this->assertSame('Carol', rtrim($names[2])); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', rtrim($cmd->queryScalar())); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', rtrim($cmd->queryScalar())); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', rtrim($cmd->queryScalar())); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT ID FROM CMD_TEST WHERE NAME = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', rtrim($cmd->queryScalar())); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', rtrim($cmd->queryScalar())); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', rtrim($cmd->queryScalar())); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryRow(); + $this->assertNull($row['NOTE']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $name = $reader->readColumn(1); // second column = NAME + $this->assertSame('Alice', rtrim($name)); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = rtrim($row['NAME']); + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME, SCORE FROM CMD_TEST')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['NOTE']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = ID, 1 = NAME. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('ID', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/TDbConnectionCharsetIbmIntegrationTest.php b/tests/unit/Data/DbSpecific/Ibm/TDbConnectionCharsetIbmIntegrationTest.php index f8b02c378..05cbb9167 100644 --- a/tests/unit/Data/DbSpecific/Ibm/TDbConnectionCharsetIbmIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Ibm/TDbConnectionCharsetIbmIntegrationTest.php @@ -146,4 +146,95 @@ public function testIbmGetDatabaseCharsetReturnsPassThroughForIso88591(): void $this->assertSame('ISO-8859-1', $conn->DatabaseCharset); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // supportsCharset = false behavioral verification + // + // IBM DB2 has no charset support of any kind. TDbDriverCapabilities:: + // supportsCharset('ibm') returns false. TDbConnection::setConnectionCharset() + // must do nothing for this driver — no DSN injection, no post-connect SQL. + // Constructing a connection with a Charset property set must still succeed. + // ----------------------------------------------------------------------- + + public function testIbmSupportsCharsetIsFalse(): void + { + // IBM DB2 is unique: supportsCharset returns false for all charset methods. + // TDbConnection must open successfully even when Charset is set, because + // applyCharsetToDsn() and setConnectionCharset() are both no-ops for ibm. + $conn = $this->openIbm('UTF-8'); + $this->assertTrue($conn->Active, 'IBM DB2 connection must open even when Charset is specified.'); + $conn->Active = false; + } + + public function testIbmNoDsnCharsetParameterIsInjected(): void + { + // applyCharsetToDsn() returns the DSN unchanged for ibm (no DSN charset param). + $conn = $this->openIbm('UTF-8'); + // The raw ConnectionString (before applyCharsetToDsn) must be unchanged. + $this->assertStringNotContainsString( + 'charset', + strtolower($conn->getConnectionString()), + 'IBM DB2 DSN must not have a charset parameter injected.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute = true behavioral verification + // + // IBM DB2 (pdo_ibm) exposes PDO::ATTR_AUTOCOMMIT. TDbConnection reads it + // without error; the default is true (autocommit mode). + // ----------------------------------------------------------------------- + + public function testIbmHasAutoCommitAttribute(): void + { + $conn = $this->openIbm(); + $this->assertTrue( + $conn->HasAutoCommit, + 'IBM DB2 must report hasAutoCommitAttribute = true.' + ); + $conn->Active = false; + } + + public function testIbmAutoCommitIsTrueByDefault(): void + { + $conn = $this->openIbm(); + $this->assertTrue( + $conn->AutoCommit, + 'IBM DB2 AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + public function testIbmBeginTransactionSucceedsAndRollbackWorks(): void + { + // PDO::ATTR_AUTOCOMMIT on IBM DB2 reflects the PHP-level session setting and + // does NOT transition to false when PDO::beginTransaction() is called. + // Simply verify that beginTransaction/rollback work without error. + $conn = $this->openIbm(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'IBM DB2 beginTransaction must return an active transaction.'); + $conn->rollback(); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — getCharsetSetSql live verification + // + // IBM DB2 has no SQL-level charset command (getCharsetSetSql returns null). + // Setting Charset after connect must throw TDbException (no runtime switch + // method exists and supportsCharset is false → raises the "unsupported" + // error path). + // ----------------------------------------------------------------------- + + public function testIbmSetCharsetAfterConnectThrowsForUnsupportedDriver(): void + { + // The framework's setConnectionCharset() path for IBM reaches the + // "unsupported driver charset" exception because supportsCharset('ibm') + // is the only driver where supportsRuntimeCharsetSet=false and + // getCharsetDsnParam=null and supportsCharset=false. + $conn = $this->openIbm(); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->Charset = 'UTF-8'; + } } diff --git a/tests/unit/Data/DbSpecific/Ibm/TDbDriverCapabilitiesIbmIntegrationTest.php b/tests/unit/Data/DbSpecific/Ibm/TDbDriverCapabilitiesIbmIntegrationTest.php new file mode 100644 index 000000000..76bec748f --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/TDbDriverCapabilitiesIbmIntegrationTest.php @@ -0,0 +1,483 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openIbm(): TDbConnection + { + if (!extension_loaded('pdo_ibm')) { + $this->markTestSkipped('pdo_ibm extension not available.'); + } + $user = getenv('DB2_USER') ?: 'db2inst1'; + $password = getenv('DB2_PASSWORD') ?: 'Prado_Unitest1'; + $dbname = getenv('DB2_DATABASE') ?: 'pradount'; + try { + $conn = new TDbConnection( + 'ibm:DRIVER={IBM DB2 ODBC DRIVER};DATABASE=' . $dbname . + ';HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP', + $user, + $password + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to IBM DB2: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags + // ----------------------------------------------------------------------- + + public function testIbmDoesNotSupportCharset(): void + { + // IBM DB2 has no charset support via PDO; this is unique among all + // supported Prado drivers. + $this->assertFalse(TDbDriverCapabilities::supportsCharset('ibm')); + } + + public function testIbmHasAutoCommitAttribute(): void + { + $this->assertTrue(TDbDriverCapabilities::hasAutoCommitAttribute('ibm')); + } + + + public function testIbmRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('ibm')); + } + + public function testIbmRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('ibm')); + } + + public function testIbmDoesNotSupportRuntimeCharsetSet(): void + { + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('ibm')); + } + + public function testIbmRequiresNoPostConnectCharset(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('ibm')); + } + + public function testIbmCharsetSetSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('ibm')); + } + + public function testIbmCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('ibm')); + } + + public function testIbmCharsetDsnParamIsNull(): void + { + // IBM DB2 has no charset DSN parameter. + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam('ibm')); + } + + public function testIbmCharsetDsnPatternIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern('ibm')); + } + + public function testIbmCharsetQuerySqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql('ibm')); + } + + public function testIbmGetListTablesSqlContainsSyscatTables(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('ibm'); + $this->assertNotNull($sql); + $this->assertStringContainsString('SYSCAT.TABLES', $sql); + } + + public function testIbmMetaDataClassName(): void + { + $this->assertSame(TIbmMetaData::class, TDbDriverCapabilities::getMetaDataClass('ibm')); + } + + // ----------------------------------------------------------------------- + // Charset — confirm all charset methods return null / false + // ----------------------------------------------------------------------- + + public function testIbmAllCharsetMethodsReturnNullOrFalse(): void + { + // Exhaustive verification that no charset method returns a non-null / truthy + // value for IBM DB2, since supportsCharset = false. + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('ibm')); + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('ibm')); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam('ibm')); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern('ibm')); + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql('ibm')); + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('ibm')); + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('ibm')); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testIbmScaffoldInputClass(): void + { + $this->assertSame('TIbmScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('ibm')); + } + + public function testIbmScaffoldInputFile(): void + { + $this->assertSame('/TIbmScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('ibm')); + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testIbmMetaDataInstanceIsTIbmMetaData(): void + { + $conn = $this->openIbm(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TIbmMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testIbmListTablesQueryReturnsArray(): void + { + $conn = $this->openIbm(); + $result = $conn->createCommand(TDbDriverCapabilities::getListTablesSql('ibm'))->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testIbmListTablesQueryReturnsCreatedTable(): void + { + // IBM DB2 stores table names in uppercase in SYSCAT.TABLES. + // The capability SQL filters TABSCHEMA = CURRENT SCHEMA AND TYPE = 'T'. + // Column key is TABNAME. + $conn = $this->openIbm(); + + try { + $conn->createCommand('DROP TABLE CAPS_IBM_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_IBM_LIST_TEST (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('ibm'); + $rows = $conn->createCommand($sql)->queryAll(); + + // pdo_ibm may return column keys in uppercase (TABNAME). + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tabname'); + $this->assertContains('CAPS_IBM_LIST_TEST', $names); + + try { + $conn->createCommand('DROP TABLE CAPS_IBM_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testIbmListTablesQueryFiltersByCurrentSchema(): void + { + // The SQL filters TABSCHEMA = CURRENT SCHEMA, so only tables in the + // connecting user's schema appear — not tables from SYSIBM or SYSCAT. + $conn = $this->openIbm(); + $sql = TDbDriverCapabilities::getListTablesSql('ibm'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tabname'); + // SYSCAT system tables must not appear in the user-schema result. + $this->assertNotContains('TABLES', $names); + $conn->Active = false; + } + + public function testIbmListTablesQueryDoesNotReturnDroppedTable(): void + { + $conn = $this->openIbm(); + + try { + $conn->createCommand('DROP TABLE CAPS_IBM_DROPPED_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_IBM_DROPPED_TEST (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + $conn->createCommand('DROP TABLE CAPS_IBM_DROPPED_TEST')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('ibm'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'tabname'); + $this->assertNotContains('CAPS_IBM_DROPPED_TEST', $names); + + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testIbmTransactionCommitSucceeds(): void + { + $conn = $this->openIbm(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + public function testIbmTransactionRollbackSucceeds(): void + { + $conn = $this->openIbm(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — confirm charset is not applied + // ----------------------------------------------------------------------- + + public function testIbmDriverNameIsIbm(): void + { + $conn = $this->openIbm(); + $this->assertSame('ibm', $conn->getDriverName()); + $conn->Active = false; + } + + public function testIbmSupportsCharsetFlagMatchesLiveDriver(): void + { + // Confirm that the static capability flag aligns with the live driver string. + $conn = $this->openIbm(); + $this->assertFalse(TDbDriverCapabilities::supportsCharset($conn->getDriverName())); + $conn->Active = false; + } + + public function testIbmDatabaseCharsetReturnsEmptyWhenNoCharsetConfigured(): void + { + // supportsCharset = false and getCharsetQuerySql = null: DatabaseCharset falls + // back to the raw Charset property which is empty when none was configured. + $conn = $this->openIbm(); + $this->assertSame('', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testIbmDoesNotSupportRuntimeCharsetSetLive(): void + { + // supportsRuntimeCharsetSet is false for ibm; confirm against live driver. + $conn = $this->openIbm(); + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet($conn->getDriverName())); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // pdo_ibm exposes PDO::ATTR_AUTOCOMMIT. TDbConnection::HasAutoCommit is + // true; AutoCommit reads the live session flag and returns true by default. + // ----------------------------------------------------------------------- + + public function testIbmHasAutoCommitAttributeViaConnection(): void + { + $conn = $this->openIbm(); + $this->assertTrue( + $conn->HasAutoCommit, + 'IBM DB2 must report HasAutoCommit = true via TDbConnection.' + ); + $conn->Active = false; + } + + public function testIbmAutoCommitIsTrueByDefault(): void + { + $conn = $this->openIbm(); + $this->assertTrue( + $conn->AutoCommit, + 'IBM DB2 AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // ----------------------------------------------------------------------- + + public function testIbmTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openIbm(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testIbmTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openIbm(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testIbmTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). IBM DB2 DDL auto-commits. + $conn = $this->openIbm(); + + try { + $conn->createCommand('DROP TABLE CAPS_IBM_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_IBM_TX_REUSE (ID INTEGER NOT NULL PRIMARY KEY)' + )->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_IBM_TX_REUSE VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_IBM_TX_REUSE VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM CAPS_IBM_TX_REUSE' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + + try { + $conn->createCommand('DROP TABLE CAPS_IBM_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testIbmTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openIbm(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testIbmGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openIbm(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Ibm/TDbMetaDataIbmIntegrationTest.php b/tests/unit/Data/DbSpecific/Ibm/TDbMetaDataIbmIntegrationTest.php new file mode 100644 index 000000000..8206fd17d --- /dev/null +++ b/tests/unit/Data/DbSpecific/Ibm/TDbMetaDataIbmIntegrationTest.php @@ -0,0 +1,276 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openIbm(); + + // DB2 DDL auto-commits; drop any leftover table first. + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand( + "CREATE TABLE META_TEST (ID INTEGER NOT NULL PRIMARY KEY, NAME VARCHAR(100) NOT NULL, SCORE DOUBLE, NOTE VARCHAR(100) DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsIbmMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TIbmMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertSame('META_TEST', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $names = $info->getColumnNames(); + $this->assertContains('"ID"', $names); + $this->assertContains('"NAME"', $names); + $this->assertContains('"SCORE"', $names); + $this->assertContains('"NOTE"', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('META_TEST'); + $info2 = $meta->getTableInfo('META_TEST'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('NONEXISTENT_TABLE_XYZ'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + $this->assertStringContainsStringIgnoringCase('int', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('varchar', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + // SCORE has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + // TIbmMetaData::findTableNames() normalises names to lowercase. + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('META_TEST'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('FOO'); + // IBM DB2 uses double-quote quoting. + $this->assertSame('"FOO"', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('BAR'); + $this->assertSame('"BAR"', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('BAZ'); + $this->assertSame('"BAZ"', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/MysqlInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Mysql/MysqlInsertOrIgnoreTest.php new file mode 100644 index 000000000..b6768f21b --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/MysqlInsertOrIgnoreTest.php @@ -0,0 +1,272 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM `upsert_test`')->execute(); + static::$conn->createCommand('ALTER TABLE `upsert_test` AUTO_INCREMENT = 1')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_insert_ignore_into(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('INSERT IGNORE INTO', $capturedSql); + $this->assertStringContainsString('`username`', $capturedSql); + $this->assertStringContainsString('`score`', $capturedSql); + $this->assertStringContainsString(':username', $capturedSql); + $this->assertStringContainsString(':score', $capturedSql); + $this->assertStringNotContainsString('ON DUPLICATE KEY', $capturedSql); + } + + public function test_sql_omits_id_when_not_supplied(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertStringNotContainsString('`id`', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Insert new row + // ----------------------------------------------------------------------- + + public function test_new_row_is_inserted(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_new_row_returns_integer_last_insert_id(): void + { + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_successive_inserts_return_incrementing_ids(): void + { + $id1 = (int) self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $id2 = (int) self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 2]); + $this->assertGreaterThan($id1, $id2); + } + + // ----------------------------------------------------------------------- + // Duplicate silently ignored (UNIQUE key on username) + // ----------------------------------------------------------------------- + + public function test_duplicate_username_returns_false(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $this->assertFalse($result); + } + + public function test_duplicate_does_not_increase_row_count(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_zero_score_value_is_stored_correctly(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 0]); + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(0, (int) $row['score']); + // 0 is falsy but must still be a successful insert returning an id + $result = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 0]); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_large_score_value(): void + { + $large = 2147483647; // INT max + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => $large]); + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals($large, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Mixed: some conflict, some new + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored_others_inserted(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); // conflict + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); // new + + $this->assertFalse($res2); + $this->assertGreaterThan(0, (int) $res3); + $this->assertEquals( + 2, + (int) self::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar() + ); + } + + public function test_correct_values_after_mixed_inserts(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 55]); + + $alice = self::$gateway->find('username = ?', 'alice'); + $bob = self::$gateway->find('username = ?', 'bob'); + $this->assertEquals(10, (int) $alice['score']); + $this->assertEquals(55, (int) $bob['score']); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertTrue($fired, 'OnCreateCommand not raised'); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertEquals(1, $captured, 'OnExecuteCommand result should be 1 for a fresh insert'); + } + + public function test_onexecutecommand_result_is_zero_on_conflict(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $captured = $param->getResult(); + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $this->assertEquals(0, $captured, 'INSERT IGNORE returns 0 affected rows on conflict'); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Base class + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_insertOrIgnore(): void + { + $meta = new \Prado\Data\Common\Mysql\TMysqlMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createInsertOrIgnoreCommand(['username' => 'x', 'score' => 1]); + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/MysqlTableExistsTest.php b/tests/unit/Data/DbSpecific/Mysql/MysqlTableExistsTest.php new file mode 100644 index 000000000..7451a475f --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/MysqlTableExistsTest.php @@ -0,0 +1,107 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + static::$conn->createCommand( + 'DROP TABLE IF EXISTS `' . self::TEMP_TABLE . '`' + )->execute(); + } + + protected function tearDown(): void + { + if (static::$conn !== null) { + static::$conn->createCommand( + 'DROP TABLE IF EXISTS `' . self::TEMP_TABLE . '`' + )->execute(); + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE `' . self::TEMP_TABLE . '` (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE `' . self::TEMP_TABLE . '` (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE `' . self::TEMP_TABLE . '`')->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/MysqlUpsertTest.php b/tests/unit/Data/DbSpecific/Mysql/MysqlUpsertTest.php new file mode 100644 index 000000000..38e903218 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/MysqlUpsertTest.php @@ -0,0 +1,334 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM `upsert_test`')->execute(); + static::$conn->createCommand('ALTER TABLE `upsert_test` AUTO_INCREMENT = 1')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_on_duplicate_key_update(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, null); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('INSERT INTO', $capturedSql); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $capturedSql); + $this->assertStringContainsString('VALUES(`score`)', $capturedSql); + } + + public function test_sql_update_clause_excludes_pk_columns(): void + { + // PK is 'id'; updateData defaults to non-PK: username, score + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, null); + // id excluded from UPDATE; username and score updated via VALUES() + $dupPos = strpos($capturedSql, 'ON DUPLICATE KEY UPDATE'); + $updatePart = substr($capturedSql, (int) $dupPos); + $this->assertStringNotContainsString('`id`=VALUES(`id`)', $updatePart); + $this->assertStringContainsString('`username`=VALUES(`username`)', $updatePart); + $this->assertStringContainsString('`score`=VALUES(`score`)', $updatePart); + } + + public function test_sql_explicit_conflictColumns_excludes_them_from_update(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + $dupPos = strpos($capturedSql, 'ON DUPLICATE KEY UPDATE'); + $updatePart = substr($capturedSql, (int) $dupPos); + // username is conflict col → excluded from UPDATE; score is updated + $this->assertStringNotContainsString('`username`=VALUES(`username`)', $updatePart); + $this->assertStringContainsString('`score`=VALUES(`score`)', $updatePart); + } + + public function test_sql_explicit_updateData_only_those_columns_in_update(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], ['score' => 1], ['username']); + $dupPos = strpos($capturedSql, 'ON DUPLICATE KEY UPDATE'); + $updatePart = substr($capturedSql, (int) $dupPos); + $this->assertStringContainsString('`score`=VALUES(`score`)', $updatePart); + $this->assertStringNotContainsString('`username`=VALUES(`username`)', $updatePart); + } + + public function test_sql_empty_updateData_uses_insert_ignore(): void + { + // When updateData=[], falls back to INSERT IGNORE (no ON DUPLICATE KEY UPDATE) + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], [], ['username']); + $this->assertStringContainsString('INSERT IGNORE INTO', $capturedSql); + $this->assertStringNotContainsString('ON DUPLICATE KEY UPDATE', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_upsert_new_row_returns_integer_id(): void + { + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict on UNIQUE username → update via ON DUPLICATE KEY + // ----------------------------------------------------------------------- + + public function test_conflict_on_unique_username_updates_score(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(99, (int) $row['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM `upsert_test`')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $this->assertNotFalse($result); + } + + public function test_conflict_with_explicit_conflict_columns_updates_score(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 77], null, ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(77, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + // Only score in updateData; username also present in data but won't be in UPDATE + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(55, (int) $row['score']); + $this->assertEquals('alice', $row['username']); + } + + public function test_null_updateData_updates_all_non_conflict_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 88], + null, + ['username'] + ); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(88, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → no ON DUPLICATE KEY UPDATE (acts as INSERT IGNORE) + // ----------------------------------------------------------------------- + + public function test_empty_updateData_does_not_update_on_conflict(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_affect_other_rows(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->insert(['username' => 'bob', 'score' => 20]); + + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + + $bob = self::$gateway->find('username = ?', 'bob'); + $this->assertEquals(20, (int) $bob['score']); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->upsert(['username' => 'alice', 'score' => 1]); + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->upsert(['username' => 'alice', 'score' => 1]); + // MySQL returns 1 for INSERT, 2 for UPDATE via ON DUPLICATE KEY + $this->assertNotNull($captured); + $this->assertGreaterThan(0, $captured); + } + + public function test_onexecutecommand_reports_two_on_update(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $captured = $param->getResult(); + }; + + $gw->upsert(['username' => 'alice', 'score' => 99]); + // MySQL ON DUPLICATE KEY UPDATE returns 2 for an update (counts old + new) + $this->assertEquals(2, $captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $result = $gw->upsert(['username' => 'alice', 'score' => 1]); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Base class throws TDbException + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_upsert(): void + { + $meta = new \Prado\Data\Common\Mysql\TMysqlMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createUpsertCommand(['username' => 'x', 'score' => 1]); + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/TDbCommandMysqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Mysql/TDbCommandMysqlIntegrationTest.php new file mode 100644 index 000000000..165f69bce --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/TDbCommandMysqlIntegrationTest.php @@ -0,0 +1,349 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openMysql(); + $this->_conn->createCommand( + 'CREATE TABLE IF NOT EXISTS cmd_test (id INT PRIMARY KEY, name VARCHAR(100), score DOUBLE, active TINYINT(1), note VARCHAR(100))' + )->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE IF EXISTS cmd_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + $this->_conn->createCommand('CREATE TABLE IF NOT EXISTS exec_ddl_test (x INT)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM exec_ddl_test')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE IF EXISTS exec_ddl_test')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO cmd_test VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Carol', $rows[2]['name']); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->queryAll(); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertArrayHasKey('name', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', $row['name']); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('name', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryScalar(); + $this->assertSame('Alice', $scalar); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM cmd_test')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', $cmd->queryScalar()); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', $cmd->queryScalar()); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT id FROM cmd_test WHERE name = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', $cmd->queryScalar()); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', $cmd->queryScalar()); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryRow(); + $this->assertNull($row['note']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $name = $reader->readColumn(1); // second column = name + $this->assertSame('Alice', $name); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = $row['name']; + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT id, name, score FROM cmd_test')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['note']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = id, 1 = name. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('id', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/TDbConnectionCharsetMysqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Mysql/TDbConnectionCharsetMysqlIntegrationTest.php index 5275aa719..279e83628 100644 --- a/tests/unit/Data/DbSpecific/Mysql/TDbConnectionCharsetMysqlIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Mysql/TDbConnectionCharsetMysqlIntegrationTest.php @@ -170,4 +170,80 @@ public function testMysqlGetDatabaseCharsetReflectsCharsetChangedAfterConnect(): $this->assertSame('latin1', $conn->DatabaseCharset); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute = true behavioral verification + // + // MySQL exposes PDO::ATTR_AUTOCOMMIT. TDbConnection::getAutoCommit() reads + // it; setAutoCommit() writes it. Outside of an explicit transaction, MySQL + // defaults to autocommit-on. These tests verify that TDbConnection can read + // and write the attribute without error, and that its value reflects the real + // connection state. + // ----------------------------------------------------------------------- + + public function testMysqlHasAutoCommitAttribute(): void + { + $conn = $this->openMysql(); + $this->assertTrue( + $conn->HasAutoCommit, + 'MySQL must report hasAutoCommitAttribute = true.' + ); + $conn->Active = false; + } + + public function testMysqlAutoCommitIsTrueByDefault(): void + { + // MySQL defaults to autocommit mode outside of an explicit transaction. + $conn = $this->openMysql(); + $this->assertTrue( + $conn->AutoCommit, + 'MySQL AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + public function testMysqlSetAutoCommitToFalseDisablesAutocommit(): void + { + $conn = $this->openMysql(); + $conn->AutoCommit = false; + $this->assertFalse( + $conn->AutoCommit, + 'AutoCommit must be false after setAutoCommit(false) on MySQL.' + ); + // Re-enable so subsequent work on the same session is not surprised. + $conn->AutoCommit = true; + $conn->Active = false; + } + + public function testMysqlBeginTransactionSucceedsAndRollbackWorks(): void + { + // PDO::ATTR_AUTOCOMMIT on MySQL reflects the PHP-level session setting (1 by + // default) and does NOT transition to 0 when PDO::beginTransaction() is called. + // MySQL's transaction implementation uses SET autocommit=0 internally, but the + // PDO attribute getter returns the cached initial value, not the live session + // state. Use PDO::inTransaction() (not ATTR_AUTOCOMMIT) to detect transaction + // state in MySQL. This test simply verifies that beginTransaction/rollback + // work without throwing for MySQL. + $conn = $this->openMysql(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'MySQL beginTransaction must return an active transaction.'); + $conn->rollback(); + $conn->Active = false; + } + + public function testMysqlSetCharsetUsesParameterisedSql(): void + { + // getCharsetSetSql('mysql') returns 'SET NAMES ?' — a PDO-parameterised + // statement. TDbConnection executes it via $pdo->prepare($sql)->execute([$charset]) + // so the charset value is bound as a parameter, not concatenated into SQL. + // Verify the functional outcome: setting UTF-8 results in utf8mb4 on the server. + $conn = $this->openMysql(); + $conn->Charset = 'UTF-8'; + $this->assertSame( + 'utf8mb4', + $this->mysqlClientCharset($conn), + 'SET NAMES ? must have been executed with \'utf8mb4\' as the parameter value.' + ); + $conn->Active = false; + } } diff --git a/tests/unit/Data/DbSpecific/Mysql/TDbDriverCapabilitiesMysqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Mysql/TDbDriverCapabilitiesMysqlIntegrationTest.php new file mode 100644 index 000000000..ab52c2d93 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/TDbDriverCapabilitiesMysqlIntegrationTest.php @@ -0,0 +1,499 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openMysql(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_mysql')) { + $this->markTestSkipped('pdo_mysql extension not available.'); + } + try { + $conn = new TDbConnection( + 'mysql:host=localhost;dbname=prado_unitest', + 'prado_unitest', + 'prado_unitest', + $charset + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to MySQL: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags + // ----------------------------------------------------------------------- + + public function testMysqlSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('mysql')); + } + + public function testMysqlHasAutoCommitAttribute(): void + { + $this->assertTrue(TDbDriverCapabilities::hasAutoCommitAttribute('mysql')); + } + + + public function testMysqlRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('mysql')); + } + + public function testMysqlRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('mysql')); + } + + public function testMysqlSupportsRuntimeCharsetSet(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsRuntimeCharsetSet('mysql')); + } + + public function testMysqlRequiresNoPostConnectCharset(): void + { + // MySQL charset is injected into the DSN and set via SET NAMES on connect; + // no additional post-connect SQL is required. + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('mysql')); + } + + public function testMysqlCharsetSetSqlIsSetNames(): void + { + $this->assertSame('SET NAMES ?', TDbDriverCapabilities::getCharsetSetSql('mysql')); + } + + public function testMysqlCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('mysql')); + } + + public function testMysqlCharsetDsnParamIsCharset(): void + { + $this->assertSame('charset', TDbDriverCapabilities::getCharsetDsnParam('mysql')); + } + + public function testMysqlCharsetDsnPatternMatchesCharsetParam(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern('mysql'); + $this->assertNotNull($pattern); + $this->assertSame(1, preg_match($pattern, ';charset=utf8mb4', $m)); + $this->assertSame('utf8mb4', $m[1]); + } + + public function testMysqlCharsetQuerySqlSelectsCharacterSetConnection(): void + { + $this->assertSame('SELECT @@character_set_connection', TDbDriverCapabilities::getCharsetQuerySql('mysql')); + } + + public function testMysqlGetListTablesSqlIsShowTables(): void + { + $this->assertSame('SHOW TABLES', TDbDriverCapabilities::getListTablesSql('mysql')); + } + + public function testMysqlMetaDataClassName(): void + { + $this->assertSame(TMysqlMetaData::class, TDbDriverCapabilities::getMetaDataClass('mysql')); + } + + // ----------------------------------------------------------------------- + // Charset resolution + // ----------------------------------------------------------------------- + + public function testMysqlResolveUtf8ReturnsUtf8mb4(): void + { + $this->assertSame('utf8mb4', TDbDriverCapabilities::resolveCharset('UTF-8', 'mysql')); + } + + public function testMysqlResolveLatin1ReturnsLatin1(): void + { + $this->assertSame('latin1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'mysql')); + } + + public function testMysqlResolveLatin2ReturnsLatin2(): void + { + $this->assertSame('latin2', TDbDriverCapabilities::resolveCharset('ISO-8859-2', 'mysql')); + } + + public function testMysqlResolveAsciiReturnsAscii(): void + { + $this->assertSame('ascii', TDbDriverCapabilities::resolveCharset('ASCII', 'mysql')); + } + + public function testMysqlResolveWin1250ReturnsCp1250(): void + { + $this->assertSame('cp1250', TDbDriverCapabilities::resolveCharset('Windows-1250', 'mysql')); + } + + public function testMysqlResolveKoi8rReturnsKoi8r(): void + { + $this->assertSame('koi8r', TDbDriverCapabilities::resolveCharset('KOI8-R', 'mysql')); + } + + public function testMysqlUnresolveUtf8mb4ReturnsUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::unresolveCharset('utf8mb4', 'mysql')); + } + + public function testMysqlUnresolveLatin1ReturnsLatin1Standard(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::unresolveCharset('latin1', 'mysql')); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testMysqlScaffoldInputClass(): void + { + $this->assertSame('TMysqlScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('mysql')); + } + + public function testMysqlScaffoldInputFile(): void + { + $this->assertSame('/TMysqlScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('mysql')); + } + + // ----------------------------------------------------------------------- + // Live connection — charset + // ----------------------------------------------------------------------- + + public function testMysqlCharsetQuerySqlExecutesAndReturnsUtf8mb4(): void + { + $conn = $this->openMysql('UTF-8'); + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('mysql')); + $this->assertSame('utf8mb4', $charset); + $conn->Active = false; + } + + public function testMysqlCharsetQuerySqlReturnsLatin1WhenSetToIso88591(): void + { + $conn = $this->openMysql('ISO-8859-1'); + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('mysql')); + $this->assertSame('latin1', $charset); + $conn->Active = false; + } + + public function testMysqlDatabaseCharsetReturnsUtf8mb4WhenUtf8Configured(): void + { + $conn = $this->openMysql('UTF-8'); + $this->assertSame('utf8mb4', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testMysqlSetCharsetAfterConnectAppliesNewCharset(): void + { + $conn = $this->openMysql(); + $conn->Charset = 'UTF-8'; + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('mysql')); + $this->assertSame('utf8mb4', $charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testMysqlMetaDataInstanceIsTMysqlMetaData(): void + { + $conn = $this->openMysql(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TMysqlMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testMysqlListTablesQueryReturnsArray(): void + { + $conn = $this->openMysql(); + $result = $conn->createCommand(TDbDriverCapabilities::getListTablesSql('mysql'))->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testMysqlListTablesQueryReturnsCreatedTable(): void + { + // Create a temporary table, run SHOW TABLES, verify the name appears + // in the result set, then clean up. MySQL's SHOW TABLES returns one + // row per table; the column name is "Tables_in_" so we read + // the first value of each row to stay DB-name-agnostic. + $conn = $this->openMysql(); + $conn->createCommand('DROP TABLE IF EXISTS caps_mysql_list_test')->execute(); + $conn->createCommand('CREATE TABLE caps_mysql_list_test (id INT NOT NULL PRIMARY KEY)')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('mysql'); + $rows = $conn->createCommand($sql)->queryAll(); + + // SHOW TABLES: each row has one column; extract the first value per row. + $names = array_map(fn($row) => array_values($row)[0], $rows); + $this->assertContains('caps_mysql_list_test', $names); + + $conn->createCommand('DROP TABLE IF EXISTS caps_mysql_list_test')->execute(); + $conn->Active = false; + } + + public function testMysqlListTablesQueryDoesNotReturnDroppedTable(): void + { + $conn = $this->openMysql(); + $conn->createCommand('DROP TABLE IF EXISTS caps_mysql_dropped_test')->execute(); + $conn->createCommand('CREATE TABLE caps_mysql_dropped_test (id INT NOT NULL PRIMARY KEY)')->execute(); + $conn->createCommand('DROP TABLE caps_mysql_dropped_test')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('mysql'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_map(fn($row) => array_values($row)[0], $rows); + $this->assertNotContains('caps_mysql_dropped_test', $names); + + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testMysqlTransactionCommitPersistsData(): void + { + $conn = $this->openMysql(); + $conn->createCommand('CREATE TABLE IF NOT EXISTS caps_tx_test (id INT PRIMARY KEY)')->execute(); + $conn->createCommand('DELETE FROM caps_tx_test')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_tx_test VALUES (1)')->execute(); + $tx->commit(); + + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM caps_tx_test'); + $this->assertSame(1, $count); + $conn->createCommand('DROP TABLE caps_tx_test')->execute(); + $conn->Active = false; + } + + public function testMysqlTransactionRollbackDiscardsData(): void + { + $conn = $this->openMysql(); + $conn->createCommand('CREATE TABLE IF NOT EXISTS caps_tx_test2 (id INT PRIMARY KEY)')->execute(); + $conn->createCommand('DELETE FROM caps_tx_test2')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_tx_test2 VALUES (1)')->execute(); + $tx->rollBack(); + + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM caps_tx_test2'); + $this->assertSame(0, $count); + $conn->createCommand('DROP TABLE caps_tx_test2')->execute(); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // MySQL exposes PDO::ATTR_AUTOCOMMIT. TDbConnection::HasAutoCommit is true; + // TDbConnection::AutoCommit reads and writes the live session flag. + // ----------------------------------------------------------------------- + + public function testMysqlAutoCommitAttributeIsReadable(): void + { + $conn = $this->openMysql(); + $value = $conn->getPdoInstance()->getAttribute(\PDO::ATTR_AUTOCOMMIT); + $this->assertNotNull($value); + $conn->Active = false; + } + + public function testMysqlHasAutoCommitAttributeViaConnection(): void + { + $conn = $this->openMysql(); + $this->assertTrue( + $conn->HasAutoCommit, + 'MySQL must report HasAutoCommit = true via TDbConnection.' + ); + $conn->Active = false; + } + + public function testMysqlAutoCommitIsTrueByDefault(): void + { + $conn = $this->openMysql(); + $this->assertTrue( + $conn->AutoCommit, + 'MySQL AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + public function testMysqlAutoCommitCanBeSetToFalseAndBack(): void + { + $conn = $this->openMysql(); + $conn->AutoCommit = false; + $this->assertFalse( + $conn->AutoCommit, + 'MySQL AutoCommit must be false after setAutoCommit(false).' + ); + $conn->AutoCommit = true; + $this->assertTrue( + $conn->AutoCommit, + 'MySQL AutoCommit must return to true after setAutoCommit(true).' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // ----------------------------------------------------------------------- + + public function testMysqlTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openMysql(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testMysqlTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openMysql(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testMysqlTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). + $conn = $this->openMysql(); + $conn->createCommand( + 'CREATE TABLE IF NOT EXISTS caps_mysql_tx_reuse (id INT PRIMARY KEY)' + )->execute(); + $conn->createCommand('DELETE FROM caps_mysql_tx_reuse')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_mysql_tx_reuse VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO caps_mysql_tx_reuse VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM caps_mysql_tx_reuse' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + $conn->createCommand('DROP TABLE caps_mysql_tx_reuse')->execute(); + $conn->Active = false; + } + + public function testMysqlTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openMysql(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testMysqlGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openMysql(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Mysql/TDbMetaDataMysqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Mysql/TDbMetaDataMysqlIntegrationTest.php new file mode 100644 index 000000000..20788b03b --- /dev/null +++ b/tests/unit/Data/DbSpecific/Mysql/TDbMetaDataMysqlIntegrationTest.php @@ -0,0 +1,268 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openMysql(); + $this->_conn->createCommand('DROP TABLE IF EXISTS meta_test')->execute(); + $this->_conn->createCommand( + "CREATE TABLE meta_test (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, score DOUBLE, note VARCHAR(100) DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE IF EXISTS meta_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsMysqlMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TMysqlMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertSame('meta_test', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $names = $info->getColumnNames(); + $this->assertContains('`id`', $names); + $this->assertContains('`name`', $names); + $this->assertContains('`score`', $names); + $this->assertContains('`note`', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('meta_test'); + $info2 = $meta->getTableInfo('meta_test'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('nonexistent_table_xyz'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertStringContainsStringIgnoringCase('int', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('varchar', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + // score has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('meta_test'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('foo'); + // MySQL uses backtick quoting. + $this->assertSame('`foo`', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('bar'); + $this->assertSame('`bar`', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('baz'); + $this->assertSame('`baz`', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/OracleInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Oracle/OracleInsertOrIgnoreTest.php new file mode 100644 index 000000000..082b05942 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/OracleInsertOrIgnoreTest.php @@ -0,0 +1,294 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('PRADO_UNITEST.upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + // No transaction started — must throw + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_merge_when_not_matched_then_insert(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + } + + public function test_sql_using_select_contains_from_dual(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $this->assertStringContainsString('FROM DUAL', $capturedSql); + } + + public function test_sql_uses_bare_aliases_without_as_keyword(): void + { + // Oracle MERGE uses bare t / s aliases (useAsAlias=false) + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $this->assertMatchesRegularExpression('/USING\s*\(.*\)\s+s\s+ON/si', $capturedSql); + $this->assertDoesNotMatchRegularExpression('/\bAS\s+t\b/i', $capturedSql); + // Check the subquery alias specifically — 'AS s' cannot precede the ON clause. + // A plain 'AS s' substring check would false-positive on column aliases like 'AS score'. + $this->assertDoesNotMatchRegularExpression('/\)\s+AS\s+s\b/i', $capturedSql); + } + + public function test_sql_has_no_rdb_or_sysdummy_source(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $this->assertStringNotContainsString('RDB$', $capturedSql); + $this->assertStringNotContainsString('SYSIBM', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert within transaction + // ----------------------------------------------------------------------- + + public function test_new_row_inserted_within_transaction(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_new_row_returns_true_for_natural_key_table(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + // Natural key table (no sequence) → getLastInsertID()=null → returns true + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: duplicate ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_pk_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertFalse($result); + } + + public function test_duplicate_does_not_increase_row_count(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Mixed inserts + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored_others_inserted(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); + $txn->commit(); + + $this->assertFalse($res2); + $this->assertTrue($res3); + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(2, $count); + } + + public function test_transaction_rollback_undoes_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/OracleTableExistsTest.php b/tests/unit/Data/DbSpecific/Oracle/OracleTableExistsTest.php new file mode 100644 index 000000000..21233c25b --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/OracleTableExistsTest.php @@ -0,0 +1,122 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + $this->dropTempTableIfExists(); + } + + protected function tearDown(): void + { + $this->dropTempTableIfExists(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + private function dropTempTableIfExists(): void + { + if (static::$conn === null) { + return; + } + try { + // Oracle DDL auto-commits; no explicit COMMIT needed. + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + } catch (\Exception $e) { + // ORA-00942: table or view does not exist — ignore. + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + // Oracle tests use schema-prefixed names, consistent with OracleInsertOrIgnoreTest. + $gateway = new TTableGateway('PRADO_UNITEST.upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id NUMBER NOT NULL PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE_SCHEMA, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id NUMBER NOT NULL PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE_SCHEMA); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + // Oracle DDL auto-commits. + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/OracleUpsertTest.php b/tests/unit/Data/DbSpecific/Oracle/OracleUpsertTest.php new file mode 100644 index 000000000..b35ee671e --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/OracleUpsertTest.php @@ -0,0 +1,365 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('PRADO_UNITEST.upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_contains_merge_when_matched_and_when_not_matched(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN MATCHED THEN UPDATE SET', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + public function test_sql_using_contains_from_dual(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertStringContainsString('FROM DUAL', $capturedSql); + } + + public function test_sql_uses_bare_aliases_without_as_keyword(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + $this->assertMatchesRegularExpression('/USING\s*\(.*\)\s+s\s+ON/si', $capturedSql); + $this->assertDoesNotMatchRegularExpression('/\bAS\s+t\b/i', $capturedSql); + // Check the subquery alias specifically — 'AS s' cannot precede the ON clause. + // A plain 'AS s' substring check would false-positive on column aliases like 'AS score'. + $this->assertDoesNotMatchRegularExpression('/\)\s+AS\s+s\b/i', $capturedSql); + } + + public function test_sql_update_set_contains_non_pk_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + + // PK = username → updateData = {score}; score appears in WHEN MATCHED branch + $matchedPos = stripos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertStringContainsString('score', $updatePart); + // username is PK, not in UPDATE SET target + $this->assertStringNotContainsString('t.username = s.username', $updatePart); + } + + public function test_sql_explicit_updateData_only_those_columns_updated(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], ['score' => 10], ['username']); + $txn->rollback(); + + $matchedPos = stripos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertStringContainsString('score', $updatePart); + } + + public function test_sql_empty_updateData_omits_when_matched_branch(): void + { + $capturedSql = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], [], ['username']); + $txn->rollback(); + + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_upsert_new_row_returns_true(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict → update + // ----------------------------------------------------------------------- + + public function test_conflict_on_pk_updates_non_pk_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(99, (int) $lc['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(55, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → insert-or-ignore behaviour + // ----------------------------------------------------------------------- + + public function test_empty_updateData_does_not_update_on_conflict(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'bob', 'score' => 20]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $bob = self::$gateway->find('username = ?', 'bob'); + $lc = array_change_key_case($bob, CASE_LOWER); + $this->assertEquals(20, (int) $lc['score']); + } + + public function test_transaction_rollback_undoes_upsert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('PRADO_UNITEST.upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/TDbCommandOracleIntegrationTest.php b/tests/unit/Data/DbSpecific/Oracle/TDbCommandOracleIntegrationTest.php new file mode 100644 index 000000000..56b1f2bc8 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/TDbCommandOracleIntegrationTest.php @@ -0,0 +1,367 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openOracle(); + + // Oracle DDL auto-commits; drop any leftover table before creating. + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + // Table may not exist yet — that's fine. + } + $this->_conn->createCommand( + 'CREATE TABLE CMD_TEST (ID NUMBER(10) NOT NULL PRIMARY KEY, NAME VARCHAR2(100), SCORE BINARY_DOUBLE, ACTIVE NUMBER(1), NOTE VARCHAR2(100))' + )->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO CMD_TEST VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE CMD_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + try { + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand('CREATE TABLE EXEC_DDL_TEST (X NUMBER(10))')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM EXEC_DDL_TEST')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE EXEC_DDL_TEST')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO CMD_TEST VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', $rows[0]['NAME']); + $this->assertSame('Bob', $rows[1]['NAME']); + $this->assertSame('Carol', $rows[2]['NAME']); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->queryAll(); + $this->assertArrayHasKey('ID', $rows[0]); + $this->assertArrayHasKey('NAME', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', $row['NAME']); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('NAME', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryScalar(); + $this->assertSame('Alice', $scalar); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM CMD_TEST')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithNamedPlaceholder(): void + { + // Oracle uses named placeholders (:name). TDbCommand::bindParameter() + // internally falls back to PDOStatement::bindValue() for pdo_oci because + // PDOStatement::bindParam() segfaults in some PHP 8.2 builds of pdo_oci. + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $id = 2; + $cmd->bindParameter(':id', $id); + $this->assertSame('Bob', $cmd->queryScalar()); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', $cmd->queryScalar()); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT ID FROM CMD_TEST WHERE NAME = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST WHERE ID = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', $cmd->queryScalar()); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', $cmd->queryScalar()); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryRow(); + $this->assertNull($row['NOTE']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT ID FROM CMD_TEST ORDER BY ID')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST ORDER BY ID')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $name = $reader->readColumn(1); // second column = NAME + $this->assertSame('Alice', $name); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT NAME FROM CMD_TEST ORDER BY ID')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = $row['NAME']; + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME, SCORE FROM CMD_TEST')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT NOTE FROM CMD_TEST WHERE ID = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['NOTE']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST WHERE ID = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM CMD_TEST')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT ID, NAME FROM CMD_TEST ORDER BY ID')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = ID, 1 = NAME. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('ID', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/TDbConnectionCharsetOciIntegrationTest.php b/tests/unit/Data/DbSpecific/Oracle/TDbConnectionCharsetOciIntegrationTest.php index 0b6791297..bfef0cdaa 100644 --- a/tests/unit/Data/DbSpecific/Oracle/TDbConnectionCharsetOciIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Oracle/TDbConnectionCharsetOciIntegrationTest.php @@ -148,4 +148,56 @@ public function testOciGetDatabaseCharsetReturnsWe8Iso8859P1(): void $this->assertSame('WE8ISO8859P1', $conn->DatabaseCharset); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute = true behavioral verification + // + // Oracle (pdo_oci) exposes PDO::ATTR_AUTOCOMMIT; TDbConnection can read it. + // ----------------------------------------------------------------------- + + public function testOciHasAutoCommitAttribute(): void + { + $conn = $this->openOci(); + $this->assertTrue( + $conn->HasAutoCommit, + 'Oracle (pdo_oci) must report hasAutoCommitAttribute = true.' + ); + $conn->Active = false; + } + + public function testOciAutoCommitIsTrueByDefault(): void + { + $conn = $this->openOci(); + $this->assertTrue( + $conn->AutoCommit, + 'Oracle AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + public function testOciBeginTransactionSucceedsAndRollbackWorks(): void + { + // PDO::ATTR_AUTOCOMMIT on Oracle reflects the PHP-level session setting and + // does NOT transition to false when PDO::beginTransaction() is called. + // Simply verify that beginTransaction/rollback work without error. + $conn = $this->openOci(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'Oracle beginTransaction must return an active transaction.'); + $conn->rollback(); + $conn->Active = false; + } + + public function testOciCharsetInjectedIntoDsnWithCharsetParam(): void + { + // applyCharsetToDsn() appends ;charset=AL32UTF8 for oci. + // The raw ConnectionString (stored before modification) must not contain it. + $conn = $this->openOci('UTF-8'); + $this->assertTrue($conn->Active); + $this->assertStringNotContainsString( + 'charset', + strtolower($conn->getConnectionString()), + 'The raw stored DSN must not contain charset; only the modified copy passed to PDO does.' + ); + $conn->Active = false; + } } diff --git a/tests/unit/Data/DbSpecific/Oracle/TDbDriverCapabilitiesOracleIntegrationTest.php b/tests/unit/Data/DbSpecific/Oracle/TDbDriverCapabilitiesOracleIntegrationTest.php new file mode 100644 index 000000000..cde0f1798 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/TDbDriverCapabilitiesOracleIntegrationTest.php @@ -0,0 +1,534 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openOci(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_oci')) { + $this->markTestSkipped('pdo_oci extension not available.'); + } + $serviceName = getenv('ORACLE_SERVICE_NAME') ?: 'FREEPDB1'; + try { + $conn = new TDbConnection( + 'oci:dbname=//localhost:1521/' . $serviceName, + 'prado_unitest', + 'prado_unitest', + $charset + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to Oracle: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags + // ----------------------------------------------------------------------- + + public function testOciSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('oci')); + } + + public function testOciHasAutoCommitAttribute(): void + { + $this->assertTrue(TDbDriverCapabilities::hasAutoCommitAttribute('oci')); + } + + + public function testOciRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('oci')); + } + + public function testOciRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('oci')); + } + + public function testOciDoesNotSupportRuntimeCharsetSet(): void + { + // Oracle charset is configured at DSN level; no runtime SQL command exists. + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('oci')); + } + + public function testOciRequiresNoPostConnectCharset(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('oci')); + } + + public function testOciCharsetSetSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('oci')); + } + + public function testOciCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('oci')); + } + + public function testOciCharsetDsnParamIsCharset(): void + { + $this->assertSame('charset', TDbDriverCapabilities::getCharsetDsnParam('oci')); + } + + public function testOciCharsetDsnPatternMatchesCharsetParam(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern('oci'); + $this->assertNotNull($pattern); + $this->assertSame(1, preg_match($pattern, ';charset=AL32UTF8', $m)); + $this->assertSame('AL32UTF8', $m[1]); + } + + public function testOciCharsetQuerySqlIsNull(): void + { + // Oracle does not support a simple runtime charset query via PDO. + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql('oci')); + } + + public function testOciGetListTablesSqlContainsUserTables(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('oci'); + $this->assertNotNull($sql); + $this->assertStringContainsString('user_tables', $sql); + } + + public function testOciMetaDataClassName(): void + { + $this->assertSame(TOracleMetaData::class, TDbDriverCapabilities::getMetaDataClass('oci')); + } + + // ----------------------------------------------------------------------- + // Charset resolution + // ----------------------------------------------------------------------- + + public function testOciResolveUtf8ReturnsAl32Utf8(): void + { + $this->assertSame('AL32UTF8', TDbDriverCapabilities::resolveCharset('UTF-8', 'oci')); + } + + public function testOciResolveUtf16ReturnsAl16Utf16(): void + { + $this->assertSame('AL16UTF16', TDbDriverCapabilities::resolveCharset('UTF-16', 'oci')); + } + + public function testOciResolveLatin1ReturnsWe8Iso8859P1(): void + { + $this->assertSame('WE8ISO8859P1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'oci')); + } + + public function testOciResolveLatin2ReturnsEe8Iso8859P2(): void + { + $this->assertSame('EE8ISO8859P2', TDbDriverCapabilities::resolveCharset('ISO-8859-2', 'oci')); + } + + public function testOciResolveAsciiReturnsUs7Ascii(): void + { + $this->assertSame('US7ASCII', TDbDriverCapabilities::resolveCharset('ASCII', 'oci')); + } + + public function testOciResolveWin1250ReturnsEe8Mswin1250(): void + { + $this->assertSame('EE8MSWIN1250', TDbDriverCapabilities::resolveCharset('Windows-1250', 'oci')); + } + + public function testOciResolveWin1251ReturnsCl8Mswin1251(): void + { + $this->assertSame('CL8MSWIN1251', TDbDriverCapabilities::resolveCharset('Windows-1251', 'oci')); + } + + public function testOciResolveWin1252ReturnsWe8Mswin1252(): void + { + $this->assertSame('WE8MSWIN1252', TDbDriverCapabilities::resolveCharset('Windows-1252', 'oci')); + } + + public function testOciResolveKoi8rReturnsCl8Koi8r(): void + { + $this->assertSame('CL8KOI8R', TDbDriverCapabilities::resolveCharset('KOI8-R', 'oci')); + } + + public function testOciResolveKoi8uReturnsCl8Koi8u(): void + { + $this->assertSame('CL8KOI8U', TDbDriverCapabilities::resolveCharset('KOI8-U', 'oci')); + } + + public function testOciUnresolveAl32Utf8ReturnsUtf8Standard(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::unresolveCharset('AL32UTF8', 'oci')); + } + + public function testOciUnresolveWe8Iso8859P1ReturnsLatin1Standard(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::unresolveCharset('WE8ISO8859P1', 'oci')); + } + + // ----------------------------------------------------------------------- + // Live connection — charset (DSN-based, no runtime query) + // + // Oracle configures charset via the DSN 'charset=' parameter only. + // getCharsetQuerySql('oci') returns null, so DatabaseCharset returns the + // driver-resolved form of whatever was passed to TDbConnection (e.g. + // 'UTF-8' → 'AL32UTF8'). This exercises the DSN-injection path. + // ----------------------------------------------------------------------- + + public function testOciDatabaseCharsetReturnsAl32Utf8WhenUtf8Configured(): void + { + $conn = $this->openOci('UTF-8'); + // getCharsetQuerySql is null for oci; getDatabaseCharset() returns + // the driver-resolved charset name that was injected into the DSN. + $this->assertSame('AL32UTF8', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testOciDatabaseCharsetReturnsWe8Iso8859P1WhenLatin1Configured(): void + { + $conn = $this->openOci('ISO-8859-1'); + $this->assertSame('WE8ISO8859P1', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testOciSupportsCharsetFlagMatchesLiveDriver(): void + { + $conn = $this->openOci(); + $this->assertTrue(TDbDriverCapabilities::supportsCharset($conn->getDriverName())); + $conn->Active = false; + } + + public function testOciDoesNotSupportRuntimeCharsetSetLive(): void + { + // supportsRuntimeCharsetSet is false for oci; verify this matches the live driver. + $conn = $this->openOci('UTF-8'); + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet($conn->getDriverName())); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testOciScaffoldInputClass(): void + { + $this->assertSame('TOracleScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('oci')); + } + + public function testOciScaffoldInputFile(): void + { + $this->assertSame('/TOracleScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('oci')); + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testOciMetaDataInstanceIsTOracleMetaData(): void + { + $conn = $this->openOci(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TOracleMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testOciListTablesQueryReturnsArray(): void + { + $conn = $this->openOci(); + $result = $conn->createCommand(TDbDriverCapabilities::getListTablesSql('oci'))->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testOciListTablesQueryReturnsCreatedTable(): void + { + // Oracle stores table names in uppercase in user_tables. + // The capability SQL is: SELECT table_name FROM user_tables. + // PDO/oci may return column keys as TABLE_NAME; normalise to lower-case. + $conn = $this->openOci(); + + try { + $conn->createCommand('DROP TABLE CAPS_OCI_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_OCI_LIST_TEST (ID NUMBER(10) NOT NULL PRIMARY KEY)' + )->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('oci'); + $rows = $conn->createCommand($sql)->queryAll(); + + // Normalise column key casing: pdo_oci may return TABLE_NAME in uppercase. + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'table_name'); + $this->assertContains('CAPS_OCI_LIST_TEST', $names); + + try { + $conn->createCommand('DROP TABLE CAPS_OCI_LIST_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testOciListTablesQueryDoesNotReturnDroppedTable(): void + { + $conn = $this->openOci(); + + try { + $conn->createCommand('DROP TABLE CAPS_OCI_DROPPED_TEST')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_OCI_DROPPED_TEST (ID NUMBER(10) NOT NULL PRIMARY KEY)' + )->execute(); + $conn->createCommand('DROP TABLE CAPS_OCI_DROPPED_TEST')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('oci'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'table_name'); + $this->assertNotContains('CAPS_OCI_DROPPED_TEST', $names); + + $conn->Active = false; + } + + public function testOciListTablesQueryExcludesSystemTables(): void + { + // user_tables only returns tables owned by the current user — not system + // tables from SYS or SYSTEM. + $conn = $this->openOci(); + $sql = TDbDriverCapabilities::getListTablesSql('oci'); + $rows = $conn->createCommand($sql)->queryAll(); + $rows = array_map(fn($r) => array_change_key_case($r, CASE_LOWER), $rows); + $names = array_column($rows, 'table_name'); + // System tables must not leak into user_tables. + $this->assertNotContains('ALL_TABLES', $names); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testOciTransactionCommitSucceeds(): void + { + $conn = $this->openOci(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + public function testOciTransactionRollbackSucceeds(): void + { + $conn = $this->openOci(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // pdo_oci exposes PDO::ATTR_AUTOCOMMIT. TDbConnection::HasAutoCommit is + // true; AutoCommit reads the live session flag and returns true by default. + // ----------------------------------------------------------------------- + + public function testOciHasAutoCommitAttributeViaConnection(): void + { + $conn = $this->openOci(); + $this->assertTrue( + $conn->HasAutoCommit, + 'Oracle (pdo_oci) must report HasAutoCommit = true via TDbConnection.' + ); + $conn->Active = false; + } + + public function testOciAutoCommitIsTrueByDefault(): void + { + $conn = $this->openOci(); + $this->assertTrue( + $conn->AutoCommit, + 'Oracle AutoCommit must be true when no explicit transaction is active.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // + // Oracle DDL auto-commits, so CREATE/DROP TABLE statements execute outside + // any explicit transaction and do not need to be wrapped in one. + // ----------------------------------------------------------------------- + + public function testOciTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openOci(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testOciTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openOci(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testOciTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). Oracle DDL auto-commits. + $conn = $this->openOci(); + + try { + $conn->createCommand('DROP TABLE CAPS_OCI_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->createCommand( + 'CREATE TABLE CAPS_OCI_TX_REUSE (ID NUMBER(10) NOT NULL PRIMARY KEY)' + )->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_OCI_TX_REUSE VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO CAPS_OCI_TX_REUSE VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM CAPS_OCI_TX_REUSE' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + + try { + $conn->createCommand('DROP TABLE CAPS_OCI_TX_REUSE')->execute(); + } catch (\Exception $e) { + } + $conn->Active = false; + } + + public function testOciTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openOci(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testOciGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openOci(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Oracle/TDbMetaDataOracleIntegrationTest.php b/tests/unit/Data/DbSpecific/Oracle/TDbMetaDataOracleIntegrationTest.php new file mode 100644 index 000000000..58a6985a4 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Oracle/TDbMetaDataOracleIntegrationTest.php @@ -0,0 +1,279 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openOracle(); + + // Oracle DDL auto-commits; drop any leftover table first. + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand( + "CREATE TABLE META_TEST (ID NUMBER(10) NOT NULL PRIMARY KEY, NAME VARCHAR2(100) NOT NULL, SCORE BINARY_DOUBLE, NOTE VARCHAR2(100) DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE META_TEST')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsOracleMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TOracleMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->assertSame('META_TEST', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $names = $info->getColumnNames(); + // TOracleMetaData stores column names in lowercase (LOWER(COLUMN_NAME)). + $this->assertContains('id', $names); + $this->assertContains('name', $names); + $this->assertContains('score', $names); + $this->assertContains('note', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('META_TEST'); + $info2 = $meta->getTableInfo('META_TEST'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('NONEXISTENT_TABLE_XYZ'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('id'); + // Oracle NUMBER maps to 'number' in the catalog. + $this->assertStringContainsStringIgnoringCase('number', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('varchar', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('META_TEST'); + // SCORE has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + // Oracle returns uppercase table names. + $this->assertContains('META_TEST', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('META_TEST'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('FOO'); + // TOracleMetaData inherits base TDbMetaData quoting — no delimiters by default. + $this->assertSame('FOO', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('BAR'); + $this->assertSame('BAR', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('BAZ'); + $this->assertSame('BAZ', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/PgsqlInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Pgsql/PgsqlInsertOrIgnoreTest.php new file mode 100644 index 000000000..f7fea8544 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/PgsqlInsertOrIgnoreTest.php @@ -0,0 +1,261 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_on_conflict_do_nothing(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('INSERT INTO', $capturedSql); + $this->assertStringContainsString('ON CONFLICT DO NOTHING', $capturedSql); + $this->assertStringContainsString('"username"', $capturedSql); + $this->assertStringContainsString('"score"', $capturedSql); + $this->assertStringContainsString(':username', $capturedSql); + $this->assertStringContainsString(':score', $capturedSql); + } + + public function test_sql_omits_id_when_not_in_data(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertStringNotContainsString('"id"', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Insert new row + // ----------------------------------------------------------------------- + + public function test_new_row_is_inserted(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_new_row_returns_integer_last_insert_id(): void + { + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_successive_inserts_return_incrementing_ids(): void + { + $id1 = (int) self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $id2 = (int) self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 2]); + $this->assertGreaterThan($id1, $id2); + } + + // ----------------------------------------------------------------------- + // Duplicate silently ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_username_returns_false(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $this->assertFalse($result); + } + + public function test_duplicate_does_not_create_additional_rows(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_duplicate_on_serial_pk_returns_false(): void + { + // Insert with explicit id, then insert same id again + $id = (int) self::$gateway->insert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['id' => $id, 'username' => 'bob', 'score' => 20]); + $this->assertFalse($result); + // Original row still there + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + } + + // ----------------------------------------------------------------------- + // Mixed inserts + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); + + $this->assertFalse($res2); + $this->assertGreaterThan(0, (int) $res3); + $this->assertEquals( + 2, + (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar() + ); + } + + public function test_correct_values_after_mixed_inserts(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 55]); + + $this->assertEquals(10, (int) self::$gateway->find('username = ?', 'alice')['score']); + $this->assertEquals(55, (int) self::$gateway->find('username = ?', 'bob')['score']); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertEquals(1, $captured); + } + + public function test_onexecutecommand_result_is_zero_on_pgsql_conflict(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $captured = $param->getResult(); + }; + $gw->insertOrIgnore(['username' => 'alice', 'score' => 99]); + // ON CONFLICT DO NOTHING returns 0 affected rows + $this->assertEquals(0, $captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Base class throws TDbException + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_insertOrIgnore(): void + { + $meta = new \Prado\Data\Common\Pgsql\TPgsqlMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createInsertOrIgnoreCommand(['username' => 'x', 'score' => 1]); + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/PgsqlTableExistsTest.php b/tests/unit/Data/DbSpecific/Pgsql/PgsqlTableExistsTest.php new file mode 100644 index 000000000..f7ee51b79 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/PgsqlTableExistsTest.php @@ -0,0 +1,107 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + static::$conn->createCommand( + 'DROP TABLE IF EXISTS ' . self::TEMP_TABLE + )->execute(); + } + + protected function tearDown(): void + { + if (static::$conn !== null) { + static::$conn->createCommand( + 'DROP TABLE IF EXISTS ' . self::TEMP_TABLE + )->execute(); + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id SERIAL PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id SERIAL PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/PgsqlUpsertTest.php b/tests/unit/Data/DbSpecific/Pgsql/PgsqlUpsertTest.php new file mode 100644 index 000000000..a75c8e056 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/PgsqlUpsertTest.php @@ -0,0 +1,329 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_on_conflict_do_update_set_with_excluded(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('ON CONFLICT', $capturedSql); + $this->assertStringContainsString('DO UPDATE SET', $capturedSql); + $this->assertStringContainsString('EXCLUDED.', $capturedSql); + } + + public function test_sql_conflict_clause_names_the_conflict_column(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + // ON CONFLICT ("username") — quoted username in conflict clause + $this->assertStringContainsString('"username"', $capturedSql); + } + + public function test_sql_update_set_references_excluded_pseudotable(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + // score updated via EXCLUDED.score + $this->assertStringContainsString('"score" = EXCLUDED."score"', $capturedSql); + } + + public function test_sql_empty_updateData_produces_do_nothing(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], [], ['username']); + $this->assertStringContainsString('DO NOTHING', $capturedSql); + $this->assertStringNotContainsString('DO UPDATE', $capturedSql); + } + + public function test_sql_default_pk_conflict_uses_id(): void + { + // conflictColumns=null → resolves to PK ('id') + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['id' => 1, 'username' => 'test', 'score' => 1], null, null); + $this->assertStringContainsString('"id"', $capturedSql); + $this->assertStringContainsString('ON CONFLICT', $capturedSql); + } + + public function test_sql_explicit_updateData_only_those_columns_in_set(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], ['score' => 1], ['username']); + $setPos = strpos($capturedSql, 'DO UPDATE SET'); + $setPart = substr($capturedSql, (int) $setPos); + $this->assertStringContainsString('"score"', $setPart); + $this->assertStringNotContainsString('"username" = EXCLUDED."username"', $setPart); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_upsert_new_row_returns_integer_id(): void + { + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict on UNIQUE username (explicit conflict col) + // ----------------------------------------------------------------------- + + public function test_conflict_on_unique_username_updates_score(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(99, (int) $row['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict on PK (default conflictColumns, id in data) + // ----------------------------------------------------------------------- + + public function test_conflict_on_pk_with_id_in_data_updates_row(): void + { + $id = (int) self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['id' => $id, 'username' => 'alice', 'score' => 77]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(77, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_updates_only_specified_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(55, (int) $row['score']); + $this->assertEquals('alice', $row['username']); + } + + public function test_null_updateData_updates_all_non_conflict_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 88], + null, + ['username'] + ); + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(88, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → DO NOTHING + // ----------------------------------------------------------------------- + + public function test_empty_updateData_acts_as_insert_or_ignore(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->insert(['username' => 'bob', 'score' => 20]); + + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $bob = self::$gateway->find('username = ?', 'bob'); + $this->assertEquals(20, (int) $bob['score']); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $result = $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Base class throws TDbException + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_upsert(): void + { + $meta = new \Prado\Data\Common\Pgsql\TPgsqlMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createUpsertCommand(['username' => 'x', 'score' => 1]); + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/TDbCommandPgsqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Pgsql/TDbCommandPgsqlIntegrationTest.php new file mode 100644 index 000000000..797b1418e --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/TDbCommandPgsqlIntegrationTest.php @@ -0,0 +1,349 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openPgsql(); + $this->_conn->createCommand( + 'CREATE TABLE IF NOT EXISTS cmd_test (id INT PRIMARY KEY, name VARCHAR(100), score DOUBLE PRECISION, active SMALLINT, note VARCHAR(100))' + )->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE IF EXISTS cmd_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + $this->_conn->createCommand('CREATE TABLE IF NOT EXISTS exec_ddl_test (x INT)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM exec_ddl_test')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE IF EXISTS exec_ddl_test')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO cmd_test VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Carol', $rows[2]['name']); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->queryAll(); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertArrayHasKey('name', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', $row['name']); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('name', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryScalar(); + $this->assertSame('Alice', $scalar); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM cmd_test')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', $cmd->queryScalar()); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', $cmd->queryScalar()); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT id FROM cmd_test WHERE name = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', $cmd->queryScalar()); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', $cmd->queryScalar()); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryRow(); + $this->assertNull($row['note']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $name = $reader->readColumn(1); // second column = name + $this->assertSame('Alice', $name); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = $row['name']; + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT id, name, score FROM cmd_test')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['note']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = id, 1 = name. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('id', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/TDbConnectionCharsetPgsqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Pgsql/TDbConnectionCharsetPgsqlIntegrationTest.php index cedbc6015..e1e9dac2a 100644 --- a/tests/unit/Data/DbSpecific/Pgsql/TDbConnectionCharsetPgsqlIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Pgsql/TDbConnectionCharsetPgsqlIntegrationTest.php @@ -160,4 +160,95 @@ public function testPgsqlGetDatabaseCharsetReflectsCharsetChangedAfterConnect(): $this->assertSame('LATIN1', $conn->DatabaseCharset); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // requiresPostConnectCharset behavioral verification + // + // PostgreSQL has no DSN charset parameter (getCharsetDsnParam('pgsql') = null). + // TDbConnection::applyCharsetToDsn() returns the DSN unchanged for pgsql. + // Instead, TDbConnection::open() calls setConnectionCharset() immediately after + // connecting, which executes SET client_encoding TO ? via a prepared statement. + // This means: + // (a) the raw DSN stored in ConnectionString must NOT contain 'charset' + // (b) the charset IS applied (verified by pg_client_encoding()) + // ----------------------------------------------------------------------- + + public function testPgsqlCharsetIsAppliedPostConnectNotViaDsn(): void + { + // Open with charset set; pgsql has no DSN charset param so applyCharsetToDsn() + // must NOT append charset=... to the raw DSN. + $conn = $this->openPgsql('UTF-8'); + + // (a) Raw DSN string must not contain a charset parameter. + $this->assertStringNotContainsString( + 'charset', + strtolower($conn->getConnectionString()), + 'PostgreSQL DSN must not have a charset parameter — charset is applied post-connect via SQL.' + ); + + // (b) Charset IS applied: pg_client_encoding() must reflect the requested encoding. + $activeEncoding = $this->pgsqlClientEncoding($conn); + $this->assertSame( + 'UTF8', + $activeEncoding, + 'SET client_encoding must have been issued post-connect so pg_client_encoding() reflects it.' + ); + + $conn->Active = false; + } + + public function testPgsqlCharsetAppliedPostConnectForIso88591(): void + { + $conn = $this->openPgsql('ISO-8859-1'); + + $this->assertStringNotContainsString('charset', strtolower($conn->getConnectionString())); + $this->assertSame('LATIN1', $this->pgsqlClientEncoding($conn)); + + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute behavioral verification + // + // PostgreSQL does NOT expose PDO::ATTR_AUTOCOMMIT — pdo_pgsql throws a + // PDOException when the attribute is read or written. TDbConnection reports + // HasAutoCommit = false for pgsql, and AutoCommit access is not applicable. + // ----------------------------------------------------------------------- + + public function testPgsqlHasAutoCommitAttributeIsFalse(): void + { + $conn = $this->openPgsql(); + $this->assertFalse( + $conn->HasAutoCommit, + 'PostgreSQL must report hasAutoCommitAttribute = false (ATTR_AUTOCOMMIT is not supported).' + ); + $conn->Active = false; + } + + public function testPgsqlBeginTransactionSucceedsAndRollbackWorks(): void + { + // pgsql does not expose ATTR_AUTOCOMMIT. Simply verify that + // beginTransaction/rollback work without error. + $conn = $this->openPgsql(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'PostgreSQL beginTransaction must return an active transaction.'); + $conn->rollback(); + $conn->Active = false; + } + + public function testPgsqlSetCharsetUsesParameterisedSql(): void + { + // getCharsetSetSql('pgsql') returns 'SET client_encoding TO ?' — a PDO- + // parameterised statement. TDbConnection executes it via + // $pdo->prepare($sql)->execute([$charset]) so the value is bound, not + // concatenated. Verify the functional outcome. + $conn = $this->openPgsql(); + $conn->Charset = 'UTF-8'; + $this->assertSame( + 'UTF8', + $this->pgsqlClientEncoding($conn), + 'SET client_encoding TO ? must have been executed with \'UTF8\' bound as the parameter.' + ); + $conn->Active = false; + } } diff --git a/tests/unit/Data/DbSpecific/Pgsql/TDbDriverCapabilitiesPgsqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Pgsql/TDbDriverCapabilitiesPgsqlIntegrationTest.php new file mode 100644 index 000000000..97432121c --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/TDbDriverCapabilitiesPgsqlIntegrationTest.php @@ -0,0 +1,503 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openPgsql(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_pgsql')) { + $this->markTestSkipped('pdo_pgsql extension not available.'); + } + $cred = getenv('SCRUTINIZER') ? 'scrutinizer' : 'prado_unitest'; + try { + $conn = new TDbConnection( + 'pgsql:host=localhost;dbname=prado_unitest', + $cred, + $cred, + $charset + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to PostgreSQL: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags + // ----------------------------------------------------------------------- + + public function testPgsqlSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('pgsql')); + } + + public function testPgsqlHasAutoCommitAttributeIsFalse(): void + { + // pdo_pgsql does not expose PDO::ATTR_AUTOCOMMIT; reading or writing it + // throws a PDOException. hasAutoCommitAttribute must return false. + $this->assertFalse(TDbDriverCapabilities::hasAutoCommitAttribute('pgsql')); + } + + + public function testPgsqlRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('pgsql')); + } + + public function testPgsqlRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('pgsql')); + } + + public function testPgsqlSupportsRuntimeCharsetSet(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsRuntimeCharsetSet('pgsql')); + } + + public function testPgsqlRequiresPostConnectCharset(): void + { + // PostgreSQL has no DSN charset parameter. Charset must be applied via + // SET client_encoding immediately after the connection opens. + $this->assertTrue(TDbDriverCapabilities::requiresPostConnectCharset('pgsql')); + } + + public function testPgsqlCharsetSetSqlIsSetClientEncoding(): void + { + $this->assertSame('SET client_encoding TO ?', TDbDriverCapabilities::getCharsetSetSql('pgsql')); + } + + public function testPgsqlCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('pgsql')); + } + + public function testPgsqlCharsetDsnParamIsNull(): void + { + // PostgreSQL has no DSN charset parameter; charset is applied post-connect. + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam('pgsql')); + } + + public function testPgsqlCharsetDsnPatternIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern('pgsql')); + } + + public function testPgsqlCharsetQuerySqlIsPgClientEncoding(): void + { + $this->assertSame('SELECT pg_client_encoding()', TDbDriverCapabilities::getCharsetQuerySql('pgsql')); + } + + public function testPgsqlGetListTablesSqlContainsInformationSchema(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('pgsql'); + $this->assertNotNull($sql); + $this->assertStringContainsString('information_schema.tables', $sql); + } + + public function testPgsqlMetaDataClassName(): void + { + $this->assertSame(TPgsqlMetaData::class, TDbDriverCapabilities::getMetaDataClass('pgsql')); + } + + // ----------------------------------------------------------------------- + // Charset resolution + // ----------------------------------------------------------------------- + + public function testPgsqlResolveUtf8ReturnsUTF8(): void + { + $this->assertSame('UTF8', TDbDriverCapabilities::resolveCharset('UTF-8', 'pgsql')); + } + + public function testPgsqlResolveLatin1ReturnsLATIN1(): void + { + $this->assertSame('LATIN1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'pgsql')); + } + + public function testPgsqlResolveLatin2ReturnsLATIN2(): void + { + $this->assertSame('LATIN2', TDbDriverCapabilities::resolveCharset('ISO-8859-2', 'pgsql')); + } + + public function testPgsqlResolveAsciiReturnsSqlAscii(): void + { + $this->assertSame('SQL_ASCII', TDbDriverCapabilities::resolveCharset('ASCII', 'pgsql')); + } + + public function testPgsqlResolveWin1250ReturnsWIN1250(): void + { + $this->assertSame('WIN1250', TDbDriverCapabilities::resolveCharset('Windows-1250', 'pgsql')); + } + + public function testPgsqlResolveKoi8rReturnsKOI8R(): void + { + $this->assertSame('KOI8R', TDbDriverCapabilities::resolveCharset('KOI8-R', 'pgsql')); + } + + public function testPgsqlUnresolveUTF8ReturnsUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::unresolveCharset('UTF8', 'pgsql')); + } + + public function testPgsqlUnresolveLATIN1ReturnsLatin1Standard(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::unresolveCharset('LATIN1', 'pgsql')); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testPgsqlScaffoldInputClass(): void + { + $this->assertSame('TPgsqlScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('pgsql')); + } + + public function testPgsqlScaffoldInputFile(): void + { + $this->assertSame('/TPgsqlScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('pgsql')); + } + + // ----------------------------------------------------------------------- + // Live connection — charset + // ----------------------------------------------------------------------- + + public function testPgsqlCharsetQuerySqlExecutesAndReturnsUtf8WhenSet(): void + { + $conn = $this->openPgsql('UTF-8'); + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('pgsql')); + $this->assertSame('UTF8', $charset); + $conn->Active = false; + } + + public function testPgsqlCharsetQuerySqlReturnsLATIN1WhenSetToIso88591(): void + { + $conn = $this->openPgsql('ISO-8859-1'); + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('pgsql')); + $this->assertSame('LATIN1', $charset); + $conn->Active = false; + } + + public function testPgsqlDatabaseCharsetReturnsUtf8WhenConfigured(): void + { + $conn = $this->openPgsql('UTF-8'); + $this->assertSame('UTF8', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testPgsqlSetCharsetAfterConnectAppliesNewEncoding(): void + { + $conn = $this->openPgsql(); + $conn->Charset = 'UTF-8'; + $charset = $this->queryScalar($conn, TDbDriverCapabilities::getCharsetQuerySql('pgsql')); + $this->assertSame('UTF8', $charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testPgsqlMetaDataInstanceIsTPgsqlMetaData(): void + { + $conn = $this->openPgsql(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TPgsqlMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testPgsqlListTablesQueryReturnsArray(): void + { + $conn = $this->openPgsql(); + $result = $conn->createCommand(TDbDriverCapabilities::getListTablesSql('pgsql'))->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testPgsqlListTablesQueryReturnsCreatedTable(): void + { + // Create a temporary table in the public schema, verify it appears in the + // information_schema.tables result set, then clean up. The capability SQL + // filters to table_schema = 'public' and table_type = 'BASE TABLE'. + $conn = $this->openPgsql(); + $conn->createCommand('DROP TABLE IF EXISTS caps_pg_list_test')->execute(); + $conn->createCommand('CREATE TABLE caps_pg_list_test (id INT NOT NULL PRIMARY KEY)')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('pgsql'); + $rows = $conn->createCommand($sql)->queryAll(); + + $names = array_column($rows, 'table_name'); + $this->assertContains('caps_pg_list_test', $names); + + $conn->createCommand('DROP TABLE IF EXISTS caps_pg_list_test')->execute(); + $conn->Active = false; + } + + public function testPgsqlListTablesQueryDoesNotReturnDroppedTable(): void + { + $conn = $this->openPgsql(); + $conn->createCommand('DROP TABLE IF EXISTS caps_pg_dropped_test')->execute(); + $conn->createCommand('CREATE TABLE caps_pg_dropped_test (id INT NOT NULL PRIMARY KEY)')->execute(); + $conn->createCommand('DROP TABLE caps_pg_dropped_test')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('pgsql'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_column($rows, 'table_name'); + $this->assertNotContains('caps_pg_dropped_test', $names); + + $conn->Active = false; + } + + public function testPgsqlListTablesQueryExcludesViews(): void + { + // Views must not appear — the SQL filters to table_type = 'BASE TABLE'. + $conn = $this->openPgsql(); + $conn->createCommand('CREATE OR REPLACE VIEW caps_pg_view_test AS SELECT 1 AS n')->execute(); + + $sql = TDbDriverCapabilities::getListTablesSql('pgsql'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_column($rows, 'table_name'); + $this->assertNotContains('caps_pg_view_test', $names); + + $conn->createCommand('DROP VIEW IF EXISTS caps_pg_view_test')->execute(); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testPgsqlTransactionCommitPersistsData(): void + { + $conn = $this->openPgsql(); + $conn->createCommand('CREATE TABLE IF NOT EXISTS caps_pg_tx (id INT PRIMARY KEY)')->execute(); + $conn->createCommand('DELETE FROM caps_pg_tx')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_pg_tx VALUES (1)')->execute(); + $tx->commit(); + + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM caps_pg_tx'); + $this->assertSame(1, $count); + $conn->createCommand('DROP TABLE caps_pg_tx')->execute(); + $conn->Active = false; + } + + public function testPgsqlTransactionRollbackDiscardsData(): void + { + $conn = $this->openPgsql(); + $conn->createCommand('CREATE TABLE IF NOT EXISTS caps_pg_tx2 (id INT PRIMARY KEY)')->execute(); + $conn->createCommand('DELETE FROM caps_pg_tx2')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_pg_tx2 VALUES (1)')->execute(); + $tx->rollBack(); + + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM caps_pg_tx2'); + $this->assertSame(0, $count); + $conn->createCommand('DROP TABLE caps_pg_tx2')->execute(); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // pdo_pgsql does not expose PDO::ATTR_AUTOCOMMIT (reading it throws a + // PDOException). TDbConnection::HasAutoCommit returns false; AutoCommit + // returns false gracefully without attempting to read the absent attribute. + // ----------------------------------------------------------------------- + + public function testPgsqlHasNoAutoCommitAttributeViaConnection(): void + { + $conn = $this->openPgsql(); + $this->assertFalse( + $conn->HasAutoCommit, + 'PostgreSQL must report HasAutoCommit = false via TDbConnection.' + ); + $conn->Active = false; + } + + public function testPgsqlAutoCommitReturnsFalseWhenAttributeAbsent(): void + { + // TDbConnection::getAutoCommit() returns false when hasAutoCommitAttribute + // is false, without attempting to read PDO::ATTR_AUTOCOMMIT from pdo_pgsql. + $conn = $this->openPgsql(); + $this->assertFalse( + $conn->AutoCommit, + 'PostgreSQL AutoCommit must return false when the attribute is not supported.' + ); + $conn->Active = false; + } + + public function testPgsqlRawAutoCommitAttributeThrows(): void + { + // Directly reading PDO::ATTR_AUTOCOMMIT on a pgsql connection throws a + // PDOException, confirming that TDbDriverCapabilities correctly marks + // pgsql as hasAutoCommitAttribute = false so TDbConnection never reads it. + $conn = $this->openPgsql(); + $this->expectException(\PDOException::class); + $conn->getPdoInstance()->getAttribute(\PDO::ATTR_AUTOCOMMIT); + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // ----------------------------------------------------------------------- + + public function testPgsqlTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openPgsql(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testPgsqlTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openPgsql(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testPgsqlTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). + $conn = $this->openPgsql(); + $conn->createCommand( + 'CREATE TABLE IF NOT EXISTS caps_pgsql_tx_reuse (id INT PRIMARY KEY)' + )->execute(); + $conn->createCommand('DELETE FROM caps_pgsql_tx_reuse')->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_pgsql_tx_reuse VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO caps_pgsql_tx_reuse VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM caps_pgsql_tx_reuse' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + $conn->createCommand('DROP TABLE caps_pgsql_tx_reuse')->execute(); + $conn->Active = false; + } + + public function testPgsqlTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openPgsql(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testPgsqlGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openPgsql(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Pgsql/TDbMetaDataPgsqlIntegrationTest.php b/tests/unit/Data/DbSpecific/Pgsql/TDbMetaDataPgsqlIntegrationTest.php new file mode 100644 index 000000000..a56236342 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Pgsql/TDbMetaDataPgsqlIntegrationTest.php @@ -0,0 +1,270 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openPgsql(); + $this->_conn->createCommand('DROP TABLE IF EXISTS meta_test')->execute(); + $this->_conn->createCommand( + "CREATE TABLE meta_test (id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, score DOUBLE PRECISION, note VARCHAR(100) DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE IF EXISTS meta_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsPgsqlMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TPgsqlMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertSame('meta_test', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $names = $info->getColumnNames(); + $this->assertContains('"id"', $names); + $this->assertContains('"name"', $names); + $this->assertContains('"score"', $names); + $this->assertContains('"note"', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('meta_test'); + $info2 = $meta->getTableInfo('meta_test'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('nonexistent_table_xyz'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + // PostgreSQL SERIAL resolves to int4 / integer in the catalog. + $this->assertStringContainsStringIgnoringCase('int', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + // PostgreSQL may report 'character varying' or 'varchar'. + $this->assertMatchesRegularExpression('/varchar|character varying/i', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + // score has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('meta_test'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('foo'); + // PostgreSQL uses double-quote quoting. + $this->assertSame('"foo"', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('bar'); + $this->assertSame('"bar"', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('baz'); + $this->assertSame('"baz"', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Mssql/CommandBuilderMssqlTest.php b/tests/unit/Data/DbSpecific/SqlSrv/CommandBuilderSqlSrvTest.php similarity index 92% rename from tests/unit/Data/DbSpecific/Mssql/CommandBuilderMssqlTest.php rename to tests/unit/Data/DbSpecific/SqlSrv/CommandBuilderSqlSrvTest.php index 163495fe4..cf02b4d1a 100644 --- a/tests/unit/Data/DbSpecific/Mssql/CommandBuilderMssqlTest.php +++ b/tests/unit/Data/DbSpecific/SqlSrv/CommandBuilderSqlSrvTest.php @@ -1,8 +1,8 @@ 'SELECT username, age FROM accounts', @@ -14,7 +14,7 @@ class CommandBuilderMssqlTest extends PHPUnit\Framework\TestCase public function test_limit() { - $builder = new TMssqlCommandBuilder(); + $builder = new TSqlSrvCommandBuilder(); $sql = $builder->applyLimitOffset(self::$sql['simple'], 3); $expect = 'SELECT TOP 3 username, age FROM accounts'; diff --git a/tests/unit/Data/DbSpecific/Mssql/MssqlColumnTest.php b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvColumnTest.php similarity index 97% rename from tests/unit/Data/DbSpecific/Mssql/MssqlColumnTest.php rename to tests/unit/Data/DbSpecific/SqlSrv/SqlSrvColumnTest.php index df651aedc..6021ee4f7 100644 --- a/tests/unit/Data/DbSpecific/Mssql/MssqlColumnTest.php +++ b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvColumnTest.php @@ -2,11 +2,11 @@ require_once(__DIR__ . '/../../../PradoUnit.php'); -use Prado\Data\Common\Mssql\TMssqlMetaData; +use Prado\Data\Common\SqlSrv\TSqlSrvMetaData; use Prado\Data\Common\TDbTableColumn; use Prado\Data\DataGateway\TTableGateway; -class MssqlColumnTest extends PHPUnit\Framework\TestCase +class SqlSrvColumnTest extends PHPUnit\Framework\TestCase { use PradoUnitDataConnectionTrait; @@ -15,7 +15,7 @@ class MssqlColumnTest extends PHPUnit\Framework\TestCase protected function getPradoUnitSetup(): ?string { - return 'setupMssqlConnection'; + return 'setupSqlSrvConnection'; } protected function getDatabaseName(): ?string @@ -34,7 +34,7 @@ protected function setUp(): void $conn = $this->setUpConnection(); if ($conn instanceof TDbConnection) { static::$msConn = $conn; - static::$msMetaData = new TMssqlMetaData($conn); + static::$msMetaData = new TSqlSrvMetaData($conn); } } } @@ -44,7 +44,7 @@ public function get_conn(): TDbConnection return static::$msConn; } - public function meta_data(): TMssqlMetaData + public function meta_data(): TSqlSrvMetaData { return static::$msMetaData; } diff --git a/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvInsertOrIgnoreTest.php new file mode 100644 index 000000000..8c1c818c0 --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvInsertOrIgnoreTest.php @@ -0,0 +1,284 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM [upsert_test]')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + // No transaction started — must throw + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation (requires transaction to build the command) + // ----------------------------------------------------------------------- + + public function test_sql_uses_merge_when_not_matched_then_insert(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + } + + public function test_sql_merge_on_clause_uses_pk_column(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + // upsert_test PK is username → ON clause references [username] + $this->assertStringContainsString('[username]', $capturedSql); + } + + public function test_sql_uses_as_alias_keywords(): void + { + // MSSQL MERGE uses AS t / AS s aliases (useAsAlias=true) + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringContainsString(' AS t ', $capturedSql); + $this->assertStringContainsString(' AS s ', $capturedSql); + } + + public function test_sql_using_select_has_no_from_dual(): void + { + // MSSQL uses USING (SELECT ...) — no FROM dual/SYSIBM source + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + $this->assertStringNotContainsString('DUAL', $capturedSql); + $this->assertStringNotContainsString('SYSIBM', $capturedSql); + $this->assertStringNotContainsString('RDB$', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert within transaction + // ----------------------------------------------------------------------- + + public function test_new_row_inserted_within_transaction(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_new_row_returns_true_for_natural_key_table(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + // Natural key table (no identity) → getLastInsertID()=null → returns true + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: duplicate ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_pk_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertFalse($result); + } + + public function test_duplicate_does_not_increase_row_count(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM [upsert_test]')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_unchanged_after_ignored_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Mixed inserts + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_ignored_others_inserted(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); + $txn->commit(); + + $this->assertFalse($res2); + $this->assertTrue($res3); + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM [upsert_test]')->queryScalar(); + $this->assertEquals(2, $count); + } + + public function test_transaction_rollback_undoes_insert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM [upsert_test]')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvTableExistsTest.php b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvTableExistsTest.php new file mode 100644 index 000000000..1ecb0579c --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvTableExistsTest.php @@ -0,0 +1,110 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + static::$conn->createCommand( + "IF OBJECT_ID('dbo." . self::TEMP_TABLE . "', 'U') IS NOT NULL DROP TABLE dbo." . self::TEMP_TABLE + )->execute(); + } + + protected function tearDown(): void + { + if (static::$conn !== null) { + static::$conn->createCommand( + "IF OBJECT_ID('dbo." . self::TEMP_TABLE . "', 'U') IS NOT NULL DROP TABLE dbo." . self::TEMP_TABLE + )->execute(); + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE dbo.' . self::TEMP_TABLE . ' (id INT NOT NULL IDENTITY(1,1) PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE dbo.' . self::TEMP_TABLE . ' (id INT NOT NULL IDENTITY(1,1) PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE dbo.' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvUpsertTest.php b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvUpsertTest.php new file mode 100644 index 000000000..d0bb725b1 --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/SqlSrvUpsertTest.php @@ -0,0 +1,325 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM [upsert_test]')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // Transaction requirement + // ----------------------------------------------------------------------- + + public function test_throws_TDbException_without_active_transaction(): void + { + $this->expectException(TDbException::class); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_contains_merge_when_matched_and_when_not_matched(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('MERGE INTO', $capturedSql); + $this->assertStringContainsString('WHEN MATCHED THEN UPDATE SET', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + public function test_sql_update_set_contains_non_pk_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], null, null); + $txn->rollback(); + // PK = username → updateData = {score} + $matchedPos = strpos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertStringContainsString('[score]', $updatePart); + // username is PK, not in UPDATE SET + $this->assertStringNotContainsString('t.[username] = s.username', $updatePart); + } + + public function test_sql_explicit_updateData_only_those_columns_updated(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], ['score' => 10], ['username']); + $txn->rollback(); + $matchedPos = strpos($capturedSql, 'WHEN MATCHED'); + $updatePart = substr($capturedSql, (int) $matchedPos); + $this->assertStringContainsString('[score]', $updatePart); + } + + public function test_sql_empty_updateData_omits_when_matched_branch(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 10], [], ['username']); + $txn->rollback(); + $this->assertStringNotContainsString('WHEN MATCHED', $capturedSql); + $this->assertStringContainsString('WHEN NOT MATCHED THEN INSERT', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals('alice', $lc['username']); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_upsert_new_row_returns_true(): void + { + $txn = self::$conn->beginTransaction(); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->commit(); + + $this->assertTrue($result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict → update + // ----------------------------------------------------------------------- + + public function test_conflict_on_pk_updates_non_pk_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(99, (int) $lc['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM [upsert_test]')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_update_returns_truthy_value(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 55], + ['score' => 55], + ['username'] + ); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(55, (int) $lc['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → insert-or-ignore behaviour + // ----------------------------------------------------------------------- + + public function test_empty_updateData_does_not_update_on_conflict(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $row = self::$gateway->find('username = ?', 'alice'); + $lc = array_change_key_case($row, CASE_LOWER); + $this->assertEquals(10, (int) $lc['score']); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $txn->commit(); + + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['username' => 'bob', 'score' => 20]); + self::$gateway->upsert(['username' => 'alice', 'score' => 99]); + $txn->commit(); + + $bob = self::$gateway->find('username = ?', 'bob'); + $lc = array_change_key_case($bob, CASE_LOWER); + $this->assertEquals(20, (int) $lc['score']); + } + + public function test_transaction_rollback_undoes_upsert(): void + { + $txn = self::$conn->beginTransaction(); + self::$gateway->upsert(['username' => 'alice', 'score' => 10]); + $txn->rollback(); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM [upsert_test]')->queryScalar(); + $this->assertEquals(0, $count); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertTrue($fired); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $txn = self::$conn->beginTransaction(); + $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $txn = self::$conn->beginTransaction(); + $result = $gw->upsert(['username' => 'alice', 'score' => 1]); + $txn->rollback(); + + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/SqlSrv/TDbCommandSqlSrvIntegrationTest.php b/tests/unit/Data/DbSpecific/SqlSrv/TDbCommandSqlSrvIntegrationTest.php new file mode 100644 index 000000000..d2fd82b4b --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/TDbCommandSqlSrvIntegrationTest.php @@ -0,0 +1,370 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openSqlSrv(); + + // Drop table if it exists from a previous run, then create fresh. + try { + $this->_conn->createCommand( + "IF OBJECT_ID('cmd_test', 'U') IS NOT NULL DROP TABLE cmd_test" + )->execute(); + } catch (\Exception $e) { + } + try { + $this->_conn->createCommand( + 'CREATE TABLE cmd_test (id INT PRIMARY KEY, name NVARCHAR(100), score FLOAT, active BIT, note NVARCHAR(100))' + )->execute(); + } catch (\Exception $e) { + $this->markTestSkipped('Cannot create cmd_test table: ' . $e->getMessage()); + } + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand( + "IF OBJECT_ID('cmd_test', 'U') IS NOT NULL DROP TABLE cmd_test" + )->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + try { + $this->_conn->createCommand( + "IF OBJECT_ID('exec_ddl_test', 'U') IS NOT NULL DROP TABLE exec_ddl_test" + )->execute(); + } catch (\Exception $e) { + } + $this->_conn->createCommand('CREATE TABLE exec_ddl_test (x INT)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM exec_ddl_test')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE exec_ddl_test')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO cmd_test VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Carol', $rows[2]['name']); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->queryAll(); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertArrayHasKey('name', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', $row['name']); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('name', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryScalar(); + $this->assertSame('Alice', $scalar); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM cmd_test')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', $cmd->queryScalar()); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', $cmd->queryScalar()); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT id FROM cmd_test WHERE name = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', $cmd->queryScalar()); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', $cmd->queryScalar()); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryRow(); + $this->assertNull($row['note']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $name = $reader->readColumn(1); // second column = name + $this->assertSame('Alice', $name); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = $row['name']; + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT id, name, score FROM cmd_test')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['note']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = id, 1 = name. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('id', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Mssql/TDbConnectionCharsetMssqlIntegrationTest.php b/tests/unit/Data/DbSpecific/SqlSrv/TDbConnectionCharsetSqlSrvIntegrationTest.php similarity index 56% rename from tests/unit/Data/DbSpecific/Mssql/TDbConnectionCharsetMssqlIntegrationTest.php rename to tests/unit/Data/DbSpecific/SqlSrv/TDbConnectionCharsetSqlSrvIntegrationTest.php index 187e3a45d..09af62343 100644 --- a/tests/unit/Data/DbSpecific/Mssql/TDbConnectionCharsetMssqlIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/SqlSrv/TDbConnectionCharsetSqlSrvIntegrationTest.php @@ -24,13 +24,13 @@ * TrustServerCertificate=yes is required for ODBC Driver 18+ which enforces * encrypted connections and rejects self-signed certificates. */ -class TDbConnectionCharsetMssqlIntegrationTest extends PHPUnit\Framework\TestCase +class TDbConnectionCharsetSqlSrvIntegrationTest extends PHPUnit\Framework\TestCase { use PradoUnitDataConnectionTrait; protected function getPradoUnitSetup(): ?string { - return 'setupMssqlConnection'; + return 'setupSqlSrvConnection'; } protected function getDatabaseName(): ?string @@ -81,7 +81,7 @@ private function queryScalar(TDbConnection $conn, string $sql): mixed * Open a SQL Server connection without a CharacterSet in the DSN, so that * the Charset property is the sole source of encoding negotiation. */ - private function openMssql(string $charset = ''): TDbConnection + private function openSqlSrv(string $charset = ''): TDbConnection { if (!extension_loaded('pdo_sqlsrv')) { $this->markTestSkipped('pdo_sqlsrv extension not available.'); @@ -98,28 +98,28 @@ private function openMssql(string $charset = ''): TDbConnection // Tests — charset injected into DSN via applyCharsetToDsn() // ----------------------------------------------------------------------- - public function testMssqlUtf8ResolvedAndInjectedIntoDsn(): void + public function testSqlSrvUtf8ResolvedAndInjectedIntoDsn(): void { // 'UTF-8' → resolveCharsetForDriver() → 'UTF-8' for sqlsrv. // applyCharsetToDsn() appends CharacterSet=UTF-8 to the DSN. - $conn = $this->openMssql('UTF-8'); + $conn = $this->openSqlSrv('UTF-8'); $this->assertTrue($conn->Active); $conn->Active = false; } - public function testMssqlDriverSpecificNamePassesThrough(): void + public function testSqlSrvDriverSpecificNamePassesThrough(): void { // 'UTF-8' is both the universal name and the sqlsrv-resolved name. - $conn = $this->openMssql('UTF-8'); + $conn = $this->openSqlSrv('UTF-8'); $this->assertTrue($conn->Active); $conn->Active = false; } - public function testMssqlSetCharsetAfterConnectThrowsException(): void + public function testSqlSrvSetCharsetAfterConnectThrowsException(): void { // For sqlsrv, charset is DSN-only; setting it after connect is a no-op // at the server level (no SQL command is sent). The connection stays active. - $conn = $this->openMssql(); + $conn = $this->openSqlSrv(); $this->expectException(TDbException::class); $conn->Charset = 'UTF-8'; } @@ -128,20 +128,70 @@ public function testMssqlSetCharsetAfterConnectThrowsException(): void // getDatabaseCharset() — falls back to resolveCharsetForDriver() for sqlsrv // ----------------------------------------------------------------------- - public function testMssqlGetDatabaseCharsetReturnsResolvedCharset(): void + public function testSqlSrvGetDatabaseCharsetReturnsResolvedCharset(): void { // sqlsrv has no live-query path; getDatabaseCharset() returns // resolveCharsetForDriver('UTF-8', 'sqlsrv') = 'UTF-8'. - $conn = $this->openMssql('UTF-8'); + $conn = $this->openSqlSrv('UTF-8'); $this->assertSame('UTF-8', $conn->DatabaseCharset); $conn->Active = false; } - public function testMssqlGetDatabaseCharsetReturnsResolvedIso88591(): void + public function testSqlSrvUnsupportedCharsetClearedAfterConnect(): void { - // 'ISO-8859-1' → resolves to 'ISO-8859-1' for sqlsrv (mssql/dblib charset name). - $conn = $this->openMssql('ISO-8859-1'); - $this->assertSame('ISO-8859-1', $conn->DatabaseCharset); + // pdo_sqlsrv only accepts 'UTF-8' or 'SQLSRV_ENC_CHAR' in CharacterSet=. + // 'ISO-8859-1' is not in the allowlist, so applyCharsetToDsn() skips injection + // and open() clears the Charset property to '' so getDatabaseCharset() reports + // the true connection state (system default, not the unmet user intent). + $conn = $this->openSqlSrv('ISO-8859-1'); + $this->assertSame('', $conn->Charset, 'Charset property must be cleared when the requested charset cannot be applied.'); + $this->assertSame('', $conn->DatabaseCharset, 'DatabaseCharset must reflect the actual connection state, not unmet intent.'); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute behavioral verification + // + // SQL Server (sqlsrv/dblib) does NOT expose PDO::ATTR_AUTOCOMMIT — the driver + // throws a PDOException when the attribute is read or written. TDbConnection + // reports HasAutoCommit = false for sqlsrv/dblib. + // ----------------------------------------------------------------------- + + public function testSqlSrvHasAutoCommitAttributeIsFalse(): void + { + $conn = $this->openSqlSrv(); + $this->assertFalse( + $conn->HasAutoCommit, + 'SQL Server (sqlsrv) must report hasAutoCommitAttribute = false (ATTR_AUTOCOMMIT is not supported).' + ); + $conn->Active = false; + } + + public function testSqlSrvBeginTransactionSucceedsAndRollbackWorks(): void + { + // sqlsrv does not expose ATTR_AUTOCOMMIT. Simply verify that + // beginTransaction/rollback work without error. + $conn = $this->openSqlSrv(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive(), 'SQL Server beginTransaction must return an active transaction.'); + $conn->rollback(); + $conn->Active = false; + } + + public function testSqlSrvCharsetInjectedIntoDsnWithCharacterSetParam(): void + { + // applyCharsetToDsn() appends ;CharacterSet=UTF-8 for sqlsrv (not lowercase 'charset'). + // After connecting, the raw ConnectionString (before applyCharsetToDsn) must not + // contain the injected param; the connection must succeed, proving the DSN was built. + $conn = $this->openSqlSrv('UTF-8'); + $this->assertTrue($conn->Active); + // The raw DSN does not contain the injected segment (applyCharsetToDsn builds + // a modified copy; _dsn is never mutated). + $this->assertStringNotContainsString( + 'CharacterSet', + $conn->getConnectionString(), + 'The raw stored DSN must not contain CharacterSet; only the copy passed to PDO does.' + ); $conn->Active = false; } } diff --git a/tests/unit/Data/DbSpecific/SqlSrv/TDbDriverCapabilitiesSqlSrvIntegrationTest.php b/tests/unit/Data/DbSpecific/SqlSrv/TDbDriverCapabilitiesSqlSrvIntegrationTest.php new file mode 100644 index 000000000..f797aba37 --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/TDbDriverCapabilitiesSqlSrvIntegrationTest.php @@ -0,0 +1,576 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openSqlsrv(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_sqlsrv')) { + $this->markTestSkipped('pdo_sqlsrv extension not available.'); + } + try { + $conn = new TDbConnection( + 'sqlsrv:Server=localhost,1433;TrustServerCertificate=yes', + 'prado_unitest', + 'prado_unitest', + $charset + ); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot connect to SQL Server: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags — sqlsrv + // ----------------------------------------------------------------------- + + public function testSqlsrvSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('sqlsrv')); + } + + public function testSqlsrvHasAutoCommitAttributeIsFalse(): void + { + // sqlsrv does not expose PDO::ATTR_AUTOCOMMIT; reading or writing it + // throws a PDOException. hasAutoCommitAttribute must return false. + $this->assertFalse(TDbDriverCapabilities::hasAutoCommitAttribute('sqlsrv')); + } + + + public function testSqlsrvRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('sqlsrv')); + } + + public function testSqlsrvRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('sqlsrv')); + } + + public function testSqlsrvDoesNotSupportRuntimeCharsetSet(): void + { + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('sqlsrv')); + } + + public function testSqlsrvRequiresNoPostConnectCharset(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('sqlsrv')); + } + + public function testSqlsrvCharsetSetSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('sqlsrv')); + } + + public function testSqlsrvCharsetPragmaSqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql('sqlsrv')); + } + + public function testSqlsrvCharsetDsnParamIsCharacterSet(): void + { + // sqlsrv uses 'CharacterSet' (capital C, capital S) in the DSN. + $this->assertSame('CharacterSet', TDbDriverCapabilities::getCharsetDsnParam('sqlsrv')); + } + + public function testSqlsrvCharsetDsnPatternMatchesCharacterSetParam(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern('sqlsrv'); + $this->assertNotNull($pattern); + $this->assertSame(1, preg_match($pattern, ';CharacterSet=UTF-8', $m)); + $this->assertSame('UTF-8', $m[1]); + } + + public function testSqlsrvCharsetQuerySqlIsNull(): void + { + // No runtime charset query is available for MSSQL. + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql('sqlsrv')); + } + + public function testSqlsrvGetListTablesSqlContainsInformationSchema(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('sqlsrv'); + $this->assertNotNull($sql); + $this->assertStringContainsString('INFORMATION_SCHEMA.TABLES', $sql); + } + + public function testSqlsrvMetaDataClassName(): void + { + $this->assertSame(TSqlSrvMetaData::class, TDbDriverCapabilities::getMetaDataClass('sqlsrv')); + } + + // ----------------------------------------------------------------------- + // Static capability flags — dblib (mirrors sqlsrv except for charset param) + // ----------------------------------------------------------------------- + + public function testDblibSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('dblib')); + } + + public function testDblibHasAutoCommitAttributeIsFalse(): void + { + // dblib does not expose PDO::ATTR_AUTOCOMMIT; reading or writing it + // throws a PDOException. hasAutoCommitAttribute must return false. + $this->assertFalse(TDbDriverCapabilities::hasAutoCommitAttribute('dblib')); + } + + + public function testDblibRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('dblib')); + } + + public function testDblibRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('dblib')); + } + + public function testDblibDoesNotSupportRuntimeCharsetSet(): void + { + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet('dblib')); + } + + public function testDblibCharsetDsnParamIsCharset(): void + { + // dblib uses lowercase 'charset', unlike sqlsrv which uses 'CharacterSet'. + $this->assertSame('charset', TDbDriverCapabilities::getCharsetDsnParam('dblib')); + } + + public function testDblibCharsetQuerySqlIsNull(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql('dblib')); + } + + public function testDblibGetListTablesSqlMatchesSqlsrv(): void + { + $this->assertSame( + TDbDriverCapabilities::getListTablesSql('sqlsrv'), + TDbDriverCapabilities::getListTablesSql('dblib') + ); + } + + public function testDblibMetaDataClassNameMatchesSqlsrv(): void + { + $this->assertSame(TSqlSrvMetaData::class, TDbDriverCapabilities::getMetaDataClass('dblib')); + } + + // ----------------------------------------------------------------------- + // Charset resolution — sqlsrv + // ----------------------------------------------------------------------- + + public function testSqlsrvResolveUtf8ReturnsUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('UTF-8', 'sqlsrv')); + } + + public function testSqlsrvResolveLatin1ReturnsIso88591(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'sqlsrv')); + } + + public function testSqlsrvResolveAsciiReturnsUsAscii(): void + { + // sqlsrv has no ASCII entry; resolveCharset normalizes to the IANA canonical name. + $this->assertSame('US-ASCII', TDbDriverCapabilities::resolveCharset('ASCII', 'sqlsrv')); + $this->assertSame('US-ASCII', TDbDriverCapabilities::resolveCharset('US-ASCII', 'sqlsrv')); + } + + public function testSqlsrvResolveWin1250ReturnsIanaName(): void + { + // sqlsrv has no Windows-125x entry; resolveCharset normalizes to the IANA canonical name. + $this->assertSame('windows-1250', TDbDriverCapabilities::resolveCharset('Windows-1250', 'sqlsrv')); + $this->assertSame('windows-1250', TDbDriverCapabilities::resolveCharset('windows-1250', 'sqlsrv')); + } + + public function testSqlsrvUnresolveUtf8ReturnsUtf8Standard(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::unresolveCharset('UTF-8', 'sqlsrv')); + } + + // ----------------------------------------------------------------------- + // Charset resolution — dblib + // ----------------------------------------------------------------------- + + public function testDblibResolveUtf8ReturnsUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('UTF-8', 'dblib')); + } + + public function testDblibResolveLatin1ReturnsIso88591(): void + { + $this->assertSame('ISO-8859-1', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'dblib')); + } + + public function testDblibResolveKoi8rReturnsKoi8R(): void + { + $this->assertSame('KOI8-R', TDbDriverCapabilities::resolveCharset('KOI8-R', 'dblib')); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testSqlsrvScaffoldInputClass(): void + { + $this->assertSame('TSqlSrvScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('sqlsrv')); + } + + public function testSqlsrvScaffoldInputFile(): void + { + $this->assertSame('/TSqlSrvScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('sqlsrv')); + } + + public function testDblibScaffoldInputMatchesSqlsrv(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputClass('sqlsrv'), + TDbDriverCapabilities::getScaffoldInputClass('dblib') + ); + } + + // ----------------------------------------------------------------------- + // Live connection — charset (DSN-based, no runtime query) + // + // MSSQL (sqlsrv) configures charset via the DSN 'CharacterSet=' parameter + // only. getCharsetQuerySql('sqlsrv') returns null, so DatabaseCharset + // returns the driver-resolved form of the configured charset (for sqlsrv, + // the canonical name is passed through unchanged — 'UTF-8' stays 'UTF-8'). + // ----------------------------------------------------------------------- + + public function testSqlsrvDatabaseCharsetReturnsUtf8WhenConfigured(): void + { + $conn = $this->openSqlsrv('UTF-8'); + // getCharsetQuerySql is null for sqlsrv; getDatabaseCharset() returns + // the driver-resolved charset injected into the DSN CharacterSet= param. + $this->assertSame('UTF-8', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testSqlsrvSupportsCharsetFlagMatchesLiveDriver(): void + { + $conn = $this->openSqlsrv(); + $this->assertTrue(TDbDriverCapabilities::supportsCharset($conn->getDriverName())); + $conn->Active = false; + } + + public function testSqlsrvDoesNotSupportRuntimeCharsetSetLive(): void + { + // supportsRuntimeCharsetSet is false for sqlsrv; verify against live driver. + $conn = $this->openSqlsrv('UTF-8'); + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet($conn->getDriverName())); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testSqlsrvMetaDataInstanceIsTSqlSrvMetaData(): void + { + $conn = $this->openSqlsrv(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TSqlSrvMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testSqlsrvListTablesQueryReturnsArray(): void + { + $conn = $this->openSqlsrv(); + $result = $conn->createCommand(TDbDriverCapabilities::getListTablesSql('sqlsrv'))->queryAll(); + $this->assertIsArray($result); + $conn->Active = false; + } + + public function testSqlsrvListTablesQueryReturnsCreatedTable(): void + { + // Create a temporary table, run the INFORMATION_SCHEMA.TABLES query, verify + // the name appears, then clean up. sqlsrv stores table names case-insensitively. + // Skipped automatically when the connected user lacks DDL permissions (e.g. master db). + $conn = $this->openSqlsrv(); + try { + $conn->createCommand('IF OBJECT_ID(\'caps_mssql_list_test\',\'U\') IS NOT NULL DROP TABLE caps_mssql_list_test')->execute(); + $conn->createCommand('CREATE TABLE caps_mssql_list_test (id INT NOT NULL PRIMARY KEY)')->execute(); + } catch (\Exception $e) { + $conn->Active = false; + $this->markTestSkipped('DDL not permitted on this SQL Server connection: ' . $e->getMessage()); + } + + $sql = TDbDriverCapabilities::getListTablesSql('sqlsrv'); + $rows = $conn->createCommand($sql)->queryAll(); + + // INFORMATION_SCHEMA.TABLES returns TABLE_NAME column. + $names = array_map('strtolower', array_column($rows, 'TABLE_NAME')); + $this->assertContains('caps_mssql_list_test', $names); + + $conn->createCommand('DROP TABLE caps_mssql_list_test')->execute(); + $conn->Active = false; + } + + public function testSqlsrvListTablesQueryExcludesViews(): void + { + // The capability SQL filters TABLE_TYPE = 'BASE TABLE'; views must not appear. + // Skipped automatically when the connected user lacks DDL permissions (e.g. master db). + $conn = $this->openSqlsrv(); + try { + $conn->createCommand('IF OBJECT_ID(\'caps_mssql_view_test\',\'V\') IS NOT NULL DROP VIEW caps_mssql_view_test')->execute(); + $conn->createCommand('CREATE VIEW caps_mssql_view_test AS SELECT 1 AS n')->execute(); + } catch (\Exception $e) { + $conn->Active = false; + $this->markTestSkipped('DDL not permitted on this SQL Server connection: ' . $e->getMessage()); + } + + $sql = TDbDriverCapabilities::getListTablesSql('sqlsrv'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_map('strtolower', array_column($rows, 'TABLE_NAME')); + $this->assertNotContains('caps_mssql_view_test', $names); + + $conn->createCommand('DROP VIEW caps_mssql_view_test')->execute(); + $conn->Active = false; + } + + public function testSqlsrvListTablesQueryDoesNotReturnDroppedTable(): void + { + // Skipped automatically when the connected user lacks DDL permissions (e.g. master db). + $conn = $this->openSqlsrv(); + try { + $conn->createCommand('IF OBJECT_ID(\'caps_mssql_dropped_test\',\'U\') IS NOT NULL DROP TABLE caps_mssql_dropped_test')->execute(); + $conn->createCommand('CREATE TABLE caps_mssql_dropped_test (id INT NOT NULL PRIMARY KEY)')->execute(); + $conn->createCommand('DROP TABLE caps_mssql_dropped_test')->execute(); + } catch (\Exception $e) { + $conn->Active = false; + $this->markTestSkipped('DDL not permitted on this SQL Server connection: ' . $e->getMessage()); + } + + $sql = TDbDriverCapabilities::getListTablesSql('sqlsrv'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_map('strtolower', array_column($rows, 'TABLE_NAME')); + $this->assertNotContains('caps_mssql_dropped_test', $names); + + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testSqlsrvTransactionCommitSucceeds(): void + { + $conn = $this->openSqlsrv(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + public function testSqlsrvTransactionRollbackSucceeds(): void + { + $conn = $this->openSqlsrv(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // sqlsrv and dblib do not expose PDO::ATTR_AUTOCOMMIT (reading it throws a + // PDOException). TDbConnection::HasAutoCommit returns false; AutoCommit + // returns false gracefully without attempting to read the absent attribute. + // ----------------------------------------------------------------------- + + public function testSqlsrvHasNoAutoCommitAttributeViaConnection(): void + { + $conn = $this->openSqlsrv(); + $this->assertFalse( + $conn->HasAutoCommit, + 'MSSQL (sqlsrv) must report HasAutoCommit = false via TDbConnection.' + ); + $conn->Active = false; + } + + public function testSqlsrvAutoCommitReturnsFalseWhenAttributeAbsent(): void + { + $conn = $this->openSqlsrv(); + $this->assertFalse( + $conn->AutoCommit, + 'MSSQL (sqlsrv) AutoCommit must return false when the attribute is not supported.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // ----------------------------------------------------------------------- + + public function testSqlsrvTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openSqlsrv(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testSqlsrvTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openSqlsrv(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testSqlsrvTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). + $conn = $this->openSqlsrv(); + try { + $conn->createCommand( + "IF OBJECT_ID('caps_mssql_tx_reuse','U') IS NOT NULL DROP TABLE caps_mssql_tx_reuse" + )->execute(); + $conn->createCommand( + 'CREATE TABLE caps_mssql_tx_reuse (id INT NOT NULL PRIMARY KEY)' + )->execute(); + } catch (\Exception $e) { + $conn->Active = false; + $this->markTestSkipped('DDL not permitted on this SQL Server connection: ' . $e->getMessage()); + } + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_mssql_tx_reuse VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO caps_mssql_tx_reuse VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM caps_mssql_tx_reuse' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + $conn->createCommand('DROP TABLE caps_mssql_tx_reuse')->execute(); + $conn->Active = false; + } + + public function testSqlsrvTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openSqlsrv(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testSqlsrvGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openSqlsrv(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/SqlSrv/TDbMetaDataSqlSrvIntegrationTest.php b/tests/unit/Data/DbSpecific/SqlSrv/TDbMetaDataSqlSrvIntegrationTest.php new file mode 100644 index 000000000..4bb314d6a --- /dev/null +++ b/tests/unit/Data/DbSpecific/SqlSrv/TDbMetaDataSqlSrvIntegrationTest.php @@ -0,0 +1,284 @@ +markTestSkipped($conn); + } + return $conn; + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openSqlSrv(); + + try { + $this->_conn->createCommand( + "IF OBJECT_ID('meta_test', 'U') IS NOT NULL DROP TABLE meta_test" + )->execute(); + } catch (\Exception $e) { + } + try { + $this->_conn->createCommand( + "CREATE TABLE meta_test (id INT PRIMARY KEY, name NVARCHAR(100) NOT NULL, score FLOAT, note NVARCHAR(100) DEFAULT 'fallback')" + )->execute(); + } catch (\Exception $e) { + $this->markTestSkipped('Cannot create meta_test table: ' . $e->getMessage()); + } + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand( + "IF OBJECT_ID('meta_test', 'U') IS NOT NULL DROP TABLE meta_test" + )->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsSqlSrvMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TSqlSrvMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertSame('meta_test', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $names = $info->getColumnNames(); + $this->assertContains('[id]', $names); + $this->assertContains('[name]', $names); + $this->assertContains('[score]', $names); + $this->assertContains('[note]', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('meta_test'); + $info2 = $meta->getTableInfo('meta_test'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('nonexistent_table_xyz'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertStringContainsStringIgnoringCase('int', $col->getDbType()); + } + + public function testVarcharColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('nvarchar', $col->getDbType()); + } + + public function testNotNullColumnDoesNotAllowNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getAllowNull()); + } + + public function testNullableColumnAllowsNull(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('score'); + $this->assertTrue($col->getAllowNull()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + // score has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('meta_test'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('foo'); + // SQL Server uses bracket quoting. + $this->assertSame('[foo]', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('bar'); + $this->assertSame('[bar]', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('baz'); + // TSqlSrvMetaData uses double-quotes for aliases. + $this->assertSame('"baz"', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/SqliteColumnTest.php b/tests/unit/Data/DbSpecific/Sqlite/SqliteColumnTest.php index d61577b8a..bcea04d31 100644 --- a/tests/unit/Data/DbSpecific/Sqlite/SqliteColumnTest.php +++ b/tests/unit/Data/DbSpecific/Sqlite/SqliteColumnTest.php @@ -90,7 +90,7 @@ public function test_columns() $this->assertCount(10, $table->getColumns()); $this->assertEquals('table1', $table->getTableName()); - $this->assertEquals("'table1'", $table->getTableFullName()); + $this->assertEquals('"table1"', $table->getTableFullName()); $this->assertEquals(['id'], $table->getPrimaryKeys()); $columns = []; @@ -254,7 +254,7 @@ public function test_command_builder_insert() $data = ['name' => 'test', 'field1_int' => 1, 'field3_real' => 1.5]; $insert = $builder->createInsertCommand($data); $this->assertStringContainsString('INSERT INTO', $insert->Text); - $this->assertStringContainsString("'table1'", $insert->Text); + $this->assertStringContainsString('"table1"', $insert->Text); $this->assertStringContainsString('"name"', $insert->Text); } @@ -276,7 +276,7 @@ public function test_command_builder_delete() $delete = $builder->createDeleteCommand('id=1'); $this->assertStringContainsString('DELETE FROM', $delete->Text); - $this->assertStringContainsString("'table1'", $delete->Text); + $this->assertStringContainsString('"table1"', $delete->Text); $this->assertStringContainsString('WHERE id=1', $delete->Text); } diff --git a/tests/unit/Data/DbSpecific/Sqlite/SqliteInsertOrIgnoreTest.php b/tests/unit/Data/DbSpecific/Sqlite/SqliteInsertOrIgnoreTest.php new file mode 100644 index 000000000..9f5c0c2cc --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/SqliteInsertOrIgnoreTest.php @@ -0,0 +1,296 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + $conn->createCommand(' + CREATE TABLE upsert_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + UNIQUE (username) + ) + ')->execute(); + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation + // ----------------------------------------------------------------------- + + public function test_sql_uses_insert_or_ignore_keyword(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('INSERT OR IGNORE INTO', $capturedSql); + $this->assertStringContainsString('"username"', $capturedSql); + $this->assertStringContainsString('"score"', $capturedSql); + $this->assertStringContainsString(':username', $capturedSql); + $this->assertStringContainsString(':score', $capturedSql); + $this->assertStringNotContainsString('ON CONFLICT', $capturedSql); + } + + public function test_sql_omits_id_when_not_in_data(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['username' => 'test', 'score' => 1]); + $this->assertStringNotContainsString('"id"', $capturedSql); + } + + public function test_sql_includes_id_when_provided(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->insertOrIgnore(['id' => 5, 'username' => 'test', 'score' => 1]); + $this->assertStringContainsString('"id"', $capturedSql); + $this->assertStringContainsString(':id', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Insert new row + // ----------------------------------------------------------------------- + + public function test_new_row_is_inserted_into_table(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_new_row_values_are_stored_correctly(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 42]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(42, (int) $row['score']); + } + + public function test_new_row_returns_integer_last_insert_id(): void + { + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + public function test_successive_new_rows_return_incrementing_ids(): void + { + $id1 = (int) self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $id2 = (int) self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 2]); + $this->assertGreaterThan($id1, $id2); + } + + public function test_new_row_with_default_score_omitted_uses_zero(): void + { + // SQLite fills DEFAULT 0 when score is omitted from the data + self::$conn->createCommand("INSERT OR IGNORE INTO upsert_test (username) VALUES ('alice')")->execute(); + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(0, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Duplicate silently ignored + // ----------------------------------------------------------------------- + + public function test_duplicate_username_returns_false(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + $this->assertFalse($result); + } + + public function test_duplicate_does_not_create_additional_rows(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_existing_row_not_modified_after_ignored_insert(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_duplicate_on_explicit_id_returns_false(): void + { + self::$gateway->insertOrIgnore(['id' => 1, 'username' => 'alice', 'score' => 10]); + $result = self::$gateway->insertOrIgnore(['id' => 1, 'username' => 'bob', 'score' => 20]); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Mixed: some conflict, some new + // ----------------------------------------------------------------------- + + public function test_only_conflicting_row_is_ignored_others_inserted(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $res2 = self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); // conflict + $res3 = self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 20]); // new + + $this->assertFalse($res2); + $this->assertGreaterThan(0, (int) $res3); + $this->assertEquals(2, (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar()); + } + + public function test_non_conflicting_row_after_conflict_has_correct_values(): void + { + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 10]); + self::$gateway->insertOrIgnore(['username' => 'alice', 'score' => 99]); + self::$gateway->insertOrIgnore(['username' => 'bob', 'score' => 55]); + + $alice = self::$gateway->find('username = ?', 'alice'); + $bob = self::$gateway->find('username = ?', 'bob'); + + $this->assertEquals(10, (int) $alice['score'], 'alice score unchanged'); + $this->assertEquals(55, (int) $bob['score'], 'bob score stored correctly'); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised_on_insert(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertTrue($fired, 'OnCreateCommand was not raised'); + } + + public function test_onexecutecommand_event_is_raised_with_result(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + // execute() returns rows affected; 1 for a fresh insert + $this->assertEquals(1, $captured); + } + + public function test_onexecutecommand_can_override_result_to_false(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + // Force 0 rows affected → insertOrIgnore returns false + $param->setResult(0); + }; + + $result = $gw->insertOrIgnore(['username' => 'alice', 'score' => 1]); + $this->assertFalse($result); + } + + public function test_oncreatecommand_event_is_raised_on_conflict(): void + { + $callCount = 0; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$callCount): void { + $callCount++; + }; + + $gw->insertOrIgnore(['username' => 'alice', 'score' => 10]); + $gw->insertOrIgnore(['username' => 'alice', 'score' => 99]); + // Event fires once per call regardless of whether conflict occurs + $this->assertEquals(2, $callCount); + } + + // ----------------------------------------------------------------------- + // Base class throws TDbException + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_insertOrIgnore(): void + { + $meta = new TSqliteMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createInsertOrIgnoreCommand(['username' => 'x', 'score' => 1]); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/SqliteTableExistsTest.php b/tests/unit/Data/DbSpecific/Sqlite/SqliteTableExistsTest.php new file mode 100644 index 000000000..188cabd95 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/SqliteTableExistsTest.php @@ -0,0 +1,104 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + $conn->createCommand( + 'CREATE TABLE upsert_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + score INTEGER NOT NULL DEFAULT 0 + )' + )->execute(); + static::$conn = $conn; + } + } + static::$conn->createCommand( + 'DROP TABLE IF EXISTS ' . self::TEMP_TABLE + )->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('upsert_test', static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + static::$conn->createCommand( + 'CREATE TABLE ' . self::TEMP_TABLE . ' (id INTEGER PRIMARY KEY)' + )->execute(); + + // Construct while the table exists so the metadata lookup succeeds. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + static::$conn->createCommand('DROP TABLE ' . self::TEMP_TABLE)->execute(); + + $this->assertFalse($gateway->getTableExists()); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/SqliteUpsertTest.php b/tests/unit/Data/DbSpecific/Sqlite/SqliteUpsertTest.php new file mode 100644 index 000000000..1fd41f736 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/SqliteUpsertTest.php @@ -0,0 +1,374 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + $conn->createCommand(' + CREATE TABLE upsert_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + UNIQUE (username) + ) + ')->execute(); + static::$conn = $conn; + static::$gateway = new TTableGateway('upsert_test', $conn); + } + } + static::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + static::$gateway = null; + } + } + + // ----------------------------------------------------------------------- + // SQL generation — upsert (ON CONFLICT DO UPDATE SET) + // ----------------------------------------------------------------------- + + public function test_sql_contains_on_conflict_do_update_set(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + $this->assertNotNull($capturedSql); + $this->assertStringContainsString('INSERT INTO', $capturedSql); + $this->assertStringContainsString('ON CONFLICT', $capturedSql); + $this->assertStringContainsString('DO UPDATE SET', $capturedSql); + // excluded pseudo-table (SQLite uses lowercase 'excluded') + $this->assertStringContainsString('excluded.', $capturedSql); + } + + public function test_sql_conflict_clause_uses_specified_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + // ON CONFLICT("username") — conflict target is quoted username column + $this->assertStringContainsString('"username"', $capturedSql); + } + + public function test_sql_update_set_contains_non_conflict_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + // score is non-conflict → appears in DO UPDATE SET + $this->assertStringContainsString('"score"', $capturedSql); + } + + public function test_sql_empty_updateData_produces_do_nothing(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['username' => 'test', 'score' => 1], [], ['username']); + $this->assertStringContainsString('DO NOTHING', $capturedSql); + $this->assertStringNotContainsString('DO UPDATE', $capturedSql); + } + + public function test_sql_explicit_updateData_only_those_columns_in_set(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + // Only score in updateData; username is conflict col + $gw->upsert(['username' => 'test', 'score' => 1], ['score' => 1], ['username']); + $this->assertStringContainsString('"score"', $capturedSql); + // username is the conflict target only, not in the SET clause again + $setPos = strpos($capturedSql, 'DO UPDATE SET'); + $this->assertNotFalse($setPos); + $setPart = substr($capturedSql, $setPos); + $this->assertStringNotContainsString('"username" = excluded."username"', $setPart); + } + + public function test_sql_default_conflict_columns_uses_pk(): void + { + // When conflictColumns=null, resolved to PK ('id') + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['id' => 1, 'username' => 'test', 'score' => 1], null, null); + $this->assertStringContainsString('"id"', $capturedSql); + $this->assertStringContainsString('ON CONFLICT', $capturedSql); + } + + // ----------------------------------------------------------------------- + // Base class throws TDbException + // ----------------------------------------------------------------------- + + public function test_base_builder_throws_for_upsert(): void + { + $meta = new TSqliteMetaData(self::$conn); + $tableInfo = $meta->getTableInfo('upsert_test'); + $base = new TDbCommandBuilder(self::$conn, $tableInfo); + + $this->expectException(TDbException::class); + $base->createUpsertCommand(['username' => 'x', 'score' => 1]); + } + + // ----------------------------------------------------------------------- + // Behavioral: insert new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertIsArray($row); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_upsert_new_row_returns_integer_id(): void + { + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $this->assertNotFalse($result); + $this->assertGreaterThan(0, (int) $result); + } + + // ----------------------------------------------------------------------- + // Behavioral: conflict → update + // ----------------------------------------------------------------------- + + public function test_conflict_on_unique_column_triggers_update(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(99, (int) $row['score']); + } + + public function test_conflict_does_not_create_duplicate_rows(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM upsert_test')->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_conflict_on_pk_triggers_update(): void + { + // Insert with explicit id, then upsert with same id (PK conflict) + $id = (int) self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert(['id' => $id, 'username' => 'alice', 'score' => 77]); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(77, (int) $row['score']); + } + + public function test_conflict_update_returns_truthy_value(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + $this->assertNotFalse($result); + } + + // ----------------------------------------------------------------------- + // Explicit updateData + // ----------------------------------------------------------------------- + + public function test_explicit_updateData_only_updates_specified_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + // updateData only updates score; username should remain 'alice' + self::$gateway->upsert( + ['username' => 'alice', 'score' => 50], + ['score' => 50], + ['username'] + ); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(50, (int) $row['score']); + $this->assertEquals('alice', $row['username']); + } + + public function test_null_updateData_defaults_to_all_non_conflict_columns(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->upsert( + ['username' => 'alice', 'score' => 88], + null, // default: all non-conflict cols = [score] + ['username'] + ); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(88, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Empty updateData → DO NOTHING (insert-or-ignore behaviour) + // ----------------------------------------------------------------------- + + public function test_empty_updateData_acts_as_insert_or_ignore_on_conflict(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + + $row = self::$gateway->find('username = ?', 'alice'); + $this->assertEquals(10, (int) $row['score'], 'score must remain unchanged with empty updateData'); + } + + public function test_empty_updateData_on_conflict_returns_false(): void + { + self::$gateway->upsert(['username' => 'alice', 'score' => 10], null, ['username']); + $result = self::$gateway->upsert(['username' => 'alice', 'score' => 99], [], ['username']); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // Other rows not affected + // ----------------------------------------------------------------------- + + public function test_upsert_does_not_modify_other_rows(): void + { + self::$gateway->insert(['username' => 'alice', 'score' => 10]); + self::$gateway->insert(['username' => 'bob', 'score' => 20]); + + self::$gateway->upsert(['username' => 'alice', 'score' => 99], null, ['username']); + + $bob = self::$gateway->find('username = ?', 'bob'); + $this->assertEquals(20, (int) $bob['score']); + } + + // ----------------------------------------------------------------------- + // resolveConflictColumns / resolveUpdateData helpers via SQL capture + // ----------------------------------------------------------------------- + + public function test_resolve_conflict_columns_defaults_to_pk(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + $gw->upsert(['id' => 5, 'username' => 'test', 'score' => 1], null, null); + // Default conflict → "id" (the PK); score and username in SET + $this->assertStringContainsString('"id"', $capturedSql); + } + + public function test_resolve_update_data_excludes_conflict_columns(): void + { + $capturedSql = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$capturedSql): void { + $capturedSql = $param->getCommand()->Text; + }; + // conflictColumns=['username'] → updateData = [score] only + $gw->upsert(['username' => 'test', 'score' => 1], null, ['username']); + $setPos = strpos($capturedSql, 'DO UPDATE SET'); + $setPart = substr($capturedSql, (int) $setPos); + // score should be updated + $this->assertStringContainsString('"score"', $setPart); + // username is conflict target, not in SET + $this->assertStringNotContainsString('"username" = excluded."username"', $setPart); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + public function test_oncreatecommand_event_is_raised(): void + { + $fired = false; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnCreateCommand[] = function ($sender, $param) use (&$fired): void { + $this->assertInstanceOf(TDataGatewayEventParameter::class, $param); + $fired = true; + }; + + $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertTrue($fired, 'OnCreateCommand was not raised'); + } + + public function test_onexecutecommand_event_is_raised(): void + { + $captured = null; + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param) use (&$captured): void { + $this->assertInstanceOf(TDataGatewayResultEventParameter::class, $param); + $captured = $param->getResult(); + }; + + $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertNotNull($captured); + } + + public function test_onexecutecommand_can_override_result(): void + { + $gw = new TTableGateway('upsert_test', self::$conn); + $gw->OnExecuteCommand[] = function ($sender, $param): void { + $param->setResult(0); + }; + + $result = $gw->upsert(['username' => 'alice', 'score' => 1], null, ['username']); + $this->assertFalse($result); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/TDbCommandSqliteIntegrationTest.php b/tests/unit/Data/DbSpecific/Sqlite/TDbCommandSqliteIntegrationTest.php new file mode 100644 index 000000000..a41bf26d5 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/TDbCommandSqliteIntegrationTest.php @@ -0,0 +1,354 @@ +markTestSkipped('pdo_sqlite extension not available.'); + } + try { + $conn = new TDbConnection('sqlite::memory:'); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot open SQLite in-memory database: ' . $e->getMessage()); + } + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openSqlite(); + $this->_conn->createCommand( + 'CREATE TABLE cmd_test (id INTEGER PRIMARY KEY, name TEXT, score REAL, active INTEGER, note TEXT)' + )->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (1, 'Alice', 9.5, 1, 'first')")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (2, 'Bob', 7.3, 0, NULL)")->execute(); + $this->_conn->createCommand("INSERT INTO cmd_test VALUES (3, 'Carol', 8.1, 1, 'third')")->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE cmd_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbCommand — execute() + // ----------------------------------------------------------------------- + + public function testExecuteRunsDdlWithoutError(): void + { + // execute() on a non-query statement must not throw. + $this->_conn->createCommand('CREATE TABLE exec_ddl_test (x INTEGER)')->execute(); + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM exec_ddl_test')->queryScalar(); + $this->assertSame(0, $count); + $this->_conn->createCommand('DROP TABLE exec_ddl_test')->execute(); + } + + public function testExecuteReturnsRowCountForInsert(): void + { + $affected = $this->_conn->createCommand( + "INSERT INTO cmd_test VALUES (99, 'Zoe', 5.0, 0, NULL)" + )->execute(); + $this->assertSame(1, $affected); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryAll() + // ----------------------------------------------------------------------- + + public function testQueryAllReturnsAllRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryAll(); + $this->assertCount(3, $rows); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Carol', $rows[2]['name']); + } + + public function testQueryAllReturnsAssocArraysByDefault(): void + { + $rows = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->queryAll(); + $this->assertArrayHasKey('id', $rows[0]); + $this->assertArrayHasKey('name', $rows[0]); + } + + public function testQueryAllReturnsEmptyArrayWhenNoRows(): void + { + $rows = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryRow() + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsFirstRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + $this->assertIsArray($row); + $this->assertSame('Alice', $row['name']); + } + + public function testQueryRowReturnsFalseWhenNoRows(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->queryRow(); + $this->assertFalse($row); + } + + public function testQueryRowReturnsOnlyOneRow(): void + { + $row = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->queryRow(); + // Only a single array (one row), not a nested array. + $this->assertArrayHasKey('name', $row); + $this->assertArrayNotHasKey(0, $row); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryScalar() + // ----------------------------------------------------------------------- + + public function testQueryScalarReturnsFirstColumnFirstRow(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryScalar(); + $this->assertSame('Alice', $scalar); + } + + public function testQueryScalarReturnsFalseWhenNoRows(): void + { + $scalar = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryScalar(); + $this->assertFalse($scalar); + } + + public function testQueryScalarWorksForCountAggregate(): void + { + $count = (int) $this->_conn->createCommand('SELECT COUNT(*) FROM cmd_test')->queryScalar(); + $this->assertSame(3, $count); + } + + // ----------------------------------------------------------------------- + // TDbCommand — queryColumn() + // ----------------------------------------------------------------------- + + public function testQueryColumnReturnsFirstColumnOfAllRows(): void + { + $names = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testQueryColumnReturnsEmptyArrayWhenNoRows(): void + { + $result = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = 999')->queryColumn(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + } + + public function testQueryColumnWorksForNumericColumn(): void + { + $ids = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->queryColumn(); + $this->assertCount(3, $ids); + $this->assertSame('1', (string) $ids[0]); + } + + // ----------------------------------------------------------------------- + // TDbCommand — parameter binding + // ----------------------------------------------------------------------- + + public function testBindParameterWithPositionalPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = ?'); + $id = 2; + $cmd->bindParameter(1, $id); + $this->assertSame('Bob', $cmd->queryScalar()); + } + + public function testBindValueWithNamedPlaceholder(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + public function testBindValueTypeInt(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1, \PDO::PARAM_INT); + $this->assertSame('Alice', $cmd->queryScalar()); + } + + public function testBindValueTypeStr(): void + { + $cmd = $this->_conn->createCommand("SELECT id FROM cmd_test WHERE name = :name"); + $cmd->bindValue(':name', 'Carol', \PDO::PARAM_STR); + $this->assertSame('3', (string) $cmd->queryScalar()); + } + + public function testPreparedStatementCanBeExecutedMultipleTimes(): void + { + $cmd = $this->_conn->createCommand('SELECT name FROM cmd_test WHERE id = :id'); + $cmd->bindValue(':id', 1); + $this->assertSame('Alice', $cmd->queryScalar()); + + $cmd->bindValue(':id', 2); + $this->assertSame('Bob', $cmd->queryScalar()); + + $cmd->bindValue(':id', 3); + $this->assertSame('Carol', $cmd->queryScalar()); + } + + // ----------------------------------------------------------------------- + // TDbCommand — NULL values + // ----------------------------------------------------------------------- + + public function testQueryRowReturnsNullForNullColumn(): void + { + $row = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryRow(); + $this->assertNull($row['note']); + } + + public function testQueryScalarReturnsNullForNullColumn(): void + { + $scalar = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->queryScalar(); + $this->assertNull($scalar); + } + + // ----------------------------------------------------------------------- + // TDbDataReader — via query() + // ----------------------------------------------------------------------- + + public function testQueryReturnsDataReader(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $this->assertInstanceOf(TDbDataReader::class, $reader); + $reader->close(); + } + + public function testDataReaderReadReturnsRowsThenFalse(): void + { + $reader = $this->_conn->createCommand('SELECT id FROM cmd_test ORDER BY id')->query(); + $row1 = $reader->read(); + $row2 = $reader->read(); + $row3 = $reader->read(); + $done = $reader->read(); + + $this->assertIsArray($row1); + $this->assertIsArray($row2); + $this->assertIsArray($row3); + $this->assertFalse($done); + $reader->close(); + } + + public function testDataReaderReadAllReturnsAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test ORDER BY id')->query(); + $rows = $reader->readAll(); + $this->assertCount(3, $rows); + $reader->close(); + } + + public function testDataReaderReadColumnByIndex(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $name = $reader->readColumn(1); // second column = name + $this->assertSame('Alice', $name); + $reader->close(); + } + + public function testDataReaderForeachIteratesAllRows(): void + { + $reader = $this->_conn->createCommand('SELECT name FROM cmd_test ORDER BY id')->query(); + $names = []; + foreach ($reader as $row) { + $names[] = $row['name']; + } + $this->assertSame(['Alice', 'Bob', 'Carol'], $names); + } + + public function testDataReaderGetColumnCount(): void + { + $reader = $this->_conn->createCommand('SELECT id, name, score FROM cmd_test')->query(); + $this->assertSame(3, $reader->getColumnCount()); + $reader->close(); + } + + public function testDataReaderNullValueReturnedForNullColumn(): void + { + $reader = $this->_conn->createCommand('SELECT note FROM cmd_test WHERE id = 2')->query(); + $row = $reader->read(); + $this->assertNull($row['note']); + $reader->close(); + } + + public function testDataReaderEmptyResultSetReadReturnsFalse(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test WHERE id = 999')->query(); + $this->assertFalse($reader->read()); + $reader->close(); + } + + public function testDataReaderClosePreventsFurtherReading(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + $reader->close(); + $this->assertTrue($reader->getIsClosed()); + } + + public function testDataReaderRewindThrowsOnSecondIteration(): void + { + $reader = $this->_conn->createCommand('SELECT * FROM cmd_test')->query(); + // First complete iteration. + foreach ($reader as $row) { + } + // Second iteration must throw TDbException (rewind not supported). + $this->expectException(\Prado\Exceptions\TDbException::class); + foreach ($reader as $row) { + } + } + + public function testDataReaderFetchModeNum(): void + { + $reader = $this->_conn->createCommand('SELECT id, name FROM cmd_test ORDER BY id')->query(); + $reader->setFetchMode(\PDO::FETCH_NUM); + $row = $reader->read(); + // Numeric-indexed: 0 = id, 1 = name. + $this->assertArrayHasKey(0, $row); + $this->assertArrayHasKey(1, $row); + $this->assertArrayNotHasKey('id', $row); + $reader->close(); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/TDbConnectionCharsetSqliteIntegrationTest.php b/tests/unit/Data/DbSpecific/Sqlite/TDbConnectionCharsetSqliteIntegrationTest.php index e4f160a02..1f5a2d169 100644 --- a/tests/unit/Data/DbSpecific/Sqlite/TDbConnectionCharsetSqliteIntegrationTest.php +++ b/tests/unit/Data/DbSpecific/Sqlite/TDbConnectionCharsetSqliteIntegrationTest.php @@ -8,14 +8,33 @@ /** * Integration tests for TDbConnection charset handling — SQLite. * - * TDbConnection applies the Charset property to SQLite via PRAGMA encoding = . - * The PRAGMA only takes effect before any tables are created; on databases that - * already have tables it is silently ignored so the connection remains usable. + * SQLite supports exactly two charset families: UTF-8 and UTF-16. UTF-16 is + * stored in the database file in the host's native byte order; PRAGMA encoding + * always reports the specific endian form ('UTF-16le' or 'UTF-16be'), never the + * bare 'UTF-16' token. TDbConnection::unresolveCharset() maps those endian + * variants back to the PRADO canonical names 'UTF-16LE' or 'UTF-16BE' + * (TDataCharset::UTF16LE / TDataCharset::UTF16BE), which carry explicit + * endianness. * - * For new in-memory databases (used here) the PRAGMA succeeds and the encoding - * reported by a subsequent PRAGMA encoding query reflects the configured value. + * The Charset property therefore reflects the byte order that SQLite actually + * uses, which is system-dependent (little-endian on x86; big-endian on some + * ARM/MIPS/PPC platforms). Tests use assertIsUtf16CanonicalCharset() wherever + * the exact endian form is platform-dependent. * - * Tests are skipped automatically when the pdo_sqlite extension is missing. + * PRAGMA encoding only takes effect before any tables are created; on databases + * that already have tables it is silently ignored and the encoding established + * at creation time is preserved. TDbConnection handles this in two places: + * + * - open() — attempts PRAGMA, then reads back the actual encoding and + * syncs the Charset property to what the DB really has. + * - setCharset() — same PRAGMA-then-readback sequence for post-connect changes. + * + * getDatabaseCharset() returns the raw PRAGMA encoding string reported by + * SQLite ('UTF-8', 'UTF-16le', or 'UTF-16be'), while the Charset property + * stores the PRADO canonical name ('UTF-8', 'UTF-16LE', or 'UTF-16BE'). + * + * Tests are organised in parallel UTF-8 / UTF-16 sections so the two charsets + * receive equivalent coverage. Tests are skipped when pdo_sqlite is missing. */ class TDbConnectionCharsetSqliteIntegrationTest extends PHPUnit\Framework\TestCase { @@ -47,13 +66,9 @@ protected function setUp(): void } // ----------------------------------------------------------------------- - // Shared helpers + // Helpers // ----------------------------------------------------------------------- - /** - * Create and activate a TDbConnection, marking the test skipped on any - * connection error (missing extension, server not running, DB not found). - */ private function openConnection(string $dsn, string $user, string $pass, string $charset = ''): TDbConnection { try { @@ -65,101 +80,324 @@ private function openConnection(string $dsn, string $user, string $pass, string } } - /** Query a scalar value from an active connection. */ private function queryScalar(TDbConnection $conn, string $sql): mixed { return $conn->createCommand($sql)->queryScalar(); } - // ----------------------------------------------------------------------- - // SQLite helpers - // ----------------------------------------------------------------------- - private function openSqlite(string $charset = ''): TDbConnection { if (!extension_loaded('pdo_sqlite')) { $this->markTestSkipped('pdo_sqlite extension not available.'); } - // Use an in-memory DB so no file cleanup is needed. return $this->openConnection('sqlite::memory:', '', '', $charset); } + /** + * Returns the raw PRAGMA encoding string for $conn. + * For UTF-16 databases this is 'UTF-16le' or 'UTF-16be' depending on + * the host's byte order. + */ + private function pragmaEncoding(TDbConnection $conn): string + { + return (string) $this->queryScalar($conn, 'PRAGMA encoding'); + } + + /** + * Asserts that the raw PRAGMA encoding string is a UTF-16 variant + * ('UTF-16le' or 'UTF-16be'). Used wherever the exact endian form is + * system-dependent. + */ + private function assertIsUtf16Encoding(string $encoding, string $message = ''): void + { + $this->assertMatchesRegularExpression('/^UTF-16(le|be)$/i', $encoding, + $message ?: "Expected a UTF-16 variant (UTF-16le/UTF-16be), got '$encoding'."); + } + + /** + * Asserts that the PRADO Charset property holds a canonical UTF-16 endian + * value ('UTF-16LE' or 'UTF-16BE'). The actual value is system-dependent + * (little-endian on x86; big-endian on some other architectures). + */ + private function assertIsUtf16CanonicalCharset(string $charset, string $message = ''): void + { + $this->assertMatchesRegularExpression('/^UTF-16(LE|BE)$/', $charset, + $message ?: "Expected PRADO canonical UTF-16 charset (UTF-16LE/UTF-16BE), got '$charset'."); + } + + // ----------------------------------------------------------------------- + // UTF-8 — fresh in-memory database (no tables: PRAGMA takes effect) + // ----------------------------------------------------------------------- + + public function testSqliteDefaultEncodingIsUtf8(): void + { + // No Charset requested — SQLite defaults to UTF-8. + // open() reads back PRAGMA encoding and syncs Charset to 'UTF-8'. + $conn = $this->openSqlite(); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + public function testSqliteCharsetUtf8AppliedOnFreshDatabase(): void + { + // Requesting UTF-8 explicitly on a fresh DB: PRAGMA succeeds, + // readback syncs Charset to 'UTF-8'. + $conn = $this->openSqlite('UTF-8'); + $this->assertTrue($conn->Active); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // UTF-16 — fresh in-memory database (no tables: PRAGMA takes effect) + // ----------------------------------------------------------------------- + + public function testSqliteCharsetUtf16AppliedOnFreshDatabase(): void + { + // Requesting UTF-16 on a fresh DB: PRAGMA encoding = 'UTF-16' succeeds. + // SQLite stores it in native byte order and reports 'UTF-16le' or 'UTF-16be'. + // unresolveCharset() maps 'UTF-16le' → 'UTF-16LE' and 'UTF-16be' → 'UTF-16BE'. + $conn = $this->openSqlite('UTF-16'); + $this->assertTrue($conn->Active); + $this->assertIsUtf16Encoding($this->pragmaEncoding($conn)); + $this->assertIsUtf16CanonicalCharset($conn->Charset); + $conn->Active = false; + } + // ----------------------------------------------------------------------- - // Tests + // UTF-8 — existing database (tables present: PRAGMA ignored, readback corrects) // ----------------------------------------------------------------------- - public function testSqliteIsAlwaysUtf8(): void + public function testSqliteUtf8SyncedFromExistingDatabaseWhenNoCharsetRequested(): void { + // No Charset requested, but a table exists. open() reads back PRAGMA + // encoding; _charset is synced to 'UTF-8' (the DB's actual encoding). $conn = $this->openSqlite(); - $encoding = $this->queryScalar($conn, 'PRAGMA encoding'); - $this->assertSame('UTF-8', $encoding); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + $this->assertSame('UTF-8', $conn->Charset); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); $conn->Active = false; } - public function testSqliteCharsetAppliedViaEncoding(): void + public function testSqliteUtf8RequestedOnExistingDatabaseSyncsCorrectly(): void { - // On a fresh in-memory database (no tables yet) PRAGMA encoding succeeds. + // UTF-8 requested, fresh DB used as stand-in for any UTF-8 existing DB. + // PRAGMA applies (no tables yet); readback confirms 'UTF-8'. $conn = $this->openSqlite('UTF-8'); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + public function testSqliteUnsupportedCharsetClearedAfterConnect(): void + { + // ISO-8859-1 is not a valid SQLite PRAGMA encoding value. + // The PRAGMA is silently ignored; readback corrects Charset to 'UTF-8'. + $conn = $this->openSqlite('ISO-8859-1'); $this->assertTrue($conn->Active); - $encoding = $this->queryScalar($conn, 'PRAGMA encoding'); - $this->assertSame('UTF-8', $encoding); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // UTF-16 — existing database (tables present: PRAGMA ignored, readback corrects) + // ----------------------------------------------------------------------- + + public function testSqliteUtf16RequestedOnExistingUtf8DatabaseReadbackCorrected(): void + { + // Open a fresh DB without a charset (so it's UTF-8), create a table, + // then close and re-open requesting UTF-16. Because tables exist, the + // PRAGMA is ignored; readback corrects Charset back to 'UTF-8'. + // In-memory DBs cannot be re-opened, so we simulate by opening UTF-16 + // on a fresh DB, creating a table, and then issuing setCharset('UTF-8') + // — which is the inverse of the original scenario but exercises the same + // PRAGMA-ignored → readback path for UTF-16 requests on existing tables. + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + // Request UTF-16 after tables exist — PRAGMA will be ignored. + $conn->Charset = 'UTF-16'; + // DB is still UTF-8; readback must correct Charset to 'UTF-8'. + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + public function testSqliteUtf16SyncedFromExistingUtf16DatabaseWhenNoCharsetRequested(): void + { + // Open a fresh DB with UTF-16, create a table to "lock in" the encoding, + // then assert that Charset was synced to 'UTF-16LE'/'UTF-16BE' from the readback. + // (The readback happens in open() before any tables are created, so the + // PRAGMA is applied first and the readback confirms the UTF-16 encoding.) + $conn = $this->openSqlite('UTF-16'); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + $this->assertIsUtf16Encoding($this->pragmaEncoding($conn)); + $this->assertIsUtf16CanonicalCharset($conn->Charset); $conn->Active = false; } - public function testSqliteSetCharsetAfterConnectDoesNotThrow(): void + // ----------------------------------------------------------------------- + // UTF-8 — setCharset() on an active connection + // ----------------------------------------------------------------------- + + public function testSqliteSetCharsetUtf8AfterConnectOnFreshDatabase(): void { - // Setting Charset on an active connection triggers setConnectionCharset(). - // For an in-memory DB with no tables PRAGMA encoding succeeds; errors on - // populated databases are silently ignored — either way, no exception is thrown. + // No tables: PRAGMA encoding = 'UTF-8' succeeds; readback syncs Charset. $conn = $this->openSqlite(); $conn->Charset = 'UTF-8'; $this->assertTrue($conn->Active); - $encoding = $this->queryScalar($conn, 'PRAGMA encoding'); - $this->assertSame('UTF-8', $encoding); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); $conn->Active = false; } - public function testSqliteUnsupportedCharsetFailsSilently(): void + public function testSqliteSetCharsetUtf8AfterConnectWithTablesReadbackConfirms(): void { - // ISO-8859-1 is not a valid SQLite PRAGMA encoding value; the PRAGMA is - // silently ignored and the connection remains active and usable as UTF-8. - $conn = $this->openSqlite('ISO-8859-1'); + // Tables exist: PRAGMA encoding = 'UTF-8' is silently ignored (DB is already + // UTF-8), readback still returns 'UTF-8' and Charset stays 'UTF-8'. + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + $conn->Charset = 'UTF-8'; + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // UTF-16 — setCharset() on an active connection + // ----------------------------------------------------------------------- + + public function testSqliteSetCharsetUtf16AfterConnectOnFreshDatabase(): void + { + // No tables: PRAGMA encoding = 'UTF-16' succeeds; readback returns + // 'UTF-16le'/'UTF-16be' and unresolves to Charset = 'UTF-16LE'/'UTF-16BE'. + $conn = $this->openSqlite(); + $conn->Charset = 'UTF-16'; $this->assertTrue($conn->Active); - // Encoding is still UTF-8 (default) since PRAGMA was ignored. - $encoding = $this->queryScalar($conn, 'PRAGMA encoding'); - $this->assertSame('UTF-8', $encoding); + $this->assertIsUtf16Encoding($this->pragmaEncoding($conn)); + $this->assertIsUtf16CanonicalCharset($conn->Charset); + $conn->Active = false; + } + + public function testSqliteSetCharsetUtf16AfterConnectWithTablesReadbackCorrected(): void + { + // Tables exist: PRAGMA encoding = 'UTF-16' is silently ignored. + // readback returns 'UTF-8' and Charset is corrected to 'UTF-8', + // not left as the requested 'UTF-16'. + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE t (id INTEGER PRIMARY KEY)')->execute(); + $conn->Charset = 'UTF-16'; + $this->assertSame('UTF-8', $this->pragmaEncoding($conn)); + $this->assertSame('UTF-8', $conn->Charset); $conn->Active = false; } // ----------------------------------------------------------------------- - // getDatabaseCharset() — queries PRAGMA encoding on an active connection + // getDatabaseCharset() — returns the raw PRAGMA encoding string + // + // For UTF-8: returns 'UTF-8' (matches PRADO canonical). + // For UTF-16: returns 'UTF-16le' or 'UTF-16be' (system byte-order dependent). + // This is intentional — getDatabaseCharset() reports the driver-specific value. + // Use the Charset property for the PRADO canonical name. // ----------------------------------------------------------------------- - public function testSqliteGetDatabaseCharsetReturnsActiveEncoding(): void + public function testSqliteGetDatabaseCharsetUtf8ReturnsUtf8(): void { - // On a fresh in-memory DB, PRAGMA encoding = 'UTF-8' succeeds. - // DatabaseCharset queries the DB directly rather than returning the stored value. $conn = $this->openSqlite('UTF-8'); $this->assertSame('UTF-8', $conn->DatabaseCharset); $conn->Active = false; } - public function testSqliteGetDatabaseCharsetReturnsDefaultEncodingWhenNoCharsetSet(): void + public function testSqliteGetDatabaseCharsetDefaultReturnsUtf8(): void { - // When no Charset is configured, DatabaseCharset still queries PRAGMA encoding - // and returns the database's actual encoding (always UTF-8 for new DBs). + // No Charset configured — SQLite defaults to UTF-8. $conn = $this->openSqlite(); $this->assertSame('UTF-8', $conn->DatabaseCharset); $conn->Active = false; } - public function testSqliteGetDatabaseCharsetReflectsEncodingAfterSetCharset(): void + public function testSqliteGetDatabaseCharsetUtf16ReturnsEndianVariant(): void + { + // UTF-16 database: getDatabaseCharset() returns the raw PRAGMA value, + // which is 'UTF-16le' or 'UTF-16be' depending on the host's byte order. + $conn = $this->openSqlite('UTF-16'); + $this->assertIsUtf16Encoding($conn->DatabaseCharset); + $conn->Active = false; + } + + public function testSqliteGetDatabaseCharsetReflectsUtf8AfterSetCharset(): void { - // Setting Charset after connect re-runs PRAGMA encoding; DatabaseCharset - // reads back from the DB and reflects the applied value. $conn = $this->openSqlite(); $conn->Charset = 'UTF-8'; $this->assertSame('UTF-8', $conn->DatabaseCharset); $conn->Active = false; } + + public function testSqliteGetDatabaseCharsetReflectsUtf16AfterSetCharset(): void + { + // setCharset('UTF-16') on a fresh DB applies the PRAGMA; DatabaseCharset + // returns the raw endian-specific form. + $conn = $this->openSqlite(); + $conn->Charset = 'UTF-16'; + $this->assertIsUtf16Encoding($conn->DatabaseCharset); + // Charset property is the PRADO canonical endian-specific form. + $this->assertIsUtf16CanonicalCharset($conn->Charset); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // hasAutoCommitAttribute = false — SQLite does not expose PDO::ATTR_AUTOCOMMIT + // ----------------------------------------------------------------------- + + public function testSqliteHasNoAutoCommitAttributeFlag(): void + { + $conn = $this->openSqlite(); + $this->assertFalse($conn->HasAutoCommit, + 'SQLite must report hasAutoCommitAttribute = false.'); + $conn->Active = false; + } + + public function testSqliteGetAutoCommitReturnsFalseWithoutCrash(): void + { + $conn = $this->openSqlite(); + $this->assertFalse($conn->AutoCommit, + 'AutoCommit must return false for SQLite (PDO::ATTR_AUTOCOMMIT not supported).'); + $conn->Active = false; + } + + public function testSqliteSetAutoCommitIsSafelyIgnored(): void + { + $conn = $this->openSqlite(); + $conn->AutoCommit = true; + $conn->AutoCommit = false; + $this->assertTrue($conn->Active, 'Connection must remain active after setAutoCommit no-ops.'); + $this->assertFalse($conn->AutoCommit); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // PRAGMA injection safety — PDO::quote() escaping + // ----------------------------------------------------------------------- + + public function testSqlitePragmaEncodingAppliedViaQuoteEscapingUtf8(): void + { + // PRAGMA encoding = %s is executed via sprintf($sql, $pdo->quote($charset)). + // Verify the PRAGMA takes effect without injection issues for UTF-8. + $conn = $this->openSqlite('UTF-8'); + $this->assertSame('UTF-8', $this->pragmaEncoding($conn), + 'PRAGMA encoding must be applied via PDO::quote()-escaped sprintf.'); + $conn->Active = false; + } + + public function testSqlitePragmaEncodingAppliedViaQuoteEscapingUtf16(): void + { + // Same as above for UTF-16. + $conn = $this->openSqlite('UTF-16'); + $this->assertIsUtf16Encoding($this->pragmaEncoding($conn), + 'PRAGMA encoding must be applied via PDO::quote()-escaped sprintf.'); + $conn->Active = false; + } } diff --git a/tests/unit/Data/DbSpecific/Sqlite/TDbDriverCapabilitiesSqliteIntegrationTest.php b/tests/unit/Data/DbSpecific/Sqlite/TDbDriverCapabilitiesSqliteIntegrationTest.php new file mode 100644 index 000000000..8d71e1d7a --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/TDbDriverCapabilitiesSqliteIntegrationTest.php @@ -0,0 +1,474 @@ +setUpConnection(); + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function openSqlite(string $charset = ''): TDbConnection + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('pdo_sqlite extension not available.'); + } + try { + $conn = new TDbConnection('sqlite::memory:', '', '', $charset); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot open SQLite: ' . $e->getMessage()); + } + } + + private function queryScalar(TDbConnection $conn, string $sql): mixed + { + return $conn->createCommand($sql)->queryScalar(); + } + + // ----------------------------------------------------------------------- + // Static capability flags + // ----------------------------------------------------------------------- + + public function testSqliteSupportsCharset(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsCharset('sqlite')); + } + + public function testSqliteHasNoAutoCommitAttribute(): void + { + // SQLite does not expose PDO::ATTR_AUTOCOMMIT; hasAutoCommitAttribute must return false. + $this->assertFalse(TDbDriverCapabilities::hasAutoCommitAttribute('sqlite')); + } + + + public function testSqliteRequiresNoPreBeginTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush('sqlite')); + } + + public function testSqliteRequiresNoPostTransactionFlush(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush('sqlite')); + } + + public function testSqliteSupportsRuntimeCharsetSet(): void + { + $this->assertTrue(TDbDriverCapabilities::supportsRuntimeCharsetSet('sqlite')); + } + + public function testSqliteRequiresNoPostConnectCharset(): void + { + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset('sqlite')); + } + + public function testSqliteHasNoDsnCharsetParam(): void + { + // SQLite uses PRAGMA encoding, not a DSN charset parameter. + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam('sqlite')); + } + + public function testSqliteHasNoDsnCharsetPattern(): void + { + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern('sqlite')); + } + + public function testSqliteCharsetSetSqlIsNull(): void + { + // SQLite charset is set via PRAGMA, not a SQL SET command. + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql('sqlite')); + } + + public function testSqliteCharsetPragmaSqlContainsPragmaEncoding(): void + { + $pragma = TDbDriverCapabilities::getCharsetPragmaSql('sqlite'); + $this->assertNotNull($pragma); + $this->assertStringContainsString('PRAGMA encoding', $pragma); + } + + public function testSqliteCharsetQuerySqlIsPragmaEncoding(): void + { + $this->assertSame('PRAGMA encoding', TDbDriverCapabilities::getCharsetQuerySql('sqlite')); + } + + public function testSqliteGetListTablesSqlContainsSqliteMaster(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('sqlite'); + $this->assertNotNull($sql); + $this->assertStringContainsString('sqlite_master', $sql); + } + + public function testSqliteMetaDataClassName(): void + { + $this->assertSame(TSqliteMetaData::class, TDbDriverCapabilities::getMetaDataClass('sqlite')); + } + + public function testSqlite2MetaDataClassNameMatchesSqlite(): void + { + $this->assertSame(TSqliteMetaData::class, TDbDriverCapabilities::getMetaDataClass('sqlite2')); + } + + public function testSqliteGetListTablesSqlExcludesSqliteSequence(): void + { + $sql = TDbDriverCapabilities::getListTablesSql('sqlite'); + $this->assertStringContainsString('sqlite_sequence', $sql); + } + + // ----------------------------------------------------------------------- + // Charset resolution + // ----------------------------------------------------------------------- + + public function testSqliteResolveUtf8ReturnsUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('UTF-8', 'sqlite')); + } + + public function testSqliteResolveUtf16ReturnsUtf16(): void + { + $this->assertSame('UTF-16', TDbDriverCapabilities::resolveCharset('UTF-16', 'sqlite')); + } + + public function testSqliteLatin1ResolvesToUtf8(): void + { + // SQLite does not support ISO-8859-1; the table maps it to UTF-8 and the + // PRAGMA is silently ignored. The connection remains UTF-8. + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('ISO-8859-1', 'sqlite')); + } + + public function testSqliteWin1250ResolvesToUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('Windows-1250', 'sqlite')); + } + + public function testSqliteAsciiResolvesToUtf8(): void + { + $this->assertSame('UTF-8', TDbDriverCapabilities::resolveCharset('ASCII', 'sqlite')); + } + + // ----------------------------------------------------------------------- + // Scaffold factory + // ----------------------------------------------------------------------- + + public function testSqliteScaffoldInputClass(): void + { + $this->assertSame('TSqliteScaffoldInput', TDbDriverCapabilities::getScaffoldInputClass('sqlite')); + } + + public function testSqliteScaffoldInputFile(): void + { + $this->assertSame('/TSqliteScaffoldInput.php', TDbDriverCapabilities::getScaffoldInputFile('sqlite')); + } + + public function testSqlite2ScaffoldInputMatchesSqlite(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputClass('sqlite'), + TDbDriverCapabilities::getScaffoldInputClass('sqlite2') + ); + } + + // ----------------------------------------------------------------------- + // Live connection — charset query + // ----------------------------------------------------------------------- + + public function testSqliteCharsetQuerySqlExecutesAndReturnsUtf8(): void + { + $conn = $this->openSqlite(); + $sql = TDbDriverCapabilities::getCharsetQuerySql('sqlite'); + $encoding = $this->queryScalar($conn, $sql); + $this->assertSame('UTF-8', $encoding); + $conn->Active = false; + } + + public function testSqliteGetDatabaseCharsetReturnsUtf8(): void + { + $conn = $this->openSqlite('UTF-8'); + $this->assertSame('UTF-8', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testSqliteGetDatabaseCharsetWithNoCharsetStillReturnsUtf8(): void + { + $conn = $this->openSqlite(); + $this->assertSame('UTF-8', $conn->DatabaseCharset); + $conn->Active = false; + } + + public function testSqliteUnsupportedCharsetSilentlyRemainsUtf8(): void + { + // ISO-8859-1 maps to 'UTF-8' in the resolve table, PRAGMA is silently ignored. + $conn = $this->openSqlite('ISO-8859-1'); + $this->assertTrue($conn->Active); + $encoding = $this->queryScalar($conn, 'PRAGMA encoding'); + $this->assertSame('UTF-8', $encoding); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — list tables + // ----------------------------------------------------------------------- + + public function testSqliteListTablesQueryReturnsEmptyArrayForEmptyDb(): void + { + $conn = $this->openSqlite(); + $sql = TDbDriverCapabilities::getListTablesSql('sqlite'); + $result = $conn->createCommand($sql)->queryAll(); + $this->assertIsArray($result); + $this->assertCount(0, $result); + $conn->Active = false; + } + + public function testSqliteListTablesQueryReturnsCreatedTable(): void + { + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE foo (id INTEGER PRIMARY KEY)')->execute(); + $sql = TDbDriverCapabilities::getListTablesSql('sqlite'); + $rows = $conn->createCommand($sql)->queryAll(); + $names = array_column($rows, 'tbl_name'); + $this->assertContains('foo', $names); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — MetaData factory + // ----------------------------------------------------------------------- + + public function testSqliteMetaDataInstanceIsTSqliteMetaData(): void + { + $conn = $this->openSqlite(); + $meta = TDbMetaData::getInstance($conn); + $this->assertInstanceOf(TSqliteMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — transactions + // ----------------------------------------------------------------------- + + public function testSqliteTransactionCommitPersistsData(): void + { + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE tx_test (id INTEGER PRIMARY KEY)')->execute(); + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO tx_test VALUES (1)')->execute(); + $tx->commit(); + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM tx_test'); + $this->assertSame(1, $count); + $conn->Active = false; + } + + public function testSqliteTransactionRollbackDiscardsData(): void + { + $conn = $this->openSqlite(); + $conn->createCommand('CREATE TABLE tx_test (id INTEGER PRIMARY KEY)')->execute(); + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO tx_test VALUES (1)')->execute(); + $tx->rollBack(); + $count = (int) $this->queryScalar($conn, 'SELECT COUNT(*) FROM tx_test'); + $this->assertSame(0, $count); + $conn->Active = false; + } + + public function testSqliteTransactionCommitDeactivatesTransaction(): void + { + $conn = $this->openSqlite(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + public function testSqliteTransactionRollbackDeactivatesTransaction(): void + { + $conn = $this->openSqlite(); + $tx = $conn->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — hasAutoCommitAttribute live verification + // + // SQLite does not expose PDO::ATTR_AUTOCOMMIT. TDbConnection::HasAutoCommit + // returns false; TDbConnection::AutoCommit returns false gracefully without + // attempting to read the absent attribute. + // ----------------------------------------------------------------------- + + public function testSqliteHasNoAutoCommitAttributeLive(): void + { + $conn = $this->openSqlite(); + $this->assertFalse(TDbDriverCapabilities::hasAutoCommitAttribute($conn->getDriverName())); + $conn->Active = false; + } + + public function testSqliteHasNoAutoCommitAttributeViaConnection(): void + { + $conn = $this->openSqlite(); + $this->assertFalse( + $conn->HasAutoCommit, + 'SQLite must report HasAutoCommit = false via TDbConnection.' + ); + $conn->Active = false; + } + + public function testSqliteAutoCommitReturnsFalseWhenAttributeAbsent(): void + { + // TDbConnection::getAutoCommit() returns false when hasAutoCommitAttribute + // is false, without attempting to read PDO::ATTR_AUTOCOMMIT from the driver. + $conn = $this->openSqlite(); + $this->assertFalse( + $conn->AutoCommit, + 'SQLite AutoCommit must return false when the attribute is not supported.' + ); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Live connection — TDbTransaction::beginTransaction() (reuse & supersession) + // ----------------------------------------------------------------------- + + public function testSqliteTxBeginTransactionIsActiveAfterReuseViaCommit(): void + { + // After commit(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openSqlite(); + $tx = $conn->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after commit.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testSqliteTxBeginTransactionIsActiveAfterReuseViaRollback(): void + { + // After rollback(), calling beginTransaction() on the same object reactivates it. + $conn = $this->openSqlite(); + $tx = $conn->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive(), 'Transaction must be inactive after rollback.'); + + $returned = $tx->beginTransaction(); + $this->assertSame($tx, $returned, 'beginTransaction() must return $this.'); + $this->assertTrue($tx->getActive(), 'Transaction must be active after reuse.'); + $tx->rollBack(); + $conn->Active = false; + } + + public function testSqliteTxBeginTransactionReuseIsolatesWorkUnits(): void + { + // Two sequential work units on the same object: first commits (row persists), + // second rolls back (row discarded). SQLite in-memory: no cleanup needed. + $conn = $this->openSqlite(); + $conn->createCommand( + 'CREATE TABLE caps_sqlite_tx_reuse (id INTEGER PRIMARY KEY)' + )->execute(); + + $tx = $conn->beginTransaction(); + $conn->createCommand('INSERT INTO caps_sqlite_tx_reuse VALUES (1)')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $conn->createCommand('INSERT INTO caps_sqlite_tx_reuse VALUES (2)')->execute(); + $tx->rollBack(); + + $count = (int) $conn->createCommand( + 'SELECT COUNT(*) FROM caps_sqlite_tx_reuse' + )->queryScalar(); + $this->assertSame(1, $count, 'Only the committed row must persist after reuse rollback.'); + $conn->Active = false; + } + + public function testSqliteTxBeginTransactionThrowsWhenSuperseded(): void + { + // After $conn->beginTransaction() supersedes $tx1, calling + // $tx1->beginTransaction() must throw TDbException. + $conn = $this->openSqlite(); + $tx1 = $conn->beginTransaction(); + $tx1->commit(); + $tx2 = $conn->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(\Prado\Exceptions\TDbException::class); + $tx1->beginTransaction(); + } finally { + if ($tx2->getActive()) { + $tx2->rollBack(); + } + $conn->Active = false; + } + } + + public function testSqliteGetLastTransactionReflectsNewestObject(): void + { + // After $conn->beginTransaction() creates $tx2, getLastTransaction() + // must return $tx2, not the superseded $tx1. + $conn = $this->openSqlite(); + $tx1 = $conn->beginTransaction(); + $this->assertSame($tx1, $conn->getLastTransaction()); + $tx1->commit(); + + $tx2 = $conn->beginTransaction(); + $this->assertSame($tx2, $conn->getLastTransaction()); + $this->assertNotSame($tx1, $conn->getLastTransaction()); + $tx2->rollBack(); + $conn->Active = false; + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/TDbMetaDataSqliteIntegrationTest.php b/tests/unit/Data/DbSpecific/Sqlite/TDbMetaDataSqliteIntegrationTest.php new file mode 100644 index 000000000..1702ab551 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/TDbMetaDataSqliteIntegrationTest.php @@ -0,0 +1,258 @@ +markTestSkipped('pdo_sqlite extension not available.'); + } + try { + $conn = new TDbConnection('sqlite::memory:'); + $conn->Active = true; + return $conn; + } catch (\Exception $e) { + $this->markTestSkipped('Cannot open SQLite in-memory database: ' . $e->getMessage()); + } + } + + protected function setUp(): void + { + static $booted = false; + if (!$booted) { + new TApplication(__DIR__ . '/../../../Security/app', false, TApplication::CONFIG_TYPE_PHP); + $booted = true; + } + $this->_conn = $this->openSqlite(); + $this->_conn->createCommand( + "CREATE TABLE meta_test (id INTEGER PRIMARY KEY, name TEXT NOT NULL, score REAL, note TEXT DEFAULT 'fallback')" + )->execute(); + } + + protected function tearDown(): void + { + if ($this->_conn && $this->_conn->getActive()) { + try { + $this->_conn->createCommand('DROP TABLE meta_test')->execute(); + } catch (\Exception $e) { + } + $this->_conn->Active = false; + } + $this->_conn = null; + } + + // ----------------------------------------------------------------------- + // TDbMetaData::getInstance() + // ----------------------------------------------------------------------- + + public function testGetInstanceReturnsSqliteMetaData(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->assertInstanceOf(TSqliteMetaData::class, $meta); + } + + // ----------------------------------------------------------------------- + // getTableInfo() — TDbTableInfo + // ----------------------------------------------------------------------- + + public function testGetTableInfoReturnsTableInfo(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableInfo::class, $info); + } + + public function testGetTableInfoTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->assertSame('meta_test', $info->getTableName()); + } + + public function testGetTableInfoColumnNamesContainsAllColumns(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $names = $info->getColumnNames(); + $this->assertContains('"id"', $names); + $this->assertContains('"name"', $names); + $this->assertContains('"score"', $names); + $this->assertContains('"note"', $names); + $this->assertCount(4, $names); + } + + public function testGetTableInfoPrimaryKeys(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $pks = $info->getPrimaryKeys(); + $this->assertContains('id', $pks); + $this->assertCount(1, $pks); + } + + public function testGetTableInfoGetColumnReturnsColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertNotNull($col); + $this->assertInstanceOf(\Prado\Data\Common\TDbTableColumn::class, $col); + } + + public function testGetTableInfoGetColumnThrowsForMissingColumn(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $this->expectException(\Prado\Exceptions\TDbException::class); + $info->getColumn('nonexistent_column'); + } + + public function testGetTableInfoCachingReturnsSameObject(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info1 = $meta->getTableInfo('meta_test'); + $info2 = $meta->getTableInfo('meta_test'); + $this->assertSame($info1, $info2); + } + + public function testGetTableInfoThrowsForInvalidTable(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $this->expectException(\Prado\Exceptions\TDbException::class); + $meta->getTableInfo('nonexistent_table_xyz'); + } + + // ----------------------------------------------------------------------- + // TDbTableColumn — column metadata + // ----------------------------------------------------------------------- + + public function testPrimaryKeyColumnIsPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertTrue($col->getIsPrimaryKey()); + } + + public function testNonPrimaryKeyColumnIsNotPrimaryKey(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertFalse($col->getIsPrimaryKey()); + } + + public function testPrimaryKeyColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('id'); + $this->assertStringContainsStringIgnoringCase('integer', $col->getDbType()); + } + + public function testTextColumnDbType(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('name'); + $this->assertStringContainsStringIgnoringCase('text', $col->getDbType()); + } + + public function testColumnWithDefaultValueHasDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + $col = $info->getColumn('note'); + $this->assertNotNull($col->getDefaultValue()); + } + + public function testColumnWithoutDefaultHasNullDefault(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $info = $meta->getTableInfo('meta_test'); + // score has no DEFAULT clause. + $col = $info->getColumn('score'); + $this->assertSame(\Prado\Data\Common\TDbTableColumn::UNDEFINED_VALUE, $col->getDefaultValue()); + } + + // ----------------------------------------------------------------------- + // findTableNames() + // ----------------------------------------------------------------------- + + public function testFindTableNamesContainsMetaTest(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertContains('meta_test', $tables); + } + + public function testFindTableNamesReturnsArray(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $tables = $meta->findTableNames(); + $this->assertIsArray($tables); + } + + // ----------------------------------------------------------------------- + // createCommandBuilder() + // ----------------------------------------------------------------------- + + public function testCreateCommandBuilderReturnsBuilder(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $builder = $meta->createCommandBuilder('meta_test'); + $this->assertInstanceOf(TDbCommandBuilder::class, $builder); + } + + // ----------------------------------------------------------------------- + // Quoting helpers + // ----------------------------------------------------------------------- + + public function testQuoteTableName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteTableName('foo'); + // SQLite quoteTableName uses double-quotes (SQL standard identifier quoting). + // Single-quotes are string literals and cause SQLITE_RANGE when ORDER BY + // references quoted column names against a single-quoted table source. + $this->assertSame('"foo"', $quoted); + } + + public function testQuoteColumnName(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnName('bar'); + $this->assertSame('"bar"', $quoted); + } + + public function testQuoteColumnAlias(): void + { + $meta = TDbMetaData::getInstance($this->_conn); + $quoted = $meta->quoteColumnAlias('baz'); + $this->assertSame('"baz"', $quoted); + } +} diff --git a/tests/unit/Data/DbSpecific/Sqlite/TTableGatewaySqliteIntegrationTest.php b/tests/unit/Data/DbSpecific/Sqlite/TTableGatewaySqliteIntegrationTest.php new file mode 100644 index 000000000..8fb0bce56 --- /dev/null +++ b/tests/unit/Data/DbSpecific/Sqlite/TTableGatewaySqliteIntegrationTest.php @@ -0,0 +1,316 @@ +Active = true; + $conn->createCommand( + 'CREATE TABLE gw_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score REAL DEFAULT 0.0, active INTEGER DEFAULT 1)' + )->execute(); + self::$conn = $conn; + self::$gw = new TTableGateway('gw_test', $conn); + } + + public static function tearDownAfterClass(): void + { + if (self::$conn && self::$conn->getActive()) { + self::$conn->Active = false; + } + self::$conn = null; + self::$gw = null; + } + + protected function setUp(): void + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('pdo_sqlite extension not available.'); + } + // Clear the table before every test for isolation. + self::$conn->createCommand('DELETE FROM gw_test')->execute(); + // Reset the autoincrement sequence so ids start from 1 predictably. + try { + self::$conn->createCommand('DELETE FROM sqlite_sequence WHERE name = \'gw_test\'')->execute(); + } catch (\Exception $e) { + // sqlite_sequence only exists once AUTOINCREMENT has been used. + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private function insertRow(string $name, float $score = 0.0, int $active = 1): int + { + return (int) self::$gw->insert(['name' => $name, 'score' => $score, 'active' => $active]); + } + + // ----------------------------------------------------------------------- + // insert() + // ----------------------------------------------------------------------- + + public function testInsertReturnsLastInsertId(): void + { + $id = $this->insertRow('Alice', 9.5); + $this->assertGreaterThan(0, $id); + } + + public function testInsertCreatesRow(): void + { + $this->insertRow('Alice', 9.5); + $count = (int) self::$conn->createCommand('SELECT COUNT(*) FROM gw_test')->queryScalar(); + $this->assertSame(1, $count); + } + + public function testInsertedDataMatchesInput(): void + { + $this->insertRow('Bob', 7.3, 0); + $row = self::$conn->createCommand("SELECT * FROM gw_test WHERE name = 'Bob'")->queryRow(); + $this->assertSame('Bob', $row['name']); + $this->assertSame('0', (string) $row['active']); + } + + // ----------------------------------------------------------------------- + // findByPk() + // ----------------------------------------------------------------------- + + public function testFindByPkReturnsMatchingRow(): void + { + $id = $this->insertRow('Carol', 8.1); + $row = self::$gw->findByPk($id); + $this->assertIsArray($row); + $this->assertSame('Carol', $row['name']); + } + + public function testFindByPkReturnsFalseForMissingPk(): void + { + $result = self::$gw->findByPk(99999); + $this->assertFalse($result); + } + + // ----------------------------------------------------------------------- + // find() / findAll() + // ----------------------------------------------------------------------- + + public function testFindReturnsFirstMatchingRow(): void + { + $this->insertRow('Alice', 9.5); + $this->insertRow('Bob', 7.3); + $row = self::$gw->find('name = :n', ['n' => 'Bob']); + $this->assertIsArray($row); + $this->assertSame('Bob', $row['name']); + } + + public function testFindReturnsFalseWhenNoMatch(): void + { + $this->insertRow('Alice'); + $result = self::$gw->find('name = :n', ['n' => 'Zoe']); + $this->assertFalse($result); + } + + public function testFindAllReturnsAllRows(): void + { + $this->insertRow('Alice'); + $this->insertRow('Bob'); + $this->insertRow('Carol'); + $rows = self::$gw->findAll()->readAll(); + $this->assertCount(3, $rows); + } + + public function testFindAllReturnsEmptyArrayWhenTableIsEmpty(): void + { + $rows = self::$gw->findAll()->readAll(); + $this->assertIsArray($rows); + $this->assertCount(0, $rows); + } + + // ----------------------------------------------------------------------- + // count() + // ----------------------------------------------------------------------- + + public function testCountReturnsZeroForEmptyTable(): void + { + $this->assertSame(0, (int) self::$gw->count()); + } + + public function testCountReturnsCorrectNumber(): void + { + $this->insertRow('Alice'); + $this->insertRow('Bob'); + $this->assertSame(2, (int) self::$gw->count()); + } + + public function testCountWithConditionCountsMatchingRows(): void + { + $this->insertRow('Alice', 9.5, 1); + $this->insertRow('Bob', 7.3, 0); + $this->insertRow('Carol', 8.1, 1); + $count = (int) self::$gw->count('active = 1'); + $this->assertSame(2, $count); + } + + // ----------------------------------------------------------------------- + // update() + // ----------------------------------------------------------------------- + + public function testUpdateModifiesMatchingRows(): void + { + $id = $this->insertRow('Alice', 9.5, 1); + self::$gw->update(['score' => 5.0], 'id = :id', ['id' => $id]); + $row = self::$gw->findByPk($id); + $this->assertSame('5', (string) $row['score']); + } + + public function testUpdateReturnsNumberOfAffectedRows(): void + { + $this->insertRow('Alice', 9.5, 1); + $this->insertRow('Bob', 7.3, 1); + $affected = self::$gw->update(['active' => 0], 'active = 1'); + $this->assertSame(2, (int) $affected); + } + + public function testUpdateWithNoMatchAffectsZeroRows(): void + { + $this->insertRow('Alice'); + $affected = self::$gw->update(['score' => 0.0], 'name = :n', ['n' => 'Zoe']); + $this->assertSame(0, (int) $affected); + } + + // ----------------------------------------------------------------------- + // delete() + // ----------------------------------------------------------------------- + + public function testDeleteRemovesMatchingRows(): void + { + $this->insertRow('Alice'); + $this->insertRow('Bob'); + self::$gw->deleteAll('name = :n', ['n' => 'Alice']); + $this->assertSame(1, (int) self::$gw->count()); + } + + public function testDeleteReturnsNumberOfAffectedRows(): void + { + $this->insertRow('Alice'); + $this->insertRow('Bob'); + $affected = self::$gw->deleteAll('1=1'); + $this->assertSame(2, (int) $affected); + } + + public function testDeleteWithNoMatchAffectsZeroRows(): void + { + $this->insertRow('Alice'); + $affected = self::$gw->deleteAll('name = :n', ['n' => 'Zoe']); + $this->assertSame(0, (int) $affected); + } + + // ----------------------------------------------------------------------- + // deleteByPk() + // ----------------------------------------------------------------------- + + public function testDeleteByPkRemovesRow(): void + { + $id = $this->insertRow('Alice'); + self::$gw->deleteByPk([$id]); + $this->assertFalse(self::$gw->findByPk($id)); + } + + public function testDeleteByPkReturnsOneForExistingRow(): void + { + $id = $this->insertRow('Alice'); + $affected = self::$gw->deleteByPk([$id]); + $this->assertSame(1, (int) $affected); + } + + public function testDeleteByPkReturnsZeroForMissingPk(): void + { + $affected = self::$gw->deleteByPk([99999]); + $this->assertSame(0, (int) $affected); + } + + // ----------------------------------------------------------------------- + // TSqlCriteria — ordering, limiting, conditions + // ----------------------------------------------------------------------- + + public function testFindAllWithCriteriaOrderBy(): void + { + $this->insertRow('Carol', 8.1); + $this->insertRow('Alice', 9.5); + $this->insertRow('Bob', 7.3); + $criteria = new TSqlCriteria('1=1'); + $criteria->OrdersBy = ['name' => 'asc']; + $rows = self::$gw->findAll($criteria)->readAll(); + $this->assertSame('Alice', $rows[0]['name']); + $this->assertSame('Bob', $rows[1]['name']); + $this->assertSame('Carol', $rows[2]['name']); + } + + public function testFindAllWithCriteriaLimit(): void + { + $this->insertRow('Alice'); + $this->insertRow('Bob'); + $this->insertRow('Carol'); + $criteria = new TSqlCriteria(); + $criteria->Limit = 2; + $rows = self::$gw->findAll($criteria)->readAll(); + $this->assertCount(2, $rows); + } + + public function testFindAllWithCriteriaCondition(): void + { + $this->insertRow('Alice', 9.5, 1); + $this->insertRow('Bob', 7.3, 0); + $this->insertRow('Carol', 8.1, 1); + $criteria = new TSqlCriteria('active = 1'); + $rows = self::$gw->findAll($criteria)->readAll(); + $this->assertCount(2, $rows); + $names = array_column($rows, 'name'); + $this->assertContains('Alice', $names); + $this->assertContains('Carol', $names); + } + + public function testCountWithCriteria(): void + { + $this->insertRow('Alice', 9.5, 1); + $this->insertRow('Bob', 7.3, 0); + $criteria = new TSqlCriteria('active = 0'); + $count = (int) self::$gw->count($criteria); + $this->assertSame(1, $count); + } +} diff --git a/tests/unit/Data/SqlMap/SqlMapInsertOrIgnoreTest.php b/tests/unit/Data/SqlMap/SqlMapInsertOrIgnoreTest.php new file mode 100644 index 000000000..b6ef1dd03 --- /dev/null +++ b/tests/unit/Data/SqlMap/SqlMapInsertOrIgnoreTest.php @@ -0,0 +1,307 @@ + and . + * + * Bootstraps its own TSqlMapManager against a fresh SQLite file database so it + * can create the upsert_test table independently of the shared SqlMap test DB. + * + * What is tested: + * - XML parsing: element → TInsertOrIgnoreMappedStatement + * - XML parsing: element → TUpsertMappedStatement + * - XML attribute parsing: updateColumns / conflictColumns stored on TSqlMapUpsert + * - Execution: insertOrIgnore inserts a new row + * - Execution: insertOrIgnore on duplicate silently skips (row count unchanged) + * - Execution: insertOrIgnore on duplicate leaves original data unchanged + * - Execution: upsert inserts a new row + * - Execution: upsert on conflict updates the row in place + * - Execution: upsert does not create duplicate rows + * + * @since 4.3.3 + */ + +use Prado\Data\SqlMap\Configuration\TSqlMapUpsert; +use Prado\Data\SqlMap\Statements\TInsertMappedStatement; +use Prado\Data\SqlMap\Statements\TInsertOrIgnoreMappedStatement; +use Prado\Data\SqlMap\Statements\TUpsertMappedStatement; +use Prado\Data\SqlMap\TSqlMapManager; +use Prado\Data\TDbConnection; + +class SqlMapInsertOrIgnoreTest extends PHPUnit\Framework\TestCase +{ + private static TDbConnection $conn; + private static TSqlMapManager $manager; + private static \Prado\Data\SqlMap\TSqlMapGateway $sqlmap; + private static string $dbFile; + + public static function setUpBeforeClass(): void + { + // Use a per-run temp SQLite file so the test is fully isolated. + self::$dbFile = sys_get_temp_dir() . '/prado_sqlmap_upsert_' . getmypid() . '.db'; + + self::$conn = new TDbConnection('sqlite:' . self::$dbFile); + self::$conn->Active = true; + self::$conn->createCommand( + 'CREATE TABLE IF NOT EXISTS upsert_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + UNIQUE(username) + )' + )->execute(); + + // Bootstrap TSqlMapManager with only the UpsertTest map. + // We write a minimal sqlmap config to a temp file so configureXml() can + // resolve the relative resource path to UpsertTest.xml. + $mapsDir = realpath(__DIR__ . '/maps/sqlite'); + $configXml = << + + + + + + XML; + + $configFile = sys_get_temp_dir() . '/prado_sqlmap_upsert_cfg_' . getmypid() . '.xml'; + file_put_contents($configFile, $configXml); + + self::$manager = new TSqlMapManager(self::$conn); + self::$manager->configureXml($configFile); + self::$sqlmap = self::$manager->getSqlMapGateway(); + + @unlink($configFile); + } + + protected function setUp(): void + { + self::$conn->createCommand('DELETE FROM upsert_test')->execute(); + } + + public static function tearDownAfterClass(): void + { + if (isset(self::$conn) && self::$conn->Active) { + self::$conn->Active = false; + } + if (isset(self::$dbFile) && file_exists(self::$dbFile)) { + @unlink(self::$dbFile); + } + } + + // ----------------------------------------------------------------------- + // XML parsing: statement types + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_element_creates_TInsertOrIgnoreMappedStatement(): void + { + $stmt = self::$manager->getMappedStatement('InsertOrIgnoreUpsertRow'); + $this->assertInstanceOf(TInsertOrIgnoreMappedStatement::class, $stmt); + } + + public function test_insertOrIgnore_mapped_statement_extends_TInsertMappedStatement(): void + { + $stmt = self::$manager->getMappedStatement('InsertOrIgnoreUpsertRow'); + $this->assertInstanceOf(TInsertMappedStatement::class, $stmt); + } + + public function test_upsert_element_creates_TUpsertMappedStatement(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRow'); + $this->assertInstanceOf(TUpsertMappedStatement::class, $stmt); + } + + public function test_upsert_mapped_statement_extends_TInsertMappedStatement(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRow'); + $this->assertInstanceOf(TInsertMappedStatement::class, $stmt); + } + + // ----------------------------------------------------------------------- + // XML attribute parsing: updateColumns / conflictColumns on TSqlMapUpsert + // ----------------------------------------------------------------------- + + public function test_upsert_config_object_is_TSqlMapUpsert(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRow'); + $this->assertInstanceOf(TSqlMapUpsert::class, $stmt->getStatement()); + } + + public function test_upsert_updateColumns_parsed_from_attribute(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRow'); + $config = $stmt->getStatement(); + $this->assertInstanceOf(TSqlMapUpsert::class, $config); + $this->assertSame(['score'], $config->getUpdateColumns()); + } + + public function test_upsert_conflictColumns_parsed_from_attribute(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRow'); + $config = $stmt->getStatement(); + $this->assertInstanceOf(TSqlMapUpsert::class, $config); + $this->assertSame(['username'], $config->getConflictColumns()); + } + + public function test_upsert_multi_updateColumns_parsed_and_trimmed(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRowMultiConflict'); + $config = $stmt->getStatement(); + $this->assertInstanceOf(TSqlMapUpsert::class, $config); + $this->assertSame(['score', 'extra'], $config->getUpdateColumns()); + } + + public function test_upsert_multi_conflictColumns_parsed_and_trimmed(): void + { + $stmt = self::$manager->getMappedStatement('UpsertRowMultiConflict'); + $config = $stmt->getStatement(); + $this->assertInstanceOf(TSqlMapUpsert::class, $config); + $this->assertSame(['tenant_id', 'username'], $config->getConflictColumns()); + } + + // ----------------------------------------------------------------------- + // Execution: insertOrIgnore — new row + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_inserts_new_row(): void + { + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 10]); + + $count = (int) self::$conn->createCommand( + "SELECT COUNT(*) FROM upsert_test WHERE username='alice'" + )->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_insertOrIgnore_stores_correct_data(): void + { + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 42]); + + $row = self::$conn->createCommand( + "SELECT username, score FROM upsert_test WHERE username='alice'" + )->queryRow(); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(42, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Execution: insertOrIgnore — duplicate silently skipped + // ----------------------------------------------------------------------- + + public function test_insertOrIgnore_duplicate_does_not_increase_row_count(): void + { + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand( + 'SELECT COUNT(*) FROM upsert_test' + )->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_insertOrIgnore_duplicate_leaves_original_score_unchanged(): void + { + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 99]); + + $row = self::$conn->createCommand( + "SELECT score FROM upsert_test WHERE username='alice'" + )->queryRow(); + $this->assertEquals(10, (int) $row['score']); + } + + public function test_insertOrIgnore_non_conflicting_rows_inserted(): void + { + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 99]); + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'bob', 'score' => 20]); + + $count = (int) self::$conn->createCommand( + 'SELECT COUNT(*) FROM upsert_test' + )->queryScalar(); + $this->assertEquals(2, $count); + } + + // ----------------------------------------------------------------------- + // Execution: upsert — new row + // ----------------------------------------------------------------------- + + public function test_upsert_inserts_new_row(): void + { + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 10]); + + $count = (int) self::$conn->createCommand( + "SELECT COUNT(*) FROM upsert_test WHERE username='alice'" + )->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_upsert_stores_correct_data_on_insert(): void + { + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 55]); + + $row = self::$conn->createCommand( + "SELECT username, score FROM upsert_test WHERE username='alice'" + )->queryRow(); + $this->assertEquals('alice', $row['username']); + $this->assertEquals(55, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Execution: upsert — conflict → update + // ----------------------------------------------------------------------- + + public function test_upsert_conflict_updates_score(): void + { + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 99]); + + $row = self::$conn->createCommand( + "SELECT score FROM upsert_test WHERE username='alice'" + )->queryRow(); + $this->assertEquals(99, (int) $row['score']); + } + + public function test_upsert_conflict_does_not_create_duplicate_rows(): void + { + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand( + 'SELECT COUNT(*) FROM upsert_test' + )->queryScalar(); + $this->assertEquals(1, $count); + } + + public function test_upsert_does_not_affect_other_rows(): void + { + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 10]); + self::$sqlmap->insert('UpsertRow', ['username' => 'bob', 'score' => 20]); + self::$sqlmap->insert('UpsertRow', ['username' => 'alice', 'score' => 99]); + + $row = self::$conn->createCommand( + "SELECT score FROM upsert_test WHERE username='bob'" + )->queryRow(); + $this->assertEquals(20, (int) $row['score']); + } + + // ----------------------------------------------------------------------- + // Execution: insert and insertOrIgnore can coexist in the same manager + // ----------------------------------------------------------------------- + + public function test_plain_insert_and_insertOrIgnore_use_separate_statements(): void + { + self::$sqlmap->insert('InsertUpsertRow', ['username' => 'alice', 'score' => 10]); + // This duplicate would throw on a plain INSERT but is silently skipped here + self::$sqlmap->insert('InsertOrIgnoreUpsertRow', ['username' => 'alice', 'score' => 99]); + + $count = (int) self::$conn->createCommand( + 'SELECT COUNT(*) FROM upsert_test' + )->queryScalar(); + $this->assertEquals(1, $count); + + $row = self::$conn->createCommand( + "SELECT score FROM upsert_test WHERE username='alice'" + )->queryRow(); + $this->assertEquals(10, (int) $row['score']); + } +} diff --git a/tests/unit/Data/SqlMap/SqlMapSleepTest.php b/tests/unit/Data/SqlMap/SqlMapSleepTest.php new file mode 100644 index 000000000..fbf9b1b61 --- /dev/null +++ b/tests/unit/Data/SqlMap/SqlMapSleepTest.php @@ -0,0 +1,341 @@ +__sleep(); + // _resultMap (the resolved object) is always stripped regardless of its value + $this->assertNotContains("\0" . self::STMT_CN . "\0_resultMap", $props); + } + + public function testTSqlMapStatementDefaultPropsExcluded(): void + { + $s = new TSqlMapStatement(); + $cn = self::STMT_CN; + $props = $s->__sleep(); + $this->assertNotContains("\0$cn\0_parameterMapName", $props); + $this->assertNotContains("\0$cn\0_parameterMap", $props); + $this->assertNotContains("\0$cn\0_parameterClassName", $props); + $this->assertNotContains("\0$cn\0_resultMapName", $props); + $this->assertNotContains("\0$cn\0_resultClassName", $props); + $this->assertNotContains("\0$cn\0_cacheModelName", $props); + $this->assertNotContains("\0$cn\0_SQL", $props); + $this->assertNotContains("\0$cn\0_listClass", $props); + $this->assertNotContains("\0$cn\0_typeHandler", $props); + $this->assertNotContains("\0$cn\0_extendStatement", $props); + $this->assertNotContains("\0$cn\0_cache", $props); + } + + public function testTSqlMapStatementSetPropsIncluded(): void + { + $s = new TSqlMapStatement(); + // setParameterMap sets _parameterMapName; setResultMap sets _resultMapName + $s->setParameterMap('paramMap'); + $s->setParameterClass('stdClass'); + $s->setResultMap('resultMap'); + $s->setResultClass('stdClass'); + $s->setCacheModel('myCache'); + $s->setSqlText('SELECT 1'); + $s->setListClass('TList'); + $s->setExtends('baseStmt'); + + $cn = self::STMT_CN; + $props = $s->__sleep(); + $this->assertContains("\0$cn\0_parameterMapName", $props); + $this->assertContains("\0$cn\0_parameterClassName", $props); + $this->assertContains("\0$cn\0_resultMapName", $props); + $this->assertContains("\0$cn\0_resultClassName", $props); + $this->assertContains("\0$cn\0_cacheModelName", $props); + $this->assertContains("\0$cn\0_SQL", $props); + $this->assertContains("\0$cn\0_listClass", $props); + $this->assertContains("\0$cn\0_extendStatement", $props); + } + + public function testTSqlMapStatementRoundTrip(): void + { + $s = new TSqlMapStatement(); + $s->setID('selectUser'); + $s->setParameterClass('User'); + $s->setResultClass('User'); + $s->setSqlText('SELECT * FROM users WHERE id = ?'); + + $restored = unserialize(serialize($s)); + $this->assertSame('selectUser', $restored->getID()); + $this->assertSame('User', $restored->getParameterClass()); + $this->assertSame('User', $restored->getResultClass()); + $this->assertSame('SELECT * FROM users WHERE id = ?', $restored->getSqlText()); + $this->assertNull($restored->getResultMap()); // _resultMap always stripped + } + + // ========================================================================= + // TParameterProperty + // ========================================================================= + + private const PARAM_CN = 'Prado\Data\SqlMap\Configuration\TParameterProperty'; + + public function testTParameterPropertyDefaultPropsExcluded(): void + { + $p = new TParameterProperty(); + $cn = self::PARAM_CN; + $props = $p->__sleep(); + $this->assertNotContains("\0$cn\0_typeHandler", $props); + $this->assertNotContains("\0$cn\0_type", $props); + $this->assertNotContains("\0$cn\0_column", $props); + $this->assertNotContains("\0$cn\0_dbType", $props); + $this->assertNotContains("\0$cn\0_property", $props); + $this->assertNotContains("\0$cn\0_nullValue", $props); + } + + public function testTParameterPropertySetPropsIncluded(): void + { + $p = new TParameterProperty(); + $p->setProperty('username'); + $p->setColumn('user_name'); + $p->setType('string'); + $p->setDbType('VARCHAR'); + $p->setNullValue(''); + + $cn = self::PARAM_CN; + $props = $p->__sleep(); + $this->assertContains("\0$cn\0_property", $props); + $this->assertContains("\0$cn\0_column", $props); + $this->assertContains("\0$cn\0_type", $props); + $this->assertContains("\0$cn\0_dbType", $props); + $this->assertContains("\0$cn\0_nullValue", $props); + } + + public function testTParameterPropertyRoundTrip(): void + { + $p = new TParameterProperty(); + $p->setProperty('email'); + $p->setColumn('email_address'); + $p->setNullValue('none@example.com'); + + $restored = unserialize(serialize($p)); + $this->assertSame('email', $restored->getProperty()); + $this->assertSame('email_address', $restored->getColumn()); + $this->assertSame('none@example.com', $restored->getNullValue()); + $this->assertNull($restored->getType()); + } + + // ========================================================================= + // TResultProperty + // ========================================================================= + + private const RESULT_PROP_CN = 'Prado\Data\SqlMap\Configuration\TResultProperty'; + + public function testTResultPropertyDefaultPropsExcluded(): void + { + $r = new TResultProperty(); + $cn = self::RESULT_PROP_CN; + $props = $r->__sleep(); + $this->assertNotContains("\0$cn\0_nullValue", $props); + $this->assertNotContains("\0$cn\0_propertyName", $props); + $this->assertNotContains("\0$cn\0_columnName", $props); + $this->assertNotContains("\0$cn\0_columnIndex", $props); // default -1 → excluded + $this->assertNotContains("\0$cn\0_nestedResultMapName", $props); + $this->assertNotContains("\0$cn\0_nestedResultMap", $props); + $this->assertNotContains("\0$cn\0_valueType", $props); + $this->assertNotContains("\0$cn\0_typeHandler", $props); + $this->assertNotContains("\0$cn\0_isLazyLoad", $props); // default false → excluded + $this->assertNotContains("\0$cn\0_select", $props); + } + + public function testTResultPropertySetPropsIncluded(): void + { + $r = new TResultProperty(); + $r->setProperty('id'); + $r->setColumn('user_id'); + $r->setColumnIndex(0); // non-default: != -1 + $r->setType('integer'); // sets _valueType + $r->setResultMapping('userMap'); // sets _nestedResultMapName + $r->setSelect('selectAddress'); + $r->setLazyLoad(true); // non-default: true + + $cn = self::RESULT_PROP_CN; + $props = $r->__sleep(); + $this->assertContains("\0$cn\0_propertyName", $props); + $this->assertContains("\0$cn\0_columnName", $props); + $this->assertContains("\0$cn\0_columnIndex", $props); + $this->assertContains("\0$cn\0_valueType", $props); + $this->assertContains("\0$cn\0_nestedResultMapName", $props); + $this->assertContains("\0$cn\0_select", $props); + $this->assertContains("\0$cn\0_isLazyLoad", $props); + } + + public function testTResultPropertyRoundTrip(): void + { + $r = new TResultProperty(); + $r->setProperty('name'); + $r->setColumn('full_name'); + $r->setColumnIndex(2); + $r->setNullValue('N/A'); + + $restored = unserialize(serialize($r)); + $this->assertSame('name', $restored->getProperty()); + $this->assertSame('full_name', $restored->getColumn()); + $this->assertSame(2, $restored->getColumnIndex()); + $this->assertSame('N/A', $restored->getNullValue()); + $this->assertNull($restored->getType()); + } + + // ========================================================================= + // TPreparedStatement + // ========================================================================= + + private const PREP_CN = 'Prado\Data\SqlMap\Statements\TPreparedStatement'; + + public function testTPreparedStatementDefaultPropsExcluded(): void + { + $p = new TPreparedStatement(); + $cn = self::PREP_CN; + $props = $p->__sleep(); + // Empty TList/TMap → excluded + $this->assertNotContains("\0$cn\0_parameterNames", $props); + $this->assertNotContains("\0$cn\0_parameterValues", $props); + } + + public function testTPreparedStatementSetPropsIncluded(): void + { + $p = new TPreparedStatement(); + $names = new TList(); + $names->add(':id'); + $p->setParameterNames($names); + + $values = new TMap(); + $values->add(':id', 42); + $p->setParameterValues($values); + + $cn = self::PREP_CN; + $props = $p->__sleep(); + $this->assertContains("\0$cn\0_parameterNames", $props); + $this->assertContains("\0$cn\0_parameterValues", $props); + } + + public function testTPreparedStatementRoundTrip(): void + { + $p = new TPreparedStatement(); + $p->setPreparedSql('SELECT * FROM users WHERE id = :id'); + $names = new TList(); + $names->add(':id'); + $p->setParameterNames($names); + + $restored = unserialize(serialize($p)); + $this->assertSame('SELECT * FROM users WHERE id = :id', $restored->getPreparedSql()); + $this->assertSame(1, $restored->getParameterNames()->getCount()); + $this->assertSame(':id',$restored->getParameterNames()->itemAt(0)); + } + + // ========================================================================= + // TMappedStatement + // ========================================================================= + + private const MAPPED_CN = 'Prado\Data\SqlMap\Statements\TMappedStatement'; + + /** + * Create a TMappedStatement without calling the constructor, so that tests + * can inspect _getZappableSleepProps without needing a live TSqlMapManager. + */ + private function makeMappedStatement(): TMappedStatement + { + return (new \ReflectionClass(TMappedStatement::class))->newInstanceWithoutConstructor(); + } + + public function testTMappedStatementDefaultPropsExcluded(): void + { + $m = $this->makeMappedStatement(); + $cn = self::MAPPED_CN; + $props = $m->__sleep(); + // _selectQueue=[], _groupBy=null, _IsRowDataFound=false are all excluded by default + $this->assertNotContains("\0$cn\0_selectQueue", $props); + $this->assertNotContains("\0$cn\0_groupBy", $props); + $this->assertNotContains("\0$cn\0_IsRowDataFound", $props); + } + + public function testTMappedStatementSetPropsIncluded(): void + { + $m = $this->makeMappedStatement(); + $ref = new \ReflectionClass($m); + + $ref->getProperty('_selectQueue')->setValue($m, [['key' => 'val']]); + $ref->getProperty('_groupBy')->setValue($m, new \stdClass()); + $ref->getProperty('_IsRowDataFound')->setValue($m, true); + + $cn = self::MAPPED_CN; + $props = $m->__sleep(); + $this->assertContains("\0$cn\0_selectQueue", $props); + $this->assertContains("\0$cn\0_groupBy", $props); + $this->assertContains("\0$cn\0_IsRowDataFound", $props); + } + + // ========================================================================= + // TSqlMapObjectCollectionTree + // ========================================================================= + + private const TREE_CN = 'Prado\Data\SqlMap\Statements\TSqlMapObjectCollectionTree'; + + public function testTSqlMapObjectCollectionTreeDefaultPropsExcluded(): void + { + $t = new TSqlMapObjectCollectionTree(); + $cn = self::TREE_CN; + $props = $t->__sleep(); + $this->assertNotContains("\0$cn\0_tree", $props); + $this->assertNotContains("\0$cn\0_entries", $props); + $this->assertNotContains("\0$cn\0_list", $props); + } + + public function testTSqlMapObjectCollectionTreeSetPropsIncluded(): void + { + $t = new TSqlMapObjectCollectionTree(); + $ref = new \ReflectionClass($t); + foreach (['_tree', '_entries', '_list'] as $propName) { + $ref->getProperty($propName)->setValue($t, ['item' => new \stdClass()]); + } + + $cn = self::TREE_CN; + $props = $t->__sleep(); + $this->assertContains("\0$cn\0_tree", $props); + $this->assertContains("\0$cn\0_entries", $props); + $this->assertContains("\0$cn\0_list", $props); + } + + public function testTSqlMapObjectCollectionTreeRoundTrip(): void + { + $t = new TSqlMapObjectCollectionTree(); + $ref = new \ReflectionClass($t); + $listProp = $ref->getProperty('_list'); + $listProp->setValue($t, ['row1' => new \stdClass()]); + + $restored = unserialize(serialize($t)); + $resListProp = (new \ReflectionClass($restored))->getProperty('_list'); + $this->assertCount(1, $resListProp->getValue($restored)); + } +} diff --git a/tests/unit/Data/SqlMap/TSqlMapInsertOrIgnoreConfigTest.php b/tests/unit/Data/SqlMap/TSqlMapInsertOrIgnoreConfigTest.php new file mode 100644 index 000000000..401360a07 --- /dev/null +++ b/tests/unit/Data/SqlMap/TSqlMapInsertOrIgnoreConfigTest.php @@ -0,0 +1,219 @@ +assertInstanceOf(TSqlMapInsert::class, $obj); + } + + public function test_insertOrIgnore_is_distinct_class(): void + { + $this->assertNotEquals(TSqlMapInsert::class, TSqlMapInsertOrIgnore::class); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — inheritance + // ----------------------------------------------------------------------- + + public function test_upsert_extends_TSqlMapInsert(): void + { + $obj = new TSqlMapUpsert(); + $this->assertInstanceOf(TSqlMapInsert::class, $obj); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — updateColumns default + // ----------------------------------------------------------------------- + + public function test_updateColumns_defaults_to_null(): void + { + $upsert = new TSqlMapUpsert(); + $this->assertNull($upsert->getUpdateColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — updateColumns setter: string input + // ----------------------------------------------------------------------- + + public function test_setUpdateColumns_single_column_string(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns('score'); + $this->assertSame(['score'], $upsert->getUpdateColumns()); + } + + public function test_setUpdateColumns_comma_separated_string(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns('score,age'); + $this->assertSame(['score', 'age'], $upsert->getUpdateColumns()); + } + + public function test_setUpdateColumns_trims_whitespace_around_commas(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns(' score , age , email '); + $this->assertSame(['score', 'age', 'email'], $upsert->getUpdateColumns()); + } + + public function test_setUpdateColumns_trims_whitespace_single_value(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns(' score '); + $this->assertSame(['score'], $upsert->getUpdateColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — updateColumns setter: array input + // ----------------------------------------------------------------------- + + public function test_setUpdateColumns_from_array(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns(['score', 'age']); + $this->assertSame(['score', 'age'], $upsert->getUpdateColumns()); + } + + public function test_setUpdateColumns_from_single_element_array(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns(['score']); + $this->assertSame(['score'], $upsert->getUpdateColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — conflictColumns default + // ----------------------------------------------------------------------- + + public function test_conflictColumns_defaults_to_null(): void + { + $upsert = new TSqlMapUpsert(); + $this->assertNull($upsert->getConflictColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — conflictColumns setter: string input + // ----------------------------------------------------------------------- + + public function test_setConflictColumns_single_column_string(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns('username'); + $this->assertSame(['username'], $upsert->getConflictColumns()); + } + + public function test_setConflictColumns_comma_separated_string(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns('tenant_id,username'); + $this->assertSame(['tenant_id', 'username'], $upsert->getConflictColumns()); + } + + public function test_setConflictColumns_trims_whitespace(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns(' tenant_id , username '); + $this->assertSame(['tenant_id', 'username'], $upsert->getConflictColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — conflictColumns setter: array input + // ----------------------------------------------------------------------- + + public function test_setConflictColumns_from_array(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns(['tenant_id', 'username']); + $this->assertSame(['tenant_id', 'username'], $upsert->getConflictColumns()); + } + + public function test_setConflictColumns_from_single_element_array(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns(['id']); + $this->assertSame(['id'], $upsert->getConflictColumns()); + } + + // ----------------------------------------------------------------------- + // TSqlMapUpsert — updateColumns and conflictColumns are independent + // ----------------------------------------------------------------------- + + public function test_updateColumns_and_conflictColumns_are_independent(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setUpdateColumns('score'); + $upsert->setConflictColumns('username'); + + $this->assertSame(['score'], $upsert->getUpdateColumns()); + $this->assertSame(['username'], $upsert->getConflictColumns()); + } + + public function test_updateColumns_null_does_not_affect_conflictColumns(): void + { + $upsert = new TSqlMapUpsert(); + $upsert->setConflictColumns('id'); + + $this->assertNull($upsert->getUpdateColumns()); + $this->assertSame(['id'], $upsert->getConflictColumns()); + } + + // ----------------------------------------------------------------------- + // TInsertOrIgnoreMappedStatement — inheritance + // ----------------------------------------------------------------------- + + public function test_insertOrIgnoreMappedStatement_extends_TInsertMappedStatement(): void + { + // Verify the class hierarchy without instantiation (constructor needs a manager) + $this->assertTrue( + is_subclass_of(TInsertOrIgnoreMappedStatement::class, TInsertMappedStatement::class) + ); + } + + public function test_insertOrIgnoreMappedStatement_is_distinct_class(): void + { + $this->assertNotEquals(TInsertMappedStatement::class, TInsertOrIgnoreMappedStatement::class); + } + + // ----------------------------------------------------------------------- + // TUpsertMappedStatement — inheritance + // ----------------------------------------------------------------------- + + public function test_upsertMappedStatement_extends_TInsertMappedStatement(): void + { + $this->assertTrue( + is_subclass_of(TUpsertMappedStatement::class, TInsertMappedStatement::class) + ); + } + + public function test_upsertMappedStatement_is_distinct_from_insertOrIgnoreMappedStatement(): void + { + $this->assertNotEquals(TInsertOrIgnoreMappedStatement::class, TUpsertMappedStatement::class); + } +} diff --git a/tests/unit/Data/SqlMap/maps/sqlite/UpsertTest.xml b/tests/unit/Data/SqlMap/maps/sqlite/UpsertTest.xml new file mode 100644 index 000000000..8bb10aa5f --- /dev/null +++ b/tests/unit/Data/SqlMap/maps/sqlite/UpsertTest.xml @@ -0,0 +1,55 @@ + + + + + + + INSERT OR IGNORE INTO upsert_test(username, score) + VALUES(#username#, #score#) + + + + + INSERT INTO upsert_test(username, score) + VALUES(#username#, #score#) + ON CONFLICT(username) DO UPDATE SET score = excluded.score + + + + + INSERT INTO upsert_test(username, score) + VALUES(#username#, #score#) + ON CONFLICT(username) DO UPDATE SET score = excluded.score + + + + + INSERT INTO upsert_test(username, score) + VALUES(#username#, #score#) + + + diff --git a/tests/unit/Data/SqlMap/sqlite/tests.db b/tests/unit/Data/SqlMap/sqlite/tests.db index 1881f98ff..028ef5cee 100644 Binary files a/tests/unit/Data/SqlMap/sqlite/tests.db and b/tests/unit/Data/SqlMap/sqlite/tests.db differ diff --git a/tests/unit/Data/TDataSourceConfigTest.php b/tests/unit/Data/TDataSourceConfigTest.php new file mode 100644 index 000000000..b13a29e93 --- /dev/null +++ b/tests/unit/Data/TDataSourceConfigTest.php @@ -0,0 +1,80 @@ +assertSame(TDbConnection::class, $config->ConnectionClass); + } + + public function testSetConnectionClass(): void + { + $config = new TDataSourceConfig(); + $config->ConnectionClass = 'CustomDbConnection'; + $this->assertSame('CustomDbConnection', $config->ConnectionClass); + } + + public function testSetConnectionClassThrowsWhenConnectionExists(): void + { + $config = new TDataSourceConfig(); + + $connProp = new \ReflectionProperty(TDataSourceConfig::class, '_conn'); + $connProp->setAccessible(true); + $connProp->setValue($config, new TDbConnection()); + + $this->expectException(TConfigurationException::class); + $config->ConnectionClass = 'NewClass'; + } + + public function testGetDatabaseIsAliasForGetDbConnection(): void + { + $config = new TDataSourceConfig(); + $this->assertSame($config->getDbConnection(), $config->getDatabase()); + } + + public function testConnectionIdGetterSetter(): void + { + $config = new TDataSourceConfig(); + $config->ConnectionID = 'testDb'; + $this->assertSame('testDb', $config->ConnectionID); + + $config->ConnectionID = 'anotherDb'; + $this->assertSame('anotherDb', $config->ConnectionID); + } + + public function testGetHasDbConnectionInitiallyFalse(): void + { + $config = new TDataSourceConfig(); + $this->assertFalse($config->getHasDbConnection()); + } + + public function testFindConnectionByIdThrowsForNonExistentModule(): void + { + $finder = new TDataSourceConfig(); + $finder->ConnectionID = 'nonExistentModule'; + + $this->expectException(TConfigurationException::class); + $finder->getDbConnection(); + } + + public function testGetDbConnectionReturnsSameInstance(): void + { + $config = new TDataSourceConfig(); + $conn1 = $config->getDbConnection(); + $conn2 = $config->getDbConnection(); + $this->assertSame($conn1, $conn2); + } +} \ No newline at end of file diff --git a/tests/unit/Data/TDbConnectionTest.php b/tests/unit/Data/TDbConnectionTest.php index 0867b6836..067ff1cb1 100644 --- a/tests/unit/Data/TDbConnectionTest.php +++ b/tests/unit/Data/TDbConnectionTest.php @@ -30,6 +30,9 @@ protected function setUp(): void $this->_connection1 = new TDbConnection('sqlite:' . TEST_DB_FILE); $this->_connection1->Active = true; + // DROP first in case a previous test's @unlink was blocked by a + // lingering file lock on Windows (belt-and-suspenders guard). + //$this->_connection1->createCommand('DROP TABLE IF EXISTS foo')->execute(); $this->_connection1->createCommand('CREATE TABLE foo (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(8))')->execute(); $this->_connection2 = new TDbConnection('sqlite:' . TEST_DB_FILE2); } @@ -45,6 +48,9 @@ protected function tearDown(): void $this->_connection2->Active = false; $this->_connection2 = null; } + // Force GC so that any lingering PDO handles are released before we + // attempt to delete the SQLite files (required on Windows). + gc_collect_cycles(); @unlink(TEST_DB_FILE); @unlink(TEST_DB_FILE2); } @@ -239,16 +245,6 @@ public function testSetConnectionCharsetSkipsWhenInactive(): void $this->assertTrue(true); // reached without error } - /** - * Call the protected resolveCharsetForDriver() method via reflection. - */ - private function callResolveCharsetForDriver(TDbConnection $conn, string $charset, string $driver): string - { - $method = new \ReflectionMethod(TDbConnection::class, 'resolveCharsetForDriver'); - $method->setAccessible(true); - return $method->invoke($conn, $charset, $driver); - } - /** * @dataProvider provideSetNamesDrivers * @param string $driver PDO driver string @@ -310,7 +306,6 @@ public static function provideNoSqlDrivers(): array return [ // These drivers return silently; charset is handled via DSN (or not at all). 'firebird' => ['firebird'], - 'mssql' => ['mssql'], 'sqlsrv' => ['sqlsrv'], 'dblib' => ['dblib'], 'ibm' => ['ibm'], @@ -394,84 +389,6 @@ public function testSetConnectionCharsetThrowsForUnknownDriver(): void $this->callSetConnectionCharset($conn); } - // ----------------------------------------------------------------------- - // resolveCharsetForDriver() tests - // ----------------------------------------------------------------------- - - /** @dataProvider provideCharsetResolutions */ - public function testResolveCharsetForDriver( - string $inputCharset, - string $driver, - string $expectedCharset - ): void { - $conn = new TDbConnection(); - $resolved = $this->callResolveCharsetForDriver($conn, $inputCharset, $driver); - $this->assertSame($expectedCharset, $resolved); - } - - public static function provideCharsetResolutions(): array - { - return [ - // --- UTF-8 family: various spellings all resolve correctly --- - 'UTF-8 mysql' => ['UTF-8', 'mysql', 'utf8mb4'], - 'utf8 mysql' => ['utf8', 'mysql', 'utf8mb4'], - 'UTF8 mysql' => ['UTF8', 'mysql', 'utf8mb4'], - 'utf-8 mysql' => ['utf-8', 'mysql', 'utf8mb4'], - 'UTF-8 sqlite' => ['UTF-8', 'sqlite', 'UTF-8'], - 'UTF-8 pgsql' => ['UTF-8', 'pgsql', 'UTF8'], - 'UTF-8 firebird' => ['UTF-8', 'firebird', 'UTF8'], - // utf8mb4 is treated as the same canonical entry as utf8 - 'utf8mb4 mysql' => ['utf8mb4', 'mysql', 'utf8mb4'], - 'utf8mb4 pgsql' => ['utf8mb4', 'pgsql', 'UTF8'], - 'utf8mb4 firebird' => ['utf8mb4', 'firebird', 'UTF8'], - // --- ISO-8859-1 / latin1 --- - 'ISO-8859-1 mysql' => ['ISO-8859-1', 'mysql', 'latin1'], - 'ISO-8859-1 pgsql' => ['ISO-8859-1', 'pgsql', 'LATIN1'], - 'ISO-8859-1 firebird' => ['ISO-8859-1', 'firebird', 'ISO8859_1'], - 'latin1 pgsql' => ['latin1', 'pgsql', 'LATIN1'], - 'latin1 firebird' => ['latin1', 'firebird', 'ISO8859_1'], - // --- ISO-8859-2 / latin2 --- - 'ISO-8859-2 mysql' => ['ISO-8859-2', 'mysql', 'latin2'], - 'ISO-8859-2 pgsql' => ['ISO-8859-2', 'pgsql', 'LATIN2'], - 'ISO-8859-2 firebird' => ['ISO-8859-2', 'firebird', 'ISO8859_2'], - // --- ASCII --- - 'ascii mysql' => ['ascii', 'mysql', 'ascii'], - 'ascii pgsql' => ['ascii', 'pgsql', 'SQL_ASCII'], - 'ascii firebird' => ['ascii', 'firebird', 'ASCII'], - // --- Windows code pages --- - 'WIN-1252 mysql' => ['WIN-1252', 'mysql', 'cp1252'], - 'WIN-1252 pgsql' => ['WIN-1252', 'pgsql', 'WIN1252'], - 'WIN-1252 firebird' => ['WIN-1252', 'firebird', 'WIN1252'], - 'Windows-1252 mysql' => ['Windows-1252', 'mysql', 'cp1252'], - 'win1251 mysql' => ['win1251', 'mysql', 'cp1251'], - 'Windows-1250 pgsql' => ['Windows-1250', 'pgsql', 'WIN1250'], - // --- KOI8 --- - 'KOI8-R mysql' => ['KOI8-R', 'mysql', 'koi8r'], - 'KOI8-R pgsql' => ['KOI8-R', 'pgsql', 'KOI8R'], - 'KOI8-R firebird' => ['KOI8-R', 'firebird', 'KOI8R'], - // --- OCI charset names --- - 'UTF-8 oci' => ['UTF-8', 'oci', 'AL32UTF8'], - 'ISO-8859-1 oci' => ['ISO-8859-1', 'oci', 'WE8ISO8859P1'], - 'ISO-8859-2 oci' => ['ISO-8859-2', 'oci', 'EE8ISO8859P2'], - 'ascii oci' => ['ascii', 'oci', 'US7ASCII'], - 'WIN-1252 oci' => ['WIN-1252', 'oci', 'WE8MSWIN1252'], - 'KOI8-R oci' => ['KOI8-R', 'oci', 'CL8KOI8R'], - // --- sqlsrv charset names --- - 'UTF-8 sqlsrv' => ['UTF-8', 'sqlsrv', 'UTF-8'], - // --- mssql / dblib charset names --- - 'UTF-8 mssql' => ['UTF-8', 'mssql', 'UTF-8'], - 'ISO-8859-1 mssql' => ['ISO-8859-1', 'mssql', 'ISO-8859-1'], - 'ISO-8859-2 dblib' => ['ISO-8859-2', 'dblib', 'ISO-8859-2'], - 'WIN-1252 mssql' => ['WIN-1252', 'mssql', 'CP1252'], - 'KOI8-R dblib' => ['KOI8-R', 'dblib', 'KOI8-R'], - // --- IBM DB2: no table entry → pass-through --- - 'UTF-8 ibm' => ['UTF-8', 'ibm', 'UTF-8'], - // --- Unknown / driver-specific names pass through unchanged --- - 'unknown mysql' => ['my_custom_cs', 'mysql', 'my_custom_cs'], - 'unknown pgsql' => ['EUC_JP', 'pgsql', 'EUC_JP'], - ]; - } - public function testCharsetIsAppliedOnActivate(): void { // End-to-end: SQLite encoding is fixed at creation time; a Charset value @@ -570,9 +487,7 @@ public static function provideDsnDriverCharsetResolutions(): array // OCI: 'UTF-8' resolves to the OCI NLS name 'AL32UTF8' 'oci/UTF-8' => ['oci', 'UTF-8', 'AL32UTF8'], 'oci/ISO-8859-1' => ['oci', 'ISO-8859-1', 'WE8ISO8859P1'], - // MSSQL / sqlsrv / dblib: iconv-compatible names - 'mssql/UTF-8' => ['mssql', 'UTF-8', 'UTF-8'], - 'mssql/ISO-8859-1' => ['mssql', 'ISO-8859-1', 'ISO-8859-1'], + // sqlsrv / dblib: iconv-compatible names 'sqlsrv/UTF-8' => ['sqlsrv', 'UTF-8', 'UTF-8'], 'dblib/UTF-8' => ['dblib', 'UTF-8', 'UTF-8'], // IBM DB2 has no alias table entry → pass-through @@ -692,12 +607,6 @@ public static function provideApplyCharsetToDsnAppend(): array 'UTF-8', 'sqlsrv:Server=localhost;Database=test;CharacterSet=UTF-8', ], - // mssql: UTF-8 → charset=UTF-8 - 'mssql/UTF-8' => [ - 'mssql:host=localhost;dbname=test', - 'UTF-8', - 'mssql:host=localhost;dbname=test;charset=UTF-8', - ], // dblib: ISO-8859-1 → charset=ISO-8859-1 'dblib/ISO-8859-1' => [ 'dblib:host=localhost;dbname=test', @@ -724,6 +633,29 @@ public function testApplyCharsetToDsnRespectsExistingSqlsrvCharacterSet(): void $this->assertSame($dsn, $result); } + public function testApplyCharsetToDsnSkipsSqlsrvIso88591(): void + { + // pdo_sqlsrv only accepts 'UTF-8' or 'SQLSRV_ENC_CHAR' in CharacterSet=. + // ISO-8859-1 resolves to itself (pass-through) and is NOT in the allowlist, + // so applyCharsetToDsn() must return the DSN unchanged rather than injecting + // an invalid CharacterSet=ISO-8859-1 that would cause a connection failure. + $dsn = 'sqlsrv:Server=localhost;Database=test'; + $conn = $this->makeConnWithCharset($dsn, 'ISO-8859-1'); + $result = $this->callApplyCharsetToDsn($conn, $dsn); + $this->assertSame($dsn, $result); + $this->assertStringNotContainsString('CharacterSet', $result); + } + + public function testApplyCharsetToDsnSkipsSqlsrvAscii(): void + { + // 'ASCII' normalizes to 'US-ASCII' for sqlsrv (no driver entry → IANA pass-through), + // which is not in the DSN allowlist — DSN is returned unchanged. + $dsn = 'sqlsrv:Server=localhost;Database=test'; + $conn = $this->makeConnWithCharset($dsn, 'ASCII'); + $result = $this->callApplyCharsetToDsn($conn, $dsn); + $this->assertSame($dsn, $result); + } + /** @dataProvider provideApplyCharsetToDsnNoOp */ public function testApplyCharsetToDsnSkipsForDriver(string $dsn, string $charset): void { @@ -754,4 +686,868 @@ public function testApplyCharsetToDsnEndToEndSqlite(): void $this->assertTrue($conn->Active); $conn->Active = false; } + + // ----------------------------------------------------------------------- + // getDriverName() tests + // ----------------------------------------------------------------------- + + public function testGetDriverNameParsesMysqlFromDsn(): void + { + $conn = new TDbConnection('mysql:host=localhost;dbname=test'); + $this->assertSame('mysql', $conn->DriverName); + } + + public function testGetDriverNameParsesPgsqlFromDsn(): void + { + $conn = new TDbConnection('pgsql:host=localhost;dbname=test'); + $this->assertSame('pgsql', $conn->DriverName); + } + + public function testGetDriverNameParsesSqliteFromDsn(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertSame('sqlite', $conn->DriverName); + } + + public function testGetDriverNameParsesFirebirdFromDsn(): void + { + $conn = new TDbConnection('firebird:dbname=localhost:/var/lib/firebird/test.fdb'); + $this->assertSame('firebird', $conn->DriverName); + } + + public function testGetDriverNameParsesOciFromDsn(): void + { + $conn = new TDbConnection('oci:dbname=//localhost/orcl'); + $this->assertSame('oci', $conn->DriverName); + } + + public function testGetDriverNameParsesIbmFromDsn(): void + { + $conn = new TDbConnection('ibm:DRIVER={IBM DB2 ODBC DRIVER};DATABASE=test'); + $this->assertSame('ibm', $conn->DriverName); + } + + public function testGetDriverNameParsesSqlsrvFromDsn(): void + { + $conn = new TDbConnection('sqlsrv:Server=localhost;Database=test'); + $this->assertSame('sqlsrv', $conn->DriverName); + } + + public function testGetDriverNameParsesDblibFromDsn(): void + { + $conn = new TDbConnection('dblib:host=localhost;dbname=test'); + $this->assertSame('dblib', $conn->DriverName); + } + + public function testGetDriverNameThrowsWhenNoColonInDsn(): void + { + $conn = new TDbConnection('invalid_dsn'); + $this->expectException(TDbException::class); + $conn->DriverName; + } + + public function testGetDriverNameReturnsActiveDriverName(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertSame('sqlite', $conn->DriverName); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // ConnectionString get/set tests + // ----------------------------------------------------------------------- + + public function testGetConnectionString(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertSame('sqlite:' . TEST_DB_FILE, $conn->ConnectionString); + } + + public function testSetConnectionString(): void + { + $conn = new TDbConnection(); + $conn->ConnectionString = 'sqlite:' . TEST_DB_FILE2; + $this->assertSame('sqlite:' . TEST_DB_FILE2, $conn->ConnectionString); + } + + // ----------------------------------------------------------------------- + // Username get/set tests + // ----------------------------------------------------------------------- + + public function testGetUsername(): void + { + $conn = new TDbConnection('sqlite:test', 'myuser', 'mypass'); + $this->assertSame('myuser', $conn->Username); + } + + public function testSetUsername(): void + { + $conn = new TDbConnection(); + $conn->Username = 'newuser'; + $this->assertSame('newuser', $conn->Username); + } + + // ----------------------------------------------------------------------- + // Password get/set tests + // ----------------------------------------------------------------------- + + public function testGetPassword(): void + { + $conn = new TDbConnection('sqlite:test', 'myuser', 'mypass'); + $this->assertSame('mypass', $conn->Password); + } + + public function testSetPassword(): void + { + $conn = new TDbConnection(); + $conn->Password = 'newpass'; + $this->assertSame('newpass', $conn->Password); + } + + public function testSetPasswordCanBeEmpty(): void + { + $conn = new TDbConnection(); + $conn->Password = ''; + $this->assertSame('', $conn->Password); + } + + // ----------------------------------------------------------------------- + // getCurrentTransaction() tests + // ----------------------------------------------------------------------- + + public function testGetCurrentTransactionReturnsNullWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertNull($conn->CurrentTransaction); + } + + public function testGetCurrentTransactionReturnsNullWhenNoTransaction(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertNull($conn->CurrentTransaction); + $conn->Active = false; + } + + public function testGetCurrentTransactionReturnsTransactionWhenActive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->beginTransaction(); + $this->assertNotNull($conn->CurrentTransaction); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // commit() convenience method tests + // ----------------------------------------------------------------------- + + public function testCommitReturnsNullWhenConnectionNotOpen(): void + { + // commit() returns null (not false) when the connection itself is not active — + // distinguishing "not connected" from "no active transaction" (false). + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertNull($conn->commit()); + } + + public function testCommitReturnsFalseWhenNoActiveTransaction(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertFalse($conn->commit()); + $conn->Active = false; + } + + public function testCommitCommitsActiveTransaction(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->createCommand('INSERT INTO foo(id, name) VALUES (1, \'test\')')->execute(); + $conn->beginTransaction(); + $conn->createCommand('UPDATE foo SET name = \'updated\' WHERE id = 1')->execute(); + $this->assertTrue($conn->commit()); + $row = $conn->createCommand('SELECT name FROM foo WHERE id = 1')->queryScalar(); + $this->assertSame('updated', $row); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // rollback() convenience method tests + // ----------------------------------------------------------------------- + + public function testRollbackReturnsNullWhenConnectionNotOpen(): void + { + // rollback() returns null (not false) when the connection itself is not active — + // distinguishing "not connected" from "no active transaction" (false). + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertNull($conn->rollback()); + } + + public function testRollbackReturnsFalseWhenNoActiveTransaction(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertFalse($conn->rollback()); + $conn->Active = false; + } + + public function testRollbackRollsBackActiveTransaction(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->createCommand('INSERT INTO foo(id, name) VALUES (1, \'original\')')->execute(); + $conn->beginTransaction(); + $conn->createCommand('UPDATE foo SET name = \'changed\' WHERE id = 1')->execute(); + $this->assertTrue($conn->rollback()); + $row = $conn->createCommand('SELECT name FROM foo WHERE id = 1')->queryScalar(); + $this->assertSame('original', $row); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getTransactionClass() tests + // ----------------------------------------------------------------------- + + public function testGetTransactionClassReturnsDefault(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertSame(\Prado\Data\TDbTransaction::class, $conn->TransactionClass); + } + + public function testSetTransactionClass(): void + { + $conn = new TDbConnection(); + $conn->TransactionClass = 'MyCustomTransaction'; + $this->assertSame('MyCustomTransaction', $conn->TransactionClass); + } + + public function testSetTransactionClassAllowsNull(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->setTransactionClass('CustomTransactionClass'); + $this->assertSame('CustomTransactionClass', $conn->TransactionClass); + } + + // ----------------------------------------------------------------------- + // getHasAutoCommit() tests + // ----------------------------------------------------------------------- + + public function testGetHasAutoCommitReturnsTrueForSqlite(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertFalse($conn->HasAutoCommit); + } + + // ----------------------------------------------------------------------- + // getAutoCommit() tests + // ----------------------------------------------------------------------- + + public function testGetAutoCommitReturnsValue(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $value = $conn->AutoCommit; + $this->assertIsBool($value); + $conn->Active = false; + } + + public function testSetAutoCommitSetsValue(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->AutoCommit = false; + $this->assertFalse($conn->AutoCommit); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getAttribute() / setAttribute() tests + // ----------------------------------------------------------------------- + + public function testGetAttributeReturnsPdoAttribute(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $driver = $conn->getAttribute(PDO::ATTR_DRIVER_NAME); + $this->assertSame('sqlite', $driver); + $conn->Active = false; + } + + public function testSetAttributeSetsPdoAttribute(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->setAttribute(PDO::ATTR_CASE, PDO::CASE_LOWER); + $this->assertSame(PDO::CASE_LOWER, $conn->getAttribute(PDO::ATTR_CASE)); + $conn->Active = false; + } + + public function testGetAttributeReturnsLazyAttributeWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->setAttribute(PDO::ATTR_PERSISTENT, true); + $this->assertTrue($conn->getAttribute(PDO::ATTR_PERSISTENT)); + } + + public function testSetAttributeStoresLazyAttributeWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->assertSame(PDO::ERRMODE_EXCEPTION, $conn->getAttribute(PDO::ATTR_ERRMODE)); + } + + public function testGetAttributeThrowsWhenInvalidForActiveConnection(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->expectException(\PDOException::class); + $conn->getAttribute(999999); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getPdoInstance() tests + // ----------------------------------------------------------------------- + + public function testGetPdoInstanceReturnsNullWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertNull($conn->PdoInstance); + } + + public function testGetPdoInstanceReturnsPdoWhenActive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertInstanceOf(PDO::class, $conn->PdoInstance); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // Persistent connection tests + // ----------------------------------------------------------------------- + + public function testGetPersistent(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $value = $conn->Persistent; + $this->assertIsBool($value); + $conn->Active = false; + } + + public function testSetPersistent(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->Persistent = false; + $this->assertFalse($conn->Persistent); + $conn->Active = false; + } + +// ----------------------------------------------------------------------- + // Server Version tests (driver-specific; SQLite returns string) + // ----------------------------------------------------------------------- + + public function testGetClientVersion(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $version = $conn->ClientVersion; + $this->assertIsString($version); + $conn->Active = false; + } + + public function testGetServerVersion(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $version = $conn->ServerVersion; + $this->assertIsString($version); + $conn->Active = false; + } + + public function testExtractCharsetFromDsnMysql() + { + $conn = new TDbConnection('mysql:host=localhost;dbname=test', 'user', 'pass'); + // Use reflection to call protected method + $method = new ReflectionMethod(TDbConnection::class, 'extractCharsetFromDsn'); + $method->setAccessible(true); + + // No charset in DSN + $this->assertNull($method->invoke($conn, 'mysql:host=localhost;dbname=test')); + + // With charset in DSN + $this->assertEquals('utf8mb4', $method->invoke($conn, 'mysql:host=localhost;dbname=test;charset=utf8mb4')); + + // With CharacterSet (sqlsrv style) + $this->assertEquals('UTF-8', $method->invoke($conn, 'sqlsrv:Server=localhost;Database=test;CharacterSet=UTF-8')); + } + + public function testExtractCharsetFromDsnSqlite() + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $method = new ReflectionMethod(TDbConnection::class, 'extractCharsetFromDsn'); + $method->setAccessible(true); + + // SQLite doesn't have DSN charset + $this->assertNull($method->invoke($conn, 'sqlite:' . TEST_DB_FILE)); + } + + public function testExtractCharsetFromDsnCaseInsensitive() + { + $conn = new TDbConnection('mysql:host=localhost', 'user', 'pass'); + $method = new ReflectionMethod(TDbConnection::class, 'extractCharsetFromDsn'); + $method->setAccessible(true); + + // Test case insensitive matching + $this->assertEquals('utf8', $method->invoke($conn, 'mysql:host=localhost;CHARSET=utf8')); + $this->assertEquals('utf8', $method->invoke($conn, 'mysql:host=localhost;CharSet=utf8')); + } + + // ----------------------------------------------------------------------- + // getAvailableDrivers() static method + // ----------------------------------------------------------------------- + + public function testGetAvailableDriversReturnsArray(): void + { + $drivers = TDbConnection::getAvailableDrivers(); + $this->assertIsArray($drivers); + } + + public function testGetAvailableDriversMatchesPdo(): void + { + $this->assertSame(PDO::getAvailableDrivers(), TDbConnection::getAvailableDrivers()); + } + + // ----------------------------------------------------------------------- + // __sleep() — serialization removes _pdo and _active + // ----------------------------------------------------------------------- + + public function testSleepExcludesPdoAndActive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + + // __sleep() is called implicitly by serialize() + $props = $conn->__sleep(); + $this->assertNotContains("\0Prado\Data\TDbConnection\0_pdo", $props); + $this->assertNotContains("\0Prado\Data\TDbConnection\0_active", $props); + } + + public function testSerializePreservesConnectionString(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE, 'user', 'pass'); + $conn->Active = true; + + $serialized = serialize($conn); + /** @var TDbConnection $restored */ + $restored = unserialize($serialized); + + $this->assertSame('sqlite:' . TEST_DB_FILE, $restored->ConnectionString); + $this->assertSame('user', $restored->Username); + // After unserializing the connection must be inactive (PDO was stripped) + $this->assertFalse($restored->Active); + $this->assertNull($restored->PdoInstance); + } + + // ----------------------------------------------------------------------- + // setCharset() — inactive connection (stores property only) + // ----------------------------------------------------------------------- + + public function testSetCharsetWhenInactiveStoresProperty(): void + { + $conn = new TDbConnection('mysql:host=localhost;dbname=test'); + $conn->Charset = 'UTF-8'; + $this->assertSame('UTF-8', $conn->Charset); + } + + public function testSetCharsetWhenInactiveAcceptsAnyValue(): void + { + $conn = new TDbConnection('firebird:dbname=localhost:/db/test.fdb'); + $conn->Charset = 'ISO-8859-1'; + $this->assertSame('ISO-8859-1', $conn->Charset); + } + + // ----------------------------------------------------------------------- + // setCharset() — active connection on non-switchable driver → exception + // ----------------------------------------------------------------------- + + /** @dataProvider provideNonSwitchableDrivers */ + public function testSetCharsetThrowsWhenActiveAndDriverCannotSwitch(string $driver): void + { + // Build an active-looking connection with an injected PDO mock. + $mockPdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $mockPdo->method('getAttribute') + ->with(\PDO::ATTR_DRIVER_NAME) + ->willReturn($driver); + + $conn = new TDbConnection($driver . ':host=localhost'); + + $activeProp = new \ReflectionProperty(TDbConnection::class, '_active'); + $activeProp->setAccessible(true); + $activeProp->setValue($conn, true); + + $pdoProp = new \ReflectionProperty(TDbConnection::class, '_pdo'); + $pdoProp->setAccessible(true); + $pdoProp->setValue($conn, $mockPdo); + + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->Charset = 'UTF-8'; + } + + public static function provideNonSwitchableDrivers(): array + { + return [ + 'firebird' => ['firebird'], + 'oci' => ['oci'], + 'sqlsrv' => ['sqlsrv'], + 'dblib' => ['dblib'], + ]; + } + + // ----------------------------------------------------------------------- + // setCharset() — active SQLite connection (runtime-switchable via PRAGMA) + // ----------------------------------------------------------------------- + + public function testSetCharsetOnActiveSqliteDoesNotThrow(): void + { + // SQLite supports runtime charset via PRAGMA (errors silently ignored). + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + // Must not throw; PRAGMA errors are caught internally. + $conn->Charset = 'UTF-8'; + $this->assertTrue($conn->Active); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // quoteTableName / quoteColumnName / quoteColumnAlias + // ----------------------------------------------------------------------- + + public function testQuoteTableNameDelegatesToMetaData(): void + { + // SQLite meta-data wraps names in double-quotes. + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $quoted = $conn->quoteTableName('my_table'); + $this->assertStringContainsString('my_table', $quoted); + $conn->Active = false; + } + + public function testQuoteColumnNameDelegatesToMetaData(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $quoted = $conn->quoteColumnName('my_col'); + $this->assertStringContainsString('my_col', $quoted); + $conn->Active = false; + } + + public function testQuoteColumnAliasDelegatesToMetaData(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $quoted = $conn->quoteColumnAlias('my_alias'); + $this->assertStringContainsString('my_alias', $quoted); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getDbMetaData() + // ----------------------------------------------------------------------- + + public function testGetDbMetaDataReturnsMetaDataInstance(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $meta = $conn->DbMetaData; + $this->assertInstanceOf(\Prado\Data\Common\TDbMetaData::class, $meta); + $conn->Active = false; + } + + public function testGetDbMetaDataReturnsCachedInstance(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $meta1 = $conn->DbMetaData; + $meta2 = $conn->DbMetaData; + $this->assertSame($meta1, $meta2); + $conn->Active = false; + } + + public function testGetDbMetaDataReturnsSqliteMetaData(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertInstanceOf(\Prado\Data\Common\Sqlite\TSqliteMetaData::class, $conn->DbMetaData); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getLastInsertID() / quoteString() throw when inactive + // ----------------------------------------------------------------------- + + public function testGetLastInsertIdThrowsWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->LastInsertID; + } + + public function testQuoteStringThrowsWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->quoteString('test'); + } + + public function testCreateCommandThrowsWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->createCommand('SELECT 1'); + } + + // ----------------------------------------------------------------------- + // beginTransaction() — duplicate / active transaction guard + // ----------------------------------------------------------------------- + + public function testBeginTransactionThrowsWhenTransactionAlreadyActive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->beginTransaction(); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->beginTransaction(); // second call with same transaction open + $conn->Active = false; + } + + public function testBeginTransactionThrowsWhenInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->beginTransaction(); + } + + public function testBeginTransactionReturnsNewTransactionAfterRollback(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $txn1 = $conn->beginTransaction(); + $txn1->rollBack(); + $txn2 = $conn->beginTransaction(); + $this->assertNotNull($txn2); + $this->assertTrue($txn2->Active); + $txn2->rollBack(); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getCurrentTransaction() edge cases + // ----------------------------------------------------------------------- + + public function testGetCurrentTransactionReturnsNullAfterCommit(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $txn = $conn->beginTransaction(); + $txn->commit(); + $this->assertNull($conn->CurrentTransaction); + $conn->Active = false; + } + + public function testGetCurrentTransactionReturnsNullAfterRollback(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $txn = $conn->beginTransaction(); + $txn->rollBack(); + $this->assertNull($conn->CurrentTransaction); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // fxDataGetMetaDataClass event — raised by TDbDriverCapabilities::getMetaDataClass + // when the driver is unknown; TDbMetaData::getInstance calls it via the connection. + // ----------------------------------------------------------------------- + + public function testFxDataGetMetaDataClassEventCanBeHandledByBehavior(): void + { + // Attach a global behavior that handles fxDataGetMetaDataClass and supplies + // TSqliteMetaData as the handler for a custom driver. + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + + // Verify the known 'sqlite' driver path works without the event. + $meta = $conn->DbMetaData; + $this->assertInstanceOf(\Prado\Data\Common\Sqlite\TSqliteMetaData::class, $meta); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // TransactionClass — get/set/null + // ----------------------------------------------------------------------- + + public function testSetTransactionClassToNullResetsToDefault(): void + { + // Passing null resets TransactionClass to the built-in default rather than + // storing null — null means "use the default class". + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->TransactionClass = \Prado\Data\TDbTransaction::class; + $conn->setTransactionClass(null); + $this->assertSame(\Prado\Data\TDbTransaction::class, $conn->TransactionClass); + } + + public function testSetTransactionClassToCustom(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->TransactionClass = \Prado\Data\TDbTransaction::class; + $this->assertSame(\Prado\Data\TDbTransaction::class, $conn->TransactionClass); + } + + // ----------------------------------------------------------------------- + // HasAutoCommit — per-driver + // ----------------------------------------------------------------------- + + public function testHasAutoCommitIsFalseForSqlite(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertFalse($conn->HasAutoCommit); + } + + public function testHasAutoCommitIsTrueForMysqlDsn(): void + { + // Not connected; DriverName derived from DSN. + $conn = new TDbConnection('mysql:host=localhost;dbname=test'); + $this->assertTrue($conn->HasAutoCommit); + } + + public function testHasAutoCommitIsFalseForPgsqlDsn(): void + { + // pgsql does not expose PDO::ATTR_AUTOCOMMIT; hasAutoCommitAttribute=false. + $conn = new TDbConnection('pgsql:host=localhost;dbname=test'); + $this->assertFalse($conn->HasAutoCommit); + } + + // ----------------------------------------------------------------------- + // AutoCommit read/write — SQLite (no attribute → no-op) + // ----------------------------------------------------------------------- + + public function testGetAutoCommitReturnsFalseWhenNoAutoCommitAttribute(): void + { + // SQLite: hasAutoCommitAttribute = false → getAutoCommit must return false. + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $this->assertFalse($conn->AutoCommit); + $conn->Active = false; + } + + public function testSetAutoCommitIsNoOpWhenNoAutoCommitAttribute(): void + { + // SQLite: setAutoCommit is a no-op; must not throw. + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->AutoCommit = true; // no-op for sqlite + $this->assertFalse($conn->AutoCommit); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // commit() / rollback() — return value semantics + // ----------------------------------------------------------------------- + + public function testCommitReturnsTrueOnSuccess(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->beginTransaction(); + $this->assertTrue($conn->commit()); + $conn->Active = false; + } + + public function testRollbackReturnsTrueOnSuccess(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + $conn->beginTransaction(); + $this->assertTrue($conn->rollback()); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // ColumnCase / NullConversion — full enum round-trip + // ----------------------------------------------------------------------- + + public function testColumnCaseUpperAndLower(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + + $conn->ColumnCase = \Prado\Data\TDbColumnCaseMode::UpperCase; + $this->assertSame(\Prado\Data\TDbColumnCaseMode::UpperCase, $conn->ColumnCase); + + $conn->ColumnCase = \Prado\Data\TDbColumnCaseMode::Preserved; + $this->assertSame(\Prado\Data\TDbColumnCaseMode::Preserved, $conn->ColumnCase); + $conn->Active = false; + } + + public function testNullConversionEmptyStringAndPreserved(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $conn->Active = true; + + $conn->NullConversion = \Prado\Data\TDbNullConversionMode::EmptyStringToNull; + $this->assertSame(\Prado\Data\TDbNullConversionMode::EmptyStringToNull, $conn->NullConversion); + + $conn->NullConversion = \Prado\Data\TDbNullConversionMode::Preserved; + $this->assertSame(\Prado\Data\TDbNullConversionMode::Preserved, $conn->NullConversion); + $conn->Active = false; + } + + // ----------------------------------------------------------------------- + // getDriverName() — extractDriverFromDsn edge cases + // ----------------------------------------------------------------------- + + public function testGetDriverNameFromEmptyDsnThrows(): void + { + $conn = new TDbConnection(''); + $this->expectException(\Prado\Exceptions\TDbException::class); + $conn->DriverName; + } + + public function testGetDriverNameIsCaseLowered(): void + { + // DSN prefixes are case-insensitive; TDbConnection normalises to lowercase. + $conn = new TDbConnection('SQLite:' . TEST_DB_FILE); + $this->assertSame('sqlite', $conn->DriverName); + } + + // ----------------------------------------------------------------------- + // getDatabaseCharset() — inactive path + // ----------------------------------------------------------------------- + + public function testGetDatabaseCharsetReturnsEmptyStringWhenNotSetAndInactive(): void + { + $conn = new TDbConnection('sqlite:' . TEST_DB_FILE); + $this->assertSame('', $conn->DatabaseCharset); + } + + // ----------------------------------------------------------------------- + // applyCharsetToDsn() — interbase treated as firebird for DSN param + // ----------------------------------------------------------------------- + + public function testApplyCharsetToDsnInterbaseUsesCharsetParam(): void + { + $dsn = 'interbase:dbname=localhost:/db/test.gdb'; + $conn = new TDbConnection($dsn, '', '', 'UTF-8'); + $method = new \ReflectionMethod(TDbConnection::class, 'applyCharsetToDsn'); + $method->setAccessible(true); + $result = $method->invoke($conn, $dsn); + $this->assertStringContainsString('charset=', $result); + } + } diff --git a/tests/unit/Data/TDbDriverCapabilitiesTest.php b/tests/unit/Data/TDbDriverCapabilitiesTest.php new file mode 100644 index 000000000..950e84327 --- /dev/null +++ b/tests/unit/Data/TDbDriverCapabilitiesTest.php @@ -0,0 +1,1436 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +use Prado\Data\TDataCharset; +use Prado\Data\TDbConnection; +use Prado\Data\TDbDriver; +use Prado\Data\TDbDriverCapabilities; +use Prado\Data\Common\Firebird\TFirebirdMetaData; +use Prado\Data\Common\Ibm\TIbmMetaData; +use Prado\Data\Common\IDataMetaData; +use Prado\Data\Common\SqlSrv\TSqlSrvMetaData; +use Prado\Data\Common\Mysql\TMysqlMetaData; +use Prado\Data\Common\Oracle\TOracleMetaData; +use Prado\Data\Common\Pgsql\TPgsqlMetaData; +use Prado\Data\Common\Sqlite\TSqliteMetaData; +use Prado\Exceptions\TDbException; + +/** + * Comprehensive unit tests for {@see TDbDriverCapabilities}. + * + * All methods under test are static and require no live database connection. + * Coverage: + * - canonicalizeCharset + * - resolveCharset (all charsets × all drivers, aliases, interbase, pass-through) + * - unresolveCharset (all charsets × all drivers, interbase, unknown pass-through) + * - getCharsetSetSql + * - getCharsetPragmaSql + * - supportsRuntimeCharsetSet + * - requiresPostConnectCharset + * - getCharsetDsnParam + * - getCharsetDsnPattern (includes live regex verification) + * - getCharsetQuerySql + * - requiresPreBeginTransactionFlush + * - requiresPostTransactionFlush + * - getListTablesSql + * - supportsCharset + * - hasAutoCommitAttribute + * - getMetaDataClass (all drivers + fxDataGetMetaDataClass event) + * - getScaffoldInputFile + * - getScaffoldInputClass + * - createScaffoldInput (all drivers + fxActiveRecordScaffoldInputClass event) + */ +class TDbDriverCapabilitiesTest extends PHPUnit\Framework\TestCase +{ + // ========================================================================= + // canonicalizeCharset + // ========================================================================= + + /** @dataProvider provideCanonicalizeCharset */ + public function testCanonicalizeCharset(string $input, string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::canonicalizeCharset($input)); + } + + public static function provideCanonicalizeCharset(): array + { + return [ + 'UTF-8' => ['UTF-8', 'utf8'], + 'utf-8' => ['utf-8', 'utf8'], + 'UTF8' => ['UTF8', 'utf8'], + 'utf8' => ['utf8', 'utf8'], + 'UTF 8' => ['UTF 8', 'utf8'], + 'UTF_8' => ['UTF_8', 'utf8'], + 'UTF-16' => ['UTF-16', 'utf16'], + 'utf16' => ['utf16', 'utf16'], + 'UTF-16LE' => ['UTF-16LE', 'utf16le'], + 'utf16le' => ['utf16le', 'utf16le'], + 'UTF-16BE' => ['UTF-16BE', 'utf16be'], + 'utf16be' => ['utf16be', 'utf16be'], + 'ISO-8859-1' => ['ISO-8859-1', 'iso88591'], + 'iso88591' => ['iso88591', 'iso88591'], + 'ISO_8859_1' => ['ISO_8859_1', 'iso88591'], + 'ISO-8859-2' => ['ISO-8859-2', 'iso88592'], + 'ASCII' => ['ASCII', 'ascii'], + 'ascii' => ['ascii', 'ascii'], + 'US-ASCII' => ['US-ASCII', 'usascii'], + 'usascii' => ['usascii', 'usascii'], + 'Windows-1250' => ['Windows-1250', 'windows1250'], + 'windows-1250' => ['windows-1250', 'windows1250'], + 'windows1250' => ['windows1250', 'windows1250'], + 'win1250' => ['win1250', 'win1250'], + 'CP1250' => ['CP1250', 'cp1250'], + 'Windows-1251' => ['Windows-1251', 'windows1251'], + 'windows-1251' => ['windows-1251', 'windows1251'], + 'Windows-1252' => ['Windows-1252', 'windows1252'], + 'windows-1252' => ['windows-1252', 'windows1252'], + 'KOI8-R' => ['KOI8-R', 'koi8r'], + 'koi8r' => ['koi8r', 'koi8r'], + 'KOI8_R' => ['KOI8_R', 'koi8r'], + 'KOI8-U' => ['KOI8-U', 'koi8u'], + 'utf8mb4' => ['utf8mb4', 'utf8mb4'], + 'latin1' => ['latin1', 'latin1'], + 'empty' => ['', ''], + 'already-lower' => ['alreadylower', 'alreadylower'], + ]; + } + + // ========================================================================= + // resolveCharset — comprehensive matrix + // ========================================================================= + + /** @dataProvider provideResolveCharset */ + public function testResolveCharset(string $charset, string $driver, string $expected): void + { + $this->assertSame( + $expected, + TDbDriverCapabilities::resolveCharset($charset, $driver), + "resolveCharset('$charset', '$driver') expected '$expected'" + ); + } + + public static function provideResolveCharset(): array + { + return [ + // --- UTF-8 (TDataCharset::UTF8 = 'UTF-8') --- + 'UTF-8/mysql' => ['UTF-8', TDbDriver::DRIVER_MYSQL, 'utf8mb4'], + 'UTF-8/pgsql' => ['UTF-8', TDbDriver::DRIVER_PGSQL, 'UTF8'], + 'UTF-8/sqlite' => ['UTF-8', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'UTF-8/sqlite2' => ['UTF-8', TDbDriver::DRIVER_SQLITE2, 'UTF-8'], + 'UTF-8/firebird' => ['UTF-8', TDbDriver::DRIVER_FIREBIRD, 'UTF8'], + 'UTF-8/interbase' => ['UTF-8', TDbDriver::DRIVER_INTERBASE,'UTF8'], + 'UTF-8/oci' => ['UTF-8', TDbDriver::DRIVER_OCI, 'AL32UTF8'], + 'UTF-8/sqlsrv' => ['UTF-8', TDbDriver::DRIVER_SQLSRV, 'UTF-8'], + 'UTF-8/dblib' => ['UTF-8', TDbDriver::DRIVER_DBLIB, 'UTF-8'], + 'UTF-8/ibm' => ['UTF-8', TDbDriver::DRIVER_IBM, 'UTF-8'], // pass-through (no entry) + + // --- UTF-16 (TDataCharset::UTF16 = 'UTF-16') --- + 'UTF-16/mysql' => ['UTF-16', TDbDriver::DRIVER_MYSQL, 'utf16'], + 'UTF-16/pgsql' => ['UTF-16', TDbDriver::DRIVER_PGSQL, 'UTF-16'], // no pgsql entry → pass-through + 'UTF-16/sqlite' => ['UTF-16', TDbDriver::DRIVER_SQLITE, 'UTF-16'], + 'UTF-16/firebird' => ['UTF-16', TDbDriver::DRIVER_FIREBIRD, 'UTF16BE'], + 'UTF-16/interbase' => ['UTF-16', TDbDriver::DRIVER_INTERBASE,'UTF16BE'], + 'UTF-16/oci' => ['UTF-16', TDbDriver::DRIVER_OCI, 'AL16UTF16'], + 'UTF-16/sqlsrv' => ['UTF-16', TDbDriver::DRIVER_SQLSRV, 'UTF-16'], // no entry → pass-through + 'UTF-16/ibm' => ['UTF-16', TDbDriver::DRIVER_IBM, 'UTF-16'], // no entry → pass-through + + // --- UTF-16LE (TDataCharset::UTF16LE = 'UTF-16LE') --- + 'UTF-16LE/mysql' => ['UTF-16LE', TDbDriver::DRIVER_MYSQL, 'utf16le'], + 'UTF-16LE/sqlite' => ['UTF-16LE', TDbDriver::DRIVER_SQLITE, 'UTF-16le'], + 'UTF-16LE/pgsql' => ['UTF-16LE', TDbDriver::DRIVER_PGSQL, 'UTF-16LE'], // no entry → pass-through + 'UTF-16LE/firebird'=> ['UTF-16LE', TDbDriver::DRIVER_FIREBIRD, 'UTF-16LE'], // no entry → pass-through + 'UTF-16LE/oci' => ['UTF-16LE', TDbDriver::DRIVER_OCI, 'UTF-16LE'], // no entry → pass-through + 'UTF-16LE/sqlsrv' => ['UTF-16LE', TDbDriver::DRIVER_SQLSRV, 'UTF-16LE'], // no entry → pass-through + + // --- UTF-16BE (TDataCharset::UTF16BE = 'UTF-16BE') --- + 'UTF-16BE/mysql' => ['UTF-16BE', TDbDriver::DRIVER_MYSQL, 'utf16'], + 'UTF-16BE/sqlite' => ['UTF-16BE', TDbDriver::DRIVER_SQLITE, 'UTF-16be'], + 'UTF-16BE/firebird'=> ['UTF-16BE', TDbDriver::DRIVER_FIREBIRD, 'UTF16BE'], + 'UTF-16BE/oci' => ['UTF-16BE', TDbDriver::DRIVER_OCI, 'AL16UTF16'], + 'UTF-16BE/pgsql' => ['UTF-16BE', TDbDriver::DRIVER_PGSQL, 'UTF-16BE'], // no entry → pass-through + 'UTF-16BE/sqlsrv' => ['UTF-16BE', TDbDriver::DRIVER_SQLSRV, 'UTF-16BE'], // no entry → pass-through + + // --- ISO-8859-1 / Latin1 --- + 'ISO-8859-1/mysql' => ['ISO-8859-1', TDbDriver::DRIVER_MYSQL, 'latin1'], + 'ISO-8859-1/pgsql' => ['ISO-8859-1', TDbDriver::DRIVER_PGSQL, 'LATIN1'], + 'ISO-8859-1/sqlite' => ['ISO-8859-1', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'ISO-8859-1/firebird' => ['ISO-8859-1', TDbDriver::DRIVER_FIREBIRD, 'ISO8859_1'], + 'ISO-8859-1/interbase' => ['ISO-8859-1', TDbDriver::DRIVER_INTERBASE,'ISO8859_1'], + 'ISO-8859-1/oci' => ['ISO-8859-1', TDbDriver::DRIVER_OCI, 'WE8ISO8859P1'], + 'ISO-8859-1/sqlsrv' => ['ISO-8859-1', TDbDriver::DRIVER_SQLSRV, 'ISO-8859-1'], // no sqlsrv entry → pass-through + 'ISO-8859-1/dblib' => ['ISO-8859-1', TDbDriver::DRIVER_DBLIB, 'ISO-8859-1'], + 'ISO-8859-1/ibm' => ['ISO-8859-1', TDbDriver::DRIVER_IBM, 'ISO-8859-1'], // no entry → pass-through + + // --- ISO-8859-2 / Latin2 --- + 'ISO-8859-2/mysql' => ['ISO-8859-2', TDbDriver::DRIVER_MYSQL, 'latin2'], + 'ISO-8859-2/pgsql' => ['ISO-8859-2', TDbDriver::DRIVER_PGSQL, 'LATIN2'], + 'ISO-8859-2/sqlite' => ['ISO-8859-2', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'ISO-8859-2/firebird' => ['ISO-8859-2', TDbDriver::DRIVER_FIREBIRD, 'ISO8859_2'], + 'ISO-8859-2/sqlsrv' => ['ISO-8859-2', TDbDriver::DRIVER_SQLSRV, 'ISO-8859-2'], // no sqlsrv entry → pass-through + 'ISO-8859-2/oci' => ['ISO-8859-2', TDbDriver::DRIVER_OCI, 'EE8ISO8859P2'], + 'ISO-8859-2/dblib' => ['ISO-8859-2', TDbDriver::DRIVER_DBLIB, 'ISO-8859-2'], + 'ISO-8859-2/ibm' => ['ISO-8859-2', TDbDriver::DRIVER_IBM, 'ISO-8859-2'], + + // --- ASCII --- + 'ASCII/mysql' => ['ASCII', TDbDriver::DRIVER_MYSQL, 'ascii'], + 'ASCII/pgsql' => ['ASCII', TDbDriver::DRIVER_PGSQL, 'SQL_ASCII'], + 'ASCII/sqlite' => ['ASCII', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'ASCII/firebird' => ['ASCII', TDbDriver::DRIVER_FIREBIRD, 'ASCII'], + 'ASCII/sqlsrv' => ['ASCII', TDbDriver::DRIVER_SQLSRV, 'US-ASCII'], // no sqlsrv entry → normalized IANA pass-through + 'ASCII/oci' => ['ASCII', TDbDriver::DRIVER_OCI, 'US7ASCII'], + 'ASCII/dblib' => ['ASCII', TDbDriver::DRIVER_DBLIB, 'ASCII'], + 'ASCII/ibm' => ['ASCII', TDbDriver::DRIVER_IBM, 'US-ASCII'], // no ibm entry → normalized IANA pass-through + // IANA canonical form also resolves correctly + 'US-ASCII/mysql' => ['US-ASCII', TDbDriver::DRIVER_MYSQL, 'ascii'], + 'US-ASCII/firebird' => ['US-ASCII', TDbDriver::DRIVER_FIREBIRD, 'ASCII'], + 'US-ASCII/oci' => ['US-ASCII', TDbDriver::DRIVER_OCI, 'US7ASCII'], + + // --- Windows-1250 --- + 'Windows-1250/mysql' => ['Windows-1250', TDbDriver::DRIVER_MYSQL, 'cp1250'], + 'Windows-1250/pgsql' => ['Windows-1250', TDbDriver::DRIVER_PGSQL, 'WIN1250'], + 'Windows-1250/sqlite' => ['Windows-1250', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'Windows-1250/firebird' => ['Windows-1250', TDbDriver::DRIVER_FIREBIRD, 'WIN1250'], + 'Windows-1250/sqlsrv' => ['Windows-1250', TDbDriver::DRIVER_SQLSRV, 'windows-1250'], // no sqlsrv entry → normalized IANA pass-through + 'Windows-1250/oci' => ['Windows-1250', TDbDriver::DRIVER_OCI, 'EE8MSWIN1250'], + 'Windows-1250/dblib' => ['Windows-1250', TDbDriver::DRIVER_DBLIB, 'CP1250'], + + // --- Windows-1251 --- + 'Windows-1251/mysql' => ['Windows-1251', TDbDriver::DRIVER_MYSQL, 'cp1251'], + 'Windows-1251/pgsql' => ['Windows-1251', TDbDriver::DRIVER_PGSQL, 'WIN1251'], + 'Windows-1251/sqlite' => ['Windows-1251', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'Windows-1251/firebird' => ['Windows-1251', TDbDriver::DRIVER_FIREBIRD, 'WIN1251'], + 'Windows-1251/sqlsrv' => ['Windows-1251', TDbDriver::DRIVER_SQLSRV, 'windows-1251'], // no sqlsrv entry → normalized IANA pass-through + 'Windows-1251/oci' => ['Windows-1251', TDbDriver::DRIVER_OCI, 'CL8MSWIN1251'], + 'Windows-1251/dblib' => ['Windows-1251', TDbDriver::DRIVER_DBLIB, 'CP1251'], + + // --- Windows-1252 --- + 'Windows-1252/mysql' => ['Windows-1252', TDbDriver::DRIVER_MYSQL, 'cp1252'], + 'Windows-1252/pgsql' => ['Windows-1252', TDbDriver::DRIVER_PGSQL, 'WIN1252'], + 'Windows-1252/sqlite' => ['Windows-1252', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'Windows-1252/firebird' => ['Windows-1252', TDbDriver::DRIVER_FIREBIRD, 'WIN1252'], + 'Windows-1252/sqlsrv' => ['Windows-1252', TDbDriver::DRIVER_SQLSRV, 'windows-1252'], // no sqlsrv entry → normalized IANA pass-through + 'Windows-1252/oci' => ['Windows-1252', TDbDriver::DRIVER_OCI, 'WE8MSWIN1252'], + 'Windows-1252/dblib' => ['Windows-1252', TDbDriver::DRIVER_DBLIB, 'CP1252'], + + // --- KOI8-R --- + 'KOI8-R/mysql' => ['KOI8-R', TDbDriver::DRIVER_MYSQL, 'koi8r'], + 'KOI8-R/pgsql' => ['KOI8-R', TDbDriver::DRIVER_PGSQL, 'KOI8R'], + 'KOI8-R/sqlite' => ['KOI8-R', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'KOI8-R/firebird' => ['KOI8-R', TDbDriver::DRIVER_FIREBIRD, 'KOI8R'], + 'KOI8-R/sqlsrv' => ['KOI8-R', TDbDriver::DRIVER_SQLSRV, 'KOI8-R'], // no sqlsrv entry → pass-through + 'KOI8-R/oci' => ['KOI8-R', TDbDriver::DRIVER_OCI, 'CL8KOI8R'], + 'KOI8-R/dblib' => ['KOI8-R', TDbDriver::DRIVER_DBLIB, 'KOI8-R'], + + // --- KOI8-U --- + 'KOI8-U/mysql' => ['KOI8-U', TDbDriver::DRIVER_MYSQL, 'koi8u'], + 'KOI8-U/pgsql' => ['KOI8-U', TDbDriver::DRIVER_PGSQL, 'KOI8U'], + 'KOI8-U/sqlite' => ['KOI8-U', TDbDriver::DRIVER_SQLITE, 'UTF-8'], + 'KOI8-U/firebird' => ['KOI8-U', TDbDriver::DRIVER_FIREBIRD, 'KOI8U'], + 'KOI8-U/sqlsrv' => ['KOI8-U', TDbDriver::DRIVER_SQLSRV, 'KOI8-U'], // no sqlsrv entry → pass-through + 'KOI8-U/oci' => ['KOI8-U', TDbDriver::DRIVER_OCI, 'CL8KOI8U'], + 'KOI8-U/dblib' => ['KOI8-U', TDbDriver::DRIVER_DBLIB, 'KOI8-U'], + + // --- Canonical key aliases --- + 'utf8/mysql' => ['utf8', TDbDriver::DRIVER_MYSQL, 'utf8mb4'], // canonical alias + 'utf8mb4/mysql' => ['utf8mb4', TDbDriver::DRIVER_MYSQL, 'utf8mb4'], // canonical alias + 'utf8mb4/pgsql' => ['utf8mb4', TDbDriver::DRIVER_PGSQL, 'UTF8'], + 'latin1/mysql' => ['latin1', TDbDriver::DRIVER_MYSQL, 'latin1'], // canonical alias + 'latin1/pgsql' => ['latin1', TDbDriver::DRIVER_PGSQL, 'LATIN1'], + 'latin2/mysql' => ['latin2', TDbDriver::DRIVER_MYSQL, 'latin2'], + 'iso88591/mysql' => ['iso88591',TDbDriver::DRIVER_MYSQL, 'latin1'], // canonical alias + 'ascii/mysql' => ['ascii', TDbDriver::DRIVER_MYSQL, 'ascii'], // canonical alias + 'win1250/mysql' => ['win1250', TDbDriver::DRIVER_MYSQL, 'cp1250'], // canonical alias + 'cp1250/pgsql' => ['cp1250', TDbDriver::DRIVER_PGSQL, 'WIN1250'], + 'koi8r/mysql' => ['koi8r', TDbDriver::DRIVER_MYSQL, 'koi8r'], // canonical alias + 'koi8u/pgsql' => ['koi8u', TDbDriver::DRIVER_PGSQL, 'KOI8U'], + 'utf16/sqlite' => ['utf16', TDbDriver::DRIVER_SQLITE,'UTF-16'], // canonical alias + 'utf16le/mysql' => ['utf16le', TDbDriver::DRIVER_MYSQL, 'utf16le'], // canonical alias + 'utf16le/sqlite' => ['utf16le', TDbDriver::DRIVER_SQLITE, 'UTF-16le'], // canonical alias + 'utf16be/mysql' => ['utf16be', TDbDriver::DRIVER_MYSQL, 'utf16'], // canonical alias + 'utf16be/sqlite' => ['utf16be', TDbDriver::DRIVER_SQLITE, 'UTF-16be'], // canonical alias + 'utf16be/firebird'=> ['utf16be', TDbDriver::DRIVER_FIREBIRD,'UTF16BE'], // canonical alias + + // --- Case/punctuation variants resolve via canonicalization --- + 'UTF-8 variants/mysql' => ['utf-8', TDbDriver::DRIVER_MYSQL, 'utf8mb4'], + 'UTF8 variants/mysql' => ['UTF8', TDbDriver::DRIVER_MYSQL, 'utf8mb4'], + 'iso-8859-1 variant/mysql' => ['iso-8859-1', TDbDriver::DRIVER_MYSQL, 'latin1'], + 'ISO_8859_1 variant/mysql' => ['ISO_8859_1', TDbDriver::DRIVER_MYSQL, 'latin1'], + 'windows1252/mysql' => ['windows1252', TDbDriver::DRIVER_MYSQL, 'cp1252'], + 'WIN-1252/pgsql' => ['WIN-1252', TDbDriver::DRIVER_PGSQL, 'WIN1252'], + 'win_1251/firebird' => ['win_1251', TDbDriver::DRIVER_FIREBIRD,'WIN1251'], + 'KOI8_R/pgsql' => ['KOI8_R', TDbDriver::DRIVER_PGSQL, 'KOI8R'], + + // --- Unknown charset: pass-through --- + 'unknown/mysql' => ['my_custom_cs', TDbDriver::DRIVER_MYSQL, 'my_custom_cs'], + 'unknown/pgsql' => ['EUC_JP', TDbDriver::DRIVER_PGSQL, 'EUC_JP'], + 'unknown/sqlite' => ['EXOTIC', TDbDriver::DRIVER_SQLITE, 'EXOTIC'], + + // --- Unknown driver: pass-through --- + // Note: 'latin1' is a canonical alias for 'ISO-8859-1' in the first + // lookup step (alias → TDataCharset::Latin1 → canonical key), so the + // output for an unknown driver is 'ISO-8859-1', not the input 'latin1'. + 'UTF-8/unknown' => ['UTF-8', 'unknown_db', 'UTF-8'], + 'latin1/mongo' => ['latin1', TDbDriver::DRIVER_MONGO, 'ISO-8859-1'], + ]; + } + + // ========================================================================= + // unresolveCharset — comprehensive matrix + // ========================================================================= + + /** @dataProvider provideUnresolveCharset */ + public function testUnresolveCharset(string $dbCharset, string $driver, string $expected): void + { + $this->assertSame( + $expected, + TDbDriverCapabilities::unresolveCharset($dbCharset, $driver), + "unresolveCharset('$dbCharset', '$driver') expected '$expected'" + ); + } + + public static function provideUnresolveCharset(): array + { + return [ + // --- MySQL --- + 'mysql/utf8mb4' => ['utf8mb4', TDbDriver::DRIVER_MYSQL, TDataCharset::UTF8], + 'mysql/utf8' => ['utf8', TDbDriver::DRIVER_MYSQL, TDataCharset::UTF8], + 'mysql/utf16' => ['utf16', TDbDriver::DRIVER_MYSQL, TDataCharset::UTF16BE], + 'mysql/utf16le' => ['utf16le', TDbDriver::DRIVER_MYSQL, TDataCharset::UTF16LE], + 'mysql/latin1' => ['latin1', TDbDriver::DRIVER_MYSQL, TDataCharset::Latin1], + 'mysql/latin2' => ['latin2', TDbDriver::DRIVER_MYSQL, TDataCharset::Latin2], + 'mysql/ascii' => ['ascii', TDbDriver::DRIVER_MYSQL, TDataCharset::ASCII], + 'mysql/cp1250' => ['cp1250', TDbDriver::DRIVER_MYSQL, TDataCharset::Win1250], + 'mysql/cp1251' => ['cp1251', TDbDriver::DRIVER_MYSQL, TDataCharset::Win1251], + 'mysql/cp1252' => ['cp1252', TDbDriver::DRIVER_MYSQL, TDataCharset::Win1252], + 'mysql/koi8r' => ['koi8r', TDbDriver::DRIVER_MYSQL, TDataCharset::KOI8R], + 'mysql/koi8u' => ['koi8u', TDbDriver::DRIVER_MYSQL, TDataCharset::KOI8U], + + // --- SQLite --- + 'sqlite/UTF-8' => ['UTF-8', TDbDriver::DRIVER_SQLITE, TDataCharset::UTF8], + 'sqlite/UTF-16' => ['UTF-16', TDbDriver::DRIVER_SQLITE, TDataCharset::UTF16], + 'sqlite/UTF-16le' => ['UTF-16le', TDbDriver::DRIVER_SQLITE, TDataCharset::UTF16LE], + 'sqlite/UTF-16be' => ['UTF-16be', TDbDriver::DRIVER_SQLITE, TDataCharset::UTF16BE], + + // --- PostgreSQL --- + 'pgsql/UTF8' => ['UTF8', TDbDriver::DRIVER_PGSQL, TDataCharset::UTF8], + 'pgsql/UTF16' => ['UTF16', TDbDriver::DRIVER_PGSQL, TDataCharset::UTF16], + 'pgsql/LATIN1' => ['LATIN1', TDbDriver::DRIVER_PGSQL, TDataCharset::Latin1], + 'pgsql/LATIN2' => ['LATIN2', TDbDriver::DRIVER_PGSQL, TDataCharset::Latin2], + 'pgsql/SQL_ASCII'=> ['SQL_ASCII', TDbDriver::DRIVER_PGSQL, TDataCharset::ASCII], + 'pgsql/WIN1250' => ['WIN1250', TDbDriver::DRIVER_PGSQL, TDataCharset::Win1250], + 'pgsql/WIN1251' => ['WIN1251', TDbDriver::DRIVER_PGSQL, TDataCharset::Win1251], + 'pgsql/WIN1252' => ['WIN1252', TDbDriver::DRIVER_PGSQL, TDataCharset::Win1252], + 'pgsql/KOI8R' => ['KOI8R', TDbDriver::DRIVER_PGSQL, TDataCharset::KOI8R], + 'pgsql/KOI8U' => ['KOI8U', TDbDriver::DRIVER_PGSQL, TDataCharset::KOI8U], + + // --- Firebird --- + 'firebird/UTF8' => ['UTF8', TDbDriver::DRIVER_FIREBIRD, TDataCharset::UTF8], + 'firebird/UTF16BE' => ['UTF16BE', TDbDriver::DRIVER_FIREBIRD, TDataCharset::UTF16BE], + 'firebird/ISO8859_1'=> ['ISO8859_1',TDbDriver::DRIVER_FIREBIRD, TDataCharset::Latin1], + 'firebird/ISO8859_2'=> ['ISO8859_2',TDbDriver::DRIVER_FIREBIRD, TDataCharset::Latin2], + 'firebird/ASCII' => ['ASCII', TDbDriver::DRIVER_FIREBIRD, TDataCharset::ASCII], + 'firebird/WIN1250' => ['WIN1250', TDbDriver::DRIVER_FIREBIRD, TDataCharset::Win1250], + 'firebird/WIN1251' => ['WIN1251', TDbDriver::DRIVER_FIREBIRD, TDataCharset::Win1251], + 'firebird/WIN1252' => ['WIN1252', TDbDriver::DRIVER_FIREBIRD, TDataCharset::Win1252], + 'firebird/KOI8R' => ['KOI8R', TDbDriver::DRIVER_FIREBIRD, TDataCharset::KOI8R], + 'firebird/KOI8U' => ['KOI8U', TDbDriver::DRIVER_FIREBIRD, TDataCharset::KOI8U], + + // --- Interbase alias → same as firebird --- + 'interbase/UTF8' => ['UTF8', TDbDriver::DRIVER_INTERBASE, TDataCharset::UTF8], + 'interbase/UTF16BE' => ['UTF16BE', TDbDriver::DRIVER_INTERBASE, TDataCharset::UTF16BE], + 'interbase/ISO8859_1'=>['ISO8859_1',TDbDriver::DRIVER_INTERBASE, TDataCharset::Latin1], + + // --- Oracle --- + 'oci/AL32UTF8' => ['AL32UTF8', TDbDriver::DRIVER_OCI, TDataCharset::UTF8], + 'oci/AL16UTF16' => ['AL16UTF16', TDbDriver::DRIVER_OCI, TDataCharset::UTF16BE], + 'oci/WE8ISO8859P1' => ['WE8ISO8859P1', TDbDriver::DRIVER_OCI, TDataCharset::Latin1], + 'oci/EE8ISO8859P2' => ['EE8ISO8859P2', TDbDriver::DRIVER_OCI, TDataCharset::Latin2], + 'oci/US7ASCII' => ['US7ASCII', TDbDriver::DRIVER_OCI, TDataCharset::ASCII], + 'oci/EE8MSWIN1250' => ['EE8MSWIN1250', TDbDriver::DRIVER_OCI, TDataCharset::Win1250], + 'oci/CL8MSWIN1251' => ['CL8MSWIN1251', TDbDriver::DRIVER_OCI, TDataCharset::Win1251], + 'oci/WE8MSWIN1252' => ['WE8MSWIN1252', TDbDriver::DRIVER_OCI, TDataCharset::Win1252], + 'oci/CL8KOI8R' => ['CL8KOI8R', TDbDriver::DRIVER_OCI, TDataCharset::KOI8R], + 'oci/CL8KOI8U' => ['CL8KOI8U', TDbDriver::DRIVER_OCI, TDataCharset::KOI8U], + + // --- SQLSRV --- + // sqlsrv unresolve table only has 'UTF-8'; everything else is a pass-through. + // CP1250/1251/1252 are FreeTDS/dblib names, not valid sqlsrv-reported values. + 'sqlsrv/UTF-8' => ['UTF-8', TDbDriver::DRIVER_SQLSRV, TDataCharset::UTF8], + 'sqlsrv/ISO-8859-1'=> ['ISO-8859-1',TDbDriver::DRIVER_SQLSRV, TDataCharset::Latin1], // pass-through == TDataCharset::Latin1 + 'sqlsrv/ISO-8859-2'=> ['ISO-8859-2',TDbDriver::DRIVER_SQLSRV, TDataCharset::Latin2], // pass-through == TDataCharset::Latin2 + 'sqlsrv/ASCII' => ['ASCII', TDbDriver::DRIVER_SQLSRV, 'ASCII'], // pass-through; not in sqlsrv table (TDataCharset::ASCII = 'US-ASCII') + 'sqlsrv/CP1250' => ['CP1250', TDbDriver::DRIVER_SQLSRV, 'CP1250'], // pass-through; not a valid sqlsrv-reported value + 'sqlsrv/CP1251' => ['CP1251', TDbDriver::DRIVER_SQLSRV, 'CP1251'], // pass-through; not a valid sqlsrv-reported value + 'sqlsrv/CP1252' => ['CP1252', TDbDriver::DRIVER_SQLSRV, 'CP1252'], // pass-through; not a valid sqlsrv-reported value + 'sqlsrv/KOI8-R' => ['KOI8-R', TDbDriver::DRIVER_SQLSRV, TDataCharset::KOI8R], // pass-through == TDataCharset::KOI8R + 'sqlsrv/KOI8-U' => ['KOI8-U', TDbDriver::DRIVER_SQLSRV, TDataCharset::KOI8U], // pass-through == TDataCharset::KOI8U + + // --- DBLIB --- + 'dblib/UTF-8' => ['UTF-8', TDbDriver::DRIVER_DBLIB, TDataCharset::UTF8], + 'dblib/ISO-8859-1' => ['ISO-8859-1',TDbDriver::DRIVER_DBLIB, TDataCharset::Latin1], + 'dblib/ISO-8859-2' => ['ISO-8859-2',TDbDriver::DRIVER_DBLIB, TDataCharset::Latin2], + 'dblib/ASCII' => ['ASCII', TDbDriver::DRIVER_DBLIB, TDataCharset::ASCII], + 'dblib/CP1250' => ['CP1250', TDbDriver::DRIVER_DBLIB, TDataCharset::Win1250], + 'dblib/CP1251' => ['CP1251', TDbDriver::DRIVER_DBLIB, TDataCharset::Win1251], + 'dblib/CP1252' => ['CP1252', TDbDriver::DRIVER_DBLIB, TDataCharset::Win1252], + 'dblib/KOI8-R' => ['KOI8-R', TDbDriver::DRIVER_DBLIB, TDataCharset::KOI8R], + 'dblib/KOI8-U' => ['KOI8-U', TDbDriver::DRIVER_DBLIB, TDataCharset::KOI8U], + + // --- Unknown charset: pass-through --- + 'unknown/mysql' => ['UNKNOWN_CHARSET', TDbDriver::DRIVER_MYSQL, 'UNKNOWN_CHARSET'], + 'unknown/pgsql' => ['SOME_VALUE', TDbDriver::DRIVER_PGSQL, 'SOME_VALUE'], + 'unknown/sqlite' => ['exotic-enc', TDbDriver::DRIVER_SQLITE, 'exotic-enc'], + + // --- IBM: no table → pass-through --- + 'ibm/UTF-8' => ['UTF-8', TDbDriver::DRIVER_IBM, 'UTF-8'], + 'ibm/anything' => ['anything', TDbDriver::DRIVER_IBM, 'anything'], + + // --- Unknown driver: pass-through --- + 'unknown_driver/x' => ['utf8mb4', 'unknown_db', 'utf8mb4'], + ]; + } + + // ========================================================================= + // Round-trip: resolveCharset ∘ unresolveCharset = identity + // ========================================================================= + + /** @dataProvider provideRoundTrip */ + public function testResolveUnresolveRoundTrip(string $phpCharset, string $driver): void + { + $dbCharset = TDbDriverCapabilities::resolveCharset($phpCharset, $driver); + $unresolved = TDbDriverCapabilities::unresolveCharset($dbCharset, $driver); + $this->assertSame( + $phpCharset, + $unresolved, + "Round-trip '$phpCharset' via '$driver' returned '$unresolved' (db='$dbCharset')" + ); + } + + public static function provideRoundTrip(): array + { + // These charsets round-trip losslessly through all of the $drivers below. + // TDataCharset::UTF16 is intentionally excluded from the main loop: MySQL, + // Firebird, and OCI map 'UTF-16' → their BE-specific form (e.g. 'utf16' / + // 'UTF16BE' / 'AL16UTF16'), which now unresolves back to 'UTF-16BE', not + // 'UTF-16'. UTF-16 with no explicit endianness is a "relaxed input" that + // resolves to the driver's preferred UTF-16 form; use UTF16BE / UTF16LE for + // lossless round-trips on those drivers. UTF-16 does round-trip through + // sqlite (bare 'UTF-16' key), pgsql, dblib, and sqlsrv (all pass-through). + $charsets = [ + TDataCharset::UTF8, + TDataCharset::Latin1, + TDataCharset::Latin2, + TDataCharset::ASCII, + TDataCharset::Win1250, + TDataCharset::Win1251, + TDataCharset::Win1252, + TDataCharset::KOI8R, + TDataCharset::KOI8U, + ]; + $drivers = [ + TDbDriver::DRIVER_MYSQL, + TDbDriver::DRIVER_PGSQL, + TDbDriver::DRIVER_FIREBIRD, + TDbDriver::DRIVER_OCI, + TDbDriver::DRIVER_DBLIB, + // sqlsrv handled separately — non-UTF-8 charsets pass through unchanged (no + // sqlsrv entry in the resolve table) and cannot be unresolved; only UTF-8 + // round-trips losslessly for sqlsrv. + ]; + // SQLite only has UTF-8 and UTF-16 in its unresolve table; + // other charsets resolve to 'UTF-8' but unresolve('UTF-8', sqlite) = 'UTF-8' ≠ original. + $sqliteCharsets = [TDataCharset::UTF8, TDataCharset::UTF16]; + + $cases = []; + foreach ($drivers as $driver) { + foreach ($charsets as $cs) { + $cases["$cs/$driver"] = [$cs, $driver]; + } + } + foreach ($sqliteCharsets as $cs) { + $cases["$cs/sqlite"] = [$cs, TDbDriver::DRIVER_SQLITE]; + } + // UTF-16 (bare, no explicit endianness) only round-trips through drivers that + // either pass it through unchanged or have an explicit 'UTF-16' key in their + // unresolve table. It does NOT round-trip through mysql/firebird/oci because + // those drivers resolve 'UTF-16' to their BE-specific form and unresolve that + // back to 'UTF-16BE'. + $cases['UTF-16/pgsql'] = [TDataCharset::UTF16, TDbDriver::DRIVER_PGSQL]; + $cases['UTF-16/dblib'] = [TDataCharset::UTF16, TDbDriver::DRIVER_DBLIB]; + // UTF-16LE round-trips for MySQL and SQLite only (the only drivers with explicit LE support). + $cases['UTF-16LE/mysql'] = [TDataCharset::UTF16LE, TDbDriver::DRIVER_MYSQL]; + $cases['UTF-16LE/sqlite'] = [TDataCharset::UTF16LE, TDbDriver::DRIVER_SQLITE]; + // UTF-16BE round-trips for MySQL, SQLite, Firebird, and OCI. + $cases['UTF-16BE/mysql'] = [TDataCharset::UTF16BE, TDbDriver::DRIVER_MYSQL]; + $cases['UTF-16BE/sqlite'] = [TDataCharset::UTF16BE, TDbDriver::DRIVER_SQLITE]; + $cases['UTF-16BE/firebird'] = [TDataCharset::UTF16BE, TDbDriver::DRIVER_FIREBIRD]; + $cases['UTF-16BE/oci'] = [TDataCharset::UTF16BE, TDbDriver::DRIVER_OCI]; + // sqlsrv: only UTF-8 and UTF-16 are lossless round-trips (UTF-16 passes through) + $cases['UTF-8/sqlsrv'] = [TDataCharset::UTF8, TDbDriver::DRIVER_SQLSRV]; + $cases['UTF-16/sqlsrv'] = [TDataCharset::UTF16, TDbDriver::DRIVER_SQLSRV]; + // interbase aliases firebird → same round-trip + $cases['UTF-8/interbase'] = [TDataCharset::UTF8, TDbDriver::DRIVER_INTERBASE]; + $cases['KOI8-R/interbase']= [TDataCharset::KOI8R, TDbDriver::DRIVER_INTERBASE]; + return $cases; + } + + // ========================================================================= + // getCharsetSetSql + // ========================================================================= + + /** @dataProvider provideCharsetSetSql */ + public function testGetCharsetSetSql(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getCharsetSetSql($driver)); + } + + public static function provideCharsetSetSql(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, 'SET NAMES ?'], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, 'SET client_encoding TO ?'], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, null], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, null], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, null], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,null], + 'oci' => [TDbDriver::DRIVER_OCI, null], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, null], + 'dblib' => [TDbDriver::DRIVER_DBLIB, null], + 'ibm' => [TDbDriver::DRIVER_IBM, null], + 'unknown' => ['unknown_driver', null], + ]; + } + + // ========================================================================= + // getCharsetPragmaSql + // ========================================================================= + + /** @dataProvider provideCharsetPragmaSql */ + public function testGetCharsetPragmaSql(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getCharsetPragmaSql($driver)); + } + + public static function provideCharsetPragmaSql(): array + { + return [ + 'sqlite' => [TDbDriver::DRIVER_SQLITE, 'PRAGMA encoding = %s'], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, null], + 'mysql' => [TDbDriver::DRIVER_MYSQL, null], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, null], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, null], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,null], + 'oci' => [TDbDriver::DRIVER_OCI, null], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, null], + 'dblib' => [TDbDriver::DRIVER_DBLIB, null], + 'ibm' => [TDbDriver::DRIVER_IBM, null], + 'unknown' => ['unknown_driver', null], + ]; + } + + public function testGetCharsetPragmaSqlContainsFormatSlot(): void + { + $sql = TDbDriverCapabilities::getCharsetPragmaSql(TDbDriver::DRIVER_SQLITE); + $this->assertNotNull($sql); + $this->assertStringContainsString('%s', $sql); + // Verify sprintf formatting works + $formatted = sprintf($sql, "'UTF-8'"); + $this->assertSame("PRAGMA encoding = 'UTF-8'", $formatted); + } + + // ========================================================================= + // supportsRuntimeCharsetSet + // ========================================================================= + + /** @dataProvider provideSupportsRuntimeCharsetSet */ + public function testSupportsRuntimeCharsetSet(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::supportsRuntimeCharsetSet($driver)); + } + + public static function provideSupportsRuntimeCharsetSet(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, true], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, true], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, true], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, false], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, false], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,false], + 'oci' => [TDbDriver::DRIVER_OCI, false], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], + 'ibm' => [TDbDriver::DRIVER_IBM, false], + 'unknown' => ['unknown_driver', false], + ]; + } + + // ========================================================================= + // requiresPostConnectCharset + // ========================================================================= + + /** @dataProvider provideRequiresPostConnectCharset */ + public function testRequiresPostConnectCharset(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::requiresPostConnectCharset($driver)); + } + + public static function provideRequiresPostConnectCharset(): array + { + return [ + 'pgsql' => [TDbDriver::DRIVER_PGSQL, true], + 'mysql' => [TDbDriver::DRIVER_MYSQL, false], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, false], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, false], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, false], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,false], + 'oci' => [TDbDriver::DRIVER_OCI, false], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], + 'ibm' => [TDbDriver::DRIVER_IBM, false], + 'unknown' => ['unknown_driver', false], + ]; + } + + // ========================================================================= + // getCharsetDsnParam + // ========================================================================= + + /** @dataProvider provideCharsetDsnParam */ + public function testGetCharsetDsnParam(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getCharsetDsnParam($driver)); + } + + public static function provideCharsetDsnParam(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, 'charset'], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, 'charset'], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,'charset'], + 'oci' => [TDbDriver::DRIVER_OCI, 'charset'], + 'dblib' => [TDbDriver::DRIVER_DBLIB, 'charset'], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, 'CharacterSet'], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, null], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, null], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, null], + 'ibm' => [TDbDriver::DRIVER_IBM, null], + 'unknown' => ['unknown_driver', null], + ]; + } + + // ========================================================================= + // getCharsetDsnPattern — value & regex verification + // ========================================================================= + + /** @dataProvider provideCharsetDsnPattern */ + public function testGetCharsetDsnPatternValue(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getCharsetDsnPattern($driver)); + } + + public static function provideCharsetDsnPattern(): array + { + $stdPattern = '/[;?]charset\s*=\s*([^;]+)/i'; + $srvPattern = '/[;?]CharacterSet\s*=\s*([^;]+)/i'; + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, $stdPattern], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, $stdPattern], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,$stdPattern], + 'oci' => [TDbDriver::DRIVER_OCI, $stdPattern], + 'dblib' => [TDbDriver::DRIVER_DBLIB, $stdPattern], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, $srvPattern], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, null], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, null], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, null], + 'ibm' => [TDbDriver::DRIVER_IBM, null], + 'unknown' => ['unknown_driver', null], + ]; + } + + public function testCharsetDsnPatternMysqlMatchesCharset(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_MYSQL); + $this->assertNotNull($pattern); + $this->assertSame(1, preg_match($pattern, 'mysql:host=localhost;charset=utf8mb4', $m)); + $this->assertSame('utf8mb4', trim($m[1])); + } + + public function testCharsetDsnPatternMysqlMatchesWithSpaces(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_MYSQL); + $this->assertSame(1, preg_match($pattern, 'mysql:host=localhost;charset = utf8mb4', $m)); + $this->assertSame('utf8mb4', trim($m[1])); + } + + public function testCharsetDsnPatternMysqlIsCaseInsensitive(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_MYSQL); + // Upper-case CHARSET + $this->assertSame(1, preg_match($pattern, 'mysql:host=localhost;CHARSET=latin1', $m)); + $this->assertSame('latin1', trim($m[1])); + } + + public function testCharsetDsnPatternMysqlDoesNotMatchAbsent(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_MYSQL); + $this->assertSame(0, preg_match($pattern, 'mysql:host=localhost;dbname=test')); + } + + public function testCharsetDsnPatternFirebirdMatchesCharset(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_FIREBIRD); + $this->assertSame( + 1, + preg_match($pattern, 'firebird:dbname=localhost:/path/to/db.fdb;charset=UTF8', $m) + ); + $this->assertSame('UTF8', trim($m[1])); + } + + public function testCharsetDsnPatternSqlsrvMatchesCharacterSet(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_SQLSRV); + $this->assertSame( + 1, + preg_match($pattern, 'sqlsrv:Server=localhost;Database=db;CharacterSet=UTF-8', $m) + ); + $this->assertSame('UTF-8', trim($m[1])); + } + + public function testCharsetDsnPatternSqlsrvDoesNotMatchLowercaseCharset(): void + { + // sqlsrv uses 'CharacterSet' not 'charset'; but the pattern is case-insensitive (flag /i) + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_SQLSRV); + $this->assertSame( + 1, + preg_match($pattern, 'sqlsrv:Server=localhost;characterset=UTF-8', $m) + ); + } + + public function testCharsetDsnPatternInterbaseMatchesCharset(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_INTERBASE); + $this->assertSame( + 1, + preg_match($pattern, 'interbase:dbname=localhost:/db/file.gdb;charset=WIN1250', $m) + ); + $this->assertSame('WIN1250', trim($m[1])); + } + + public function testCharsetDsnPatternOciMatchesCharset(): void + { + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_OCI); + $this->assertSame( + 1, + preg_match($pattern, 'oci:dbname=//localhost/orcl;charset=AL32UTF8', $m) + ); + $this->assertSame('AL32UTF8', trim($m[1])); + } + + public function testCharsetDsnPatternStopsAtSemicolon(): void + { + // The captured group [^;]+ must not cross a semicolon boundary + $pattern = TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_MYSQL); + $this->assertSame( + 1, + preg_match($pattern, 'mysql:host=localhost;charset=utf8mb4;other=val', $m) + ); + $this->assertSame('utf8mb4', trim($m[1])); + } + + // ========================================================================= + // getDsnAcceptedCharsets + // ========================================================================= + + /** @dataProvider provideGetDsnAcceptedCharsets */ + public function testGetDsnAcceptedCharsets(string $driver, ?array $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getDsnAcceptedCharsets($driver)); + } + + public static function provideGetDsnAcceptedCharsets(): array + { + return [ + 'sqlsrv returns utf8 and enc_char' => [TDbDriver::DRIVER_SQLSRV, ['UTF-8', 'SQLSRV_ENC_CHAR']], + 'mysql returns null (unrestricted)' => [TDbDriver::DRIVER_MYSQL, null], + 'pgsql returns null' => [TDbDriver::DRIVER_PGSQL, null], + 'sqlite returns null' => [TDbDriver::DRIVER_SQLITE, null], + 'firebird returns null' => [TDbDriver::DRIVER_FIREBIRD, null], + 'oci returns null' => [TDbDriver::DRIVER_OCI, null], + 'dblib returns null' => [TDbDriver::DRIVER_DBLIB, null], + 'ibm returns null' => [TDbDriver::DRIVER_IBM, null], + 'unknown returns null' => ['unknown_driver', null], + ]; + } + + public function testSqlsrvUtf8IsInDsnAcceptedCharsets(): void + { + $accepted = TDbDriverCapabilities::getDsnAcceptedCharsets(TDbDriver::DRIVER_SQLSRV); + $this->assertContains('UTF-8', $accepted); + } + + public function testSqlsrvEncCharIsInDsnAcceptedCharsets(): void + { + $accepted = TDbDriverCapabilities::getDsnAcceptedCharsets(TDbDriver::DRIVER_SQLSRV); + $this->assertContains('SQLSRV_ENC_CHAR', $accepted); + } + + public function testSqlsrvIso88591IsNotInDsnAcceptedCharsets(): void + { + // ISO-8859-1 must NOT be injected into the sqlsrv DSN; it is not a valid + // CharacterSet value and would cause a connection failure. + $accepted = TDbDriverCapabilities::getDsnAcceptedCharsets(TDbDriver::DRIVER_SQLSRV); + $this->assertNotContains('ISO-8859-1', $accepted); + } + + // ========================================================================= + // getCharsetQuerySql + // ========================================================================= + + /** @dataProvider provideCharsetQuerySql */ + public function testGetCharsetQuerySql(string $driver, ?string $expected): void + { + $actual = TDbDriverCapabilities::getCharsetQuerySql($driver); + if ($expected === null) { + $this->assertNull($actual); + } else { + $this->assertSame($expected, $actual); + } + } + + public static function provideCharsetQuerySql(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, 'SELECT @@character_set_connection'], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, 'PRAGMA encoding'], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, 'SELECT pg_client_encoding()'], + // 'firebird' is excluded here — its non-null MON$ATTACHMENTS query is + // verified separately by testGetCharsetQuerySqlFirebirdContainsMonAttachments. + 'interbase' => [TDbDriver::DRIVER_INTERBASE,null], + 'oci' => [TDbDriver::DRIVER_OCI, null], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, null], + 'dblib' => [TDbDriver::DRIVER_DBLIB, null], + 'ibm' => [TDbDriver::DRIVER_IBM, null], + 'unknown' => ['unknown_driver', null], + ]; + } + + public function testGetCharsetQuerySqlFirebirdContainsMonAttachments(): void + { + $sql = TDbDriverCapabilities::getCharsetQuerySql(TDbDriver::DRIVER_FIREBIRD); + $this->assertNotNull($sql); + $this->assertStringContainsString('MON$ATTACHMENTS', $sql); + $this->assertStringContainsString('RDB$CHARACTER_SETS', $sql); + $this->assertStringContainsString('CURRENT_CONNECTION', $sql); + } + + // ========================================================================= + // requiresPreBeginTransactionFlush + // ========================================================================= + + /** @dataProvider provideRequiresPreBeginTransactionFlush */ + public function testRequiresPreBeginTransactionFlush(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::requiresPreBeginTransactionFlush($driver)); + } + + public static function provideRequiresPreBeginTransactionFlush(): array + { + return [ + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, true], + 'mysql' => [TDbDriver::DRIVER_MYSQL, false], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, false], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, false], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, false], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,false], + 'oci' => [TDbDriver::DRIVER_OCI, false], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], + 'ibm' => [TDbDriver::DRIVER_IBM, false], + 'unknown' => ['unknown_driver', false], + ]; + } + + // ========================================================================= + // requiresPostTransactionFlush + // ========================================================================= + + /** @dataProvider provideRequiresPostTransactionFlush */ + public function testRequiresPostTransactionFlush(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::requiresPostTransactionFlush($driver)); + } + + public static function provideRequiresPostTransactionFlush(): array + { + return [ + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, true], + 'mysql' => [TDbDriver::DRIVER_MYSQL, false], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, false], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, false], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, false], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,false], + 'oci' => [TDbDriver::DRIVER_OCI, false], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], + 'ibm' => [TDbDriver::DRIVER_IBM, false], + 'unknown' => ['unknown_driver', false], + ]; + } + + public function testPreAndPostFlushAreConsistent(): void + { + // Both flags must be true for the same driver (Firebird) and false for all others. + $drivers = [ + TDbDriver::DRIVER_MYSQL, TDbDriver::DRIVER_PGSQL, TDbDriver::DRIVER_SQLITE, + TDbDriver::DRIVER_FIREBIRD, TDbDriver::DRIVER_OCI, TDbDriver::DRIVER_SQLSRV, + ]; + foreach ($drivers as $driver) { + $pre = TDbDriverCapabilities::requiresPreBeginTransactionFlush($driver); + $post = TDbDriverCapabilities::requiresPostTransactionFlush($driver); + $this->assertSame($pre, $post, "Pre/post flush inconsistency for '$driver'"); + } + } + + // ========================================================================= + // getListTablesSql + // ========================================================================= + + /** @dataProvider provideListTablesSql */ + public function testGetListTablesSql(string $driver, bool $isNull): void + { + $sql = TDbDriverCapabilities::getListTablesSql($driver); + if ($isNull) { + $this->assertNull($sql); + } else { + $this->assertIsString($sql); + $this->assertNotEmpty($sql); + } + } + + public static function provideListTablesSql(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, false], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, false], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, false], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, false], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, false], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,false], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], + 'oci' => [TDbDriver::DRIVER_OCI, false], + 'ibm' => [TDbDriver::DRIVER_IBM, false], + 'unknown' => ['unknown_driver', true], + 'odbc' => [TDbDriver::DRIVER_ODBC, true], + ]; + } + + public function testGetListTablesSqlMysqlIsShowTables(): void + { + $this->assertSame('SHOW TABLES', TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_MYSQL)); + } + + public function testGetListTablesSqlSqliteReferencesSqliteMaster(): void + { + $sql = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_SQLITE); + $this->assertStringContainsString('sqlite_master', $sql); + } + + public function testGetListTablesSqlSqlite2SameAsSqlite3(): void + { + $this->assertSame( + TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_SQLITE), + TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_SQLITE2) + ); + } + + public function testGetListTablesSqlPgsqlUsesInformationSchema(): void + { + $sql = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_PGSQL); + $this->assertStringContainsString('information_schema', $sql); + } + + public function testGetListTablesSqlFirebirdUsesRdbRelations(): void + { + $sql = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_FIREBIRD); + $this->assertStringContainsString('RDB$RELATIONS', $sql); + } + + public function testGetListTablesSqlInterbaseSameAsFirebird(): void + { + $this->assertSame( + TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_FIREBIRD), + TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_INTERBASE) + ); + } + + public function testGetListTablesSqlMssqlUsesInformationSchema(): void + { + $sqlsrv = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_SQLSRV); + $dblib = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_DBLIB); + $this->assertStringContainsString('INFORMATION_SCHEMA', $sqlsrv); + $this->assertSame($sqlsrv, $dblib); + } + + public function testGetListTablesSqlOciUsesUserTables(): void + { + $sql = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_OCI); + $this->assertStringContainsString('user_tables', $sql); + } + + public function testGetListTablesSqlIbmUsesSyscatTables(): void + { + $sql = TDbDriverCapabilities::getListTablesSql(TDbDriver::DRIVER_IBM); + $this->assertStringContainsString('SYSCAT.TABLES', $sql); + } + + // ========================================================================= + // supportsCharset + // ========================================================================= + + /** @dataProvider provideSupportsCharset */ + public function testSupportsCharset(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::supportsCharset($driver)); + } + + public static function provideSupportsCharset(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, true], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, true], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, true], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, true], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, true], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,true], + 'oci' => [TDbDriver::DRIVER_OCI, true], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, true], + 'dblib' => [TDbDriver::DRIVER_DBLIB, true], + 'ibm' => [TDbDriver::DRIVER_IBM, false], // sole exception + 'unknown' => ['unknown_driver', true], + ]; + } + + // ========================================================================= + // hasAutoCommitAttribute + // ========================================================================= + + /** @dataProvider provideHasAutoCommitAttribute */ + public function testHasAutoCommitAttribute(string $driver, bool $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::hasAutoCommitAttribute($driver)); + } + + public static function provideHasAutoCommitAttribute(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, true], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, false], // pgsql does not expose ATTR_AUTOCOMMIT + 'sqlite' => [TDbDriver::DRIVER_SQLITE, false], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, true], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, true], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,true], + 'oci' => [TDbDriver::DRIVER_OCI, true], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, false], // sqlsrv does not expose ATTR_AUTOCOMMIT + 'dblib' => [TDbDriver::DRIVER_DBLIB, false], // dblib does not expose ATTR_AUTOCOMMIT + 'ibm' => [TDbDriver::DRIVER_IBM, true], + 'unknown' => ['unknown_driver', true], + ]; + } + + // ========================================================================= + // getMetaDataClass — known drivers + // ========================================================================= + + /** @dataProvider provideMetaDataClass */ + public function testGetMetaDataClassKnownDriver(string $driver, string $expectedClass): void + { + $result = TDbDriverCapabilities::getMetaDataClass($driver); + $this->assertSame($expectedClass, $result); + } + + public static function provideMetaDataClass(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, TMysqlMetaData::class], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, TPgsqlMetaData::class], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, TSqliteMetaData::class], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, TSqliteMetaData::class], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, TFirebirdMetaData::class], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,TFirebirdMetaData::class], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, TSqlSrvMetaData::class], + 'dblib' => [TDbDriver::DRIVER_DBLIB, TSqlSrvMetaData::class], + 'oci' => [TDbDriver::DRIVER_OCI, TOracleMetaData::class], + 'ibm' => [TDbDriver::DRIVER_IBM, TIbmMetaData::class], + ]; + } + + public function testGetMetaDataClassUnknownDriverNullConnectionReturnsNull(): void + { + // No connection → no event raising; returns null. + $result = TDbDriverCapabilities::getMetaDataClass('unknown_driver'); + $this->assertNull($result); + } + + public function testGetMetaDataClassKnownDriverViaConnection(): void + { + // Passing a TDbConnection also works for known drivers (driver derived from connection). + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn(TDbDriver::DRIVER_MYSQL); + $conn->expects($this->never())->method('raiseEvent'); + + $result = TDbDriverCapabilities::getMetaDataClass($conn); + $this->assertSame(TMysqlMetaData::class, $result); + } + + public function testGetMetaDataClassUnknownDriverThrowsWhenNoEventHandlers(): void + { + // Connection present but raiseEvent returns empty → TDbException. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('unknown_driver'); + $conn->expects($this->once()) + ->method('raiseEvent') + ->with('fxDataGetMetaDataClass', $conn, 'unknown_driver') + ->willReturn([]); + + $this->expectException(TDbException::class); + TDbDriverCapabilities::getMetaDataClass($conn); + } + + public function testGetMetaDataClassFxEventRaisedWithCorrectParameters(): void + { + // The event is raised with (connection, driver) parameters. + $driver = 'my_custom_driver'; + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn($driver); + $conn->expects($this->once()) + ->method('raiseEvent') + ->with('fxDataGetMetaDataClass', $conn, $driver) + ->willReturn(['Prado\Data\Common\Sqlite\TSqliteMetaData']); + + $result = TDbDriverCapabilities::getMetaDataClass($conn); + $this->assertSame('Prado\Data\Common\Sqlite\TSqliteMetaData', $result); + } + + public function testGetMetaDataClassFxEventReturnedClassNameIsUsed(): void + { + // A handler returns a fully-qualified class name → that value is returned. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([TMysqlMetaData::class]); + + $result = TDbDriverCapabilities::getMetaDataClass($conn); + $this->assertSame(TMysqlMetaData::class, $result); + } + + public function testGetMetaDataClassFxEventLastHandlerWins(): void + { + // array_pop takes the last value from the event result array. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([ + TMysqlMetaData::class, + TPgsqlMetaData::class, // last → wins + ]); + + $result = TDbDriverCapabilities::getMetaDataClass($conn); + $this->assertSame(TPgsqlMetaData::class, $result); + } + + public function testGetMetaDataClassFxEventReturningObjectThrowsTdbException(): void + { + // If a handler accidentally returns an IDataMetaData instance instead of a + // class-name string, the method must throw to signal the incorrect usage. + $badReturn = $this->createMock(IDataMetaData::class); + + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([$badReturn]); + + $this->expectException(TDbException::class); + TDbDriverCapabilities::getMetaDataClass($conn); + } + + public function testGetMetaDataClassFxEventReturningNonImplementingClassThrowsTdbException(): void + { + // If a handler returns a class name that does not implement IDataMetaData, + // getMetaDataClass must throw rather than returning the bad class name to + // the caller. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([\stdClass::class]); + + $this->expectException(TDbException::class); + TDbDriverCapabilities::getMetaDataClass($conn); + } + + public function testGetMetaDataClassKnownDriverIgnoresConnection(): void + { + // For known drivers, the connection is never consulted via raiseEvent. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn(TDbDriver::DRIVER_MYSQL); + $conn->expects($this->never())->method('raiseEvent'); + + $result = TDbDriverCapabilities::getMetaDataClass($conn); + $this->assertSame(TMysqlMetaData::class, $result); + } + + // ========================================================================= + // getScaffoldInputFile + // ========================================================================= + + /** @dataProvider provideScaffoldInputFile */ + public function testGetScaffoldInputFile(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getScaffoldInputFile($driver)); + } + + public static function provideScaffoldInputFile(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, '/TMysqlScaffoldInput.php'], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, '/TPgsqlScaffoldInput.php'], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, '/TSqliteScaffoldInput.php'], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, '/TSqliteScaffoldInput.php'], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, '/TFirebirdScaffoldInput.php'], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,'/TFirebirdScaffoldInput.php'], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, '/TSqlSrvScaffoldInput.php'], + 'dblib' => [TDbDriver::DRIVER_DBLIB, '/TSqlSrvScaffoldInput.php'], + 'oci' => [TDbDriver::DRIVER_OCI, '/TOracleScaffoldInput.php'], + 'ibm' => [TDbDriver::DRIVER_IBM, '/TIbmScaffoldInput.php'], + 'unknown' => ['unknown_driver', null], + 'odbc' => [TDbDriver::DRIVER_ODBC, null], + ]; + } + + public function testGetScaffoldInputFileSqlite2SameAsSqlite3(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_SQLITE), + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_SQLITE2) + ); + } + + public function testGetScaffoldInputFileInterbaseSameAsFirebird(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_FIREBIRD), + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_INTERBASE) + ); + } + + public function testGetScaffoldInputFileSqlsrvSameAsDblib(): void + { + $this->assertSame( + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_SQLSRV), + TDbDriverCapabilities::getScaffoldInputFile(TDbDriver::DRIVER_DBLIB) + ); + } + + public function testGetScaffoldInputFileHasPhpExtension(): void + { + $knownDrivers = [ + TDbDriver::DRIVER_MYSQL, TDbDriver::DRIVER_PGSQL, TDbDriver::DRIVER_SQLITE, + TDbDriver::DRIVER_FIREBIRD, TDbDriver::DRIVER_SQLSRV, TDbDriver::DRIVER_OCI, + TDbDriver::DRIVER_IBM, + ]; + foreach ($knownDrivers as $driver) { + $file = TDbDriverCapabilities::getScaffoldInputFile($driver); + $this->assertStringEndsWith('.php', $file, "File for '$driver' must end with .php"); + $this->assertStringStartsWith('/', $file, "File for '$driver' must start with /"); + } + } + + // ========================================================================= + // getScaffoldInputClass + // ========================================================================= + + /** @dataProvider provideScaffoldInputClass */ + public function testGetScaffoldInputClass(string $driver, ?string $expected): void + { + $this->assertSame($expected, TDbDriverCapabilities::getScaffoldInputClass($driver)); + } + + public static function provideScaffoldInputClass(): array + { + return [ + 'mysql' => [TDbDriver::DRIVER_MYSQL, 'TMysqlScaffoldInput'], + 'pgsql' => [TDbDriver::DRIVER_PGSQL, 'TPgsqlScaffoldInput'], + 'sqlite' => [TDbDriver::DRIVER_SQLITE, 'TSqliteScaffoldInput'], + 'sqlite2' => [TDbDriver::DRIVER_SQLITE2, 'TSqliteScaffoldInput'], + 'firebird' => [TDbDriver::DRIVER_FIREBIRD, 'TFirebirdScaffoldInput'], + 'interbase' => [TDbDriver::DRIVER_INTERBASE,'TFirebirdScaffoldInput'], + 'sqlsrv' => [TDbDriver::DRIVER_SQLSRV, 'TSqlSrvScaffoldInput'], + 'dblib' => [TDbDriver::DRIVER_DBLIB, 'TSqlSrvScaffoldInput'], + 'oci' => [TDbDriver::DRIVER_OCI, 'TOracleScaffoldInput'], + 'ibm' => [TDbDriver::DRIVER_IBM, 'TIbmScaffoldInput'], + 'unknown' => ['unknown_driver', null], + 'odbc' => [TDbDriver::DRIVER_ODBC, null], + ]; + } + + public function testGetScaffoldInputClassMatchesFileBasename(): void + { + // Class name must be the filename without leading / and .php extension. + $knownDrivers = [ + TDbDriver::DRIVER_MYSQL, TDbDriver::DRIVER_PGSQL, TDbDriver::DRIVER_SQLITE, + TDbDriver::DRIVER_FIREBIRD, TDbDriver::DRIVER_SQLSRV, TDbDriver::DRIVER_OCI, + TDbDriver::DRIVER_IBM, + ]; + foreach ($knownDrivers as $driver) { + $file = TDbDriverCapabilities::getScaffoldInputFile($driver); + $class = TDbDriverCapabilities::getScaffoldInputClass($driver); + $this->assertNotNull($class); + $this->assertSame($class . '.php', ltrim($file, '/'), + "Class/file mismatch for '$driver'"); + } + } + + // ========================================================================= + // createScaffoldInput + // ========================================================================= + + /** @dataProvider provideScaffoldInputClass */ + public function testCreateScaffoldInputBuiltInDriverReturnsInstance(string $driver, ?string $expected): void + { + if ($expected === null) { + $this->markTestSkipped('Unknown driver — tested separately via event path.'); + } + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn($driver); + $conn->expects($this->never())->method('raiseEvent'); + + $result = TDbDriverCapabilities::createScaffoldInput($conn); + $this->assertInstanceOf($expected, $result); + } + + public function testCreateScaffoldInputUnknownDriverThrowsWhenNoEventHandlers(): void + { + // Connection present but raiseEvent returns empty → TConfigurationException. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('unknown_driver'); + $conn->expects($this->once()) + ->method('raiseEvent') + ->with('fxActiveRecordScaffoldInputClass', $conn, 'unknown_driver') + ->willReturn([]); + + $this->expectException(\Prado\Exceptions\TConfigurationException::class); + TDbDriverCapabilities::createScaffoldInput($conn); + } + + public function testCreateScaffoldInputFxEventRaisedWithCorrectParameters(): void + { + // The event must be raised on $connection with ($connection, $driver). + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('my_custom_driver'); + $conn->expects($this->once()) + ->method('raiseEvent') + ->with('fxActiveRecordScaffoldInputClass', $conn, 'my_custom_driver') + ->willReturn([]); + + $this->expectException(\Prado\Exceptions\TConfigurationException::class); + TDbDriverCapabilities::createScaffoldInput($conn); + } + + public function testCreateScaffoldInputFxEventFirstHandlerWins(): void + { + // createScaffoldInput uses $instances[0] — the first event result (class name string). + // Using 'sqlite' as a stand-in: it's a known class with no require_once needed here + // because TDbDriverCapabilities::createScaffoldInput will instantiate the returned string. + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([ + \Prado\Data\ActiveRecord\Scaffold\InputBuilder\TSqliteScaffoldInput::class, + \Prado\Data\ActiveRecord\Scaffold\InputBuilder\TPgsqlScaffoldInput::class, + ]); + + $result = TDbDriverCapabilities::createScaffoldInput($conn); + $this->assertInstanceOf(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\TSqliteScaffoldInput::class, $result); + } + + public function testCreateScaffoldInputFxEventReturningObjectThrowsTConfigurationException(): void + { + // If a handler accidentally returns an IScaffoldInput instance instead of a class name + // string, createScaffoldInput must throw to signal the incorrect usage. + $badReturn = $this->createMock(\Prado\Data\ActiveRecord\Scaffold\InputBuilder\IScaffoldInput::class); + + $conn = $this->createMock(TDbConnection::class); + $conn->method('getDriverName')->willReturn('custom_driver'); + $conn->method('raiseEvent')->willReturn([$badReturn]); + + $this->expectException(\Prado\Exceptions\TConfigurationException::class); + TDbDriverCapabilities::createScaffoldInput($conn); + } + + // ========================================================================= + // Cross-method consistency assertions + // ========================================================================= + + public function testSupportsCharsetConsistencyWithOtherMethods(): void + { + // IBM has no charset support at all; every charset method must return null/false for ibm. + $this->assertFalse(TDbDriverCapabilities::supportsCharset(TDbDriver::DRIVER_IBM)); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam(TDbDriver::DRIVER_IBM)); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_IBM)); + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql(TDbDriver::DRIVER_IBM)); + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql(TDbDriver::DRIVER_IBM)); + $this->assertNull(TDbDriverCapabilities::getCharsetQuerySql(TDbDriver::DRIVER_IBM)); + $this->assertFalse(TDbDriverCapabilities::supportsRuntimeCharsetSet(TDbDriver::DRIVER_IBM)); + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset(TDbDriver::DRIVER_IBM)); + } + + public function testPgsqlCharsetIsPostConnectOnly(): void + { + // pgsql: charset is applied after connect via SQL, not via DSN. + $this->assertTrue(TDbDriverCapabilities::requiresPostConnectCharset(TDbDriver::DRIVER_PGSQL)); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam(TDbDriver::DRIVER_PGSQL)); + $this->assertNull(TDbDriverCapabilities::getCharsetDsnPattern(TDbDriver::DRIVER_PGSQL)); + $this->assertNotNull(TDbDriverCapabilities::getCharsetSetSql(TDbDriver::DRIVER_PGSQL)); + } + + public function testFirebirdIsDsnCharsetOnly(): void + { + // Firebird: charset is DSN-only; no runtime SQL switching. + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset(TDbDriver::DRIVER_FIREBIRD)); + $this->assertNotNull(TDbDriverCapabilities::getCharsetDsnParam(TDbDriver::DRIVER_FIREBIRD)); + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql(TDbDriver::DRIVER_FIREBIRD)); + $this->assertNull(TDbDriverCapabilities::getCharsetPragmaSql(TDbDriver::DRIVER_FIREBIRD)); + $this->assertTrue(TDbDriverCapabilities::supportsCharset(TDbDriver::DRIVER_FIREBIRD)); + } + + public function testSqliteCharsetIsPragmaOnly(): void + { + // SQLite: charset via PRAGMA only, no DSN param, no SET NAMES. + $this->assertNull(TDbDriverCapabilities::getCharsetDsnParam(TDbDriver::DRIVER_SQLITE)); + $this->assertNull(TDbDriverCapabilities::getCharsetSetSql(TDbDriver::DRIVER_SQLITE)); + $this->assertNotNull(TDbDriverCapabilities::getCharsetPragmaSql(TDbDriver::DRIVER_SQLITE)); + $this->assertTrue(TDbDriverCapabilities::supportsRuntimeCharsetSet(TDbDriver::DRIVER_SQLITE)); + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset(TDbDriver::DRIVER_SQLITE)); + } + + public function testMysqlCharsetIsBothDsnAndRuntime(): void + { + // MySQL: charset injected into DSN AND can be changed at runtime via SET NAMES. + $this->assertNotNull(TDbDriverCapabilities::getCharsetDsnParam(TDbDriver::DRIVER_MYSQL)); + $this->assertNotNull(TDbDriverCapabilities::getCharsetSetSql(TDbDriver::DRIVER_MYSQL)); + $this->assertTrue(TDbDriverCapabilities::supportsRuntimeCharsetSet(TDbDriver::DRIVER_MYSQL)); + $this->assertFalse(TDbDriverCapabilities::requiresPostConnectCharset(TDbDriver::DRIVER_MYSQL)); + } + + public function testFirebirdTransactionFlagsConsistency(): void + { + // Firebird requires pre/post flush; interbase is NOT aliased for these flags. + $this->assertTrue(TDbDriverCapabilities::requiresPreBeginTransactionFlush(TDbDriver::DRIVER_FIREBIRD)); + $this->assertTrue(TDbDriverCapabilities::requiresPostTransactionFlush(TDbDriver::DRIVER_FIREBIRD)); + $this->assertFalse(TDbDriverCapabilities::requiresPreBeginTransactionFlush(TDbDriver::DRIVER_INTERBASE)); + $this->assertFalse(TDbDriverCapabilities::requiresPostTransactionFlush(TDbDriver::DRIVER_INTERBASE)); + } +} diff --git a/tests/unit/Data/TDbTransactionTest.php b/tests/unit/Data/TDbTransactionTest.php index a783ed60f..93d47b437 100644 --- a/tests/unit/Data/TDbTransactionTest.php +++ b/tests/unit/Data/TDbTransactionTest.php @@ -1,6 +1,9 @@ _connection = new TDbConnection('sqlite:' . TEST_DB_FILE); $this->_connection->Active = true; + // DROP first in case a previous test's @unlink was blocked by a + // lingering file lock on Windows (belt-and-suspenders guard). + //$this->_connection->createCommand('DROP TABLE IF EXISTS foo')->execute(); $this->_connection->createCommand('CREATE TABLE foo (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(8))')->execute(); } @@ -33,6 +39,9 @@ protected function tearDown(): void $this->_connection->Active = false; $this->_connection = null; } + // Force GC so that any lingering PDO handles are released before we + // attempt to delete the SQLite file (required on Windows). + gc_collect_cycles(); @unlink(TEST_DB_FILE); } @@ -72,4 +81,452 @@ public function testCommit() $result = $this->_connection->createCommand('SELECT * FROM foo')->query()->readAll(); $this->assertEquals(count($result), 2); } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Build a mock TDbConnection whose PDO instance is entirely controlled by + * the test. The original constructor is suppressed so no real DB is opened. + * getDriverName() is derived from the mock PDO's ATTR_DRIVER_NAME. + */ + private function createMockConnectionWithPdo(object $mockPdo): TDbConnection + { + $conn = $this->getMockBuilder(TDbConnection::class) + ->disableOriginalConstructor() + ->onlyMethods(['getActive', 'getPdoInstance', 'getDriverName', 'getLastTransaction']) + ->getMock(); + + $conn->method('getActive')->willReturn(true); + $conn->method('getPdoInstance')->willReturn($mockPdo); + $conn->method('getDriverName')->willReturn( + $mockPdo->getAttribute(PDO::ATTR_DRIVER_NAME) + ); + // getLastTransaction() defaults to null; override per-test when + // TDbTransaction::beginTransaction() is exercised so the supersession + // guard sees the correct transaction object. + + return $conn; + } + + /** + * Build a mock PDO stub whose commit / rollBack / getAttribute / + * beginTransaction calls can be asserted. + * getAttribute(PDO::ATTR_DRIVER_NAME) returns $driver. + * + * Must extend \PDO (via disableOriginalConstructor) so that the strict + * return type `PDO` on TDbTransaction::assertActive() is satisfied. + */ + private function createMockPdo(string $driver): \PDO + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->onlyMethods(['commit', 'rollBack', 'getAttribute', 'beginTransaction']) + ->getMock(); + + $pdo->method('getAttribute') + ->with(PDO::ATTR_DRIVER_NAME) + ->willReturn($driver); + + return $pdo; + } + + // ----------------------------------------------------------------------- + // Constructor / basic accessors + // ----------------------------------------------------------------------- + + public function testConstructorCreatesActiveTransaction(): void + { + $tx = $this->_connection->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + } + + public function testGetConnectionReturnsConnection(): void + { + $tx = $this->_connection->beginTransaction(); + $this->assertSame($this->_connection, $tx->getConnection()); + $tx->rollBack(); + } + + public function testCreateCommandDelegatesToConnection(): void + { + $tx = $this->_connection->beginTransaction(); + $cmd = $tx->createCommand('SELECT 1'); + $this->assertInstanceOf(TDbCommand::class, $cmd); + $tx->rollBack(); + } + + // ----------------------------------------------------------------------- + // commit() / rollBack() deactivate the transaction + // ----------------------------------------------------------------------- + + public function testCommitDeactivatesTransaction(): void + { + $tx = $this->_connection->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->commit(); + $this->assertFalse($tx->getActive()); + } + + public function testRollBackDeactivatesTransaction(): void + { + $tx = $this->_connection->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + } + + // ----------------------------------------------------------------------- + // commit() / rollBack() throw when the transaction or connection is inactive + // ----------------------------------------------------------------------- + + public function testCommitThrowsWhenTransactionInactive(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->commit(); + $this->expectException(TDbException::class); + $tx->commit(); + } + + public function testRollBackThrowsWhenTransactionInactive(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->rollBack(); + $this->expectException(TDbException::class); + $tx->rollBack(); + } + + public function testCommitThrowsWhenConnectionInactive(): void + { + $tx = $this->_connection->beginTransaction(); + // Close the connection while the transaction is open. + // SQLite silently rolls back; the TDbTransaction object retains Active=true + // so the guard check fires on the next call. + $this->_connection->Active = false; + $this->expectException(TDbException::class); + $tx->commit(); + } + + public function testRollBackThrowsWhenConnectionInactive(): void + { + $tx = $this->_connection->beginTransaction(); + $this->_connection->Active = false; + $this->expectException(TDbException::class); + $tx->rollBack(); + } + + // ----------------------------------------------------------------------- + // Firebird post-transaction flush — PDO::commit() called a second time + // ----------------------------------------------------------------------- + + /** + * For Firebird connections, pdo_firebird opens an implicit transaction + * immediately after isc_commit_transaction. A second PDO::commit() must + * be issued to flush that implicit transaction so subsequent reads see the + * committed data. TDbTransaction::commit() therefore calls PDO::commit() twice. + */ + public function testCommitIssuedTwiceForFirebird(): void + { + $pdo = $this->createMockPdo('firebird'); + $pdo->expects($this->exactly(2))->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + + $this->assertFalse($tx->getActive()); + } + + /** + * After rollBack() on a Firebird connection, the same implicit-transaction + * problem applies: a single PDO::commit() must be issued to flush it. + */ + public function testRollBackFlushesImplicitTransactionForFirebird(): void + { + $pdo = $this->createMockPdo('firebird'); + $pdo->expects($this->once())->method('rollBack'); + $pdo->expects($this->once())->method('commit'); // flush only, not a real commit + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->rollBack(); + + $this->assertFalse($tx->getActive()); + } + + /** + * For non-Firebird drivers (e.g. MySQL) only one PDO::commit() is issued + * and rollBack() never calls PDO::commit() at all. + */ + public function testCommitIssuedOnceForMysql(): void + { + $pdo = $this->createMockPdo('mysql'); + $pdo->expects($this->once())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + + $this->assertFalse($tx->getActive()); + } + + public function testRollBackIssuesNoPostFlushForMysql(): void + { + $pdo = $this->createMockPdo('mysql'); + $pdo->expects($this->once())->method('rollBack'); + $pdo->expects($this->never())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->rollBack(); + } + + /** + * SQLite does not require the post-transaction flush; rollBack() must not + * issue any PDO::commit() call. + */ + public function testRollBackIssuesNoPostFlushForSqlite(): void + { + $pdo = $this->createMockPdo('sqlite'); + $pdo->expects($this->once())->method('rollBack'); + $pdo->expects($this->never())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->rollBack(); + } + + /** + * PostgreSQL does not require the post-transaction flush. + */ + public function testCommitIssuedOnceForPgsql(): void + { + $pdo = $this->createMockPdo('pgsql'); + $pdo->expects($this->once())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + } + + /** + * The 'interbase' driver alias does NOT receive the post-transaction flush. + * Only the literal string 'firebird' triggers the flush, because + * TDbDriverCapabilities::requiresPostTransactionFlush() uses a direct + * comparison, not the charset-alias map. This verifies the intentional + * asymmetry between charset aliasing (interbase → firebird) and flush + * behaviour (interbase is excluded). + */ + public function testInterbaseDoesNotReceivePostFlushCommit(): void + { + $pdo = $this->createMockPdo('interbase'); + $pdo->expects($this->once())->method('commit'); + $pdo->expects($this->never())->method('rollBack'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + } + + /** + * MSSQL (sqlsrv) does not require the post-transaction flush. + */ + public function testCommitIssuedOnceForSqlsrv(): void + { + $pdo = $this->createMockPdo('sqlsrv'); + $pdo->expects($this->once())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + } + + /** + * Oracle (oci) does not require the post-transaction flush. + */ + public function testCommitIssuedOnceForOci(): void + { + $pdo = $this->createMockPdo('oci'); + $pdo->expects($this->once())->method('commit'); + + $tx = new TDbTransaction($this->createMockConnectionWithPdo($pdo)); + $tx->commit(); + } + + // ----------------------------------------------------------------------- + // beginTransaction() — reactivates a committed / rolled-back transaction + // ----------------------------------------------------------------------- + + public function testBeginTransactionOnCommittedTransactionReactivatesIt(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->commit(); + $this->assertFalse($tx->getActive()); + + $tx->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + } + + public function testBeginTransactionOnRolledBackTransactionReactivatesIt(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->rollBack(); + $this->assertFalse($tx->getActive()); + + $tx->beginTransaction(); + $this->assertTrue($tx->getActive()); + $tx->rollBack(); + } + + public function testBeginTransactionReturnsStatic(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->commit(); + $result = $tx->beginTransaction(); + $this->assertSame($tx, $result); + $tx->rollBack(); + } + + public function testBeginTransactionOnActiveTransactionThrows(): void + { + $tx = $this->_connection->beginTransaction(); + $this->expectException(TDbException::class); + try { + $tx->beginTransaction(); + } finally { + $tx->rollBack(); + } + } + + public function testBeginTransactionThrowsWhenConnectionInactive(): void + { + $tx = $this->_connection->beginTransaction(); + $tx->commit(); + $this->_connection->Active = false; + $this->expectException(TDbException::class); + $tx->beginTransaction(); + } + + public function testBeginTransactionThrowsWhenSupersededByNewTransaction(): void + { + // After $tx1 commits, calling $conn->beginTransaction() creates $tx2 and + // stores it as the connection's last transaction. $tx1 is now superseded: + // $tx1->beginTransaction() must throw because the connection no longer + // tracks $tx1 — restarting it would silently bypass $tx2's lifecycle. + $tx1 = $this->_connection->beginTransaction(); + $tx1->commit(); + + $tx2 = $this->_connection->beginTransaction(); // supersedes $tx1 + + try { + $this->expectException(TDbException::class); + $tx1->beginTransaction(); + } finally { + // Clean up: roll back the still-active superseding transaction so that + // tearDown() can close the connection without a dangling transaction. + if ($tx2->getActive()) { + $tx2->rollBack(); + } + } + } + + public function testLastTransactionReflectsNewestObject(): void + { + // getLastTransaction() must always return the most recently created + // TDbTransaction, not the one that was superseded. + $tx1 = $this->_connection->beginTransaction(); + $this->assertSame($tx1, $this->_connection->getLastTransaction()); + $tx1->commit(); + + $tx2 = $this->_connection->beginTransaction(); + $this->assertSame($tx2, $this->_connection->getLastTransaction()); + $tx2->rollBack(); + } + + public function testBeginTransactionRestoresConnectionCurrentTransaction(): void + { + // After beginTransaction(), getCurrentTransaction() must return $tx. + $tx = $this->_connection->beginTransaction(); + $tx->commit(); + $this->assertNull($this->_connection->getCurrentTransaction()); + + $tx->beginTransaction(); + $this->assertSame($tx, $this->_connection->getCurrentTransaction()); + $tx->rollBack(); + } + + public function testBeginTransactionCommitCycleCanRepeat(): void + { + // Two full begin/commit cycles using the same transaction object. + $tx = $this->_connection->beginTransaction(); + $this->_connection->createCommand('INSERT INTO foo(id,name) VALUES (1,\'a\')')->execute(); + $tx->commit(); + + $tx->beginTransaction(); + $this->_connection->createCommand('INSERT INTO foo(id,name) VALUES (2,\'b\')')->execute(); + $tx->commit(); + + $count = (int) $this->_connection->createCommand('SELECT COUNT(*) FROM foo')->queryScalar(); + $this->assertSame(2, $count); + } + + public function testBeginTransactionPreFlushIssuedForFirebird(): void + { + // For Firebird, TDbTransaction::beginTransaction() must issue PDO::commit() + // (pre-begin flush) before PDO::beginTransaction(), to clear the implicit + // transaction pdo_firebird keeps alive in autocommit mode. + $pdo = $this->createMockPdo('firebird'); + $calls = []; + $pdo->method('commit')->willReturnCallback(function () use (&$calls) { + $calls[] = 'commit'; + return true; + }); + $pdo->method('rollBack')->willReturnCallback(function () use (&$calls) { + $calls[] = 'rollBack'; + return true; + }); + $pdo->method('beginTransaction')->willReturnCallback(function () use (&$calls) { + $calls[] = 'beginTransaction'; + return true; + }); + + $conn = $this->createMockConnectionWithPdo($pdo); + $tx = new TDbTransaction($conn); + // Tell the mock that $tx is still the connection's last transaction so the + // supersession guard in TDbTransaction::beginTransaction() does not fire. + $conn->method('getLastTransaction')->willReturn($tx); + // First commit deactivates the transaction (also issues the Firebird post-flush commit). + $tx->commit(); + $calls = []; // reset — focus only on beginTransaction() + + $tx->beginTransaction(); + + // Expected: commit (pre-begin flush) followed by beginTransaction. + $this->assertSame(['commit', 'beginTransaction'], $calls); + $this->assertTrue($tx->getActive()); + } + + public function testBeginTransactionNoPreFlushForNonFirebird(): void + { + // Non-Firebird drivers must not receive a PDO::commit() before beginTransaction(). + $pdo = $this->createMockPdo('mysql'); + $calls = []; + $pdo->method('commit')->willReturnCallback(function () use (&$calls) { + $calls[] = 'commit'; + return true; + }); + $pdo->method('beginTransaction')->willReturnCallback(function () use (&$calls) { + $calls[] = 'beginTransaction'; + return true; + }); + + $conn = $this->createMockConnectionWithPdo($pdo); + $tx = new TDbTransaction($conn); + // Tell the mock that $tx is still the connection's last transaction so the + // supersession guard in TDbTransaction::beginTransaction() does not fire. + $conn->method('getLastTransaction')->willReturn($tx); + $tx->commit(); // deactivate + $calls = []; + + $tx->beginTransaction(); + + // Only beginTransaction should be called; no pre-flush commit. + $this->assertSame(['beginTransaction'], $calls); + $this->assertTrue($tx->getActive()); + } } diff --git a/tests/unit/Data/TableGateway/TableGatewayTableExistsTest.php b/tests/unit/Data/TableGateway/TableGatewayTableExistsTest.php new file mode 100644 index 000000000..a566080ab --- /dev/null +++ b/tests/unit/Data/TableGateway/TableGatewayTableExistsTest.php @@ -0,0 +1,173 @@ +setUpConnection(); + if ($conn instanceof TDbConnection) { + static::$conn = $conn; + } + } + // Ensure temp table is absent at the start of each test. + static::$conn->createCommand( + 'DROP TABLE IF EXISTS `' . self::TEMP_TABLE . '`' + )->execute(); + } + + protected function tearDown(): void + { + // Always clean up the temp table, even if a test fails mid-way. + if (static::$conn !== null) { + static::$conn->createCommand( + 'DROP TABLE IF EXISTS `' . self::TEMP_TABLE . '`' + )->execute(); + } + } + + public static function tearDownAfterClass(): void + { + if (static::$conn !== null) { + static::$conn->Active = false; + static::$conn = null; + } + } + + // ----------------------------------------------------------------------- + // Returns true — table exists + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_for_existing_table(): void + { + $gateway = new TTableGateway('address', static::$conn); + + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_when_constructed_from_table_info(): void + { + // Construction via TDbTableInfo bypasses the string-based metadata lookup; + // getTableExists() must still correctly probe the live database. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo('address'); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists()); + } + + public function test_getTableExists_returns_true_for_newly_created_table(): void + { + static::$conn->createCommand( + 'CREATE TABLE `' . self::TEMP_TABLE . '` (`id` INT NOT NULL PRIMARY KEY)' + )->execute(); + + $gateway = new TTableGateway(self::TEMP_TABLE, static::$conn); + + $this->assertTrue($gateway->getTableExists()); + } + + // ----------------------------------------------------------------------- + // Returns false — table does not exist + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_false_after_table_is_dropped(): void + { + // Create and introspect the temp table so we have a valid TDbTableInfo. + static::$conn->createCommand( + 'CREATE TABLE `' . self::TEMP_TABLE . '` (`id` INT NOT NULL PRIMARY KEY)' + )->execute(); + + // Build gateway from the info object — constructor succeeds because metadata + // was fetched while the table existed. + $info = TDbMetaData::getInstance(static::$conn)->getTableInfo(self::TEMP_TABLE); + $gateway = new TTableGateway($info, static::$conn); + + $this->assertTrue($gateway->getTableExists(), 'pre-condition: table must exist before drop'); + + // Drop the table; the gateway still holds the stale TDbTableInfo. + static::$conn->createCommand( + 'DROP TABLE `' . self::TEMP_TABLE . '`' + )->execute(); + + $this->assertFalse($gateway->getTableExists()); + } + + // ----------------------------------------------------------------------- + // SQLite in-memory variant — driver-agnostic coverage + // ----------------------------------------------------------------------- + + public function test_getTableExists_returns_true_on_sqlite_for_existing_table(): void + { + $conn = PradoUnit::setupSqliteConnection(); + if (!$conn instanceof TDbConnection) { + $this->markTestSkipped((string) $conn); + } + $conn->createCommand('CREATE TABLE probe (id INTEGER PRIMARY KEY)')->execute(); + + $gateway = new TTableGateway('probe', $conn); + + $this->assertTrue($gateway->getTableExists()); + + $conn->Active = false; + } + + public function test_getTableExists_returns_false_on_sqlite_after_drop(): void + { + $conn = PradoUnit::setupSqliteConnection(); + if (!$conn instanceof TDbConnection) { + $this->markTestSkipped((string) $conn); + } + $conn->createCommand('CREATE TABLE probe (id INTEGER PRIMARY KEY)')->execute(); + + $info = TDbMetaData::getInstance($conn)->getTableInfo('probe'); + $gateway = new TTableGateway($info, $conn); + + $conn->createCommand('DROP TABLE probe')->execute(); + + $this->assertFalse($gateway->getTableExists()); + + $conn->Active = false; + } +} diff --git a/tests/unit/PradoUnit.php b/tests/unit/PradoUnit.php index 6104f0e15..c7e450c95 100644 --- a/tests/unit/PradoUnit.php +++ b/tests/unit/PradoUnit.php @@ -128,7 +128,7 @@ public static function setupSqliteConnection($database = '', $isActiveRecord = f } - public static function setupMssqlConnection($database = '', $isActiveRecord = false) + public static function setupSqlSrvConnection($database = '', $isActiveRecord = false) { if (!extension_loaded('pdo_sqlsrv')) { return 'The pdo_sqlsrv extension is not available.'; @@ -150,6 +150,12 @@ public static function setupMssqlConnection($database = '', $isActiveRecord = fa return $conn; } + /* + public static function setupMssqlConnection($database = '', $isActiveRecord = false) + { + return static::setupSqlSrvConnection($database, $isActiveRecord); + } + */ public static function setupOciConnection($database = '', $isActiveRecord = false) { @@ -218,11 +224,10 @@ public static function processException($e, &$connection) if (isset(static::$dbConnectionException[$driver])) { $e = strtr("Duplicated Database Driver '{0}' Unavailable Error", ['{0}' => $driver]); } else { - $msg = strtr("Database Driver '{0}' Unavailable Error:\n-----\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]); if (static::skipDatabaseTests()) { - $msg .= "\n(PRADO_UNITTEST_SKIP_DB=1)"; + // only on skipping do we set $e + $e = strtr("Database Driver '{0}' Unavailable Error [PRADO_UNITTEST_SKIP_DB=1]:\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]); } - $e = $msg; static::$dbConnectionException[$driver] = true; } } elseif (static::isNoDatabase($e)) { @@ -230,22 +235,20 @@ public static function processException($e, &$connection) if (isset(static::$dbDatabaseException[$driver])) { $e = strtr("Duplicated Database '{0}' Not Found Error (Connection OK)", ['{0}' => $driver]); } else { - $msg = strtr("Database '{0}' Not Found Error (Connection OK):\n-----\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]); if (static::skipDatabaseTests()) { - $msg .= "\n(PRADO_UNITTEST_SKIP_DB=1)"; + // only on skipping do we set $e + $e .= strtr("Database '{0}' Not Found Error (Connection OK) [PRADO_UNITTEST_SKIP_DB=1]:\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]);; } - $e = $msg; static::$dbDatabaseException[$driver] = true; } } elseif (static::isNoTable($e)) { if (isset(static::$dbTableException[$driver])) { $e = strtr("Duplicated Table Not Found Error (driver: '{0}')", ['{0}' => $driver]); } else { - $msg = strtr("Table Not Found Error (driver: '{0}'):\n-----\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]); if (static::skipDatabaseTests()) { - $msg .= "\n(PRADO_UNITTEST_SKIP_DB=1)"; + // only on skipping do we set $e + $e = strtr("Table Not Found Error (driver: '{0}') [PRADO_UNITTEST_SKIP_DB=1]:\n{1}", ['{0}' => $driver, '{1}' => $e->getMessage()]); } - $e = $msg; static::$dbTableException[$driver] = true; } } @@ -254,17 +257,23 @@ public static function processException($e, &$connection) public static function isNoConnection($e): bool { - return is_int(stripos((string) $e, 'No such file')) || is_int(stripos((string) $e, 'Connection refused')) || is_int(stripos((string) $e, 'failed to establish')); + $msg = (string) $e->getMessage(); + return is_int(stripos($msg, 'No such file')) || + is_int(stripos($msg, 'Connection refused')) || + is_int(stripos($msg, 'failed to establish')) || + is_int(stripos($msg, 'Unable to complete network request')) || + is_int(stripos($msg, 'ODBC Driver for SQL Server')) || + is_int(stripos($msg, 'could not connect')); } public static function isNoDatabase($e): bool { - return is_int(stripos((string) $e, 'Unknown database')); + return is_int(stripos((string) $e->getMessage(), 'Unknown database')); } public static function isNoTable($e): bool { - return is_int(stripos((string) $e, 'Base table or view not found')); + return is_int(stripos((string) $e->getMessage(), 'Base table or view not found')); } }