Skip to content

Implement INSERT and UPDATE value type casting - #276

Merged
adamziel merged 12 commits into
developfrom
insert-update-type-casting
Oct 31, 2025
Merged

Implement INSERT and UPDATE value type casting#276
adamziel merged 12 commits into
developfrom
insert-update-type-casting

Conversation

@JanJakes

@JanJakes JanJakes commented Oct 23, 2025

Copy link
Copy Markdown
Member

This PR originates from the following error (#268):

SQLSTATE[23000]: Integrity constraint violation:
19 cannot store TEXT value in BLOB column wp_wfconfig.val

However, there isn't a simple fix. It requires correctly casting values for INSERT, REPLACE, and UPDATE statements, and that's what this PR implements. Type casting logic, together with "implicit defaults", was already implemented for MySQL's "non-strict" mode. This PR extends the type casting part to be used with both strict and non-strict modes.

Generally, it does the following:

-- Rewrite INSERT statement from:
INSERT INTO t (col1, col2, ...) VALUES (v1, v2, ...);

-- To:
INSERT INTO t (col1, col2, ...)
SELECT <apply-type-casting-to-select-list> FROM (VALUES (v1, v2, ...)) WHERE true;

-- Rewrite UPDATE statement from:
UPDATE t SET col1 = value;

-- To:
UPDATE t SET col1 = <apply-type-casting-to-value>;

The PR comes with a comprehensive test suite, but it doesn't implement all tiny MySQL nuances and acknowledges some differences in TODO comments. It should increase the type compatibility when saving values significantly. We can address more edge cases separately, and it should also enable us checking for column lengths and trimming or enforcing the values based on the SQL mode.

Fixes #268.

@JanJakes
JanJakes force-pushed the insert-update-type-casting branch 4 times, most recently from 955402f to d04ff75 Compare October 29, 2025 11:00
@JanJakes
JanJakes force-pushed the insert-update-type-casting branch 2 times, most recently from ff98a29 to a1eb12e Compare October 29, 2025 14:03
@JanJakes
JanJakes force-pushed the insert-update-type-casting branch from a1eb12e to 8bd98ae Compare October 29, 2025 15:53
@JanJakes
JanJakes marked this pull request as ready for review October 29, 2025 16:13
@JanJakes
JanJakes requested a review from Copilot October 29, 2025 16:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php Outdated
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php Outdated
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php Outdated
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php Outdated
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php Outdated
Comment thread tests/WP_SQLite_Driver_Tests.php Outdated
Comment thread tests/WP_SQLite_Driver_Tests.php Outdated
Comment thread tests/WP_SQLite_Driver_Tests.php Outdated
Comment thread tests/WP_SQLite_Driver_Tests.php Outdated

Copilot AI commented Oct 29, 2025

Copy link
Copy Markdown

@JanJakes I've opened a new pull request, #278, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Oct 29, 2025

Copy link
Copy Markdown

@JanJakes I've opened a new pull request, #279, to work on those changes. Once the pull request is ready, I'll request review from you.

@JanJakes
JanJakes force-pushed the insert-update-type-casting branch from 8bd98ae to b7f7921 Compare October 29, 2025 17:07
@JanJakes
JanJakes requested a review from Copilot October 29, 2025 17:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php
Comment thread wp-includes/sqlite-ast/class-wp-sqlite-driver.php
@JanJakes
JanJakes requested a review from adamziel October 30, 2025 08:28
$this->assertQuery( "INSERT INTO t VALUES ('3.0')" );

// TODO: These are supported in MySQL:
$this->assertQueryError( "INSERT INTO t VALUES ('4.5')", 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store REAL value in INTEGER column t.value' );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good call describing known limitations of the system with tests

* @throws Exception The error message.
* @return void
*/
public function throw( $message ): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm surprised this is not a reserved keyword and makes a valid method name. PHP docs confirms it's safe:

They are, however, allowed as property, constant, and method names of classes, interfaces and traits, except that class may not be used as constant name.

TIL

if ( 0 === count( $columns ) ) {
throw $this->new_driver_exception(
sprintf(
"SQLSTATE[42S02]: Base table or view not found: 1146 Table '%s' doesn't exist",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

MySQL error codes! Nice

$this->cast_value_in_non_strict_mode( $column['DATA_TYPE'], $identifier ),
$identifier
);
$fragment .= $this->cast_value_for_insert_or_update( $column['DATA_TYPE'], $identifier );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

cast_value_for_insert_or_update – Any other statements that need casting? E.g. REPLACE INTO?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel Yeah, REPLACE as well. Maybe we could use a more generic name like cast_value_for_saving or something along those lines?

*
* When the strict mode is not enabled, executing an UPDATE statement that
* sets a NOT NULL column value to NULL saves an IMPLICIT DEFAULT instead.
* This method applies relevant type casting and emulates IMPLICIT DEFAULT

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's drop a couple of links to the relevant MySQL doc page in a few places as a reference:

https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html

string $translated_value
): string {
$sqlite_data_type = self::DATA_TYPE_STRING_MAP[ $mysql_data_type ];
// TODO: This is also a good place to implement checks for maximum column

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍

} elseif ( 'year' === $mysql_data_type ) {
$function_call = sprintf( "STRFTIME('%%Y', %s)", $translated_value );
/*
* The YEAR type in MySQL only uses 1 byte and therefore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

good comment

* @return string The translated value.
*/
private function cast_value_in_non_strict_mode(
private function cast_value_for_insert_or_update(

@adamziel adamziel Oct 30, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need to consider any potential interferences with SQLite's type affinity?

sqlite> create table t(a int);
sqlite> insert into t(a) values (22), ('33');
sqlite> select a from t where a = '22' or a = 33;
22
33

https://www.sqlite.org/datatype3.html

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel This particular example behaves the same in MySQL, but generally, it can be a problem when an expression evaluates types differently, or comparisons are automatically type casted in one DB but not in the other one.

CREATE TABLE t(a DATE);
INSERT INTO t(a) VALUES ('2021-01-01');
SELECT * FROM t WHERE a = '20210101'

This returns one row in MySQL but no rows in SQLite. It's probably difficult to solve—it would require inferring the correct type of every part of every expression. Fortunately, the most common numeric string cast seems to work in both DBs.

@JanJakes JanJakes Oct 30, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel Checking further, the situation is not that good:

SELECT 1 = '1';         -- true in MySQL, false in SQLite
SELECT 1 WHERE 1 = '1'; -- one row in MySQL, no rows in SQLite

So it appears that the numeric string type cast is only applied when comparing against a numeric column.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can probably come up with a sqlite expression equivalent to MYSQL_TYPECAST( value, column_type ) => value_in_column_type that pattern-matches and transforms the value in the same way as mysql-server would. Maybe we can even source the logic from the actual mysql-server codebase

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel I think that's basically what the cast_value_for_saving() method does. It knows the column type, and it has a value expression, and then it wraps it in a casting expression. We can make it more exhaustive and handle all edge cases, but the core logic is there.

What will be a problem, though, is expressions where we have no column type at all—things like WHERE '1' = 1, but with arbitrary complexity. Anyway, that's probably a topic for another PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Separate pr sounds good! The transform there would be more like (lhs value, lhs type, comparison, rhs value, rhs type) -> expression that evaluates to true or false using mysql rules.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel Oh, right. Seems like it's definitely better to tackle this separately when needed. Rewriting all expressions on every nesting level seems like a bigger topic and it could possibly cause some performance issues (like index not being used due to inline casting, etc.).

Otherwise, do you think this is good to merge?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yup!

// In non-strict mode, they get an IMPLICIT DEFAULT value.
if ( $is_strict_mode ) {
$fallback = sprintf(
"THROW('Incorrect %s value: ''' || %s || '''')",

@adamziel adamziel Oct 30, 2025

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Such a nice use of callbacks 🤯 ! At the same time – could we just throw in place? Do we need to delay it? What's the benefit of doing this vs just throwing a new exception right here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@adamziel We need to throw the error from SQLite, because in PHP we don't know the value. You can insert known values (INSERT INTO ... VALUES (1, 2, 3)), as well as unknown ones INSERT INTO ... SELECT .... For that reason, we need SQLite to evaluate this for every inserted/updated row, as we can't do that in PHP. In PHP the "value" can be a column name, an expression, etc.

@JanJakes
JanJakes requested a review from adamziel October 30, 2025 16:51
@JanJakes

Copy link
Copy Markdown
Member Author

@adamziel I added two commits with small fixes.

@adamziel
adamziel merged commit def2400 into develop Oct 31, 2025
14 checks passed
@JanJakes
JanJakes deleted the insert-update-type-casting branch October 31, 2025 08:29
adamziel added a commit that referenced this pull request Nov 3, 2025
… clause (#282)

A small follow-up on
#276.

We emulate MySQL type casting for `INSERT`, `REPLACE`, and `UPDATE`
values, but we need to do so for the `ON DUPLICATE KEY UPDATE` clause as
well. This fixes some errors in the top 100 plugin activation tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants