Order Mongoid results by dynamic, user-specific value - ruby-on-rails

I have a Mongoid model, and I'd like to order the results by a score that's calculated between the model and current_user in real time. Suppose Thing has an instance method match_score:
def match_score(user) #can be user object too
score = 100
score -= 5 if user.min_price && !(price < user.min_price)
score -= 10 if user.max_price && !(price > user.max_price)
#... bunch of other factors...
return [score, 0].max
end
Is it possible to sort the results of any query by the value returned for a particular user?

MongoDB doesn't support arbitrary expressions in sorting. Basically, you only can specify a field and a direction (asc/desc).
With such complicated sorting logic as yours, the only way is to do it in the app. Or look at another data store.

Related

Computing ActiveRecord nil attributes

In my Rails app I have something like this in one of the models
def self.calc
columns_to_sum = "sum(price_before + price_after) as price"
where('product.created_at >= ?', 1.month.ago.beginning_of_day).select(columns_to_sum)
end
For some of the rows we have price_before and or price_after as nil. This is not ideal as I want to add both columns and call it price. How do I achieve this without hitting the database too many times?
You can ensure the NULL values to be calculated as 0 by using COALESCE which will return the first non NULL value:
columns_to_sum = "sum(COALESCE(price_before, 0) + COALESCE(price_after, 0)) as price"
This would however calculate the sum prices of all products.
On the other hand, you might not have to do this if all you want to do is have an easy way to calculate the price of one product. Then you could add a method to the Product model
def.price
price_before.to_i + price_after.to_i
end
This has the advantage of being able to reflect changes to the price (via price_before or price_after) without having to go through the db again as price_before and price_after will be fetched by default.
But if you want to e.g. select records from the db based on the price you need to place that functionality in the DB.
For that I'd modulize your scopes and join them again later:
def self.with_price
columns_to_sum = "(COALESCE(price_before, 0) + COALESCE(price_after, 0)) as price"
select(column_names, columns_to_sum)
end
This will return all records with an additional price reader method.
And a scope independent from the one before:
def self.one_month_ago
where('product.created_at >= ?', 1.month.ago.beginning_of_day)
end
Which could then be used like this:
Product.with_price.one_month_ago
This allows you to continue modifying the scope before hitting the DB, e.g. to get all Products where the price is higher than x
Product.with_price.one_month_ago.where('price > 5')
If you are trying to get the sum of price_before and price_after for each individual record (as opposed to a single sum for the entire query result), you want to do it like this:
columns_to_sum = "(coalesce(price_before, 0) + coalesce(price_after, 0)) as price"
I suspect that's what you're after, since you have no group in your query. If you are after a single sum, then the answer by #ulferts is correct.

Rails, sum the aggregate of an attribute of all instances of a model

I have a model Channel. The relating table has several column, for example clicks.
So Channel.all.sum(:clicks) gives me the sum of clicks of all channels.
In my model I have added a new method
def test
123 #this is just an example
end
So now, Channel.first.test returns 123
What I want to do is something like Channel.all.sum(:test) which sums the test value of all channels.
The error I get is that test is not a column, which of course it is not, but I hoped to till be able to build this sum.
How could I achieve this?
You could try:
Channel.all.map(&:test).sum
Where clicks is a column of the model's table, use:
Channel.sum(:clicks)
To solve your issue, you can do
Channel.all.sum(&:test)
But it would be better to try achieving it on the database layer, because processing with Ruby might be heavy for memory and efficiency.
EDIT
If you want to sum by a method which takes arguments:
Channel.all.sum { |channel| channel.test(start_date, end_date) }
What you are talking about here is two very different things:
ActiveRecord::Calculations.sum sums the values of a column in the database:
SELECT SUM("table_name"."column_name") FROM "column_name"
This is what happens if you call Channel.sum(:column_name).
ActiveSupport also extends the Enumerable module with a .sum method:
module Enumerable
def sum(identity = nil, &block)
if block_given?
map(&block).sum(identity)
else
sum = identity ? inject(identity, :+) : inject(:+)
sum || identity || 0
end
end
end
This loops though all the values in memory and adds them together.
Channel.all.sum(&:test)
Is equivalent to:
Channel.all.inject(0) { |sum, c| sum + c.test }
Using the later can lead to serious performance issues as it pulls all the data out of the database.
Alternatively you do this.
Channel.all.inject(0) {|sum,x| sum + x.test }
You can changed the 0 to whatever value you want the sum to start off at.

Extract records which satisfy a model function in Rails

I have following method in a model named CashTransaction.
def is_refundable?
self.amount > self.total_refunded_amount
end
def total_refunded_amount
self.refunds.sum(:amount)
end
Now I need to extract all the records which satisfy the above function i.e records which return true.
I got that working by using following statement:
CashTransaction.all.map { |x| x if x.is_refundable? }
But the result is an Array. I am looking for ActiveRecord_Relation object as I need to perform join on the result.
I feel I am missing something here as it doesn't look that difficult. Anyways, it got me stuck. Constructive suggestions would be great.
Note: Just amount is a CashTransaction column.
EDIT
Following SQL does the job. If I can change that to ORM, it will still do the job.
SELECT `cash_transactions`.* FROM `cash_transactions` INNER JOIN `refunds` ON `refunds`.`cash_transaction_id` = `cash_transactions`.`id` WHERE (cash_transactions.amount > (SELECT SUM(`amount`) FROM `refunds` WHERE refunds.cash_transaction_id = cash_transactions.id GROUP BY `cash_transaction_id`));
Sharing Progress
I managed to get it work by following ORM:
CashTransaction
.joins(:refunds)
.group('cash_transactions.id')
.having('cash_transactions.amount > sum(refunds.amount)')
But what I was actually looking was something like:
CashTransaction.joins(:refunds).where(is_refundable? : true)
where is_refundable? being a model function. Initially I thought setting is_refundable? as attr_accesor would work. But I was wrong.
Just a thought, can the problem be fixed in an elegant way using Arel.
There are two options.
1) Finish, what you have started (which is extremely inefficient when it comes to bigger amount of data, since it all is taken into the memory before processing):
CashTransaction.all.map(&:is_refundable?) # is the same to what you've written, but shorter.
SO get the ids:
ids = CashTransaction.all.map(&:is_refundable?).map(&:id)
ANd now, to get ActiveRecord Relation:
CashTransaction.where(id: ids) # will return a relation
2) Move the calculation to SQL:
CashTransaction.where('amount > total_refunded_amount')
Second option is in every possible way faster and efficient.
When you deal with database, try to process it on the database level, with smallest Ruby involvement possible.
EDIT
According to edited question here is how you would achieve the desired result:
CashTransaction.joins(:refunds).where('amount > SUM(refunds.amount)')
EDIT #2
As to your updates in question - I don't really understand, why you have latched onto is_refundable? as an instance method, which could be used in query, which is basically not possible in AR, but..
My suggestion is to create a scope is_refundable:
scope :is_refundable, -> { CashTransaction
.joins(:refunds)
.group('cash_transactions.id')
.having('cash_transactions.amount > sum(refunds.amount)')
}
Now it is available in as short notation as
CashTransaction.is_refundable
which is shorter and more clear than aimed
CashTransaction.where('is_refundable = ?', true)
You can do it this way:
cash_transactions = CashTransaction.all.map { |x| x if x.is_refundable? } # Array
CashTransaction.where(id: cash_transactions.map(&:id)) # ActiveRecord_Relation
But, this is an in-efficient way of doing it as the other answerers also mentioned.
You can do it using SQL if amount and total_refunded_amount are the columns of the cash_transactions table in the database which will be much more efficient and performant:
CashTransaction.where('amount > total_refunded_amount')
But, if amount or total_refunded_amount are not the actual columns in the database, then you can't do it this way. Then, I guess you have do it the other way which is in-efficient than using raw SQL.
I think you should pre-compute is_refundable result (in a new column) when a CashTransaction and his refunds (supposed has_many ?) are updated by using callbacks :
class CashTransaction
before_save :update_is_refundable
def update_is_refundable
is_refundable = amount > total_refunded_amount
end
def total_refunded_amount
self.refunds.sum(:amount)
end
end
class Refund
belongs_to :cash_transaction
after_save :update_cash_transaction_is_refundable
def update_cash_transaction_is_refundable
cash_transaction.update_is_refundable
cash_transaction.save!
end
end
Note : The above code must certainly be optimized to prevent some queries
They you can query is_refundable column :
CashTransaction.where(is_refundable: true)
I think it's not bad to do this on two queries instead of a join table, something like this
def refundable
where('amount < ?', total_refunded_amount)
end
This will do a single sum query then use the sum in the second query, when the tables grow larger you might find that this is faster than doing a join in the database.

Passing params to sql query

So, in my rails app I developed a search filter where I am using sliders. For example, I want to show orders where the price is between min value and max value which comes from the slider in params. I have column in my db called "price" and params[:priceMin], params[:priceMax]. So I can't write something kinda MyModel.where(params).... You may say, that I should do something like MyModel.where('price >= ? AND price <= ?', params[:priceMin], params[:priceMax]) but there is a problem: the number of search criteria depends on user desire, so I don't know the size of params hash that passes to query. Are there any ways to solve this problem?
UPDATE
I've already done it this way
def query_senders
query = ""
if params.has_key?(:place_from)
query += query_and(query) + "place_from='#{params[:place_from]}'"
end
if params.has_key?(:expected_price_min) and params.has_key?(:expected_price_max)
query += query_and(query) + "price >= '#{params[:expected_price_min]}' AND price <= '#{params[:expected_price_max]}'"
end...
but according to ruby guides (http://guides.rubyonrails.org/active_record_querying.html) this approach is bad because of SQL injection danger.
You can get the size of params hash by doing params.count. By the way you described it, it still seems that you will know what parameters can be passed by the user. So just check whether they're present, and split the query accordingly.
Edited:
def query_string
return = {}
if params[:whatever].present?
return.merge({whatever: #{params[:whatever]}}"
elsif ...
end
The above would form a hash for all of the exact values you're searching for, avoiding SQL injection. Then for such filters as prices you can just check whether the values are in correct format (numbers only) and only perform if so.

Ruby on Rails: how to assign a random number to users without storing it?

How can I assign a table of users a random number between 1 and 9 without needing to store it in the db (to recall it later).
Is there some way to hash their user_id into returning a number between a range (and then get the same number for that user every time that function would be called).
I know the following is not an optimal way to do this, but it works and is guaranteed to return the same random number between 1 and 9, which will be unique for each user, i.e. you wont need to store it in your database:
require 'digest/md5'
def unique_number_for(user)
hash = (Digest::MD5.new << user.id.to_s).to_s
hash.split("").map(&:to_i).detect {|a| a > 0}
end
The obvious solution:
id.to_s[-1,1]

Resources