activekit 0.5.0.dev7 → 0.5.0.dev8

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: 7fe66b1b57a945fa39f8d3daad8daa1dd3d564d190fb302453e1e1d9b4bc64e8
4
+ data.tar.gz: '0026541966d64d4fb509ae32dbd69715cf86d8dd79abd51537aee335bb0da393'
5
5
  SHA512:
6
- metadata.gz: a2f82ef282ec1226ac39d8ac42993f5ed621ea89407c22ebd6a0850d40157f32e20be968a946899c833bb041b2fe258914c64247c7746a968709f17da88db6af
7
- data.tar.gz: baea0b85ab9b4adbd8e3c1f1eb1e37d62c673b83fc4858233a96cee9b7721a37f7985479a66a0848b16db33137dda1a48553a5d275da5ab0241aa85eee84ebd3
6
+ metadata.gz: ebc4d83438c610266f4de8c72395199f71282f5e8b00cfdebcf9e532950094997f520b0dd3e65a85e05f5e62fae365e5eaebc770691c4fcb2131a0ce3225bbc1
7
+ data.tar.gz: bbb51f533e437c2de942dcc5a8d9120b3f1cf0317b138c0b924cd433a48bbe6c145b4c77e3eaf0160c2a37d47d0d35df07916a090fbd237bc4929c7de8b0d790
@@ -0,0 +1,33 @@
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
+ private
12
+
13
+ def #{current_component}er
14
+ @#{current_component}er ||= ActiveKit::#{current_component.to_s.titleize}::#{current_component.to_s.titleize}er.new(current_component: :#{current_component}, current_class: self)
15
+ end
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
+
25
+ def #{current_component}_describer_method(describer)
26
+ raise NotImplementedError
27
+ end
28
+ end
29
+ CODE
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,120 @@
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}
19
+ if describer = #{@current_component}er.find_describer_by(name: "#{name}".to_sym)
20
+ #{@current_component}_describer_method(describer)
21
+ end
22
+ end
23
+ CODE
24
+ end
25
+ end
26
+
27
+ def create_attribute(name, options)
28
+ options.deep_symbolize_keys!
29
+
30
+ create_default_describer unless @describers.present?
31
+
32
+ describer_names = Array(options.delete(:describers))
33
+ describer_names = @describers.keys if describer_names.blank?
34
+
35
+ describer_names.each do |describer_name|
36
+ if describer_options = @describers.dig(describer_name)
37
+ describer_options[:attributes].store(name, options)
38
+ end
39
+ end
40
+ end
41
+
42
+ def find_describer_by(name:)
43
+ options = @describers.dig(name)
44
+ return nil unless options.present?
45
+
46
+ hash = {
47
+ name: name,
48
+ database: options[:database],
49
+ attributes: options[:attributes]
50
+ }
51
+
52
+ if @current_component == :export
53
+ hash.merge!(kind: options[:kind])
54
+ hash.merge!(includes: options[:attributes].values.map { |options| options.dig(:includes) }.compact.flatten(1).uniq)
55
+ hash.merge!(fields: build_describer_fields(options[:attributes]))
56
+ end
57
+ OpenStruct.new(hash)
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)
97
+ end
98
+
99
+ nested_field[0].push(nested_field_key)
100
+ nested_field[1].push(nested_field_val)
101
+ end
102
+ end
103
+
104
+ nested_field
105
+ end
106
+ end
107
+
108
+ def get_heading(options_heading)
109
+ options_heading.is_a?(Proc) ? options_heading.call(@current_class) : options_heading
110
+ end
111
+
112
+ def create_default_describer
113
+ case @current_component
114
+ when :export
115
+ create_describer(:to_csv, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database.to_sym })
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveKit
2
+ module Bedrock
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :Bedrocker
6
+ end
7
+ 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,24 +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
13
  def export_describer_method(describer)
25
14
  case describer.kind
26
15
  when :csv
@@ -1,113 +1,9 @@
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
-
3
+ class Exporter < Bedrock::Bedrocker
56
4
  def new_exporting(describer:)
57
5
  Exporting.new(describer: describer)
58
6
  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)
97
- end
98
-
99
- nested_field[0].push(nested_field_key)
100
- nested_field[1].push(nested_field_val)
101
- end
102
- end
103
-
104
- nested_field
105
- end
106
- end
107
-
108
- def get_heading(options_heading)
109
- options_heading.is_a?(Proc) ? options_heading.call(@current_class) : options_heading
110
- end
111
7
  end
112
8
  end
113
9
  end
@@ -0,0 +1,181 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Index
4
+ attr_reader :prefix, :schema, :attribute_value_parser
5
+
6
+ def initialize(current_class:)
7
+ @redis = ActiveKit::Search.redis
8
+ @current_class = current_class
9
+
10
+ current_class_name = current_class.to_s.parameterize.pluralize
11
+ @name = "activekit:search:index:#{current_class_name}"
12
+ @prefix = "activekit:search:#{current_class_name}"
13
+ @schema = {}
14
+ @attribute_value_parser = {}
15
+ end
16
+
17
+ def add_attribute_to_schema(name:, options:)
18
+ raise "Error: No type specified for the search attribute #{name}." unless options[:type].present?
19
+
20
+ attribute_schema = []
21
+
22
+ as = options.delete(:as)
23
+ attribute_schema.push("AS #{as}") unless as.nil?
24
+
25
+ type = options.delete(:type)
26
+ attribute_schema.push(type.to_s.upcase) unless type.nil?
27
+
28
+ options.each do |key, value|
29
+ if key == :value
30
+ @attribute_value_parser.store(name.to_s, value)
31
+ elsif key.is_a?(Symbol)
32
+ if value == true
33
+ attribute_schema.push(key.to_s.upcase)
34
+ elsif value != false
35
+ attribute_schema.push("#{key.to_s.upcase} #{value.to_s}")
36
+ end
37
+ else
38
+ raise "Invalid option provided to search attribute."
39
+ end
40
+ end
41
+
42
+ @schema.store(name.to_s, attribute_schema.join(" "))
43
+ end
44
+
45
+ def reload
46
+ current_command = @redis.get("#{@name}:command")
47
+ schema = { "database" => "TAG SORTABLE", "id" => "NUMERIC SORTABLE" }.merge(@schema)
48
+ command = "FT.CREATE #{@name} ON HASH PREFIX 1 #{@prefix}: SCHEMA #{schema.to_a.flatten.join(' ')}"
49
+ unless current_command == command
50
+ drop
51
+ @redis.call(command.split(' '))
52
+ @redis.set("#{@name}:command", command)
53
+ Rails.logger.info "ActiveKit::Search | Index Reloaded: " + "#{@name}:command"
54
+ Rails.logger.debug "=> " + @redis.get("#{@name}:command").to_s
55
+ end
56
+ end
57
+
58
+ def drop
59
+ if exists?
60
+ command = "FT.DROPINDEX #{@name}"
61
+ @redis.call(command.split(' '))
62
+ @redis.del("#{@name}:command")
63
+ Rails.logger.info "ActiveKit::Search | Index Dropped: " + @name
64
+ end
65
+ end
66
+
67
+ # Redis returns the results in the following form. Where first value is count of results, then every 2 elements are document_id, attributes respectively.
68
+ # [2, "doc:3", ["name", "Grape Juice", "stock_quantity", "4", "minimum_stock", "2"], "doc:4", ["name", "Apple Juice", "stock_quantity", "4", "minimum_stock", "2"]]
69
+ def fetch(term: nil, matching: "*", tags: {}, modifiers: {}, offset: nil, limit: nil, order: nil, page: nil, **options)
70
+ original_term = term
71
+
72
+ if term == ""
73
+ results = nil
74
+ elsif self.exists?
75
+ if term.present?
76
+ term.strip!
77
+ term = escape_separators(term)
78
+
79
+ case matching
80
+ when "*"
81
+ term = "#{term}*"
82
+ when "%"
83
+ term = "%#{term}%"
84
+ when "%%"
85
+ term = "%%#{term}%%"
86
+ when "%%%"
87
+ term = "%%%#{term}%%%"
88
+ end
89
+
90
+ term = " #{term}"
91
+ else
92
+ term = ""
93
+ end
94
+
95
+ if tags.present?
96
+ tags = tags.map do |key, value|
97
+ value = value.join("|") if value.is_a?(Array)
98
+ "@#{escape_separators(key)}:{#{escape_separators(value, include_space: true).presence || 'nil'}}"
99
+ end
100
+ tags = tags.join(" ")
101
+ tags = " #{tags}"
102
+ else
103
+ tags = ""
104
+ end
105
+
106
+ if modifiers.present?
107
+ modifiers = modifiers.map { |key, value| "@#{escape_separators(key)}:#{escape_separators(value)}" }.join(" ")
108
+ modifiers = " #{modifiers}"
109
+ else
110
+ modifiers = ""
111
+ end
112
+
113
+ if (offset.present? || limit.present?) && page.present?
114
+ raise "Error: Cannot specify page and offset/limit at the same time. Please specify one of either page or offset/limit."
115
+ end
116
+
117
+ if page.present?
118
+ page = page.to_i.abs
119
+
120
+ case page
121
+ when 0
122
+ page = 1
123
+ offset = 0
124
+ limit = 15
125
+ when 1
126
+ offset = 0
127
+ limit = 15
128
+ when 2
129
+ offset = 15
130
+ limit = 30
131
+ when 3
132
+ offset = 45
133
+ limit = 50
134
+ else
135
+ limit = 100
136
+ offset = 15 + 30 + 50 + (page - 4) * limit
137
+ end
138
+ end
139
+
140
+ query = "@database:{#{escape_separators(System::Current.tenant.database, include_space: true)}}#{term}#{tags}#{modifiers}"
141
+ command = [
142
+ "FT.SEARCH",
143
+ @name,
144
+ query,
145
+ "LIMIT",
146
+ offset ? offset.to_i : 0, # 0 is the default offset of redisearch in LIMIT 0 10. https://redis.io/commands/ft.search
147
+ limit ? limit.to_i : 10 # 10 is the default limit of redisearch in LIMIT 0 10. https://redis.io/commands/ft.search
148
+ ]
149
+ command.push("SORTBY", *order.split(' ')) if order.present?
150
+ results = @redis.call(command)
151
+ Rails.logger.info "ActiveKit::Search | Index Searched: " + command.to_s
152
+ Rails.logger.debug "=> " + results.to_s
153
+ else
154
+ results = nil
155
+ end
156
+
157
+ SearchResult.new(term: original_term, results: results, offset: offset, limit: limit, page: page, current_class: @current_class)
158
+ end
159
+
160
+ # List of characters from https://oss.redislabs.com/redisearch/Escaping/
161
+ # ,.<>{}[]"':;!@#$%^&*()-+=~
162
+ def escape_separators(value, include_space: false)
163
+ value = value.to_s
164
+
165
+ unless include_space
166
+ pattern = %r{(\'|\"|\.|\,|\;|\<|\>|\{|\}|\[|\]|\"|\'|\=|\~|\*|\:|\#|\+|\^|\$|\@|\%|\!|\&|\)|\(|/|\-|\\)}
167
+ else
168
+ pattern = %r{(\'|\"|\.|\,|\;|\<|\>|\{|\}|\[|\]|\"|\'|\=|\~|\*|\:|\#|\+|\^|\$|\@|\%|\!|\&|\)|\(|/|\-|\\|\s)}
169
+ end
170
+
171
+ value.gsub(pattern) { |match| '\\' + match }
172
+ end
173
+
174
+ private
175
+
176
+ def exists?
177
+ @redis.call("FT._LIST").include?(@name)
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,44 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Key
4
+ def initialize(index:)
5
+ @redis = ActiveKit::Search.redis
6
+ @index = index
7
+ end
8
+
9
+ def reload(record:)
10
+ clear(record: record)
11
+
12
+ hash_key = key(record: record)
13
+ hash_value = { "database" => System::Current.tenant.database, "id" => record.id }
14
+ @index.schema.each do |field_name, field_value|
15
+ attribute_name = field_name
16
+ attribute_value = @index.attribute_value_parser[field_name]&.call(record) || record.public_send(field_name)
17
+ attribute_value = field_value.downcase.include?("tag") ? attribute_value : @index.escape_separators(attribute_value)
18
+ hash_value.store(attribute_name, attribute_value)
19
+ end
20
+ @redis.hset(hash_key, hash_value)
21
+ Rails.logger.info "ActiveKit::Search | Key Reloaded: " + hash_key
22
+ Rails.logger.debug "=> " + @redis.hgetall("#{hash_key}").to_s
23
+ end
24
+
25
+ def clear(record:)
26
+ hash_key = key(record: record)
27
+ drop(keys: hash_key)
28
+ end
29
+
30
+ def drop(keys:)
31
+ return unless keys.present?
32
+ if @redis.del(keys) > 0
33
+ Rails.logger.info "ActiveKit::Search | Keys Removed: " + keys.to_s
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def key(record:)
40
+ "#{@index.prefix}:#{System::Current.tenant.database}:#{record.id}"
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Search
4
+ attr_reader :current_page, :previous_page, :next_page
5
+
6
+ def initialize(current_class:)
7
+ @current_class = current_class
8
+
9
+ @index = Index.new(current_class: @current_class)
10
+ @key = Key.new(index: @index)
11
+ @suggestion = Suggestion.new(current_class: @current_class)
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,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,126 @@
1
+ require 'active_support/concern'
2
+
3
+ module ActiveKit
4
+ module Search
5
+ module Searchable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ end
10
+
11
+ class_methods do
12
+ def searcher
13
+ @searcher ||= ActiveKit::Search::Search.new(current_class: self)
14
+ end
15
+
16
+ def searching(term: nil, **options)
17
+ options[:page] = 1 if options.key?(:page) && options[:page].blank?
18
+ searcher.fetch(term: term, **options).records
19
+ end
20
+
21
+ def search_attribute(name, **options)
22
+ options.deep_symbolize_keys!
23
+
24
+ set_activekit_search_callbacks unless searcher.attributes_present?
25
+ depends_on = options.delete(:depends_on) || {}
26
+ set_activekit_search_depends_on_callbacks(depends_on: depends_on) unless depends_on.empty?
27
+
28
+ searcher.add_attribute(name: name, options: options)
29
+ end
30
+
31
+ def set_activekit_search_callbacks
32
+ after_commit do
33
+ self.class.searcher.reload(record: self)
34
+ logger.info "ActiveKit::Search | Indexing from #{self.class.name}: Done."
35
+ end
36
+ end
37
+
38
+ def set_activekit_search_depends_on_callbacks(depends_on:)
39
+ depends_on.each do |depends_on_association, depends_on_inverse|
40
+ klass = self.reflect_on_all_associations.map { |assoc| [assoc.name, assoc.klass.name] }.to_h[depends_on_association]
41
+ klass.constantize.class_eval do
42
+ after_commit do
43
+ inverse_assoc = self.public_send(depends_on_inverse)
44
+ if inverse_assoc.respond_to?(:each)
45
+ inverse_assoc.each { |instance| instance.class.searcher.reload(record: instance) }
46
+ else
47
+ inverse_assoc.class.searcher.reload(record: inverse_assoc)
48
+ end
49
+ logger.info "ActiveKit::Search | Indexing from #{self.class.name}: Done."
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+
60
+
61
+
62
+ # require 'active_support/concern'
63
+
64
+ # module ActiveKit
65
+ # module Search
66
+ # module Searchable
67
+ # extend ActiveSupport::Concern
68
+
69
+ # included do
70
+ # end
71
+
72
+ # class_methods do
73
+ # def searcher
74
+ # @searcher ||= ActiveKit::Search::Searcher.new(current_class: self)
75
+ # end
76
+
77
+ # def search_describer(name, **options)
78
+ # name = name.to_sym
79
+ # options.deep_symbolize_keys!
80
+
81
+ # unless searcher.find_describer_by(describer_name: name)
82
+ # searcher.new_describer(name: name, options: options)
83
+ # define_search_describer_method(kind: options[:kind], name: name)
84
+ # end
85
+ # end
86
+
87
+ # def search_attribute(name, **options)
88
+ # search_describer(:to_csv, kind: :csv, database: -> { ActiveRecord::Base.connection_db_config.database.to_sym }) unless searcher.describers?
89
+
90
+ # options.deep_symbolize_keys!
91
+ # searcher.new_attribute(name: name.to_sym, options: options)
92
+ # end
93
+
94
+ # def define_search_describer_method(kind:, name:)
95
+ # case kind
96
+ # when :csv
97
+ # define_singleton_method name do
98
+ # describer = exporter.find_describer_by(describer_name: name)
99
+ # raise "could not find describer for the describer name '#{name}'" unless describer.present?
100
+
101
+ # # The 'all' relation must be captured outside the Enumerator,
102
+ # # else it will get reset to all the records of the class.
103
+ # all_activerecord_relation = all.includes(describer.includes)
104
+
105
+ # Enumerator.new do |yielder|
106
+ # ActiveRecord::Base.connected_to(role: :writing, shard: describer.database.call) do
107
+ # exporting = exporter.new_exporting(describer: describer)
108
+
109
+ # # Add the headings.
110
+ # yielder << CSV.generate_line(exporting.headings) if exporting.headings?
111
+
112
+ # # Add the values.
113
+ # # find_each will ignore any order if set earlier.
114
+ # all_activerecord_relation.find_each do |record|
115
+ # lines = exporting.lines_for(record: record)
116
+ # lines.each { |line| yielder << CSV.generate_line(line) }
117
+ # end
118
+ # end
119
+ # end
120
+ # end
121
+ # end
122
+ # end
123
+ # end
124
+ # end
125
+ # end
126
+ # end
@@ -0,0 +1,106 @@
1
+ # module ActiveKit
2
+ # module Search
3
+ # class Searcher
4
+ # attr_reader :describers
5
+
6
+ # def initialize(current_class:)
7
+ # @current_class = current_class
8
+ # @describers = {}
9
+ # end
10
+
11
+ # def find_describer_by(describer_name:)
12
+ # describer_options = @describers.dig(describer_name)
13
+ # return nil unless describer_options.present?
14
+
15
+ # describer_attributes = describer_options[:attributes]
16
+ # includes = describer_attributes.values.map { |options| options.dig(:includes) }.compact.flatten(1).uniq
17
+ # fields = build_describer_fields(describer_attributes)
18
+ # hash = {
19
+ # name: describer_name,
20
+ # kind: describer_options[:kind],
21
+ # database: describer_options[:database],
22
+ # attributes: describer_attributes,
23
+ # includes: includes,
24
+ # fields: fields
25
+ # }
26
+ # OpenStruct.new(hash)
27
+ # end
28
+
29
+ # def new_describer(name:, options:)
30
+ # options.store(:attributes, {})
31
+ # @describers.store(name, options)
32
+ # end
33
+
34
+ # def describers?
35
+ # @describers.present?
36
+ # end
37
+
38
+ # def new_attribute(name:, options:)
39
+ # describer_names = Array(options.delete(:describers))
40
+ # describer_names = @describers.keys if describer_names.blank?
41
+
42
+ # describer_names.each do |describer_name|
43
+ # if describer_options = @describers.dig(describer_name)
44
+ # describer_options[:attributes].store(name, options)
45
+ # end
46
+ # end
47
+ # end
48
+
49
+ # def new_exporting(describer:)
50
+ # Exporting.new(describer: describer)
51
+ # end
52
+
53
+ # private
54
+
55
+ # def build_describer_fields(describer_attributes)
56
+ # describer_attributes.inject({}) do |fields_hash, (name, options)|
57
+ # enclosed_attributes = Array(options.dig(:attributes))
58
+
59
+ # if enclosed_attributes.blank?
60
+ # field_key, field_value = (get_heading(options.dig(:heading))&.to_s || name.to_s.titleize), (options.dig(:value) || name)
61
+ # else
62
+ # field_key, field_value = get_nested_field(name, options, enclosed_attributes)
63
+ # end
64
+ # fields_hash.store(field_key, field_value)
65
+
66
+ # fields_hash
67
+ # end
68
+ # end
69
+
70
+ # def get_nested_field(name, options, enclosed_attributes, ancestor_heading = nil)
71
+ # parent_heading = ancestor_heading.present? ? ancestor_heading : ""
72
+ # parent_heading += (get_heading(options.dig(:heading))&.to_s || name.to_s.singularize.titleize) + " "
73
+ # parent_value = options.dig(:value) || name
74
+
75
+ # enclosed_attributes.inject([[], [parent_value]]) do |nested_field, enclosed_attribute|
76
+ # unless enclosed_attribute.is_a? Hash
77
+ # nested_field_key = parent_heading + enclosed_attribute.to_s.titleize
78
+ # nested_field_val = enclosed_attribute
79
+
80
+ # nested_field[0].push(nested_field_key)
81
+ # nested_field[1].push(nested_field_val)
82
+ # else
83
+ # enclosed_attribute.each do |enclosed_attribute_key, enclosed_attribute_value|
84
+ # wrapped_attributes = Array(enclosed_attribute_value.dig(:attributes))
85
+ # if wrapped_attributes.blank?
86
+ # nested_field_key = parent_heading + (get_heading(enclosed_attribute_value.dig(:heading))&.to_s || enclosed_attribute_key.to_s.titleize)
87
+ # nested_field_val = enclosed_attribute_value.dig(:value) || enclosed_attribute_key
88
+ # else
89
+ # nested_field_key, nested_field_val = get_nested_field(enclosed_attribute_key, enclosed_attribute_value, wrapped_attributes, parent_heading)
90
+ # end
91
+
92
+ # nested_field[0].push(nested_field_key)
93
+ # nested_field[1].push(nested_field_val)
94
+ # end
95
+ # end
96
+
97
+ # nested_field
98
+ # end
99
+ # end
100
+
101
+ # def get_heading(options_heading)
102
+ # options_heading.is_a?(Proc) ? options_heading.call(@current_class) : options_heading
103
+ # end
104
+ # end
105
+ # end
106
+ # end
@@ -0,0 +1,104 @@
1
+ # module ActiveKit
2
+ # module Search
3
+ # class Searching
4
+
5
+ # def initialize(describer:)
6
+ # @describer = describer
7
+ # end
8
+
9
+ # def headings
10
+ # @headings ||= @describer.fields.keys.flatten
11
+ # end
12
+
13
+ # def headings?
14
+ # headings.present?
15
+ # end
16
+
17
+ # def lines_for(record:)
18
+ # row_counter, column_counter = 1, 0
19
+
20
+ # @describer.fields.inject([[]]) do |rows, (heading, value)|
21
+ # if value.is_a? Proc
22
+ # rows[0].push(value.call(record))
23
+ # column_counter += 1
24
+ # elsif value.is_a?(Symbol) || value.is_a?(String)
25
+ # rows[0].push(record.public_send(value))
26
+ # column_counter += 1
27
+ # elsif value.is_a? Array
28
+ # deeprows = get_deeprows(record, heading, value, column_counter)
29
+ # deeprows.each do |deeprow|
30
+ # rows[row_counter] = deeprow
31
+ # row_counter += 1
32
+ # end
33
+
34
+ # column_count = get_column_count_for(value)
35
+ # column_count.times { |i| rows[0].push(nil) }
36
+ # column_counter += column_count
37
+ # else
38
+ # raise "Could not identify '#{value}' for '#{heading}'."
39
+ # end
40
+
41
+ # rows
42
+ # end
43
+ # end
44
+
45
+ # private
46
+
47
+ # def get_deeprows(record, heading, value, column_counter)
48
+ # value_clone = value.clone
49
+ # assoc_value = value_clone.shift
50
+
51
+ # if assoc_value.is_a? Proc
52
+ # assoc_records = assoc_value.call(record)
53
+ # elsif assoc_value.is_a?(Symbol) || assoc_value.is_a?(String)
54
+ # assoc_records = record.public_send(assoc_value)
55
+ # else
56
+ # raise "Count not identity '#{assoc_value}' for '#{heading}'."
57
+ # end
58
+
59
+ # subrows = []
60
+ # assoc_records.each do |assoc_record|
61
+ # subrow, subrow_column_counter, deeprows = [], 0, []
62
+ # column_counter.times { |i| subrow.push(nil) }
63
+
64
+ # subrow = value_clone.inject(subrow) do |subrow, v|
65
+ # if v.is_a? Proc
66
+ # subrow.push(v.call(assoc_record))
67
+ # subrow_column_counter += 1
68
+ # elsif v.is_a?(Symbol) || v.is_a?(String)
69
+ # subrow.push(assoc_record.public_send(v))
70
+ # subrow_column_counter += 1
71
+ # elsif v.is_a? Array
72
+ # deeprows = get_deeprows(assoc_record, heading, v, (column_counter + subrow_column_counter))
73
+
74
+ # column_count = get_column_count_for(v)
75
+ # column_count.times { |i| subrow.push(nil) }
76
+ # subrow_column_counter += column_count
77
+ # end
78
+
79
+ # subrow
80
+ # end
81
+
82
+ # subrows.push(subrow)
83
+ # deeprows.each { |deeprow| subrows.push(deeprow) }
84
+ # end
85
+
86
+ # subrows
87
+ # end
88
+
89
+ # def get_column_count_for(value)
90
+ # count = 0
91
+
92
+ # value.each do |v|
93
+ # unless v.is_a? Array
94
+ # count += 1
95
+ # else
96
+ # count += get_column_count_for(v)
97
+ # end
98
+ # end
99
+
100
+ # count - 1
101
+ # end
102
+ # end
103
+ # end
104
+ # end
@@ -0,0 +1,39 @@
1
+ module ActiveKit
2
+ module Search
3
+ class Suggestion
4
+ def initialize(current_class:)
5
+ @redis = ActiveKit::Search.redis
6
+ @current_class = current_class
7
+ @current_class_name = current_class.to_s.parameterize.pluralize
8
+ end
9
+
10
+ def add(term:, score: 1, increment: true)
11
+ command = ["FT.SUGADD", key, term, score, (increment ? 'INCR' : '')]
12
+ @redis.call(command)
13
+ end
14
+
15
+ def fetch(prefix:)
16
+ command = ["FT.SUGGET", key, prefix, "FUZZY", "MAX", "10", "WITHSCORES"]
17
+ results = @redis.call(command)
18
+
19
+ SuggestionResult.new(prefix: prefix, results: results)
20
+ end
21
+
22
+ def del(term:)
23
+ command = ["FT.SUGDEL", key, term]
24
+ @redis.call(command)
25
+ end
26
+
27
+ def len
28
+ command = ["FT.SUGLEN", key]
29
+ @redis.call(command)
30
+ end
31
+
32
+ private
33
+
34
+ def key
35
+ "activekit:search:suggestions:#{@current_class_name}:#{System::Current.tenant.database}"
36
+ end
37
+ end
38
+ end
39
+ 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 :Search
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.dev8'
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,84 @@
1
+ namespace :active_kit do
2
+ desc "bundle exec rails active_kit:boot DOMAIN='www.yourdomain.com'"
3
+ task boot: [:environment] do
4
+ # Database & Preferences
5
+ domain = ENV['DOMAIN'] || raise("DOMAIN not specified")
6
+
7
+ tenant = nil
8
+ shard_name = nil
9
+
10
+ # Returns the first db config for a specific environment where it is assumed that all tenants data is stored.
11
+ default_shard_name = ActiveRecord::Base.configurations.find_db_config(Rails.env).name
12
+
13
+ ActiveRecord::Base.connected_to(role: :writing, shard: default_shard_name.to_sym) do
14
+ tenant = System::Tenant.where(domain: domain).or(System::Tenant.where(custom_domain: domain)).select(:database, :storage, :domain, :custom_domain).first
15
+ shard_name = tenant.database
16
+ raise RuntimeError, 'Could not set shard name.' unless shard_name.present? # TODO: In future, redirect this to "Nothing Here Yet" page
17
+ end
18
+
19
+ ApplicationRecord.connects_to database: { writing: "#{shard_name}".to_sym }
20
+
21
+ System::Current.tenant = OpenStruct.new(tenant.serializable_hash)
22
+ System::Current.preferences = System::Preference.revealed
23
+ System::Current.integrations = System::Integration.revealed
24
+ end
25
+
26
+ namespace :search do
27
+ desc "bundle exec rails active_kit:search:reload CLASS='Article' DOMAIN='www.yourdomain.com'"
28
+ task reload: [:boot] do
29
+ if ENV['CLASS']
30
+ if ENV['CLASS'].constantize.searcher.attributes_present?
31
+ puts "ActiveKit::Search | Reloading: #{ENV['CLASS']}"
32
+ ENV['CLASS'].constantize.searcher.reload
33
+ end
34
+ else
35
+ Rails.application.eager_load!
36
+ models = ApplicationRecord.descendants.collect(&:name)
37
+ models.each do |model|
38
+ if model.constantize.searcher.attributes_present?
39
+ puts "ActiveKit::Search | Reloading: #{model}"
40
+ model.constantize.searcher.reload
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ desc "bundle exec rails active_kit:search:clear CLASS='Article' DOMAIN='www.yourdomain.com'"
47
+ task clear: [:boot] do
48
+ if ENV['CLASS']
49
+ if ENV['CLASS'].constantize.searcher.attributes_present?
50
+ puts "ActiveKit::Search | Clearing: #{ENV['CLASS']}"
51
+ ENV['CLASS'].constantize.searcher.clear
52
+ end
53
+ else
54
+ Rails.application.eager_load!
55
+ models = ApplicationRecord.descendants.collect(&:name)
56
+ models.each do |model|
57
+ if model.constantize.searcher.attributes_present?
58
+ puts "ActiveKit::Search | Clearing: #{model}"
59
+ model.constantize.searcher.clear
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ desc "bundle exec rails active_kit:search:drop CLASS='Article' DOMAIN='www.yourdomain.com'"
66
+ task drop: [:boot] do
67
+ if ENV['CLASS']
68
+ if ENV['CLASS'].constantize.searcher.attributes_present?
69
+ puts "ActiveKit::Search | Dropping: #{ENV['CLASS']}"
70
+ ENV['CLASS'].constantize.searcher.drop
71
+ end
72
+ else
73
+ Rails.application.eager_load!
74
+ models = ApplicationRecord.descendants.collect(&:name)
75
+ models.each do |model|
76
+ if model.constantize.searcher.attributes_present?
77
+ puts "ActiveKit::Search | Dropping: #{model}"
78
+ model.constantize.searcher.drop
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ 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.dev8
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-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -50,8 +50,9 @@ 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
55
56
  - lib/active_kit/engine.rb
56
57
  - lib/active_kit/export.rb
57
58
  - lib/active_kit/export/exportable.rb
@@ -63,9 +64,20 @@ files:
63
64
  - lib/active_kit/position/positionable.rb
64
65
  - lib/active_kit/position/positioner.rb
65
66
  - lib/active_kit/position/positioning.rb
67
+ - lib/active_kit/search.rb
68
+ - lib/active_kit/search/index.rb
69
+ - lib/active_kit/search/key.rb
70
+ - lib/active_kit/search/search.rb
71
+ - lib/active_kit/search/search_result.rb
72
+ - lib/active_kit/search/searchable.rb
73
+ - lib/active_kit/search/searcher.rb
74
+ - lib/active_kit/search/searching.rb
75
+ - lib/active_kit/search/suggestion.rb
76
+ - lib/active_kit/search/suggestion_result.rb
66
77
  - lib/active_kit/version.rb
67
78
  - lib/activekit.rb
68
79
  - lib/tasks/active_kit_tasks.rake
80
+ - lib/tasks/search_tasks.rake
69
81
  homepage: https://github.com/plainsource/activekit
70
82
  licenses:
71
83
  - MIT
@@ -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