From f8c58ded52815dd59a698bfa1a74d63ae9a1df66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 11 Mar 2026 23:31:03 +0100 Subject: [PATCH 01/10] Accept zero dates when NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes are off MySQL's behavior around zero dates ('0000-00-00') and dates with zero parts ('2020-00-15') depends on two SQL modes: NO_ZERO_DATE and NO_ZERO_IN_DATE. Both are enabled by default in MySQL 8.0, but applications can disable them. Previously, the SQLite driver always rejected zero dates through SQLite's DATE()/DATETIME() functions, which return NULL for such values. This caused incorrect errors when strict mode was on but the zero-date modes were off. Now the CASE expression in cast_value_for_saving() checks the active SQL modes and inserts additional WHEN clauses to accept zero dates when appropriate. The translate_datetime_literal() method also preserves dates with zero month/day parts when NO_ZERO_IN_DATE is off, instead of truncating them via strtotime(). --- tests/WP_SQLite_Driver_Tests.php | 191 ++++++++++++++++++ .../class-wp-pdo-mysql-on-sqlite.php | 83 +++++++- 2 files changed, 271 insertions(+), 3 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index c4b83e02..e94f3168 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2370,6 +2370,197 @@ public function testTruncatesInvalidDates() { $this->assertEquals( '0000-00-00 00:00:00', $results[1]->option_value ); } + /** + * Test NO_ZERO_DATE SQL mode behavior. + * + * MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date): + * + * "The NO_ZERO_DATE mode affects whether the server permits '0000-00-00' as a valid date. + * Its effect also depends on whether strict SQL mode is enabled. + * - If this mode is not enabled, '0000-00-00' is permitted and inserts produce no warning. + * - If this mode is enabled, '0000-00-00' is permitted but produces a warning. + * - If this mode and strict mode are both enabled, '0000-00-00' is not permitted + * and inserts produce an error, unless IGNORE is also given." + */ + public function testZeroDateAcceptedWhenNoZeroDateModeIsOff() { + // With NO_ZERO_DATE disabled, '0000-00-00 00:00:00' should be accepted. + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); + } + + /** + * Test that zero dates are rejected in strict mode when NO_ZERO_DATE is active. + * + * MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date): + * + * "If this mode and strict mode are both enabled, '0000-00-00' is not + * permitted and inserts produce an error." + */ + public function testZeroDateRejectedWhenNoZeroDateAndStrictModeAreOn() { + // Default modes include both NO_ZERO_DATE and STRICT_TRANS_TABLES. + $this->assertQueryError( + "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');", + "Incorrect datetime value: '0000-00-00 00:00:00'" + ); + } + + /** + * Test that zero dates are accepted (with warning) when NO_ZERO_DATE is on + * but strict mode is off. + * + * MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date): + * + * "If this mode is enabled, '0000-00-00' is permitted but produces a warning." + */ + public function testZeroDateAcceptedWhenNoZeroDateOnButStrictModeOff() { + $this->assertQuery( "SET sql_mode = 'NO_ZERO_DATE'" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); + } + + /** + * Test that zero dates work with the DATE column type too. + */ + public function testZeroDateAcceptedForDateColumn() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( + "CREATE TABLE _date_test ( + ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, + col_date DATE NOT NULL + );" + ); + + $this->assertQuery( "INSERT INTO _date_test (col_date) VALUES ('0000-00-00');" ); + + $this->assertQuery( 'SELECT * FROM _date_test;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '0000-00-00', $results[0]->col_date ); + } + + /** + * Test NO_ZERO_IN_DATE SQL mode behavior. + * + * MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date): + * + * "The NO_ZERO_IN_DATE mode affects whether the server permits dates in + * which the year part is nonzero but the month or day part is 0. (This + * mode affects dates such as '2010-00-01' or '2010-01-00', but not + * '0000-00-00'. To control whether the server permits '0000-00-00', + * use the NO_ZERO_DATE mode.) + * - If this mode is not enabled, dates with zero parts are permitted + * and inserts produce no warning. + * - If this mode is enabled, dates with zero parts are inserted as + * '0000-00-00' and produce a warning. + * - If this mode and strict mode are both enabled, dates with zero parts + * are not permitted and inserts produce an error." + */ + public function testZeroInDateAcceptedWhenNoZeroInDateModeIsOff() { + // Disable NO_ZERO_IN_DATE but keep strict mode on. + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-01-00 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-00 00:00:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( '2020-00-15 00:00:00', $results[0]->option_value ); + $this->assertEquals( '2020-01-00 00:00:00', $results[1]->option_value ); + $this->assertEquals( '2020-00-00 00:00:00', $results[2]->option_value ); + } + + /** + * Test that dates with zero parts are rejected in strict mode when + * NO_ZERO_IN_DATE is active. + */ + public function testZeroInDateRejectedWhenNoZeroInDateAndStrictModeAreOn() { + // Default modes include both NO_ZERO_IN_DATE and STRICT_TRANS_TABLES. + $this->assertQueryError( + "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');", + "Incorrect datetime value: '2020-00-15 00:00:00'" + ); + } + + /** + * Test that dates with zero parts get stored as '0000-00-00 00:00:00' + * when NO_ZERO_IN_DATE is on but strict mode is off. + * + * MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date): + * + * "If this mode is enabled, dates with zero parts are inserted as + * '0000-00-00' and produce a warning." + */ + public function testZeroInDateBecomesZeroDateWhenNoZeroInDateOnButStrictOff() { + $this->assertQuery( "SET sql_mode = 'NO_ZERO_IN_DATE'" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); + } + + /** + * Test that all modes disabled allows both zero dates and zero-in-dates. + */ + public function testBothZeroDateModesDisabledAcceptsAll() { + $this->assertQuery( "SET sql_mode = ''" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-01-00 00:00:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); + $this->assertEquals( '2020-00-15 00:00:00', $results[1]->option_value ); + $this->assertEquals( '2020-01-00 00:00:00', $results[2]->option_value ); + } + + /** + * Test that valid dates still work correctly regardless of zero date modes. + */ + public function testValidDatesWorkWithZeroDateModes() { + // Default modes (NO_ZERO_DATE + STRICT_TRANS_TABLES). + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '2022-01-15 14:30:00', $results[0]->option_value ); + } + + /** + * Test zero date handling in UPDATE statements. + */ + public function testZeroDateInUpdate() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" ); + $this->assertQuery( "UPDATE _dates SET option_value = '0000-00-00 00:00:00';" ); + + $this->assertQuery( 'SELECT * FROM _dates;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table ( diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index f57814ff..1a8376f1 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -4494,14 +4494,30 @@ private function translate_datetime_literal( string $value ): string { * In the future, let's update WordPress to do its own date validation * and stop relying on this MySQL feature, */ - if ( 1 === preg_match( '/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) { + if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) { /* * Calling strtotime("0000-00-00 00:00:00") in 32-bit environments triggers * an "out of integer range" warning – let's avoid that call for the popular * case of "zero" dates. */ if ( '0000-00-00 00:00:00' !== $value && false === strtotime( $value ) ) { - $value = '0000-00-00 00:00:00'; + /* + * Check for dates with zero month/day parts (e.g. '2020-00-15 00:00:00'). + * + * When the NO_ZERO_IN_DATE SQL mode is not active, MySQL accepts dates + * where the year is nonzero but the month or day is zero. We must + * preserve these values so that cast_value_for_saving() can handle + * them correctly at the column level. + * + * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date + */ + $is_zero_in_date = ( + '0000' !== $matches[1] + && ( '00' === $matches[2] || '00' === $matches[3] ) + ); + if ( ! $is_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + $value = '0000-00-00 00:00:00'; + } } } return $value; @@ -5660,13 +5676,74 @@ private function cast_value_for_saving( ? 'NULL' : $this->quote_sqlite_value( $implicit_default ); } + + /* + * Build additional WHEN clauses to accept zero dates based + * on the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes. + * + * In MySQL, the behavior of zero dates depends on these modes: + * + * NO_ZERO_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date): + * - Disabled: '0000-00-00' is permitted and produces no warning. + * - Enabled without strict mode: '0000-00-00' is permitted but produces a warning. + * - Enabled with strict mode: '0000-00-00' is not permitted and produces an error. + * + * NO_ZERO_IN_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date): + * - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted. + * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'. + * - Enabled with strict mode: zero-part dates produce an error. + * + * SQLite's DATE()/DATETIME() functions return NULL for zero dates, + * so without these extra WHEN clauses, zero dates would always fall + * through to the error/implicit-default fallback. + */ + $zero_date_whens = ''; + if ( 'time' !== $mysql_data_type ) { + /* + * When NO_ZERO_DATE is not active, or when it is active but + * strict mode is off, accept all-zero dates. In MySQL, only + * the combination of NO_ZERO_DATE + strict mode rejects them. + */ + $reject_zero_date = ( + $this->is_sql_mode_active( 'NO_ZERO_DATE' ) + && $is_strict_mode + ); + if ( ! $reject_zero_date ) { + $zero_date_value = 'date' === $mysql_data_type + ? "'0000-00-00'" + : "'0000-00-00 00:00:00'"; + $zero_date_whens .= sprintf( + "WHEN %s IN ('0000-00-00', '0000-00-00 00:00:00') THEN %s\n", + $translated_value, + $zero_date_value + ); + } + + /* + * When NO_ZERO_IN_DATE is not active, accept dates where the + * year is nonzero but the month or day part is zero (e.g. + * '2020-00-15' or '2020-01-00'). These are valid in MySQL when + * the NO_ZERO_IN_DATE mode is disabled. + */ + if ( ! $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + $zero_date_whens .= sprintf( + "WHEN SUBSTR(%s, 1, 4) != '0000' AND (SUBSTR(%s, 6, 2) = '00' OR SUBSTR(%s, 9, 2) = '00') THEN %s\n", + $translated_value, + $translated_value, + $translated_value, + $translated_value + ); + } + } + return sprintf( "CASE WHEN %s IS NULL THEN NULL - WHEN %s > '0' THEN %s + %sWHEN %s > '0' THEN %s ELSE %s END", $translated_value, + $zero_date_whens, $function_call, $function_call, $fallback From e962cdf2980c777abfc5eb565b9f1a2341a5e340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 11 Mar 2026 23:55:58 +0100 Subject: [PATCH 02/10] Inline zero-date mode checks into the CASE expression Instead of conditionally building $zero_date_whens in PHP, the WHEN clauses are always present in the CASE statement with the SQL mode check embedded as a literal boolean (AND NOT 0/1). This keeps the generated SQL structure consistent regardless of active modes. --- .../class-wp-pdo-mysql-on-sqlite.php | 72 ++++++------------- 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 1a8376f1..950b8ef7 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -4511,11 +4511,11 @@ private function translate_datetime_literal( string $value ): string { * * See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date */ - $is_zero_in_date = ( + $has_zero_in_date = ( + ( '00' === $matches[2] || '00' === $matches[3] ) && '0000' !== $matches[1] - && ( '00' === $matches[2] || '00' === $matches[3] ) ); - if ( ! $is_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + if ( ! $has_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { $value = '0000-00-00 00:00:00'; } } @@ -5678,8 +5678,11 @@ private function cast_value_for_saving( } /* - * Build additional WHEN clauses to accept zero dates based - * on the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes. + * Build the CASE expression for date/time validation. + * + * SQLite's DATE()/DATETIME() functions return NULL for zero + * dates, so the CASE includes explicit checks controlled by + * the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes. * * In MySQL, the behavior of zero dates depends on these modes: * @@ -5692,60 +5695,27 @@ private function cast_value_for_saving( * - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted. * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'. * - Enabled with strict mode: zero-part dates produce an error. - * - * SQLite's DATE()/DATETIME() functions return NULL for zero dates, - * so without these extra WHEN clauses, zero dates would always fall - * through to the error/implicit-default fallback. */ - $zero_date_whens = ''; - if ( 'time' !== $mysql_data_type ) { - /* - * When NO_ZERO_DATE is not active, or when it is active but - * strict mode is off, accept all-zero dates. In MySQL, only - * the combination of NO_ZERO_DATE + strict mode rejects them. - */ - $reject_zero_date = ( - $this->is_sql_mode_active( 'NO_ZERO_DATE' ) - && $is_strict_mode - ); - if ( ! $reject_zero_date ) { - $zero_date_value = 'date' === $mysql_data_type - ? "'0000-00-00'" - : "'0000-00-00 00:00:00'"; - $zero_date_whens .= sprintf( - "WHEN %s IN ('0000-00-00', '0000-00-00 00:00:00') THEN %s\n", - $translated_value, - $zero_date_value - ); - } - - /* - * When NO_ZERO_IN_DATE is not active, accept dates where the - * year is nonzero but the month or day part is zero (e.g. - * '2020-00-15' or '2020-01-00'). These are valid in MySQL when - * the NO_ZERO_IN_DATE mode is disabled. - */ - if ( ! $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { - $zero_date_whens .= sprintf( - "WHEN SUBSTR(%s, 1, 4) != '0000' AND (SUBSTR(%s, 6, 2) = '00' OR SUBSTR(%s, 9, 2) = '00') THEN %s\n", - $translated_value, - $translated_value, - $translated_value, - $translated_value - ); - } - } + $reject_zero_date = ( + $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode + ) ? 1 : 0; + $reject_zero_in_date = $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0; + $zero_date_value = 'date' === $mysql_data_type + ? "'0000-00-00'" + : "'0000-00-00 00:00:00'"; return sprintf( "CASE WHEN %s IS NULL THEN NULL - %sWHEN %s > '0' THEN %s + WHEN %s IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT %d THEN %s + WHEN SUBSTR(%s, 1, 4) != '0000' AND (SUBSTR(%s, 6, 2) = '00' OR SUBSTR(%s, 9, 2) = '00') AND NOT %d THEN %s + WHEN %s > '0' THEN %s ELSE %s END", $translated_value, - $zero_date_whens, - $function_call, - $function_call, + $translated_value, $reject_zero_date, $zero_date_value, + $translated_value, $translated_value, $translated_value, $reject_zero_in_date, $translated_value, + $function_call, $function_call, $fallback ); default: From 025b03d3927d9b528c578a979102985a3af846c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 00:04:24 +0100 Subject: [PATCH 03/10] Fix PHPCS code style violations One argument per line in the sprintf call, align equals signs, and use single quotes for CREATE TABLE string in tests. --- tests/WP_SQLite_Driver_Tests.php | 4 ++-- .../sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index e94f3168..bb0ea4f3 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2436,10 +2436,10 @@ public function testZeroDateAcceptedForDateColumn() { $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); $this->assertQuery( - "CREATE TABLE _date_test ( + 'CREATE TABLE _date_test ( ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL, col_date DATE NOT NULL - );" + );' ); $this->assertQuery( "INSERT INTO _date_test (col_date) VALUES ('0000-00-00');" ); diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 950b8ef7..6428b3fe 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -5696,7 +5696,7 @@ private function cast_value_for_saving( * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'. * - Enabled with strict mode: zero-part dates produce an error. */ - $reject_zero_date = ( + $reject_zero_date = ( $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode ) ? 1 : 0; $reject_zero_in_date = $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0; @@ -5713,9 +5713,16 @@ private function cast_value_for_saving( ELSE %s END", $translated_value, - $translated_value, $reject_zero_date, $zero_date_value, - $translated_value, $translated_value, $translated_value, $reject_zero_in_date, $translated_value, - $function_call, $function_call, + $translated_value, + $reject_zero_date, + $zero_date_value, + $translated_value, + $translated_value, + $translated_value, + $reject_zero_in_date, + $translated_value, + $function_call, + $function_call, $fallback ); default: From be86458f44cfa7d4794867ad87561a21968845d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 00:33:09 +0100 Subject: [PATCH 04/10] Use strtr with named placeholders for the zero-date CASE expression Replace sprintf with strtr so each substituted value appears once in the argument list and the template reads like annotated SQL. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-pdo-mysql-on-sqlite.php | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index 6428b3fe..f9ef7f13 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -5696,34 +5696,26 @@ private function cast_value_for_saving( * - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'. * - Enabled with strict mode: zero-part dates produce an error. */ - $reject_zero_date = ( - $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode - ) ? 1 : 0; - $reject_zero_in_date = $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0; - $zero_date_value = 'date' === $mysql_data_type - ? "'0000-00-00'" - : "'0000-00-00 00:00:00'"; - - return sprintf( + return strtr( "CASE - WHEN %s IS NULL THEN NULL - WHEN %s IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT %d THEN %s - WHEN SUBSTR(%s, 1, 4) != '0000' AND (SUBSTR(%s, 6, 2) = '00' OR SUBSTR(%s, 9, 2) = '00') AND NOT %d THEN %s - WHEN %s > '0' THEN %s - ELSE %s + WHEN {value} IS NULL THEN NULL + WHEN {value} IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT {reject_zero_date} THEN {zero_date_value} + WHEN SUBSTR({value}, 1, 4) != '0000' AND (SUBSTR({value}, 6, 2) = '00' OR SUBSTR({value}, 9, 2) = '00') AND NOT {reject_zero_in_date} THEN {value} + WHEN {value} > '0' THEN {function_call} + ELSE {fallback} END", - $translated_value, - $translated_value, - $reject_zero_date, - $zero_date_value, - $translated_value, - $translated_value, - $translated_value, - $reject_zero_in_date, - $translated_value, - $function_call, - $function_call, - $fallback + array( + '{value}' => $translated_value, + '{reject_zero_date}' => ( + $this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode + ) ? 1 : 0, + '{zero_date_value}' => 'date' === $mysql_data_type + ? "'0000-00-00'" + : "'0000-00-00 00:00:00'", + '{reject_zero_in_date}' => $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0, + '{function_call}' => $function_call, + '{fallback}' => $fallback, + ) ); default: /* From 7763982fbb571b34ec8b268fa445a3db39100513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 00:42:26 +0100 Subject: [PATCH 05/10] Fix the zero-date CASE expression to compare the function call result, not the raw value The strtr refactor accidentally changed `WHEN $function_call > '0'` to `WHEN {value} > '0'`, comparing the wrong operand and breaking date validation. Co-Authored-By: Claude Opus 4.6 --- wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index f9ef7f13..b70a0dd6 100644 --- a/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php +++ b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php @@ -5701,7 +5701,7 @@ private function cast_value_for_saving( WHEN {value} IS NULL THEN NULL WHEN {value} IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT {reject_zero_date} THEN {zero_date_value} WHEN SUBSTR({value}, 1, 4) != '0000' AND (SUBSTR({value}, 6, 2) = '00' OR SUBSTR({value}, 9, 2) = '00') AND NOT {reject_zero_in_date} THEN {value} - WHEN {value} > '0' THEN {function_call} + WHEN {function_call} > '0' THEN {function_call} ELSE {fallback} END", array( From cc3c49433edc51d3d1ebdda3bce69caa54d5a5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 01:30:57 +0100 Subject: [PATCH 06/10] Test that UPDATE rejects zero dates in strict mode Verify that UPDATE statements produce errors for zero dates and zero-in-dates when strict mode is combined with NO_ZERO_DATE or NO_ZERO_IN_DATE, matching the existing INSERT rejection tests. Co-Authored-By: Claude Opus 4.6 --- tests/WP_SQLite_Driver_Tests.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index bb0ea4f3..07d158ab 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2561,6 +2561,31 @@ public function testZeroDateInUpdate() { $this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value ); } + /** + * Test that zero dates are rejected in UPDATE when NO_ZERO_DATE and strict mode are on. + */ + public function testZeroDateInUpdateRejectedWhenNoZeroDateAndStrictModeAreOn() { + // Default modes include both NO_ZERO_DATE and STRICT_TRANS_TABLES. + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" ); + $this->assertQueryError( + "UPDATE _dates SET option_value = '0000-00-00 00:00:00';", + "Incorrect datetime value: '0000-00-00 00:00:00'" + ); + } + + /** + * Test that dates with zero parts are rejected in UPDATE when + * NO_ZERO_IN_DATE and strict mode are on. + */ + public function testZeroInDateInUpdateRejectedWhenNoZeroInDateAndStrictModeAreOn() { + // Default modes include both NO_ZERO_IN_DATE and STRICT_TRANS_TABLES. + $this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" ); + $this->assertQueryError( + "UPDATE _dates SET option_value = '2020-00-15 00:00:00';", + "Incorrect datetime value: '2020-00-15 00:00:00'" + ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table ( From 13c8a349ff1ee94d1b52abc7f40bc1db9466c082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 01:46:48 +0100 Subject: [PATCH 07/10] Test zero dates in SELECT and fix YEAR/MONTH/DAY for zero dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests verifying that stored zero dates and zero-in-dates can be selected, compared, ordered, and filtered – matching MySQL behavior. Fix YEAR(), MONTH(), and DAY() functions to return 0 for zero date parts. Previously, strtotime() couldn't parse dates like '0000-00-00' or '2020-00-15', producing wrong results. Now the date parts are extracted directly from the string when possible. Co-Authored-By: Claude Opus 4.6 --- tests/WP_SQLite_Driver_Tests.php | 85 +++++++++++++++++++ ...s-wp-sqlite-pdo-user-defined-functions.php | 28 +++--- 2 files changed, 102 insertions(+), 11 deletions(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 07d158ab..1297ccfd 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2586,6 +2586,91 @@ public function testZeroInDateInUpdateRejectedWhenNoZeroInDateAndStrictModeAreOn ); } + /** + * Test that stored zero dates can be selected and compared. + * + * In MySQL, zero dates are regular values for reads — they can appear in + * WHERE, ORDER BY, and comparisons regardless of the current SQL mode. + */ + public function testSelectZeroDatesComparison() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero', '0000-00-00 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('real', '2022-01-15 14:30:00');" ); + + // Zero dates compare as less than real dates. + $this->assertQuery( "SELECT option_name FROM _dates WHERE option_value < '2000-01-01 00:00:00' ORDER BY option_value;" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'zero', $results[0]->option_name ); + + // Equality match on zero date. + $this->assertQuery( "SELECT option_name FROM _dates WHERE option_value = '0000-00-00 00:00:00';" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'zero', $results[0]->option_name ); + } + + /** + * Test ORDER BY with a mix of zero and non-zero dates. + */ + public function testSelectZeroDatesOrderBy() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('b', '2022-06-01 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('a', '0000-00-00 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('c', '2023-01-01 00:00:00');" ); + + $this->assertQuery( 'SELECT option_name FROM _dates ORDER BY option_value ASC;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( 'a', $results[0]->option_name ); + $this->assertEquals( 'b', $results[1]->option_name ); + $this->assertEquals( 'c', $results[2]->option_name ); + } + + /** + * Test that zero-in-dates stored in the database can be read back + * and filtered in SELECT statements. + */ + public function testSelectZeroInDates() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero-month', '2020-00-15 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero-day', '2020-01-00 00:00:00');" ); + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('normal', '2020-01-15 00:00:00');" ); + + // All three rows are readable. + $this->assertQuery( 'SELECT option_name, option_value FROM _dates ORDER BY option_value ASC;' ); + $results = $this->engine->get_query_results(); + $this->assertCount( 3, $results ); + $this->assertEquals( '2020-00-15 00:00:00', $results[0]->option_value ); + $this->assertEquals( '2020-01-00 00:00:00', $results[1]->option_value ); + $this->assertEquals( '2020-01-15 00:00:00', $results[2]->option_value ); + + // Filtering by a zero-in-date value works. + $this->assertQuery( "SELECT option_name FROM _dates WHERE option_value = '2020-00-15 00:00:00';" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 'zero-month', $results[0]->option_name ); + } + + /** + * Test date functions on zero dates — YEAR(), MONTH(), DAY() all return 0. + */ + public function testDateFunctionsOnZeroDates() { + $this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" ); + + $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero', '0000-00-00 00:00:00');" ); + + $this->assertQuery( "SELECT YEAR(option_value) as y, MONTH(option_value) as m, DAY(option_value) as d FROM _dates;" ); + $results = $this->engine->get_query_results(); + $this->assertCount( 1, $results ); + $this->assertEquals( 0, $results[0]->y ); + $this->assertEquals( 0, $results[0]->m ); + $this->assertEquals( 0, $results[0]->d ); + } + public function testCaseInsensitiveSelect() { $this->assertQuery( "CREATE TABLE _tmp_table ( diff --git a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 06bad718..072b164a 100644 --- a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -239,11 +239,13 @@ public function dateformat( $date, $format ) { */ public function month( $field ) { /* - * From https://www.php.net/manual/en/datetime.format.php: - * - * n - Numeric representation of a month, without leading zeros. - * 1 through 12 + * MySQL returns 0 for MONTH('0000-00-00') and for dates with + * zero month parts like '2020-00-15'. PHP's strtotime() can't + * parse these, so we extract the month directly from the string. */ + if ( preg_match( '/\d{4}-(\d{2})/', $field, $matches ) ) { + return intval( $matches[1] ); + } return intval( gmdate( 'n', strtotime( $field ) ) ); } @@ -256,10 +258,12 @@ public function month( $field ) { */ public function year( $field ) { /* - * From https://www.php.net/manual/en/datetime.format.php: - * - * Y - A full numeric representation of a year, 4 digits. + * MySQL returns 0 for YEAR('0000-00-00'). PHP's strtotime() + * can't parse zero dates, so we extract the year directly. */ + if ( preg_match( '/(\d{4})-\d{2}/', $field, $matches ) ) { + return intval( $matches[1] ); + } return intval( gmdate( 'Y', strtotime( $field ) ) ); } @@ -272,11 +276,13 @@ public function year( $field ) { */ public function day( $field ) { /* - * From https://www.php.net/manual/en/datetime.format.php: - * - * j - Day of the month without leading zeros. - * 1 to 31. + * MySQL returns 0 for DAY('0000-00-00') and for dates with + * zero day parts like '2020-01-00'. PHP's strtotime() can't + * parse these, so we extract the day directly from the string. */ + if ( preg_match( '/\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) { + return intval( $matches[1] ); + } return intval( gmdate( 'j', strtotime( $field ) ) ); } From 8e2c32fc86b03f1b0796d7cd3e31a71a23f01def Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 01:53:51 +0100 Subject: [PATCH 08/10] Fix PHPCS: use single quotes for SQL string without interpolation Co-Authored-By: Claude Opus 4.6 --- tests/WP_SQLite_Driver_Tests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index 1297ccfd..c3251708 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2663,7 +2663,7 @@ public function testDateFunctionsOnZeroDates() { $this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero', '0000-00-00 00:00:00');" ); - $this->assertQuery( "SELECT YEAR(option_value) as y, MONTH(option_value) as m, DAY(option_value) as d FROM _dates;" ); + $this->assertQuery( 'SELECT YEAR(option_value) as y, MONTH(option_value) as m, DAY(option_value) as d FROM _dates;' ); $results = $this->engine->get_query_results(); $this->assertCount( 1, $results ); $this->assertEquals( 0, $results[0]->y ); From c199ff80845ca56cb0090b5d1d5a811c42603496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 09:30:55 +0100 Subject: [PATCH 09/10] Restore PHP datetime.format.php documentation comments The PHP manual references for format specifiers used by gmdate() were accidentally removed when adding zero-date handling. These comments document why specific format characters (n, Y, j) are used in the fallback path and should be preserved. --- ...ass-wp-sqlite-pdo-user-defined-functions.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 072b164a..0851ec2f 100644 --- a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -246,6 +246,12 @@ public function month( $field ) { if ( preg_match( '/\d{4}-(\d{2})/', $field, $matches ) ) { return intval( $matches[1] ); } + /* + * From https://www.php.net/manual/en/datetime.format.php: + * + * n - Numeric representation of a month, without leading zeros. + * 1 through 12 + */ return intval( gmdate( 'n', strtotime( $field ) ) ); } @@ -264,6 +270,11 @@ public function year( $field ) { if ( preg_match( '/(\d{4})-\d{2}/', $field, $matches ) ) { return intval( $matches[1] ); } + /* + * From https://www.php.net/manual/en/datetime.format.php: + * + * Y - A full numeric representation of a year, 4 digits. + */ return intval( gmdate( 'Y', strtotime( $field ) ) ); } @@ -283,6 +294,12 @@ public function day( $field ) { if ( preg_match( '/\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) { return intval( $matches[1] ); } + /* + * From https://www.php.net/manual/en/datetime.format.php: + * + * j - Day of the month without leading zeros. + * 1 to 31. + */ return intval( gmdate( 'j', strtotime( $field ) ) ); } From 131d3b2c9b588750a176fe824e91189ab2483db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 12 Mar 2026 15:53:08 +0100 Subject: [PATCH 10/10] Anchor date-extraction regexps with ^ Add ^ anchors to the YEAR/MONTH/DAY regex patterns so they only match date strings starting at the beginning of the input, not arbitrary substrings. --- .../sqlite/class-wp-sqlite-pdo-user-defined-functions.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php index 0851ec2f..b72d787f 100644 --- a/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php +++ b/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php @@ -243,7 +243,7 @@ public function month( $field ) { * zero month parts like '2020-00-15'. PHP's strtotime() can't * parse these, so we extract the month directly from the string. */ - if ( preg_match( '/\d{4}-(\d{2})/', $field, $matches ) ) { + if ( preg_match( '/^\d{4}-(\d{2})/', $field, $matches ) ) { return intval( $matches[1] ); } /* @@ -267,7 +267,7 @@ public function year( $field ) { * MySQL returns 0 for YEAR('0000-00-00'). PHP's strtotime() * can't parse zero dates, so we extract the year directly. */ - if ( preg_match( '/(\d{4})-\d{2}/', $field, $matches ) ) { + if ( preg_match( '/^(\d{4})-\d{2}/', $field, $matches ) ) { return intval( $matches[1] ); } /* @@ -291,7 +291,7 @@ public function day( $field ) { * zero day parts like '2020-01-00'. PHP's strtotime() can't * parse these, so we extract the day directly from the string. */ - if ( preg_match( '/\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) { + if ( preg_match( '/^\d{4}-\d{2}-(\d{2})/', $field, $matches ) ) { return intval( $matches[1] ); } /*