Description:
# MySQL prepared CAST-AS-UNSIGNED truncation
Date: 2026-08-18
Status: exact MySQL 8.4.10 Debug+ASan and official Release reproduced; no
public causal match found, pending vendor adjudication.
## Root cause
`Item_typecast_unsigned::resolve_type()` propagates
`MYSQL_TYPE_LONGLONG` through an overload whose `Type_properties` constructor
defaults numeric signedness to false. The trailing booleans in that call are
`pin` and `inherit`, not signedness. Consequently, a placeholder that the SQL
manual says derives its type from `CAST(... AS UNSIGNED)` is resolved as a
signed BIGINT.
When EXECUTE supplies a string above `LLONG_MAX`, `Item_param::convert_value()`
cannot accept it through the signed integer fast path and stores DECIMAL.
`Item_param::val_int()` then calls `my_decimal2int()` with the resolved signed
flag and saturates to `9223372036854775807`. The outer unsigned cast sees an
already-numeric argument and does not reparse the original string as unsigned.
Direct string literals do take the unsigned parser, so they return the entire
`0..ULLONG_MAX` domain correctly.
## Boundary and effect
- `9223372036854775807`: direct and prepared results agree.
- `9223372036854775808`: direct returns the input; prepared returns
`9223372036854775807` with warning 1292.
- `18446744073709551615`: direct returns the input; prepared returns the same
wrong signed maximum on both executions.
- Non-strict prepared CTAS creates `BIGINT UNSIGNED` but persists the wrong
signed-maximum value. `CHECK TABLE` remains OK.
Both exact Debug+ASan and official Release reproduce all observations 2/2.
All servers remain live, no run is OOM-killed, and ASan emits no report.
How to repeat:
SET sql_mode='STRICT_ALL_TABLES';
SELECT 'direct-control' AS phase,CAST('9223372036854775807' AS UNSIGNED) AS v;
SELECT 'direct-edge' AS phase,CAST('9223372036854775808' AS UNSIGNED) AS v;
SELECT 'direct-max' AS phase,CAST('18446744073709551615' AS UNSIGNED) AS v;
PREPARE s FROM 'SELECT CAST(? AS UNSIGNED) AS v';
SET @p='9223372036854775807';
SELECT 'prepared-control' AS phase; EXECUTE s USING @p;
SET @p='9223372036854775808';
SELECT 'prepared-edge' AS phase; EXECUTE s USING @p;
SET @p='18446744073709551615';
SELECT 'prepared-max' AS phase; EXECUTE s USING @p;
EXECUTE s USING @p;
DEALLOCATE PREPARE s;
DROP DATABASE IF EXISTS h97f;
CREATE DATABASE h97f;
USE h97f;
SET sql_mode='';
PREPARE c FROM 'CREATE TABLE persisted AS SELECT CAST(? AS UNSIGNED) AS v';
SET @p='18446744073709551615';
EXECUTE c USING @p;
DEALLOCATE PREPARE c;
SHOW CREATE TABLE persisted;
SELECT 'persisted-max' AS phase,v FROM persisted;
CHECK TABLE persisted;
DROP DATABASE h97f;
Description: # MySQL prepared CAST-AS-UNSIGNED truncation Date: 2026-08-18 Status: exact MySQL 8.4.10 Debug+ASan and official Release reproduced; no public causal match found, pending vendor adjudication. ## Root cause `Item_typecast_unsigned::resolve_type()` propagates `MYSQL_TYPE_LONGLONG` through an overload whose `Type_properties` constructor defaults numeric signedness to false. The trailing booleans in that call are `pin` and `inherit`, not signedness. Consequently, a placeholder that the SQL manual says derives its type from `CAST(... AS UNSIGNED)` is resolved as a signed BIGINT. When EXECUTE supplies a string above `LLONG_MAX`, `Item_param::convert_value()` cannot accept it through the signed integer fast path and stores DECIMAL. `Item_param::val_int()` then calls `my_decimal2int()` with the resolved signed flag and saturates to `9223372036854775807`. The outer unsigned cast sees an already-numeric argument and does not reparse the original string as unsigned. Direct string literals do take the unsigned parser, so they return the entire `0..ULLONG_MAX` domain correctly. ## Boundary and effect - `9223372036854775807`: direct and prepared results agree. - `9223372036854775808`: direct returns the input; prepared returns `9223372036854775807` with warning 1292. - `18446744073709551615`: direct returns the input; prepared returns the same wrong signed maximum on both executions. - Non-strict prepared CTAS creates `BIGINT UNSIGNED` but persists the wrong signed-maximum value. `CHECK TABLE` remains OK. Both exact Debug+ASan and official Release reproduce all observations 2/2. All servers remain live, no run is OOM-killed, and ASan emits no report. How to repeat: SET sql_mode='STRICT_ALL_TABLES'; SELECT 'direct-control' AS phase,CAST('9223372036854775807' AS UNSIGNED) AS v; SELECT 'direct-edge' AS phase,CAST('9223372036854775808' AS UNSIGNED) AS v; SELECT 'direct-max' AS phase,CAST('18446744073709551615' AS UNSIGNED) AS v; PREPARE s FROM 'SELECT CAST(? AS UNSIGNED) AS v'; SET @p='9223372036854775807'; SELECT 'prepared-control' AS phase; EXECUTE s USING @p; SET @p='9223372036854775808'; SELECT 'prepared-edge' AS phase; EXECUTE s USING @p; SET @p='18446744073709551615'; SELECT 'prepared-max' AS phase; EXECUTE s USING @p; EXECUTE s USING @p; DEALLOCATE PREPARE s; DROP DATABASE IF EXISTS h97f; CREATE DATABASE h97f; USE h97f; SET sql_mode=''; PREPARE c FROM 'CREATE TABLE persisted AS SELECT CAST(? AS UNSIGNED) AS v'; SET @p='18446744073709551615'; EXECUTE c USING @p; DEALLOCATE PREPARE c; SHOW CREATE TABLE persisted; SELECT 'persisted-max' AS phase,v FROM persisted; CHECK TABLE persisted; DROP DATABASE h97f;