Rails query with multiple conditions on the same field - sql

I have two models, Recipes and Skills. In this scenario, a skill is a cooking technique, like baking, frying, etc. So each recipe has a certain set of associated skills.
I want to search all the recipes like this:
Find all recipes that use any given set of skills (e.g. Baking OR Frying OR both)
EDIT: This should NOT return recipes that require a skill that wasn't in the search query - e.g. If I search for skills [1, 2] I don't want a recipe that uses skills [1, 2, 4] or any other superset.
If you add a new skill to the search, return just the additional recipes (e.g. if you add Boiling to the previous query of Baking or Frying, how many NEW recipes can you now cook?)
I currently have this working in Rails using plain old Ruby methods:
class Recipe < ActiveRecord::Base
has_many :practices
has_many :skills, through: :practices
def self.find_recipes_that_require_any_of_these_skills(*known_skill_ids)
self.select do |recipe|
recipe.skill_ids.all? do |skill_id|
known_skill_ids.include?(skill_id)
end
end
end
# calls the above method twice, once with the new skill and once without
# and subtracts one result from the other
def self.find_newly_unlocked_recipes(*prior_skill_ids, new_skill_id)
self.find_recipes_that_require_any_of_these_skills(*(prior_skill_ids + [new_skill_id])) - self.find_recipes_that_require_any_of_these_skills(*prior_skill_ids)
end
end
In Rails console: Recipe.find_recipes_that_require_any_of_these_skills(1,4)
returns all the Recipe objects for skill 1, skill 4, or skills 1 & 4.
But this is inefficient because it generates a SQL query for every single recipe in my database.
How can I write these queries the ActiveRecord/SQL way?

def self.find_recipes_that_require_any_of_these_skills(*known_skill_ids)
self.includes(:skills).where(skills: { id: known_skill_ids })
end

Two queries to DB:
def self.find_recipes_that_require_any_of_these_skills(*known_skill_ids)
Recipe.joins(:skills)
.merge(Skill.where(id: known_skill_ids))
.where("recipes.id NOT IN (?)", Recipe.joins(:skills).merge(Skill.where("skills.id NOT IN (?)", known_skill_ids)).uniq.pluck(:id)).uniq
end

Since you're using has_many :skills, through: :practices - your Practices table should have both recipe_id and skills_id columns.
previous_recipe_ids = Recipe.joins(:practices).where('practices.skill_id in (?)', prior_skills_ids).map(&:id)
Recipe.joins(:practices).where('practices.skill_id = (?) and recipes.recipe_id not in (?)', new_skill_id, previous_recipe_ids)

The following method uses just three sql queries in total to create a collection of recipies
array_of_skills=["cooking","frying",...]
skills=Skill.where('name in (?)',array_of_skills).map(&:id)
Up to here you already have, so you might need just this:
practices=Practice.where('skill_id in (?)',skills).map(&:recipe_id)
recipes=Recipe.where('id in (?)', practices)
Maybe there is a better way, but I don't think there would be something with much less sql

It seems to be that your find_recipes_that_require_any_of_these_skills method isn't correct. It returns recipes that have all of the known_skill, not any.
So, the ActiveRecord/SQL way:
Known skills:
class Skill < ActiveRecord::Base
#known skills
scope :known_skills, -> { where(id: known_skill_ids) }
#not known skills
scope :not_known_skills, -> { where("skills.id NOT IN (?)", known_skill_ids) }
Recipes that have any of known skills:
Recipe.joins(:skills).merge(Skill.known_skills)
Newly_unlocked_recipes:
Recipe.joins(:skills).merge(Skill.not_known_skills).where("skills.id = ?", new_skill_id)

Related

Rails concatenate sql columns and create one to many relationship

I've got two models: Building and BuildingInfo. I want to relate the two tables using two columns townhall_level and name.
Ideally it will work like the following: Building.first.building_info For instance Building.first.townhall_level => 5 and Building.first.name => cannon, Building.first.building_info would access BuildingInfo.where(townhall_level: 5, name:"cannon".
What's the best way to do this? Can I create a third column which concatenates name and townhall_level? Could I also use that column to create the belongs_to and has_many relationship?
Simple and straightforward:
class Building < ActiveRecord::Base
def building_info
BuildingInfo.find_by(townhall_level: townhall_level, name: name)
end
end
It will be nil if nothing is found, and will return only the first record even if multiples are found. I also highly suggest that you add an index to the two columns through a migration:
add_index :building_infos, [:townhall_level, :name], name: 'building_infos_level_and_name'
Which will speed up searching, if you were concerned about performance.
mmm...I'm not sure this will work but you can do something like
class Building < ActiveRecord::Base
def self.bulding_info
BuildingInfo.find_by(townhall_level: townhall_level, name: name)
end
end
but I would really suggest you to put a building_info_id in the Building model and have a
class Building < ActiveRecord::Base
belongs_to :bulding_info
end

Rails: complex search on 3 models, return only newest - how to do this?

I'm trying to add an advanced search option to my app in which the user can search for certain links based on attributes from 3 different models.
My app is set up so that a User has_many :websites, Website has_many :links, and Link has_many :stats
I know how create SQL queries with joins or includes etc in Rails but I'm getting stuck since I only want to retrieve the latest stat for each link and not all of them - and I don't know the most efficient way to do this.
So for example, let's say a user has 2 websites, each with 10 links, and each link has 100 stats, that's 2,022 objects total, but I only want to search through 42 objects (only 1 stat per link).
Once I get only those 42 objects in a database query I can add .where("attribute like ?", user_input) and return the correct links.
Update
I've tried adding the following to my Link model:
has_many :stats, dependent: :destroy
has_many :one_stat, class_name: "Stat", order: "id ASC", limit: 1
But this doesn't seem to work, for example if I do:
#links = Link.includes(:one_stat).all
#links.each do |l|
puts l.one_stat.size
end
Instead of getting 1, 1, 1... I get the number of all the stats: 125, 40, 76....
Can I use the limit option to get the results I want or does it not work that way?
2nd Update
I've updated my code according to Erez's advice, but still not working properly:
has_one :latest_stat, class_name: "Stat", order: "id ASC"
#links = Link.includes(:latest_stat)
#links.each do |l|
puts l.latest_stat.indexed
end
=> true
=> true
=> true
=> false
=> true
=> true
=> true
Link.includes(:latest_stat).where("stats.indexed = ?", false).count
=> 6
Link.includes(:latest_stat).where("stats.indexed = ?", true).count
=> 7
It should return 1 and 6, but it's still checking all the stats rather than the latest only.
Sometimes, you gotta break through the AR abstraction and get your SQL on. Just a tiny bit.
Let's assume you have really simple relationships: Website has_many :links, and Link belongs_to :website and has_many :stats, and Stat belongs_to :link. No denormalization anywhere. Now, you want to build a query that finds, all of their links, and, for each link, the latest stat, but only for stats with some property (or it could be websites with some property or links with some property).
Untested, but something like:
Website
.includes(:links => :stats)
.where("stats.indexed" => true)
.where("stats.id = (select max(stats2.id)
from stats stats2 where stats2.link_id = links.id)")
That last bit subselects stats that are part of each link and finds the max id. It then filters out stats (from the join at the top) that don't match that max id. The query returns websites, which each have some number of links, and each link has just one stat in its stats collection.
Some extra info
I originally wrote this answer in terms of window functions, which turned out to be overkill, but I think I should cover it here anyway, since, well, fun. You'll note that the aggregate function trick we used above only works because we're determining which stat to use based on its ID, which exactly the property we need to filter the stats from the join by. But let's say you wanted only the first stat as ranked by some criteria other than ID, such as as, say, number_of_clicks; that trick won't work anymore because the aggregation loses track of the IDs. That's where window functions come in.
Again, totally untested:
Website
.includes(:links => :stats)
.where("stats.indexed" => true)
.where(
"(stats.id, 1) in (
select id, row_number()
over (partition by stats2.id order by stats2.number_of_clicks DESC)
from stat stats2 where stats2.link_id = links.id
)"
)
That last where subselects stats that match each link and order them by number_of_clicks ascending, then the in part matches it to a stat from the join. Note that window queries aren't portable to other database platforms. You could also use this technique to solve the original problem you posed (just swap stats2.id for stats2.number_of_clicks); it could conceivably perform better, and is advocated by this blog post.
I'd try this:
has_one :latest_stat, class_name: "Stat", order: "id ASC"
#links = Link.includes(:latest_stat)
#links.each do |l|
puts l.latest_stat
end
Note you can't print latest_stat.size since it is the stat object itself and not a relation.
Is this what you're looking for?
#user.websites.map { |site| site.links.map { |link| link.stats.last } }.flatten
For a given user, this will return an array with that contains the last stats for the links on that users website.

BEGINNER: Correct seeds.rb in rails 3

I've just created two models and one "join table". Person, Adress (create_adresses_personss)
class Person < ActiveRecord::Base
has_and_belongs_to_many :streets
end
class Street < ActiveRecord::Base
has_and_belongs_to_many :persons
end
Now I want to add some data to these models in the db/seeds.rb file. The tutorial I follow just adds the objects:
person = Person.create :name => 'Dexter'
street.create[{:streetname => 'street1'},
{:streetname => 'street2'},
{:streetname => 'julianave'},
{:streetname => 'street3'}]
Question 1: Why is persons' data added differently than streets'? Is it just the tutorial that wants to show that there are many ways of adding data in the seeds.rb?
Question 2: The tutorial doesn't make the connections/joins in the seeds.rb. It does that in the rails console;
>>p1 = Person.find(1)
>>s1 = Street.find(1)
>>p1.streets << s1
Can't theese connections be made in the seeds.rb file?
Question 3: Would it be better to do this join with a "rich many_to_many-assocciation"?
Thanks for your time and patience with a beginner ;)
1) The first method is creating one object. The second method is creating multiple objects. However, for the second method you would need to do Street.create, not street.create.
2) Yes, you can do that in the seed file the same way.
3) The "Rich many-to-many" you're talking about is an association with a Join Model, I guess you're talking about. This is opposed to just a join table, which is what has_and_belongs_to_many does. To use a join model, you'll want to look up has_many :through. It's generally considered better to always use a proper join model, however I still use HABTM when I just need a quick, simple association. has_many :through allows for more options and more flexibility, but it is a little more complicated to setup (not that much, though). It's your decision.
One way that I like to create seed data for many-to-many associations is setting up one of the models, the adding a tap block that sets up the other models through the association.
Person.create!(:name => "Fubar").tap do |person|
3.times do |n|
person.streets.create!(:streetname => "street #{n}")
end
# OR
person.streets.create!([
{:streetname => "street 1"},
{:streetname => "street 2"},
... and so on
])
end
All tap is doing is executing the block with the object as it's only parameter. I find it convenient for seeds.
One other tip I would toss out there would be to have your model attribute names spaced on the words with underscores.
:street_name instead of :streetname
The difference is more profound when you start wanting to use some of the ActiveSupport helers that take model attributes and turn them into text strings for use in the UI.
e
:streetname.to_s.titleize # "Streetname"
:street_name.to_s.titleize # "Street Name"
And one last nitpick, you might want your join table to be addresses_people not addresses_persons since the rais inflector is going to pluralize person as people. The same would go for your controller on the Person model, PeopleController instead of PersonsController. Though maybe it will work with persons as well.
:person.to_s.pluralize # "people"
:people.to_s.singularize # "person"
:persons.to_s.singularize # "person"

Constructing a has-and-belongs-to-many query

I have a rails app (running on version 2.2.2) that has a model called Product. Product is in a has-and-belongs-to-many relationship with Feature. The problem is that I need have search functionality for the products. So I need to be able to search for products that have a similar name, and some other attributes. The tricky part is that the search must also return products that have the exact set of features indicated in the search form (this is represented by a bunch of checkboxes). The following code works, but it strikes me as rather inefficient:
#products = Product.find(:all, :conditions=>["home=? AND name LIKE ? AND made_by LIKE ? AND supplier LIKE ? AND ins LIKE ?",hme,'%'+opts[0]+'%','%'+opts[1]+'%','%'+opts[3]+'%','%'+opts[4]+'%'])
#see if any of these products have the correct features
if !params[:feature_ids].nil?
f = params[:feature_ids].collect{|i| i.to_i}
#products.delete_if {|x| x.feature_ids!=f}
end
I'm sorry that my grasp of rails/sql is so weak, but does anyone have any suggestions about how to improve the above code? Thanks so much!
First, i would recommend you to manually write a FeatureProduct model (and not use the default 'has_and_belongs_to_many')
EG
class FeatureProduct
belongs_to :feature
belongs_to :product
end
class Product
has_many :feature_products
has_many :features, :through => :feature_products
end
class Feature
has_many :feature_products
has_many :products, :through => :feature_products
end
For the search: You may find the gem SearchLogic to be exactly what you need. It has support for 'LIKE' conditions (it means that you can write in a more 'Rails way' your query). It also has support for performing a search with conditions on a related model (on your Feature model, to be more precise).
The solution would be something like:
search = Product.search
search.name_like = opt[0]
search.made_by_like = opt[1]
...
search.feature_products_id_equals = your_feature_ids
..
#product_list = search.all
There is also an excellent screencast explaining the use of this gem.
Good luck :)

Help with Rails find_by queries

Say if #news_writers is an array of records. I then want to use #news_writers to find all news items that are written by all the news writers contained in #news_writers.
So I want something like this (but this is syntactically incorrect):
#news = News.find_all_by_role_id(#news_writers.id)
Note that
class Role < ActiveRecord::Base
has_many :news
end
and
class News < ActiveRecord::Base
belongs_to :role
end
Like ennen, I'm unsure what relationships your models are supposed to have. But in general, you can find all models with a column value from a given set like this:
News.all(:conditions => {:role_id => #news_writers.map(&:id)})
This will create a SQL query with a where condition like:
WHERE role_id IN (1, 10, 13, ...)
where the integers are the ids of the #news_writers.
I'm not sure if I understand you - #news_writers is a collection of Role models? If that assumption is correct, your association appears to be backwards - if these represent authors of news items, shouldn't News belong_to Role (being the author)?
At any rate, I would assume the most direct approach would be to use an iterator over #news_writers, calling on the association for each news_writer (like news_writer.news) in turn and pushing it into a separate variable.
Edit: Daniel Lucraft's suggestion is a much more elegant solution than the above.