diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/lib/pg_searchable_regex.rb b/lib/pg_searchable_regex.rb index 1dc287c..530b24a 100644 --- a/lib/pg_searchable_regex.rb +++ b/lib/pg_searchable_regex.rb @@ -3,7 +3,7 @@ require 'active_support' require 'squeel' require_relative './text_to_tsquery' -require_relative './text_to_regex_query' +require_relative './text_to_sql_query' module PgSearchable extend ActiveSupport::Concern @@ -48,7 +48,7 @@ module PgSearchable def ts_search(value) return if @ts_search_fields.blank? || value.blank? - TextToRegexQuery.new(value, @ts_search_fields, @default_field, @ts_search_fields_mappings).where_clause( includes(@ts_joins).references(:all)) + 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? diff --git a/lib/text_to_sql_query.rb b/lib/text_to_sql_query.rb new file mode 100644 index 0000000..f0bbce2 --- /dev/null +++ b/lib/text_to_sql_query.rb @@ -0,0 +1,93 @@ +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 \ No newline at end of file diff --git a/spec/lib/text_to_sql_query_spec.rb b/spec/lib/text_to_sql_query_spec.rb new file mode 100644 index 0000000..fd76b8f --- /dev/null +++ b/spec/lib/text_to_sql_query_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +describe TextToRegexQuery do + include_examples 'pg_search', SimpleVectorModel + describe '.new' do + # tests simple search term without column name and without quotes + it { expect(described_class.new('some-default-value', [:"players.name"], :"players.name").where_clause(SimpleVectorModel)).to eq(['players.name ILIKE ?', '%some-default-value%']) } + + # tests simple search term with column name and without quotes + it { expect(described_class.new('title:ab', [:"players.title"], :"players.title").where_clause(SimpleVectorModel)).to eq(['players.title ILIKE ?', '%ab%']) } + + # tests simple search term with unknown column name and without quotes + it { expect(described_class.new('unknown:ab', [:"players.name"], :"players.name").where_clause(SimpleVectorModel)).to raise_error(RuntimeError, "Unknown field 'unknown'") } + + # 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(SimpleVectorModel)).to eq(["players.device_id ILIKE ?", "%ab"]) } + + # tests simple search term with column name and with quotes + it { expect(described_class.new('tag:"ab"', [:"players.name", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(["players.tag ILIKE ?", "%ab%"]) } + + # tests search without operators + it { expect(described_class.new('123 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(["(players.device_id ILIKE ? OR players.device_id ILIKE ?)", "%123%", "%456%"]) } + + # tests search with OR operator + it { expect(described_class.new('123 or 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(["(players.device_id ILIKE ? OR players.device_id ILIKE ?)", "%123%", "%456%"]) } + + # tests search with AND operator + it { expect(described_class.new('123 and 456', [:"players.name", :"players.device_id"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(["(players.device_id ILIKE ? AND players.device_id ILIKE ?)", "%123%", "%456%"]) } + + # 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(SimpleVectorModel)).to eq(["NOT players.device_id ILIKE ?", "%23%"]) } + + # tests search with NOT operator on non-default column + it { expect(described_class.new('not tag:23', [:"players.name", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(["NOT players.tag ILIKE ?", "%23%"]) } + + # tests search with mixed logical operators + it { expect(described_class.new('title:ab and not tag:hf-1', [:"players.title", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(['(players.title ILIKE ? AND NOT players.tag ILIKE ?)', '%ab%', '%hf-1%']) } + + # tests search with mixed logical operators without NOT' + it { expect(described_class.new('title:a and title:b or title:c', [:"players.title", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(['((players.title ILIKE ? AND players.title ILIKE ?) OR players.title ILIKE ?)', '%a%', '%b%', '%c%']) } + + # tests search with brackets in expression + it { expect(described_class.new('title:a and (title:b or title:c)', [:"players.title", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(['(players.title ILIKE ? AND (players.title ILIKE ? OR players.title ILIKE ?))', '%a%', '%b%', '%c%']) } + + # tests search with brackets in expression and with NOT operator + it { expect(described_class.new('title:a and not (title:b or title:c)', [:"players.title", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(['(players.title ILIKE ? AND NOT (players.title ILIKE ? OR players.title ILIKE ?))', '%a%', '%b%', '%c%']) } + + # tests search with special characters in search term + it { expect(described_class.new('title:"%a_\"', [:"players.title", :"players.tag"], :"players.device_id").where_clause(SimpleVectorModel)).to eq(['players.title ILIKE ?', '%\%a\_\\%']) } + + # tests search with field mappings + it { expect(described_class.new('tags:h1-r', [:'players.title', :'players.name', :'players.device_id'], :"players.device_id", { tags: "tags.name" }).where_clause(SimpleVectorModel)).to eq(['tags.name ILIKE ?', '%h1-r%']) } + + # tests search with field mappings when fields array has same mapping + it { expect(described_class.new('tags:hs1-r', [:'players.title', :'players.tags', :'players.device_id'], :"players.device_id", { tags: "tags.name" }).where_clause(SimpleVectorModel)).to eq(["players.tag ILIKE ?", "%ab%"]) } + + # 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.title', :'players.name', :'players.device_id'], :"players.device_id", { tags: 'tags.name' }).where_clause(SimpleVectorModel)).to eq(['((players.device_id ILIKE ? OR (tags.name ILIKE ? OR players.device_id ILIKE ?)) OR (players.device_id ILIKE ? AND (((players.device_id ILIKE ? OR players.device_id ILIKE ?) AND (players.device_id ILIKE ? OR players.device_id ILIKE ?)) AND NOT players.device_id 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%']) } + + end +end