Uploaded image for project: 'MariaDB Server'
  1. MariaDB Server
  2. MDEV-40563

ref access on an indexed INT column compared to a VARCHAR column builds the lookup key by integer parsing, silently dropping matching rows

    XMLWordPrintable

Details

    • Bug
    • Status: Closed (View Workflow)
    • Major
    • Resolution: Duplicate
    • 12.3.2
    • N/A
    • Optimizer
    • None
    • Not for Release Notes

    Description

      When an indexed `INT` column is equated to a `VARCHAR` column and the optimizer chooses `ref` access on the integer index, the index lookup key is built by parsing the string with *integer* semantics, while the comparison itself is defined to use *decimal* semantics. For a string such as `'1e1'`, integer parsing stops at the `e` and yields `1`, whereas the value of the comparison is `10`. The index is probed at key `1`, the row `n = 10` is never visited, and the join silently loses the row.

      The MariaDB documentation ("Type Conversion", Rules for Conversion on Comparison) states:

      > If one argument is string and the other argument is integer, they are compared as decimals.

      MariaDB's own scalar evaluation agrees: `SELECT 10 = '1e1'` returns `1`, and so does `SELECT s1.n = s2.v` over the same two rows. It is only the `ref` access path that disagrees.

      Note that the same documentation page already recognises this hazard for the mirror-image case:

      > Note that if a string column is being compared with a numeric value, MariaDB will not use the index on the column, as there are numerous alternatives that may evaluate as equal

      We confirmed that guard is in place: with the index on the string column instead (`VARCHAR` indexed, compared to an `INT` column), MariaDB declines `ref` (`EXPLAIN` shows `Range checked for each record`) and returns the correct row. The guard is simply missing in the direction where the *integer* column carries the index.

      Minimal Reproduction

      ```sql
      DROP DATABASE IF EXISTS bugrep_mariadb;
      CREATE DATABASE bugrep_mariadb;
      USE bugrep_mariadb;

      CREATE TABLE s1 (n INT, KEY k_n );
      CREATE TABLE s2 (v VARCHAR(20));
      INSERT INTO s1 VALUES (10);
      INSERT INTO s2 VALUES ('1e1');

      – The engine's own scalar truth: 10 = '1e1' is TRUE
      SELECT 10 = '1e1' AS eq_literal, s1.n = s2.v AS eq_columns,
      CAST('1e1' AS SIGNED) AS as_signed, '1e1' + 0 AS as_double FROM s1, s2;

      – A. INNER JOIN, index usable -> expected 1 row
      SELECT 'A inner join' AS form, s2.v, s1.n FROM s2 JOIN s1 ON s1.n = s2.v;

      – B. same join, index disabled -> expected 1 row
      SELECT 'B ignore index' AS form, s2.v, s1.n FROM s2 JOIN s1 IGNORE INDEX (k_n) ON s1.n = s2.v;

      – C. same predicate as EXISTS -> expected 1 row
      SELECT 'C exists' AS form, s2.v FROM s2 WHERE EXISTS (SELECT 1 FROM s1 WHERE s1.n = s2.v);

      – D. same predicate as scalar subquery -> expected 10
      SELECT 'D scalar' AS form, s2.v, (SELECT n FROM s1 WHERE s1.n = s2.v) AS scalar_n FROM s2;

      – E. LEFT JOIN -> expected ('1e1', 10), not a NULL-extended row
      SELECT 'E left join' AS form, s2.v, s1.n FROM s2 LEFT JOIN s1 ON s1.n = s2.v;

      – F. constant on the right-hand side -> expected 10
      SELECT 'F constant' AS form, n FROM s1 WHERE n = '1e1';

      EXPLAIN SELECT s2.v, s1.n FROM s2 JOIN s1 ON s1.n = s2.v;
      ```

      Expected Result

      `10 = '1e1'` is TRUE, so forms A, B, C, D, E and F must all report the single matching pair: A/B/E return `('1e1', 10)`, C returns `('1e1')`, D returns `10`, F returns `10`.

      Actual Result

      Measured on MariaDB 12.3.2:

      ```
      ----------------------------------------+

      eq_literal eq_columns as_signed as_double

      ----------------------------------------+

      1 1 1 10

      ----------------------------------------+
      TRUE TRUE <- key used <- comparison value

      A. inner join, index usable -> Empty set (0 rows) *** WRONG, expected 1 row

      ------------------------

      form v n

      ------------------------

      B ignore index 1e1 10 correct
      ------------------------
      --------------+
      form v

      --------------+

      C exists 1e1 correct — directly contradicts A
      --------------+
      ----------------------
      form v scalar_n

      ----------------------

      D scalar 1e1 NULL
          • WRONG, expected 10
            ----------------------
            ---------------------
      form v n

      ---------------------

      E left join 1e1 NULL
          • WRONG: a fabricated NULL-extended row
            ---------------------
            ----------------+
      form n

      ----------------+

      F constant 10 correct
      ----------------+
      ```

      Form A returning zero rows while form C returns one row is an internal contradiction: the two statements express the same predicate over the same two rows, and no reference engine is needed to see that at most one of them can be right.

      `RIGHT JOIN` behaves the same way as `LEFT JOIN` (`('1e1', NULL)`).

      Affected Value Domain

      The rows that are lost are exactly those whose leading-integer parse differs from their numeric value. With `s1` holding the single value `10`:

      ```sql
      CREATE TABLE s4 (v VARCHAR(20));
      INSERT INTO s4 VALUES ('10'),(' 10'),(' 10 '),('+10'),('10.0'),('10.4'),('10abc'),
      ('1e1'),('1E1'),('0.1e2'),('10e0'),('1e+1'),('100e-1');

      SELECT GROUP_CONCAT(CONCAT('"',v,'"') ORDER BY v SEPARATOR ' ') AS matched_without_index
      FROM s4 JOIN s1 IGNORE INDEX (k_n) ON s1.n = s4.v;
      SELECT GROUP_CONCAT(CONCAT('"',v,'"') ORDER BY v SEPARATOR ' ') AS matched_with_ref_access
      FROM s4 STRAIGHT_JOIN s1 FORCE INDEX (k_n) ON s1.n = s4.v;
      ```

      ```
      matched_without_index : " 10 " " 10" "+10" "0.1e2" "10" "10.0" "100e-1" "10abc" "10e0" "1e+1" "1E1" "1e1" (12 rows)
      matched_with_ref_access : " 10 " " 10" "+10" "10" "10.0" "10abc" "10e0" (7 rows)
      ```

      Lost under `ref` access: `'0.1e2'`, `'100e-1'`, `'1e+1'`, `'1E1'`, `'1e1'` — i.e. scientific-notation strings. Leading/trailing whitespace (`' 10 '`), an explicit sign (`'+10'`), a fractional zero (`'10.0'`) and a trailing garbage suffix (`'10abc'`) are *not* affected, because their leading-integer parse happens to equal their numeric value. `'10.4'` is correctly excluded by both paths.

      We did not observe spurious extra rows. With `s1` holding `(0),(1),(10)` and `s2` holding `'1e1'`, the lookup key `1` does fetch the row `n = 1`, but the residual `Using where` condition re-evaluates `n = v` with decimal semantics and rejects it, so the failure mode is missing rows (and, under outer joins, fabricated NULL-extended rows) rather than extra rows.

      Cross-Engine Comparison

      Same DDL, same data, same statements, measured on the same machine:

      Form MariaDB 12.3.2 MySQL 9.7.1 TiDB v8.5.7
      `SELECT 10 = '1e1'` `1` `1` `1`
      A. `s2 JOIN s1 ON s1.n = s2.v` *0 rows* `('1e1', 10)` `('1e1', 10)`
      B. same with `IGNORE INDEX (k_n)` `('1e1', 10)` `('1e1', 10)` `('1e1', 10)`
      C. `EXISTS (... s1.n = s2.v)` `('1e1')` `('1e1')` `('1e1')`
      D. `(SELECT n FROM s1 WHERE s1.n = s2.v)` *NULL* `10` `10`
      E. `s2 LEFT JOIN s1 ON s1.n = s2.v` *`('1e1', NULL)`* `('1e1', 10)` `('1e1', 10)`
      F. `WHERE n = '1e1'` `10` `10` `10`
      Value-domain sweep (13 strings) 12 without index, *7 with `ref`* 12 both ways 12 both ways

      MySQL 9.7.1 executes essentially the same plan shape but does not lose the row; its `EXPLAIN FORMAT=TREE` shows the index lookup followed by an explicit `Filter: (cast(s1.n as double) = cast(s2.v as double))` and the row survives.

      DuckDB 1.5.5 also allows `INTEGER = VARCHAR` implicitly, evaluates `10 = '1e1'` as true, and returns `('1e1', 10)` for the inner join, the left join, and for the inner join after `CREATE INDEX k_n ON s1` — so it is not affected. PostgreSQL 18.4 has no comparable form: it rejects `integer = text` at parse time (`operator does not exist: integer = text`) unless an explicit cast is written.

      Attachments

        Issue Links

          Activity

            People

              Unassigned Unassigned
              Jacob Ding Jiahao Ding
              Votes:
              0 Vote for this issue
              Watchers:
              2 Start watching this issue

              Dates

                Created:
                Updated:
                Resolved:

                Git Integration

                  Error rendering 'com.xiplink.jira.git.jira_git_plugin:git-issue-webpanel'. Please contact your Jira administrators.