pursuit 0.1.1 → 0.3.2

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: 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