Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 32 additions & 2 deletions lib/errgonomic/rails/active_record_optional.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
31 changes: 31 additions & 0 deletions test/rails_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading