diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index c4b83e02..c3251708 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -2370,6 +2370,307 @@ 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 ); + } + + /** + * 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'" + ); + } + + /** + * 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-ast/class-wp-pdo-mysql-on-sqlite.php b/wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php index f57814ff..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 @@ -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 + */ + $has_zero_in_date = ( + ( '00' === $matches[2] || '00' === $matches[3] ) && + '0000' !== $matches[1] + ); + if ( ! $has_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) { + $value = '0000-00-00 00:00:00'; + } } } return $value; @@ -5660,16 +5676,46 @@ private function cast_value_for_saving( ? 'NULL' : $this->quote_sqlite_value( $implicit_default ); } - return sprintf( + + /* + * 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: + * + * 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. + */ + return strtr( "CASE - WHEN %s IS NULL THEN NULL - 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 {function_call} > '0' THEN {function_call} + ELSE {fallback} END", - $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: /* 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..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 @@ -238,6 +238,14 @@ public function dateformat( $date, $format ) { * @return string Representing the number of the month between 1 and 12. */ public function month( $field ) { + /* + * 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] ); + } /* * From https://www.php.net/manual/en/datetime.format.php: * @@ -255,6 +263,13 @@ public function month( $field ) { * @return string Representing the number of the year. */ 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 ) ) { + return intval( $matches[1] ); + } /* * From https://www.php.net/manual/en/datetime.format.php: * @@ -271,6 +286,14 @@ public function year( $field ) { * @return string Representing the number of the day of the month from 1 and 31. */ public function day( $field ) { + /* + * 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] ); + } /* * From https://www.php.net/manual/en/datetime.format.php: *