Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Ruby on Rails - ActiveRecord::Relation count method is wrong?

I'm writing an application that allows users to send one another messages about an 'offer'.

I thought I'd save myself some work and use the Mailboxer gem.

I'm following a test driven development approach with RSpec. I'm writing a test that should ensure that only one Conversation is allowed per offer. An offer belongs_to two different users (the user that made the offer, and the user that received the offer).

Here is my failing test:

describe "after a message is sent to the same user twice" do
  before do 
    2.times { sending_user.message_user_regarding_offer!  offer, receiving_user, random_string }
  end
  specify { sending_user.mailbox.conversations.count.should == 1 }
end

So before the test runs a user sending_user sends a message to the receiving_user twice. The message_user_regarding_offer! looks like this:

def message_user_regarding_offer! offer, receiver, body
    conversation = offer.conversation
    if conversation.nil?
      self.send_message(receiver, body, offer.conversation_subject)
    else  
      self.reply_to_conversation(conversation, body)
      # I put a binding.pry here to examine in console
    end
    offer.create_activity key: PublicActivityKeys.message_received, owner: self, recipient: receiver
end

On the first iteration in the test (when the first message is sent) the conversation variable is nil therefore a message is sent and a conversation is created between the two users.

On the second iteration the conversation created in the first iteration is returned and the user replies to that conversation, but a new conversation isn't created.

This all works, but the test fails and I cannot understand why!

When I place a pry binding in the code in the location specified above I can examine what is going on... now riddle me this:

wtf ruby

self.mailbox.conversations[0] returns a Conversation instance

self.mailbox.conversations[1] returns nil

self.mailbox.conversations clearly shows a collection containing ONE object.

self.mailbox.conversations.count returns 2?!

What is going on there? the count method is incorrect and my test is failing...

What am I missing? Or is this a bug?!

EDIT

offer.conversation looks like this:

  def conversation
    Conversation.where({subject: conversation_subject}).last
  end

and offer.conversation_subject:

  def conversation_subject
    "offer-#{self.id}"
  end

EDIT 2 - Showing the first and second iteration in pry

wtf ruby 2

Also...

Conversation.all.count returns 1!

and:

Conversation.all == self.mailbox.conversations returns true

and

Conversation.all.count == self.mailbox.conversations.count returns false

How can that be if the arrays are equal? I don't know what's going on here, blown hours on this now. Think it's a bug?!

EDIT 3

From the source of the Mailboxer gem...

def conversations(options = {})
  conv = Conversation.participant(@messageable)

  if options[:mailbox_type].present?
    case options[:mailbox_type]
    when 'inbox'
      conv = Conversation.inbox(@messageable)
    when 'sentbox'
      conv = Conversation.sentbox(@messageable)
    when 'trash'
      conv = Conversation.trash(@messageable)
    when  'not_trash'
      conv = Conversation.not_trash(@messageable)
    end
  end

  if (options.has_key?(:read) && options[:read]==false) || (options.has_key?(:unread) && options[:unread]==true)
    conv = conv.unread(@messageable)
  end

  conv
end

The reply_to_convesation code is available here -> http://rubydoc.info/gems/mailboxer/frames.

Just can't see what I'm doing wrong! Might rework my tests to get around this. Or ditch the gem and write my own.

like image 755
Adam Waite Avatar asked Sep 19 '13 22:09

Adam Waite


2 Answers

see this Rails 3: Difference between Relation.count and Relation.all.count

In short Rails ignores the select columns (if more than one) when you apply count to the query. This is because

SQL's COUNT allows only one or less columns as parameters.

From Mailbox code

 scope :participant, lambda {|participant|
    select('DISTINCT conversations.*').
      where('notifications.type'=> Message.name).
      order("conversations.updated_at DESC").
      joins(:receipts).merge(Receipt.recipient(participant))
  }

self.mailbox.conversations.count ignores the select('DISTINCT conversations.*') and counts the join table with receipts, essentially counting number of receipts with duplicate conversations in it.

On the other hand, self.mailbox.conversations.all.count first gets the records applying the select, which gets unique conversations and then counts it.

self.mailbox.conversations.all == self.mailbox.conversations since both of them query the db with the select.

To solve your problem you can use sending_user.mailbox.conversations.all.count or sending_user.mailbox.conversations.group('conversations.id').length

like image 157
tihom Avatar answered Sep 24 '22 09:09

tihom


I have tended to use the size method in my code. As per the ActiveRecord code, size will use a cached count if available and also returns the correct number when models have been created through relations and have not yet been saved.

# File activerecord/lib/active_record/relation.rb, line 228
def size
  loaded? ? @records.length : count
end

There is a blog on this here.

In Ruby, #length and #size are synonyms and both do the same thing: they tell you how many elements are in an array or hash. Technically #length is the method and #size is an alias to it.

In ActiveRecord, there are several ways to find out how many records are in an association, and there are some subtle differences in how they work.

post.comments.count - Determine the number of elements with an SQL COUNT query. You can also specify conditions to count only a subset of the associated elements (e.g. :conditions => {:author_name => "josh"}). If you set up a counter cache on the association, #count will return that cached value instead of executing a new query.

post.comments.length - This always loads the contents of the association into memory, then returns the number of elements loaded. Note that this won't force an update if the association had been previously loaded and then new comments were created through another way (e.g. Comment.create(...) instead of post.comments.create(...)).

post.comments.size - This works as a combination of the two previous options. If the collection has already been loaded, it will return its length just like calling #length. If it hasn't been loaded yet, it's like calling #count.

It is also worth mentioning to be careful if you are not creating models through associations, as the related model will not necessarily have those instances in its association proxy/collection.

# do this
mailbox.conversations.build(attrs)
# or this
mailbox.conversations << Conversation.new(attrs)
# or this
mailbox.conversations.create(attrs)
# or this
mailbox.conversations.create!(attrs)

# NOT this
Conversation.new(mailbox_id: some_id, ....)
like image 41
Andrew Hacking Avatar answered Sep 25 '22 09:09

Andrew Hacking