How to set parent attribute based on child attributes - ruby-on-rails

I have a situation where a parent Order has multiple child Items. Both Order and Item have a status_id column. I want the user to update the status_id of the Item, and then when all of the Items have a status_id, then the Order's status_id should be auto-set (to some value based on what Item status_ids are).
The code that I currently have is this:
class Item
after_save :set_order_status_id
def set_order_status_id
if self.order.items.where(status_id:nil).blank?
self.order.update_attributes(status_id:X)
end
end
end
Ths is pretty smelly code because it violates SRP, uses a callback, and is pretty inefficient, considering that this means if an Order has 5 Items and all 5 Items are being updated, after EACH Item update, the set_order_status_id method is called, and a database query is run.
So... is there a better way of writing this to avoid these issues? Particularly I'm interested in removing the inefficiency with constantly checking the parent Order's other child Items' statuses... because again if an all 5 Items is updated at once, it's silly to check after each and every update when it should just wait until the 5th update.... Does Rails have a magical way of doing this?

The answer is no, there is no magic way to do that using the framework. Your best option is to use a customised solution and run your check only after your items update. Something like:
...your code...
order.items.update_all(whatever) <-- update items
update_status(order) <-- update order status
...
def update_status(order)
return if order.items.where(status_id: nil).exist? <-- more efficient
update_attributes(status_id: X)
end
the method could also be in the Offer model for simplicity.

If the order status can be derived from the item status at any point in time, it may be better to avoid setting it in the database entirely. You can instead create an accessor to query it on-demand:
class Order < ActiveRecord::Base
def status
# memoize the calculation, including nils
return #status if defined? #status
item_statuses = items.pluck(:status).uniq
# your logic here:
# 1. check for any nils
# 2. check for any 'pending', etc.
#status = 'pending'
end
end
Whether this alternate solution fits your needs depends on your database read patterns.

Related

Dealing with model object updates and synchronizing associated tables

​Hello, I am building a Ruby on Rails cashflow app where the "balance" field in the "accounts" table will be updated based on the "amount" field in the "incomes" table.
How should I deal with Income object updates (when "amount" is changed), so that the "balance" field in "accounts" is updated properly (first decreased by the previous "amount" of the "Income" and then updated with new "amount")?
Is it a good practice to use callbacks in the "Income" model and ActiveModel::Dirty methods such as "income.amount_was" to get the previous value?
I recommend to create a service object, instead of directly updating the Income#amount via income.update in the controller. Updating another model inside some other model's callback IMO increases coupling and debugging complexity as the responsibility bleeds outside its own.
For example, create a IncomeUpdaterService that does this:
class IncomeUpdaterService
def initialize(income)
#income = income
#account = ... # Depends on how they are connected
end
def update(params)
# DB Transaction
# You might want to lock the Account record
...
update_balance(params.amount) if params.amount
...
# DB Transaction Commit
end
private
def update_balance(new_amount)
prev_amount = #income.amount
balance_adjustment = new_amount - prev_amount
#account.balance += balance_adjustment
# I think theres a #account.increment(:balance, balance_adjustment)
# to avoid race condition instead of locking the record, but not sure
# if it's a good practice to use. From what I remember there's some
# kind of warning for using it.
#account.save
end
end
(For the self projecting master developers out there. Just wrote this quickly just to put it out there, don't be too hypercritical on this code as this is just an example and start insulting me, but feel free to suggest some improvements.)

Ruby Review class creation

The Review class you see below represent a review that a user submitted for a product. Somewhere else in the code, Review.recent is called with a product_id, which is just a unique number that represents a single product. Fill in the code to make it work as expected!
Review.recent - This function should return the 5 most recent reviews (sorted by submit_time) with the specified product_id.
<=> - This special Ruby function is called when comparing two objects for sorting. It returns 1, 0, or +1 depending on whether the object is less than, equal to, or greater than the other object. You'll want to sort items by submit_time so recent items appear first.
set_submit_time - This function is called right before a review is created. We can use Ruby's Time class to set submit_time to now so we know when the review was created.
I'm new to ruby and I want this code for my very important work so how can I complete it help me please!
class Review < ApplicationRecord
# Every Review has a product_id and submit time
attr_accessor :product_id
attr_accessor :submit_time
# Before the new record is created, we'll call the :set_submit_time method
before_create :set_submit_time
def self.recent(product_id)
# Return only the 5 newest results for this product
# Reference: https://ruby-doc.org/core-2.4.2/Enumerable.html
Review.all
end
def <=>(other_review)
# Implement the comparison function for sorting
# Reference: http://ruby-doc.org/core-2.4.2/Comparable.html
end
private
def set_submit_time
# Set the submit_time
# Reference: https://ruby-doc.org/core-2.2.4/Time.html
end
end
self.recent
This is asking you to order by submit_time and return the first 5 results.
To perform the ordering, see: https://apidock.com/rails/ActiveRecord/QueryMethods/order
To perform the limit, see: https://apidock.com/rails/ActiveRecord/QueryMethods/limit
If you're still stuck on this problem, please show us what you've tried.
<=>
If you click the link in the comment you provided (http://ruby-doc.org/core-2.4.2/Comparable.html), the solution is almost identical to that example.
If you're still stuck on this problem, please show us what you've tried.
set_submit_time
It's worth having a quick look at: https://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html - to understand what is meant by a callback. Basically, this method is going to get automatically called whenever a new record is created. (You probably could have guessed this, based on the fairly self-explanatory name: before_create!)
Again, the first example on that page is almost identical to your scenario. You can use Time.now to get the current time.
If you're still stuck on this problem, please show us what you've tried.

When updating an individual child record triggers update to parent, and child records can be updated all at once, how to manage efficiency

I have a parent Order that has many child Items. There is a status attribute on both parent and children, and the statuses interact. For example, only when all Items are complete can the parent Order be complete.
Think of it like a restaurant where the policy is to bring out all the food in a given Order at once. Each food Item goes through very statuses like in_prep, being_cooked, etc. to complete and when all Items are complete, then the Order is complete, and then it can be brought out, at which point all Item and the parent Order can have a status of enjoyed_by_customer
Extending this example, let's say the various line cooks at each part of the kitchen have a tablet where they can update statuses of specific Items and only those Items in their purview. The chef, however, can view the entirety of the Order and make updates to any individual Item status, because the chef is walking around and tweaking and tasting and making changes, with creative ability to change any Item at will. For example, maybe the line cook says the Item salad is complete, but the chef tastes it, says it needs more dressing, and sets it back to in_prep.
First, I built a dashboard for line cooks to mark specific Items in their purview. A line cook making salads, for example, should be able to view all the salads from all the Orders that are not complete, and make batch updates to all of them. For example, the last step might be garnish, so they can garnish a dozen plates all at once and then set all those salads to complete. This meant that I had thought to put a callback on the Item model that called a set_status method on the parent Order:
class Item
after_update :set_order_status
# after_update so we know that the status on this Item is valid
def set_order_status
if self.status.changed?
self.order.set_status
end
end
end
class Order
def set_status
new_status = calculated_somehow_from_item_statuses # eg if all items are 'complete', this returns 'complete'
self.update_attributes(status: new_status)
end
end
Second, I built an overall Order dashboard for the chef, where they can, again, modify statuses of any Item as well as other attributes on the Order itself. For example, maybe chef wants to mark the Order with a special discount or something. For efficiency's sake, I set up the Order dashboard to update the entire Order all at once using nested attributes. In other words, the submitted params are like:
{
"discount": "true",
"items_attributes": [
{"id"=>"1","status"=>"complete"},
{"id"=>"2","status"=>"in_prep"},
]
}
# this way it's easy to just do:
#order.update_attributes(params)
However, of course, because of the way the individual line cook's dashboards have been set up, #order.update_attributes is actually called once for the #order and then once again after each child item updates. In other words, there's redundancy caused by the fact that child items can be both updated 1) individually & 2) en masse, where in either situation, the parent order should be updated at the end based on all child items collectively.
My only thought on revising this is to adjust the set_order_status callback on the child Item, it isn't triggered if the item update is done at the Order level by a chef. In other words:
class Item
after_update :set_order_status
attr_accessor: changed_by_chef
def set_order_status
if self.status.changed? && self.changed_by_chef == false
self.order.set_status
end
end
end
# where updates from the chef's dashboard, rather than individual line cooks, will have changed_by_chef in params
{
"discount": "true",
"items_attributes": [
{"id"=>"1","status"=>"complete", "changed_by_chef"=>"true"},
{"id"=>"2","status"=>"in_prep", "changed_by_chef"=>"true"},
]
}
#order.assign_attributes(params) # assign statuses to child items and run validations
#order.set_status # this method calculates appropriate order level status based on validated child item statuses, and calls the final update_attributes to save everything
In this way, #order.update_attributes is effectively called once. However, this also feels somewhat hacky to me, and I'm wondering if there's a more conventional Railsy way of doing this.
Thanks!

How to filter an ActiveRecord query result by comparison to another model instance?

I have a simple ActiveRecord query along the lines of this:
similar_changes = Notification.where(change_owner: 'foo1', change_target: 'foo2', change_cancelled: false)
Each notification object has a field change_type and I have another function that checks one Notification's change_type with one other Notification for inverse changes (changes that undo each other in the context of my application).
I need to take this Notification's change_type and compare it against all others in the array. I have to reference the objects like so: similar_changes[0]['change_type'] where the first index is each ActiveRecord in the array and the second is the dictionary that specifies which property in the Notification object.
I have a feeling I could do this manually with two nested loops and if statements, but I also know Ruby and I feel like this is something that it should have built in.
Am I wrong, or is there a better way to do this?
Here is the code (note all this code isn't quite finished so bear with me if it's not perfect):
def self.group_similar_changes(owner, target, change_type)
# long query where it selects all rows where change_owner and change_target
# are the same as original
# also where cancelled is false
# determine if cancelled (yaml)
# if cancelled (do nothing)
similar_changes = Notification.where(
change_owner: owner,
change_target: target,
change_cancelled: false
)
similar_changes.each do |change|
cancel_inverse_change(change, change.change_type)
if change.cancelled?
similar_changes.delete(change)
end
end
end
end
def cancel_inverse_change(change, change_type)
if change.inverse?(change_type)
change.cancel
end
end
def inverse?(possible_inverse_change)
is_inverse = false
change_types = YAML.load_file(File.join(NotificationManager::Engine.root, 'config/change_types.yaml'))
if self.change_type == change_types[possible_inverse_change]['inverse']
is_inverse = true
end
return is_inverse
end
Yes, your loop over similar_changes can be improved.
It's confusing to modify the array you're looping over. I don't even know if it's reliable, because I never do it!
It's also not idiomatic Ruby to rely on the return value of each. each is normally used to do something to the elements of an Enumerable that already exists, so using its return value seems strange.
I'd write it as
similar_changes.reject do |change|
cancel_inverse_change(change, change.change)
change.cancelled?
end

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:
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
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.
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
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, ....)
I don't know if this explains what's going on, but the ActiveRecord count method queries the database for the number of records stored. The length of the Relation could be different, as discussed in http://archive.railsforum.com/viewtopic.php?id=6255, although in that example, the number of records in the database was less than the number of items in the Rails data structure.
Try
self.mailbox.conversations.reload; self.mailbox.conversations.count
or perhaps
self.mailbox.reload; self.mailbox.conversations.count
or, if neither of those work, just try reloading as many of the objects as possible to see if you can get it to work (self, mailbox, conversations, etc.).
My guess is that something is messed up between memory and the DB. This is definitely a really weird error though, might wanna put in an issue on Rails to see why this would be the case.
The result of mailbox.conversations is cached after the first call. To reload it write mailbox.conversations(true)

Resources