4

I have 2 models - Restaurant and Feature. They are connected via has_and_belongs_to_many relationship. The gist of it is that you have restaurants with many features like delivery, pizza, sandwiches, salad bar, vegetarian option,… So now when the user wants to filter the restaurants and lets say he checks pizza and delivery, I want to display all the restaurants that have both features; pizza, delivery and maybe some more, but it HAS TO HAVE pizza AND delivery.

If I do a simple .where('features IN (?)', params[:features]) I (of course) get the restaurants that have either - so or pizza or delivery or both - which is not at all what I want.

My SQL/Rails knowledge is kinda limited since I'm new to this but I asked a friend and now I have this huuuge SQL that gets the job done:

Restaurant.find_by_sql(['SELECT restaurant_id FROM (
                                                  SELECT features_restaurants.*, ROW_NUMBER() OVER(PARTITION BY restaurants.id ORDER BY features.id) AS rn FROM restaurants
                                                  JOIN features_restaurants ON restaurants.id = features_restaurants.restaurant_id
                                                  JOIN features ON features_restaurants.feature_id = features.id
                                                  WHERE features.id in (?)
                                                ) t
                                                WHERE rn = ?', params[:features], params[:features].count])

So my question is: is there a better - more Rails even - way of doing this? How would you do it?

Oh BTW I'm using Rails 4 on Heroku so it's a Postgres DB.

Miha Rekar
  • 1,237
  • 12
  • 15

6 Answers6

3

This is an example of a set-iwthin-sets query. I advocate solving these with group by and having, because this provides a general framework.

Here is how this works in your case:

select fr.restaurant_id
from features_restaurants fr join
     features f
     on fr.feature_id = f.feature_id
group by fr.restaurant_id
having sum(case when f.feature_name = 'pizza' then 1 else 0 end) > 0 and
       sum(case when f.feature_name = 'delivery' then 1 else 0 end) > 0

Each condition in the having clause is counting for the presence of one of the features -- "pizza" and "delivery". If both features are present, then you get the restaurant_id.

Gordon Linoff
  • 1,242,037
  • 58
  • 646
  • 786
1

This is not "copy and paste" solution but if you consider following steps you will have fast working query.

  • index feature_name column (I'm assuming that column feature_id is indexed on both tables)
  • place each feature_name param in exists():

    select fr.restaurant_id
    from
        features_restaurants fr
    where
        exists(select true from features f where fr.feature_id = f.feature_id and f.feature_name = 'pizza') 
        and
        exists(select true from features f where fr.feature_id = f.feature_id and f.feature_name = 'delivery')
    group by 
        fr.restaurant_id
    
Wojtas
  • 2,254
  • 1
  • 17
  • 9
  • This also isn't very Rails way. As I said, my query already works, I'm just wondering if there is a prettier way. User can select up to 20 features so that query of yours would get ugly and unreadable very fast. – Miha Rekar May 09 '13 at 14:29
  • I agree this isn't Rails way but I think the base - sql isn't efficient and that's the main problem. – Wojtas May 09 '13 at 14:43
1

Maybe you're looking at it backwards?

Maybe try merging the restaurants returned by each feature.

Simplified:

pizza_restaurants = Feature.find_by_name('pizza').restaurants
delivery_restaurants = Feature.find_by_name('delivery').restaurants

pizza_delivery_restaurants = pizza_restaurants & delivery_restaurants

Obviously, this is a single instance solution. But it illustrates the idea.

UPDATE

Here's a dynamic method to pull in all filters without writing SQL (i.e. the "Railsy" way)

def get_restaurants_by_feature_names(features)
  # accepts an array of feature names
  restaurants = Restaurant.all
  features.each do |f|
    feature_restaurants = Feature.find_by_name(f).restaurants
    restaurants = feature_restaurants & restaurants
  end

  return restaurants
end
Kyle Macey
  • 8,074
  • 2
  • 38
  • 78
  • That would work but that would also make many queries and complicate stuff - user can select up to 20 features. – Miha Rekar May 09 '13 at 14:28
  • @MihaRekar Ok, then make a method out of it to make it dynamic. Gimme a minute and I'll put together an example – Kyle Macey May 09 '13 at 14:40
  • What is the point to make it more "railsy" ? Ruby is 100x times slower than any DB, I think - more you do in DB the better, only you could try to simplify the sql. Don't get me wrong - I like activerecord's lazy where().joins().select() - but the above `Restaurant.all` and then `Feature.find` is a `N+1` problem. – Gacha May 10 '13 at 08:17
  • OP asked for a more Rails-like solution. Which typically means relying on AR. I'm sure there's a better answer than mine, for sure. – Kyle Macey May 10 '13 at 13:29
1

How much data is in your features table? Is it just a table of ids and names?

If so, and you're willing to do a little denormalization, you can do this much more easily by encoding the features as a text array on restaurant.

With this scheme your queries boil down to

select * from restaurants where restaurants.features @> ARRAY['pizza', 'delivery']

If you want to maintain your features table because it contains useful data, you can store the array of feature ids on the restaurant and do a query like this:

select * from restaurants where restaurants.feature_ids @> ARRAY[5, 17]

If you don't know the ids up front, and want it all in one query, you should be able to do something along these lines:

select * from restaurants where restaurants.feature_ids @> (
  select id from features where name in ('pizza', 'delivery')
) as matched_features

That last query might need some more consideration...

Anyways, I've actually got a pretty detailed article written up about Tagging in Postgres and ActiveRecord if you want some more details.

Adam Sanderson
  • 470
  • 5
  • 7
  • Thanks. I've been looking into this array stuff ever since RailsConf demo you gave. Now I just have to figure out how to migrate everything :) – Miha Rekar May 15 '13 at 08:53
  • The migration could be pretty easy. Alter the restaurants table and add an array column. Next update with a query roughly like `update restaurants set features = features_agg from (select restaurant_id, array_agg(name) as features_agg from restaurants_features left join features on features.id = restaurants_features.feature_id group by restaurant_id)` Or at least something like that. Sorry if the formatting is odd, not sure how much you can do in a comment. – Adam Sanderson May 15 '13 at 14:21
  • Rails 4 rc1 didn't work with array migrations so now that rc2 came out I tried this and it works perfectly. And SQL is oh so much more readable :P Tnx again! – Miha Rekar Jun 12 '13 at 05:21
0

Since its an AND condition (the OR conditions get dicey with AREL). I reread your stated problem and ignoring the SQL. I think this is what you want.

# in Restaurant
has_many :features

# in Feature
has_many :restaurants

# this is a contrived example. you may be doing something like 
# where(name: 'pizza'). I'm just making this condition up. You
# could also make this more DRY by just passing in the name if 
# that's what you're doing. 

def self.pizza
  where(pizza: true) 
end

def self.delivery
  where(delivery: true)  
end 

# query 
Restaurant.features.pizza.delivery

Basically you call the association with ".features" and then you use the self methods defined on features. Hopefully I didn't misunderstand the original problem.

Cheers!

engineerDave
  • 3,887
  • 26
  • 28
  • That would work but that would also make many queries and complicate stuff - user can select up to 20 features. – Miha Rekar May 09 '13 at 14:28
  • ActiveRecord's AREL should do a lazy query. – engineerDave May 09 '13 at 22:12
  • That's a four word query as opposed to a 7 line SQL statement that'll be hard to manager. Complexity is relative. IMO I think you should be able to use .explain to see the query http://weblog.rubyonrails.org/2011/12/6/what-s-new-in-edge-rails-explain/ Also you can use an .include(:features) do limit the repetition. Although this will bloat your memory. Or you could use AR's "find_in_batches" bottom line is don't premature optimize as that's the root of all programming evil – engineerDave May 09 '13 at 22:14
0
Restaurant
  .joins(:features)
  .where(features: {name: ['pizza','delivery']})
  .group(:id)
  .having('count(features.name) = ?', 2)

This seems to work for me. I tried it with SQLite though.

j03w
  • 3,679
  • 1
  • 21
  • 15
  • That wouldn't select a restaurant with lets say pizza, delivery and terrace since it has 3 features. – Miha Rekar May 09 '13 at 14:32
  • substitute `['pizza','delivery']` with your `params[:features]` and 2 with `params[:features].count` – j03w May 09 '13 at 14:33
  • Yeah I know, but as I said, that will not select restaurants that have all the features requested and more. – Miha Rekar May 09 '13 at 14:41