Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [Postgre] QueryBuilder::updateBatch() does not work (No API change) #8439

Merged
merged 23 commits into from
Jan 24, 2024

Conversation

kenjis
Copy link
Member

@kenjis kenjis commented Jan 21, 2024

Description
Supersedes #8426
Fixes #7387
See https://forum.codeigniter.com/showthread.php?tid=88871

$data = [
    [
        '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',
    ],
];
$this->db->table($table)->updateBatch($data, 'type_date');
UPDATE "db_type_test"
SET
"type_bigint" = CAST(_u."type_bigint" AS BIGINT),
"type_datetime" = CAST(_u."type_datetime" AS TIMESTAMP WITHOUT TIME ZONE),
"type_text" = CAST(_u."type_text" AS TEXT)
FROM (
SELECT 9999999 "type_bigint", '2023-12-01' "type_date", '2024-01-01 09:00:00' "type_datetime", 'updated' "type_text" UNION ALL
SELECT 9999999 "type_bigint", '2023-12-02' "type_date", '2024-01-01 09:00:00' "type_datetime", 'updated' "type_text"
) _u
WHERE "db_type_test"."type_date" = CAST(_u."type_date" AS DATE)

References:

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@kenjis kenjis added bug Verified issues on the current code behavior or pull requests that will fix them database Issues or pull requests that affect the database layer labels Jan 21, 2024
@kenjis
Copy link
Member Author

kenjis commented Jan 21, 2024

Starting mssql service container
  /usr/bin/docker pull mcr.microsoft.com/mssql/server:2022-latest
  Error response from daemon: received unexpected HTTP status: 503 Service Unavailable
  Warning: Docker pull failed with exit code 1, back off 8.668 seconds before retry.
  /usr/bin/docker pull mcr.microsoft.com/mssql/server:2022-latest
  Error response from daemon: received unexpected HTTP status: 503 Service Unavailable
  Warning: Docker pull failed with exit code 1, back off 4.351 seconds before retry.
  /usr/bin/docker pull mcr.microsoft.com/mssql/server:2022-latest
  Error response from daemon: Head "https://mcr.microsoft.com/v2/mssql/server/manifests/2022-latest": no basic auth credentials
  Error: Docker pull failed with exit code 1

https://github.com/codeigniter4/CodeIgniter4/actions/runs/7598402465/job/20694371974?pr=8439

@kenjis
Copy link
Member Author

kenjis commented Jan 21, 2024

SQLSRV

There was 1 error:

1) CodeIgniter\Database\Live\UpdateTest::testUpdateBatch
CodeIgniter\Database\Exceptions\DatabaseException: [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]The data types text and varchar are incompatible in the equal to operator.

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseConnection.php:647
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseBuilder.php:1697
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Test/Constraints/SeeInDatabase.php:55
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Test/DatabaseTestTrait.php:280
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Database/Live/UpdateTest.php:156
phpvfscomposer:///home/runner/work/CodeIgniter4/CodeIgniter4/vendor/phpunit/phpunit/phpunit:106

https://github.com/codeigniter4/CodeIgniter4/actions/runs/7598402465/job/20695798884?pr=8439

It seems we cannot compare text and varchar with = .
The following code causes the error.

        $this->seeInDatabase($table, [
            'type_varchar'  => 'test1',
            'type_text'     => 'updated',
            'type_bigint'   => 9_999_999,
            'type_date'     => '2024-01-01',
            'type_datetime' => '2024-01-01 09:00:00',
        ]);
SELECT COUNT(*) AS "numrows"
FROM "test"."dbo"."db_type_test"
WHERE "type_varchar" = 'test1'
AND "type_text" = 'updated'
AND "type_bigint" = 9999999
AND "type_date" = '2024-01-01'
AND "type_datetime" = '2024-01-01 09:00:00'

Data type text, ntext, image are deprecated in SQL Server 2016
Use varchar(max), nvarchar(max), and varbinary(max) data types.
https://learn.microsoft.com/en-us/sql/database-engine/deprecated-database-engine-features-in-sql-server-2016?view=sql-server-ver16#features-deprecated-in-a-future-version-of-sql-server

@kenjis
Copy link
Member Author

kenjis commented Jan 21, 2024

OCI8

There was 1 error:

1) CodeIgniter\Database\Live\UpdateTest::testUpdateBatch
CodeIgniter\Database\Exceptions\DatabaseException: oci_execute(): ORA-01400: cannot insert NULL into ("ORACLE"."db_type_test"."type_boolean")

/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseConnection.php:647
/home/runner/work/CodeIgniter4/CodeIgniter4/system/Database/BaseBuilder.php:2302
/home/runner/work/CodeIgniter4/CodeIgniter4/tests/system/Database/Live/UpdateTest.php:133
phpvfscomposer:///home/runner/work/CodeIgniter4/CodeIgniter4/vendor/phpunit/phpunit/phpunit:106

https://github.com/codeigniter4/CodeIgniter4/actions/runs/7598402465/job/20695798698

It seems OCI8 Forge maps 'type_boolean' => ['type' => 'BOOLEAN', 'null' => true], to "type_boolean" NUMBER(1) not null.
But not null is not needed. Is this a bug?
→ I sent PR #8440

@sclubricants
Copy link
Member

Might run into problems in the where part as well

WHERE "db_type_test"."type_varchar" = _u."type_varchar"

It probably works here because its text to varchar.

We could cast everything in the select part but it adds a lot of redundancy.

Maybe try the query where the constraint is timestamp and see what it does.

@sclubricants
Copy link
Member

Looks like we should take a look at sqsrv->forge->_attributeType(array &$attributes)

Maybe text should be mapped to nvarchar(max) and enum as well.

This could be problematic changing these though I suppose.

@kenjis
Copy link
Member Author

kenjis commented Jan 22, 2024

Might run into problems in the where part as well
WHERE "db_type_test"."type_varchar" = _u."type_varchar"

Yes, fixed in c4ecd69

@sclubricants
Copy link
Member

sclubricants commented Jan 22, 2024

Maybe uppercase ::text to ::TEXT. This is part of SQL and might be easier to read.

Could do this in getFieldTypes()

@kenjis
Copy link
Member Author

kenjis commented Jan 22, 2024

We could cast everything in the select part but it adds a lot of redundancy.
Maybe try the query where the constraint is timestamp and see what it does.

I'm not sure what types are safe to remove.

If we remove ::bigint, the following query fails:

UPDATE "db_type_test"
SET
"type_bigint" = _u."type_bigint",
"type_integer" = _u."type_integer"
FROM (
SELECT '2448114396435166946' "type_bigint", '9999999' "type_integer", 'test1' "type_varchar" UNION ALL
SELECT '2448114396435166946' "type_bigint", '9999999' "type_integer", 'test2' "type_varchar"
) _u
WHERE "db_type_test"."type_varchar" = _u."type_varchar"
ErrorException: pg_query(): Query failed: ERROR:  column "type_bigint" is of type bigint but expression is of type text
LINE 3: "type_bigint" = _u."type_bigint",
                        ^
HINT:  You will need to rewrite or cast the expression. 

In this case, the value '2448114396435166946' is wrong, but this may happen, because all data returned by PosrgreSQL are PHP strings.
https://forum.codeigniter.com/showthread.php?tid=88871&pid=414027#pid414027

@kenjis kenjis force-pushed the fix-postgre-updateBatch-2 branch from 5c01940 to b008b0d Compare January 22, 2024 02:51
// 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Float is an approximation of a number. The following would work:

SELECT * FROM t WHERE f > 99.89 AND f < 99.91;

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and we cannot use seeInDatabase().

private function castValue(string $fieldName, $value): string
{
if (! isset($this->QBOptions['fieldTypes'])) {
throw new LogicException(
Copy link
Member

@sclubricants sclubricants Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I like throwing an exception here. I think rather than an exception if fieldTypes aren't defined then getFieldTypes() should be called. This would require the additional parameter $table though. The way it is the method is dependent on another method being called to get some data. If you don't want to add $table as another parameter then you could add $fieldTypes.

You also would not need to pass $value to the method as you could simply append ::TEXT or empty string.

$value . $that->getFieldType($fieldName, $fieldTypes);

$value . $that->getFieldType($fieldName, $table); 

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought might should include value because with other DBMS as well as Postgre the format is:

CAST ('100' AS INTEGER)

So:

$that->castValue($table, $fieldName, $value);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    private function castValue(string $table, string $fieldName, $value): string
    {
        if (! isset($this->QBOptions['fieldTypes'])) {
            $this->QBOptions['fieldTypes'] = $this->getFieldTypes($table);
        }

        $type = $this->QBOptions['fieldTypes'][$fieldName] ?? null;

        return ($type === null) ? $value : $value . '::' . strtoupper($type);
        // return ($type === null) ? $value : 'CAST(' . $value . ' AS ' . strtoupper($type) . ')';
    }

Copy link
Member

@sclubricants sclubricants Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$that->castValue($table, $fieldName, "'2024-01-01'"); // CAST('2024-01-01' AS DATE)

$that->castValue($table, $fieldName, $alias . $fieldName); // CAST(_u."created_date" AS DATE)

Copy link
Member

@sclubricants sclubricants Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could still use the ::DATE format but in case this was ever implemented with other DBMS then it would work with the CAST(expression AS type) format.

If you were using it in the SELECT part then you would be using values but in the SET part you are using field names.

Copy link
Member

@sclubricants sclubricants Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call it expression:

$that->castExpression($table, $fieldName, $expression);

$that->cast($table, $fieldName, $expression);

$that->castExpression($table, $fieldName, '1!=2'); // CAST(1!=2 AS BOOLEAN)

Copy link
Member

@sclubricants sclubricants Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$this->cast($expression, $type); // CAST(expression AS type)

$this->cast($expression, $this->getFieldType($table, $fieldName));


    private function getFieldType(string $table, string $fieldName): string
    {
        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;
    }
    
    // BaseBuilder
    protected function cast($expression, $type): string // possible future public method
    {
          return $this->_cast($expression, $type);
    }

    protected function _cast($expression, $type): string
    {
        //return ($type === null) ? $expression : $expression . '::' . strtoupper($type);
        return ($type === null) ? $expression : 'CAST(' . $expression . ' AS ' . strtoupper($type) . ')'; // covers most all dbms
    }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed. 6d4ce25

Copy link
Member

@sclubricants sclubricants Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made some updates to last post.. see what ya think

I don't like castValue() because it might not always be a value but rather an expression. Most DBMS document as CAST(expression AS type)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cast($expression, $type) makes sense. But we can't add API in a patch version.

Changed. 92cc84e

@kenjis kenjis force-pushed the fix-postgre-updateBatch-2 branch from e18caaf to 6d4ce25 Compare January 23, 2024 00:58
@kenjis kenjis force-pushed the fix-postgre-updateBatch-2 branch from e5cfe9e to 92cc84e Compare January 23, 2024 02:32
@kenjis
Copy link
Member Author

kenjis commented Jan 23, 2024

@codeigniter4/database-team Review, please.

Copy link
Member

@michalsn michalsn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great - thank you @kenjis and @sclubricants!

array_map(
static fn ($key, $value) => $key . ($value instanceof RawSql ?
' = ' . $value :
' = ' . $alias . '.' . $that->cast($value, $that->getFieldType($table, $key))),
Copy link
Member

@sclubricants sclubricants Jan 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
' = ' . $alias . '.' . $that->cast($value, $that->getFieldType($table, $key))),
' = ' . $that->cast($alias . '.' . $value, $that->getFieldType($table, $key))),

$alias . '.' . $value is the full part of the expression. I would like to see us use the more universal format of CAST(expression AS type). If we use this then you definitely have to include the alias as part of expression. The expression here is "table"."column"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return $value;
}

return $table . '.' . $value . ' = ' . $alias . '.' . $that->cast($value, $that->getFieldType($table, $value));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return $table . '.' . $value . ' = ' . $alias . '.' . $that->cast($value, $that->getFieldType($table, $value));
return $table . '.' . $value . ' = ' . $that->cast($alias . '.' . $value, $that->getFieldType($table, $value));

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

*/
private function cast($expression, ?string $type): string
{
return ($type === null) ? $expression : $expression . '::' . strtoupper($type);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return ($type === null) ? $expression : $expression . '::' . strtoupper($type);
return ($type === null) ? $expression : 'CAST(' . $expression . ' AS ' . strtoupper($type) . ')';

The expression::TYPE format is a shorthand format postgre supports. However Postgre and all other DBMS support the format CAST(expression AS type). We should go ahead and use this format so that if this method is moved to BaseBuilder it will generate code compatible with all DBMS.

https://www.postgresqltutorial.com/postgresql-tutorial/postgresql-cast/
https://docs.oracle.com/javadb/10.8.3.0/ref/rrefsqlj33562.html
https://dev.mysql.com/doc/refman/8.0/en/cast-functions.html#function_cast
https://learn.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql?view=sql-server-ver16
https://www.sqlite.org/lang_expr.html#castexpr

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@kenjis kenjis requested a review from sclubricants January 23, 2024 20:50
@sclubricants
Copy link
Member

This looks good to me.

We should probably check upsertBatch() and deleteBatch() as well. I think insertBatch() should be ok.

@kenjis kenjis merged commit c7cfba4 into codeigniter4:develop Jan 24, 2024
63 checks passed
@kenjis kenjis deleted the fix-postgre-updateBatch-2 branch January 24, 2024 23:45
@kenjis
Copy link
Member Author

kenjis commented Jan 24, 2024

Thank you! @sclubricants @michalsn

@kenjis
Copy link
Member Author

kenjis commented Jan 25, 2024

@sclubricants

We should probably check upsertBatch() and deleteBatch() as well. I think insertBatch() should be ok.

See

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Verified issues on the current code behavior or pull requests that will fix them database Issues or pull requests that affect the database layer
Projects
None yet
3 participants