pursuit 0.4.5 → 1.0.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rubygem.yaml +46 -0
  3. data/Gemfile +14 -13
  4. data/Gemfile.lock +14 -13
  5. data/README.md +210 -27
  6. data/bin/console +10 -0
  7. data/lib/pursuit/aggregate_modifier_not_found.rb +20 -0
  8. data/lib/pursuit/aggregate_modifier_required.rb +20 -0
  9. data/lib/pursuit/aggregate_modifiers_not_available.rb +13 -0
  10. data/lib/pursuit/attribute_not_found.rb +20 -0
  11. data/lib/pursuit/constants.rb +1 -1
  12. data/lib/pursuit/error.rb +7 -0
  13. data/lib/pursuit/predicate_parser.rb +181 -0
  14. data/lib/pursuit/predicate_search.rb +83 -0
  15. data/lib/pursuit/predicate_transform.rb +231 -0
  16. data/lib/pursuit/query_error.rb +7 -0
  17. data/lib/pursuit/simple_search.rb +64 -0
  18. data/lib/pursuit/term_parser.rb +44 -0
  19. data/lib/pursuit/term_search.rb +69 -0
  20. data/lib/pursuit/term_transform.rb +35 -0
  21. data/lib/pursuit.rb +18 -4
  22. data/pursuit.gemspec +4 -3
  23. data/spec/internal/app/models/application_record.rb +5 -0
  24. data/spec/internal/app/models/product.rb +25 -9
  25. data/spec/internal/app/models/product_category.rb +23 -1
  26. data/spec/internal/app/models/product_variation.rb +26 -1
  27. data/spec/lib/pursuit/predicate_parser_spec.rb +1604 -0
  28. data/spec/lib/pursuit/predicate_search_spec.rb +80 -0
  29. data/spec/lib/pursuit/predicate_transform_spec.rb +624 -0
  30. data/spec/lib/pursuit/simple_search_spec.rb +59 -0
  31. data/spec/lib/pursuit/term_parser_spec.rb +271 -0
  32. data/spec/lib/pursuit/term_search_spec.rb +71 -0
  33. data/spec/lib/pursuit/term_transform_spec.rb +105 -0
  34. metadata +47 -25
  35. data/.travis.yml +0 -26
  36. data/lib/pursuit/dsl.rb +0 -28
  37. data/lib/pursuit/railtie.rb +0 -13
  38. data/lib/pursuit/search.rb +0 -172
  39. data/lib/pursuit/search_options.rb +0 -86
  40. data/lib/pursuit/search_term_parser.rb +0 -46
  41. data/spec/lib/pursuit/dsl_spec.rb +0 -22
  42. data/spec/lib/pursuit/search_options_spec.rb +0 -146
  43. data/spec/lib/pursuit/search_spec.rb +0 -516
  44. data/spec/lib/pursuit/search_term_parser_spec.rb +0 -34
  45. data/travis/gemfiles/5.2.gemfile +0 -8
  46. data/travis/gemfiles/6.0.gemfile +0 -8
  47. data/travis/gemfiles/6.1.gemfile +0 -8
  48. data/travis/gemfiles/7.0.gemfile +0 -8
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pursuit
4
- class Railtie < Rails::Railtie
5
- initializer 'pursuit.active_record.inject_dsl' do
6
- ActiveSupport.on_load(:active_record) do
7
- require 'pursuit/dsl'
8
-
9
- ActiveRecord::Base.include(Pursuit::DSL)
10
- end
11
- end
12
- end
13
- end
@@ -1,172 +0,0 @@
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
- attribute_name = term.key.to_sym
55
- reflection = options.relations.key?(attribute_name) ? options.record_class.reflections[term.key] : nil
56
- node = if reflection.present?
57
- attribute_names = options.relations[attribute_name]
58
- build_arel_for_reflection(reflection, attribute_names, term.operator, term.value)
59
- else
60
- node_builder = options.keyed_attributes[attribute_name]
61
- build_arel_for_node(node_builder.call, term.operator, term.value)
62
- end
63
-
64
- chain ? chain.and(node) : node
65
- end
66
- end
67
-
68
- def build_arel_for_node(node, operator, value) # rubocop:disable Metrics/CyclomaticComplexity
69
- sanitized_value = ActiveRecord::Base.sanitize_sql_like(value)
70
- sanitized_value = sanitized_value.to_i if sanitized_value =~ /^[0-9]+$/
71
-
72
- case operator
73
- when '>' then node.gt(sanitized_value)
74
- when '>=' then node.gteq(sanitized_value)
75
- when '<' then node.lt(sanitized_value)
76
- when '<=' then node.lteq(sanitized_value)
77
- when '*=' then node.matches("%#{sanitized_value}%")
78
- when '!*=' then node.does_not_match("%#{sanitized_value}%")
79
- when '!=' then node.not_eq(sanitized_value)
80
- when '=='
81
- if value.present?
82
- node.eq(sanitized_value)
83
- else
84
- node.eq(nil).or(node.eq(''))
85
- end
86
- else
87
- raise "The operator '#{operator}' is not supported."
88
- end
89
- end
90
-
91
- def build_arel_for_reflection(reflection, attribute_names, operator, value)
92
- nodes = build_arel_for_reflection_join(reflection)
93
- count_nodes = build_arel_for_relation_count(nodes, operator, value) unless reflection.belongs_to?
94
- return count_nodes if count_nodes.present?
95
-
96
- match_nodes = attribute_names.reduce(nil) do |chain, attribute_name|
97
- node = build_arel_for_node(reflection.klass.arel_table[attribute_name], operator, value)
98
- chain ? chain.or(node) : node
99
- end
100
-
101
- return nil if match_nodes.blank?
102
-
103
- nodes.where(match_nodes).project(1).exists
104
- end
105
-
106
- def build_arel_for_reflection_join(reflection)
107
- if reflection.belongs_to?
108
- build_arel_for_belongs_to_reflection_join(reflection)
109
- else
110
- build_arel_for_has_reflection_join(reflection)
111
- end
112
- end
113
-
114
- def build_arel_for_belongs_to_reflection_join(reflection)
115
- reflection_table = reflection.klass.arel_table
116
- reflection_table.where(
117
- reflection_table[reflection.join_primary_key].eq(
118
- options.record_class.arel_table[reflection.join_foreign_key]
119
- )
120
- )
121
- end
122
-
123
- # rubocop:disable Metrics/AbcSize
124
- def build_arel_for_has_reflection_join(reflection)
125
- reflection_table = reflection.klass.arel_table
126
- reflection_through = reflection.through_reflection
127
-
128
- if reflection_through.present?
129
- # :has_one through / :has_many through
130
- reflection_through_table = reflection_through.klass.arel_table
131
- reflection_table.join(reflection_through_table).on(
132
- reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
133
- ).where(
134
- reflection_through_table[reflection_through.foreign_key].eq(
135
- options.record_class.arel_table[options.record_class.primary_key]
136
- )
137
- )
138
- else
139
- # :has_one / :has_many
140
- reflection_table.where(
141
- reflection_table[reflection.foreign_key].eq(
142
- options.record_class.arel_table[options.record_class.primary_key]
143
- )
144
- )
145
- end
146
- end
147
- # rubocop:enable Metrics/AbcSize
148
-
149
- def build_arel_for_relation_count(nodes, operator, value) # rubocop:disable Metrics/CyclomaticComplexity
150
- node_builder = proc do |klass|
151
- count = ActiveRecord::Base.sanitize_sql_like(value).to_i
152
- klass.new(nodes.project(Arel.star.count), count)
153
- end
154
-
155
- case operator
156
- when '>' then node_builder.call(Arel::Nodes::GreaterThan)
157
- when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
158
- when '<' then node_builder.call(Arel::Nodes::LessThan)
159
- when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
160
- else
161
- return nil unless value =~ /^([0-9]+)$/
162
-
163
- case operator
164
- when '==' then node_builder.call(Arel::Nodes::Equality)
165
- when '!=' then node_builder.call(Arel::Nodes::NotEqual)
166
- when '*=' then node_builder.call(Arel::Nodes::Matches)
167
- when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
168
- end
169
- end
170
- end
171
- end
172
- end
@@ -1,86 +0,0 @@
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)
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,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Pursuit
4
- class SearchTermParser
5
- # @return [Struct] Represents a single keyed term extracted from a query.
6
- #
7
- KeyedTerm = Struct.new(:key, :operator, :value)
8
-
9
- # @return [Array<Pursuit::SearchTermParser::KeyedTerm>] The keys which are permitted for use as keyed terms.
10
- #
11
- attr_reader :keyed_terms
12
-
13
- # @return [String] The unkeyed term.
14
- #
15
- attr_reader :unkeyed_term
16
-
17
- # Create a new search term parser by parsing the specified query into an 'unkeyed term' and 'keyed terms'.
18
- #
19
- # @param query [String] The query to parse.
20
- # @param keys [Array<String>] The keys which are permitted for use as keyed terms.
21
- #
22
- def initialize(query, keys: [])
23
- @keyed_terms = []
24
- @unkeyed_term = query.gsub(/(\s+)?(\w+)(==|\*=|!=|!\*=|<=|>=|<|>)("([^"]+)?"|'([^']+)?'|[^\s]+)(\s+)?/) do |term|
25
- key = Regexp.last_match(2)
26
- next term unless keys.include?(key)
27
-
28
- operator = Regexp.last_match(3)
29
- value = Regexp.last_match(4)
30
- value = value[1..-2] if value =~ /^(".*"|'.*')$/
31
-
32
- @keyed_terms << KeyedTerm.new(key, operator, value)
33
-
34
- # Both the starting and ending spaces surrounding the keyed term can be removed, so in this case we'll need to
35
- # replace with a single space to ensure the unkeyed term's words are separated correctly.
36
- if term =~ /^\s.*\s$/
37
- ' '
38
- else
39
- ''
40
- end
41
- end
42
-
43
- @unkeyed_term = @unkeyed_term.strip
44
- end
45
- end
46
- end
@@ -1,22 +0,0 @@
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
@@ -1,146 +0,0 @@
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', [Product.arel_table[:title]])
9
- end
10
- end
11
-
12
- describe '#record_class' do
13
- subject(:record_class) { search_options.record_class }
14
-
15
- it 'is expected to eq the class passed during initialization' do
16
- expect(record_class).to eq(Product)
17
- end
18
- end
19
-
20
- describe '#relations' do
21
- subject(:relations) { search_options.relations }
22
-
23
- before do
24
- search_options.relation :variations, :title, :stock_status
25
- end
26
-
27
- it 'is expected to contain the correct relations' do
28
- expect(relations).to eq(variations: %i[title stock_status])
29
- end
30
- end
31
-
32
- describe '#keyed_attributes' do
33
- subject(:keyed_attributes) { search_options.keyed_attributes }
34
-
35
- before do
36
- search_options.attribute :title, keyed: false
37
- search_options.attribute :title_length, &title_length_node_builder
38
- search_options.attribute :description
39
- search_options.attribute :rating, unkeyed: false
40
- end
41
-
42
- it 'is expected to contain the correct keyed attributes' do
43
- expect(keyed_attributes.keys).to contain_exactly(:title_length, :description, :rating)
44
- end
45
-
46
- it 'is expected to set a default node builder for attributes declared without a block' do
47
- expect(keyed_attributes[:description].call).to eq(Product.arel_table[:description])
48
- end
49
-
50
- it 'is expected to set a custom node builder for attributes declared with a block' do
51
- expect(keyed_attributes[:title_length]).to eq(title_length_node_builder)
52
- end
53
- end
54
-
55
- describe '#unkeyed_attributes' do
56
- subject(:unkeyed_attributes) { search_options.unkeyed_attributes }
57
-
58
- before do
59
- search_options.attribute :title, keyed: false
60
- search_options.attribute :title_length, &title_length_node_builder
61
- search_options.attribute :description
62
- search_options.attribute :rating, unkeyed: false
63
- end
64
-
65
- it 'is expected to contain the correct unkeyed attributes' do
66
- expect(unkeyed_attributes.keys).to contain_exactly(:title, :title_length, :description)
67
- end
68
-
69
- it 'is expected to set a default node builder for attributes declared without a block' do
70
- expect(unkeyed_attributes[:title].call).to eq(Product.arel_table[:title])
71
- end
72
-
73
- it 'is expected to set a custom node builder for attributes declared with a block' do
74
- expect(unkeyed_attributes[:title_length]).to eq(title_length_node_builder)
75
- end
76
- end
77
-
78
- describe '#relation' do
79
- subject(:relation) { search_options.relation(:variations, :title, :stock_status) }
80
-
81
- it 'is expected to add the relation to #relations' do
82
- expect { relation }.to change(search_options, :relations).from({}).to(variations: %i[title stock_status])
83
- end
84
- end
85
-
86
- describe '#attribute' do
87
- subject(:attribute) { search_options.attribute(:description) }
88
-
89
- it { is_expected.to be_nil }
90
-
91
- it 'is expected to add the attribute to #attributes' do
92
- expect { attribute }.to change(search_options.attributes, :keys).from([]).to(%i[description])
93
- end
94
-
95
- it 'is expected to allow keyed searching by default' do
96
- attribute
97
- expect(search_options.attributes[:description].keyed).to be(true)
98
- end
99
-
100
- it 'is expected to allow unkeyed searching by default' do
101
- attribute
102
- expect(search_options.attributes[:description].unkeyed).to be(true)
103
- end
104
-
105
- it 'is expected to query the #term_name attribute' do
106
- attribute
107
- expect(search_options.attributes[:description].block.call).to eq(Product.arel_table[:description])
108
- end
109
-
110
- context 'when passing the attribute name to search' do
111
- subject(:attribute) { search_options.attribute(:desc, :description) }
112
-
113
- it 'is expected to query the #attribute_name attribute' do
114
- attribute
115
- expect(search_options.attributes[:desc].block.call).to eq(Product.arel_table[:description])
116
- end
117
- end
118
-
119
- context 'when passing :keyed eq false' do
120
- subject(:attribute) { search_options.attribute(:description, keyed: false) }
121
-
122
- it 'is expected to disallow keyed searching' do
123
- attribute
124
- expect(search_options.attributes[:description].keyed).to be(false)
125
- end
126
- end
127
-
128
- context 'when passing :unkeyed eq false' do
129
- subject(:attribute) { search_options.attribute(:description, unkeyed: false) }
130
-
131
- it 'is expected to disallow unkeyed searching' do
132
- attribute
133
- expect(search_options.attributes[:description].unkeyed).to be(false)
134
- end
135
- end
136
-
137
- context 'when passing a block' do
138
- subject(:attribute) { search_options.attribute(:description, &title_length_node_builder) }
139
-
140
- it 'is expected to query the result of the passed block' do
141
- attribute
142
- expect(search_options.attributes[:description].block).to eq(title_length_node_builder)
143
- end
144
- end
145
- end
146
- end