This is something I encountered when working on wp-cli/entity-command#639 (using WP_Site_Query with WP_Date_Query). The report below is AI-generated, so please take it with a grain of salt.
WP_Date_Query filtering by an exact time silently returns zero rows under the SQLite driver, with no SQL error and nothing in $wpdb->last_error. The same query returns the correct rows on MySQL/MariaDB.
Digging into it, the immediate cause is narrow (a missing float cast), but it sits on top of a broader problem: 21 of the 30 entries in MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAP produce wrong output, and 9 of those produce plausible-looking wrong values rather than NULL. Several map MySQL specifiers onto valid-but-unrelated SQLite specifiers — e.g. MySQL %b (abbreviated month) is mapped to SQLite %M, which is minutes.
Tested against sqlite-database-integration 3.0.0, SQLite 3.45.1, WordPress trunk.
Reproduction
// A site registered at 2014-10-21 07:30:15.
$sites = get_sites( [
'date_query' => [ [
'column' => 'registered',
'year' => 2014, 'month' => 10, 'day' => 21,
'hour' => 7, 'minute' => 30, 'second' => 15,
] ],
] );
|
MySQL |
SQLite |
year + month + day |
matches |
matches |
… + hour + minute + second |
matches |
0 rows |
$wpdb->last_error is empty in the failing case — the query executes successfully and just silently matches nothing.
The SQL WP_Site_Query generates is:
SELECT wp_blogs.blog_id FROM wp_blogs
WHERE (
( YEAR( wp_blogs.registered ) = 2014
AND MONTH( wp_blogs.registered ) = 10
AND DAYOFMONTH( wp_blogs.registered ) = 21
AND DATE_FORMAT( wp_blogs.registered, '%H.%i%s' ) = 7.301500 )
)
The YEAR()/MONTH()/DAYOFMONTH() parts translate fine. The DATE_FORMAT() comparison is what fails.
Defect 1 — the float cast covers only one of the four formats WP_Date_Query emits
class-wp-mysql-on-sqlite.php has an explicit workaround for MySQL's string-to-float comparison semantics:
$cast_to_float = "'%H.%i'" === $mysql_format;
if ( true === $cast_to_float ) {
return sprintf( 'CAST(STRFTIME(%s, %s) AS FLOAT)', $format, $date );
}
But WP_Date_Query::build_time_query() builds its format string incrementally and can emit four different values, all compared against %f (a float):
// wp-includes/class-wp-date-query.php
if ( null !== $hour ) { $format .= '%H.'; } else { $format .= '0.'; }
$format .= '%i';
if ( null !== $second ) { $format .= '%s'; }
return $wpdb->prepare( "DATE_FORMAT( $column, %s ) $compare %f", $format, $time );
| Format |
Emitted when |
Cast applied? |
%H.%i |
hour + minute |
✅ |
%H.%i%s |
hour + minute + second |
❌ |
0.%i |
minute only |
❌ |
0.%i%s |
minute + second |
❌ |
So three of the four are compared as string-vs-float and never match. Notably 0.%i produces the textually-correct '0.30' and still fails, purely because SQLite won't compare '0.30' to 0.30.
Checking in_array( $mysql_format, [ "'%H.%i'", "'%H.%i%s'", "'0.%i'", "'0.%i%s'" ], true ) would cover all four.
Defect 2 — MySQL %S / %s (seconds) are mapped to SQLite %s (Unix timestamp)
'%S' => '%s',
'%s' => '%s',
MySQL %S and %s both mean seconds, 00–59. SQLite %s is seconds since 1970-01-01; SQLite's seconds-of-minute is uppercase %S.
DATE_FORMAT('2014-10-21 07:30:15', '%S')
MySQL => '15'
SQLite => '1413876615'
This is what turns %H.%i%s into '07.301413876615' above. Both entries should map to '%S'.
Defect 3 — multi-specifier expansions are never re-translated
'%r' => '%h:%i:%s %A',
'%T' => '%H:%i:%s',
These expand to MySQL specifiers, but the translation is a single strtr() call, which by design never revisits text it has already substituted. So %i and %s survive into the SQLite format string, %i is not a valid SQLite specifier, and strftime() returns NULL for the whole expression.
DATE_FORMAT('2014-10-21 07:30:15', '%T')
MySQL => '07:30:15'
SQLite => NULL
SQLite 3.44+ supports %T natively, so '%T' => '%T' works. %r needs '%I:%M:%S %p' (SQLite specifiers).
Full comparison
Every MySQL specifier, DATE_FORMAT('2014-10-21 07:30:15', <code>), MariaDB 10.11 vs sqlite-database-integration 3.0.0 on SQLite 3.45.1:
Silently wrong — returns a plausible value, so callers cannot detect the failure:
| Code |
Meaning |
Mapped to |
MySQL |
SQLite |
%b |
Abbreviated month |
%M (minute) |
Oct |
30 |
%M |
Full month name |
%F (ISO date) |
October |
2014-10-21 |
%W |
Full weekday name |
%l (12-hour) |
Tuesday |
7 |
%S |
Seconds |
%s (Unix time) |
15 |
1413876615 |
%s |
Seconds |
%s (Unix time) |
15 |
1413876615 |
%e |
Day of month |
%j (day of year) |
21 |
294 |
%D |
Day + suffix |
%jS |
21st |
294S |
%u |
Week (Mon-first) |
%W (Sun-first) |
43 |
42 |
%v |
Week (Mon-first) |
%W (Sun-first) |
43 |
42 |
Returns NULL — mapped to a specifier this SQLite build does not have:
| Code |
Meaning |
Mapped to |
MySQL |
SQLite |
%a |
Abbreviated weekday |
%D |
Tue |
NULL |
%c |
Month, no padding |
%n |
10 |
NULL |
%h |
Hour (12) |
%h |
07 |
NULL |
%I |
Hour (12) |
%h |
07 |
NULL |
%j |
Day of year |
%z |
294 |
NULL |
%k |
Hour (24), no padding |
%G |
7 |
NULL |
%l |
Hour (12), no padding |
%g |
7 |
NULL |
%p |
AM/PM |
%A |
AM |
NULL |
%r |
12-hour time |
%h:%i:%s %A |
07:30:15 AM |
NULL |
%T |
24-hour time |
%H:%i:%s |
07:30:15 |
NULL |
%x |
ISO year |
%o |
2014 |
NULL |
%y |
2-digit year |
%y |
14 |
NULL |
Correct: %d, %H, %i, %m, %U, %V, %w, %X, %Y.
Suggested direction
SQLite 3.44 (Nov 2023) added a batch of specifiers that resolve most of these directly:
| MySQL |
Currently |
Native SQLite equivalent |
%e |
%j |
%e |
%h, %I |
%h |
%I |
%j |
%z |
%j |
%p |
%A |
%p |
%S, %s |
%s |
%S |
%T |
%H:%i:%s |
%T |
%r |
%h:%i:%s %A |
%I:%M:%S %p |
Two caveats on that list: SQLite's %k and %l are space-padded (' 7') where MySQL's are unpadded ('7'), so those need trimming rather than a straight mapping. And %e/%I/%p/%T require SQLite ≥ 3.44 — worth confirming against the project's minimum supported version.
The remainder have no native equivalent and would need a UDF: %a, %b, %M, %W (locale-independent name lookups), %D (ordinal suffix), %c and %y (trimming), and %x/%u/%v (ISO week/year — SQLite's %G/%V cover these but returned NULL on 3.45.1 here, so they look like 3.46+). The driver already registers UDFs via WP_SQLite_PDO_User_Defined_Functions, so the mechanism is in place.
Separately, it may be worth having an unmappable specifier throw rather than silently emit a wrong value — the existing Could not translate a DATE_FORMAT() format exception never fires for these cases because strtr() always returns a non-empty string, so the if ( ! $format ) guard cannot catch them.
This is something I encountered when working on wp-cli/entity-command#639 (using
WP_Site_QuerywithWP_Date_Query). The report below is AI-generated, so please take it with a grain of salt.WP_Date_Queryfiltering by an exact time silently returns zero rows under the SQLite driver, with no SQL error and nothing in$wpdb->last_error. The same query returns the correct rows on MySQL/MariaDB.Digging into it, the immediate cause is narrow (a missing float cast), but it sits on top of a broader problem: 21 of the 30 entries in
MYSQL_DATE_FORMAT_TO_SQLITE_STRFTIME_MAPproduce wrong output, and 9 of those produce plausible-looking wrong values rather thanNULL. Several map MySQL specifiers onto valid-but-unrelated SQLite specifiers — e.g. MySQL%b(abbreviated month) is mapped to SQLite%M, which is minutes.Tested against
sqlite-database-integration3.0.0, SQLite 3.45.1, WordPress trunk.Reproduction
year+month+dayhour+minute+second$wpdb->last_erroris empty in the failing case — the query executes successfully and just silently matches nothing.The SQL
WP_Site_Querygenerates is:The
YEAR()/MONTH()/DAYOFMONTH()parts translate fine. TheDATE_FORMAT()comparison is what fails.Defect 1 — the float cast covers only one of the four formats
WP_Date_Queryemitsclass-wp-mysql-on-sqlite.phphas an explicit workaround for MySQL's string-to-float comparison semantics:But
WP_Date_Query::build_time_query()builds its format string incrementally and can emit four different values, all compared against%f(a float):%H.%i%H.%i%s0.%i0.%i%sSo three of the four are compared as string-vs-float and never match. Notably
0.%iproduces the textually-correct'0.30'and still fails, purely because SQLite won't compare'0.30'to0.30.Checking
in_array( $mysql_format, [ "'%H.%i'", "'%H.%i%s'", "'0.%i'", "'0.%i%s'" ], true )would cover all four.Defect 2 — MySQL
%S/%s(seconds) are mapped to SQLite%s(Unix timestamp)MySQL
%Sand%sboth mean seconds, 00–59. SQLite%sis seconds since 1970-01-01; SQLite's seconds-of-minute is uppercase%S.This is what turns
%H.%i%sinto'07.301413876615'above. Both entries should map to'%S'.Defect 3 — multi-specifier expansions are never re-translated
These expand to MySQL specifiers, but the translation is a single
strtr()call, which by design never revisits text it has already substituted. So%iand%ssurvive into the SQLite format string,%iis not a valid SQLite specifier, andstrftime()returnsNULLfor the whole expression.SQLite 3.44+ supports
%Tnatively, so'%T' => '%T'works.%rneeds'%I:%M:%S %p'(SQLite specifiers).Full comparison
Every MySQL specifier,
DATE_FORMAT('2014-10-21 07:30:15', <code>), MariaDB 10.11 vs sqlite-database-integration 3.0.0 on SQLite 3.45.1:Silently wrong — returns a plausible value, so callers cannot detect the failure:
%b%M(minute)Oct30%M%F(ISO date)October2014-10-21%W%l(12-hour)Tuesday7%S%s(Unix time)151413876615%s%s(Unix time)151413876615%e%j(day of year)21294%D%jS21st294S%u%W(Sun-first)4342%v%W(Sun-first)4342Returns
NULL— mapped to a specifier this SQLite build does not have:%a%DTueNULL%c%n10NULL%h%h07NULL%I%h07NULL%j%z294NULL%k%G7NULL%l%g7NULL%p%AAMNULL%r%h:%i:%s %A07:30:15 AMNULL%T%H:%i:%s07:30:15NULL%x%o2014NULL%y%y14NULLCorrect:
%d,%H,%i,%m,%U,%V,%w,%X,%Y.Suggested direction
SQLite 3.44 (Nov 2023) added a batch of specifiers that resolve most of these directly:
%e%j%e%h,%I%h%I%j%z%j%p%A%p%S,%s%s%S%T%H:%i:%s%T%r%h:%i:%s %A%I:%M:%S %pTwo caveats on that list: SQLite's
%kand%lare space-padded (' 7') where MySQL's are unpadded ('7'), so those need trimming rather than a straight mapping. And%e/%I/%p/%Trequire SQLite ≥ 3.44 — worth confirming against the project's minimum supported version.The remainder have no native equivalent and would need a UDF:
%a,%b,%M,%W(locale-independent name lookups),%D(ordinal suffix),%cand%y(trimming), and%x/%u/%v(ISO week/year — SQLite's%G/%Vcover these but returnedNULLon 3.45.1 here, so they look like 3.46+). The driver already registers UDFs viaWP_SQLite_PDO_User_Defined_Functions, so the mechanism is in place.Separately, it may be worth having an unmappable specifier throw rather than silently emit a wrong value — the existing
Could not translate a DATE_FORMAT() formatexception never fires for these cases becausestrtr()always returns a non-empty string, so theif ( ! $format )guard cannot catch them.