activekit 0.5.0.dev7 → 0.5.0.dev8

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