activekit 0.4.0 → 0.5.0.dev1

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: 2dffaf7d844bba5ea65c34b98f3872cf70cb44c76963fb0d7a96bb39df174acb
4
- data.tar.gz: 5c64d866b07f8460145f0a8069ac316e96d9ee15985fcce13e9a598fd2f6dedc
3
+ metadata.gz: 20c474dd0fdd1ea839f9f6cb7a00e1c99c2f3f68aa40ae70fa273daf1bfa32a7
4
+ data.tar.gz: 318854b73241d7b45c6f123f91e78fd58dd394ff37383530579d48ae30083832
5
5
  SHA512:
6
- metadata.gz: f704f847169d738fa9f708246661b1dfff4594349fd3c8297b9070542046684106b5f5a831505bc5160c544fcc79d13b2c2920520e5184191832f34ff3e62979
7
- data.tar.gz: af2af6217c992fb16c4205b88e0172947b043c2f52c93f389570989444a4726aa0fa62392e7d27b4dc48450120cb943c0e7e38c9f4606a3719d0a868badc8608
6
+ metadata.gz: 3b6fc80b934fd2fdb7743cc72b45b18ee276cc129fd11731e5e10c2ab5b7acfda24d03b979b578f2e19bb054b5e357a3361688860bec9d0e3bb0d221229646e7
7
+ data.tar.gz: 9383e8c8deaf6a2e268cb8b7cf5a3c04204be745d7519b969d4bd151616266f915ac2f24543555a5d675fa409b979be03099026dda806669b200eed67a1b6af6
@@ -12,10 +12,12 @@ module ActiveKit
12
12
  initializer "active_kit.activekitable" do
13
13
  require "active_kit/export/exportable"
14
14
  require "active_kit/position/positionable"
15
+ require "active_kit/search/searchable"
15
16
 
16
17
  ActiveSupport.on_load(:active_record) do
17
18
  include ActiveKit::Export::Exportable
18
19
  include ActiveKit::Position::Positionable
20
+ include ActiveKit::Search::Searchable
19
21
  end
20
22
  end
21
23
  end
@@ -19,7 +19,7 @@ module ActiveKit
19
19
 
20
20
  unless exporter.find_describer_by(describer_name: name)
21
21
  exporter.new_describer(name: name, options: options)
22
- define_describer_method(kind: options[:kind], name: name)
22
+ define_export_describer_method(kind: options[:kind], name: name)
23
23
  end
24
24
  end
25
25
 
@@ -30,7 +30,7 @@ module ActiveKit
30
30
  exporter.new_attribute(name: name.to_sym, options: options)
31
31
  end
32
32
 
33
- def define_describer_method(kind:, name:)
33
+ def define_export_describer_method(kind:, name:)
34
34
  case kind
35
35
  when :csv
36
36
  define_singleton_method name do
@@ -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,10 @@
1
+ module ActiveKit
2
+ module Search
3
+ extend ActiveSupport::Autoload
4
+
5
+ autoload :Searcher
6
+ autoload :Searching
7
+
8
+ mattr_accessor :redis, instance_accessor: false
9
+ end
10
+ end
@@ -1,3 +1,3 @@
1
1
  module ActiveKit
2
- VERSION = '0.4.0'
2
+ VERSION = '0.5.0.dev1'
3
3
  end
data/lib/active_kit.rb CHANGED
@@ -6,4 +6,5 @@ module ActiveKit
6
6
 
7
7
  autoload :Export
8
8
  autoload :Position
9
+ autoload :Search
9
10
  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.4.0
4
+ version: 0.5.0.dev1
5
5
  platform: ruby
6
6
  authors:
7
7
  - plainsource
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-28 00:00:00.000000000 Z
11
+ date: 2024-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -61,6 +61,16 @@ files:
61
61
  - lib/active_kit/position/positionable.rb
62
62
  - lib/active_kit/position/positioner.rb
63
63
  - lib/active_kit/position/positioning.rb
64
+ - lib/active_kit/search.rb
65
+ - lib/active_kit/search/index.rb
66
+ - lib/active_kit/search/key.rb
67
+ - lib/active_kit/search/search.rb
68
+ - lib/active_kit/search/search_result.rb
69
+ - lib/active_kit/search/searchable.rb
70
+ - lib/active_kit/search/searcher.rb
71
+ - lib/active_kit/search/searching.rb
72
+ - lib/active_kit/search/suggestion.rb
73
+ - lib/active_kit/search/suggestion_result.rb
64
74
  - lib/active_kit/version.rb
65
75
  - lib/activekit.rb
66
76
  - lib/tasks/active_kit_tasks.rake
@@ -80,9 +90,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
90
  version: '0'
81
91
  required_rubygems_version: !ruby/object:Gem::Requirement
82
92
  requirements:
83
- - - ">="
93
+ - - ">"
84
94
  - !ruby/object:Gem::Version
85
- version: '0'
95
+ version: 1.3.1
86
96
  requirements: []
87
97
  rubygems_version: 3.1.2
88
98
  signing_key: