5 Commits

Author SHA1 Message Date
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
5 changed files with 65 additions and 17 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,7 +36,11 @@ 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
@@ -52,7 +56,7 @@ 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( includes(@ts_joins).references(:all).where(
TextToSqlQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause).distinct TextToSqlQuery.new(value, @ts_search_fields, @default_fields, @ts_search_fields_mappings).where_clause).distinct
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 = {})
@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
@@ -29,7 +36,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

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,8 +158,6 @@ 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 in referenced column and in model columns with multiple search terms connected with logical operators' do it 'can search in referenced column and in model columns with multiple search terms connected with logical operators' do
record1 = DynamicModelWithTagValues.create name: 'something', value: 'amazing' record1 = DynamicModelWithTagValues.create name: 'something', value: 'amazing'
record2 = DynamicModelWithTagValues.create name: 'new record', value: 'not so amazing' record2 = DynamicModelWithTagValues.create name: 'new record', value: 'not so amazing'

View File

@@ -4,7 +4,16 @@ 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(['CAST(players.name AS TEXT) ILIKE ?', '%ab%']) }
@@ -13,22 +22,22 @@ describe TextToSqlQuery do
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(["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 CAST(players.value AS TEXT) ILIKE ?", "%23%"]) }
@@ -55,12 +64,15 @@ describe TextToSqlQuery do
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(["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(['((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%']) }
# 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 CAST(players.device_id AS TEXT) ILIKE ?) OR ((CAST(players.name AS TEXT) ILIKE ? OR CAST(players.tags AS TEXT) ILIKE ?) AND NOT 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

@@ -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