pursuit 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e63f3c017580251d11f1db0994e4e771f9689e63ed2ae8f4c1c98e14ba27e0b
4
- data.tar.gz: 7956f93f4eea40de80878022796d823b57cbe95da8472a1784ab864fb6b17165
3
+ metadata.gz: c5d86e6be4355b96bdaf0d8c02ea680e5cf0f6c3c9f5fdcd3d8e9a20785aad8b
4
+ data.tar.gz: 310009d659879f97ae8bc9947594533fdb0c11f20702b2bf10e1003c072b4425
5
5
  SHA512:
6
- metadata.gz: 7cfd6c4114d8e02181a8e74a8865f862cfbc904b56a64819b6993a788bf83f847b0859037baf7f43fa3425acb5b33e937875dfffbb0e57abe7dcb094c78e8dde
7
- data.tar.gz: ca46b2fd3b10167df8b74419bd1e41ee200a943b3d73ce5842b52429f89b69fd111ab884b6941d5a84a402ecebab5831941838b10eee0454cb8df8d27c4caca0
6
+ metadata.gz: 49991dd50ed278c76d9dbc7f0dd8f48a7f41fa7905f284964a27df9bc90a55ad6833a5911b86ed159d4d4c88fc2aac736ac5feb2c1b8469fb9adbf02abca3f49
7
+ data.tar.gz: 6663dc11f0f6e6adc08f87834d303ed59e0269415132d7c0fdb54d96ee7ca926be69097dd26dd58d0bc836b9a9bf7f8ccfc35fd3e8f9c6976340e52b0719c5b1
data/Gemfile.lock CHANGED
@@ -1,9 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pursuit (0.1.0)
5
- activerecord (>= 5.2.0, < 6.1.0)
6
- activesupport (>= 5.2.0, < 6.1.0)
4
+ pursuit (0.3.0)
5
+ activerecord (>= 5.2.0, < 6.2.0)
6
+ activesupport (>= 5.2.0, < 6.2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -20,9 +20,27 @@ You can use the convenient DSL syntax to declare which attributes and relationsh
20
20
 
21
21
  ```ruby
22
22
  class Product < ActiveRecord::Base
23
- has_search relationships: { variations: %i[title] },
24
- keyed_attributes: %i[title description rating],
25
- unkeyed_attributes: %i[title description]
23
+ searchable do |o|
24
+ o.relation :variations, :title, :stock_status
25
+
26
+ # Attributes can be used for both keyed and unkeyed searching by default, but you can pass either `keyed: false` or
27
+ # `unkeyed: false` to restrict when the attribute is searched.
28
+ o.attribute :title
29
+ o.attribute :rating, unkeyed: false
30
+
31
+ # You can shorten the search keyword by passing the desired search term first, and then the real attribute name
32
+ # as the second argument.
33
+ # => "desc*=foo"
34
+ o.attribute :desc, :description
35
+
36
+ # It's also possible to query entirely custom Arel nodes by passing a block which returns the Arel node to query.
37
+ # You could use this to query a person's full name by concatenating their first and last name columns, for example.
38
+ o.attribute :title_length, unkeyed: false do
39
+ Arel::Nodes::NamedFunction.new('LENGTH', [
40
+ arel_table[:title]
41
+ ])
42
+ end
43
+ end
26
44
  end
27
45
  ```
28
46
 
@@ -3,5 +3,5 @@
3
3
  module Pursuit
4
4
  # @return [String] The gem's semantic version number.
5
5
  #
6
- VERSION = '0.1.0'
6
+ VERSION = '0.3.1'
7
7
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ module DSL
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ end
8
+
9
+ module ClassMethods
10
+ def searchable(&block)
11
+ if respond_to?(:search_options) || respond_to?(:search)
12
+ raise "#{self} already has #search and #search_options defined."
13
+ end
14
+
15
+ options = SearchOptions.new(self, &block)
16
+
17
+ define_singleton_method(:search_options) do
18
+ options
19
+ end
20
+
21
+ define_singleton_method(:search) do |query|
22
+ search = Pursuit::Search.new(options)
23
+ search.perform(query)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,9 +4,9 @@ module Pursuit
4
4
  class Railtie < Rails::Railtie
5
5
  initializer 'pursuit.active_record.inject_dsl' do
6
6
  ActiveSupport.on_load(:active_record) do
7
- require 'pursuit/active_record_dsl'
7
+ require 'pursuit/dsl'
8
8
 
9
- ActiveRecord::Base.include(Pursuit::ActiveRecordDSL)
9
+ ActiveRecord::Base.include(Pursuit::DSL)
10
10
  end
11
11
  end
12
12
  end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ class Search
5
+ # @return [SearchOptions] The options to use when building the search query.
6
+ #
7
+ attr_reader :options
8
+
9
+ # Create a new instance to search a specific ActiveRecord record class.
10
+ #
11
+ # @param options [SearchOptions]
12
+ #
13
+ def initialize(options)
14
+ @options = options
15
+ end
16
+
17
+ # Perform a search for the specified query.
18
+ #
19
+ # @param query [String] The query to transform into a SQL search.
20
+ # @return [ActiveRecord::Relation] The search results.
21
+ #
22
+ def perform(query)
23
+ options.record_class.where(build_arel(query))
24
+ end
25
+
26
+ private
27
+
28
+ def build_arel(query)
29
+ parser = SearchTermParser.new(query, keys: options.keys)
30
+ unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
31
+ keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)
32
+
33
+ if unkeyed_arel && keyed_arel
34
+ unkeyed_arel.and(keyed_arel)
35
+ else
36
+ unkeyed_arel || keyed_arel
37
+ end
38
+ end
39
+
40
+ def build_arel_for_unkeyed_term(value)
41
+ return nil if value.blank?
42
+
43
+ sanitized_value = "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
44
+ options.unkeyed_attributes.reduce(nil) do |chain, (attribute_name, node_builder)|
45
+ node = node_builder.call.matches(sanitized_value)
46
+ chain ? chain.or(node) : node
47
+ end
48
+ end
49
+
50
+ def build_arel_for_keyed_terms(terms)
51
+ return nil if terms.blank?
52
+
53
+ terms.reduce(nil) do |chain, term|
54
+ reflection = options.record_class.reflections[term.key]
55
+ node = if reflection.present?
56
+ attribute_names = options.relations[term.key.to_sym]
57
+ build_arel_for_reflection(reflection, attribute_names, term.operator, term.value)
58
+ else
59
+ node_builder = options.keyed_attributes[term.key.to_sym]
60
+ build_arel_for_node(node_builder.call, term.operator, term.value)
61
+ end
62
+
63
+ chain ? chain.and(node) : node
64
+ end
65
+ end
66
+
67
+ def build_arel_for_node(node, operator, value)
68
+ sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
69
+ sanitized_value = sanitized_value.to_i if sanitized_value =~ /^[0-9]+$/
70
+
71
+ case operator
72
+ when '>' then node.gt(sanitized_value)
73
+ when '>=' then node.gteq(sanitized_value)
74
+ when '<' then node.lt(sanitized_value)
75
+ when '<=' then node.lteq(sanitized_value)
76
+ when '*=' then node.matches("%#{sanitized_value}%")
77
+ when '!*=' then node.does_not_match("%#{sanitized_value}%")
78
+ when '!=' then node.not_eq(sanitized_value)
79
+ when '=='
80
+ if value.present?
81
+ node.eq(sanitized_value)
82
+ else
83
+ node.eq(nil).or(node.eq(''))
84
+ end
85
+ else
86
+ raise "The operator '#{operator}' is not supported."
87
+ end
88
+ end
89
+
90
+ def build_arel_for_reflection(reflection, attribute_names, operator, value)
91
+ nodes = build_arel_for_reflection_join(reflection)
92
+ count_nodes = build_arel_for_relation_count(nodes, operator, value)
93
+ return count_nodes if count_nodes.present?
94
+
95
+ match_nodes = attribute_names.reduce(nil) do |chain, attribute_name|
96
+ node = build_arel_for_node(reflection.klass.arel_table[attribute_name], operator, value)
97
+ chain ? chain.or(node) : node
98
+ end
99
+
100
+ return nil if match_nodes.blank?
101
+
102
+ nodes.where(match_nodes).project(1).exists
103
+ end
104
+
105
+ def build_arel_for_reflection_join(reflection)
106
+ reflection_table = reflection.klass.arel_table
107
+ reflection_through = reflection.through_reflection
108
+
109
+ if reflection_through.present?
110
+ # :has_one through / :has_many through
111
+ reflection_through_table = reflection_through.klass.arel_table
112
+ reflection_table.join(reflection_through_table).on(
113
+ reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
114
+ ).where(
115
+ reflection_through_table[reflection_through.foreign_key].eq(
116
+ options.record_class.arel_table[options.record_class.primary_key]
117
+ )
118
+ )
119
+ else
120
+ # :has_one / :has_many
121
+ reflection_table.where(
122
+ reflection_table[reflection.foreign_key].eq(
123
+ options.record_class.arel_table[options.record_class.primary_key]
124
+ )
125
+ )
126
+ end
127
+ end
128
+
129
+ def build_arel_for_relation_count(nodes, operator, value)
130
+ node_builder = proc do |klass|
131
+ count = ActiveRecord::Base.sanitize_sql_like(value).to_i
132
+ klass.new(nodes.project(Arel.star.count), count)
133
+ end
134
+
135
+ case operator
136
+ when '>' then node_builder.call(Arel::Nodes::GreaterThan)
137
+ when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
138
+ when '<' then node_builder.call(Arel::Nodes::LessThan)
139
+ when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
140
+ else
141
+ return nil unless value =~ /^([0-9]+)$/
142
+
143
+ case operator
144
+ when '==' then node_builder.call(Arel::Nodes::Equality)
145
+ when '!=' then node_builder.call(Arel::Nodes::NotEqual)
146
+ when '*=' then node_builder.call(Arel::Nodes::Matches)
147
+ when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pursuit
4
+ class SearchOptions
5
+ # @return [Struct] The structure which holds the search options for a single attribute.
6
+ #
7
+ AttributeOptions = Struct.new(:keyed, :unkeyed, :block)
8
+
9
+ # @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class to search.
10
+ #
11
+ attr_reader :record_class
12
+
13
+ # @return [Hash<Symbol, Array<Symbol>>] The attribute names of the record's relatives which can be searched.
14
+ #
15
+ attr_reader :relations
16
+
17
+ # @return [Hash<Symbol, AttributeOptions>] The attributes which can be searched.
18
+ #
19
+ attr_reader :attributes
20
+
21
+ # Create a new `SearchOptions` and call the passed block to setup the options.
22
+ #
23
+ # @params record_class [Class<ActiveRecord::Base>]
24
+ # @params block [Proc]
25
+ #
26
+ def initialize(record_class, &block)
27
+ @record_class = record_class
28
+ @relations = {}
29
+ @attributes = {}
30
+
31
+ block.call(self) if block
32
+ end
33
+
34
+ # @return [Hash<Symbol, Proc>] The attributes which can be queried using a keyed term.
35
+ #
36
+ def keyed_attributes
37
+ attributes.each_with_object({}) do |(name, options), keyed_attributes|
38
+ keyed_attributes[name] = options.block if options.keyed
39
+ end
40
+ end
41
+
42
+ # @return [Hash<Symbol, Proc>] The attributes which can be queried using an unkeyed term.
43
+ #
44
+ def unkeyed_attributes
45
+ attributes.each_with_object({}) do |(name, options), unkeyed_attributes|
46
+ unkeyed_attributes[name] = options.block if options.unkeyed
47
+ end
48
+ end
49
+
50
+ # @return [Array<String>] The collection of all possible attributes which can be used as a keyed term.
51
+ #
52
+ def keys
53
+ keys = relations.keys + attributes.select { |_, a| a.keyed }.keys
54
+ keys.map(&:to_s).uniq
55
+ end
56
+
57
+ # Add a relation to search.
58
+ #
59
+ # @param name [Symbol] The name of the relationship attribute.
60
+ # @param attribute_names [Splat] The name of the attributes within the relationship to search.
61
+ #
62
+ def relation(name, *attribute_names)
63
+ relations[name] = attribute_names
64
+ nil
65
+ end
66
+
67
+ # Add an attribute to search.
68
+ #
69
+ # @param term_name [Symbol] The keyed search term (can be an existing attribute, or a custom value when
70
+ # passing either the `attribute_name` or a block returning an Arel node).
71
+ # @param attribute_name [Symbol] The attribute name to search (defaults to the keyword, when left blank and no
72
+ # block is passed).
73
+ # @param keyed [Boolean] `true` when the attribute should be searchable using a keyed term,
74
+ # `false` otherwise.
75
+ # @param unkeyed [Boolean] `true` when the attribute should be searchable using an unkeyed term,
76
+ # `false` otherwise.
77
+ # @param block [Proc] A block which returns the Arel node to query against. When left blank, the
78
+ # matching attribute from `.arel_table` is queried instead.
79
+ #
80
+ def attribute(term_name, attribute_name = nil, keyed: true, unkeyed: true, &block)
81
+ block ||= -> { record_class.arel_table[attribute_name || term_name] }
82
+ attributes[term_name] = AttributeOptions.new(keyed, unkeyed, block)
83
+ nil
84
+ end
85
+ end
86
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pursuit
4
- class TermParser
4
+ class SearchTermParser
5
5
  # @return [Struct] Represents a single keyed term extracted from a query.
6
6
  #
7
7
  KeyedTerm = Struct.new(:key, :operator, :value)
8
8
 
9
- # @return [Array<Pursuit::TermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
9
+ # @return [Array<Pursuit::SearchTermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
10
10
  #
11
11
  attr_reader :keyed_terms
12
12
 
@@ -14,14 +14,12 @@ module Pursuit
14
14
  #
15
15
  attr_reader :unkeyed_term
16
16
 
17
- # Create a new `TermParser` by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
17
+ # Create a new search term parser by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
18
18
  #
19
19
  # @param query [String] The query to parse.
20
- # @param keys [Array<Symbol>] The keys which are permitted for use as keyed terms.
20
+ # @param keys [Array<String>] The keys which are permitted for use as keyed terms.
21
21
  #
22
22
  def initialize(query, keys: [])
23
- keys = keys.map(&:to_s)
24
-
25
23
  @keyed_terms = []
26
24
  @unkeyed_term = query.gsub(/(\s+)?(\w+)(==|\*=|!=|!\*=|<=|>=|<|>)("([^"]+)?"|'([^']+)?'|[^\s]+)(\s+)?/) do |term|
27
25
  key = Regexp.last_match(2)
data/lib/pursuit.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pursuit/constants'
4
- require 'pursuit/term_parser'
5
- require 'pursuit/active_record_search'
4
+ require 'pursuit/search_options'
5
+ require 'pursuit/search_term_parser'
6
+ require 'pursuit/search'
6
7
  require 'pursuit/railtie' if defined?(Rails::Railtie)
data/pursuit.gemspec CHANGED
@@ -21,8 +21,8 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.metadata['yard.run'] = 'yri'
23
23
 
24
- spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '< 6.1.0'
25
- spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '< 6.1.0'
24
+ spec.add_runtime_dependency 'activerecord', '>= 5.2.0', '< 6.2.0'
25
+ spec.add_runtime_dependency 'activesupport', '>= 5.2.0', '< 6.2.0'
26
26
 
27
27
  spec.add_development_dependency 'bundler', '~> 2.0'
28
28
  spec.add_development_dependency 'combustion', '~> 1.1'
@@ -3,9 +3,18 @@
3
3
  class Product < ActiveRecord::Base
4
4
  has_many :variations, class_name: 'ProductVariation', inverse_of: :product
5
5
 
6
- has_search relationships: { variations: %i[title stock_status] },
7
- keyed_attributes: %i[title description rating],
8
- unkeyed_attributes: %i[title description]
9
-
10
6
  validates :title, presence: true
7
+
8
+ searchable do |o|
9
+ o.relation :variations, :title, :stock_status
10
+
11
+ o.attribute :title
12
+ o.attribute :description
13
+ o.attribute :rating, unkeyed: false
14
+ o.attribute :title_length, unkeyed: false do
15
+ Arel::Nodes::NamedFunction.new('LENGTH', [
16
+ arel_table[:title]
17
+ ])
18
+ end
19
+ end
11
20
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::DSL do
4
+ subject(:record) { Product.new }
5
+
6
+ describe '.search_options' do
7
+ subject(:search_options) { record.class.search_options }
8
+
9
+ it { is_expected.to be_a(Pursuit::SearchOptions) }
10
+ end
11
+
12
+ describe '.search' do
13
+ subject(:search) { record.class.search('funky') }
14
+
15
+ let(:product_a) { Product.create!(title: 'Plain Shirt') }
16
+ let(:product_b) { Product.create!(title: 'Funky Shirt') }
17
+
18
+ it 'is expected to return the matching records' do
19
+ expect(search).to contain_exactly(product_b)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::SearchOptions do
4
+ subject(:search_options) { described_class.new(Product) }
5
+
6
+ let(:title_length_node_builder) do
7
+ proc do
8
+ Arel::Nodes::NamedFunction.new('LENGTH', [
9
+ Product.arel_table[:title]
10
+ ])
11
+ end
12
+ end
13
+
14
+ describe '#record_class' do
15
+ subject(:record_class) { search_options.record_class }
16
+
17
+ it 'is expected to eq the class passed during initialization' do
18
+ expect(record_class).to eq(Product)
19
+ end
20
+ end
21
+
22
+ describe '#relations' do
23
+ subject(:relations) { search_options.relations }
24
+
25
+ before do
26
+ search_options.relation :variations, :title, :stock_status
27
+ end
28
+
29
+ it 'is expected to contain the correct relations' do
30
+ expect(relations).to eq(variations: %i[title stock_status])
31
+ end
32
+ end
33
+
34
+ describe '#keyed_attributes' do
35
+ subject(:keyed_attributes) { search_options.keyed_attributes }
36
+
37
+ before do
38
+ search_options.attribute :title, keyed: false
39
+ search_options.attribute :title_length, &title_length_node_builder
40
+ search_options.attribute :description
41
+ search_options.attribute :rating, unkeyed: false
42
+ end
43
+
44
+ it 'is expected to contain the correct keyed attributes' do
45
+ expect(keyed_attributes.keys).to contain_exactly(:title_length, :description, :rating)
46
+ end
47
+
48
+ it 'is expected to set a default node builder for attributes declared without a block' do
49
+ expect(keyed_attributes[:description].call).to eq(Product.arel_table[:description])
50
+ end
51
+
52
+ it 'is expected to set a custom node builder for attributes declared with a block' do
53
+ expect(keyed_attributes[:title_length]).to eq(title_length_node_builder)
54
+ end
55
+ end
56
+
57
+ describe '#unkeyed_attributes' do
58
+ subject(:unkeyed_attributes) { search_options.unkeyed_attributes }
59
+
60
+ before do
61
+ search_options.attribute :title, keyed: false
62
+ search_options.attribute :title_length, &title_length_node_builder
63
+ search_options.attribute :description
64
+ search_options.attribute :rating, unkeyed: false
65
+ end
66
+
67
+ it 'is expected to contain the correct unkeyed attributes' do
68
+ expect(unkeyed_attributes.keys).to contain_exactly(:title, :title_length, :description)
69
+ end
70
+
71
+ it 'is expected to set a default node builder for attributes declared without a block' do
72
+ expect(unkeyed_attributes[:title].call).to eq(Product.arel_table[:title])
73
+ end
74
+
75
+ it 'is expected to set a custom node builder for attributes declared with a block' do
76
+ expect(unkeyed_attributes[:title_length]).to eq(title_length_node_builder)
77
+ end
78
+ end
79
+
80
+ describe '#relation' do
81
+ subject(:relation) { search_options.relation(:variations, :title, :stock_status) }
82
+
83
+ it 'is expected to add the relation to #relations' do
84
+ expect { relation }.to change(search_options, :relations).from({}).to(variations: %i[title stock_status])
85
+ end
86
+ end
87
+
88
+ describe '#attribute' do
89
+ subject(:attribute) { search_options.attribute(:description) }
90
+
91
+ it { is_expected.to eq(nil) }
92
+
93
+ it 'is expected to add the attribute to #attributes' do
94
+ expect { attribute }.to change(search_options.attributes, :keys).from([]).to(%i[description])
95
+ end
96
+
97
+ it 'is expected to allow keyed searching by default' do
98
+ attribute
99
+ expect(search_options.attributes[:description].keyed).to eq(true)
100
+ end
101
+
102
+ it 'is expected to allow unkeyed searching by default' do
103
+ attribute
104
+ expect(search_options.attributes[:description].unkeyed).to eq(true)
105
+ end
106
+
107
+ it 'is expected to query the #term_name attribute' do
108
+ attribute
109
+ expect(search_options.attributes[:description].block.call).to eq(Product.arel_table[:description])
110
+ end
111
+
112
+ context 'when passing the attribute name to search' do
113
+ subject(:attribute) { search_options.attribute(:desc, :description) }
114
+
115
+ it 'is expected to query the #attribute_name attribute' do
116
+ attribute
117
+ expect(search_options.attributes[:desc].block.call).to eq(Product.arel_table[:description])
118
+ end
119
+ end
120
+
121
+ context 'when passing :keyed eq false' do
122
+ subject(:attribute) { search_options.attribute(:description, keyed: false) }
123
+
124
+ it 'is expected to disallow keyed searching' do
125
+ attribute
126
+ expect(search_options.attributes[:description].keyed).to eq(false)
127
+ end
128
+ end
129
+
130
+ context 'when passing :unkeyed eq false' do
131
+ subject(:attribute) { search_options.attribute(:description, unkeyed: false) }
132
+
133
+ it 'is expected to disallow unkeyed searching' do
134
+ attribute
135
+ expect(search_options.attributes[:description].unkeyed).to eq(false)
136
+ end
137
+ end
138
+
139
+ context 'when passing a block' do
140
+ subject(:attribute) { search_options.attribute(:description, &title_length_node_builder) }
141
+
142
+ it 'is expected to query the result of the passed block' do
143
+ attribute
144
+ expect(search_options.attributes[:description].block).to eq(title_length_node_builder)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -1,17 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- RSpec.describe Pursuit::ActiveRecordSearch do
4
- subject(:active_record_search) do
5
- described_class.new(
6
- Product,
7
- relationships: { variations: %i[title stock_status] },
8
- keyed_attributes: %i[title description rating],
9
- unkeyed_attributes: %i[title description]
10
- )
3
+ RSpec.describe Pursuit::Search do
4
+ subject(:search) { described_class.new(search_options) }
5
+
6
+ let(:search_options) do
7
+ Pursuit::SearchOptions.new(Product) do |o|
8
+ o.relation :variations, :title, :stock_status
9
+
10
+ o.attribute :title
11
+ o.attribute :description
12
+ o.attribute :rating, unkeyed: false
13
+ o.attribute :title_length, unkeyed: false do
14
+ Arel::Nodes::NamedFunction.new('LENGTH', [
15
+ Product.arel_table[:title]
16
+ ])
17
+ end
18
+ end
11
19
  end
12
20
 
13
- describe '#search' do
14
- subject(:search) { active_record_search.search(query) }
21
+ describe '#perform' do
22
+ subject(:perform) { search.perform(query) }
15
23
 
16
24
  context 'when passed a blank query' do
17
25
  let(:query) { '' }
@@ -25,7 +33,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
25
33
  end
26
34
 
27
35
  it 'is expected to contain all records' do
28
- expect(search).to contain_exactly(product_a, product_b)
36
+ expect(perform).to contain_exactly(product_a, product_b)
29
37
  end
30
38
  end
31
39
 
@@ -43,7 +51,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
43
51
  end
44
52
 
45
53
  it 'is expected to contain the matching records' do
46
- expect(search).to contain_exactly(product_a, product_b)
54
+ expect(perform).to contain_exactly(product_a, product_b)
47
55
  end
48
56
  end
49
57
 
@@ -61,7 +69,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
61
69
  end
62
70
 
63
71
  it 'is expected to contain the matching records' do
64
- expect(search).to contain_exactly(product_b)
72
+ expect(perform).to contain_exactly(product_b)
65
73
  end
66
74
  end
67
75
 
@@ -79,7 +87,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
79
87
  end
80
88
 
81
89
  it 'is expected to contain the matching records' do
82
- expect(search).to contain_exactly(product_a, product_c)
90
+ expect(perform).to contain_exactly(product_a, product_c)
83
91
  end
84
92
  end
85
93
 
@@ -97,7 +105,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
97
105
  end
98
106
 
99
107
  it 'is expected to contain the matching records' do
100
- expect(search).to contain_exactly(product_a, product_b)
108
+ expect(perform).to contain_exactly(product_a, product_b)
101
109
  end
102
110
  end
103
111
 
@@ -115,7 +123,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
115
123
  end
116
124
 
117
125
  it 'is expected to contain the matching records' do
118
- expect(search).to contain_exactly(product_a, product_b)
126
+ expect(perform).to contain_exactly(product_a, product_b)
119
127
  end
120
128
  end
121
129
 
@@ -133,7 +141,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
133
141
  end
134
142
 
135
143
  it 'is expected to contain the matching records' do
136
- expect(search).to contain_exactly(product_b, product_c)
144
+ expect(perform).to contain_exactly(product_b, product_c)
137
145
  end
138
146
  end
139
147
 
@@ -151,7 +159,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
151
159
  end
152
160
 
153
161
  it 'is expected to contain the matching records' do
154
- expect(search).to contain_exactly(product_b, product_c)
162
+ expect(perform).to contain_exactly(product_b, product_c)
155
163
  end
156
164
  end
157
165
 
@@ -169,7 +177,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
169
177
  end
170
178
 
171
179
  it 'is expected to contain the matching records' do
172
- expect(search).to contain_exactly(product_b, product_c)
180
+ expect(perform).to contain_exactly(product_b, product_c)
173
181
  end
174
182
  end
175
183
 
@@ -187,7 +195,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
187
195
  end
188
196
 
189
197
  it 'is expected to contain the matching records' do
190
- expect(search).to contain_exactly(product_a, product_b)
198
+ expect(perform).to contain_exactly(product_a, product_b)
191
199
  end
192
200
  end
193
201
 
@@ -205,7 +213,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
205
213
  end
206
214
 
207
215
  it 'is expected to contain the matching records' do
208
- expect(search).to contain_exactly(product_a, product_b)
216
+ expect(perform).to contain_exactly(product_a, product_b)
209
217
  end
210
218
  end
211
219
 
@@ -223,7 +231,23 @@ RSpec.describe Pursuit::ActiveRecordSearch do
223
231
  end
224
232
 
225
233
  it 'is expected to contain the matching records' do
226
- expect(search).to contain_exactly(product_b)
234
+ expect(perform).to contain_exactly(product_b)
235
+ end
236
+ end
237
+
238
+ context 'when passed a virtual keyed attribute query' do
239
+ let(:query) { 'title_length==5' }
240
+
241
+ let(:product_a) { Product.create!(title: 'Plain Shirt') }
242
+ let(:product_b) { Product.create!(title: 'Socks') }
243
+
244
+ before do
245
+ product_a
246
+ product_b
247
+ end
248
+
249
+ it 'is expected to contain the matching records' do
250
+ expect(perform).to contain_exactly(product_b)
227
251
  end
228
252
  end
229
253
 
@@ -247,7 +271,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
247
271
  end
248
272
 
249
273
  it 'is expected to contain the matching records' do
250
- expect(search).to contain_exactly(product_b)
274
+ expect(perform).to contain_exactly(product_b)
251
275
  end
252
276
  end
253
277
 
@@ -275,7 +299,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
275
299
  end
276
300
 
277
301
  it 'is expected to contain the matching records' do
278
- expect(search).to contain_exactly(product_c)
302
+ expect(perform).to contain_exactly(product_c)
279
303
  end
280
304
  end
281
305
 
@@ -307,7 +331,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
307
331
  end
308
332
 
309
333
  it 'is expected to contain the matching records' do
310
- expect(search).to contain_exactly(product_a, product_b)
334
+ expect(perform).to contain_exactly(product_a, product_b)
311
335
  end
312
336
  end
313
337
 
@@ -339,7 +363,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
339
363
  end
340
364
 
341
365
  it 'is expected to contain the matching records' do
342
- expect(search).to contain_exactly(product_a, product_b)
366
+ expect(perform).to contain_exactly(product_a, product_b)
343
367
  end
344
368
  end
345
369
 
@@ -371,7 +395,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
371
395
  end
372
396
 
373
397
  it 'is expected to contain the matching records' do
374
- expect(search).to contain_exactly(product_a, product_c)
398
+ expect(perform).to contain_exactly(product_a, product_c)
375
399
  end
376
400
  end
377
401
 
@@ -403,7 +427,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
403
427
  end
404
428
 
405
429
  it 'is expected to contain the matching records' do
406
- expect(search).to contain_exactly(product_a, product_c)
430
+ expect(perform).to contain_exactly(product_a, product_c)
407
431
  end
408
432
  end
409
433
 
@@ -435,7 +459,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
435
459
  end
436
460
 
437
461
  it 'is expected to contain the matching records' do
438
- expect(search).to contain_exactly(product_a)
462
+ expect(perform).to contain_exactly(product_a)
439
463
  end
440
464
  end
441
465
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Pursuit::SearchTermParser do
4
+ subject(:parser) { described_class.new(query, keys: keys) }
5
+
6
+ let(:keys) { %w[title description rating stock_status] }
7
+ let(:query) do
8
+ "plain title!='Socks' description*=\"green\" stock_status==in_stock shirt rating>=2 other*=thing rating<5"
9
+ end
10
+
11
+ describe '#unkeyed_term' do
12
+ subject(:unkeyed_term) { parser.unkeyed_term }
13
+
14
+ it 'is expected to eq the correct unkeyed term' do
15
+ expect(unkeyed_term).to eq('plain shirt other*=thing')
16
+ end
17
+ end
18
+
19
+ describe '#keyed_terms' do
20
+ subject(:keyed_terms) { parser.keyed_terms }
21
+
22
+ it 'is expected to eq the correct keyed terms' do
23
+ expect(keyed_terms).to eq([
24
+ Pursuit::SearchTermParser::KeyedTerm.new('title', '!=', 'Socks'),
25
+ Pursuit::SearchTermParser::KeyedTerm.new('description', '*=', 'green'),
26
+ Pursuit::SearchTermParser::KeyedTerm.new('stock_status', '==', 'in_stock'),
27
+ Pursuit::SearchTermParser::KeyedTerm.new('rating', '>=', '2'),
28
+ Pursuit::SearchTermParser::KeyedTerm.new('rating', '<', '5')
29
+ ])
30
+ end
31
+ end
32
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pursuit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nialto Services
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-11-18 00:00:00.000000000 Z
11
+ date: 2021-11-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -19,7 +19,7 @@ dependencies:
19
19
  version: 5.2.0
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: 6.1.0
22
+ version: 6.2.0
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -29,7 +29,7 @@ dependencies:
29
29
  version: 5.2.0
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: 6.1.0
32
+ version: 6.2.0
33
33
  - !ruby/object:Gem::Dependency
34
34
  name: activesupport
35
35
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +39,7 @@ dependencies:
39
39
  version: 5.2.0
40
40
  - - "<"
41
41
  - !ruby/object:Gem::Version
42
- version: 6.1.0
42
+ version: 6.2.0
43
43
  type: :runtime
44
44
  prerelease: false
45
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -49,7 +49,7 @@ dependencies:
49
49
  version: 5.2.0
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
- version: 6.1.0
52
+ version: 6.2.0
53
53
  - !ruby/object:Gem::Dependency
54
54
  name: bundler
55
55
  requirement: !ruby/object:Gem::Requirement
@@ -199,21 +199,23 @@ files:
199
199
  - bin/setup
200
200
  - config.ru
201
201
  - lib/pursuit.rb
202
- - lib/pursuit/active_record_dsl.rb
203
- - lib/pursuit/active_record_search.rb
204
202
  - lib/pursuit/constants.rb
203
+ - lib/pursuit/dsl.rb
205
204
  - lib/pursuit/railtie.rb
206
- - lib/pursuit/term_parser.rb
205
+ - lib/pursuit/search.rb
206
+ - lib/pursuit/search_options.rb
207
+ - lib/pursuit/search_term_parser.rb
207
208
  - pursuit.gemspec
208
209
  - spec/internal/app/models/product.rb
209
210
  - spec/internal/app/models/product_variation.rb
210
211
  - spec/internal/config/database.yml
211
212
  - spec/internal/db/schema.rb
212
213
  - spec/internal/log/.keep
213
- - spec/pursuit/active_record_dsl_spec.rb
214
- - spec/pursuit/active_record_search_spec.rb
215
214
  - spec/pursuit/constants_spec.rb
216
- - spec/pursuit/term_parser_spec.rb
215
+ - spec/pursuit/dsl_spec.rb
216
+ - spec/pursuit/search_options_spec.rb
217
+ - spec/pursuit/search_spec.rb
218
+ - spec/pursuit/search_term_parser_spec.rb
217
219
  - spec/spec_helper.rb
218
220
  - travis/gemfiles/5.2.gemfile
219
221
  - travis/gemfiles/6.0.gemfile
@@ -248,8 +250,9 @@ test_files:
248
250
  - spec/internal/config/database.yml
249
251
  - spec/internal/db/schema.rb
250
252
  - spec/internal/log/.keep
251
- - spec/pursuit/active_record_dsl_spec.rb
252
- - spec/pursuit/active_record_search_spec.rb
253
253
  - spec/pursuit/constants_spec.rb
254
- - spec/pursuit/term_parser_spec.rb
254
+ - spec/pursuit/dsl_spec.rb
255
+ - spec/pursuit/search_options_spec.rb
256
+ - spec/pursuit/search_spec.rb
257
+ - spec/pursuit/search_term_parser_spec.rb
255
258
  - spec/spec_helper.rb
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pursuit
4
- # Provides a DSL for the `ActiveRecord::Base` class.
5
- #
6
- module ActiveRecordDSL
7
- def self.included(base)
8
- base.extend ClassMethods
9
- end
10
-
11
- module ClassMethods
12
- def has_search(relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
13
- raise 'The #search method has already been defined.' if respond_to?(:search)
14
-
15
- # The value of `self` is a constant for the current `ActiveRecord::Base` subclass. We'll need to capture this
16
- # in a custom variable to make it accessible from within the #define_method block.
17
- klass = self
18
-
19
- define_method(:search) do |query|
20
- search = Pursuit::ActiveRecordSearch.new(
21
- klass,
22
- relationships: relationships,
23
- keyed_attributes: keyed_attributes,
24
- unkeyed_attributes: unkeyed_attributes
25
- )
26
-
27
- search.search(query)
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,165 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pursuit
4
- class ActiveRecordSearch
5
- # @return [Class<ActiveRecord::Base>] The `ActiveRecord::Base` child class being searched.
6
- #
7
- attr_reader :klass
8
-
9
- # @return [Hash<Symbol, Array<Symbol>>] The attribute names for relationships which can be queried with a keyed
10
- # term and the attributes in the relative's table which should be searched.
11
- #
12
- attr_accessor :relationships
13
-
14
- # @return [Array<Symbol>] The attribute names which can be queried with a keyed term (e.g. 'last_name:*herb').
15
- #
16
- attr_accessor :keyed_attributes
17
-
18
- # @return [Array<Symbol>] The attribute names which can be queried with an unkeyed term (e.g. 'herb').
19
- #
20
- attr_accessor :unkeyed_attributes
21
-
22
- # Create a new instance to search a specific ActiveRecord record class.
23
- #
24
- # @param klass [Class<ActiveRecord::Base>]
25
- # @param relationships [Hash<Symbol, Array<Symbol>>]
26
- # @param keyed_attributes [Array<Symbol>]
27
- # @param unkeyed_attributes [Array<Symbol>]
28
- #
29
- def initialize(klass, relationships: {}, keyed_attributes: [], unkeyed_attributes: [])
30
- @klass = klass
31
- @relationships = relationships
32
- @keyed_attributes = keyed_attributes
33
- @unkeyed_attributes = unkeyed_attributes
34
- end
35
-
36
- # Search the record with the specified query.
37
- #
38
- # @param query [String] The query to transform into a SQL search.
39
- # @return [ActiveRecord::Relation] The search results.
40
- #
41
- def search(query)
42
- klass.where(build_arel(query))
43
- end
44
-
45
- private
46
-
47
- def build_arel(query)
48
- parser = TermParser.new(query, keys: relationships.keys + keyed_attributes)
49
- unkeyed_arel = build_arel_for_unkeyed_term(parser.unkeyed_term)
50
- keyed_arel = build_arel_for_keyed_terms(parser.keyed_terms)
51
-
52
- if unkeyed_arel && keyed_arel
53
- unkeyed_arel.and(keyed_arel)
54
- else
55
- unkeyed_arel || keyed_arel
56
- end
57
- end
58
-
59
- def build_arel_for_unkeyed_term(value)
60
- return nil if value.blank?
61
-
62
- sanitized_value = "%#{klass.sanitize_sql_like(value)}%"
63
- unkeyed_attributes.reduce(nil) do |chain, attribute_name|
64
- node = klass.arel_table[attribute_name].matches(sanitized_value)
65
- chain ? chain.or(node) : node
66
- end
67
- end
68
-
69
- def build_arel_for_keyed_terms(terms)
70
- return nil if terms.blank?
71
-
72
- terms.reduce(nil) do |chain, term|
73
- reflection = klass.reflections[term.key]
74
- node = if reflection.present?
75
- keys = relationships[term.key.to_sym].presence || []
76
- build_arel_for_reflection(reflection, keys, term.operator, term.value)
77
- else
78
- build_arel_for_attribute(klass.arel_table[term.key], term.operator, term.value)
79
- end
80
-
81
- chain ? chain.and(node) : node
82
- end
83
- end
84
-
85
- def build_arel_for_attribute(attribute, operator, value)
86
- sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
87
-
88
- case operator
89
- when '>' then attribute.gt(sanitized_value)
90
- when '>=' then attribute.gteq(sanitized_value)
91
- when '<' then attribute.lt(sanitized_value)
92
- when '<=' then attribute.lteq(sanitized_value)
93
- when '*=' then attribute.matches("%#{sanitized_value}%")
94
- when '!*=' then attribute.does_not_match("%#{sanitized_value}%")
95
- when '!=' then attribute.not_eq(sanitized_value)
96
- when '=='
97
- if value.present?
98
- attribute.eq(sanitized_value)
99
- else
100
- attribute.eq(nil).or(attribute.eq(''))
101
- end
102
- else
103
- raise "The operator '#{operator}' is not supported."
104
- end
105
- end
106
-
107
- def build_arel_for_reflection(reflection, relation_attributes, operator, value)
108
- nodes = build_arel_for_reflection_join(reflection)
109
- count_nodes = build_arel_for_relation_count(nodes, operator, value)
110
- return count_nodes if count_nodes.present?
111
-
112
- match_nodes = relation_attributes.reduce(nil) do |chain, attribute_name|
113
- node = build_arel_for_attribute(reflection.klass.arel_table[attribute_name], operator, value)
114
- chain ? chain.or(node) : node
115
- end
116
-
117
- return nil if match_nodes.blank?
118
-
119
- nodes.where(match_nodes).project(1).exists
120
- end
121
-
122
- def build_arel_for_reflection_join(reflection)
123
- reflection_table = reflection.klass.arel_table
124
- reflection_through = reflection.through_reflection
125
-
126
- if reflection_through.present?
127
- # :has_one through / :has_many through
128
- reflection_through_table = reflection_through.klass.arel_table
129
- reflection_table.join(reflection_through_table).on(
130
- reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
131
- ).where(
132
- reflection_through_table[reflection_through.foreign_key].eq(klass.arel_table[klass.primary_key])
133
- )
134
- else
135
- # :has_one / :has_many
136
- reflection_table.where(
137
- reflection_table[reflection.foreign_key].eq(klass.arel_table[klass.primary_key])
138
- )
139
- end
140
- end
141
-
142
- def build_arel_for_relation_count(nodes, operator, value)
143
- build = proc do |klass|
144
- count = ActiveRecord::Base.sanitize_sql_like(value).to_i
145
- klass.new(nodes.project(Arel.star.count), count)
146
- end
147
-
148
- case operator
149
- when '>' then build.call(Arel::Nodes::GreaterThan)
150
- when '>=' then build.call(Arel::Nodes::GreaterThanOrEqual)
151
- when '<' then build.call(Arel::Nodes::LessThan)
152
- when '<=' then build.call(Arel::Nodes::LessThanOrEqual)
153
- else
154
- return nil unless value =~ /^([0-9]+)$/
155
-
156
- case operator
157
- when '==' then build.call(Arel::Nodes::Equality)
158
- when '!=' then build.call(Arel::Nodes::NotEqual)
159
- when '*=' then build.call(Arel::Nodes::Matches)
160
- when '!*=' then build.call(Arel::Nodes::DoesNotMatch)
161
- end
162
- end
163
- end
164
- end
165
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- RSpec.describe Pursuit::ActiveRecordDSL do
4
- subject(:product) { Product.new }
5
-
6
- it { is_expected.to respond_to(:search) }
7
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- RSpec.describe Pursuit::TermParser do
4
- subject(:term_parser) { described_class.new(query, keys: keys) }
5
-
6
- let(:keys) { %i[title description rating stock_status] }
7
- let(:query) do
8
- "plain title!='Socks' description*=\"green\" stock_status==in_stock shirt rating>=2 other*=thing rating<5"
9
- end
10
-
11
- describe '#unkeyed_term' do
12
- subject(:unkeyed_term) { term_parser.unkeyed_term }
13
-
14
- it 'is expected to eq the correct unkeyed term' do
15
- expect(unkeyed_term).to eq('plain shirt other*=thing')
16
- end
17
- end
18
-
19
- describe '#keyed_terms' do
20
- subject(:keyed_terms) { term_parser.keyed_terms }
21
-
22
- it 'is expected to eq the correct keyed terms' do
23
- expect(keyed_terms).to eq([
24
- Pursuit::TermParser::KeyedTerm.new('title', '!=', 'Socks'),
25
- Pursuit::TermParser::KeyedTerm.new('description', '*=', 'green'),
26
- Pursuit::TermParser::KeyedTerm.new('stock_status', '==', 'in_stock'),
27
- Pursuit::TermParser::KeyedTerm.new('rating', '>=', '2'),
28
- Pursuit::TermParser::KeyedTerm.new('rating', '<', '5')
29
- ])
30
- end
31
- end
32
- end