1
0
Fork 0
forked from OpenNeo/impress

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!
This commit is contained in:
Matchu 2023-07-28 14:45:10 -07:00
parent 4ac1304c74
commit 070599e2ca
5 changed files with 96 additions and 10 deletions

View file

@ -6,6 +6,11 @@ class Color < ActiveRecord::Base
scope :standard, -> { where(:standard => true) } scope :standard, -> { where(:standard => true) }
scope :nonstandard, -> { where(:standard => false) } scope :nonstandard, -> { where(:standard => false) }
scope :funny, -> { order(:prank) unless pranks_funny? } 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 validates :name, presence: true
@ -33,4 +38,10 @@ class Color < ActiveRecord::Base
now = Time.now.in_time_zone('Pacific Time (US & Canada)') now = Time.now.in_time_zone('Pacific Time (US & Canada)')
now.month == 4 && now.day == 1 now.month == 4 && now.day == 1
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 end

View file

@ -48,12 +48,12 @@ class Item < ActiveRecord::Base
scope :name_includes, ->(value, locale = I18n.locale) { scope :name_includes, ->(value, locale = I18n.locale) {
it = Item::Translation.arel_table it = Item::Translation.arel_table
Item.joins(:translations).where(it[:locale].eq(locale)). 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) { scope :name_excludes, ->(value, locale = I18n.locale) {
it = Item::Translation.arel_table it = Item::Translation.arel_table
Item.joins(:translations).where(it[:locale].eq(locale)). 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, -> { scope :is_nc, -> {
i = Item.arel_table i = Item.arel_table
@ -67,23 +67,20 @@ class Item < ActiveRecord::Base
it = Item::Translation.arel_table it = Item::Translation.arel_table
joins(:translations).where(it[:locale].eq('en')). joins(:translations).where(it[:locale].eq('en')).
where('description LIKE ?', where('description LIKE ?',
'%' + Item.sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
} }
scope :is_not_pb, -> { scope :is_not_pb, -> {
it = Item::Translation.arel_table it = Item::Translation.arel_table
joins(:translations).where(it[:locale].eq('en')). joins(:translations).where(it[:locale].eq('en')).
where('description NOT LIKE ?', where('description NOT LIKE ?',
'%' + Item.sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
} }
scope :occupies, ->(zone_label, locale = I18n.locale) { scope :occupies, ->(zone_label, locale = I18n.locale) {
zone_ids = Zone.matching_label(zone_label, locale).map(&:id) zone_ids = Zone.matching_label(zone_label, locale).map(&:id)
i = Item.arel_table
sa = SwfAsset.arel_table sa = SwfAsset.arel_table
joins(:swf_assets).where(sa[:zone_id].in(zone_ids)).distinct joins(:swf_assets).where(sa[:zone_id].in(zone_ids)).distinct
} }
scope :not_occupies, ->(zone_label, locale = I18n.locale) { 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) zone_ids = Zone.matching_label(zone_label, locale).map(&:id)
i = Item.arel_table i = Item.arel_table
sa = SwfAsset.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 ') condition = zone_ids.map { '(SUBSTR(zones_restrict, ?, 1) = "1")' }.join(' OR ')
where("NOT (#{condition})", *zone_ids) 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? def closeted?
@owned || @wanted @owned || @wanted

View file

@ -46,6 +46,18 @@ class Item
filters << (is_positive ? filters << (is_positive ?
Filter.restricts(value, locale) : Filter.restricts(value, locale) :
Filter.not_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' when 'is'
case value case value
when 'nc' when 'nc'
@ -82,9 +94,9 @@ class Item
# A Filter is basically a wrapper for an Item scope, with extra info about # A Filter is basically a wrapper for an Item scope, with extra info about
# how to convert it into a search query string. # how to convert it into a search query string.
class Filter class Filter
def initialize(query, text) def initialize(query, text_fn)
@query = query @query = query
@text = text @text_fn = text_fn
end end
def to_query def to_query
@ -92,7 +104,7 @@ class Item
end end
def to_s def to_s
@text @text_fn.call
end end
def inspect def inspect
@ -125,6 +137,20 @@ class Item
self.new Item.not_restricts(value, locale), "-restricts:#{value}" self.new Item.not_restricts(value, locale), "-restricts:#{value}"
end 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 def self.is_nc
self.new Item.is_nc, 'is:nc' self.new Item.is_nc, 'is:nc'
end end
@ -148,6 +174,16 @@ class Item
def self.is_not_pb def self.is_not_pb
self.new Item.is_not_pb, '-is:pb' self.new Item.is_not_pb, '-is:pb'
end 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 end
end end

View file

@ -21,6 +21,12 @@ class PetType < ActiveRecord::Base
scope :includes_child_translations, scope :includes_child_translations,
-> { includes({:color => :translations, :species => :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) def self.special_color_or_basic(special_color)
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id) color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)

View file

@ -2,6 +2,12 @@ class Species < ActiveRecord::Base
translates :name translates :name
scope :alphabetical, -> { with_translations(I18n.locale).order(Species::Translation.arel_table[: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={}) def as_json(options={})
{:id => id, :name => human_name} {:id => id, :name => human_name}
@ -14,4 +20,10 @@ class Species < ActiveRecord::Base
I18n.translate('species.default_human_name') I18n.translate('species.default_human_name')
end end
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 end