All Files
(24.18%
covered at
0.22
hits/line)
3 files in total.
91 relevant lines.
22 lines covered and
69 lines missed
-
# frozen_string_literal: true
-
-
1
require 'active_support'
-
1
require 'squeel'
-
1
require_relative './text_to_tsquery'
-
1
require_relative './text_to_sql_query'
-
-
module PgSearchable
-
extend ActiveSupport::Concern
-
-
included do
-
def update_pg_search_cache
-
# kept just for compatibility with pg_searchable
-
# noop in this implementation
-
end
-
end
-
-
class_methods do
-
def pg_search(
-
fields: [],
-
fields_mappings: {},
-
cache: nil,
-
language: 'english',
-
scope: 'scope_search',
-
skip_callback: false,
-
wildcard: true,
-
external_cache_data: nil,
-
joins: [],
-
default_field: ""
-
)
-
@ts_search_fields = fields
-
@ts_search_fields_mappings = fields_mappings
-
@ts_cache_field = cache
-
@ts_language = language
-
@ts_scope_method = scope
-
@ts_skip_cache_update = skip_callback
-
@ts_wildcard = wildcard
-
@ts_joins = joins
-
@default_field = (default_field.to_sym || fields.first)
-
ts_add_scope
-
end
-
-
def ts_add_scope
-
class_eval do
-
scope ts_scope_method, ->(value) { ts_search(value) }
-
end
-
end
-
-
def ts_search(value)
-
return if @ts_search_fields.blank? || value.blank?
-
TextToSqlQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause( includes(@ts_joins).references(:all))
-
end
-
-
def should_update_cache_field?
-
!@ts_skip_cache_update && @ts_cache_field.present?
-
end
-
-
def ts_cache_field
-
@ts_cache_field
-
end
-
-
def ts_scope_method
-
@ts_scope_method
-
end
-
-
def ts_cache_method
-
@ts_cache_method
-
end
-
-
def ts_fields_to_vector(extra_data = [])
-
field_to_vector = ->(field) { "to_tsvector('#{@ts_language}', coalesce(#{field}::text, ''))" }
-
data_to_vector = ->(data) { "to_tsvector('#{@ts_language}', '#{data}')" }
-
(@ts_search_fields.map(&field_to_vector) + extra_data.map(&data_to_vector)).join(' || ')
-
end
-
end
-
end
-
require './parser'
-
-
class TextToSqlQuery
-
def initialize(text, fields, default_field, fields_mappings = {})
-
@text = text.to_s.strip
-
@fields = fields.map(&:to_sym)
-
@default_field = default_field.to_sym
-
@fields_mappings = fields_mappings.merge(@fields.reduce({}) do |mappings, field|
-
_table_name, field_name = field.to_s.split('.')
-
mappings[field_name.to_sym] = field
-
mappings
-
end)
-
fields_mappings.each do |field, value|
-
@fields_mappings[field] = value if @fields_mappings[field]
-
end
-
end
-
-
def where_clause
-
@parser = Query.new
-
@parsed_tree = @parser.parse(@text)
-
generate_sql @parsed_tree
-
end
-
-
private
-
-
def generate_sql(tree)
-
first_key = tree.keys.first
-
node_value = tree[first_key]
-
case first_key
-
when :DEFAULT_COLUMN
-
escaped_node_value = handle_special_chars node_value
-
["#{@default_field.to_s} ILIKE ?", "%#{escaped_node_value}%"]
-
when :OPERATOR_OR
-
generate_expression_for_logical_operator(:OR, node_value)
-
when :OPERATOR_AND
-
generate_expression_for_logical_operator(:AND, node_value)
-
when :OPERATOR_NOT
-
not_array = generate_sql node_value
-
-
if not_array.length < 2
-
raise "There should be more than 1 element for expression following NOT operator"
-
end
-
-
not_expression = not_array.first
-
not_params = not_array[1..]
-
-
["NOT #{not_expression}"] + not_params
-
-
else
-
# key is column name
-
escaped_node_value = handle_special_chars node_value
-
mapping = @fields_mappings[first_key.to_sym]
-
if mapping.nil?
-
raise "Unknown field '#{first_key.to_s}'"
-
else
-
["#{mapping.to_s} ILIKE ?", "%#{escaped_node_value}%"]
-
end
-
end
-
end
-
-
def generate_expression_for_logical_operator(operator, operator_array)
-
if operator_array.length != 2
-
raise "There should be two array elements for #{operator.to_s} operator"
-
end
-
-
first_operand = generate_sql operator_array.first
-
second_operand = generate_sql operator_array.last
-
-
if first_operand.length < 2
-
raise 'There should be more than 1 element in first operand array'
-
end
-
-
if second_operand.length < 2
-
raise 'There should be more than 1 element in second operand array'
-
end
-
-
first_operand_expression = first_operand.first
-
first_operand_params = first_operand[1..]
-
-
second_operand_expression = second_operand.first
-
second_operand_params = second_operand[1..]
-
-
["(#{first_operand_expression} #{operator.to_s} #{second_operand_expression})"] + first_operand_params + second_operand_params
-
end
-
-
def handle_special_chars(text)
-
result = text.gsub(/\"/, '')
-
result.gsub!(/\_/, '\_')
-
result.tr!('\\', '\\')
-
result.gsub!(/%/, '\%')
-
result
-
end
-
end
-
# frozen_string_literal: true
-
-
# transforms "english like" text queries into a tsquery operation
-
# https://www.postgresql.org/docs/9.5/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
-
1
class TextToTsquery
-
1
attr_reader :text
-
-
1
def initialize(text, wildcard: true)
-
@text = text.to_s.strip
-
@wildcard = wildcard
-
@exact_matches = []
-
validate!
-
end
-
-
1
EXACT_WORD_CHAR = 'ยง'.freeze
-
-
1
def tsquery
-
@tsquery = @text
-
strip_exact_words
-
remove_duplicated_spaces
-
transform_or_into_operator
-
transform_and_into_operator
-
strip_spaces_from_parenthesis
-
transform_remaining_spaces_into_and_operator
-
transform_keywords
-
join_operators_with_and
-
remove_partial_match_from_not_keywords
-
add_exact_words
-
@tsquery
-
end
-
-
1
def validate!
-
parenthesis_error unless self.class.valid_search_parenthesis?(@text)
-
end
-
-
1
def self.valid_search_parenthesis?(text)
-
text.split('').reduce(0) do |acc, char|
-
return false if acc < 0
-
-
if char == '('
-
acc + 1
-
elsif char == ')'
-
acc - 1
-
else
-
acc
-
end
-
end.zero?
-
end
-
-
1
def parenthesis_error
-
raise ArgumentError, "incorrect number/order of parenthesis in search query: '#{@text}'"
-
end
-
-
1
def strip_exact_words
-
@exact_matches << Regexp.last_match(1) while @tsquery.sub!(/"(.*?)"/, EXACT_WORD_CHAR)
-
end
-
-
1
def remove_duplicated_spaces
-
@tsquery = @tsquery.gsub(/\s+/, ' ')
-
end
-
-
# transforms or/OR/|/|| into | operator
-
1
def transform_or_into_operator
-
@tsquery = @tsquery.gsub(/ ((or|\|+) )+/i, '|').gsub(/ *\|+ */, '|')
-
end
-
-
# transforms and/AND/&/&& into & operator
-
1
def transform_and_into_operator
-
@tsquery = @tsquery.gsub(/ ((and|\&+) )+/i, '&')
-
end
-
-
1
def strip_spaces_from_parenthesis
-
@tsquery = @tsquery.gsub(/ *([()]) */, '\1')
-
end
-
-
1
def transform_remaining_spaces_into_and_operator
-
@tsquery = @tsquery.tr(' ', '&')
-
end
-
-
# adds :* for partial match of words
-
1
def transform_keywords
-
keyword = @wildcard ? '\1:*' : '\1:'
-
@tsquery = @tsquery.gsub(/([^#{EXACT_WORD_CHAR}|&!())]+)/, keyword)
-
end
-
-
# adds & between operations
-
1
def join_operators_with_and
-
@tsquery = @tsquery.gsub(/:(\**)\!/, ':\1&!').gsub(/:(\**)\(/, ':\1&(').gsub(/\&+/, '&')
-
end
-
-
# removes partial match from NOT operations
-
1
def remove_partial_match_from_not_keywords
-
@tsquery = @tsquery.gsub(/\!([^|&!())]+):\**/, '!\1')
-
end
-
-
1
def add_exact_words
-
@exact_matches.each { |phrase| @tsquery = @tsquery.sub(EXACT_WORD_CHAR, "'#{phrase}'") }
-
end
-
end