Can you add clauses in a where block conditionally when using Squeel? - ruby-on-rails

To start, I'm using Rails v3.2.9 with Squeel 1.0.13 and here's what I'm trying to do:
I want to search for a client using any of three pieces of identifying information - name, date of birth (dob), and social insurance number (sin). The result set must include any record that has any of the identifier - an OR of the conditions. I have done this in Squeel before and it would look something like:
scope :by_any, ->(sin, name, dob){ where{(client.sin == "#{sin}") | (client.name =~ "%#{name}%") | (client.dob == "#{dob}")} }
This works fine as long as I provide all of the identifiers. But what if I only have a name? The above scope results in:
SELECT "clients".* FROM "clients" WHERE ((("clients"."sin" IS NULL OR "clients"."name" ILIKE '%John Doe%') OR "clients"."dob" IS NULL))
This includes the set of clients where sin is null and the set of clients where dob is null along with the requested set of clients with a name like 'John Doe'.
So enter my attempt to conditionally add clauses to the where block. At first, I tried to check the values using the nil? method:
def self.by_any (sin, name, dob)
where do
(clients.sin == "#{sin}" unless sin.nil?) |
(clients.name =~ "%#{name}" unless name.nil?) |
(clients.dob == "#{dob}" unless dob.nil?)
end
which results in:
SELECT "clients".* FROM "clients" WHERE ('t')
raising many other questions, like what's the deal with that 't', but that's a tangent.
Short of writing the where clause for each permutation, is there a way I can conditionally add clauses?

So, this isn't the prettiest thing ever, but it does what you're after.
def self.by_any(sin, name, dob)
where do
[
sin.presence && clients.sin == "#{sin}",
name.presence && clients.name =~ "%#{name}",
dob.presence && clients.dob == "#{dob}"
].compact.reduce(:|)
# compact to remove the nils, reduce to combine the cases with |
end
end
Basically, [a, b, c].reduce(:f) returns (a.f(b)).f(c). In this case f, the method invoked, is the pipe, so we get (a.|(b)).|(c) which, in less confusing notation, is (a | b) | c.
It works because, in Squeel, the predicate operators (==, =~, and so on) return a Predicate node, so we can construct them independently before joining them with |.
In the case where all three are nil, it returns all records.

After eventually finding this related post, I cannibalized #bradgonesurfing 's alternate pattern to come to this solution:
def self.by_any (sin, name, dob)
queries = Array.new
queries << self.by_sin(sin) unless sin.nil?
queries << self.by_name(name) unless name.nil?
queries << self.by_dob(dob) unless dob.nil?
self.where do
queries = queries.map { |q| id.in q.select{id} }
queries.inject { |s, i| s | i }
end
end
where self.by_sin, self.by_name, and self.by_dob are simple scopes with filters. This produces something along the lines of:
SELECT *
FROM clients
WHERE clients.id IN (<subquery for sin>)
OR clients.id IN (<subquery for name>)
OR clients.id IN (<subquery for dob>)
where the subqueries are only include if their associated value is not nil.
This effectively allows me to union the appropriate scopes together as an ActiveRecord::Relation.

Related

how to match all multiple conditions with rails where method

Using active record, I want to perform a lookup that returns a collection of items that have ALL matching id's.
Given that the below example matches on ANY id in the array, I am trying to figure out the syntax so that it will match when ALL of the id's match. (given that in this example there is a many to many relationship).
The array length of the id's is also variable which prohibits chaining .where()
x.where(id: [1,2])
Note: this question got removed before and there are a lot of answers for performing a sql "where in" but this question is about performing a sql "where and"
You can use exec_query and execute your own bound query:
values = [1, 2]
where_condition = values.map.with_index(1) { |_, index| "id = $#{index}" }.join(" AND ")
sql = "SELECT * FROM table WHERE #{ where_condition }"
binds = values.map { |i| ActiveRecord::Relation::QueryAttribute.new(nil, i, ActiveRecord::Type::Integer.new) }
ActiveRecord::Base.connection.exec_query(sql, nil, binds)
I completely agree with #muistooshort's comment
where(id: [1,2]) doesn't make sense unless you're joining to an association table and in that case,..."where in" combined with HAVING [solves your problem].
But for the sake of answering the question and the assumption that id was just and example.
While #SebastianPalma's answer will work it will return an ActiveRecord::Result whereas most of the time the desire is an ActiveRecord::Relation.
We can achieve this by using Arel to build the where clause like so:
(I modified the example to use description rather than id so that it makes more logical sense)
table = MyObject.arel_table
values = ['Jamesla','Example']
where_clause = values.map {|v| table[:description].matches("%{v}%")}.reduce(&:and)
# OR
where_clause = table[:description].matches_all(values.map {|v| "%#{v}%"})
MyObject.where(where_clause)
This will result in the following SQL query:
SELECT
my_objects.*
FROM
my_objects
WHERE
my_objects.description LIKE '%Jamesla%'
AND my_objects.description LIKE '%Example%'

Selecting MIN and MAX for a collection, based on an ActiveRecord scope, in one SQL query

I would like to run the following query:
/* Inside a heredoc */
SELECT
MIN("books"."page_count") AS min,
MAX("books"."page_count") AS max
FROM "books"
WHERE "books"."author_id" IN (#{authors.pluck(:id).map { |id| "'#{id}'" }.join(",")})
AND "books"."publisher_id" IN (#{publishers.pluck(:id).map { |id| "'#{id}'" }.join(",")});
But instead of having to manually write the WHERE clauses at the end with that ugly interpolation, I'd like to use scopes I've defined on Book, something like:
query = Book.by_author(authors).by_publisher(publishers).select('MIN("books"."page_count") as min, MAX("books"."page_count") as max').first
attrs = [query["min"], query["max"]]
I understand that this doesn't work because Book.my_scope... expects to return a collection of books, while I'm looking for numeric values.
I am aware I could do e.g.:
query = Book.by_author(authors).by_publisher(publishers)
attrs = [query.minimum(:page_count), query.maximum(:page_count)]
But this results in two SQL queries, which seems quite unnecessary.
Is there a Rails-y way I can do this while keeping the flexibility of chainable scopes, without heredoc interpolation, and in one SQL query?
You could define it as a class method:
class Book
class << self
def minmax_page_count
count = select('MIN(page_count) OVER (), MAX(page_count) OVER ()').take
[count.min, count.max]
end
end
end
Book.by_author(authors).by_publisher(publishers).minmax_page_count # => [1, 2]

ActiveRecord select with OR and exclusive limit

I have the need to query the database and retrieve the last 10 objects that are either active or declined. We use the following:
User.where(status: [:active, :declined]).limit(10)
Now we need to get the last 10 of each status (total of 20 users)
I've tried the following:
User.where(status: :active).limit(10).or(User.where(status: : declined).limit(10))
# SELECT "users".* FROM "users" WHERE ("users"."status" = $1 OR "users"."status" = $2) LIMIT $3
This does the same as the previous query and returns only 10 users, of mixed statuses.
How can I get the last 10 active users and the last 10 declined users with a single query?
I'm not sure that SQL allows doing what you want. First thing I would try would be to use a subquery, something like this:
class User < ApplicationRecord
scope :active, -> { where status: :active }
scope :declined, -> { where status: :declined }
scope :last_active_or_declined, -> {
where(id: active.limit(10).pluck(:id))
.or(where(id: declined.limit(10).pluck(:id))
}
end
Then somewhere else you could just do
User.last_active_or_declined()
What this does is to perform 2 different subqueries asking separately for each of the group of users and then getting the ones in the propper group ids. I would say you could even forget about the pluck(:id) parts since ActiveRecord is smart enough to add the proper select clause to your SQL, but I'm not 100% sure and I don't have any Rails project at hand where I can try this.
limit is not a permitted value for #or relationship. If you check the Rails code, the Error raised come from here:
def or!(other) # :nodoc:
incompatible_values = structurally_incompatible_values_for_or(other)
unless incompatible_values.empty?
raise ArgumentError, "Relation passed to #or must be structurally compatible. Incompatible values: #{incompatible_values}"
end
# more code
end
You can check which methods are restricted further down in the code here:
STRUCTURAL_OR_METHODS = Relation::VALUE_METHODS - [:extending, :where, :having, :unscope, :references]
def structurally_incompatible_values_for_or(other)
STRUCTURAL_OR_METHODS.reject do |method|
get_value(method) == other.get_value(method)
end
end
You can see in the Relation class here that limit is restricted:
SINGLE_VALUE_METHODS = [:limit, :offset, :lock, :readonly, :reordering,
:reverse_order, :distinct, :create_with, :skip_query_cache,
:skip_preloading]
So you will have to resort to raw SQL I'm afraid
I don't think you can do it with a single query, but you can do it with two queries, get the record ids, and then build a query using those record ids.
It's not ideal but as you're just plucking ids the impact isn't too bad.
user_ids = User.where(status: :active).limit(10).pluck(:id) + User.where(status: :declined).limit(10).pluck(id)
users = User.where(id: user_ids)
I think you can use UNION. Install active_record_union and replace or with union:
User.where(status: :active).limit(10).union(User.where(status: :declined).limit(10))

Ordering by specific value in Activerecord

In Ruby on Rails, I'm trying to order the matches of a player by whether the current user is the winner.
The sort order would be:
Sort by whether the current user is the winner
Then sort by created_at, etc.
I can't figure out how to do the equivalent of :
Match.all.order('winner_id == ?', #current_user.id)
I know this line is not syntactically correct but hopefully it expresses that the order must be:
1) The matches where the current user is the winner
2) the other matches
You can use a CASE expression in an SQL ORDER BY clause. However, AR doesn't believe in using placeholders in an ORDER BY so you have to do nasty things like this:
by_owner = Match.send(:sanitize_sql_array, [ 'case when winner_id = %d then 0 else 1 end', #current_user.id ])
Match.order(by_owner).order(:created_at)
That should work the same in any SQL database (assuming that your #current_user.id is an integer of course).
You can make it less unpleasant by using a class method as a scope:
class Match < ActiveRecord::Base
def self.this_person_first(id)
by_owner = sanitize_sql_array([ 'case when winner_id = %d then 0 else 1 end', id])
order(by_owner)
end
end
# and later...
Match.this_person_first(#current_user.id).order(:created_at)
to hide the nastiness.
This can be achived using Arel without writing any raw SQL!
matches = Match.arel_table
Match
.order(matches[:winner_id].eq(#current_user.id).desc)
.order(created_at: :desc)
Works for me with Postgres 12 / Rails 6.0.3 without any security warning
If you want to do sorting on the ruby side of things (instead of the SQL side), then you can use the Array#sort_by method:
query.sort_by(|a| a.winner_id == #current_user.id)
If you're dealing with bigger queries, then you should probably stick to the SQL side of things.
I would build a query and then execute it after it's built (mostly because you may not have #current_user. So, something like this:
query = Match.scoped
query = query.order("winner_id == ?", #current_user.id) if #current_user.present?
query = query.order("created_at")
#results = query.all

Rails, how to sanitize SQL in find_by_sql

Is there a way to sanitize sql in rails method find_by_sql?
I've tried this solution:
Ruby on Rails: How to sanitize a string for SQL when not using find?
But it fails at
Model.execute_sql("Update users set active = 0 where id = 2")
It throws an error, but sql code is executed and the user with ID 2 now has a disabled account.
Simple find_by_sql also does not work:
Model.find_by_sql("UPDATE user set active = 0 where id = 1")
# => code executed, user with id 1 have now ban
Edit:
Well my client requested to make that function (select by sql) in admin panel to make some complex query(joins, special conditions etc). So I really want to find_by_sql that.
Second Edit:
I want to achieve that 'evil' SQL code won't be executed.
In admin panel you can type query -> Update users set admin = true where id = 232 and I want to block any UPDATE / DROP / ALTER SQL command.
Just want to know, that here you can ONLY execute SELECT.
After some attempts I conclude sanitize_sql_array unfortunatelly don't do that.
Is there a way to do that in Rails??
Sorry for the confusion..
Try this:
connect = ActiveRecord::Base.connection();
connect.execute(ActiveRecord::Base.send(:sanitize_sql_array, "your string"))
You can save it in variable and use for your purposes.
I made a little snippet for this that you can put in initializers.
class ActiveRecord::Base
def self.escape_sql(array)
self.send(:sanitize_sql_array, array)
end
end
Right now you can escape your query with this:
query = User.escape_sql(["Update users set active = ? where id = ?", true, params[:id]])
And you can call the query any way you like:
users = User.find_by_sql(query)
Slightly more general-purpose:
class ActiveRecord::Base
def self.escape_sql(clause, *rest)
self.send(:sanitize_sql_array, rest.empty? ? clause : ([clause] + rest))
end
end
This one lets you call it just like you'd type in a where clause, without extra brackets, and using either array-style ? or hash-style interpolations.
User.find_by_sql(["SELECT * FROM users WHERE (name = ?)", params])
Source: http://blog.endpoint.com/2012/10/dont-sleep-on-rails-3-sql-injection.html
Though this example is for INSERT query, one can use similar approach for UPDATE queries. Raw SQL bulk insert:
users_places = []
users_values = []
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
params[:users].each do |user|
users_places << "(?,?,?,?)" # Append to array
users_values << user[:name] << user[:punch_line] << timestamp << timestamp
end
bulk_insert_users_sql_arr = ["INSERT INTO users (name, punch_line, created_at, updated_at) VALUES #{users_places.join(", ")}"] + users_values
begin
sql = ActiveRecord::Base.send(:sanitize_sql_array, bulk_insert_users_sql_arr)
ActiveRecord::Base.connection.execute(sql)
rescue
"something went wrong with the bulk insert sql query"
end
Here is the reference to sanitize_sql_array method in ActiveRecord::Base, it generates the proper query string by escaping the single quotes in the strings. For example the punch_line "Don't let them get you down" will become "Don\'t let them get you down".
I prefer to do it with key parameters. In your case it may looks like this:
Model.find_by_sql(["UPDATE user set active = :active where id = :id", active: 0, id: 1])
Pay attention, that you pass ONLY ONE parameter to :find_by_sql method - its an array, which contains two elements: string query and hash with params (since its our favourite Ruby, you can omit the curly brackets).

Resources