17 Commits

Author SHA1 Message Date
Bilal
3c4f3f3b58 add more search tests 2020-04-29 18:00:58 +02:00
Bilal
c7b173cb79 refactor text_to_sql spec to include STRING_AGG wrap 2020-04-29 17:10:54 +02:00
Bilal
6c9878ecea add spec to test searching for INNER search term 2020-04-29 16:53:41 +02:00
Bilal
15fc342633 remove comment 2020-04-29 16:53:18 +02:00
Bilal
f8352dcaa1 transform INNER JOIN to LEFT OUTER 2020-04-29 16:52:54 +02:00
Bilal
e0fae50584 add new tables and models for join through association 2020-04-29 07:22:03 +02:00
Bilal
fe96f682db start using HAVING instead of manually building joins 2020-04-29 07:21:39 +02:00
Bilal Catic
8f7b94f8cd Merge branch 'fix-bug-with-join-query' into 'join-bug-fix-with-multiple-default-columns'
Fix bug with join query

See merge request saburly/senadibilal/reklamice/gem!3
2020-04-24 09:44:27 +00:00
Bilal
0de540c5bd fix bug after rebase 2020-04-24 11:40:53 +02:00
Bilal
455845e36b update specs; add new specs to test for reported bug related to AND query in referenced table 2020-04-24 11:37:37 +02:00
Bilal
f4bec85161 change ActiveRecord joins query with custom generated SQL 2020-04-24 11:34:24 +02:00
Bilal Catic
4e382d7c1a Merge branch 'allow-multiple-default-columns' into 'join-bug-fix-with-multiple-default-columns'
Allow multiple default columns

See merge request saburly/senadibilal/reklamice/gem!2
2020-04-24 09:28:06 +00:00
Bilal
18f6875b54 add specs to search multiple default columns 2020-04-16 15:31:50 +02:00
Bilal
a54b2586ec update PgSearchable to accept array of default columns 2020-04-16 15:30:01 +02:00
Bilal
fc5fdd3db2 add model with two default fields 2020-04-16 15:28:28 +02:00
Bilal
b62638a647 Update and add new text-to-sql-query specs 2020-04-16 15:00:25 +02:00
Bilal
838b60ed92 Enable multiple default columns 2020-04-16 14:59:51 +02:00
6 changed files with 345 additions and 55 deletions

View File

@@ -26,7 +26,7 @@ module PgSearchable
wildcard: true, wildcard: true,
external_cache_data: nil, external_cache_data: nil,
joins: [], joins: [],
default_field: "" default_fields: []
) )
@ts_search_fields = fields @ts_search_fields = fields
@ts_search_fields_mappings = fields_mappings @ts_search_fields_mappings = fields_mappings
@@ -36,14 +36,18 @@ module PgSearchable
@ts_skip_cache_update = skip_callback @ts_skip_cache_update = skip_callback
@ts_wildcard = wildcard @ts_wildcard = wildcard
@ts_joins = joins @ts_joins = joins
@default_field = default_field.to_s.empty? ? fields.first : default_field.to_sym @default_fields = if default_fields.is_a? Array
default_fields.empty? ? [fields.first] : default_fields
else
default_fields.to_s.empty? ? [fields.first] : [default_fields.to_sym]
end
ts_add_scope ts_add_scope
end end
def ts_add_scope def ts_add_scope
class_eval do class_eval do
scope ts_scope_method, ->(value) do scope ts_scope_method, ->(value) do
resulting_ids = ts_search(value).map(&:id) resulting_ids = ts_search(value).rows.map { |row| row[0] }
where(id: resulting_ids) where(id: resulting_ids)
end end
end end
@@ -51,8 +55,16 @@ module PgSearchable
def ts_search(value) def ts_search(value)
return if @ts_search_fields.blank? || value.blank? return if @ts_search_fields.blank? || value.blank?
includes(@ts_joins).references(:all).where( sql_query_object = TextToSqlQuery.new(
TextToSqlQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause).distinct value,
@ts_search_fields,
@default_fields,
@ts_search_fields_mappings,
@ts_joins
)
sql_query = select(:id).distinct.joins(sql_query_object.join_clause).group(:id).having(sql_query_object.where_clause)
modified_sql_query = sql_query.to_sql.gsub('INNER', 'LEFT OUTER')
ActiveRecord::Base.connection.exec_query(modified_sql_query)
end end
def should_update_cache_field? def should_update_cache_field?

View File

@@ -1,10 +1,17 @@
require_relative 'parser' require_relative 'parser'
class TextToSqlQuery class TextToSqlQuery
def initialize(text, fields, default_field, fields_mappings = {}) def initialize(text, fields, default_fields, fields_mappings = {}, joins = [])
@text = text.to_s.strip @text = text.to_s.strip
@fields = fields.map(&:to_sym) @fields = fields.map(&:to_sym)
@default_field = default_field.to_sym
# Keep compatibility with previous version where default_field(s) was string/symbol
@default_fields = if default_fields.is_a? Array
default_fields.map(&:to_sym)
else
[default_fields.to_sym]
end
@fields_mappings = fields_mappings.merge(@fields.reduce({}) do |mappings, field| @fields_mappings = fields_mappings.merge(@fields.reduce({}) do |mappings, field|
_table_name, field_name = field.to_s.split('.') _table_name, field_name = field.to_s.split('.')
mappings[field_name.to_sym] = field mappings[field_name.to_sym] = field
@@ -13,6 +20,7 @@ class TextToSqlQuery
fields_mappings.each do |field, value| fields_mappings.each do |field, value|
@fields_mappings[field] = value if @fields_mappings[field] @fields_mappings[field] = value if @fields_mappings[field]
end end
@joins = joins
end end
def where_clause def where_clause
@@ -21,6 +29,11 @@ class TextToSqlQuery
generate_sql @parsed_tree generate_sql @parsed_tree
end end
def join_clause
return nil if @joins.empty?
return *@joins
end
private private
def generate_sql(tree) def generate_sql(tree)
@@ -29,7 +42,14 @@ class TextToSqlQuery
case first_key case first_key
when :DEFAULT_COLUMN when :DEFAULT_COLUMN
escaped_node_value = handle_special_chars node_value escaped_node_value = handle_special_chars node_value
["CAST(#{@default_field.to_s} AS TEXT) ILIKE ?", "%#{escaped_node_value}%"] query_part = '('
params_part = []
@default_fields.each do |default_field|
query_part += "CAST(#{default_field.to_s} AS TEXT) ILIKE ? OR "
params_part << "%#{escaped_node_value}%"
end
query_part = query_part[0...-4] + ')'
[query_part, *params_part]
when :OPERATOR_OR when :OPERATOR_OR
generate_expression_for_logical_operator(:OR, node_value) generate_expression_for_logical_operator(:OR, node_value)
when :OPERATOR_AND when :OPERATOR_AND
@@ -53,7 +73,7 @@ class TextToSqlQuery
if mapping.nil? if mapping.nil?
raise "Unknown field '#{first_key.to_s}'" raise "Unknown field '#{first_key.to_s}'"
else else
["CAST(#{mapping.to_s} AS TEXT) ILIKE ?", "%#{escaped_node_value}%"] ["STRING_AGG(CAST(#{mapping.to_s} AS TEXT), '') ILIKE ?", "%#{escaped_node_value}%"]
end end
end end
end end
@@ -88,6 +108,7 @@ class TextToSqlQuery
result.gsub!(/\_/, '\_') result.gsub!(/\_/, '\_')
result.tr!('\\', '\\') result.tr!('\\', '\\')
result.gsub!(/%/, '\%') result.gsub!(/%/, '\%')
result.downcase!
result result
end end
end end

View File

@@ -47,6 +47,20 @@ describe PgSearchable do
expect(VectorModel.scope_search("#{record2.id}")).to contain_exactly(record2) expect(VectorModel.scope_search("#{record2.id}")).to contain_exactly(record2)
end end
it 'can search multiple default columns if no column name is used with single search term' do
records = VectorModelWithTwoDefaultColumns.create [{ name: 'hamo', value: '5' }, { name: 'meho', value: '20 hamo' }, { name: 'munja-5', value: '300' }]
expect(VectorModelWithTwoDefaultColumns.scope_search('name:hamo')).to contain_exactly(records[0])
expect(VectorModelWithTwoDefaultColumns.scope_search('hamo')).to contain_exactly(records[0], records[1])
expect(VectorModelWithTwoDefaultColumns.scope_search('5')).to contain_exactly(records[0], records[2])
end
it 'can search multiple default columns if no column name is used in query containing multiple search terms' do
records = VectorModelWithTwoDefaultColumns.create [{ name: 'hamo', value: '9' }, { name: 'meho', value: '5' }, { name: 'oko-9', value: '100' }]
expect(VectorModelWithTwoDefaultColumns.scope_search('(9 and not name:hamo) or meho')).to contain_exactly(records[2], records[1])
end
it 'searches column declared in search term' do it 'searches column declared in search term' do
record1 = VectorModel.create name: 'hamo', value: '-45' record1 = VectorModel.create name: 'hamo', value: '-45'
record2 = VectorModel.create name: 'meho', value: '120' record2 = VectorModel.create name: 'meho', value: '120'
@@ -144,37 +158,211 @@ describe PgSearchable do
expect(DynamicModelWithTagValues.scope_search('tag:green or value:"not"')).to contain_exactly(record1, record2) expect(DynamicModelWithTagValues.scope_search('tag:green or value:"not"')).to contain_exactly(record1, record2)
end end
it 'can search for search term containing INNER word' do
records = DynamicModelWithTagValues.create [{ name: 'inner', value: 'amazing' },
{ name: 'new record', value: 'INNER' }]
Tag.create [{ taggable: records[0], value: 'red', custom_attribute: 'rose' },
{ taggable: records[1], value: 'INNER', custom_attribute: 'garden' }]
it 'can search in referenced column and in model columns with multiple search terms connected with logical operators' do expect(DynamicModelWithTagValues.scope_search('name:inner')).to contain_exactly(records[0])
record1 = DynamicModelWithTagValues.create name: 'something', value: 'amazing' expect(DynamicModelWithTagValues.scope_search('value:INNER')).to contain_exactly(records[1])
record2 = DynamicModelWithTagValues.create name: 'new record', value: 'not so amazing' expect(DynamicModelWithTagValues.scope_search('tag:inner')).to contain_exactly(records[1])
record3 = DynamicModelWithTagValues.create name: 'last one', value: 'no value' expect(DynamicModelWithTagValues.scope_search('tag:INNER')).to contain_exactly(records[1])
record4 = DynamicModelWithTagValues.create name: 'really last one', value: 'no value' end
Tag.create(taggable: record1, value: 'red')
Tag.create(taggable: record1, value: 'green') describe 'searching in model with has_many association' do
Tag.create(taggable: record2, value: 'black') before do
Tag.create(taggable: record3, value: '-12') records = DynamicModelWithTagValues.create [{ name: 'something', value: 'amazing' },
Tag.create(taggable: record4, value: '-') { name: 'new record', value: 'not so amazing' },
{ name: 'last one', value: 'no value' },
{ name: 'really last one', value: 'no value' }]
Tag.create [{ taggable: records[0], value: 'red', custom_attribute: 'rose' },
{ taggable: records[0], value: 'green', custom_attribute: 'garden' },
{ taggable: records[1], value: 'black', custom_attribute: 'sky' },
{ taggable: records[2], value: '-1/12', custom_attribute: 'gold nugget' },
{ taggable: records[3], value: '-', custom_attribute: 'unknown' },
{ taggable: records[3], value: 'red-green', custom_attribute: 'unicorn' }]
end
it 'can search with multiple search terms connected with OR operator' do
records = DynamicModelWithTagValues.all
expect(DynamicModelWithTagValues.scope_search('tag:red or tag:black')).to contain_exactly(records[0], records[1], records[3])
expect(DynamicModelWithTagValues.scope_search('tag:red or tag:green')).to contain_exactly(records[0], records[3])
end
it 'can search with multiple search terms connected with AND operator' do
records = DynamicModelWithTagValues.all
expect(DynamicModelWithTagValues.scope_search('tag:red or tag:black')).to contain_exactly(record1, record2)
expect(DynamicModelWithTagValues.scope_search('tag:red and tag:black')).to be_empty expect(DynamicModelWithTagValues.scope_search('tag:red and tag:black')).to be_empty
expect(DynamicModelWithTagValues.scope_search('tag:red or tag:green')).to contain_exactly(record1) expect(DynamicModelWithTagValues.scope_search('tag:red and tag:green')).to contain_exactly(records[0], records[3])
expect(DynamicModelWithTagValues.scope_search('not tag:-12 and not value:amazing')).to contain_exactly(record4) end
it 'can search with multiple search terms and containing NOT' do
records = DynamicModelWithTagValues.all
expect(DynamicModelWithTagValues.scope_search('not tag:"-1/12" and not value:amazing')).to contain_exactly(records[3])
end end
it 'can search in referenced column and in model columns with multiple search terms connected with logical operators and with brackets' do it 'can search in referenced column and in model columns with multiple search terms connected with logical operators and with brackets' do
record1 = DynamicModelWithTagValues.create name: 'something', value: 'amazing' records = DynamicModelWithTagValues.all
record2 = DynamicModelWithTagValues.create name: 'new record', value: 'not so amazing'
record3 = DynamicModelWithTagValues.create name: 'last one', value: 'no value'
record4 = DynamicModelWithTagValues.create name: 'really last one', value: 'no value'
Tag.create(taggable: record1, value: 'red')
Tag.create(taggable: record1, value: 'green')
Tag.create(taggable: record2, value: 'black')
Tag.create(taggable: record3, value: '-12')
Tag.create(taggable: record4, value: '-')
expect(DynamicModelWithTagValues.scope_search('(tag:- and not tag:12) or (value:"amazing" and not value:"not")')).to contain_exactly(record1, record4) expect(DynamicModelWithTagValues.scope_search('(tag:- and not tag:12) or (value:"amazing" and not value:"not")')).to contain_exactly(records[0], records[3])
end
end
describe 'searching through multiple referenced columns in model with has_many association' do
before do
records = DynamicModelWithTagValuesAndCustomAttribute.create [{ name: 'something', value: 'amazing' },
{ name: 'new record', value: 'not so amazing' },
{ name: 'last one', value: 'no value' },
{ name: 'really last one', value: 'no value' }]
Tag.create [{ taggable: records[0], value: 'red', custom_attribute: 'rose' },
{ taggable: records[0], value: 'green', custom_attribute: 'garden' },
{ taggable: records[1], value: 'black', custom_attribute: 'sky' },
{ taggable: records[2], value: '-1/12', custom_attribute: 'gold nugget' },
{ taggable: records[3], value: '-', custom_attribute: 'unknown' },
{ taggable: records[3], value: 'red-green', custom_attribute: 'unicorn' }]
end
it 'can search with multiple search terms connected with OR operator' do
records = DynamicModelWithTagValuesAndCustomAttribute.all
expect(DynamicModelWithTagValuesAndCustomAttribute.scope_search('tag:red or custom:sky')).to contain_exactly(records[0], records[1], records[3])
end
it 'can search with multiple search terms connected with AND operator' do
records = DynamicModelWithTagValuesAndCustomAttribute.all
expect(DynamicModelWithTagValuesAndCustomAttribute.scope_search('custom:unicorn and tag:black')).to be_empty
expect(DynamicModelWithTagValuesAndCustomAttribute.scope_search('tag:"-1/12" and custom:gold')).to contain_exactly(records[2])
end
it 'can search with multiple search terms and containing NOT' do
expect(DynamicModelWithTagValuesAndCustomAttribute.scope_search('not tag:"-1/12" and not value:amazing and not custom:unknown')).to be_empty
end
it 'can search with multiple search terms connected with logical operators and with brackets' do
records = DynamicModelWithTagValuesAndCustomAttribute.all
expect(DynamicModelWithTagValuesAndCustomAttribute.scope_search('(tag:- and not tag:12) or (value:"amazing" and not value:"not") or (custom:unknown or custom:rose)')).to contain_exactly(records[0], records[3])
end
end
describe 'searching in model with multiple has_many associations' do
before do
records = DynamicModelWithTagAndCategories.create [{ name: 'something', value: 'amazing' },
{ name: 'new record', value: 'not so amazing' },
{ name: 'last one', value: 'no value' },
{ name: 'really last one', value: 'no value' }]
Tag.create [{ taggable: records[0], value: 'red', custom_attribute: 'rose' },
{ taggable: records[0], value: 'green', custom_attribute: 'garden' },
{ taggable: records[1], value: 'black', custom_attribute: 'sky' },
{ taggable: records[2], value: '-1/12', custom_attribute: 'gold nugget' },
{ taggable: records[3], value: '-', custom_attribute: 'unknown' },
{ taggable: records[3], value: 'red-green', custom_attribute: 'unicorn' }]
Category.create [{ categoriable: records[0], name: 'home' },
{ categoriable: records[0], name: 'home' },
{ categoriable: records[1], name: 'world' },
{ categoriable: records[2], name: 'math' },
{ categoriable: records[3], name: 'unknown' },
{ categoriable: records[3], name: 'myth' }]
end
it 'can search with multiple search terms connected with OR operator' do
records = DynamicModelWithTagAndCategories.all
expect(DynamicModelWithTagAndCategories.scope_search('tag:red or category:math')).to contain_exactly(records[0], records[2], records[3])
end
it 'can search with multiple search terms connected with AND operator' do
records = DynamicModelWithTagAndCategories.all
expect(DynamicModelWithTagAndCategories.scope_search('category:home and tag:-')).to be_empty
expect(DynamicModelWithTagAndCategories.scope_search('tag:"-1/12" and category:math')).to contain_exactly(records[2])
end
it 'can search with multiple search terms and containing NOT' do
expect(DynamicModelWithTagAndCategories.scope_search('not tag:"-1/12" and not value:amazing and not category:unknown')).to be_empty
end
it 'can search with multiple search terms connected with logical operators and with brackets' do
records = DynamicModelWithTagAndCategories.all
expect(DynamicModelWithTagAndCategories.scope_search('(tag:- and not tag:12) or (value:"amazing" and not value:"not") or (category:unknown or category:math)')).to contain_exactly(records[0], records[2], records[3])
end
end
describe 'joins with through association' do
let(:players) { Player.all }
before do
players = Player.create([{ name: 'first', value: 'downtown' },
{ name: 'redliner', value: 'uptown' },
{ name: 'flying', value: 'eagle' },
{ name: 'blue', value: 'marine' }])
ptags = Ptag.create [{ value: 'e-ink' },
{ value: 'black&white' },
{ value: 'rich' },
{ value: 'grayscale' },
{ value: 'LED' }]
players[0].ptags << ptags[0..1]
players[1].ptags << ptags[0..2]
players[2].ptags << ptags[2..3]
players[3].ptags << ptags[4]
end
it 'can search in default column of model with through association' do
expect(Player.scope_search('l and not y and not blue')).to contain_exactly(players[1])
end
it 'can search in referenced column associated with through relation - simple search' do
expect(Player.scope_search('tag:e-ink')).to contain_exactly(players[0], players[1])
end
it 'can search in referenced column associated with through relaction - simple search with OR operator' do
expect(Player.scope_search('tag:e-ink OR tag:rich')).to contain_exactly(players[0], players[1], players[2])
end
it 'can search in referenced column associated with through relaction - simple search with AND operator' do
expect(Player.scope_search('tag:e-ink AND tag:rich')).to contain_exactly(players[1])
end
it 'can search in referenced column associated with through relaction - simple search with NOT operator' do
expect(Player.scope_search('NOT tag:e-ink')).to contain_exactly(players[2], players[3])
end
it 'can search in referenced column associated with through relaction - mixed search terms with OR' do
expect(Player.scope_search('tag:e-ink OR blue')).to contain_exactly(players[0], players[1], players[3])
end
it 'can search in referenced column associated with through relaction - mixed search terms with AND' do
expect(Player.scope_search('tag:e-ink AND first')).to contain_exactly(players[0])
end
it 'can search in referenced column associated with through relaction - mixed search terms with OR NOT' do
expect(Player.scope_search('tag:led OR NOT first')).to contain_exactly(players[1], players[2], players[3])
end
it 'can search in referenced column associated with through relaction - mixed search terms with AND NOT' do
expect(Player.scope_search('tag:e-ink AND NOT first')).to contain_exactly(players[1])
end
it 'can search in referenced column and in model columns - complex search' do
expect(Player.scope_search('(tag:e-ink AND tag:rich) OR (blue or value:eagle)')).to contain_exactly(players[1], players[2], players[3])
expect(Player.scope_search('tag:e-ink AND tag:rich AND NOT tag:black')).to be_empty
expect(Player.scope_search('name:l AND (value:uptown or value:marine) AND (tag:e-ink and tag:rich)')).to contain_exactly(players[1])
expect(Player.scope_search('name:l AND (value:uptown or value:marine) AND (tag:e-ink and tag:led)')).to be_empty
end
end end
end end
end end

View File

@@ -4,63 +4,75 @@ require_relative '../../lib/text_to_sql_query'
describe TextToSqlQuery do describe TextToSqlQuery do
describe '.new' do describe '.new' do
# tests simple search term without column name and without quotes # tests simple search term without column name and without quotes
it { expect(described_class.new('some-default-value', [:"players.name"], :"players.name").where_clause).to eq(['CAST(players.name AS TEXT) ILIKE ?', '%some-default-value%']) } it { expect(described_class.new('some-default-value', [:"players.name"], :"players.name").where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ?)', '%some-default-value%']) }
# tests simple search with two default columns without quotes
it { expect(described_class.new('search-term', [:'players.name', :'players.tags', 'players.id'], [:'players.name', :'players.tags']).where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ?)', '%search-term%', '%search-term%']) }
# tests simple search with two default columns with quotes
it { expect(described_class.new('"search-term"', [:'players.name', :'players.tags', 'players.id'], [:'players.name', :'players.tags']).where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ?)', '%search-term%', '%search-term%']) }
# tests simple search with three default columns without quotes
it { expect(described_class.new('search-term', [:'players.name', :'players.tags', 'players.id'], [:'players.name', :'players.tags', :'players.id']).where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ? OR CAST(players.id AS TEXT) ILIKE ?)', '%search-term%', '%search-term%', '%search-term%']) }
# tests simple search term with column name and without quotes # tests simple search term with column name and without quotes
it { expect(described_class.new('name:ab', [:"players.name"], :"players.name").where_clause).to eq(['CAST(players.name AS TEXT) ILIKE ?', '%ab%']) } it { expect(described_class.new('name:ab', [:"players.name"], :"players.name").where_clause).to eq(["STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?", '%ab%']) }
# tests simple search term with unknown column name and without quotes # tests simple search term with unknown column name and without quotes
it { expect{described_class.new('unknown:ab', [:"players.name"], :"players.name").where_clause}.to raise_error(RuntimeError, "Unknown field 'unknown'") } it { expect{described_class.new('unknown:ab', [:"players.name"], :"players.name").where_clause}.to raise_error(RuntimeError, "Unknown field 'unknown'") }
# tests simple search term without column name and with quotes # tests simple search term without column name and with quotes
it { expect(described_class.new('"ab"', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["CAST(players.device_id AS TEXT) ILIKE ?", "%ab%"]) } it { expect(described_class.new('"ab"', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["(CAST(players.device_id AS TEXT) ILIKE ?)", "%ab%"]) }
# tests simple search term with column name and with quotes # tests simple search term with column name and with quotes
it { expect(described_class.new('tags:"ab"', [:"players.name", :"players.tags"], :"players.device_id").where_clause).to eq(["CAST(players.tags AS TEXT) ILIKE ?", "%ab%"]) } it { expect(described_class.new('tags:"ab"', [:"players.name", :"players.tags"], :"players.device_id").where_clause).to eq(["STRING_AGG(CAST(players.tags AS TEXT), '') ILIKE ?", "%ab%"]) }
# tests search without operators # tests search without operators
it { expect(described_class.new('123 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["(CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?)", "%123%", "%456%"]) } it { expect(described_class.new('123 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?))", "%123%", "%456%"]) }
# tests search with OR operator # tests search with OR operator
it { expect(described_class.new('123 or 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["(CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?)", "%123%", "%456%"]) } it { expect(described_class.new('123 or 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?))", "%123%", "%456%"]) }
# tests search with AND operator # tests search with AND operator
it { expect(described_class.new('123 and 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["(CAST(players.device_id AS TEXT) ILIKE ? AND CAST(players.device_id AS TEXT) ILIKE ?)", "%123%", "%456%"]) } it { expect(described_class.new('123 and 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["((CAST(players.device_id AS TEXT) ILIKE ?) AND (CAST(players.device_id AS TEXT) ILIKE ?))", "%123%", "%456%"]) }
# tests search with NOT operator on default column # tests search with NOT operator on default column
it { expect(described_class.new('not 23', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["NOT CAST(players.device_id AS TEXT) ILIKE ?", "%23%"]) } it { expect(described_class.new('not 23', [:"players.name", :"players.device_id"], :"players.device_id").where_clause).to eq(["NOT (CAST(players.device_id AS TEXT) ILIKE ?)", "%23%"]) }
# tests search with NOT operator on non-default column # tests search with NOT operator on non-default column
it { expect(described_class.new('not value:23', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["NOT CAST(players.value AS TEXT) ILIKE ?", "%23%"]) } it { expect(described_class.new('not value:23', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["NOT STRING_AGG(CAST(players.value AS TEXT), '') ILIKE ?", "%23%"]) }
# tests search with mixed logical operators # tests search with mixed logical operators
it { expect(described_class.new('name:ab and not value:hf-1', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? AND NOT CAST(players.value AS TEXT) ILIKE ?)', '%ab%', '%hf-1%']) } it { expect(described_class.new('name:ab and not value:hf-1', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["(STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? AND NOT STRING_AGG(CAST(players.value AS TEXT), '') ILIKE ?)", '%ab%', '%hf-1%']) }
# tests search with mixed logical operators without NOT' # tests search with mixed logical operators without NOT'
it { expect(described_class.new('name:a and name:b or name:c', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(['((CAST(players.name AS TEXT) ILIKE ? AND CAST(players.name AS TEXT) ILIKE ?) OR CAST(players.name AS TEXT) ILIKE ?)', '%a%', '%b%', '%c%']) } it { expect(described_class.new('name:a and name:b or name:c', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["((STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? AND STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?) OR STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?)", '%a%', '%b%', '%c%']) }
# tests search with brackets in expression # tests search with brackets in expression
it { expect(described_class.new('name:a and (name:b or name:c)', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? AND (CAST(players.name AS TEXT) ILIKE ? OR CAST(players.name AS TEXT) ILIKE ?))', '%a%', '%b%', '%c%']) } it { expect(described_class.new('name:a and (name:b or name:c)', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["(STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? AND (STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? OR STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?))", '%a%', '%b%', '%c%']) }
# tests search with brackets in expression and with NOT operator # tests search with brackets in expression and with NOT operator
it { expect(described_class.new('name:a and not (name:b or name:c)', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(['(CAST(players.name AS TEXT) ILIKE ? AND NOT (CAST(players.name AS TEXT) ILIKE ? OR CAST(players.name AS TEXT) ILIKE ?))', '%a%', '%b%', '%c%']) } it { expect(described_class.new('name:a and not (name:b or name:c)', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["(STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? AND NOT (STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ? OR STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?))", '%a%', '%b%', '%c%']) }
# tests search with special characters in search term # tests search with special characters in search term
it { expect(described_class.new('name:"%a_\"', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(['CAST(players.name AS TEXT) ILIKE ?', '%\%a\_\\%']) } it { expect(described_class.new('name:"%a_\"', [:"players.name", :"players.value"], :"players.device_id").where_clause).to eq(["STRING_AGG(CAST(players.name AS TEXT), '') ILIKE ?", '%\%a\_\\%']) }
# tests search with field mappings # tests search with field mappings
it { expect(described_class.new('tags:h1-r', [:'players.name', :'players.value', :'players.device_id'], :"players.device_id", { tags: "tags.value" }).where_clause).to eq(['CAST(tags.value AS TEXT) ILIKE ?', '%h1-r%']) } it { expect(described_class.new('tags:h1-r', [:'players.name', :'players.value', :'players.device_id'], :"players.device_id", { tags: "tags.value" }).where_clause).to eq(["STRING_AGG(CAST(tags.value AS TEXT), '') ILIKE ?", '%h1-r%']) }
# tests search with field mappings when fields array has same mapping # tests search with field mappings when fields array has same mapping
it { expect(described_class.new('tags:h1-r', [:'players.name', :'players.tags', :'players.device_id'], :"players.device_id", { tags: "tags.value" }).where_clause).to eq(["CAST(tags.value AS TEXT) ILIKE ?", "%h1-r%"]) } it { expect(described_class.new('tags:h1-r', [:'players.name', :'players.tags', :'players.device_id'], :"players.device_id", { tags: "tags.value" }).where_clause).to eq(["STRING_AGG(CAST(tags.value AS TEXT), '') ILIKE ?", "%h1-r%"]) }
# tests complex query # tests complex query
it { expect(described_class.new('(device_id:"with space" tags:mta no-quotes-id-123) or "id with quotes-5" and ( ("id with q 10" or "id with q 20") and ("id with Q 30" "id with Q 40") and not id-without-Q-50)', [:'players.name', :'players.value', :'players.device_id'], :"players.device_id", { tags: 'tags.name' }).where_clause).to eq(['((CAST(players.device_id AS TEXT) ILIKE ? OR (CAST(tags.name AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?)) OR (CAST(players.device_id AS TEXT) ILIKE ? AND (((CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?) AND (CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?)) AND NOT CAST(players.device_id AS TEXT) ILIKE ?)))', '%with space%', '%mta%', '%no-quotes-id-123%', '%id with quotes-5%', '%id with q 10%', '%id with q 20%', '%id with Q 30%', '%id with Q 40%', '%id-without-Q-50%']) } it { expect(described_class.new('(device_id:"with space" tags:mta no-quotes-id-123) or "id with quotes-5" and ( ("id with q 10" or "id with q 20") and ("id with Q 30" "id with Q 40") and not id-without-Q-50)', [:'players.name', :'players.value', :'players.device_id'], :"players.device_id", { tags: 'tags.name' }).where_clause).to eq(["((STRING_AGG(CAST(players.device_id AS TEXT), '') ILIKE ? OR (STRING_AGG(CAST(tags.name AS TEXT), '') ILIKE ? OR (CAST(players.device_id AS TEXT) ILIKE ?))) OR ((CAST(players.device_id AS TEXT) ILIKE ?) AND ((((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?)) AND ((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?))) AND NOT (CAST(players.device_id AS TEXT) ILIKE ?))))", '%with space%', '%mta%', '%no-quotes-id-123%', '%id with quotes-5%', '%id with q 10%', '%id with q 20%', '%id with q 30%', '%id with q 40%', '%id-without-q-50%']) }
# tests mixed query with and without column names and with multiple default columns
it { expect(described_class.new('("green hunter" and device_id:100) or ("blue bird" and not device_id:200)', [:'players.name', :'players.value', :'players.device_id'], [:"players.name", :"players.tags"]).where_clause).to eq(["(((CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ?) AND STRING_AGG(CAST(players.device_id AS TEXT), '') ILIKE ?) OR ((CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ?) AND NOT STRING_AGG(CAST(players.device_id AS TEXT), '') ILIKE ?))", '%green hunter%', '%green hunter%', '%100%', '%blue bird%', '%blue bird%', '%200%']) }
# tests query with multiple search terms with mixed and-or-not after dash and underscore # tests query with multiple search terms with mixed and-or-not after dash and underscore
it { expect(described_class.new('123-and-456 -or-2 -not_not_1', [:'players.title', :'players.tag', :'players.device_id'], :'players.device_id').where_clause).to eq(['(CAST(players.device_id AS TEXT) ILIKE ? OR (CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?))', '%123-and-456%', '%-or-2%', '%-not\_not\_1%'])} it { expect(described_class.new('123-and-456 -or-2 -not_not_1', [:'players.title', :'players.tag', :'players.device_id'], :'players.device_id').where_clause).to eq(['((CAST(players.device_id AS TEXT) ILIKE ?) OR ((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?)))', '%123-and-456%', '%-or-2%', '%-not\_not\_1%'])}
# tests query with multiple search terms with mixed and-or-not after dash and underscore # tests query with multiple search terms with mixed and-or-not after dash and underscore
it { expect(described_class.new('andrew or ornela', [:'players.title', :'players.tag', :'players.device_id'], :'players.device_id').where_clause).to eq(['(CAST(players.device_id AS TEXT) ILIKE ? OR CAST(players.device_id AS TEXT) ILIKE ?)', '%andrew%', '%ornela%'])} it { expect(described_class.new('andrew or ornela', [:'players.title', :'players.tag', :'players.device_id'], :'players.device_id').where_clause).to eq(['((CAST(players.device_id AS TEXT) ILIKE ?) OR (CAST(players.device_id AS TEXT) ILIKE ?))', '%andrew%', '%ornela%'])}
end end
end end

View File

@@ -27,6 +27,7 @@ ActiveRecord::Schema.define do
create_table :tags, force: true do |t| create_table :tags, force: true do |t|
t.string :value t.string :value
t.string :custom_attribute
t.references :category, index: true t.references :category, index: true
t.references :taggable, polymorphic: true, index: true t.references :taggable, polymorphic: true, index: true
t.timestamps null: false t.timestamps null: false
@@ -34,6 +35,7 @@ ActiveRecord::Schema.define do
create_table :categories, force: true do |t| create_table :categories, force: true do |t|
t.string :name t.string :name
t.references :categoriable, polymorphic: true, index: true
t.timestamps null: false t.timestamps null: false
end end
@@ -42,4 +44,19 @@ ActiveRecord::Schema.define do
t.string :name t.string :name
t.timestamps null: false t.timestamps null: false
end end
create_table :players, force: true do |t|
t.string :name
t.string :value
t.timestamps null: false
end
create_table :ptags, force: true do |t|
t.string :value
end
create_table :taggings, force: true do |t|
t.belongs_to :player
t.belongs_to :ptag
end
end end

View File

@@ -5,6 +5,12 @@ class VectorModel < ActiveRecord::Base
pg_search fields: %i[vector_models.id vector_models.name vector_models.value], cache: :search_cache pg_search fields: %i[vector_models.id vector_models.name vector_models.value], cache: :search_cache
end end
class VectorModelWithTwoDefaultColumns < ActiveRecord::Base
self.table_name = :vector_models
include PgSearchable
pg_search fields: %i[vector_models.id vector_models.name vector_models.value], default_fields: %i[vector_models.name vector_models.value], cache: :search_cache
end
class VectorModelWithMappings < ActiveRecord::Base class VectorModelWithMappings < ActiveRecord::Base
self.table_name = :vector_models self.table_name = :vector_models
include PgSearchable include PgSearchable
@@ -76,6 +82,21 @@ class DynamicModelWithTagValues < ActiveRecord::Base
has_many :tags, as: :taggable has_many :tags, as: :taggable
end end
class DynamicModelWithTagAndCategories < ActiveRecord::Base
self.table_name = :dynamic_models
include PgSearchable
pg_search fields: %i[dynamic_models.id dynamic_models.name dynamic_models.value], fields_mappings: {tag: 'tags.value', category: 'categories.name'}, joins: [:tags, :categories]
has_many :tags, as: :taggable
has_many :categories, as: :categoriable
end
class DynamicModelWithTagValuesAndCustomAttribute < ActiveRecord::Base
self.table_name = :dynamic_models
include PgSearchable
pg_search fields: %i[dynamic_models.id dynamic_models.name dynamic_models.value], fields_mappings: {tag: 'tags.value', custom: 'tags.custom_attribute'}, joins: [:tags]
has_many :tags, as: :taggable
end
class DynamicModelWithCategory < ActiveRecord::Base class DynamicModelWithCategory < ActiveRecord::Base
self.table_name = :dynamic_models self.table_name = :dynamic_models
include PgSearchable include PgSearchable
@@ -106,4 +127,23 @@ end
class Category < ActiveRecord::Base class Category < ActiveRecord::Base
has_many :tags has_many :tags
belongs_to :categoriable, polymorphic: true
end
class Player < ActiveRecord::Base
include PgSearchable
pg_search fields: %i[players.id players.name players.value], default_fields: [:name], fields_mappings: { tag: 'ptags.value' }, joins: [:ptags]
has_many :taggings
has_many :ptags, through: :taggings
end
class Ptag < ActiveRecord::Base
self.table_name = :ptags
has_many :taggings
has_many :players, through: :taggings
end
class Tagging < ActiveRecord::Base
belongs_to :player
belongs_to :ptag
end end