diff --git a/.github/workflows/prado-sqlmap.yml b/.github/workflows/prado-sqlmap.yml index 332f378..f26bdfd 100644 --- a/.github/workflows/prado-sqlmap.yml +++ b/.github/workflows/prado-sqlmap.yml @@ -15,11 +15,11 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['8.1', '8.2', '8.3'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -39,7 +39,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -59,7 +59,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with SQLite extensions uses: shivammathur/setup-php@v2 @@ -72,7 +72,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with MySQL extensions uses: shivammathur/setup-php@v2 @@ -102,7 +102,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -138,7 +138,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with PostgreSQL extensions uses: shivammathur/setup-php@v2 @@ -151,7 +151,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -187,7 +187,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with Firebird extensions uses: shivammathur/setup-php@v2 @@ -200,7 +200,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -213,6 +213,7 @@ jobs: run: bash .github/scripts/setup-firebird.sh ${{ job.services.firebird.id }} - name: Run Firebird tests + timeout-minutes: 6 run: vendor/bin/phpunit --testsuite db-firebird db-sqlsrv: @@ -233,7 +234,7 @@ jobs: --health-retries 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with SQL Server extensions uses: shivammathur/setup-php@v2 @@ -246,7 +247,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -280,7 +281,7 @@ jobs: --health-retries 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install Oracle Instant Client run: bash .github/scripts/install-oracle-client.sh @@ -296,7 +297,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} @@ -334,7 +335,7 @@ jobs: --health-retries 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP with IBM DB2 extensions uses: shivammathur/setup-php@v2 @@ -347,7 +348,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} diff --git a/CLAUDE.md b/CLAUDE.md index 263bd6f..ee1e915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,7 +197,7 @@ The phpunit bootstrap (`tests/test_tools/phpunit_bootstrap.php`) loads `PradoUni - Indentation: **tabs** (not spaces) - Line endings: Unix (`\n`) -- PHP minimum: **8.1** (CI tests 8.1, 8.2, 8.3) +- PHP minimum: **8.1** (CI tests 8.2, 8.3, 8.4, 8.5; the `pradosoft/prado` dev dependency requires 8.2) - PSR-12 enforced via php-cs-fixer - Use `?` for single nullable types and in doc blocks diff --git a/composer.json b/composer.json index 8ef2000..3c850b5 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,11 @@ "Prado\\": "src" } }, + "autoload-dev": { + "psr-4": { + "Prado\\Test\\Unit\\": "vendor/pradosoft/prado/tests/unit/" + } + }, "repositories": [ { "type": "composer", @@ -47,7 +52,7 @@ }, "config": { "platform": { - "php": "8.1" + "php": "8.2" }, "allow-plugins": { "phpdocumentor/shim": true diff --git a/src/Data/SqlMap/Statements/TMappedStatement.php b/src/Data/SqlMap/Statements/TMappedStatement.php index 158d991..0a4e8cf 100644 --- a/src/Data/SqlMap/Statements/TMappedStatement.php +++ b/src/Data/SqlMap/Statements/TMappedStatement.php @@ -494,6 +494,8 @@ public function onExecuteQuery($sql) /** * Apply result mapping. + * The PRADO_ROWNUM column is removed from the row. TIbmCommandBuilder::applyLimitOffset() + * pages through a ROW_NUMBER() subquery, and its outer "SELECT *" returns that helper column. * @param array $row a result set row retrieved from the database * @param null|object $resultObject the result object, will create if necessary. * @return object the result filled with data, null if not filled. @@ -503,6 +505,7 @@ protected function applyResultMap($row, &$resultObject = null) if ($row === false) { return null; } + unset($row['PRADO_ROWNUM'], $row['prado_rownum']); $resultMapName = $this->_statement->getResultMap(); $resultClass = $this->_statement->getResultClass(); diff --git a/src/Data/SqlMap/Statements/TPreparedCommand.php b/src/Data/SqlMap/Statements/TPreparedCommand.php index 262de05..af6db79 100644 --- a/src/Data/SqlMap/Statements/TPreparedCommand.php +++ b/src/Data/SqlMap/Statements/TPreparedCommand.php @@ -51,12 +51,22 @@ protected function applyParameterMap($manager, $command, $prepared, $statement, //$parameters = $prepared->getParameterValues(); $registry = $manager->getTypeHandlers(); if ($properties) { + $driverName = $command->getConnection()->getDriverName(); for ($i = 0, $k = $properties->getCount(); $i < $k; $i++) { $property = $statement->parameterMap()->getProperty($i); $value = $statement->parameterMap()->getPropertyValue($registry, $property, $parameterObject); + if (is_bool($value) && $driverName === 'firebird') { + // pdo_firebird binds a PHP bool only to a BOOLEAN column; integer flag columns take 0 or 1. + $value = (int) $value; + } $dbType = $property->getDbType(); if ($dbType == '') { //relies on PHP lax comparison - $command->bindValue($i + 1, $value, TDbCommandBuilder::getPdoType($value)); + $pdoType = TDbCommandBuilder::getPdoType($value); + if ($pdoType === \PDO::PARAM_NULL && $driverName === 'ibm') { + // pdo_ibm rejects PDO::PARAM_NULL; it binds a null PDO::PARAM_STR as SQL NULL. + $pdoType = \PDO::PARAM_STR; + } + $command->bindValue($i + 1, $value, $pdoType); } elseif (strpos($dbType, 'PDO::') === 0) { $command->bindValue($i + 1, $value, constant($property->getDbType())); } //assumes PDO types, e.g. PDO::PARAM_INT diff --git a/tests/test_tools/phpunit_bootstrap.php b/tests/test_tools/phpunit_bootstrap.php index bb15e6a..714c89f 100644 --- a/tests/test_tools/phpunit_bootstrap.php +++ b/tests/test_tools/phpunit_bootstrap.php @@ -2,6 +2,5 @@ require_once(__DIR__ . '/../../vendor/autoload.php'); require_once(__DIR__ . '/../../vendor/pradosoft/prado/framework/Prado.php'); -require_once(__DIR__ . '/../../vendor/pradosoft/prado/tests/unit/PradoUnitRequires.php'); -$application = new \Prado\TTestApplication(); +$application = new \Prado\Test\Unit\Harness\TTestApplication(); diff --git a/tests/unit/Data/SqlMap/ActiveRecordSqlMapTestCase.php b/tests/unit/Data/SqlMap/ActiveRecordSqlMapTestCase.php index 02125db..28f6f5d 100644 --- a/tests/unit/Data/SqlMap/ActiveRecordSqlMapTestCase.php +++ b/tests/unit/Data/SqlMap/ActiveRecordSqlMapTestCase.php @@ -18,9 +18,10 @@ class ActiveAccount extends TActiveRecord const TABLE = 'accounts'; /** - * Maps PostgreSQL lowercase column names to the mixed-case PHP property names. - * On MySQL and SQLite the schema returns 'Account_Id' (matching the property), - * so these entries are simply never looked up on those drivers. + * Maps folded column names to the mixed-case PHP property names. + * - PostgreSQL and IBM DB2 metadata report lowercase names ('account_id'). + * - Firebird metadata reports uppercase names ('ACCOUNT_ID'). + * - MySQL and SQLite report 'Account_Id', which matches the property directly. */ public static $COLUMN_MAPPING = [ 'account_id' => 'Account_Id', @@ -29,6 +30,12 @@ class ActiveAccount extends TActiveRecord 'account_email' => 'Account_Email', 'account_banner_option' => 'Account_Banner_Option', 'account_cart_option' => 'Account_Cart_Option', + 'ACCOUNT_ID' => 'Account_Id', + 'ACCOUNT_FIRSTNAME' => 'Account_FirstName', + 'ACCOUNT_LASTNAME' => 'Account_LastName', + 'ACCOUNT_EMAIL' => 'Account_Email', + 'ACCOUNT_BANNER_OPTION' => 'Account_Banner_Option', + 'ACCOUNT_CART_OPTION' => 'Account_Cart_Option', ]; public static function finder($className = __CLASS__) @@ -104,6 +111,13 @@ public function testLoadWithSqlMap_SaveWithActiveRecord() $record->markLoaded(); $record->Account_FirstName = "Testing 123"; + // The record has a NULL Account_Email. TDbCommandBuilder binds NULL as + // PDO::PARAM_NULL, which pdo_ibm rejects with "Unknown parameter type". + // Prado's TDbDriverCapabilities::requiresUntypedParameters() resolves it. + if (static::$dbDriver === 'ibm' && !class_exists('\Prado\Data\TDbDriverCapabilities')) { + $this->markTestSkipped('ActiveRecord NULL binding on pdo_ibm requires Prado\Data\TDbDriverCapabilities.'); + } + $this->assertTrue($record->save()); $check1 = self::$sqlmap->queryForObject('GetActiveRecordAccounts'); diff --git a/tests/unit/Data/SqlMap/BaseCase.php b/tests/unit/Data/SqlMap/BaseCase.php index c50fbf3..eff53fe 100644 --- a/tests/unit/Data/SqlMap/BaseCase.php +++ b/tests/unit/Data/SqlMap/BaseCase.php @@ -4,6 +4,7 @@ use Prado\Data\SqlMap\DataMapper\TSqlMapTypeHandler; use Prado\Data\SqlMap\TSqlMapManager; +use Prado\Test\Unit\PradoUnit; abstract class BaseCase extends PHPUnit\Framework\TestCase { @@ -68,13 +69,7 @@ public static function setUpBeforeClass(): void // PHP static properties declared on a base class are shared across all // subclasses — without this reset, the MySQL TDbConnection left behind by // MysqlStatementTest would be picked up by PgsqlStatementTest. - if (static::$connection !== null) { - try { - static::$connection->setActive(false); - } catch (\Throwable $ignored) { - } - static::$connection = null; - } + static::closeConnections(); static::$sqlmap = null; static::$mapper = null; static::$config = null; @@ -133,7 +128,7 @@ protected static function initSchema(): void // Schema initialisation failed — treat DB as unavailable. static::$unavailableKey = static::$dbDriver; static::$unavailableCause = $e; - static::$unavailableReason = 'Schema init failed (' . static::$dbDriver . ')'; + static::$unavailableReason = 'Schema init failed (' . static::$dbDriver . '): ' . $e->getMessage(); static::$config = null; } return; @@ -148,13 +143,32 @@ public static function tearDownAfterClass(): void } // Close the connection so the next class (possibly a different driver) starts // fresh. See the setUpBeforeClass() comment above. - if (static::$connection !== null) { + static::closeConnections(); + } + + /** + * Closes the shared connection and the current config's connection. + * The config connection is closed even when $connection was never assigned, + * because TActiveRecordManager may still hold it. An open pdo_firebird + * connection keeps an implicit transaction alive, and its table locks block + * the next class's DROP TABLE indefinitely. + */ + protected static function closeConnections(): void + { + $connections = [static::$connection]; + if (static::$config !== null) { + $connections[] = static::$config->getConnection(); + } + foreach ($connections as $connection) { + if ($connection === null) { + continue; + } try { - static::$connection->setActive(false); + $connection->setActive(false); } catch (\Throwable $ignored) { } - static::$connection = null; } + static::$connection = null; } public static function getConnection() diff --git a/tests/unit/Data/SqlMap/DbSpecific/Ibm/IbmBaseTestConfig.php b/tests/unit/Data/SqlMap/DbSpecific/Ibm/IbmBaseTestConfig.php index b17d9b8..3c4f695 100644 --- a/tests/unit/Data/SqlMap/DbSpecific/Ibm/IbmBaseTestConfig.php +++ b/tests/unit/Data/SqlMap/DbSpecific/Ibm/IbmBaseTestConfig.php @@ -13,6 +13,9 @@ public function __construct() $dbname = getenv('DB2_DATABASE') ?: 'pradount'; $dsn = 'ibm:DRIVER={IBM DB2 ODBC DRIVER};DATABASE=' . $dbname . ';HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP'; $this->_connection = new TDbConnection($dsn, $user, $password); + // pdo_ibm defaults PDO::ATTR_CASE to CASE_UPPER, which also folds quoted + // aliases such as "Id". CASE_NATURAL keeps the names DB2 reports. + $this->_connection->setAttribute(PDO::ATTR_CASE, PDO::CASE_NATURAL); } public function getScriptRunner() diff --git a/tests/unit/Data/SqlMap/common.php b/tests/unit/Data/SqlMap/common.php index 96b9cb7..2f60895 100644 --- a/tests/unit/Data/SqlMap/common.php +++ b/tests/unit/Data/SqlMap/common.php @@ -48,15 +48,17 @@ public function runScript($connection, $script) } /** - * Script runner for IBM DB2. Silently ignores SQLSTATE 42704 ("undefined name") - * errors so that DROP TABLE/SEQUENCE statements do not abort on a fresh database - * where the objects do not yet exist. + * Script runner for IBM DB2. Strips full-line "--" comments before splitting on + * semicolons, so a semicolon inside a comment does not split a statement. + * Silently ignores SQLSTATE 42704 ("undefined name") errors so that DROP + * TABLE/SEQUENCE statements do not abort on a fresh database where the objects + * do not yet exist. */ class IbmScriptRunner extends DefaultScriptRunner { public function runScript($connection, $script) { - $sql = file_get_contents($script); + $sql = preg_replace('/^\s*--.*$/m', '', file_get_contents($script)); $lines = explode(';', $sql); foreach ($lines as $line) { $line = trim($line); diff --git a/tests/unit/Data/SqlMap/maps/firebird/Account.xml b/tests/unit/Data/SqlMap/maps/firebird/Account.xml index 5f86b3a..3297b89 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Account.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Account.xml @@ -176,10 +176,10 @@ parameterClass="int" resultClass="array"> select - Account_Id as Id, - Account_FirstName as FirstName, - Account_LastName as LastName, - Account_Email as EmailAddress + Account_Id as "Id", + Account_FirstName as "FirstName", + Account_LastName as "LastName", + Account_Email as "EmailAddress" from Accounts where Account_Id = #value# @@ -187,10 +187,10 @@ @@ -291,7 +291,7 @@ @@ -432,8 +432,8 @@ delete from Accounts - where Account_Id = #Id# - and Account_Id = #Id# + where Account_Id = CAST(#Id# AS INTEGER) + and Account_Id = CAST(#Id# AS INTEGER) diff --git a/tests/unit/Data/SqlMap/maps/firebird/Category.xml b/tests/unit/Data/SqlMap/maps/firebird/Category.xml index ca5e462..d38f0bd 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Category.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Category.xml @@ -23,7 +23,7 @@ @@ -32,7 +32,7 @@ - SELECT GEN_ID(categories_gen, 0) AS value FROM RDB$DATABASE + SELECT GEN_ID(categories_gen, 0) AS "value" FROM RDB$DATABASE insert into Categories (Category_Name, Category_Guid) @@ -43,7 +43,7 @@ - SELECT GEN_ID(categories_gen, 0) AS value FROM RDB$DATABASE + SELECT GEN_ID(categories_gen, 0) AS "value" FROM RDB$DATABASE insert into Categories (Category_Name, Category_Guid) @@ -54,7 +54,7 @@ - SELECT GEN_ID(categories_gen, 0) AS value FROM RDB$DATABASE + SELECT GEN_ID(categories_gen, 0) AS "value" FROM RDB$DATABASE insert into Categories (Category_Name, Category_Guid) @@ -65,7 +65,7 @@ - SELECT GEN_ID(categories_gen, 0) AS value FROM RDB$DATABASE + SELECT GEN_ID(categories_gen, 0) AS "value" FROM RDB$DATABASE insert into Categories (Category_Name, Category_Guid) @@ -87,7 +87,7 @@ - SELECT GEN_ID(categories_gen, 0) AS value FROM RDB$DATABASE + SELECT GEN_ID(categories_gen, 0) AS "value" FROM RDB$DATABASE diff --git a/tests/unit/Data/SqlMap/maps/firebird/Enumeration.xml b/tests/unit/Data/SqlMap/maps/firebird/Enumeration.xml index 58391c5..f82c76d 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Enumeration.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Enumeration.xml @@ -27,9 +27,9 @@ diff --git a/tests/unit/Data/SqlMap/maps/firebird/Order.xml b/tests/unit/Data/SqlMap/maps/firebird/Order.xml index 17b45d3..51d1564 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/Order.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/Order.xml @@ -257,7 +257,7 @@ parameterClass="int" resultClass="date"> select - Order_Date as 'datetime' + Order_Date as "datetime" from Orders where Order_Id = #value# @@ -334,15 +334,15 @@ parameterClass="Integer" resultClass="Order" > select - Orders.Order_Id as Id, - Order_Date as Date, - Order_CardExpiry as CardExpiry, - Order_CardType as CardType, - Order_CardNumber as CardNumber, - Order_Street as Street, - Order_City as City, - Order_Province as Province, - Order_PostalCode as PostalCode, + Orders.Order_Id as "Id", + Order_Date as "Date", + Order_CardExpiry as "CardExpiry", + Order_CardType as "CardType", + Order_CardNumber as "CardNumber", + Order_Street as "Street", + Order_City as "City", + Order_Province as "Province", + Order_PostalCode as "PostalCode", LineItem_ID as "FavouriteLineItem.Id", LineItem_Code as "FavouriteLineItem.Code", LineItem_Quantity as "FavouriteLineItem.Quantity", diff --git a/tests/unit/Data/SqlMap/maps/firebird/sqlmap.xml b/tests/unit/Data/SqlMap/maps/firebird/sqlmap.xml index 43e9e32..451ae26 100644 --- a/tests/unit/Data/SqlMap/maps/firebird/sqlmap.xml +++ b/tests/unit/Data/SqlMap/maps/firebird/sqlmap.xml @@ -2,7 +2,7 @@ - + diff --git a/tests/unit/Data/SqlMap/maps/mssql/Category.xml b/tests/unit/Data/SqlMap/maps/mssql/Category.xml index f495837..1fc643c 100644 --- a/tests/unit/Data/SqlMap/maps/mssql/Category.xml +++ b/tests/unit/Data/SqlMap/maps/mssql/Category.xml @@ -12,7 +12,7 @@ select Category_ID as Id, Category_Name as Name, - Category_Guid as Guid + Category_Guid as GuidString from Categories where Category_ID = #value# @@ -73,13 +73,15 @@ select SCOPE_IDENTITY() as value - + + + SELECT CAST(IDENT_CURRENT('Categories') AS INT) AS value + insert into Categories (Category_Name, Category_Guid) values - (?,?); - select SCOPE_IDENTITY() as value - + (?,?) + update Categories set @@ -144,7 +146,7 @@ - +