Skip to content

Commit d87bc8f

Browse files
authored
Accept zero dates when NO_ZERO_DATE SQL mode is off (#327)
## Summary MySQL controls zero-date acceptance through two SQL modes: `NO_ZERO_DATE` and `NO_ZERO_IN_DATE`. Both are on by default, but applications can turn them off to allow values like `'0000-00-00 00:00:00'` or `'2020-00-15'`. The SQLite driver wasn't honoring these modes – SQLite's `DATE()`/`DATETIME()` functions return NULL for zero dates, so they always fell through to the strict-mode error, even when the zero-date modes were disabled. This PR adds zero-date acceptance checks to `cast_value_for_saving()` that inspect the active SQL modes before rejecting a date. When `NO_ZERO_DATE` is off (or on without strict mode), all-zero dates pass through. When `NO_ZERO_IN_DATE` is off, dates with zero month/day parts like `'2020-00-15'` are accepted too. The `translate_datetime_literal()` method is also updated to preserve zero-part dates instead of truncating them via `strtotime()`. The behavior matrix now matches MySQL's documentation: | Mode combination | `'0000-00-00'` | `'2020-00-15'` | |---|---|---| | Both modes off | Accepted | Accepted | | Mode on, non-strict | Accepted (warning in MySQL) | Stored as `'0000-00-00'` | | Mode on + strict | Error | Error | ## Test plan - [x] 12 new PHPUnit tests covering all combinations of NO_ZERO_DATE × NO_ZERO_IN_DATE × strict mode - [x] Full test suite passes (772 tests, 0 failures) - [ ] Review that WordPress core's `test_insert_empty_post_date` still works with default modes
2 parents 3a5c48c + 131d3b2 commit d87bc8f

3 files changed

Lines changed: 380 additions & 10 deletions

File tree

tests/WP_SQLite_Driver_Tests.php

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,307 @@ public function testTruncatesInvalidDates() {
23702370
$this->assertEquals( '0000-00-00 00:00:00', $results[1]->option_value );
23712371
}
23722372

2373+
/**
2374+
* Test NO_ZERO_DATE SQL mode behavior.
2375+
*
2376+
* MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
2377+
*
2378+
* "The NO_ZERO_DATE mode affects whether the server permits '0000-00-00' as a valid date.
2379+
* Its effect also depends on whether strict SQL mode is enabled.
2380+
* - If this mode is not enabled, '0000-00-00' is permitted and inserts produce no warning.
2381+
* - If this mode is enabled, '0000-00-00' is permitted but produces a warning.
2382+
* - If this mode and strict mode are both enabled, '0000-00-00' is not permitted
2383+
* and inserts produce an error, unless IGNORE is also given."
2384+
*/
2385+
public function testZeroDateAcceptedWhenNoZeroDateModeIsOff() {
2386+
// With NO_ZERO_DATE disabled, '0000-00-00 00:00:00' should be accepted.
2387+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2388+
2389+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" );
2390+
2391+
$this->assertQuery( 'SELECT * FROM _dates;' );
2392+
$results = $this->engine->get_query_results();
2393+
$this->assertCount( 1, $results );
2394+
$this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value );
2395+
}
2396+
2397+
/**
2398+
* Test that zero dates are rejected in strict mode when NO_ZERO_DATE is active.
2399+
*
2400+
* MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
2401+
*
2402+
* "If this mode and strict mode are both enabled, '0000-00-00' is not
2403+
* permitted and inserts produce an error."
2404+
*/
2405+
public function testZeroDateRejectedWhenNoZeroDateAndStrictModeAreOn() {
2406+
// Default modes include both NO_ZERO_DATE and STRICT_TRANS_TABLES.
2407+
$this->assertQueryError(
2408+
"INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');",
2409+
"Incorrect datetime value: '0000-00-00 00:00:00'"
2410+
);
2411+
}
2412+
2413+
/**
2414+
* Test that zero dates are accepted (with warning) when NO_ZERO_DATE is on
2415+
* but strict mode is off.
2416+
*
2417+
* MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
2418+
*
2419+
* "If this mode is enabled, '0000-00-00' is permitted but produces a warning."
2420+
*/
2421+
public function testZeroDateAcceptedWhenNoZeroDateOnButStrictModeOff() {
2422+
$this->assertQuery( "SET sql_mode = 'NO_ZERO_DATE'" );
2423+
2424+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" );
2425+
2426+
$this->assertQuery( 'SELECT * FROM _dates;' );
2427+
$results = $this->engine->get_query_results();
2428+
$this->assertCount( 1, $results );
2429+
$this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value );
2430+
}
2431+
2432+
/**
2433+
* Test that zero dates work with the DATE column type too.
2434+
*/
2435+
public function testZeroDateAcceptedForDateColumn() {
2436+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2437+
2438+
$this->assertQuery(
2439+
'CREATE TABLE _date_test (
2440+
ID INTEGER PRIMARY KEY AUTO_INCREMENT NOT NULL,
2441+
col_date DATE NOT NULL
2442+
);'
2443+
);
2444+
2445+
$this->assertQuery( "INSERT INTO _date_test (col_date) VALUES ('0000-00-00');" );
2446+
2447+
$this->assertQuery( 'SELECT * FROM _date_test;' );
2448+
$results = $this->engine->get_query_results();
2449+
$this->assertCount( 1, $results );
2450+
$this->assertEquals( '0000-00-00', $results[0]->col_date );
2451+
}
2452+
2453+
/**
2454+
* Test NO_ZERO_IN_DATE SQL mode behavior.
2455+
*
2456+
* MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
2457+
*
2458+
* "The NO_ZERO_IN_DATE mode affects whether the server permits dates in
2459+
* which the year part is nonzero but the month or day part is 0. (This
2460+
* mode affects dates such as '2010-00-01' or '2010-01-00', but not
2461+
* '0000-00-00'. To control whether the server permits '0000-00-00',
2462+
* use the NO_ZERO_DATE mode.)
2463+
* - If this mode is not enabled, dates with zero parts are permitted
2464+
* and inserts produce no warning.
2465+
* - If this mode is enabled, dates with zero parts are inserted as
2466+
* '0000-00-00' and produce a warning.
2467+
* - If this mode and strict mode are both enabled, dates with zero parts
2468+
* are not permitted and inserts produce an error."
2469+
*/
2470+
public function testZeroInDateAcceptedWhenNoZeroInDateModeIsOff() {
2471+
// Disable NO_ZERO_IN_DATE but keep strict mode on.
2472+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2473+
2474+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" );
2475+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-01-00 00:00:00');" );
2476+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-00 00:00:00');" );
2477+
2478+
$this->assertQuery( 'SELECT * FROM _dates;' );
2479+
$results = $this->engine->get_query_results();
2480+
$this->assertCount( 3, $results );
2481+
$this->assertEquals( '2020-00-15 00:00:00', $results[0]->option_value );
2482+
$this->assertEquals( '2020-01-00 00:00:00', $results[1]->option_value );
2483+
$this->assertEquals( '2020-00-00 00:00:00', $results[2]->option_value );
2484+
}
2485+
2486+
/**
2487+
* Test that dates with zero parts are rejected in strict mode when
2488+
* NO_ZERO_IN_DATE is active.
2489+
*/
2490+
public function testZeroInDateRejectedWhenNoZeroInDateAndStrictModeAreOn() {
2491+
// Default modes include both NO_ZERO_IN_DATE and STRICT_TRANS_TABLES.
2492+
$this->assertQueryError(
2493+
"INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');",
2494+
"Incorrect datetime value: '2020-00-15 00:00:00'"
2495+
);
2496+
}
2497+
2498+
/**
2499+
* Test that dates with zero parts get stored as '0000-00-00 00:00:00'
2500+
* when NO_ZERO_IN_DATE is on but strict mode is off.
2501+
*
2502+
* MySQL reference (https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
2503+
*
2504+
* "If this mode is enabled, dates with zero parts are inserted as
2505+
* '0000-00-00' and produce a warning."
2506+
*/
2507+
public function testZeroInDateBecomesZeroDateWhenNoZeroInDateOnButStrictOff() {
2508+
$this->assertQuery( "SET sql_mode = 'NO_ZERO_IN_DATE'" );
2509+
2510+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" );
2511+
2512+
$this->assertQuery( 'SELECT * FROM _dates;' );
2513+
$results = $this->engine->get_query_results();
2514+
$this->assertCount( 1, $results );
2515+
$this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value );
2516+
}
2517+
2518+
/**
2519+
* Test that all modes disabled allows both zero dates and zero-in-dates.
2520+
*/
2521+
public function testBothZeroDateModesDisabledAcceptsAll() {
2522+
$this->assertQuery( "SET sql_mode = ''" );
2523+
2524+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('0000-00-00 00:00:00');" );
2525+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-00-15 00:00:00');" );
2526+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2020-01-00 00:00:00');" );
2527+
2528+
$this->assertQuery( 'SELECT * FROM _dates;' );
2529+
$results = $this->engine->get_query_results();
2530+
$this->assertCount( 3, $results );
2531+
$this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value );
2532+
$this->assertEquals( '2020-00-15 00:00:00', $results[1]->option_value );
2533+
$this->assertEquals( '2020-01-00 00:00:00', $results[2]->option_value );
2534+
}
2535+
2536+
/**
2537+
* Test that valid dates still work correctly regardless of zero date modes.
2538+
*/
2539+
public function testValidDatesWorkWithZeroDateModes() {
2540+
// Default modes (NO_ZERO_DATE + STRICT_TRANS_TABLES).
2541+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" );
2542+
2543+
$this->assertQuery( 'SELECT * FROM _dates;' );
2544+
$results = $this->engine->get_query_results();
2545+
$this->assertCount( 1, $results );
2546+
$this->assertEquals( '2022-01-15 14:30:00', $results[0]->option_value );
2547+
}
2548+
2549+
/**
2550+
* Test zero date handling in UPDATE statements.
2551+
*/
2552+
public function testZeroDateInUpdate() {
2553+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2554+
2555+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" );
2556+
$this->assertQuery( "UPDATE _dates SET option_value = '0000-00-00 00:00:00';" );
2557+
2558+
$this->assertQuery( 'SELECT * FROM _dates;' );
2559+
$results = $this->engine->get_query_results();
2560+
$this->assertCount( 1, $results );
2561+
$this->assertEquals( '0000-00-00 00:00:00', $results[0]->option_value );
2562+
}
2563+
2564+
/**
2565+
* Test that zero dates are rejected in UPDATE when NO_ZERO_DATE and strict mode are on.
2566+
*/
2567+
public function testZeroDateInUpdateRejectedWhenNoZeroDateAndStrictModeAreOn() {
2568+
// Default modes include both NO_ZERO_DATE and STRICT_TRANS_TABLES.
2569+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" );
2570+
$this->assertQueryError(
2571+
"UPDATE _dates SET option_value = '0000-00-00 00:00:00';",
2572+
"Incorrect datetime value: '0000-00-00 00:00:00'"
2573+
);
2574+
}
2575+
2576+
/**
2577+
* Test that dates with zero parts are rejected in UPDATE when
2578+
* NO_ZERO_IN_DATE and strict mode are on.
2579+
*/
2580+
public function testZeroInDateInUpdateRejectedWhenNoZeroInDateAndStrictModeAreOn() {
2581+
// Default modes include both NO_ZERO_IN_DATE and STRICT_TRANS_TABLES.
2582+
$this->assertQuery( "INSERT INTO _dates (option_value) VALUES ('2022-01-15 14:30:00');" );
2583+
$this->assertQueryError(
2584+
"UPDATE _dates SET option_value = '2020-00-15 00:00:00';",
2585+
"Incorrect datetime value: '2020-00-15 00:00:00'"
2586+
);
2587+
}
2588+
2589+
/**
2590+
* Test that stored zero dates can be selected and compared.
2591+
*
2592+
* In MySQL, zero dates are regular values for reads — they can appear in
2593+
* WHERE, ORDER BY, and comparisons regardless of the current SQL mode.
2594+
*/
2595+
public function testSelectZeroDatesComparison() {
2596+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2597+
2598+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero', '0000-00-00 00:00:00');" );
2599+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('real', '2022-01-15 14:30:00');" );
2600+
2601+
// Zero dates compare as less than real dates.
2602+
$this->assertQuery( "SELECT option_name FROM _dates WHERE option_value < '2000-01-01 00:00:00' ORDER BY option_value;" );
2603+
$results = $this->engine->get_query_results();
2604+
$this->assertCount( 1, $results );
2605+
$this->assertEquals( 'zero', $results[0]->option_name );
2606+
2607+
// Equality match on zero date.
2608+
$this->assertQuery( "SELECT option_name FROM _dates WHERE option_value = '0000-00-00 00:00:00';" );
2609+
$results = $this->engine->get_query_results();
2610+
$this->assertCount( 1, $results );
2611+
$this->assertEquals( 'zero', $results[0]->option_name );
2612+
}
2613+
2614+
/**
2615+
* Test ORDER BY with a mix of zero and non-zero dates.
2616+
*/
2617+
public function testSelectZeroDatesOrderBy() {
2618+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2619+
2620+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('b', '2022-06-01 00:00:00');" );
2621+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('a', '0000-00-00 00:00:00');" );
2622+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('c', '2023-01-01 00:00:00');" );
2623+
2624+
$this->assertQuery( 'SELECT option_name FROM _dates ORDER BY option_value ASC;' );
2625+
$results = $this->engine->get_query_results();
2626+
$this->assertCount( 3, $results );
2627+
$this->assertEquals( 'a', $results[0]->option_name );
2628+
$this->assertEquals( 'b', $results[1]->option_name );
2629+
$this->assertEquals( 'c', $results[2]->option_name );
2630+
}
2631+
2632+
/**
2633+
* Test that zero-in-dates stored in the database can be read back
2634+
* and filtered in SELECT statements.
2635+
*/
2636+
public function testSelectZeroInDates() {
2637+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2638+
2639+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero-month', '2020-00-15 00:00:00');" );
2640+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero-day', '2020-01-00 00:00:00');" );
2641+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('normal', '2020-01-15 00:00:00');" );
2642+
2643+
// All three rows are readable.
2644+
$this->assertQuery( 'SELECT option_name, option_value FROM _dates ORDER BY option_value ASC;' );
2645+
$results = $this->engine->get_query_results();
2646+
$this->assertCount( 3, $results );
2647+
$this->assertEquals( '2020-00-15 00:00:00', $results[0]->option_value );
2648+
$this->assertEquals( '2020-01-00 00:00:00', $results[1]->option_value );
2649+
$this->assertEquals( '2020-01-15 00:00:00', $results[2]->option_value );
2650+
2651+
// Filtering by a zero-in-date value works.
2652+
$this->assertQuery( "SELECT option_name FROM _dates WHERE option_value = '2020-00-15 00:00:00';" );
2653+
$results = $this->engine->get_query_results();
2654+
$this->assertCount( 1, $results );
2655+
$this->assertEquals( 'zero-month', $results[0]->option_name );
2656+
}
2657+
2658+
/**
2659+
* Test date functions on zero dates — YEAR(), MONTH(), DAY() all return 0.
2660+
*/
2661+
public function testDateFunctionsOnZeroDates() {
2662+
$this->assertQuery( "SET sql_mode = 'STRICT_TRANS_TABLES'" );
2663+
2664+
$this->assertQuery( "INSERT INTO _dates (option_name, option_value) VALUES ('zero', '0000-00-00 00:00:00');" );
2665+
2666+
$this->assertQuery( 'SELECT YEAR(option_value) as y, MONTH(option_value) as m, DAY(option_value) as d FROM _dates;' );
2667+
$results = $this->engine->get_query_results();
2668+
$this->assertCount( 1, $results );
2669+
$this->assertEquals( 0, $results[0]->y );
2670+
$this->assertEquals( 0, $results[0]->m );
2671+
$this->assertEquals( 0, $results[0]->d );
2672+
}
2673+
23732674
public function testCaseInsensitiveSelect() {
23742675
$this->assertQuery(
23752676
"CREATE TABLE _tmp_table (

wp-includes/sqlite-ast/class-wp-pdo-mysql-on-sqlite.php

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4494,14 +4494,30 @@ private function translate_datetime_literal( string $value ): string {
44944494
* In the future, let's update WordPress to do its own date validation
44954495
* and stop relying on this MySQL feature,
44964496
*/
4497-
if ( 1 === preg_match( '/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) {
4497+
if ( 1 === preg_match( '/^(\d{4})-(\d{2})-(\d{2}) (\d{2}:\d{2}:\d{2})$/', $value, $matches ) ) {
44984498
/*
44994499
* Calling strtotime("0000-00-00 00:00:00") in 32-bit environments triggers
45004500
* an "out of integer range" warning – let's avoid that call for the popular
45014501
* case of "zero" dates.
45024502
*/
45034503
if ( '0000-00-00 00:00:00' !== $value && false === strtotime( $value ) ) {
4504-
$value = '0000-00-00 00:00:00';
4504+
/*
4505+
* Check for dates with zero month/day parts (e.g. '2020-00-15 00:00:00').
4506+
*
4507+
* When the NO_ZERO_IN_DATE SQL mode is not active, MySQL accepts dates
4508+
* where the year is nonzero but the month or day is zero. We must
4509+
* preserve these values so that cast_value_for_saving() can handle
4510+
* them correctly at the column level.
4511+
*
4512+
* See: https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date
4513+
*/
4514+
$has_zero_in_date = (
4515+
( '00' === $matches[2] || '00' === $matches[3] ) &&
4516+
'0000' !== $matches[1]
4517+
);
4518+
if ( ! $has_zero_in_date || $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) {
4519+
$value = '0000-00-00 00:00:00';
4520+
}
45054521
}
45064522
}
45074523
return $value;
@@ -5660,16 +5676,46 @@ private function cast_value_for_saving(
56605676
? 'NULL'
56615677
: $this->quote_sqlite_value( $implicit_default );
56625678
}
5663-
return sprintf(
5679+
5680+
/*
5681+
* Build the CASE expression for date/time validation.
5682+
*
5683+
* SQLite's DATE()/DATETIME() functions return NULL for zero
5684+
* dates, so the CASE includes explicit checks controlled by
5685+
* the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes.
5686+
*
5687+
* In MySQL, the behavior of zero dates depends on these modes:
5688+
*
5689+
* NO_ZERO_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
5690+
* - Disabled: '0000-00-00' is permitted and produces no warning.
5691+
* - Enabled without strict mode: '0000-00-00' is permitted but produces a warning.
5692+
* - Enabled with strict mode: '0000-00-00' is not permitted and produces an error.
5693+
*
5694+
* NO_ZERO_IN_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
5695+
* - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted.
5696+
* - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'.
5697+
* - Enabled with strict mode: zero-part dates produce an error.
5698+
*/
5699+
return strtr(
56645700
"CASE
5665-
WHEN %s IS NULL THEN NULL
5666-
WHEN %s > '0' THEN %s
5667-
ELSE %s
5701+
WHEN {value} IS NULL THEN NULL
5702+
WHEN {value} IN ('0000-00-00', '0000-00-00 00:00:00') AND NOT {reject_zero_date} THEN {zero_date_value}
5703+
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}
5704+
WHEN {function_call} > '0' THEN {function_call}
5705+
ELSE {fallback}
56685706
END",
5669-
$translated_value,
5670-
$function_call,
5671-
$function_call,
5672-
$fallback
5707+
array(
5708+
'{value}' => $translated_value,
5709+
'{reject_zero_date}' => (
5710+
$this->is_sql_mode_active( 'NO_ZERO_DATE' ) && $is_strict_mode
5711+
) ? 1 : 0,
5712+
'{zero_date_value}' => 'date' === $mysql_data_type
5713+
? "'0000-00-00'"
5714+
: "'0000-00-00 00:00:00'",
5715+
'{reject_zero_in_date}' => $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ? 1 : 0,
5716+
'{function_call}' => $function_call,
5717+
'{fallback}' => $fallback,
5718+
)
56735719
);
56745720
default:
56755721
/*

0 commit comments

Comments
 (0)