All Files
(68.94%
covered at
5.11
hits/line)
3 files in total.
132 relevant lines.
91 lines covered and
41 lines missed
-
# frozen_string_literal: true
-
-
1
require 'active_support'
-
1
require 'squeel'
-
1
require_relative './text_to_tsquery'
-
1
require_relative './text_to_regex_query'
-
-
1
module PgSearchable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
12
def update_pg_search_cache
-
# kept just for compatibility with pg_searchable
-
# noop in this implementation
-
end
-
end
-
-
1
class_methods do
-
1
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: ""
-
)
-
12
@ts_search_fields = fields
-
12
@ts_search_fields_mappings = fields_mappings
-
12
@ts_cache_field = cache
-
12
@ts_language = language
-
12
@ts_scope_method = scope
-
12
@ts_skip_cache_update = skip_callback
-
12
@ts_wildcard = wildcard
-
12
@ts_joins = joins
-
12
@default_field = (default_field.to_sym || fields.first)
-
12
ts_add_scope
-
end
-
-
1
def ts_add_scope
-
12
class_eval do
-
43
scope ts_scope_method, ->(value) { ts_search(value) }
-
end
-
end
-
-
1
def ts_search(value)
-
31
return if @ts_search_fields.blank? || value.blank?
-
30
TextToRegexQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause( includes(@ts_joins).references(:all))
-
end
-
-
1
def should_update_cache_field?
-
!@ts_skip_cache_update && @ts_cache_field.present?
-
end
-
-
1
def ts_cache_field
-
@ts_cache_field
-
end
-
-
1
def ts_scope_method
-
12
@ts_scope_method
-
end
-
-
1
def ts_cache_method
-
@ts_cache_method
-
end
-
-
1
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
-
# frozen_string_literal: true
-
-
# transforms "english like" text queries into a where clause with regex
-
# https://www.postgresql.org/docs/9.5/textsearch-controls.html#TEXTSEARCH-PARSING-QUERIES
-
-
1
class TextToRegexQuery
-
1
def initialize(text, fields, default_field, fields_mappings = {})
-
35
@text = text.to_s.strip
-
35
@fields = fields.map(&:to_sym)
-
35
@default_field = default_field.to_sym
-
35
@fields_mappings = fields_mappings.merge(@fields.reduce({}) do |mappings, field|
-
37
table_name, field_name = field.to_s.split(".")
-
37
mappings[field_name.to_sym] = field
-
7
mappings
-
end)
-
end
-
-
1
def where_clause(query)
-
5
@cleared_text = @text.dup
-
5
@column_chunks = []
-
5
remove_duplicated_spaces
-
5
extract_columns
-
5
escape_special_characters
-
5
generate_where_clause(query)
-
end
-
-
1
private
-
-
1
def remove_duplicated_spaces
-
5
@cleared_text.gsub!(/\s+/, ' ')
-
end
-
-
1
def escape_special_characters
-
5
@cleared_text.gsub!(/\_/, '\_')
-
5
@cleared_text.tr!('\\', '\\')
-
5
@cleared_text.gsub!(/%/, '\%')
-
end
-
-
1
def extract_columns
-
5
column_search_term_pairs = @cleared_text.scan(/([A-Za-z0-9_]+:[\w\_-]+)/)
-
-
5
@column_chunks = (column_search_term_pairs.flatten.map do |pair|
-
7
column, term = pair.split(':')
-
7
next unless @fields_mappings.include?(column.to_sym)
-
5
@cleared_text.gsub!(pair, '')
-
5
{ @fields_mappings[column.to_sym] => term }
-
end).compact
-
5
unless @cleared_text.strip.empty?
-
3
@column_chunks = [{ @default_field.to_s => @cleared_text.strip }] + @column_chunks
-
end
-
5
@column_chunks
-
end
-
-
1
def generate_where_clause(query)
-
5
where_clause = ''
-
13
columns = @column_chunks.map { |c| c.keys.first }
-
13
values = @column_chunks.map { |c| c.values.first }
-
-
5
columns.each do |column|
-
8
where_clause += "#{column} LIKE ? OR "
-
end
-
5
where_clause += " 1<>1 "
-
-
5
query.where([where_clause] + values)
-
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