Skip to content

Database queries with an Option now find your rows - #48

Merged
nz merged 1 commit into
mainfrom
fix/where-option-unwrap
Aug 11, 2026
Merged

Database queries with an Option now find your rows#48
nz merged 1 commit into
mainfrom
fix/where-option-unwrap

Conversation

@nz

@nz nz commented Aug 11, 2026

Copy link
Copy Markdown
Member

Fixes #44.

If you put an Option into a where query, the SQL compared against NULL, which never matches anything. The query quietly found zero rows and never raised. Now the value comes out of the Option before the query is built, so the query finds your rows. A None now asks for IS NULL, which is what SQL needs to find empty columns.

Detail

  • Hash conditions never reach the two existing quoting prepends: the value flows through PredicateBuilder#buildbuild_bind_attributeRelation::QueryAttributetype.serialize, which casts an unrecognized object to nil (activerecord 8.1.3, predicate_builder.rb:57,67, query_attribute.rb:28).
  • Fix: Errgonomic::Rails::ActiveRecordPredicateBuilder prepended onto ActiveRecord::PredicateBuilder, unwrapping at #build — the single point every hash condition passes. Some(v) binds as v, None as nil (Arel renders IS NULL), and arrays unwrap one level, since where(col: [Some(1), Some(2)]) previously produced IN (NULL) via an ArrayHandler path that bypasses bind attributes entirely.
  • None in where previously worked by accident: QueryAttribute#nil? consults the value's own nil?, which the None#nil? compromise answers. Now pinned by a test instead of luck.

Known gaps, deliberately out of scope: string-fragment conditions (where("x = ?", Some(1))) raise loudly but with a confusing message; association-key conditions (where(author: Some(record))) route through AssociationQueryValue before #build and are untested, probably still broken; Ranges of Options and nested arrays are not unwrapped.

Tests: Some matches the row and to_sql contains no = NULL; None yields IS NULL and matches; array of Somes yields IN (1, 2).

A hash condition never reached the quoting prepends. The predicate
builder wraps the value in a Relation::QueryAttribute and serializes it
through the column type, which casts an unrecognized object to nil, so
where(col: Some(1)) emitted = NULL and matched nothing. A None came out
right only by accident, because QueryAttribute#nil? consults the value's
own nil?, which None answers true.

Prepend PredicateBuilder#build, the one point every hash condition
passes through, and unwrap there: a Some binds as its inner value, a
None as nil, which Arel renders IS NULL. Arrays unwrap one level, so a
list of Options builds an ordinary IN list.
@nz
nz enabled auto-merge August 11, 2026 00:21

@lutzcc1 lutzcc1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool

@nz
nz merged commit 577a5a2 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActiveRecordOptional: where(col: option) generates = NULL and silently matches nothing

2 participants