From 9cb9edecb6bbfcbe57b1af1332c732ad62b83042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 20 Apr 2026 10:43:05 +0200 Subject: [PATCH] Handle NO_AUTO_VALUE_ON_ZERO in the INSERT translator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default, MySQL treats a literal 0 in an AUTO_INCREMENT column the same as NULL and generates the next sequence value. This behavior is suppressed by the NO_AUTO_VALUE_ON_ZERO SQL mode, which is not part of the default modes. To emulate this on SQLite, rewrite the value to NULL via NULLIF(CAST(... AS INTEGER), 0). The explicit CAST is required because SQLite compares storage classes strictly, so NULLIF('0', 0) returns '0', not NULL — and WordPress 1.0 emits the string form. Also exclude AUTO_INCREMENT columns from the non-strict IMPLICIT DEFAULT COALESCE wrapping, so a NULL (original or rewritten from 0) always advances the sequence. See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_auto_value_on_zero --- .../sqlite/class-wp-pdo-mysql-on-sqlite.php | 31 ++++++-- .../tests/WP_SQLite_Driver_Tests.php | 71 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php index 34171ac7..799a6669 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-pdo-mysql-on-sqlite.php @@ -5132,9 +5132,24 @@ function ( $column ) use ( $is_strict_mode, $insert_map ) { $fragment .= null === $default ? 'NULL' : $this->quote_sqlite_value( $default ); } else { // When a column value is included, we need to apply type casting. - $position = array_search( $column['COLUMN_NAME'], $insert_list, true ); - $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] ); - $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier ); + $position = array_search( $column['COLUMN_NAME'], $insert_list, true ); + $identifier = $this->quote_sqlite_identifier( $select_list[ $position ] ); + $value = $this->cast_value_for_saving( $column['DATA_TYPE'], $identifier ); + $is_auto_increment = 'auto_increment' === $column['EXTRA']; + + /* + * In MySQL, inserting 0 into an AUTO_INCREMENT column increments + * the sequence, unless the NO_AUTO_VALUE_ON_ZERO SQL mode is set. + * + * In SQLite, we need to rewrite 0 to NULL to advance the sequence. + * The value is cast to INTEGER before the comparison, because + * SQLite treats values of different types as unequal (0 != '0'). + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_auto_value_on_zero + */ + if ( $is_auto_increment && ! $this->is_sql_mode_active( 'NO_AUTO_VALUE_ON_ZERO' ) ) { + $value = sprintf( 'NULLIF(CAST(%s AS INTEGER), 0)', $value ); + } /* * In MySQL non-STRICT mode, when inserting from a SELECT query: @@ -5142,9 +5157,17 @@ function ( $column ) use ( $is_strict_mode, $insert_map ) { * When a column is declared as NOT NULL, inserting a NULL value * saves an IMPLICIT DEFAULT value instead. This behavior only * applies to the INSERT ... SELECT syntax (not VALUES or SET). + * + * AUTO_INCREMENT columns are excluded. A NULL value advances + * the sequence regardless of the column's nullability. */ $is_insert_from_select = 'insertQueryExpression' === $node->rule_name; - if ( ! $is_strict_mode && $is_insert_from_select && 'NO' === $column['IS_NULLABLE'] ) { + if ( + ! $is_strict_mode + && ! $is_auto_increment + && $is_insert_from_select + && 'NO' === $column['IS_NULLABLE'] + ) { $implicit_default = self::DATA_TYPE_IMPLICIT_DEFAULT_MAP[ $column['DATA_TYPE'] ] ?? null; if ( null !== $implicit_default ) { $value = sprintf( 'COALESCE(%s, %s)', $value, $this->quote_sqlite_value( $implicit_default ) ); diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php index 4daf7ca7..73b4166a 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Driver_Tests.php @@ -2671,6 +2671,77 @@ public function testDateFunctionsOnZeroDates() { $this->assertEquals( 0, $results[0]->d ); } + public function testDefaultSqlModeDoesNotIncludeNoAutoValueOnZero() { + $this->assertQuery( 'SELECT @@sql_mode AS mode;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertStringNotContainsString( 'NO_AUTO_VALUE_ON_ZERO', strtoupper( $results[0]->mode ) ); + } + + public function testAutoIncrementZeroAdvancesSequenceByDefault() { + // Default SQL modes do not include NO_AUTO_VALUE_ON_ZERO. + // Values like 0 and '0' should behave like NULL and advance the sequence. + $this->assertQuery( + "INSERT INTO _options (ID, option_name, option_value) VALUES (0, 'a', '1');" + ); + $this->assertQuery( + "INSERT INTO _options (ID, option_name, option_value) VALUES ('0', 'b', '2');" + ); + $this->assertQuery( + "INSERT INTO _options (ID, option_name, option_value) VALUES (NULL, 'c', '3');" + ); + + $this->assertQuery( 'SELECT ID, option_name FROM _options ORDER BY ID;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( 1, $results[0]->ID ); + $this->assertEquals( 'a', $results[0]->option_name ); + $this->assertEquals( 2, $results[1]->ID ); + $this->assertEquals( 'b', $results[1]->option_name ); + $this->assertEquals( 3, $results[2]->ID ); + $this->assertEquals( 'c', $results[2]->option_name ); + } + + public function testAutoIncrementZeroAdvancesSequenceForAllInsertShapes() { + // INSERT ... SET + $this->assertQuery( "INSERT INTO _options SET ID = 0, option_name = 'set', option_value = '1';" ); + + // INSERT ... SELECT + $this->assertQuery( "INSERT INTO _options (ID, option_name, option_value) SELECT 0, 'select', '2';" ); + + // REPLACE ... VALUES + $this->assertQuery( "REPLACE INTO _options (ID, option_name, option_value) VALUES ('0', 'replace', '3');" ); + + $this->assertQuery( 'SELECT ID, option_name FROM _options ORDER BY ID;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( 1, $results[0]->ID ); + $this->assertEquals( 2, $results[1]->ID ); + $this->assertEquals( 3, $results[2]->ID ); + } + + public function testNoAutoValueOnZeroSqlMode() { + $this->assertQuery( "SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO'" ); + + // Literal 0 and '0' are stored as-is. Only NULL generates a value. + $this->assertQuery( + "INSERT INTO _options (ID, option_name, option_value) VALUES (0, 'a', '1');" + ); + + $this->assertQuery( "SELECT ID FROM _options WHERE option_name = 'a';" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 0, $results[0]->ID ); + + $this->assertQuery( + "INSERT INTO _options (ID, option_name, option_value) VALUES (NULL, 'b', '2');" + ); + $this->assertQuery( "SELECT ID FROM _options WHERE option_name = 'b';" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 1, $results[0]->ID ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table (