Skip to content

LEFT JOIN never emits unmatched left rows — behaves as INNER JOIN #34

Description

@mp-grind

Summary

LEFT JOIN drops left-hand rows that find no partner instead of emitting them
with NULLs. The result is identical to the equivalent INNER JOIN. This makes
anti-joins (LEFT JOIN ... WHERE right.col IS NULL) impossible to express, which
is the natural way to ask the most common corpus question there is: which
documents are missing X?

Reproduction

mkdir -p /tmp/mqbug/docs && cd /tmp/mqbug
printf '# Alpha\n\nIntro paragraph.\n\n## Install\n\nRun the installer.\n' > docs/a.md
printf '# Beta\n\nAnother paragraph.\n\n## Usage\n\nUse it well.\n'       > docs/b.md
mq-db index docs -o s.mq-db

docs/a.md has an Install heading; docs/b.md does not.

$ mq-db sql -d s.mq-db -F json \
  "SELECT d.path, b.id FROM documents d JOIN blocks b ON b.document_id = d.id AND b.content = 'Install'"
[{"path":"docs/a.md","id":6}]

$ mq-db sql -d s.mq-db -F json \
  "SELECT d.path, b.id FROM documents d LEFT JOIN blocks b ON b.document_id = d.id AND b.content = 'Install'"
[{"path":"docs/a.md","id":6}]

Expected from the LEFT JOIN: two rows, the second being
{"path":"docs/b.md","id":null}. Actual: byte-identical to the INNER JOIN.

Consequently the anti-join returns nothing:

$ mq-db sql -d s.mq-db -F json \
  "SELECT d.path FROM documents d LEFT JOIN blocks b ON b.document_id = d.id AND b.content = 'Install' WHERE b.id IS NULL"
[]

Expected docs/b.md.

It isn't IS NULL, and it isn't join resolution

IS NULL / IS NOT NULL evaluate correctly on ordinary columns:

$ mq-db sql -d s.mq-db -F json "SELECT path FROM documents WHERE title IS NOT NULL"
[{"path":"docs/b.md"},{"path":"docs/a.md"}]

And the join itself resolves fine when every left row happens to match — all 8
pairs come back:

$ mq-db sql -d s.mq-db -F json "SELECT d.path, b.id FROM documents d LEFT JOIN blocks b ON b.document_id = d.id"
[{"path":"docs/b.md","id":0}, ... 8 rows ... ,{"path":"docs/a.md","id":7}]

So the missing piece is specifically the generation of NULL-extended rows for
unmatched left tuples.

The same happens when the right side is a CTE, so it isn't specific to base
tables:

$ mq-db sql -d s.mq-db -F json \
  "WITH h AS (SELECT document_id FROM blocks WHERE content = 'Install') SELECT d.path FROM documents d LEFT JOIN h ON h.document_id = d.id WHERE h.document_id IS NULL"
[]

(The INNER JOIN form of that CTE query works correctly and returns docs/a.md.)

EXPLAIN

$ mq-db sql -d s.mq-db "EXPLAIN SELECT d.path, b.id FROM documents d LEFT JOIN blocks b ON b.document_id = d.id AND b.content = 'Install'"
│ query:from    │ documents (documents)                                        │
│ query:where   │ none — full scan                                             │
│ query:join[0] │ blocks: hash join on b.document_id = d.id — join partner always full-scanned │

The plan records only hash join, with no left/outer marker — the join type
appears not to survive into the plan. (Minor, separate observation: the extra
AND b.content = 'Install' conjunct in the ON clause is absent from the plan
detail too, though it clearly is applied to the results.)

Environment

  • mq-db 0.2.1, installed via cargo install
  • macOS 26.6.2 (build 25G83), arm64

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions