From e1b17e05bebfbe5e9fc81287e5887a9fb08faee4 Mon Sep 17 00:00:00 2001 From: Matchu Date: Fri, 28 Jul 2023 14:45:10 -0700 Subject: [PATCH] Add fits and not_fits back to item search Some fun stuff here to figure out how to API this out well, but I'm pretty pleased with where it ended up! --- app/models/color.rb | 11 +++++++++ app/models/item.rb | 35 +++++++++++++++++++++------ app/models/item/search/query.rb | 42 ++++++++++++++++++++++++++++++--- app/models/pet_type.rb | 6 +++++ app/models/species.rb | 12 ++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) diff --git a/app/models/color.rb b/app/models/color.rb index 4fe6aab4..0732929e 100644 --- a/app/models/color.rb +++ b/app/models/color.rb @@ -6,6 +6,11 @@ class Color < ActiveRecord::Base scope :standard, -> { where(:standard => true) } scope :nonstandard, -> { where(:standard => false) } scope :funny, -> { order(:prank) unless pranks_funny? } + scope :matching_name, ->(name, locale = I18n.locale) { + ct = Color::Translation.arel_table + joins(:translations).where(ct[:locale].eq(locale)). + where(ct[:name].matches(sanitize_sql_like(name))) + } validates :name, presence: true @@ -33,4 +38,10 @@ class Color < ActiveRecord::Base now = Time.now.in_time_zone('Pacific Time (US & Canada)') now.month == 4 && now.day == 1 end + + # TODO: Copied from modern Rails source, can delete once we're there! + def self.sanitize_sql_like(string, escape_character = "\\") + pattern = Regexp.union(escape_character, "%", "_") + string.gsub(pattern) { |x| [escape_character, x].join } + end end diff --git a/app/models/item.rb b/app/models/item.rb index 2bbd918d..dbca9405 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -48,12 +48,12 @@ class Item < ActiveRecord::Base scope :name_includes, ->(value, locale = I18n.locale) { it = Item::Translation.arel_table Item.joins(:translations).where(it[:locale].eq(locale)). - where(it[:name].matches('%' + Item.sanitize_sql_like(value) + '%')) + where(it[:name].matches('%' + sanitize_sql_like(value) + '%')) } scope :name_excludes, ->(value, locale = I18n.locale) { it = Item::Translation.arel_table Item.joins(:translations).where(it[:locale].eq(locale)). - where(it[:name].matches('%' + Item.sanitize_sql_like(value) + '%').not) + where(it[:name].matches('%' + sanitize_sql_like(value) + '%').not) } scope :is_nc, -> { i = Item.arel_table @@ -67,23 +67,20 @@ class Item < ActiveRecord::Base it = Item::Translation.arel_table joins(:translations).where(it[:locale].eq('en')). where('description LIKE ?', - '%' + Item.sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') + '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') } scope :is_not_pb, -> { it = Item::Translation.arel_table joins(:translations).where(it[:locale].eq('en')). where('description NOT LIKE ?', - '%' + Item.sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') + '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') } scope :occupies, ->(zone_label, locale = I18n.locale) { zone_ids = Zone.matching_label(zone_label, locale).map(&:id) - i = Item.arel_table sa = SwfAsset.arel_table joins(:swf_assets).where(sa[:zone_id].in(zone_ids)).distinct } scope :not_occupies, ->(zone_label, locale = I18n.locale) { - # TODO: This is pretty slow! But I imagine it's uncommon so that's probably - # fine in practice? Querying for "has NO records matching X" is hard! zone_ids = Zone.matching_label(zone_label, locale).map(&:id) i = Item.arel_table sa = SwfAsset.arel_table @@ -107,6 +104,30 @@ class Item < ActiveRecord::Base condition = zone_ids.map { '(SUBSTR(zones_restrict, ?, 1) = "1")' }.join(' OR ') where("NOT (#{condition})", *zone_ids) } + scope :fits, ->(body_id) { + sa = SwfAsset.arel_table + joins(:swf_assets).where(sa[:body_id].eq(body_id)).distinct + } + scope :not_fits, ->(body_id) { + i = Item.arel_table + sa = SwfAsset.arel_table + # Querying for "has NO swf_assets matching these body IDs" is trickier than + # the positive case! To do it, we GROUP_CONCAT the body_ids together for + # each item, then use FIND_IN_SET to search the result for the body ID, + # and assert that it must not find a match. (This is uhh, not exactly fast, + # so it helps to have other tighter conditions applied first!) + # + # TODO: I feel like this could also be solved with a LEFT JOIN, idk if that + # performs any better? In Rails 5+ `left_outer_joins` is built in so! + # + # NOTE: The `fits` and `not_fits` counts don't perfectly add up to the + # total number of items, 5 items aren't accounted for? I'm not going to + # bother looking into this, but one thing I notice is items with no assets + # somehow would not match either scope in this impl (but LEFT JOIN would!) + joins(:swf_assets).group(i[:id]). + having('FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0', body_id). + distinct + } def closeted? @owned || @wanted diff --git a/app/models/item/search/query.rb b/app/models/item/search/query.rb index 849b6a34..66839850 100644 --- a/app/models/item/search/query.rb +++ b/app/models/item/search/query.rb @@ -46,6 +46,18 @@ class Item filters << (is_positive ? Filter.restricts(value, locale) : Filter.not_restricts(value, locale)) + when 'fits' + color_name, species_name = value.split('-') + begin + pet_type = PetType.matching_name(color_name, species_name, locale).first! + rescue ActiveRecord::RecordNotFound + message = I18n.translate('items.search.errors.not_found.pet_type', + name1: color_name.capitalize, name2: species_name.capitalize) + raise Item::Search::Error, message + end + filters << (is_positive ? + Filter.fits(pet_type.body_id, color_name, species_name) : + Filter.not_fits(pet_type.body_id, color_name, species_name)) when 'is' case value when 'nc' @@ -82,9 +94,9 @@ class Item # A Filter is basically a wrapper for an Item scope, with extra info about # how to convert it into a search query string. class Filter - def initialize(query, text) + def initialize(query, text_fn) @query = query - @text = text + @text_fn = text_fn end def to_query @@ -92,7 +104,7 @@ class Item end def to_s - @text + @text_fn.call end def inspect @@ -125,6 +137,20 @@ class Item self.new Item.not_restricts(value, locale), "-restricts:#{value}" end + def self.fits(body_id, color_name, species_name) + # NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! + value = "#{color_name.downcase}-#{species_name.downcase}" + value = '"' + value + '"' if value.include? ' ' + self.new Item.fits(body_id), "fits:#{value}" + end + + def self.not_fits(body_id, color_name, species_name) + # NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! + value = "#{color_name.downcase}-#{species_name.downcase}" + value = '"' + value + '"' if value.include? ' ' + self.new Item.not_fits(body_id), "-fits:#{value}" + end + def self.is_nc self.new Item.is_nc, 'is:nc' end @@ -148,6 +174,16 @@ class Item def self.is_not_pb self.new Item.is_not_pb, '-is:pb' end + + private + + def self.build_fits_filter_text(color_name, species_name) + # NOTE: Colors like "Polka Dot" must be written as + # `fits:"polka dot-aisha"`. + value = "#{color_name.downcase}-#{species_name.downcase}" + value = '"' + value + '"' if value.include? ' ' + "fits:#{value}" + end end end end diff --git a/app/models/pet_type.rb b/app/models/pet_type.rb index 8eaf530f..459fa261 100644 --- a/app/models/pet_type.rb +++ b/app/models/pet_type.rb @@ -21,6 +21,12 @@ class PetType < ActiveRecord::Base scope :includes_child_translations, -> { includes({:color => :translations, :species => :translations}) } + + scope :matching_name, ->(color_name, species_name, locale = I18n.locale) { + color = Color.matching_name(color_name, locale).first! + species = Species.matching_name(species_name, locale).first! + where(color_id: color.id, species_id: species.id) + } def self.special_color_or_basic(special_color) color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id) diff --git a/app/models/species.rb b/app/models/species.rb index 4b827c44..35092aed 100644 --- a/app/models/species.rb +++ b/app/models/species.rb @@ -2,6 +2,12 @@ class Species < ActiveRecord::Base translates :name scope :alphabetical, -> { with_translations(I18n.locale).order(Species::Translation.arel_table[:name]) } + + scope :matching_name, ->(name, locale = I18n.locale) { + st = Species::Translation.arel_table + joins(:translations).where(st[:locale].eq(locale)). + where(st[:name].matches(sanitize_sql_like(name))) + } def as_json(options={}) {:id => id, :name => human_name} @@ -14,4 +20,10 @@ class Species < ActiveRecord::Base I18n.translate('species.default_human_name') end end + + # TODO: Copied from modern Rails source, can delete once we're there! + def self.sanitize_sql_like(string, escape_character = "\\") + pattern = Regexp.union(escape_character, "%", "_") + string.gsub(pattern) { |x| [escape_character, x].join } + end end