activekit 0.4.0 → 0.5.0.dev2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/active_kit/engine.rb +2 -0
- data/lib/active_kit/export/exportable.rb +2 -2
- data/lib/active_kit/search/index.rb +181 -0
- data/lib/active_kit/search/key.rb +44 -0
- data/lib/active_kit/search/search.rb +68 -0
- data/lib/active_kit/search/search_result.rb +33 -0
- data/lib/active_kit/search/searchable.rb +126 -0
- data/lib/active_kit/search/searcher.rb +106 -0
- data/lib/active_kit/search/searching.rb +104 -0
- data/lib/active_kit/search/suggestion.rb +39 -0
- data/lib/active_kit/search/suggestion_result.rb +21 -0
- data/lib/active_kit/search.rb +16 -0
- data/lib/active_kit/version.rb +1 -1
- data/lib/active_kit.rb +1 -0
- metadata +14 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2e2eea75f42af6dee1472a45419f36d2ac503c1aaa30745e0a513728bca2a7de
|
4
|
+
data.tar.gz: 4a377406bcfab283361ce08307989ee06a4753af4470524a2d0cc3ef3e0a0f50
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 03727406b7acdd0257e723fe771c0cb5f85b2c0a72e57b6d63735e9ad92ef4634b3e500233ec4cc585f450ae952d7f0802d4e86b9a9dbadb8bb51ea8e894baf2
|
7
|
+
data.tar.gz: 5a518d157ac540f007be8c7b9c57010f68934ed9ad74ab8ba2fbce38a51517d3d2a0c90c74b616299f66e3e256dc1b7a467ee5d8ef258689e8c92e4e76fa66bd
|
data/lib/active_kit/engine.rb
CHANGED
@@ -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
|
-
|
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
|
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,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
|
data/lib/active_kit/version.rb
CHANGED
data/lib/active_kit.rb
CHANGED
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
|
+
version: 0.5.0.dev2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- plainsource
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
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:
|
95
|
+
version: 1.3.1
|
86
96
|
requirements: []
|
87
97
|
rubygems_version: 3.1.2
|
88
98
|
signing_key:
|