3

I have two models: Author and Book, where

class Book< ApplicationRecord
 belongs_to :author
end
class Author< ApplicationRecord
  has_many :books
 end

Author has a name:string

Book has title:string and author_id as index

I'm interested in finding all authors that have books that contain some given strings

eg if the terms are ['wizard', 'one hundred', 'two cities'] I want to return the authors whose books contain the terms in the title ( one book title containing 'wizard' AND one book containing 'one hundred' AND...)

I could go with something like

class Post
 scope :including_all_title, -> (titles) { where(matching_tag_query(titles, 'AND')) }

 private

  def matching_tag_query(titles, condition_separator = 'AND')
    titles.map { |t| "(title LIKE '%#{t}%')" }.join(" #{condition_separator} ")
 end
 end

( Author.joins(:books).merge(Books.including_all_title... ) but doesn't seem great as the number of terms increases.

I've seen responses that contain Postgres's '@> ' ( eg Postgres: check if array field contains value?)

but so far I couldn't make it work( probably syntax isn't good, I don't get an error but no results )

I'm wondering how could I achieve that with Active Record ( or another solution better than my own)

Edit: I realise I might have been unclear, what I'm trying to do is: If Author(name:'John') has books Book(title: 'The wonderful Wizard of Oz') Book(title: 'Fantastic beasts') Book(title: 'Some other work') Book(title: 'Another book') and I query by ["%wizard%", "%fantastic%"] I would want to find the author whose books , match ALL of the keywords, so in this case John would be a match( because "wizard" matches one book, and "fantastic" matches another, even if some are unmatched- I'm interested in my keywords)

newbietech
  • 31
  • 4
  • If `books.title` isn't json then you can not use `@>`. Otherwise it should work as it says in that answer. – Sebastián Palma Jan 08 '22 at 22:57
  • On the answer I've posted, the field on which @> is used seems to be of character varying ( varchar(200) . – newbietech Jan 08 '22 at 23:13
  • It seems so, but there's no operator @> for strings in Postgres (that I'm aware). Check the docs for strings and json (https://www.postgresql.org/docs/14/functions-string.html, https://www.postgresql.org/docs/14/functions-json.html) – Sebastián Palma Jan 08 '22 at 23:46
  • Thank you, I've seen that with Postgres, a flag like `array: true` can be added on the string column ( title in my case) . Would that be of any help? – newbietech Jan 09 '22 at 00:02
  • That's for declaring your column is of array type. It'd make your title column store an array of strings, but that's not what you probably want here. – Sebastián Palma Jan 09 '22 at 11:13

1 Answers1

1

This should work:

def matching_tag_query(titles, condition_separator = 'AND')
  titles.map { |t| "(title LIKE '%#{t}%')" }.join(" #{condition_separator} ")
end

but it is subject to SQL injection and you can make it cleaner using PostgreSQL's expr op any(array) and expr op all(array) with the ILIKE operator. Something more like this:

scope :including_all_title, -> (titles) { where('title ilike all(array[?])', titles.map { |t| "%#{sanitize_sql_like(t)}%" })
scope :including_any_title, -> (titles) { where('title ilike any(array[?])', titles.map { |t| "%#{sanitize_sql_like(t)}%" })

Should be fine for realistic titles but if someone throws a couple thousand titles at you then it could run into query size limits and it wouldn't find anything anyway.


In response to the comments: If you want to find all the books that match any of the keywords then:

books = Book.where('title like any(array[?])', titles.map { |t| "%#{sanitize_sql_likt(t)}%" })

Then to find the authors:

Author.where(id: books.select(:author_id))

That'll do a subquery like this:

select *
from authors
where id in (select author_id from books where ...)

which won't be as efficient as it could be but should be good enough. If it is too slow, convert it to a JOIN.

mu is too short
  • 426,620
  • 70
  • 833
  • 800
  • Thank you for the response, I've tried in the console like : `Author.joins(:books).merge(Book.where("title ilike all(array[?])", ["%wizard", "%fantastic%"]))` and even if one of my authors has titles that contain that, it fails to bring him up ( the author also has other entries besides those ) - for one string, it brings results – newbietech Jan 08 '22 at 23:59
  • 1
    (1) Use `all` if you want to match all of them, `any` if you want to match at least one. (2) You'd want `["%wizard%", "%fantastic%"]` rather than `["%wizard", "%fantastic%"]` (note the percents around "wizard"). – mu is too short Jan 09 '22 at 03:14
  • Sorry, my example was wrong: I've used the wildcard operator, like mentioned, as in `Author.joins(:books).merge(Book.where("title ilike all(array[?])", ["%wizard%", "%fantastic%"])) ` but still didn't get the expected result – newbietech Jan 09 '22 at 07:59
  • What is the expected result? Which title are you expecting to find? – mu is too short Jan 09 '22 at 08:44
  • I am expecting Author as result eg if Author(name:'John') has books Book(title: 'The wonderful Wizard of Oz') Book(title: 'Fantastic beasts') Book(title: 'Some other work') and I query by ["%wizard%", "%fantastic%"] I would want to get the Author with name John as result ( or more authors if they also match the condition) – newbietech Jan 09 '22 at 08:54
  • Have a look at my update. – mu is too short Jan 09 '22 at 18:28
  • Thank you for the post- currently it still doesn't bring the desired outcome(it brings authors that have just one of them) and I'm sorry I was unclear (I've updated the question on top ) I would want to find the author whose books match **ALL** of the keywords, so in this case John would be a match( because "wizard" matches one book, and "fantastic" matches another, even if some are unmatched- I'm interested in my keywords) – newbietech Jan 09 '22 at 19:33