Skip to content

Commit 52769f0

Browse files
committed
Implement emulation of MySQL non-strict mode for INSERT queries
1 parent c9b443b commit 52769f0

2 files changed

Lines changed: 295 additions & 0 deletions

File tree

tests/WP_SQLite_Driver_Tests.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4040,4 +4040,90 @@ public function testTemporaryTableHasPriorityOverStandardTable(): void {
40404040
$this->assertQuery( 'DROP TABLE t' );
40414041
$result = $this->assertQuery( 'SHOW COLUMNS FROM t' );
40424042
}
4043+
4044+
public function testNonStrictSqlModeNullWithoutDefault(): void {
4045+
// No value saves NULL:
4046+
$this->assertQuery( 'CREATE TABLE t1 (id INT, value TEXT NULL)' );
4047+
$this->assertQuery( 'INSERT INTO t1 (id) VALUES (1)' );
4048+
$result = $this->assertQuery( 'SELECT * FROM t1' );
4049+
$this->assertCount( 1, $result );
4050+
$this->assertNull( $result[0]->value );
4051+
4052+
// NULL value saves NULL on INSERT:
4053+
$this->assertQuery( 'CREATE TABLE t2 (id INT, value TEXT NULL)' );
4054+
$this->assertQuery( 'INSERT INTO t2 (id, value) VALUES (1, NULL)' );
4055+
$result = $this->assertQuery( 'SELECT * FROM t2' );
4056+
$this->assertCount( 1, $result );
4057+
$this->assertNull( $result[0]->value );
4058+
4059+
// NULL value saves NULL on UPDATE:
4060+
$this->assertQuery( 'CREATE TABLE t3 (id INT, value TEXT NULL)' );
4061+
$this->assertQuery( "INSERT INTO t3 (id, value) VALUES (1, 'initial-value')" );
4062+
$this->assertQuery( 'UPDATE t3 SET value = NULL WHERE id = 1' );
4063+
$result = $this->assertQuery( 'SELECT * FROM t3' );
4064+
$this->assertCount( 1, $result );
4065+
$this->assertNull( $result[0]->value );
4066+
}
4067+
4068+
public function testNonStrictSqlModeNullWithDefault(): void {
4069+
// No value saves DEFAULT:
4070+
$this->assertQuery( "CREATE TABLE t1 (id INT, value TEXT NULL DEFAULT 'd')" );
4071+
$this->assertQuery( 'INSERT INTO t1 (id) VALUES (1)' );
4072+
$result = $this->assertQuery( 'SELECT * FROM t1' );
4073+
$this->assertCount( 1, $result );
4074+
$this->assertSame( 'd', $result[0]->value );
4075+
4076+
// NULL value saves NULL on INSERT:
4077+
$this->assertQuery( "CREATE TABLE t2 (id INT, value TEXT NULL DEFAULT 'd')" );
4078+
$this->assertQuery( 'INSERT INTO t2 (id, value) VALUES (1, NULL)' );
4079+
$result = $this->assertQuery( 'SELECT * FROM t2' );
4080+
$this->assertCount( 1, $result );
4081+
$this->assertNull( $result[0]->value );
4082+
}
4083+
4084+
public function testNonStrictSqlModeNotNullWithoutDefault(): void {
4085+
// No value saves IMPLICIT DEFAULT:
4086+
$this->assertQuery( 'CREATE TABLE t1 (id INT, value TEXT NOT NULL)' );
4087+
$this->assertQuery( 'INSERT INTO t1 (id) VALUES (1)' );
4088+
$result = $this->assertQuery( 'SELECT * FROM t1' );
4089+
$this->assertCount( 1, $result );
4090+
$this->assertSame( '', $result[0]->value );
4091+
4092+
// NULL value is rejected on INSERT.
4093+
$this->assertQuery( 'CREATE TABLE t2 (id INT, value TEXT NOT NULL)' );
4094+
$exception = null;
4095+
try {
4096+
$this->assertQuery( 'INSERT INTO t2 (id, value) VALUES (1, NULL)' );
4097+
} catch ( WP_SQLite_Driver_Exception $e ) {
4098+
$exception = $e;
4099+
}
4100+
$this->assertNotNull( $exception );
4101+
$this->assertSame(
4102+
'SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: t2.value',
4103+
$exception->getMessage()
4104+
);
4105+
}
4106+
4107+
public function testNonStrictSqlModeNotNullWithDefault(): void {
4108+
// No value saves DEFAULT:
4109+
$this->assertQuery( "CREATE TABLE t1 (id INT, value TEXT NOT NULL DEFAULT 'd')" );
4110+
$this->assertQuery( 'INSERT INTO t1 (id) VALUES (1)' );
4111+
$result = $this->assertQuery( 'SELECT * FROM t1' );
4112+
$this->assertCount( 1, $result );
4113+
$this->assertSame( 'd', $result[0]->value );
4114+
4115+
// NULL value is rejected on INSERT.
4116+
$this->assertQuery( "CREATE TABLE t2 (id INT, value TEXT NOT NULL DEFAULT 'd')" );
4117+
$exception = null;
4118+
try {
4119+
$this->assertQuery( 'INSERT INTO t2 (id, value) VALUES (1, NULL)' );
4120+
} catch ( WP_SQLite_Driver_Exception $e ) {
4121+
$exception = $e;
4122+
}
4123+
$this->assertNotNull( $exception );
4124+
$this->assertSame(
4125+
'SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: t2.value',
4126+
$exception->getMessage()
4127+
);
4128+
}
40434129
}

wp-includes/sqlite-ast/class-wp-sqlite-driver.php

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,74 @@ class WP_SQLite_Driver {
215215
'%y' => '%y',
216216
);
217217

218+
/**
219+
* A map of MySQL data types to implicit default values for non-strict mode.
220+
*
221+
* In MySQL, when STRICT_TRANS_TABLES and STRICT_ALL_TABLES modes are disabled,
222+
* columns get IMPLICIT DEFAULT values that are used under some circumstances.
223+
*
224+
* See:
225+
* https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
226+
*/
227+
const DATA_TYPE_IMPLICIT_DEFAULT_MAP = array(
228+
// Numeric data types:
229+
'bit' => '0',
230+
'bool' => '0',
231+
'boolean' => '0',
232+
'tinyint' => '0',
233+
'smallint' => '0',
234+
'mediumint' => '0',
235+
'int' => '0',
236+
'integer' => '0',
237+
'bigint' => '0',
238+
'float' => '0',
239+
'double' => '0',
240+
'real' => '0',
241+
'decimal' => '0',
242+
'dec' => '0',
243+
'fixed' => '0',
244+
'numeric' => '0',
245+
246+
// String data types:
247+
'char' => '',
248+
'varchar' => '',
249+
'nchar' => '',
250+
'nvarchar' => '',
251+
'tinytext' => '',
252+
'text' => '',
253+
'mediumtext' => '',
254+
'longtext' => '',
255+
'enum' => '', // TODO: Implement (first enum value).
256+
'set' => '',
257+
'json' => 'null', // String value 'null' (valid JSON)
258+
259+
// Date and time data types:
260+
'date' => '0000-00-00',
261+
'time' => '00:00:00',
262+
'datetime' => '0000-00-00 00:00:00',
263+
'timestamp' => '0000-00-00 00:00:00',
264+
'year' => '0000',
265+
266+
// Binary data types:
267+
'binary' => '',
268+
'varbinary' => '',
269+
'tinyblob' => '',
270+
'blob' => '',
271+
'mediumblob' => '',
272+
'longblob' => '',
273+
274+
// Spatial data types (no implicit defaults):
275+
'geometry' => null,
276+
'point' => null,
277+
'linestring' => null,
278+
'polygon' => null,
279+
'multipoint' => null,
280+
'multilinestring' => null,
281+
'multipolygon' => null,
282+
'geomcollection' => null,
283+
'geometrycollection' => null,
284+
);
285+
218286
/**
219287
* The SQLite engine version.
220288
*
@@ -912,6 +980,14 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo
912980
if ( $child instanceof WP_MySQL_Token && WP_MySQL_Lexer::IGNORE_SYMBOL === $child->id ) {
913981
// Translate "UPDATE IGNORE" to "UPDATE OR IGNORE".
914982
$parts[] = 'OR IGNORE';
983+
} elseif (
984+
$child instanceof WP_Parser_Node &&
985+
( 'insertFromConstructor' === $child->rule_name || 'insertQueryExpression' === $child->rule_name )
986+
) {
987+
// TODO: Use this only in non-strict mode.
988+
$table_ref = $node->get_first_child_node( 'tableRef' );
989+
$table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) );
990+
$parts[] = $this->translate_insert_or_replace_body_in_non_strict_mode( $table_name, $child );
915991
} else {
916992
$parts[] = $this->translate( $child );
917993
}
@@ -2592,6 +2668,139 @@ private function translate_show_like_or_where_condition( WP_Parser_Node $like_or
25922668
return '';
25932669
}
25942670

2671+
/**
2672+
* Translate INSERT body, emulating MySQL implicit defaults in non-strict mode.
2673+
*
2674+
* In MySQL, the behavior of INSERT and UPDATE statements depends on whether
2675+
* the STRICT_TRANS_TABLES (InnoDB) or STRICT_ALL_TABLES SQL mode is enabled.
2676+
*
2677+
* By default, STRICT_TRANS_TABLES is enabled, which makes the InnoDB table
2678+
* behavior correspond to the natural behavior of SQLite tables. However,
2679+
* some applications, including WordPress, disable strict mode altogether.
2680+
*
2681+
* The strict SQL modes can be set per session, and can be changed at runtime.
2682+
* In SQLite, we can emulate this using the knowledge of the table structure:
2683+
* 1. Explicitly passed INSERT statement values are used without change.
2684+
* 2. Values omitted from the INSERT statement are replaced with the column
2685+
* DEFAULT or an IMPLICIT DEFAULT value based on their data type.
2686+
*
2687+
* Here's a summary of the strict vs. non-strict behaviors in MySQL:
2688+
*
2689+
* When STRICT_TRANS_TABLES or STRICT_ALL_TABLES is enabled:
2690+
* 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
2691+
* 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
2692+
* 3. NOT NULL + NO DEFAULT: No value is rejected, NULL is rejected, DEFAULT is rejected.
2693+
* 4. NOT NULL + DEFAULT: No value saves DEFAULT, NULL is rejected, DEFAULT saves DEFAULT.
2694+
*
2695+
* When STRICT_TRANS_TABLES and STRICT_ALL_TABLES are disabled:
2696+
* 1. NULL + NO DEFAULT: No value saves NULL, NULL saves NULL, DEFAULT saves NULL.
2697+
* 2. NULL + DEFAULT: No value saves DEFAULT, NULL saves NULL, DEFAULT saves DEFAULT.
2698+
* 3. NOT NULL + NO DEFAULT: No value saves IMPLICIT DEFAULT.
2699+
* NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
2700+
* DEFAULT saves IMPLICIT DEFAULT.
2701+
* 4. NOT NULL + DEFAULT: No value saves DEFAULT.
2702+
* NULL is rejected on INSERT, but saves IMPLICIT DEFAULT on UPDATE.
2703+
* DEFAULT saves DEFAULT.
2704+
*
2705+
* For more information about IMPLICIT DEFAULT values in MySQL, see:
2706+
* https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html#data-type-defaults-implicit
2707+
*
2708+
* @param string $table_name The name of the target table.
2709+
* @param WP_Parser_Node $node The "insertQueryExpression" or "insertValues" AST node.
2710+
* @return string The translated INSERT query body.
2711+
*/
2712+
private function translate_insert_or_replace_body_in_non_strict_mode(
2713+
string $table_name,
2714+
WP_Parser_Node $node
2715+
): string {
2716+
// 1. Get column metadata from information schema.
2717+
$is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name );
2718+
$columns_table = $this->information_schema_builder->get_table_name( $is_temporary, 'columns' );
2719+
$columns = $this->execute_sqlite_query(
2720+
"
2721+
SELECT column_name, is_nullable, column_default, data_type, extra
2722+
FROM $columns_table
2723+
WHERE table_schema = ?
2724+
AND table_name = ?
2725+
ORDER BY ordinal_position
2726+
",
2727+
array( $this->db_name, $table_name )
2728+
)->fetchAll( PDO::FETCH_ASSOC );
2729+
2730+
// 2. Get the list of fields explicitly defined in the INSERT statement.
2731+
$insert_list = array();
2732+
$fields_node = $node->get_first_child_node( 'fields' );
2733+
if ( $fields_node ) {
2734+
// This is the optional "INSERT INTO ... (field1, field2, ...)" list.
2735+
foreach ( $fields_node->get_child_nodes() as $field ) {
2736+
$insert_list[] = $this->unquote_sqlite_identifier( $this->translate( $field ) );
2737+
}
2738+
} else {
2739+
// When no explicit field list is provided, all columns are required.
2740+
foreach ( array_column( $columns, 'COLUMN_NAME' ) as $column_name ) {
2741+
$insert_list[] = $column_name;
2742+
}
2743+
}
2744+
2745+
// 3. Get the list of column names returned by VALUES or SELECT clause.
2746+
$select_list = array();
2747+
if ( 'insertQueryExpression' === $node->rule_name ) {
2748+
// When inserting from a SELECT query, we don't know the column names.
2749+
// Let's wrap the query with a SELECT (...) LIMIT 0 to get obtain them.
2750+
$expr = $node->get_first_child_node( 'queryExpressionOrParens' );
2751+
$stmt = $this->execute_sqlite_query(
2752+
'SELECT * FROM (' . $this->translate( $expr ) . ') LIMIT 1'
2753+
);
2754+
$stmt->execute();
2755+
2756+
for ( $i = 0; $i < $stmt->columnCount(); $i++ ) {
2757+
$select_list[] = $stmt->getColumnMeta( $i )['name'];
2758+
}
2759+
} else {
2760+
// When inserting from a VALUES list, SQLite uses "columnN" naming.
2761+
foreach ( array_keys( $insert_list ) as $position ) {
2762+
$select_list[] = 'column' . ( $position + 1 );
2763+
}
2764+
}
2765+
2766+
// 4. Compose a new INSERT field list with all columns from the table.
2767+
$fragment = '(';
2768+
foreach ( $columns as $i => $column ) {
2769+
$fragment .= $i > 0 ? ', ' : '';
2770+
$fragment .= $this->quote_sqlite_identifier( $column['COLUMN_NAME'] );
2771+
}
2772+
$fragment .= ')';
2773+
2774+
// 5. Compose a wrapper SELECT statement emulating IMPLICIT DEFAULT values.
2775+
$fragment .= ' SELECT ';
2776+
foreach ( $columns as $i => $column ) {
2777+
$is_omitted = ! in_array( $column['COLUMN_NAME'], $insert_list, true );
2778+
$fragment .= $i > 0 ? ', ' : '';
2779+
if ( $is_omitted ) {
2780+
// When a column value is omitted from the INSERT statement, we
2781+
// need to use the DEFAULT value or the IMPLICIT DEFAULT value.
2782+
$is_auto_inc = str_contains( $column['EXTRA'], 'auto_increment' );
2783+
$is_nullable = 'YES' === $column['IS_NULLABLE'];
2784+
$default = $column['COLUMN_DEFAULT'];
2785+
if ( null === $default && ! $is_nullable && ! $is_auto_inc ) {
2786+
$default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null;
2787+
}
2788+
$fragment .= null === $default ? 'NULL' : $this->pdo->quote( $default );
2789+
} else {
2790+
// When a colum value is included, we can use it without change.
2791+
$position = array_search( $column['COLUMN_NAME'], $insert_list, true );
2792+
$fragment .= $this->quote_sqlite_identifier( $select_list[ $position ] );
2793+
}
2794+
}
2795+
2796+
// 6. Wrap the original insert VALUES or SELECT expression in a FROM clause.
2797+
$values = 'insertFromConstructor' === $node->rule_name
2798+
? $node->get_first_child_node( 'insertValues' )
2799+
: $node->get_first_child_node( 'queryExpressionOrParens' );
2800+
$fragment .= ' FROM (' . $this->translate( $values ) . ') WHERE true';
2801+
2802+
return $fragment;
2803+
}
25952804

25962805
/**
25972806
* Generate a SQLite CREATE TABLE statement from information schema data.

0 commit comments

Comments
 (0)