Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BatchLoader.for().batch {} evaluated as Truthy but should be Falsey #84

Open
FX-HAO opened this issue Nov 21, 2022 · 5 comments
Open

BatchLoader.for().batch {} evaluated as Truthy but should be Falsey #84

FX-HAO opened this issue Nov 21, 2022 · 5 comments

Comments

@FX-HAO
Copy link

FX-HAO commented Nov 21, 2022

I think this bug is a serious problem. every time BatchLoader.for().batch is called, a new object of nil will be returned by default. this can cause some weird bugs in Ruby and is a waste of memory. you can reproduce it as the following:

➜  ~ irb
irb(main):001:0> require 'batch-loader'
=> true
irb(main):002:0> a = nil
=> nil
irb(main):003:0> b = BatchLoader.for(1).batch do |ids, loader|
  ids.each { |id| loader.call(id, nil) }
end
=> nil
irb(main):004:0> a.object_id
=> 8
irb(main):005:0> b.object_id # why 'b' is a new object of `nil`?
=> 200
irb(main):006:0> b = BatchLoader.for(2).batch do |ids, loader|
  ids.each { |id| loader.call(id, nil) }
end
=> nil
irb(main):007:0> b.object_id # a new object of `nil` again
=> 220
irb(main):008:1* unless b
irb(main):009:1*   puts 1 # this should be called, but it was skipped because of a new object of `nil`
irb(main):010:0> end
=> nil
irb(main):011:1* unless a
irb(main):012:1*   puts 1
irb(main):013:0> end
1
=> nil

env: batch-loader (2.0.1)

this bug result in some weird behaviors that unless b cannot be evaluated as true and it's hard to debug, it confused me a lot.

@FX-HAO
Copy link
Author

FX-HAO commented Nov 29, 2022

I know why. it's because b is not an actual NilClass object. it's just an instance of BatchLoader. method_missing is patched and be proxied to the underlying __loaded_value so it looks like an underlying object but it's actually not. But I'm doubting if that can work for every case. just like what I showed above, unless b is an edge case that should be evaluated as true but is false right now.

@FX-HAO
Copy link
Author

FX-HAO commented Nov 30, 2022

given another case, some unexpected behavior:

> nil == b
=> false
> b == nil
=> true
> [nil].include?(b) # because `includes?` internally uses '=='
=> false
> b || 1
=> nil # should be 1

@FX-HAO FX-HAO changed the title BatchLoader.for().batch returns a new object of nil whenever it's called BatchLoader.for().batch {} evaluated as Truthy but should be Falsey Nov 30, 2022
@sos4nt
Copy link

sos4nt commented Nov 30, 2022

FYI BatchLoader undefines several methods (see batch_loader.rb:144) including is_a? and PP's pretty_print which messes with IRB's inspector logic.

If you try it outside of IRB, you'll see the actual inspect value, e.g.

$ ruby -rbatch-loader -e "p BatchLoader.for(1).batch {}"
#<BatchLoader:0x120>

@sos4nt
Copy link

sos4nt commented Nov 30, 2022

The delegation only works for / relies on method calls. Here's another example:

require 'batch-loader'

class Foo; end

foo = BatchLoader.for(1).batch do |ids, loader|
  ids.each { |id| loader.call(id, Foo.new) }
end

foo.class
#=> Foo

but:

case foo
when Foo
  puts 'a Foo'
else
  puts 'not a Foo'
end
# not a Foo

This is because case calls Foo === foo under the hood (instead of foo.is_a?(Foo)), thus bypassing the proxy object and its delegation.

As a workaround, you could call itself on the proxy which returns the actual object:

case foo.itself
when Foo
  puts 'a Foo'
else
  puts 'not a Foo'
end
# a Foo

Applied to your examples:

b = BatchLoader.for(1).batch do |ids, loader|
  ids.each { |id| loader.call(id, nil) }
end

b.itself.object_id
#=> 8

puts 1 unless b.itself
# prints 1

nil == b.itself
#=> true

[nil].include?(b.itself)
#=> true

b.itself || 1
#=> 1

@FX-HAO
Copy link
Author

FX-HAO commented Nov 30, 2022

@sos4nt Thanks for your detailed explanation. I totally understand what you mean. But I still think this should be a problem. We're trying to use batch-loader to fix our N+1 issues. I thought it can be integrated seamlessly without significant changes. For example:

class UserService
  def self.find_by_user_id(id)
    User.find_by(id: id)
  end

  def self.lazy_find_by_user_id(id)
    BatchLoader.for(id).batch do |ids, loader|
      User.where(id: ids).each { |user| loader.call(user.id, user) }
    end
  end
end

class OrderService
  def create(user_id)
    user = UserService.find_by_user_id(user_id)
    unless user
      raise "user not found"
    end
    ...
  end
end

I thought we can just replace UserService.find_by_user_id(user_id) with UserService.lazy_find_by_user_id(user_id). Nothing else we need to change. But seems like we also need to change unless user to unless !!user. Of course we can simply do that. but please just imagine that if there are many places that write code like if user or user_ids.map {|id| UserService.lazy_find_by_user_id(id)}.include?(nil) or user || create_user(). We need to carefully check the each of them and correct them one by one. And tell people to use if !!user rather than if user because user is not an actual nil but a delegator. IMHO, It's counter-intuitive and error-prone. How can we integrate it without significant changes?

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

No branches or pull requests

2 participants