pursuit 0.1.1 → 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7d982a8b67d1a828b2e19e01e3477364bb607fa1fe51ef9d4ac1542a335e319
4
- data.tar.gz: 6a552d6af9ca41bbdc6b4df6cfcfd3cdd3f06c6a4163e8879289cfcef6679fc5
3
+ metadata.gz: 5cf47bc77d2ccf93d20d66ee529c6554a995ee612e3135f90fd7d437f0fa5b08
4
+ data.tar.gz: 8ac3ace51a32566144c34cbe9ebb33d0c8327327f8ce1d2d8ae120c079a494f8
5
5
  SHA512:
6
- metadata.gz: 9b56dd56e67e489faee6f2ba58acd4d68ae75e989cce1cad2a8e578ad830e93cf346168282400d57fc0936029fc93916115bc46dadece9ca1ee7e9c36981981c
7
- data.tar.gz: cbd230e5840c4ed85041dc7463fbf8867bed55d1c114a78b6ca3d65c429350bc1bd3f52044fe7658bad26acc1f93f145f6a0c228ff82bb3d4d64f5d742871294
6
+ metadata.gz: 184fd8c2520cedd0c3ac45e83e4040295de141065419ee676ea159d8a419222a9bac51a950d6e0decb0e7106f8a3839d76f1a3144786647db528b9115d10bb9b
7
+ data.tar.gz: 65462af7fb13f713f4680845ba297bf659d230d9285b89b2b4c1de1bde2a64402a022c7929298d022f2a9b7a8fa1f4e0bbb7e569fb88cba99e39c01db17f244d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pursuit (0.1.1)
4
+ pursuit (0.3.2)
5
5
  activerecord (>= 5.2.0, < 6.2.0)
6
6
  activesupport (>= 5.2.0, < 6.2.0)
7
7
 
data/README.md CHANGED
@@ -20,9 +20,28 @@ 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 :description
30
+ o.attribute :rating, unkeyed: false
31
+
32
+ # You can shorten the search keyword by passing the desired search term first, and then the real attribute name
33
+ # as the second argument.
34
+ # => "category*=shirts"
35
+ o.attribute :category, :category_id
36
+
37
+ # It's also possible to query entirely custom Arel nodes by passing a block which returns the Arel node to query.
38
+ # You could use this to query a person's full name by concatenating their first and last name columns, for example.
39
+ o.attribute :title_length, unkeyed: false do
40
+ Arel::Nodes::NamedFunction.new('LENGTH', [
41
+ arel_table[:title]
42
+ ])
43
+ end
44
+ end
26
45
  end
27
46
  ```
28
47
 
@@ -3,5 +3,5 @@
3
3
  module Pursuit
4
4
  # @return [String] The gem's semantic version number.
5
5
  #
6
- VERSION = '0.1.1'
6
+ VERSION = '0.3.2'
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,153 @@
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)
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)
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
+ reflection_table = reflection.klass.arel_table
108
+ reflection_through = reflection.through_reflection
109
+
110
+ if reflection_through.present?
111
+ # :has_one through / :has_many through
112
+ reflection_through_table = reflection_through.klass.arel_table
113
+ reflection_table.join(reflection_through_table).on(
114
+ reflection_through_table[reflection.foreign_key].eq(reflection_table[reflection.klass.primary_key])
115
+ ).where(
116
+ reflection_through_table[reflection_through.foreign_key].eq(
117
+ options.record_class.arel_table[options.record_class.primary_key]
118
+ )
119
+ )
120
+ else
121
+ # :has_one / :has_many
122
+ reflection_table.where(
123
+ reflection_table[reflection.foreign_key].eq(
124
+ options.record_class.arel_table[options.record_class.primary_key]
125
+ )
126
+ )
127
+ end
128
+ end
129
+
130
+ def build_arel_for_relation_count(nodes, operator, value)
131
+ node_builder = proc do |klass|
132
+ count = ActiveRecord::Base.sanitize_sql_like(value).to_i
133
+ klass.new(nodes.project(Arel.star.count), count)
134
+ end
135
+
136
+ case operator
137
+ when '>' then node_builder.call(Arel::Nodes::GreaterThan)
138
+ when '>=' then node_builder.call(Arel::Nodes::GreaterThanOrEqual)
139
+ when '<' then node_builder.call(Arel::Nodes::LessThan)
140
+ when '<=' then node_builder.call(Arel::Nodes::LessThanOrEqual)
141
+ else
142
+ return nil unless value =~ /^([0-9]+)$/
143
+
144
+ case operator
145
+ when '==' then node_builder.call(Arel::Nodes::Equality)
146
+ when '!=' then node_builder.call(Arel::Nodes::NotEqual)
147
+ when '*=' then node_builder.call(Arel::Nodes::Matches)
148
+ when '!*=' then node_builder.call(Arel::Nodes::DoesNotMatch)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ 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)
@@ -1,11 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Product < ActiveRecord::Base
4
- has_many :variations, class_name: 'ProductVariation', inverse_of: :product
4
+ belongs_to :category, class_name: 'ProductCategory', inverse_of: :products, optional: true
5
5
 
6
- has_search relationships: { variations: %i[title stock_status] },
7
- keyed_attributes: %i[title description rating],
8
- unkeyed_attributes: %i[title description]
6
+ has_many :variations, class_name: 'ProductVariation', inverse_of: :product
9
7
 
10
8
  validates :title, presence: true
9
+
10
+ searchable do |o|
11
+ o.relation :variations, :title, :stock_status
12
+
13
+ o.attribute :title
14
+ o.attribute :description
15
+ o.attribute :rating, unkeyed: false
16
+ o.attribute :title_length, unkeyed: false do
17
+ Arel::Nodes::NamedFunction.new('LENGTH', [
18
+ arel_table[:title]
19
+ ])
20
+ end
21
+
22
+ o.attribute :category, :category_id
23
+ end
11
24
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ProductCategory < ActiveRecord::Base
4
+ has_many :products, class_name: 'Product', foreign_key: :category_id, inverse_of: :category, dependent: :nullify
5
+
6
+ validates :name, presence: true
7
+ end
@@ -1,7 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ActiveRecord::Schema.define do
4
+ create_table :product_categories, id: :string, force: true do |t|
5
+ t.string :name, null: false
6
+
7
+ t.timestamps null: false
8
+ end
9
+
4
10
  create_table :products, force: true do |t|
11
+ t.belongs_to :category, type: :string, foreign_key: { to_table: 'product_categories' }
12
+
5
13
  t.string :title, null: false
6
14
 
7
15
  t.text :description
@@ -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,27 @@
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
+
19
+ o.attribute :category, :category_id
20
+ end
11
21
  end
12
22
 
13
- describe '#search' do
14
- subject(:search) { active_record_search.search(query) }
23
+ describe '#perform' do
24
+ subject(:perform) { search.perform(query) }
15
25
 
16
26
  context 'when passed a blank query' do
17
27
  let(:query) { '' }
@@ -25,7 +35,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
25
35
  end
26
36
 
27
37
  it 'is expected to contain all records' do
28
- expect(search).to contain_exactly(product_a, product_b)
38
+ expect(perform).to contain_exactly(product_a, product_b)
29
39
  end
30
40
  end
31
41
 
@@ -43,7 +53,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
43
53
  end
44
54
 
45
55
  it 'is expected to contain the matching records' do
46
- expect(search).to contain_exactly(product_a, product_b)
56
+ expect(perform).to contain_exactly(product_a, product_b)
47
57
  end
48
58
  end
49
59
 
@@ -61,7 +71,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
61
71
  end
62
72
 
63
73
  it 'is expected to contain the matching records' do
64
- expect(search).to contain_exactly(product_b)
74
+ expect(perform).to contain_exactly(product_b)
65
75
  end
66
76
  end
67
77
 
@@ -79,7 +89,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
79
89
  end
80
90
 
81
91
  it 'is expected to contain the matching records' do
82
- expect(search).to contain_exactly(product_a, product_c)
92
+ expect(perform).to contain_exactly(product_a, product_c)
83
93
  end
84
94
  end
85
95
 
@@ -97,7 +107,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
97
107
  end
98
108
 
99
109
  it 'is expected to contain the matching records' do
100
- expect(search).to contain_exactly(product_a, product_b)
110
+ expect(perform).to contain_exactly(product_a, product_b)
101
111
  end
102
112
  end
103
113
 
@@ -115,7 +125,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
115
125
  end
116
126
 
117
127
  it 'is expected to contain the matching records' do
118
- expect(search).to contain_exactly(product_a, product_b)
128
+ expect(perform).to contain_exactly(product_a, product_b)
119
129
  end
120
130
  end
121
131
 
@@ -133,7 +143,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
133
143
  end
134
144
 
135
145
  it 'is expected to contain the matching records' do
136
- expect(search).to contain_exactly(product_b, product_c)
146
+ expect(perform).to contain_exactly(product_b, product_c)
137
147
  end
138
148
  end
139
149
 
@@ -151,7 +161,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
151
161
  end
152
162
 
153
163
  it 'is expected to contain the matching records' do
154
- expect(search).to contain_exactly(product_b, product_c)
164
+ expect(perform).to contain_exactly(product_b, product_c)
155
165
  end
156
166
  end
157
167
 
@@ -169,7 +179,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
169
179
  end
170
180
 
171
181
  it 'is expected to contain the matching records' do
172
- expect(search).to contain_exactly(product_b, product_c)
182
+ expect(perform).to contain_exactly(product_b, product_c)
173
183
  end
174
184
  end
175
185
 
@@ -187,7 +197,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
187
197
  end
188
198
 
189
199
  it 'is expected to contain the matching records' do
190
- expect(search).to contain_exactly(product_a, product_b)
200
+ expect(perform).to contain_exactly(product_a, product_b)
191
201
  end
192
202
  end
193
203
 
@@ -205,7 +215,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
205
215
  end
206
216
 
207
217
  it 'is expected to contain the matching records' do
208
- expect(search).to contain_exactly(product_a, product_b)
218
+ expect(perform).to contain_exactly(product_a, product_b)
209
219
  end
210
220
  end
211
221
 
@@ -223,7 +233,23 @@ RSpec.describe Pursuit::ActiveRecordSearch do
223
233
  end
224
234
 
225
235
  it 'is expected to contain the matching records' do
226
- expect(search).to contain_exactly(product_b)
236
+ expect(perform).to contain_exactly(product_b)
237
+ end
238
+ end
239
+
240
+ context 'when passed a virtual keyed attribute query' do
241
+ let(:query) { 'title_length==5' }
242
+
243
+ let(:product_a) { Product.create!(title: 'Plain Shirt') }
244
+ let(:product_b) { Product.create!(title: 'Socks') }
245
+
246
+ before do
247
+ product_a
248
+ product_b
249
+ end
250
+
251
+ it 'is expected to contain the matching records' do
252
+ expect(perform).to contain_exactly(product_b)
227
253
  end
228
254
  end
229
255
 
@@ -247,7 +273,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
247
273
  end
248
274
 
249
275
  it 'is expected to contain the matching records' do
250
- expect(search).to contain_exactly(product_b)
276
+ expect(perform).to contain_exactly(product_b)
251
277
  end
252
278
  end
253
279
 
@@ -275,7 +301,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
275
301
  end
276
302
 
277
303
  it 'is expected to contain the matching records' do
278
- expect(search).to contain_exactly(product_c)
304
+ expect(perform).to contain_exactly(product_c)
279
305
  end
280
306
  end
281
307
 
@@ -307,7 +333,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
307
333
  end
308
334
 
309
335
  it 'is expected to contain the matching records' do
310
- expect(search).to contain_exactly(product_a, product_b)
336
+ expect(perform).to contain_exactly(product_a, product_b)
311
337
  end
312
338
  end
313
339
 
@@ -339,7 +365,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
339
365
  end
340
366
 
341
367
  it 'is expected to contain the matching records' do
342
- expect(search).to contain_exactly(product_a, product_b)
368
+ expect(perform).to contain_exactly(product_a, product_b)
343
369
  end
344
370
  end
345
371
 
@@ -371,7 +397,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
371
397
  end
372
398
 
373
399
  it 'is expected to contain the matching records' do
374
- expect(search).to contain_exactly(product_a, product_c)
400
+ expect(perform).to contain_exactly(product_a, product_c)
375
401
  end
376
402
  end
377
403
 
@@ -403,7 +429,7 @@ RSpec.describe Pursuit::ActiveRecordSearch do
403
429
  end
404
430
 
405
431
  it 'is expected to contain the matching records' do
406
- expect(search).to contain_exactly(product_a, product_c)
432
+ expect(perform).to contain_exactly(product_a, product_c)
407
433
  end
408
434
  end
409
435
 
@@ -435,7 +461,28 @@ RSpec.describe Pursuit::ActiveRecordSearch do
435
461
  end
436
462
 
437
463
  it 'is expected to contain the matching records' do
438
- expect(search).to contain_exactly(product_a)
464
+ expect(perform).to contain_exactly(product_a)
465
+ end
466
+ end
467
+
468
+ context 'when querying a custom attribute whose name matches a reflection' do
469
+ let(:query) { 'category==shirts' }
470
+
471
+ let(:shirts_category) { ProductCategory.create!(id: 'shirts', name: 'The Shirt Collection') }
472
+ let(:socks_category) { ProductCategory.create!(id: 'socks', name: 'The Sock Collection') }
473
+
474
+ let(:product_a) { Product.create!(title: 'Plain Shirt', category: shirts_category) }
475
+ let(:product_b) { Product.create!(title: 'Funky Shirt', category: shirts_category) }
476
+ let(:product_c) { Product.create!(title: 'Socks - Pack of 4', category: socks_category) }
477
+
478
+ before do
479
+ product_a
480
+ product_b
481
+ product_c
482
+ end
483
+
484
+ it 'is expected to contain the matching records' do
485
+ expect(perform).to contain_exactly(product_a, product_b)
439
486
  end
440
487
  end
441
488
  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.1
4
+ version: 0.3.2
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
@@ -199,21 +199,24 @@ 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
210
+ - spec/internal/app/models/product_category.rb
209
211
  - spec/internal/app/models/product_variation.rb
210
212
  - spec/internal/config/database.yml
211
213
  - spec/internal/db/schema.rb
212
214
  - spec/internal/log/.keep
213
- - spec/pursuit/active_record_dsl_spec.rb
214
- - spec/pursuit/active_record_search_spec.rb
215
215
  - spec/pursuit/constants_spec.rb
216
- - spec/pursuit/term_parser_spec.rb
216
+ - spec/pursuit/dsl_spec.rb
217
+ - spec/pursuit/search_options_spec.rb
218
+ - spec/pursuit/search_spec.rb
219
+ - spec/pursuit/search_term_parser_spec.rb
217
220
  - spec/spec_helper.rb
218
221
  - travis/gemfiles/5.2.gemfile
219
222
  - travis/gemfiles/6.0.gemfile
@@ -244,12 +247,14 @@ specification_version: 4
244
247
  summary: Advanced key-based searching for ActiveRecord objects.
245
248
  test_files:
246
249
  - spec/internal/app/models/product.rb
250
+ - spec/internal/app/models/product_category.rb
247
251
  - spec/internal/app/models/product_variation.rb
248
252
  - spec/internal/config/database.yml
249
253
  - spec/internal/db/schema.rb
250
254
  - spec/internal/log/.keep
251
- - spec/pursuit/active_record_dsl_spec.rb
252
- - spec/pursuit/active_record_search_spec.rb
253
255
  - spec/pursuit/constants_spec.rb
254
- - spec/pursuit/term_parser_spec.rb
256
+ - spec/pursuit/dsl_spec.rb
257
+ - spec/pursuit/search_options_spec.rb
258
+ - spec/pursuit/search_spec.rb
259
+ - spec/pursuit/search_term_parser_spec.rb
255
260
  - 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