Skip to content

Commit f8c58de

Browse files
committed
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().
1 parent 3a5c48c commit f8c58de

2 files changed

Lines changed: 271 additions & 3 deletions

File tree

tests/WP_SQLite_Driver_Tests.php

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,197 @@ 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+
23732564
public function testCaseInsensitiveSelect() {
23742565
$this->assertQuery(
23752566
"CREATE TABLE _tmp_table (

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

Lines changed: 80 additions & 3 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+
$is_zero_in_date = (
4515+
'0000' !== $matches[1]
4516+
&& ( '00' === $matches[2] || '00' === $matches[3] )
4517+
);
4518+
if ( ! $is_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,13 +5676,74 @@ private function cast_value_for_saving(
56605676
? 'NULL'
56615677
: $this->quote_sqlite_value( $implicit_default );
56625678
}
5679+
5680+
/*
5681+
* Build additional WHEN clauses to accept zero dates based
5682+
* on the NO_ZERO_DATE and NO_ZERO_IN_DATE SQL modes.
5683+
*
5684+
* In MySQL, the behavior of zero dates depends on these modes:
5685+
*
5686+
* NO_ZERO_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_date):
5687+
* - Disabled: '0000-00-00' is permitted and produces no warning.
5688+
* - Enabled without strict mode: '0000-00-00' is permitted but produces a warning.
5689+
* - Enabled with strict mode: '0000-00-00' is not permitted and produces an error.
5690+
*
5691+
* NO_ZERO_IN_DATE (see https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html#sqlmode_no_zero_in_date):
5692+
* - Disabled: dates with zero month/day parts (e.g. '2020-00-15') are permitted.
5693+
* - Enabled without strict mode: zero-part dates produce a warning and are stored as '0000-00-00'.
5694+
* - Enabled with strict mode: zero-part dates produce an error.
5695+
*
5696+
* SQLite's DATE()/DATETIME() functions return NULL for zero dates,
5697+
* so without these extra WHEN clauses, zero dates would always fall
5698+
* through to the error/implicit-default fallback.
5699+
*/
5700+
$zero_date_whens = '';
5701+
if ( 'time' !== $mysql_data_type ) {
5702+
/*
5703+
* When NO_ZERO_DATE is not active, or when it is active but
5704+
* strict mode is off, accept all-zero dates. In MySQL, only
5705+
* the combination of NO_ZERO_DATE + strict mode rejects them.
5706+
*/
5707+
$reject_zero_date = (
5708+
$this->is_sql_mode_active( 'NO_ZERO_DATE' )
5709+
&& $is_strict_mode
5710+
);
5711+
if ( ! $reject_zero_date ) {
5712+
$zero_date_value = 'date' === $mysql_data_type
5713+
? "'0000-00-00'"
5714+
: "'0000-00-00 00:00:00'";
5715+
$zero_date_whens .= sprintf(
5716+
"WHEN %s IN ('0000-00-00', '0000-00-00 00:00:00') THEN %s\n",
5717+
$translated_value,
5718+
$zero_date_value
5719+
);
5720+
}
5721+
5722+
/*
5723+
* When NO_ZERO_IN_DATE is not active, accept dates where the
5724+
* year is nonzero but the month or day part is zero (e.g.
5725+
* '2020-00-15' or '2020-01-00'). These are valid in MySQL when
5726+
* the NO_ZERO_IN_DATE mode is disabled.
5727+
*/
5728+
if ( ! $this->is_sql_mode_active( 'NO_ZERO_IN_DATE' ) ) {
5729+
$zero_date_whens .= sprintf(
5730+
"WHEN SUBSTR(%s, 1, 4) != '0000' AND (SUBSTR(%s, 6, 2) = '00' OR SUBSTR(%s, 9, 2) = '00') THEN %s\n",
5731+
$translated_value,
5732+
$translated_value,
5733+
$translated_value,
5734+
$translated_value
5735+
);
5736+
}
5737+
}
5738+
56635739
return sprintf(
56645740
"CASE
56655741
WHEN %s IS NULL THEN NULL
5666-
WHEN %s > '0' THEN %s
5742+
%sWHEN %s > '0' THEN %s
56675743
ELSE %s
56685744
END",
56695745
$translated_value,
5746+
$zero_date_whens,
56705747
$function_call,
56715748
$function_call,
56725749
$fallback

0 commit comments

Comments
 (0)