activekit 0.5.0.dev7 → 0.5.0

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