activekit 0.5.0.dev7 → 0.5.0

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: 2e3830a0b0d888ffcd7a7f921b282fbf27dc0f2bdde5322772a71ed04755add3
4
- data.tar.gz: abb0bbead8a270129c04dfb0998e15999df84a6daeb7c28a3237dac161211f43
3
+ metadata.gz: 71b9eb7bd5fc27d2794cd15b8ec08a68e9baae06e37e072188260ddbaaa25473
4
+ data.tar.gz: 33f906a96b417c86b8048a2f663dbac5d4a6b0595f9cea23b6a0db92f98982cb
5
5
  SHA512:
6
- metadata.gz: a2f82ef282ec1226ac39d8ac42993f5ed621ea89407c22ebd6a0850d40157f32e20be968a946899c833bb041b2fe258914c64247c7746a968709f17da88db6af
7
- data.tar.gz: baea0b85ab9b4adbd8e3c1f1eb1e37d62c673b83fc4858233a96cee9b7721a37f7985479a66a0848b16db33137dda1a48553a5d275da5ab0241aa85eee84ebd3
6
+ metadata.gz: 5172f9cb6b0aa0623a9e6f9a0263de7a5fadc016a25c6b92514f9916fca13653f8326479f4bc7fa8c54314c77c8d9d75435034b3c0e1e40054f7f002bf969d90
7
+ data.tar.gz: 65cb7a16747fb2281042dced7e1781bf0709437c470e8621b7ee232b6b205ab1ad021d8b1287a147abba85ddb96fe4f020e1ebb455b00a532d45488fbdf6d47f
data/README.md CHANGED
@@ -3,6 +3,46 @@ Add the essential kit for rails ActiveRecord models and be happy.
3
3
 
4
4
  ## Usage
5
5
 
6
+ ### Search Attribute
7
+
8
+ Add searching to your ActiveRecord models.
9
+ Search Attribute provides full searching functionality for your model database records using redis search including search suggestions.
10
+
11
+ You can define any number of model attributes in one model to search together.
12
+
13
+ Define the search attributes in accordance with the column name in your model like below.
14
+ ```ruby
15
+ class Product < ApplicationRecord
16
+ search_attribute :name, type: :text
17
+ search_attribute :permalink, type: :tag
18
+ search_attribute :short_description, type: :text
19
+ search_attribute :published, type: :tag, sortable: true
20
+ end
21
+ ```
22
+
23
+ You can also define a search_describer to describe the details of the search instead of using the defaults.
24
+ ```ruby
25
+ class Product < ApplicationRecord
26
+ # search_describer method_name, database: -> { ActiveRecord::Base.connection_db_config.database }
27
+ search_describer :limit_by_search, database: -> { System::Current.tenant.database }
28
+ search_attribute :name, type: :text
29
+ search_attribute :permalink, type: :tag
30
+ search_attribute :short_description, type: :text
31
+ search_attribute :published, type: :tag, sortable: true
32
+ end
33
+ ```
34
+
35
+ The following class methods will be added to your model class to use in accordance with details provided for search_describer:
36
+ ```ruby
37
+ Product.limit_by_search(term: "term", tags: { published: true }, order: "name asc", page: 1)
38
+ Product.searcher.for(:limit_by_search).current_page
39
+ Product.searcher.for(:limit_by_search).previous_page?
40
+ Product.searcher.for(:limit_by_search).previous_page
41
+ Product.searcher.for(:limit_by_search).next_page?
42
+ Product.searcher.for(:limit_by_search).next_page
43
+ Product.searcher.for(:limit_by_search).suggestions(prefix: "prefix_term").keys
44
+ ```
45
+
6
46
  ### Export Attribute
7
47
 
8
48
  Add exporting to your ActiveRecord models.
@@ -23,8 +63,8 @@ end
23
63
  You can also define an export_describer to describe the details of the export instead of using the defaults.
24
64
  ```ruby
25
65
  class Product < ApplicationRecord
26
- # export_describer method_name, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database.to_sym }
27
- export_describer :to_csv, kind: :csv, database: -> { System::Current.tenant.database.to_sym }
66
+ # export_describer method_name, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database }
67
+ export_describer :to_csv, kind: :csv, database: -> { System::Current.tenant.database }
28
68
  export_attribute :name
29
69
  export_attribute :sku, heading: "SKU No."
30
70
  export_attribute :image_name, value: lambda { |record| record.image&.name }, includes: :image
@@ -0,0 +1,29 @@
1
+ require 'active_support/concern'
2
+
3
+ module ActiveKit
4
+ module Bedrock
5
+ module Bedrockable
6
+ def self.extended(base)
7
+ current_component = base.name.deconstantize.demodulize.downcase
8
+
9
+ base.module_eval <<-CODE, __FILE__, __LINE__ + 1
10
+ module ClassMethods
11
+ def #{current_component}er
12
+ @#{current_component}er ||= ActiveKit::#{current_component.to_s.titleize}::#{current_component.to_s.titleize}er.new(current_component: :#{current_component}, current_class: self)
13
+ end
14
+
15
+ private
16
+
17
+ def #{current_component}_describer(name, **options)
18
+ #{current_component}er.create_describer(name, options)
19
+ end
20
+
21
+ def #{current_component}_attribute(name, **options)
22
+ #{current_component}er.create_attribute(name, options)
23
+ end
24
+ end
25
+ CODE
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,148 @@
1
+ module ActiveKit
2
+ module Bedrock
3
+ class Bedrocker
4
+ def initialize(current_component:, current_class:)
5
+ @current_component = current_component
6
+ @current_class = current_class
7
+ @describers = {}
8
+ end
9
+
10
+ def create_describer(name, options)
11
+ name = name.to_sym
12
+ options.deep_symbolize_keys!
13
+
14
+ unless find_describer_by(name: name)
15
+ options.store(:attributes, {})
16
+ @describers.store(name, options)
17
+ @current_class.class_eval <<-CODE, __FILE__, __LINE__ + 1
18
+ def self.#{name}(**params)
19
+ #{@current_component}er.run_describer_method("#{name}", params)
20
+ end
21
+ CODE
22
+ end
23
+ end
24
+
25
+ def create_attribute(name, options)
26
+ options.deep_symbolize_keys!
27
+
28
+ create_default_describer unless @describers.present?
29
+
30
+ describer_names = Array(options.delete(:describers))
31
+ describer_names = @describers.keys if describer_names.blank?
32
+
33
+ describer_names.each do |describer_name|
34
+ if describer_options = @describers.dig(describer_name)
35
+ describer_options[:attributes].store(name, options)
36
+ end
37
+ end
38
+
39
+ describer_names
40
+ end
41
+
42
+ def run_describer_method(describer_name, params)
43
+ raise "Could not find describer while creating describer method." unless describer = find_describer_by(name: describer_name.to_sym)
44
+ describer_method(describer, params)
45
+ end
46
+
47
+ def describer_method(describer, params)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ def for(describer_name)
52
+ describer_name = @describers.keys[0] if describer_name.nil?
53
+ raise "Could not find any describer name in #{@current_class.name}." if describer_name.blank?
54
+
55
+ describer_name = describer_name.to_sym
56
+ raise "Could not find describer '#{describer_name}' in #{@current_class.name}." unless @describers.dig(describer_name)
57
+ componenting = @describers.dig(describer_name, :componenting)
58
+ return componenting if componenting
59
+
60
+ @describers[describer_name][:componenting] = "ActiveKit::#{@current_component.to_s.titleize}::#{@current_component.to_s.titleize}ing".constantize.new(describer: find_describer_by(name: describer_name), current_class: @current_class)
61
+ @describers[describer_name][:componenting]
62
+ end
63
+
64
+ def get_describer_names
65
+ @describers.keys.map(&:to_s)
66
+ end
67
+
68
+ private
69
+
70
+ def create_default_describer
71
+ case @current_component
72
+ when :export
73
+ create_describer(:to_csv, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database })
74
+ when :search
75
+ create_describer(:limit_by_search, database: -> { ActiveRecord::Base.connection_db_config.database })
76
+ end
77
+ end
78
+
79
+ def find_describer_by(name:)
80
+ options = @describers.dig(name)
81
+ return nil unless options.present?
82
+
83
+ hash = {
84
+ name: name,
85
+ database: options[:database],
86
+ attributes: options[:attributes]
87
+ }
88
+
89
+ if @current_component == :export
90
+ hash.merge!(kind: options[:kind])
91
+ hash.merge!(includes: options[:attributes].values.map { |options| options.dig(:includes) }.compact.flatten(1).uniq)
92
+ hash.merge!(fields: build_describer_fields(options[:attributes]))
93
+ end
94
+ OpenStruct.new(hash)
95
+ end
96
+
97
+ def build_describer_fields(describer_attributes)
98
+ describer_attributes.inject({}) do |fields_hash, (name, options)|
99
+ enclosed_attributes = Array(options.dig(:attributes))
100
+
101
+ if enclosed_attributes.blank?
102
+ field_key, field_value = (get_heading(options.dig(:heading))&.to_s || name.to_s.titleize), (options.dig(:value) || name)
103
+ else
104
+ field_key, field_value = get_nested_field(name, options, enclosed_attributes)
105
+ end
106
+ fields_hash.store(field_key, field_value)
107
+
108
+ fields_hash
109
+ end
110
+ end
111
+
112
+ def get_nested_field(name, options, enclosed_attributes, ancestor_heading = nil)
113
+ parent_heading = ancestor_heading.present? ? ancestor_heading : ""
114
+ parent_heading += (get_heading(options.dig(:heading))&.to_s || name.to_s.singularize.titleize) + " "
115
+ parent_value = options.dig(:value) || name
116
+
117
+ enclosed_attributes.inject([[], [parent_value]]) do |nested_field, enclosed_attribute|
118
+ unless enclosed_attribute.is_a? Hash
119
+ nested_field_key = parent_heading + enclosed_attribute.to_s.titleize
120
+ nested_field_val = enclosed_attribute
121
+
122
+ nested_field[0].push(nested_field_key)
123
+ nested_field[1].push(nested_field_val)
124
+ else
125
+ enclosed_attribute.each do |enclosed_attribute_key, enclosed_attribute_value|
126
+ wrapped_attributes = Array(enclosed_attribute_value.dig(:attributes))
127
+ if wrapped_attributes.blank?
128
+ nested_field_key = parent_heading + (get_heading(enclosed_attribute_value.dig(:heading))&.to_s || enclosed_attribute_key.to_s.titleize)
129
+ nested_field_val = enclosed_attribute_value.dig(:value) || enclosed_attribute_key
130
+ else
131
+ nested_field_key, nested_field_val = get_nested_field(enclosed_attribute_key, enclosed_attribute_value, wrapped_attributes, parent_heading)
132
+ end
133
+
134
+ nested_field[0].push(nested_field_key)
135
+ nested_field[1].push(nested_field_val)
136
+ end
137
+ end
138
+
139
+ nested_field
140
+ end
141
+ end
142
+
143
+ def get_heading(options_heading)
144
+ options_heading.is_a?(Proc) ? options_heading.call(@current_class) : options_heading
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,10 @@
1
+ module ActiveKit
2
+ module Bedrock
3
+ class Bedrocking
4
+ def initialize(describer:, current_class:)
5
+ @describer = describer
6
+ @current_class = current_class
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ module ActiveKit
2
+ module Bedrock
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :Bedrocker
6
+ autoload :Bedrocking
7
+ end
8
+ end
@@ -10,12 +10,15 @@ module ActiveKit
10
10
  end
11
11
 
12
12
  initializer "active_kit.activekitable" do
13
+ require "active_kit/bedrock/bedrockable"
13
14
  require "active_kit/export/exportable"
14
15
  require "active_kit/position/positionable"
16
+ require "active_kit/search/searchable"
15
17
 
16
18
  ActiveSupport.on_load(:active_record) do
17
19
  include ActiveKit::Export::Exportable
18
20
  include ActiveKit::Position::Positionable
21
+ include ActiveKit::Search::Searchable
19
22
  end
20
23
  end
21
24
  end
@@ -3,48 +3,13 @@ require 'active_support/concern'
3
3
  module ActiveKit
4
4
  module Export
5
5
  module Exportable
6
+ extend Bedrock::Bedrockable
6
7
  extend ActiveSupport::Concern
7
8
 
8
9
  included do
9
10
  end
10
11
 
11
12
  class_methods do
12
- def exporter
13
- @exporter ||= ActiveKit::Export::Exporter.new(current_class: self)
14
- end
15
-
16
- def export_describer(name, **options)
17
- exporter.create_describer(name, options)
18
- end
19
-
20
- def export_attribute(name, **options)
21
- exporter.create_attribute(name, options)
22
- end
23
-
24
- def export_describer_method(describer)
25
- case describer.kind
26
- when :csv
27
- # The 'all' relation must be captured outside the Enumerator,
28
- # else it will get reset to all the records of the class.
29
- all_activerecord_relation = all.includes(describer.includes)
30
-
31
- Enumerator.new do |yielder|
32
- ActiveRecord::Base.connected_to(role: :writing, shard: describer.database.call) do
33
- exporting = exporter.new_exporting(describer: describer)
34
-
35
- # Add the headings.
36
- yielder << CSV.generate_line(exporting.headings) if exporting.headings?
37
-
38
- # Add the values.
39
- # find_each will ignore any order if set earlier.
40
- all_activerecord_relation.find_each do |record|
41
- lines = exporting.lines_for(record: record)
42
- lines.each { |line| yielder << CSV.generate_line(line) }
43
- end
44
- end
45
- end
46
- end
47
- end
48
13
  end
49
14
  end
50
15
  end
@@ -1,113 +1,30 @@
1
1
  module ActiveKit
2
2
  module Export
3
- class Exporter
4
- def initialize(current_class:)
5
- @current_class = current_class
6
- @describers = {}
7
- end
8
-
9
- def create_describer(name, options)
10
- name = name.to_sym
11
- options.deep_symbolize_keys!
12
-
13
- unless find_describer_by(describer_name: name)
14
- options.store(:attributes, {})
15
- @describers.store(name, options)
16
- @current_class.class_eval do
17
- define_singleton_method name do
18
- if describer = exporter.find_describer_by(describer_name: name)
19
- export_describer_method(describer)
20
- end
21
- end
22
- end
23
- end
24
- end
25
-
26
- def create_attribute(name, options)
27
- options.deep_symbolize_keys!
28
-
29
- create_describer(:to_csv, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database.to_sym }) unless @describers.present?
30
-
31
- describer_names = Array(options.delete(:describers))
32
- describer_names = @describers.keys if describer_names.blank?
33
-
34
- describer_names.each do |describer_name|
35
- if describer_options = @describers.dig(describer_name)
36
- describer_options[:attributes].store(name, options)
37
- end
38
- end
39
- end
40
-
41
- def find_describer_by(name:)
42
- options = @describers.dig(name)
43
- return nil unless options.present?
44
-
45
- hash = {
46
- name: name,
47
- kind: options[:kind],
48
- database: options[:database],
49
- attributes: options[:attributes],
50
- includes: options[:attributes].values.map { |options| options.dig(:includes) }.compact.flatten(1).uniq,
51
- fields: build_describer_fields(options[:attributes])
52
- }
53
- OpenStruct.new(hash)
54
- end
55
-
56
- def new_exporting(describer:)
57
- Exporting.new(describer: describer)
58
- end
59
-
60
- private
61
-
62
- def build_describer_fields(describer_attributes)
63
- describer_attributes.inject({}) do |fields_hash, (name, options)|
64
- enclosed_attributes = Array(options.dig(:attributes))
65
-
66
- if enclosed_attributes.blank?
67
- field_key, field_value = (get_heading(options.dig(:heading))&.to_s || name.to_s.titleize), (options.dig(:value) || name)
68
- else
69
- field_key, field_value = get_nested_field(name, options, enclosed_attributes)
70
- end
71
- fields_hash.store(field_key, field_value)
72
-
73
- fields_hash
74
- end
75
- end
76
-
77
- def get_nested_field(name, options, enclosed_attributes, ancestor_heading = nil)
78
- parent_heading = ancestor_heading.present? ? ancestor_heading : ""
79
- parent_heading += (get_heading(options.dig(:heading))&.to_s || name.to_s.singularize.titleize) + " "
80
- parent_value = options.dig(:value) || name
81
-
82
- enclosed_attributes.inject([[], [parent_value]]) do |nested_field, enclosed_attribute|
83
- unless enclosed_attribute.is_a? Hash
84
- nested_field_key = parent_heading + enclosed_attribute.to_s.titleize
85
- nested_field_val = enclosed_attribute
86
-
87
- nested_field[0].push(nested_field_key)
88
- nested_field[1].push(nested_field_val)
89
- else
90
- enclosed_attribute.each do |enclosed_attribute_key, enclosed_attribute_value|
91
- wrapped_attributes = Array(enclosed_attribute_value.dig(:attributes))
92
- if wrapped_attributes.blank?
93
- nested_field_key = parent_heading + (get_heading(enclosed_attribute_value.dig(:heading))&.to_s || enclosed_attribute_key.to_s.titleize)
94
- nested_field_val = enclosed_attribute_value.dig(:value) || enclosed_attribute_key
95
- else
96
- nested_field_key, nested_field_val = get_nested_field(enclosed_attribute_key, enclosed_attribute_value, wrapped_attributes, parent_heading)
3
+ class Exporter < Bedrock::Bedrocker
4
+ def describer_method(describer, params)
5
+ case describer.kind
6
+ when :csv
7
+ # The 'all' relation must be captured outside the Enumerator,
8
+ # else it will get reset to all the records of the class.
9
+ all_activerecord_relation = @current_class.all.includes(describer.includes)
10
+
11
+ Enumerator.new do |yielder|
12
+ ActiveRecord::Base.connected_to(role: :writing, shard: describer.database.call.to_sym) do
13
+ exporting = self.for(describer.name)
14
+
15
+ # Add the headings.
16
+ yielder << CSV.generate_line(exporting.headings) if exporting.headings?
17
+
18
+ # Add the values.
19
+ # find_each will ignore any order if set earlier.
20
+ all_activerecord_relation.find_each do |record|
21
+ lines = exporting.lines_for(record: record)
22
+ lines.each { |line| yielder << CSV.generate_line(line) }
97
23
  end
98
-
99
- nested_field[0].push(nested_field_key)
100
- nested_field[1].push(nested_field_val)
101
24
  end
102
25
  end
103
-
104
- nested_field
105
26
  end
106
27
  end
107
-
108
- def get_heading(options_heading)
109
- options_heading.is_a?(Proc) ? options_heading.call(@current_class) : options_heading
110
- end
111
28
  end
112
29
  end
113
30
  end
@@ -1,11 +1,6 @@
1
1
  module ActiveKit
2
2
  module Export
3
- class Exporting
4
-
5
- def initialize(describer:)
6
- @describer = describer
7
- end
8
-
3
+ class Exporting < Bedrock::Bedrocking
9
4
  def headings
10
5
  @headings ||= @describer.fields.keys.flatten
11
6
  end
@@ -0,0 +1,183 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Index
4
+ attr_reader :prefix, :schema, :attribute_value_parser
5
+
6
+ def initialize(current_class:, describer:)
7
+ @redis = ActiveKit::Search.redis
8
+ @current_class = current_class
9
+ @describer = describer
10
+
11
+ current_class_name = current_class.to_s.parameterize.pluralize
12
+ describer_name = describer.name.to_s
13
+ @name = "activekit:search:index:#{current_class_name}:#{describer_name}"
14
+ @prefix = "activekit:search:#{current_class_name}:#{describer_name}"
15
+ @schema = {}
16
+ @attribute_value_parser = {}
17
+ end
18
+
19
+ def add_attribute_to_schema(name:, options:)
20
+ raise "Error: No type specified for the search attribute '#{name}'." unless options[:type].present?
21
+
22
+ attribute_schema = []
23
+
24
+ as = options.delete(:as)
25
+ attribute_schema.push("AS #{as}") unless as.nil?
26
+
27
+ type = options.delete(:type)
28
+ attribute_schema.push(type.to_s.upcase) unless type.nil?
29
+
30
+ options.each do |key, value|
31
+ if key == :value
32
+ @attribute_value_parser.store(name.to_s, value)
33
+ elsif key.is_a?(Symbol)
34
+ if value == true
35
+ attribute_schema.push(key.to_s.upcase)
36
+ elsif value != false
37
+ attribute_schema.push("#{key.to_s.upcase} #{value.to_s}")
38
+ end
39
+ else
40
+ raise "Invalid option provided to search attribute."
41
+ end
42
+ end
43
+
44
+ @schema.store(name.to_s, attribute_schema.join(" "))
45
+ end
46
+
47
+ def reload
48
+ current_command = @redis.get("#{@name}:command")
49
+ schema = { "database" => "TAG SORTABLE", "id" => "NUMERIC SORTABLE" }.merge(@schema)
50
+ command = "FT.CREATE #{@name} ON HASH PREFIX 1 #{@prefix}: SCHEMA #{schema.to_a.flatten.join(' ')}"
51
+ unless current_command == command
52
+ drop
53
+ @redis.call(command.split(' '))
54
+ @redis.set("#{@name}:command", command)
55
+ Rails.logger.info "ActiveKit::Search | Index Reloaded: " + "#{@name}:command"
56
+ Rails.logger.debug "=> " + @redis.get("#{@name}:command").to_s
57
+ end
58
+ end
59
+
60
+ def drop
61
+ if exists?
62
+ command = "FT.DROPINDEX #{@name}"
63
+ @redis.call(command.split(' '))
64
+ @redis.del("#{@name}:command")
65
+ Rails.logger.info "ActiveKit::Search | Index Dropped: " + @name
66
+ end
67
+ end
68
+
69
+ # Redis returns the results in the following form. Where first value is count of results, then every 2 elements are document_id, attributes respectively.
70
+ # [2, "doc:3", ["name", "Grape Juice", "stock_quantity", "4", "minimum_stock", "2"], "doc:4", ["name", "Apple Juice", "stock_quantity", "4", "minimum_stock", "2"]]
71
+ def fetch(term: nil, matching: "*", tags: {}, modifiers: {}, offset: nil, limit: nil, order: nil, page: nil, **options)
72
+ original_term = term
73
+
74
+ if term == ""
75
+ results = nil
76
+ elsif self.exists?
77
+ if term.present?
78
+ term.strip!
79
+ term = escape_separators(term)
80
+
81
+ case matching
82
+ when "*"
83
+ term = "#{term}*"
84
+ when "%"
85
+ term = "%#{term}%"
86
+ when "%%"
87
+ term = "%%#{term}%%"
88
+ when "%%%"
89
+ term = "%%%#{term}%%%"
90
+ end
91
+
92
+ term = " #{term}"
93
+ else
94
+ term = ""
95
+ end
96
+
97
+ if tags.present?
98
+ tags = tags.map do |key, value|
99
+ value = value.join("|") if value.is_a?(Array)
100
+ "@#{escape_separators(key)}:{#{escape_separators(value, include_space: true).presence || 'nil'}}"
101
+ end
102
+ tags = tags.join(" ")
103
+ tags = " #{tags}"
104
+ else
105
+ tags = ""
106
+ end
107
+
108
+ if modifiers.present?
109
+ modifiers = modifiers.map { |key, value| "@#{escape_separators(key)}:#{escape_separators(value)}" }.join(" ")
110
+ modifiers = " #{modifiers}"
111
+ else
112
+ modifiers = ""
113
+ end
114
+
115
+ if (offset.present? || limit.present?) && page.present?
116
+ raise "Error: Cannot specify page and offset/limit at the same time. Please specify one of either page or offset/limit."
117
+ end
118
+
119
+ if page.present?
120
+ page = page.to_i.abs
121
+
122
+ case page
123
+ when 0
124
+ page = 1
125
+ offset = 0
126
+ limit = 15
127
+ when 1
128
+ offset = 0
129
+ limit = 15
130
+ when 2
131
+ offset = 15
132
+ limit = 30
133
+ when 3
134
+ offset = 45
135
+ limit = 50
136
+ else
137
+ limit = 100
138
+ offset = 15 + 30 + 50 + (page - 4) * limit
139
+ end
140
+ end
141
+
142
+ query = "@database:{#{escape_separators(@describer.database.call, include_space: true)}}#{term}#{tags}#{modifiers}"
143
+ command = [
144
+ "FT.SEARCH",
145
+ @name,
146
+ query,
147
+ "LIMIT",
148
+ offset ? offset.to_i : 0, # 0 is the default offset of redisearch in LIMIT 0 10. https://redis.io/commands/ft.search
149
+ limit ? limit.to_i : 10 # 10 is the default limit of redisearch in LIMIT 0 10. https://redis.io/commands/ft.search
150
+ ]
151
+ command.push("SORTBY", *order.split(' ')) if order.present?
152
+ results = @redis.call(command)
153
+ Rails.logger.info "ActiveKit::Search | Index Searched: " + command.to_s
154
+ Rails.logger.debug "=> " + results.to_s
155
+ else
156
+ results = nil
157
+ end
158
+
159
+ SearchResult.new(term: original_term, results: results, offset: offset, limit: limit, page: page, current_class: @current_class)
160
+ end
161
+
162
+ # List of characters from https://oss.redislabs.com/redisearch/Escaping/
163
+ # ,.<>{}[]"':;!@#$%^&*()-+=~
164
+ def escape_separators(value, include_space: false)
165
+ value = value.to_s
166
+
167
+ unless include_space
168
+ pattern = %r{(\'|\"|\.|\,|\;|\<|\>|\{|\}|\[|\]|\"|\'|\=|\~|\*|\:|\#|\+|\^|\$|\@|\%|\!|\&|\)|\(|/|\-|\\)}
169
+ else
170
+ pattern = %r{(\'|\"|\.|\,|\;|\<|\>|\{|\}|\[|\]|\"|\'|\=|\~|\*|\:|\#|\+|\^|\$|\@|\%|\!|\&|\)|\(|/|\-|\\|\s)}
171
+ end
172
+
173
+ value.gsub(pattern) { |match| '\\' + match }
174
+ end
175
+
176
+ private
177
+
178
+ def exists?
179
+ @redis.call("FT._LIST").include?(@name)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,45 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Key
4
+ def initialize(index:, describer:)
5
+ @redis = ActiveKit::Search.redis
6
+ @index = index
7
+ @describer = describer
8
+ end
9
+
10
+ def reload(record:)
11
+ clear(record: record)
12
+
13
+ hash_key = key(record: record)
14
+ hash_value = { "database" => @describer.database.call, "id" => record.id }
15
+ @index.schema.each do |field_name, field_value|
16
+ attribute_name = field_name
17
+ attribute_value = @index.attribute_value_parser[field_name]&.call(record) || record.public_send(field_name)
18
+ attribute_value = field_value.downcase.include?("tag") ? attribute_value : @index.escape_separators(attribute_value)
19
+ hash_value.store(attribute_name, attribute_value)
20
+ end
21
+ @redis.hset(hash_key, hash_value)
22
+ Rails.logger.info "ActiveKit::Search | Key Reloaded: " + hash_key
23
+ Rails.logger.debug "=> " + @redis.hgetall("#{hash_key}").to_s
24
+ end
25
+
26
+ def clear(record:)
27
+ hash_key = key(record: record)
28
+ drop(keys: hash_key)
29
+ end
30
+
31
+ def drop(keys:)
32
+ return unless keys.present?
33
+ if @redis.del(keys) > 0
34
+ Rails.logger.info "ActiveKit::Search | Keys Removed: " + keys.to_s
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def key(record:)
41
+ "#{@index.prefix}:#{@describer.database.call}:#{record.id}"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Manager
4
+ def initialize(given_class:, given_describer:)
5
+ @given_class = given_class
6
+ @given_describer = given_describer
7
+
8
+ Rails.application.eager_load!
9
+ end
10
+
11
+ def reload
12
+ task(name: :reload, log_name: "Reloading")
13
+ end
14
+
15
+ def clear
16
+ task(name: :clear, log_name: "Clearing")
17
+ end
18
+
19
+ def drop
20
+ task(name: :drop, log_name: "Dropping")
21
+ end
22
+
23
+ private
24
+
25
+ def task(name:, log_name: "Reloading")
26
+ models = @given_class.present? ? [@given_class] : ActiveRecord::Base.descendants.collect(&:name)
27
+ # Removing these models for efficiency as they will never contain searcher.
28
+ models -= ["ApplicationRecord", "ActionText::Record", "ActionText::RichText", "ActiveKit::ApplicationRecord", "ActionMailbox::Record", "ActionMailbox::InboundEmail", "ActiveStorage::Record", "ActiveStorage::Blob", "ActiveStorage::VariantRecord", "ActiveStorage::Attachment"]
29
+ models.each do |model|
30
+ model_const = model.constantize
31
+ if model_const.try(:searcher)
32
+ describer_names = @given_describer.present? ? [@given_describer] : model_const.searcher.get_describer_names
33
+ describer_names.each do |describer_name|
34
+ if model_const.searcher.for(describer_name).attributes_present?
35
+ puts "ActiveKit::Search | #{log_name}: #{model}"
36
+ model_const.searcher.for(describer_name).public_send(name)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,33 @@
1
+ module ActiveKit
2
+ module Search
3
+ class SearchResult
4
+ attr_reader :term, :count, :documents, :keys, :ids, :records, :current_page, :previous_page, :next_page
5
+
6
+ def initialize(term:, results:, offset:, limit:, page:, current_class:)
7
+ @term = term
8
+
9
+ if results
10
+ @count = results.shift
11
+ @documents = results.each_slice(2).map { |key, attributes| [key, attributes.each_slice(2).to_h] }.to_h
12
+
13
+ if page.present?
14
+ @current_page = page
15
+ @previous_page = @current_page > 1 ? (@current_page - 1) : nil
16
+ @next_page = (offset + limit) < count ? (@current_page + 1) : nil
17
+ end
18
+ else
19
+ @count = 0
20
+ @documents = {}
21
+ end
22
+
23
+ @keys = @documents.keys
24
+ @ids = @documents.map { |key, value| key.split(":").last }
25
+
26
+ # Return records from database.
27
+ # This also ensures that any left over document_ids in redis that have been deleted in the database are left out of the results.
28
+ # This orders the records in the order of executed search.
29
+ @records = current_class.where(id: ids).reorder(Arel.sql("FIELD(#{current_class.table_name}.id, #{ids.join(', ')})"))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ require 'active_support/concern'
2
+
3
+ module ActiveKit
4
+ module Search
5
+ module Searchable
6
+ extend Bedrock::Bedrockable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ end
11
+
12
+ class_methods do
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,51 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Searcher < Bedrock::Bedrocker
4
+ def create_attribute(name, options)
5
+ describer_names = super
6
+
7
+ depends_on = options.delete(:depends_on) || {}
8
+ describer_names.each do |describer_name|
9
+ set_reload_callbacks(depends_on, describer_name)
10
+ self.for(describer_name).add_attribute(name: name, options: options.deep_dup)
11
+ end
12
+ end
13
+
14
+ def describer_method(describer, params)
15
+ params[:page] = 1 if params.key?(:page) && params[:page].blank?
16
+ self.for(describer.name).fetch(term: params.delete(:term), **params).records
17
+ end
18
+
19
+ private
20
+
21
+ # Set callbacks for current class and depending classes.
22
+ def set_reload_callbacks(depends_on, describer_name)
23
+ @current_class.class_eval do
24
+ unless searcher.for(describer_name).attributes_present?
25
+ after_commit do
26
+ self.class.searcher.for(describer_name).reload(record: self)
27
+ logger.info "ActiveKit::Search | Indexing from #{self.class.name}: Done."
28
+ end
29
+ end
30
+
31
+ unless depends_on.empty?
32
+ depends_on.each do |depends_on_association, depends_on_inverse|
33
+ klass = self.reflect_on_all_associations.map { |assoc| [assoc.name, assoc.klass.name] }.to_h[depends_on_association]
34
+ klass.constantize.class_eval do
35
+ after_commit do
36
+ inverse_assoc = self.public_send(depends_on_inverse)
37
+ if inverse_assoc.respond_to?(:each)
38
+ inverse_assoc.each { |instance| instance.class.searcher.for(describer_name).reload(record: instance) }
39
+ else
40
+ inverse_assoc.class.searcher.for(describer_name).reload(record: inverse_assoc)
41
+ end
42
+ logger.info "ActiveKit::Search | Indexing from #{self.class.name}: Done."
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,68 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Searching < Bedrock::Bedrocking
4
+ attr_reader :current_page, :previous_page, :next_page
5
+
6
+ def initialize(describer:, current_class:)
7
+ super
8
+
9
+ @index = Index.new(current_class: @current_class, describer: describer)
10
+ @key = Key.new(index: @index, describer: describer)
11
+ @suggestion = Suggestion.new(current_class: @current_class, describer: describer)
12
+ end
13
+
14
+ def reload(record: nil)
15
+ record ? @key.reload(record: record) : @current_class.all.each { |rec| @key.reload(record: rec) }
16
+ @index.reload
17
+ end
18
+
19
+ def clear(record: nil)
20
+ record ? @key.clear(record: record) : @current_class.all.each { |rec| @key.clear(record: rec) }
21
+ @index.reload
22
+ end
23
+
24
+ def fetch(**options)
25
+ search_result = @index.fetch(**options)
26
+
27
+ if search_result.keys.any?
28
+ @suggestion.add(term: search_result.term)
29
+ else
30
+ @suggestion.del(term: search_result.term)
31
+ end
32
+
33
+ @current_page = search_result.current_page
34
+ @previous_page = search_result.previous_page
35
+ @next_page = search_result.next_page
36
+
37
+ search_result
38
+ end
39
+
40
+ def suggestions(prefix:)
41
+ @suggestion.fetch(prefix: prefix)
42
+ end
43
+
44
+ def drop
45
+ total_count = @index.fetch(offset: 0, limit: 0).count
46
+ keys = @index.fetch(offset: 0, limit: total_count).keys
47
+ @key.drop(keys: keys)
48
+ @index.drop
49
+ end
50
+
51
+ def previous_page?
52
+ !!@previous_page
53
+ end
54
+
55
+ def next_page?
56
+ !!@next_page
57
+ end
58
+
59
+ def add_attribute(name:, options:)
60
+ @index.add_attribute_to_schema(name: name, options: options)
61
+ end
62
+
63
+ def attributes_present?
64
+ @index.schema.present?
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Suggestion
4
+ def initialize(current_class:, describer:)
5
+ @redis = ActiveKit::Search.redis
6
+ @current_class = current_class
7
+ @describer = describer
8
+ @current_class_name = current_class.to_s.parameterize.pluralize
9
+ @describer_name = describer.name.to_s
10
+ end
11
+
12
+ def add(term:, score: 1, increment: true)
13
+ command = ["FT.SUGADD", key, term, score, (increment ? 'INCR' : '')]
14
+ @redis.call(command)
15
+ end
16
+
17
+ def fetch(prefix:)
18
+ command = ["FT.SUGGET", key, prefix, "FUZZY", "MAX", "10", "WITHSCORES"]
19
+ results = @redis.call(command)
20
+
21
+ SuggestionResult.new(prefix: prefix, results: results)
22
+ end
23
+
24
+ def del(term:)
25
+ command = ["FT.SUGDEL", key, term]
26
+ @redis.call(command)
27
+ end
28
+
29
+ def len
30
+ command = ["FT.SUGLEN", key]
31
+ @redis.call(command)
32
+ end
33
+
34
+ private
35
+
36
+ def key
37
+ "activekit:search:suggestions:#{@current_class_name}:#{@describer_name}:#{@describer.database.call}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,21 @@
1
+ module ActiveKit
2
+ module Search
3
+ class SuggestionResult
4
+ attr_reader :prefix, :documents, :keys, :scores
5
+
6
+ def initialize(prefix:, results:)
7
+ @prefix = prefix
8
+
9
+ if results
10
+ @documents = results.each_slice(2).map { |key, value| [key, value] }.to_h
11
+ @keys = @documents.keys
12
+ @scores = @documents.values
13
+ else
14
+ @documents = {}
15
+ @keys = []
16
+ @scores = []
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ module ActiveKit
2
+ module Search
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :Index
6
+ autoload :Key
7
+ autoload :Manager
8
+ autoload :SearchResult
9
+ autoload :Searcher
10
+ autoload :Searching
11
+ autoload :Suggestion
12
+ autoload :SuggestionResult
13
+
14
+ mattr_accessor :redis, instance_accessor: false
15
+ end
16
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveKit
2
- VERSION = '0.5.0.dev7'
2
+ VERSION = '0.5.0'
3
3
  end
data/lib/active_kit.rb CHANGED
@@ -4,6 +4,8 @@ require "active_kit/engine"
4
4
  module ActiveKit
5
5
  extend ActiveSupport::Autoload
6
6
 
7
+ autoload :Bedrock
7
8
  autoload :Export
8
9
  autoload :Position
10
+ autoload :Search
9
11
  end
@@ -0,0 +1,21 @@
1
+ namespace :active_kit do
2
+ namespace :search do
3
+ desc "bundle exec rails active_kit:search:reload CLASS='Article' DESCRIBER='limit_by_search'"
4
+ task :reload do
5
+ manager = ActiveKit::Search::Manager.new(given_class: ENV['CLASS'], given_describer: ENV['DESCRIBER'])
6
+ manager.reload
7
+ end
8
+
9
+ desc "bundle exec rails active_kit:search:clear CLASS='Article' DESCRIBER='limit_by_search'"
10
+ task :clear do
11
+ manager = ActiveKit::Search::Manager.new(given_class: ENV['CLASS'], given_describer: ENV['DESCRIBER'])
12
+ manager.clear
13
+ end
14
+
15
+ desc "bundle exec rails active_kit:search:drop CLASS='Article' DESCRIBER='limit_by_search'"
16
+ task :drop do
17
+ manager = ActiveKit::Search::Manager.new(given_class: ENV['CLASS'], given_describer: ENV['DESCRIBER'])
18
+ manager.drop
19
+ end
20
+ end
21
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activekit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0.dev7
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - plainsource
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-22 00:00:00.000000000 Z
11
+ date: 2024-04-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -50,8 +50,10 @@ files:
50
50
  - app/views/layouts/active_kit/application.html.erb
51
51
  - config/routes.rb
52
52
  - lib/active_kit.rb
53
- - lib/active_kit/base/baseable.rb
54
- - lib/active_kit/base/baser.rb
53
+ - lib/active_kit/bedrock.rb
54
+ - lib/active_kit/bedrock/bedrockable.rb
55
+ - lib/active_kit/bedrock/bedrocker.rb
56
+ - lib/active_kit/bedrock/bedrocking.rb
55
57
  - lib/active_kit/engine.rb
56
58
  - lib/active_kit/export.rb
57
59
  - lib/active_kit/export/exportable.rb
@@ -63,9 +65,20 @@ files:
63
65
  - lib/active_kit/position/positionable.rb
64
66
  - lib/active_kit/position/positioner.rb
65
67
  - lib/active_kit/position/positioning.rb
68
+ - lib/active_kit/search.rb
69
+ - lib/active_kit/search/index.rb
70
+ - lib/active_kit/search/key.rb
71
+ - lib/active_kit/search/manager.rb
72
+ - lib/active_kit/search/search_result.rb
73
+ - lib/active_kit/search/searchable.rb
74
+ - lib/active_kit/search/searcher.rb
75
+ - lib/active_kit/search/searching.rb
76
+ - lib/active_kit/search/suggestion.rb
77
+ - lib/active_kit/search/suggestion_result.rb
66
78
  - lib/active_kit/version.rb
67
79
  - lib/activekit.rb
68
80
  - lib/tasks/active_kit_tasks.rake
81
+ - lib/tasks/search_tasks.rake
69
82
  homepage: https://github.com/plainsource/activekit
70
83
  licenses:
71
84
  - MIT
@@ -82,9 +95,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
82
95
  version: '0'
83
96
  required_rubygems_version: !ruby/object:Gem::Requirement
84
97
  requirements:
85
- - - ">"
98
+ - - ">="
86
99
  - !ruby/object:Gem::Version
87
- version: 1.3.1
100
+ version: '0'
88
101
  requirements: []
89
102
  rubygems_version: 3.1.2
90
103
  signing_key:
@@ -1,39 +0,0 @@
1
- require 'active_support/concern'
2
-
3
- module ActiveKit
4
- module Base
5
- module Baseable
6
- extend ActiveSupport::Concern
7
-
8
- included do
9
- end
10
-
11
- class_methods do
12
- def define_activekit_describer(baser, name, options)
13
- name = name.to_sym
14
- options.deep_symbolize_keys!
15
-
16
- unless baser.find_describer_by(describer_name: name)
17
- baser.new_describer(name: name, options: options)
18
-
19
- define_singleton_method name do
20
- describer = baser.find_describer_by(describer_name: name)
21
- raise "could not find describer for the describer name '#{name}'" unless describer.present?
22
-
23
- yield(describer) if block_given?
24
- end
25
- end
26
- end
27
-
28
- def define_activekit_attribute(baser, name, options)
29
- define_activekit_describer(baser, :to_csv, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database.to_sym }) unless baser.describers?
30
-
31
- options.deep_symbolize_keys!
32
- baser.new_attribute(name: name.to_sym, options: options)
33
-
34
- yield if block_given?
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,27 +0,0 @@
1
- module ActiveKit
2
- module Base
3
- module Baser
4
- attr_reader :describers
5
-
6
- def new_describer(name:, options:)
7
- options.store(:attributes, {})
8
- @describers.store(name, options)
9
- end
10
-
11
- def describers?
12
- @describers.present?
13
- end
14
-
15
- def new_attribute(name:, options:)
16
- describer_names = Array(options.delete(:describers))
17
- describer_names = @describers.keys if describer_names.blank?
18
-
19
- describer_names.each do |describer_name|
20
- if describer_options = @describers.dig(describer_name)
21
- describer_options[:attributes].store(name, options)
22
- end
23
- end
24
- end
25
- end
26
- end
27
- end