Inconsistent results: HAVING vs equivalent derived-table+WHERE rewriting on multi-table query with DEFAULT()/CAST AS DATETIME
- Dominant language
- Go
- Stars
- 40.5k
- Forks
- 6.2k
- PR merge metrics
- PR metrics pending
Description
## Bug Report
Please answer these questions before submitting your issue. Thanks!
### 1. Minimal reproduce step (Required)
```sql
DROP DATABASE IF EXISTS database14;
CREATE DATABASE database14;
USE database14;
CREATE TABLE t0(c0 DOUBLE DEFAULT 0.8863054882559297);
CREATE TABLE t1 LIKE t0;
INSERT INTO t1(c0) VALUES (0.8803556550068232);
INSERT INTO t0 VALUES (NULL);
UPDATE t1 SET c0 = -299481979;
-- Q1 (original, HAVING)
SELECT CONCAT(IFNULL(t0.c0, '__NULL__'), '#',
IFNULL(t1.c0, '__NULL__'), '#',
IFNULL(DEFAULT(t0.c0), '__NULL__'))
FROM t1, t0
GROUP BY t0.c0, t1.c0
HAVING (((CASE ((((t0.c0) IS NOT NULL))<(-1976143320))
WHEN t0.c0 THEN DEFAULT(t1.c0)
WHEN 1972331796 THEN CAST(t1.c0 AS DECIMAL)
ELSE '(' END))
>= (IF(t0.c0, CAST(-1584286057 AS DATETIME), t1.c0)));
-- Q2 (semantically equivalent: derived table + outer WHERE)
SELECT ref0
FROM (
SELECT CONCAT(IFNULL(t0.c0, '__NULL__'), '#',
IFNULL(t1.c0, '__NULL__'), '#',
IFNULL(DEFAULT(t0.c0), '__NULL__')) AS ref0,
(((CASE ((((t0.c0) IS NOT NULL))<(-1976143320))
WHEN t0.c0 THEN DEFAULT(t1.c0)
WHEN 1972331796 THEN CAST(t1.c0 AS DECIMAL)
ELSE '(' END))
>= (IF(t0.c0, CAST(-1584286057 AS DATETIME), t1.c0))) AS ref1
FROM t1, t0
GROUP BY t0.c0, t1.c0
) AS s
WHERE ref1;
```
### 2. What did you expect to see? (Required)
Q1 and Q2 are semantically equivalent (they wrap the same boolean expression — once as a HAVING filter, once as a derived-column filtered by an outer WHERE). They must return the same set of rows.
### 3. What did you see instead (Required)
```sql
SELECT CONCAT(IFNULL(t0.c0, '__NULL__'), '#', IFNULL(t1.c0, '__NULL__'), '#', IFNULL(DEFAULT(t0.c0), '__NULL__')) FROM t1, t0 GROUP BY t0.c0, t1.c0 HAVING (((CASE ((((t0.c0) IS NOT NULL))<(-1976143320)) WHEN t0.c0 THEN DEFAULT(t1.c0) WHEN 1972331796 THEN CAST(t1.c0 AS DECIMAL) ELSE '(' END ))>=(IF(t0.c0, CAST(-1584286057 AS DATETIME), t1.c0)));-- cardinality: 0
Empty set, 1 warning (0.01 sec)
SELECT ref0 FROM (SELECT CONCAT(IFNULL(t0.c0, '__NULL__'), '#', IFNULL(t1.c0, '__NULL__'), '#', IFNULL(DEFAULT(t0.c0), '__NULL__')) AS ref0, (((CASE ((((t0.c0) IS NOT NULL))<(-1976143320)) WHEN t0.c0 THEN DEFAULT(t1.c0) WHEN 1972331796 THEN CAST(t1.c0 AS DECIMAL) ELSE '(' END ))>=(IF(t0.c0, CAST(-1584286057 AS DATETIME), t1.c0))) AS ref1 FROM t1, t0 GROUP BY t0.c0, t1.c0) AS s WHERE ref1;-- cardinality: 1
+----------------------------------------+
| ref0 |
+----------------------------------------+
| __NULL__#-299481979#0.8863054882559297 |
+----------------------------------------+
1 row in set, 1 warning (0.01 sec)
```
### 4. What is your TiDB version? (Required)
Two version
```sql
mysql> select tidb_version();
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| tidb_version() |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Release Version: v8.5.5
Edition: Community
Git Commit Hash: 1fa258b833ff113883beeba40bc130be7ce66610
Git Branch: HEAD
UTC Build Time: 2026-01-14 22:20:57
GoVersion: go1.25.5
Race Enabled: false
Check Table Before Drop: false
Store: unistore |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
mysql> select tidb_version();
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| tidb_version() |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Release Version: v9.0.0-beta.2.pre-1651-g33ae9e3cb5
Edition: Community
Git Commit Hash: 33ae9e3cb52f4891ff35407ed736107aa34e84b9
Git Branch: HEAD
UTC Build Time: 2026-05-01 06:07:45
GoVersion: go1.25.9
Race Enabled: false
Check Table Before Drop: false
Store: unistore
Kernel Type: Classic |
+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
```
### 5. Root cause analysis from EXPLAIN
The HAVING expression is rewritten into a form containing if(istrue_with_null(t0.c0), NULL, cast(t1.c0, var_string(370))), but the predicate is placed at different locations in the two plans:
Q1 (HAVING) — predicate placed at a root Selection after the cartesian HashJoin:
```sql
Projection_10
Selection_12 (root): ge( case(...,"0.8863054882559297",..., cast(decimal),"("),
if(istrue_with_null(t0.c0), NULL, cast(t1.c0, var_string(370))) )
Projection_13
HashAgg_16
HashJoin_29 (CARTESIAN inner join)
├─ TableReader -> TableFullScan t0
└─ TableReader -> TableFullScan t1
```
Q2 (subquery + WHERE) — same predicate becomes the other cond of the cartesian HashJoin:
```sql
Projection_11
HashAgg_14
HashJoin_27 (CARTESIAN inner join,
other cond: ge( case(...), if(istrue_with_null(t0.c0), NULL, cast(t1.c0, var_string(370))) ))
├─ TableReader -> TableFullScan t0
└─ TableReader -> TableFullScan t1
```
So the same expression is evaluated:
- in Q1, after HashAgg, at a root-level Selection — and TiDB returns 0 rows;
- in Q2, before HashAgg, as a HashJoin other cond — and TiDB returns 1 row.
The two paths disagree on the truth value of the same expression for the same input row (t0.c0=NULL, t1.c0=-299481979), very likely due to inconsistent NULL handling around if(istrue_with_null(...), NULL, ...) between the two execution sites.
Contributor guide
Assessment
This issue has not been assessed yet.