diff --git a/app/models/printing.rb b/app/models/printing.rb index 1a0bb56b..895d5e50 100644 --- a/app/models/printing.rb +++ b/app/models/printing.rb @@ -8,4 +8,8 @@ class Printing < ApplicationRecord has_one :side, :through => :card has_many :illustrator_printings has_many :illustrators, :through => :illustrator_printings + + has_many :unified_restrictions, primary_key: :card_id, foreign_key: :card_id + has_many :card_pool_cards, primary_key: :card_id, foreign_key: :card_id + has_many :card_pools, :through => :card_pool_cards end diff --git a/app/resources/api/v3/public/printing_resource.rb b/app/resources/api/v3/public/printing_resource.rb index 4a201071..f9ae857b 100644 --- a/app/resources/api/v3/public/printing_resource.rb +++ b/app/resources/api/v3/public/printing_resource.rb @@ -83,7 +83,17 @@ class Api::V3::Public::PrintingResource < JSONAPI::Resource filter :is_unique, apply: ->(records, value, _options){ records.joins(:card).where('cards.is_unique= ?', value) } - + filter :search, apply: ->(records, value, _options) { + query_builder = PrintingSearchQueryBuilder.new(value[0]) + if query_builder.parse_error.nil? + records.left_joins(query_builder.left_joins) + .where(query_builder.where, *query_builder.where_values) + else + raise JSONAPI::Exceptions::BadRequest.new( + 'Invalid search query: [%s] / %s' % [value[0], query_builder.parse_error]) + end + } + # Images will return a nested map for different types of images. # 'nrdb_classic' represents the JPEGs used for classic netrunnerdb.com. # We will likely add other formats like png and webp, as well as various sizes, diff --git a/lib/card_search_parser.rb b/lib/card_search_parser.rb index d3f573ec..112df801 100644 --- a/lib/card_search_parser.rb +++ b/lib/card_search_parser.rb @@ -21,15 +21,12 @@ class CardSearchParser < Parslet::Parser str('advancement_cost') | str('agenda_points') | str('base_link') | - str('card_cycle') | str('card_pool') | - str('card_set') | str('card_subtype') | str('card_type') | str('cost') | str('eternal_points') | str('faction') | - str('flavor_text') | str('format') | str('global_penalty') | str('illustrator') | @@ -38,8 +35,6 @@ class CardSearchParser < Parslet::Parser str('is_restricted') | str('is_unique') | str('memory_usage') | - str('quantity_in_card_set') | - str('release_date') | str('restriction_id') | str('side') | str('strength') | diff --git a/lib/card_search_query_builder.rb b/lib/card_search_query_builder.rb index 42203993..df43950d 100644 --- a/lib/card_search_query_builder.rb +++ b/lib/card_search_query_builder.rb @@ -60,22 +60,6 @@ class CardSearchQueryBuilder } @@term_to_field_map = { # format should implicitly use the currently active card pool and restriction lists unless another is specified. - # 'format' => '', - # 'restriction' => '', - - # printing? or minimum release date from printing for the card? Add release date to the card? 'r' => 'release_date', - # printing 'a' => 'flavor', - # printing 'c' => 'card_cycle_id', - # printing 'card_cycle' => 'card_cycle_id'', - # printing 'card_set' => 'card_set_id'', - # printing 'e' => 'card_set_id', - # printing 'i' => 'illustrator', - # printing 'quantity_in_card_set' => ''', - # printing 'release_date' => ''', - # printing flavor 'flavor_text' => ''', - # printing illustrator 'illustrator' => ''', - # printing quantity 'y' => ''', - '_' => 'cards.stripped_title', 'advancement_cost' => 'cards.advancement_requirement', 'agenda_points' => 'cards.agenda_points', @@ -88,6 +72,7 @@ class CardSearchQueryBuilder 'eternal_points' => 'unified_restrictions.eternal_points', 'f' => 'cards.faction_id', 'faction' => 'cards.faction_id', + 'format' => 'unified_restrictions.format_id', 'g' => 'cards.advancement_requirement', 'global_penalty' => 'unified_restrictions.global_penalty', 'h' => 'cards.trash_cost', @@ -115,11 +100,23 @@ class CardSearchQueryBuilder 'x' => 'cards.stripped_text', } + @@term_to_left_join_map = { + 'card_pool' => :card_pool_cards, + 'card_subtype' => :card_subtypes, + 'eternal_points' => :unified_restrictions, + 'global_penalty' => :unified_restrictions, + 'is_banned' => :unified_restrictions, + 'is_restricted' => :unified_restrictions, + 'restriction_id' => :unified_restrictions, + 's' => :card_subtypes, + 'universal_faction_cost' => :unified_restrictions, + } + def initialize(query) @query = query @parse_error = nil @parse_tree = nil - @left_joins = [] + @left_joins = Set.new @where = '' @where_values = [] begin @@ -152,9 +149,6 @@ def initialize(query) @parse_error = 'Invalid boolean operator "%s"' % match_type return end - if ['is_banned', 'is_restricted'].include?(keyword) - @left_joins << :unified_restrictions - end constraints << '%s %s ?' % [@@term_to_field_map[keyword], operator] where << value elsif @@numeric_keywords.include?(keyword) @@ -163,10 +157,7 @@ def initialize(query) return end operator = '' - if ['eternal_points', 'global_penalty', 'universal_faction_cost'].include?(keyword) - @left_joins << :unified_restrictions - end - if @@numeric_operators.include?(match_type) + if @@numeric_operators.include?(match_type) operator = @@numeric_operators[match_type] else @parse_error = 'Invalid numeric operator "%s"' % match_type @@ -184,15 +175,13 @@ def initialize(query) @parse_error = 'Invalid string operator "%s"' % match_type return end - if ['s', 'card_subtype'].include?(keyword) - @left_joins << :card_subtypes - elsif keyword == 'card_pool' - @left_joins << :card_pool_cards - end - constraints << 'lower(%s) %s ?' % [@@term_to_field_map[keyword], operator] + constraints << 'lower(%s) %s ?' % [@@term_to_field_map[keyword], operator] where << '%%%s%%' % value end - end + if @@term_to_left_join_map.include?(keyword) + @left_joins << @@term_to_left_join_map[keyword] + end + end # bare/quoted words in the query are automatically mapped to stripped_title if f.include?(:string) @@ -216,6 +205,6 @@ def where_values return @where_values end def left_joins - return @left_joins + return @left_joins.to_a end end diff --git a/lib/printing_search_parser.rb b/lib/printing_search_parser.rb new file mode 100644 index 00000000..8da88d95 --- /dev/null +++ b/lib/printing_search_parser.rb @@ -0,0 +1,60 @@ +require 'parslet' + +# TODO(plural): Add support for | in : and ! operators . +class PrintingSearchParser < Parslet::Parser + rule(:spaces) { match('\s').repeat(1) } + rule(:spaces?) { spaces.maybe } + rule(:bare_string) { + match('[!\w-]').repeat(1).as(:string) + } + rule(:quoted_string) { + str('"') >> ( + str('"').absent? >> any + ).repeat.as(:string) >> str('"') + } + rule(:string) { + spaces? >> (bare_string | quoted_string) >> spaces? + } + # Note that while this list should generally be kept sorted, an entry that is a prefix of + # a later entry will clobber the later entries and throw an error parsing text with the later entries. + rule(:keyword) { + str('advancement_cost') | + str('agenda_points') | + str('base_link') | + str('card_cycle') | + str('card_pool') | + str('card_set') | + str('card_subtype') | + str('card_type') | + str('cost') | + str('eternal_points') | + str('faction') | + str('flavor') | + str('format') | + str('global_penalty') | + str('illustrator') | + str('influence_cost') | + str('is_banned') | + str('is_restricted') | + str('is_unique') | + str('memory_usage') | + str('quantity') | + str('release_date') | + str('restriction_id') | + str('side') | + str('strength') | + str('text') | + str('title') | + str('trash_cost') | + str('universal_faction_cost') | + # Single letter 'short codes' + match('[_abcdefghilmnoprstuvxyz]') + } + rule(:match_type) { str('<=') | str('>=') | match('[:!<>]') } + rule(:operator) { keyword >> match_type} + rule(:search_term) { keyword.as(:keyword) >> match_type.as(:match_type) >> (string).as(:value) } + rule(:query) { + (spaces? >> (search_term.as(:search_term) | string) >> spaces?).repeat.as(:fragments) + } + root :query +end diff --git a/lib/printing_search_query_builder.rb b/lib/printing_search_query_builder.rb new file mode 100644 index 00000000..9e5fef40 --- /dev/null +++ b/lib/printing_search_query_builder.rb @@ -0,0 +1,288 @@ +class PrintingSearchQueryBuilder + @@parser = PrintingSearchParser.new + @@boolean_keywords = [ + 'b', + 'banlist', + 'is_banned', + 'is_restricted', + 'is_unique', + 'u', + ] + @@date_keywords = [ + 'r', + 'release_date' + ] + @@numeric_keywords = [ + 'advancement_cost', + 'agenda_points', + 'base_link', + 'cost', + 'eternal_points', + 'g', + 'global_penalty', + 'h', + 'influence_cost', + 'l', + 'm', + 'memory_usage', + 'n', + 'o', + 'p', + 'quantity', + 'strength', + 'trash_cost', + 'universal_faction_cost', + 'v', + 'y', + ] + @@string_keywords = [ + '_', + 'card_type', + 'd', + 'f', + 'faction', + 'flavor', + 'i', + 'illustrator', + 'r', + 'release_date', + 'restriction_id', + 'side', + 't', + 'text', + 'title', + 'x', + ] + @@boolean_operators = { + ':' => '=', + '!' => '!=', + } + @@date_operators = { + ':' => '=', + '!' => '!=', + '<' => '<', + '<=' => '<=', + '>' => '>', + '>=' => '>=' + } + @@numeric_operators = { + ':' => '=', + '!' => '!=', + '<' => '<', + '<=' => '<=', + '>' => '>', + '>=' => '>=' + } + @@string_operators = { + ':' => 'LIKE', + '!' => 'NOT LIKE', + } + @@term_to_field_map = { + '_' => 'cards.stripped_title', + 'a' => 'printings.flavor', + 'advancement_cost' => 'cards.advancement_requirement', + 'agenda_points' => 'cards.agenda_points', + 'base_link' => 'cards.base_link', + 'c' => 'card_sets.card_cycle_id', + 'card_cycle' => 'card_sets.card_cycle_id', + 'card_pool' => 'card_pools_cards.card_pool_id', + 'card_set' => 'printings.card_set_id', + 'card_subtype' => 'card_subtypes.name', + 'card_type' => 'cards.card_type_id', + 'cost' => 'cards.cost', + 'd' => 'cards.side_id', + 'e' => 'printings.card_set_id', + 'eternal_points' => 'unified_restrictions.eternal_points', + 'f' => 'cards.faction_id', + 'faction' => 'cards.faction_id', + 'flavor' => 'printings.flavor', + 'format' => 'unified_restrictions.format_id', + 'g' => 'cards.advancement_requirement', + 'global_penalty' => 'unified_restrictions.global_penalty', + 'h' => 'cards.trash_cost', + 'i' => 'illustrators.name', + 'illustrator' => 'illustrators.name', + 'influence_cost' => 'cards.influence_cost', + 'is_banned' => 'unified_restrictions.is_banned', + 'is_restricted' => 'unified_restrictions.is_restricted', + 'is_unique' => 'cards.is_unique', + 'l' => 'cards.base_link', + 'm' => 'cards.memory_cost', + 'memory_usage' => 'cards.memory_cost', + 'n' => 'cards.influence_cost', + 'o' => 'cards.cost', + 'p' => 'cards.strength', + 'quantity' => 'printings.quantity', + 'r' => 'printings.date_release', + 'release_date' => 'printings.date_release', + 'restriction_id' => 'unified_restrictions.restriction_id', + 's' => 'card_subtypes.name', + 'side' => 'cards.card_side_id', + 'strength' => 'cards.strength', + 't' => 'cards.card_type_id', + 'text' => 'cards.stripped_text', + 'title' => 'cards.stripped_title', + 'trash_cost' => 'cards.trash_cost', + 'u' => 'cards.is_unique', + 'universal_faction_cost' => 'unified_restrictions.universal_faction_cost', + 'v' => 'cards.agenda_points', + 'x' => 'cards.stripped_text', + 'y' => 'printings.quantity', + } + + # TODO(plural): Unify more of this with card_search_query_builder. + @@term_to_left_join_map = { + '_' => :card, + 'advancement_cost' => :card, + 'agenda_points' => :card, + 'base_link' => :card, + 'c' => :card_set, + 'card_cycle' => :card_set, + 'card_pool' => :card_pool_cards, + 'card_subtype' => :card_subtypes, + 'card_type' => :card, + 'cost' => :card, + 'd' => :card, + 'eternal_points' => :unified_restrictions, + 'f' => :card, + 'faction' => :card, + 'format' => :unified_restrictions, + 'g' => :card, + 'global_penalty' => :unified_restrictions, + 'h' => :card, + 'i' => :illustrators, + 'illustrator' => :illustrators, + 'influence_cost' => :card, + 'is_banned' => :unified_restrictions, + 'is_restricted' => :unified_restrictions, + 'is_unique' => :card, + 'l' => :card, + 'm' => :card, + 'memory_usage' => :card, + 'n' => :card, + 'o' => :card, + 'p' => :card, + 'restriction_id' => :unified_restrictions, + 's' => :card_subtypes, + 'side' => :card, + 'strength' => :card, + 't' => :card, + 'text' => :card, + 'title' => :card, + 'trash_cost' => :card, + 'u' => :card, + 'universal_faction_cost' => :unified_restrictions, + 'v' => :card, + 'x' => :card, + } + + def initialize(query) + @query = query + @parse_error = nil + @parse_tree = nil + @left_joins = Set.new + @where = '' + @where_values = [] + begin + @parse_tree = @@parser.parse(@query) + rescue Parslet::ParseFailed => e + @parse_error = e + end + if @parse_error != nil + return + end + constraints = [] + where = [] + # TODO(plural): build in explicit support for requirements + # {is_banned,is_restricted,eternal_points,global_penalty,universal_faction_cost} all require restriction_id, would be good to have card_pool_id as well. + # TODO(plural): build in explicit support for smart defaults, like restriction_id should imply is_banned = false. card_pool_id should imply the latest restriction list. + @parse_tree[:fragments].each {|f| + if f.include?(:search_term) + keyword = f[:search_term][:keyword].to_s + match_type = f[:search_term][:match_type].to_s + value = f[:search_term][:value][:string].to_s.downcase + if @@boolean_keywords.include?(keyword) + if !['true', 'false', 't', 'f', '1', '0'].include?(value) + @parse_error = 'Invalid value "%s" for boolean field "%s"' % [value, keyword] + return + end + operator = '' + if @@boolean_operators.include?(match_type) + operator = @@boolean_operators[match_type] + else + @parse_error = 'Invalid boolean operator "%s"' % match_type + return + end + constraints << '%s %s ?' % [@@term_to_field_map[keyword], operator] + where << value + elsif @@date_keywords.include?(keyword) + if !value.match?(/\A(\d{4}-\d{2}-\d{2}|\d{8})\Z/) + @parse_error = 'Invalid value "%s" for date field "%s" - only YYYY-MM-DD or YYYYMMDD are supported.' % [value, keyword] + return + end + operator = '' + if @@date_operators.include?(match_type) + operator = @@date_operators[match_type] + else + @parse_error = 'Invalid numeric operator "%s"' % match_type + return + end + constraints << '%s %s ?' % [@@term_to_field_map[keyword], operator] + where << value + elsif @@numeric_keywords.include?(keyword) + if !value.match?(/\A\d+\Z/) + @parse_error = 'Invalid value "%s" for integer field "%s"' % [value, keyword] + return + end + operator = '' + if @@numeric_operators.include?(match_type) + operator = @@numeric_operators[match_type] + else + @parse_error = 'Invalid numeric operator "%s"' % match_type + return + end + constraints << '%s %s ?' % [@@term_to_field_map[keyword], operator] + where << value + else + # String fields only support : and !, resolving to to {,NOT} LIKE %value%. + # TODO(plural): consider ~ for regex matches. + operator = '' + if @@string_operators.include?(match_type) + operator = @@string_operators[match_type] + else + @parse_error = 'Invalid string operator "%s"' % match_type + return + end + constraints << 'lower(%s) %s ?' % [@@term_to_field_map[keyword], operator] + where << '%%%s%%' % value + end + if @@term_to_left_join_map.include?(keyword) + @left_joins << @@term_to_left_join_map[keyword] + end + end + + # bare/quoted words in the query are automatically mapped to stripped_title + if f.include?(:string) + value = f[:string].to_s.downcase + operator = value.start_with?('!') ? 'NOT LIKE' : 'LIKE' + value = value.start_with?('!') ? value[1..] : value + constraints << 'lower(cards.stripped_title) %s ?' % operator + where << '%%%s%%' % value + end + } + @where = constraints.join(' AND ') + @where_values = where + end + def parse_error + return @parse_error + end + def where + return @where + end + def where_values + return @where_values + end + def left_joins + return @left_joins.to_a + end +end diff --git a/test/unit/printing_search_parser_test.rb b/test/unit/printing_search_parser_test.rb new file mode 100644 index 00000000..b75476f7 --- /dev/null +++ b/test/unit/printing_search_parser_test.rb @@ -0,0 +1,169 @@ +require 'minitest/autorun' +require 'parslet/convenience' + +class PrintingSearchParserTest < Minitest::Test + def test_fails_with_non_keyword + input = %Q{w} + parser = PrintingSearchParser.new.keyword + tree = nil + begin + tree = parser.parse(input) + refute(true, 'parser unexpectedly passed') + rescue Parslet::ParseFailed => e + assert tree.nil? + end + end + + def test_parses_a_keyword + input = %Q{t} + parser = PrintingSearchParser.new.keyword + tree = parser.parse_with_debug(input) + refute_equal nil, tree + end + + def test_parses_a_match_type + [':', '!', '>', '<', '<=', '>='].each {|i| + parser = PrintingSearchParser.new.match_type + tree = parser.parse_with_debug(i) + refute_equal nil, tree + } + end + + def test_parses_an_operator + ['a', 'b', 'c'].each {|k| + [':', '!', '>', '<'].each {|i| + parser = PrintingSearchParser.new.operator + tree = parser.parse_with_debug('%s%s' % [k, i]) + refute_equal nil, tree + } + } + end + + def test_parses_a_search_term_with_a_bare_string + input = %Q{f:weyland-consortium} + parser = PrintingSearchParser.new.search_term + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {keyword: "f", match_type: ":", value: {string: "weyland-consortium"}} + assert_equal expected, tree + end + + def test_parses_a_query + input = %Q{f:weyland-consortium t!"operation" n<=1} + parser = PrintingSearchParser.new.query + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [ + {search_term: {keyword: "f", match_type: ":", value: {string: "weyland-consortium"}}}, + {search_term: {keyword: "t", match_type: "!", value: {string: "operation"}}}, + {search_term: {keyword: "n", match_type: "<=", value: {string: "1"}}} + ]} + + assert_equal expected, tree + end + + def test_parses_a_bare_string + input = %Q{hello-world} + parser = PrintingSearchParser.new.bare_string + tree = parser.parse_with_debug(input) + + expected = {string: "hello-world"} + assert_equal expected, tree + end + + def test_parses_a_quoted_string + input = %Q{"hello world"} + parser = PrintingSearchParser.new.quoted_string + tree = parser.parse_with_debug(input) + + expected = {string: "hello world"} + assert_equal expected, tree + end + + def test_root_parses_a_query + input = %Q{ f:weyland-consortium t!"operation" n<=1} + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [ + {search_term: {keyword: "f", match_type: ":", value: {string: "weyland-consortium"}}}, + {search_term: {keyword: "t", match_type: "!", value: {string: "operation"}}}, + {search_term: {keyword: "n", match_type: "<=", value: {string: "1"}}} + ]} + assert_equal expected, tree + end + + def test_root_parses_a_bare_word + input = %Q{ siphon } + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [{ string: "siphon" }]} + assert_equal expected, tree + end + + def test_root_parses_a_quoted_word + input = %Q{ "sure gamble"} + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [{ string: "sure gamble" }]} + assert_equal expected, tree + end + + def test_string + [%Q{ "sure gamble"}, %Q{diversion}].each{ |s| + parser = PrintingSearchParser.new.string + tree = parser.parse_with_debug(s) + refute_equal nil, tree + expected = {string: s.gsub(/["']/, '').strip} + assert_equal expected, tree + } + end + + def test_root_strings + input = %Q{ "sure gamble" diversion } + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [{ string: "sure gamble" }, { string: "diversion" }]} + assert_equal expected, tree + end + + def test_root_parses_a_query_and_some_words + input = %Q{"bean" f:weyland-consortium t!"operation" royalties n<=1 } + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [ + {string: "bean"}, + {search_term: {keyword: "f", match_type: ":", value: {string: "weyland-consortium"}}}, + {search_term: {keyword: "t", match_type: "!", value: {string: "operation"}}}, + {string: "royalties"}, + {search_term: {keyword: "n", match_type: "<=", value: {string: "1"}}} + ]} + assert_equal expected, tree + end + + def test_root_parses_card_set_short + input = %Q{e:midnight_sun} + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [ + {search_term: {keyword: "e", match_type: ":", value: {string: "midnight_sun"}}}, + ]} + assert_equal expected, tree + end + + def test_root_parses_card_set_full + input = %Q{card_set:midnight_sun} + parser = PrintingSearchParser.new + tree = parser.parse_with_debug(input) + refute_equal nil, tree + expected = {fragments: [ + {search_term: {keyword: "card_set", match_type: ":", value: {string: "midnight_sun"}}}, + ]} + assert_equal expected, tree + end +end diff --git a/test/unit/printing_search_query_builder_test.rb b/test/unit/printing_search_query_builder_test.rb new file mode 100644 index 00000000..6605e4d6 --- /dev/null +++ b/test/unit/printing_search_query_builder_test.rb @@ -0,0 +1,296 @@ +require 'minitest/autorun' +require 'parslet/convenience' + +class PrintingSearchQueryBuilderTest < Minitest::Test + def test_simple_successful_query + input = %Q{x:trash} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_text) LIKE ?', builder.where + assert_equal ['%trash%'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_simple_successful_query_with_multiple_terms + input = %Q{x:trash cost:3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_text) LIKE ? AND cards.cost = ?', builder.where + assert_equal ['%trash%', '3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_numeric_field_not_equal + input = %Q{trash_cost!3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'cards.trash_cost != ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_numeric_field_less_than + input = %Q{trash_cost<3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'cards.trash_cost < ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_numeric_field_less_than_equal_to + input = %Q{trash_cost<=3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'cards.trash_cost <= ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_numeric_field_greater_than + input = %Q{trash_cost>3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'cards.trash_cost > ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_numeric_field_greater_than_equal_to + input = %Q{trash_cost>=3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'cards.trash_cost >= ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_string_field_not_like + input = %Q{title!sure} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_title) NOT LIKE ?', builder.where + assert_equal ['%sure%'], builder.where_values + assert_equal [:card], builder.left_joins + end + + def test_boolean_field_bad_operators + bad_operators = ['<', '<=', '>', '>='] + bad_operators.each {|op| + input = 'is_unique%strue' % op + builder = PrintingSearchQueryBuilder.new(input) + + assert_equal 'Invalid boolean operator "%s"' % op, builder.parse_error + assert_equal '', builder.where + assert_equal [], builder.where_values + assert_equal [], builder.left_joins + } + end + + def test_string_field_bad_operators + bad_operators = ['<', '<=', '>', '>='] + bad_operators.each {|op| + input = 'title%ssure' % op + builder = PrintingSearchQueryBuilder.new(input) + + assert_equal 'Invalid string operator "%s"' % op, builder.parse_error + assert_equal '', builder.where + assert_equal [], builder.where_values + assert_equal [], builder.left_joins + } + end + + def test_bare_word + input = %Q{diversion} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_title) LIKE ?', builder.where + assert_equal ['%diversion%'], builder.where_values + assert_equal [], builder.left_joins + end + + def test_bare_word_negated + input = %Q{!diversion} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_title) NOT LIKE ?', builder.where + assert_equal ['%diversion%'], builder.where_values + assert_equal [], builder.left_joins + end + + def test_quoted_string_negated + input = %Q{"!diversion of funds"} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(cards.stripped_title) NOT LIKE ?', builder.where + assert_equal ['%diversion of funds%'], builder.where_values + assert_equal [], builder.left_joins + end + + def test_bad_query_bad_operator + builder = PrintingSearchQueryBuilder.new('w:bleargh') + refute_equal builder.parse_error, nil + end + + def test_is_banned_no_restriction_specified + input = %Q{is_banned:true} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.is_banned = ?', builder.where + assert_equal ['true'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_is_restricted_no_restriction_specified + input = %Q{is_restricted:true} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.is_restricted = ?', builder.where + assert_equal ['true'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_is_banned_restriction_specified + input = %Q{is_banned:true restriction_id:ban_list_foo} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.is_banned = ? AND lower(unified_restrictions.restriction_id) LIKE ?', builder.where + assert_equal ['true', '%ban_list_foo%'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_is_restricted_restriction_specified + input = %Q{is_restricted:true restriction_id:ban_list_foo} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.is_restricted = ? AND lower(unified_restrictions.restriction_id) LIKE ?', builder.where + assert_equal ['true', '%ban_list_foo%'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_eternal_points + input = %Q{eternal_points:3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.eternal_points = ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_global_penalty + input = %Q{global_penalty:3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.global_penalty = ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_universal_faction_cost + input = %Q{universal_faction_cost:3} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'unified_restrictions.universal_faction_cost = ?', builder.where + assert_equal ['3'], builder.where_values + assert_equal [:unified_restrictions], builder.left_joins + end + + def test_card_pool + input = %Q{card_pool:best_pool} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(card_pools_cards.card_pool_id) LIKE ?', builder.where + assert_equal ['%best_pool%'], builder.where_values + assert_equal [:card_pool_cards], builder.left_joins + end + + def test_bad_boolean_value + input = %Q{is_banned:nah} + builder = PrintingSearchQueryBuilder.new(input) + + assert_equal 'Invalid value "nah" for boolean field "is_banned"', builder.parse_error + assert_equal '', builder.where + assert_equal [], builder.where_values + assert_equal [], builder.left_joins + end + + def test_bad_numeric_value + input = %Q{trash_cost:"too damn high"} + builder = PrintingSearchQueryBuilder.new(input) + + assert_equal 'Invalid value "too damn high" for integer field "trash_cost"', builder.parse_error + assert_equal '', builder.where + assert_equal [], builder.where_values + assert_equal [], builder.left_joins + end + + def test_release_date_full + input = %Q{release_date:2022-07-22} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'printings.date_release = ?', builder.where + assert_equal ['2022-07-22'], builder.where_values + assert_equal [], builder.left_joins + end + + def test_release_date_short + input = %Q{r>=20220722} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'printings.date_release >= ?', builder.where + assert_equal ['20220722'], builder.where_values + assert_equal [], builder.left_joins + end + + def test_bad_date_value + input = %Q{release_date:Jul-22-2022} + builder = PrintingSearchQueryBuilder.new(input) + + assert_equal 'Invalid value "jul-22-2022" for date field "release_date" - only YYYY-MM-DD or YYYYMMDD are supported.', builder.parse_error + assert_equal '', builder.where + assert_equal [], builder.where_values + assert_equal [], builder.left_joins + end + + def test_illustrator_full + input = %Q{illustrator:Zeilinger} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(illustrators.name) LIKE ?', builder.where + assert_equal ['%zeilinger%'], builder.where_values + assert_equal [:illustrators], builder.left_joins + end + + def test_illustrator_short + input = %Q{i!Zeilinger} + builder = PrintingSearchQueryBuilder.new(input) + + assert_nil builder.parse_error + assert_equal 'lower(illustrators.name) NOT LIKE ?', builder.where + assert_equal ['%zeilinger%'], builder.where_values + assert_equal [:illustrators], builder.left_joins + end + +end