diff --git a/phpstan-baseline.php b/phpstan-baseline.php index 48585d6fd969..22aae16b046e 100644 --- a/phpstan-baseline.php +++ b/phpstan-baseline.php @@ -1341,11 +1341,6 @@ 'count' => 7, 'path' => __DIR__ . '/system/Database/Postgre/Builder.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Only booleans are allowed in a negated boolean, array\\\\|string\\> given\\.$#', - 'count' => 1, - 'path' => __DIR__ . '/system/Database/Postgre/Builder.php', -]; $ignoreErrors[] = [ 'message' => '#^Return type \\(CodeIgniter\\\\Database\\\\BaseBuilder\\) of method CodeIgniter\\\\Database\\\\Postgre\\\\Builder\\:\\:join\\(\\) should be covariant with return type \\(\\$this\\(CodeIgniter\\\\Database\\\\BaseBuilder\\)\\) of method CodeIgniter\\\\Database\\\\BaseBuilder\\:\\:join\\(\\)$#', 'count' => 1, diff --git a/system/Database/BaseBuilder.php b/system/Database/BaseBuilder.php index 9d43e5577943..48302ab26fc7 100644 --- a/system/Database/BaseBuilder.php +++ b/system/Database/BaseBuilder.php @@ -169,8 +169,11 @@ class BaseBuilder * constraints?: array, * setQueryAsData?: string, * sql?: string, - * alias?: string + * alias?: string, + * fieldTypes?: array> * } + * + * fieldTypes: [ProtectedTableName => [FieldName => Type]] */ protected $QBOptions; @@ -1758,6 +1761,8 @@ public function getWhere($where = null, ?int $limit = null, ?int $offset = 0, bo /** * Compiles batch insert/update/upsert strings and runs the queries * + * @param '_deleteBatch'|'_insertBatch'|'_updateBatch'|'_upsertBatch' $renderMethod + * * @return false|int|string[] Number of rows inserted or FALSE on failure, SQL array when testMode * * @throws DatabaseException diff --git a/system/Database/Postgre/Builder.php b/system/Database/Postgre/Builder.php index 3e3ed68a9356..3d566f6051a0 100644 --- a/system/Database/Postgre/Builder.php +++ b/system/Database/Postgre/Builder.php @@ -146,7 +146,7 @@ public function replace(?array $set = null) $this->set($set); } - if (! $this->QBSet) { + if ($this->QBSet === []) { if ($this->db->DBDebug) { throw new DatabaseException('You must use the "set" method to update an entry.'); } @@ -312,6 +312,132 @@ public function join(string $table, $cond, string $type = '', ?bool $escape = nu return parent::join($table, $cond, $type, $escape); } + /** + * Generates a platform-specific batch update string from the supplied data + * + * @used-by batchExecute + * + * @param string $table Protected table name + * @param list $keys QBKeys + * @param list> $values QBSet + */ + protected function _updateBatch(string $table, array $keys, array $values): string + { + $sql = $this->QBOptions['sql'] ?? ''; + + // if this is the first iteration of batch then we need to build skeleton sql + if ($sql === '') { + $constraints = $this->QBOptions['constraints'] ?? []; + + if ($constraints === []) { + if ($this->db->DBDebug) { + throw new DatabaseException('You must specify a constraint to match on for batch updates.'); // @codeCoverageIgnore + } + + return ''; // @codeCoverageIgnore + } + + $updateFields = $this->QBOptions['updateFields'] ?? + $this->updateFields($keys, false, $constraints)->QBOptions['updateFields'] ?? + []; + + $alias = $this->QBOptions['alias'] ?? '_u'; + + $sql = 'UPDATE ' . $this->compileIgnore('update') . $table . "\n"; + + $sql .= "SET\n"; + + $that = $this; + $sql .= implode( + ",\n", + array_map( + static fn ($key, $value) => $key . ($value instanceof RawSql ? + ' = ' . $value : + ' = ' . $that->cast($alias . '.' . $value, $that->getFieldType($table, $key))), + array_keys($updateFields), + $updateFields + ) + ) . "\n"; + + $sql .= "FROM (\n{:_table_:}"; + + $sql .= ') ' . $alias . "\n"; + + $sql .= 'WHERE ' . implode( + ' AND ', + array_map( + static function ($key, $value) use ($table, $alias, $that) { + if ($value instanceof RawSql && is_string($key)) { + return $table . '.' . $key . ' = ' . $value; + } + + if ($value instanceof RawSql) { + return $value; + } + + return $table . '.' . $value . ' = ' + . $that->cast($alias . '.' . $value, $that->getFieldType($table, $value)); + }, + array_keys($constraints), + $constraints + ) + ); + + $this->QBOptions['sql'] = $sql; + } + + if (isset($this->QBOptions['setQueryAsData'])) { + $data = $this->QBOptions['setQueryAsData']; + } else { + $data = implode( + " UNION ALL\n", + array_map( + static fn ($value) => 'SELECT ' . implode(', ', array_map( + static fn ($key, $index) => $index . ' ' . $key, + $keys, + $value + )), + $values + ) + ) . "\n"; + } + + return str_replace('{:_table_:}', $data, $sql); + } + + /** + * Returns cast expression. + * + * @TODO move this to BaseBuilder in 4.5.0 + * + * @param float|int|string $expression + */ + private function cast($expression, ?string $type): string + { + return ($type === null) ? $expression : 'CAST(' . $expression . ' AS ' . strtoupper($type) . ')'; + } + + /** + * Returns the filed type from database meta data. + * + * @param string $table Protected table name. + * @param string $fieldName Field name. May be protected. + */ + private function getFieldType(string $table, string $fieldName): ?string + { + $fieldName = trim($fieldName, $this->db->escapeChar); + + if (! isset($this->QBOptions['fieldTypes'][$table])) { + $this->QBOptions['fieldTypes'][$table] = []; + + foreach ($this->db->getFieldData($table) as $field) { + $this->QBOptions['fieldTypes'][$table][$field->name] = $field->type; + } + } + + return $this->QBOptions['fieldTypes'][$table][$fieldName] ?? null; + } + /** * Generates a platform-specific upsertBatch string from the supplied data * diff --git a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php index 6e3d9be574fa..e646e371fb7c 100644 --- a/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php +++ b/tests/_support/Database/Migrations/20160428212500_Create_test_tables.php @@ -46,16 +46,23 @@ public function up(): void ])->addKey('id', true)->createTable('misc', true); // Database Type test table - // missing types : - // TINYINT,MEDIUMINT,BIT,YEAR,BINARY , VARBINARY, TINYTEXT,LONGTEXT,YEAR,JSON,Spatial data types - // id must be interger else SQLite3 error on not null for autoinc field + // missing types: + // TINYINT,MEDIUMINT,BIT,YEAR,BINARY,VARBINARY,TINYTEXT,LONGTEXT, + // JSON,Spatial data types + // `id` must be INTEGER else SQLite3 error on not null for autoincrement field. $data_type_fields = [ - 'id' => ['type' => 'INTEGER', 'constraint' => 20, 'auto_increment' => true], - 'type_varchar' => ['type' => 'VARCHAR', 'constraint' => 40, 'null' => true], - 'type_char' => ['type' => 'CHAR', 'constraint' => 10, 'null' => true], - 'type_text' => ['type' => 'TEXT', 'null' => true], - 'type_smallint' => ['type' => 'SMALLINT', 'null' => true], - 'type_integer' => ['type' => 'INTEGER', 'null' => true], + 'id' => ['type' => 'INTEGER', 'constraint' => 20, 'auto_increment' => true], + 'type_varchar' => ['type' => 'VARCHAR', 'constraint' => 40, 'null' => true], + 'type_char' => ['type' => 'CHAR', 'constraint' => 10, 'null' => true], + // TEXT should not be used on SQLSRV. It is deprecated. + 'type_text' => ['type' => 'TEXT', 'null' => true], + 'type_smallint' => ['type' => 'SMALLINT', 'null' => true], + 'type_integer' => ['type' => 'INTEGER', 'null' => true], + // FLOAT should not be used on MySQL. + // CREATE TABLE t (f FLOAT, d DOUBLE); + // INSERT INTO t VALUES(99.9, 99.9); + // SELECT * FROM t WHERE f=99.9; // Empty set + // SELECT * FROM t WHERE d=99.9; // 1 row 'type_float' => ['type' => 'FLOAT', 'null' => true], 'type_numeric' => ['type' => 'NUMERIC', 'constraint' => '18,2', 'null' => true], 'type_date' => ['type' => 'DATE', 'null' => true], diff --git a/tests/system/Database/Live/UpdateTest.php b/tests/system/Database/Live/UpdateTest.php index 1c6ae8a430b6..f8b0ced088db 100644 --- a/tests/system/Database/Live/UpdateTest.php +++ b/tests/system/Database/Live/UpdateTest.php @@ -111,29 +111,159 @@ public function testUpdateWithWhereAndLimit(): void } } - public function testUpdateBatch(): void + /** + * @dataProvider provideUpdateBatch + */ + public function testUpdateBatch(string $constraints, array $data, array $expected): void { - $data = [ - [ - 'name' => 'Derek Jones', - 'country' => 'Greece', + $table = 'type_test'; + + // Prepares test data. + $builder = $this->db->table($table); + $builder->truncate(); + + for ($i = 1; $i < 4; $i++) { + $builder->insert([ + 'type_varchar' => 'test' . $i, + 'type_char' => 'char', + 'type_text' => 'text', + 'type_smallint' => 32767, + 'type_integer' => 2_147_483_647, + 'type_bigint' => 9_223_372_036_854_775_807, + 'type_float' => 10.1, + 'type_numeric' => 123.23, + 'type_date' => '2023-12-0' . $i, + 'type_datetime' => '2023-12-21 12:00:00', + ]); + } + + $this->db->table($table)->updateBatch($data, $constraints); + + if ($this->db->DBDriver === 'SQLSRV') { + // We cannot compare `text` and `varchar` with `=`. It causes the error: + // [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]The data types text and varchar are incompatible in the equal to operator. + // And data type `text`, `ntext`, `image` are deprecated in SQL Server 2016 + // See https://github.com/codeigniter4/CodeIgniter4/pull/8439#issuecomment-1902535909 + unset($expected[0]['type_text'], $expected[1]['type_text']); + } + + $this->seeInDatabase($table, $expected[0]); + $this->seeInDatabase($table, $expected[1]); + } + + public static function provideUpdateBatch(): iterable + { + yield from [ + 'constraints varchar' => [ + 'type_varchar', + [ + [ + 'type_varchar' => 'test1', // Key + 'type_text' => 'updated', + 'type_smallint' => 9999, + 'type_integer' => 9_999_999, + 'type_bigint' => 9_999_999, + 'type_float' => 99.9, + 'type_numeric' => 999999.99, + 'type_date' => '2024-01-01', + 'type_datetime' => '2024-01-01 09:00:00', + ], + [ + 'type_varchar' => 'test2', // Key + 'type_text' => 'updated', + 'type_smallint' => 9999, + 'type_integer' => 9_999_999, + 'type_bigint' => 9_999_999, + 'type_float' => 99.9, + 'type_numeric' => 999999.99, + 'type_date' => '2024-01-01', + 'type_datetime' => '2024-01-01 09:00:00', + ], + ], + [ + [ + 'type_varchar' => 'test1', + 'type_text' => 'updated', + 'type_smallint' => 9999, + 'type_integer' => 9_999_999, + 'type_bigint' => 9_999_999, + 'type_numeric' => 999999.99, + 'type_date' => '2024-01-01', + 'type_datetime' => '2024-01-01 09:00:00', + ], + [ + 'type_varchar' => 'test2', + 'type_text' => 'updated', + 'type_smallint' => 9999, + 'type_integer' => 9_999_999, + 'type_bigint' => 9_999_999, + 'type_numeric' => 999999.99, + 'type_date' => '2024-01-01', + 'type_datetime' => '2024-01-01 09:00:00', + ], + ], ], - [ - 'name' => 'Ahmadinejad', - 'country' => 'Greece', + 'constraints date' => [ + 'type_date', + [ + [ + 'type_text' => 'updated', + 'type_bigint' => 9_999_999, + 'type_date' => '2023-12-01', // Key + 'type_datetime' => '2024-01-01 09:00:00', + ], + [ + 'type_text' => 'updated', + 'type_bigint' => 9_999_999, + 'type_date' => '2023-12-02', // Key + 'type_datetime' => '2024-01-01 09:00:00', + ], + ], + [ + [ + 'type_varchar' => 'test1', + 'type_text' => 'updated', + 'type_bigint' => 9_999_999, + 'type_date' => '2023-12-01', + 'type_datetime' => '2024-01-01 09:00:00', + ], + [ + 'type_varchar' => 'test2', + 'type_text' => 'updated', + 'type_bigint' => 9_999_999, + 'type_date' => '2023-12-02', + 'type_datetime' => '2024-01-01 09:00:00', + ], + ], + ], + 'int as string' => [ + 'type_varchar', + [ + [ + 'type_varchar' => 'test1', // Key + 'type_integer' => '9999999', // PHP string + 'type_bigint' => '2448114396435166946', // PHP string + ], + [ + 'type_varchar' => 'test2', // Key + 'type_integer' => '9999999', // PHP string + 'type_bigint' => '2448114396435166946', // PHP string + ], + ], + [ + [ + 'type_varchar' => 'test1', + 'type_integer' => 9_999_999, + 'type_bigint' => 2_448_114_396_435_166_946, + ], + [ + 'type_varchar' => 'test2', + 'type_integer' => 9_999_999, + 'type_bigint' => 2_448_114_396_435_166_946, + ], + ], ], ]; - - $this->db->table('user')->updateBatch($data, 'name'); - - $this->seeInDatabase('user', [ - 'name' => 'Derek Jones', - 'country' => 'Greece', - ]); - $this->seeInDatabase('user', [ - 'name' => 'Ahmadinejad', - 'country' => 'Greece', - ]); } public function testUpdateWithWhereSameColumn(): void diff --git a/user_guide_src/source/changelogs/v4.4.5.rst b/user_guide_src/source/changelogs/v4.4.5.rst index e029a1d9f3a5..cc395dc9100f 100644 --- a/user_guide_src/source/changelogs/v4.4.5.rst +++ b/user_guide_src/source/changelogs/v4.4.5.rst @@ -30,6 +30,9 @@ Deprecations Bugs Fixed ********** +- **QueryBuilder:** Fixed a bug that the ``updateBatch()`` method does not work + due to type errors on PostgreSQL. + See the repo's `CHANGELOG.md `_ for a complete list of bugs fixed. diff --git a/user_guide_src/source/database/query_builder.rst b/user_guide_src/source/database/query_builder.rst index 0be07d71340b..5e6d54985a6c 100755 --- a/user_guide_src/source/database/query_builder.rst +++ b/user_guide_src/source/database/query_builder.rst @@ -1107,18 +1107,20 @@ $builder->updateBatch() .. note:: Since v4.3.0, the second parameter ``$index`` of ``updateBatch()`` has changed to ``$constraints``. It now accepts types array, string, or ``RawSql``. +Update from Data +^^^^^^^^^^^^^^^^ + Generates an update string based on the data you supply, and runs the query. You can either pass an **array** or an **object** to the method. Here is an example using an array: .. literalinclude:: query_builder/092.php -.. note:: Since v4.3.0, the generated SQL structure has been Improved. +The first parameter is an associative array of values, the second parameter is the where keys. -The first parameter is an associative array of values, the second parameter is the where key. +.. note:: Since v4.3.0, the generated SQL structure has been Improved. -Since v4.3.0, you can also use the ``setQueryAsData()``, ``onConstraint()``, and -``updateFields()`` methods: +Since v4.3.0, you can also use the ``onConstraint()`` and ``updateFields()`` methods: .. literalinclude:: query_builder/120.php @@ -1130,12 +1132,12 @@ Since v4.3.0, you can also use the ``setQueryAsData()``, ``onConstraint()``, and due to the very nature of how it works. Instead, ``updateBatch()`` returns the number of rows affected. -You can also update from a query: +Update from a Query +^^^^^^^^^^^^^^^^^^^ -.. literalinclude:: query_builder/116.php +Since v4.3.0, you can also update from a query with the ``setQueryAsData()`` method: -.. note:: The ``setQueryAsData()``, ``onConstraint()``, and ``updateFields()`` - methods can be used since v4.3.0. +.. literalinclude:: query_builder/116.php .. note:: It is required to alias the columns of the select query to match those of the target table.