diff --git a/README.md b/README.md index 9505cae..9b39275 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ ActiveRecord assumes things about accessors that a strict Rust Option cannot sat 1. `None#nil?` answers `true`, so ActiveRecord internals and ordinary `.nil?` checks treat an absent value as absent. Equality does not follow suit: `None() == nil` is still `false`. 2. `Some` delegates `persisted?`, `marked_for_destruction?`, and `touch_later` to its record, so a `Some` can stand in for its record during persistence. -3. Quoting is patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary. +3. Quoting and the predicate builder are patched so an `Option` passed into `where`/`quote` is unwrapped at the SQL boundary: `Some(v)` binds exactly as `v`, and `None()` as `nil`, so a hash condition asks for `IS NULL`. An array of Options unwraps too. An Option interpolated into raw SQL (`where("id = ?", opt)`) still raises, as it should. 4. `SomeValidator` provides a presence-style validation for Option attributes. The set is closed. If a future integration appears to need a fifth compromise, that is a signal ActiveRecord is pushing back somewhere unmapped, and it warrants a design discussion rather than a quiet patch. diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 1da2928..72983a6 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -15,8 +15,8 @@ module Rails # None() == nil stays false. # 2. Some delegates persisted?, marked_for_destruction?, and touch_later # to its record, so a Some can stand in for it during persistence. - # 3. Two quoting prepends unwrap Options at the SQL boundary, so an - # Option can be passed to where/quote. + # 3. Quoting and predicate-building prepends unwrap Options at the SQL + # boundary, so an Option can be passed to where/quote. # 4. SomeValidator provides a presence-style validation for Option # attributes. module ActiveRecordOptional @@ -136,3 +136,33 @@ def quote(value) end ActiveRecord::ConnectionAdapters::Quoting.prepend(Errgonomic::Rails::ActiveRecordQuoting) + +module Errgonomic + module Rails + # A hash condition never reaches the quoting layer as its raw value: the + # predicate builder hands it to a bind attribute, which serializes it + # through the column type and casts an unrecognized object to nil. Unwrap + # one step earlier, where every hash condition passes, so a Some binds as + # its inner value and a None as nil, which Arel renders as IS NULL. + module ActiveRecordPredicateBuilder + def build(attribute, value, *args) + super(attribute, Errgonomic::Rails.unwrap_options(value), *args) + end + end + + # Unwrap Options in a query condition, reaching one level into an array + # so a list of Options binds like a list of values. + def self.unwrap_options(value) + case value + when Errgonomic::Option::Any + value.unwrap_or(nil) + when Array + value.any? { |v| v.is_a?(Errgonomic::Option::Any) } ? value.map { |v| unwrap_options(v) } : value + else + value + end + end + end +end + +ActiveRecord::PredicateBuilder.prepend(Errgonomic::Rails::ActiveRecordPredicateBuilder) diff --git a/test/rails_test.rb b/test/rails_test.rb index 7d405c1..0590fe1 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -96,6 +96,37 @@ def test_optional_associations assert book.author.some? end + # Feeding a wrapped attribute back into a query is among the most common + # Rails idioms, so a Some has to bind exactly as its inner value would. + def test_where_with_a_some_matches_the_row + genre = Genre.create!(name: 'Sci-Fi') + book = Book.create!(title: 'The Dark Forest', genre_id: genre.id) + + assert_equal 1, Book.where(genre_id: book.genre_id).count + refute_includes Book.where(genre_id: book.genre_id).to_sql, '= NULL' + end + + # A None reads as absent, which for a hash condition means IS NULL rather + # than an = NULL that can never match. + def test_where_with_a_none_asks_for_null + unshelved = Book.create!(title: 'Ball Lightning') + + relation = Book.where(title: 'Ball Lightning', genre_id: unshelved.genre_id) + assert_includes relation.to_sql, 'IS NULL' + assert_equal 1, relation.count + end + + def test_where_with_an_array_of_options + first = Genre.create!(name: 'Sci-Fi') + second = Genre.create!(name: 'Fantasy') + Book.create!(title: 'The Dark Forest', genre_id: first.id) + Book.create!(title: 'The Hobbit', genre_id: second.id) + + relation = Book.where(genre_id: [Some(first.id), Some(second.id)]) + assert_equal 2, relation.count + refute_includes relation.to_sql, 'NULL' + end + def test_delegate_optional author = Author.create!(name: 'Cixin Liu') book = author.books.create!(title: 'Death\'s End')