redi_search 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.rubocop.yml +1757 -0
  4. data/.travis.yml +23 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +17 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +220 -0
  9. data/Rakefile +12 -0
  10. data/bin/console +8 -0
  11. data/bin/publish +58 -0
  12. data/bin/setup +8 -0
  13. data/bin/test +7 -0
  14. data/lib/redi_search/client.rb +68 -0
  15. data/lib/redi_search/configuration.rb +17 -0
  16. data/lib/redi_search/document/converter.rb +26 -0
  17. data/lib/redi_search/document.rb +79 -0
  18. data/lib/redi_search/error.rb +6 -0
  19. data/lib/redi_search/index.rb +100 -0
  20. data/lib/redi_search/log_subscriber.rb +94 -0
  21. data/lib/redi_search/model.rb +57 -0
  22. data/lib/redi_search/result/collection.rb +22 -0
  23. data/lib/redi_search/schema/field.rb +21 -0
  24. data/lib/redi_search/schema/geo_field.rb +30 -0
  25. data/lib/redi_search/schema/numeric_field.rb +30 -0
  26. data/lib/redi_search/schema/tag_field.rb +32 -0
  27. data/lib/redi_search/schema/text_field.rb +36 -0
  28. data/lib/redi_search/schema.rb +34 -0
  29. data/lib/redi_search/search/and_clause.rb +15 -0
  30. data/lib/redi_search/search/boolean_clause.rb +72 -0
  31. data/lib/redi_search/search/clauses.rb +89 -0
  32. data/lib/redi_search/search/highlight_clause.rb +43 -0
  33. data/lib/redi_search/search/or_clause.rb +21 -0
  34. data/lib/redi_search/search/term.rb +72 -0
  35. data/lib/redi_search/search/where_clause.rb +66 -0
  36. data/lib/redi_search/search.rb +89 -0
  37. data/lib/redi_search/spellcheck.rb +53 -0
  38. data/lib/redi_search/version.rb +5 -0
  39. data/lib/redi_search.rb +40 -0
  40. data/redi_search.gemspec +48 -0
  41. metadata +141 -0
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/schema"
4
+ require "redi_search/search"
5
+ require "redi_search/spellcheck"
6
+
7
+ module RediSearch
8
+ class Index
9
+ attr_reader :name, :schema, :model
10
+
11
+ def initialize(name, schema, model = nil)
12
+ @name = name
13
+ @schema = Schema.new(schema)
14
+ @model = model
15
+ end
16
+
17
+ def search(term = nil, **term_options)
18
+ Search.new(self, term, model, **term_options)
19
+ end
20
+
21
+ def spellcheck(query, distance: 1)
22
+ Spellcheck.new(self, query, distance: distance)
23
+ end
24
+
25
+ def create
26
+ create!
27
+ rescue Redis::CommandError
28
+ false
29
+ end
30
+
31
+ def create!
32
+ client.call!("CREATE", name, "SCHEMA", schema.to_a).ok?
33
+ end
34
+
35
+ def drop
36
+ drop!
37
+ rescue Redis::CommandError
38
+ false
39
+ end
40
+
41
+ def drop!
42
+ client.call!("DROP", name).ok?
43
+ end
44
+
45
+ def add(record, score = 1.0)
46
+ add!(record, score)
47
+ rescue Redis::CommandError
48
+ false
49
+ end
50
+
51
+ def add!(record, score = 1.0)
52
+ client.call!(
53
+ "ADD", name, record.id, score, "REPLACE", "FIELDS",
54
+ Document::Converter.new(self, record).document.to_a
55
+ )
56
+ end
57
+
58
+ def add_multiple!(records)
59
+ client.pipelined do
60
+ records.each do |record|
61
+ add!(record)
62
+ end
63
+ end.ok?
64
+ end
65
+
66
+ def del(record, delete_document: false)
67
+ client.call!("DEL", name, record.id, ("DD" if delete_document))
68
+ end
69
+
70
+ def exist?
71
+ !client.call!("INFO", name).empty?
72
+ rescue Redis::CommandError
73
+ false
74
+ end
75
+
76
+ def info
77
+ hash = Hash[*client.call!("INFO", name)]
78
+ info_struct = Struct.new(*hash.keys.map(&:to_sym))
79
+ info_struct.new(*hash.values)
80
+ rescue Redis::CommandError
81
+ nil
82
+ end
83
+
84
+ def fields
85
+ @fields ||= schema.fields.map(&:to_s)
86
+ end
87
+
88
+ def reindex(docs)
89
+ drop if exist?
90
+ create
91
+ add_multiple! docs
92
+ end
93
+
94
+ private
95
+
96
+ def client
97
+ RediSearch.client
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RediSearch
4
+ class LogSubscriber < ActiveSupport::LogSubscriber
5
+ def self.runtime=(value)
6
+ Thread.current[:searchkick_runtime] = value
7
+ end
8
+
9
+ def self.runtime
10
+ Thread.current[:searchkick_runtime] ||= 0
11
+ end
12
+
13
+ #:nocov:
14
+ def self.reset_runtime
15
+ rt = runtime
16
+ self.runtime = 0
17
+ rt
18
+ end
19
+ #:nocov:
20
+
21
+ def search(event)
22
+ log_command(event, YELLOW)
23
+ end
24
+
25
+ def create(event)
26
+ log_command(event, GREEN)
27
+ end
28
+
29
+ def drop(event)
30
+ log_command(event, RED)
31
+ end
32
+
33
+ def add(event)
34
+ log_command(event, GREEN)
35
+ end
36
+
37
+ def info(event)
38
+ log_command(event, CYAN)
39
+ end
40
+
41
+ def pipeline(event)
42
+ log_command(event, MAGENTA)
43
+ end
44
+
45
+ def get(event)
46
+ log_command(event, CYAN)
47
+ end
48
+
49
+ def mget(event)
50
+ log_command(event, CYAN)
51
+ end
52
+
53
+ def del(event)
54
+ log_command(event, RED)
55
+ end
56
+
57
+ def spellcheck(event)
58
+ log_command(event, YELLOW)
59
+ end
60
+
61
+ private
62
+
63
+ def log_command(event, debug_color)
64
+ self.class.runtime += event.duration
65
+ return unless logger.debug?
66
+
67
+ payload = event.payload
68
+ name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
69
+ command = command_string(payload)
70
+
71
+ debug " #{color(name, RED, true)} #{color(command, debug_color, true)}"
72
+ end
73
+
74
+ def command_string(payload)
75
+ payload[:query].flatten.map.with_index do |arg, i|
76
+ arg = "FT.#{arg}" if prepend_ft?(arg, i)
77
+ arg = arg.inspect if inspect_arg?(payload, arg)
78
+ arg
79
+ end.join(" ")
80
+ end
81
+
82
+ def multiword?(string)
83
+ !string.to_s.starts_with?(/\(-?@/) && string.to_s.split(/\s|\|/).size > 1
84
+ end
85
+
86
+ def prepend_ft?(arg, index)
87
+ index.zero? && !multiword?(arg)
88
+ end
89
+
90
+ def inspect_arg?(payload, arg)
91
+ multiword?(arg) && payload[:query].flatten.count > 1
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/index"
4
+ require "redi_search/document/converter"
5
+
6
+ require "active_support/concern"
7
+
8
+ module RediSearch
9
+ module Model
10
+ extend ActiveSupport::Concern
11
+
12
+ class_methods do
13
+ attr_reader :redi_search_index
14
+
15
+ def redi_search(**options) # rubocop:disable Metrics/MethodLength
16
+ @redi_search_index = Index.new(
17
+ options[:index_name] || "#{name.underscore}_idx",
18
+ options[:schema],
19
+ self
20
+ )
21
+
22
+ if respond_to? :after_commit
23
+ after_commit :redi_search_add_document, on: [:create, :update]
24
+ end
25
+ if respond_to? :after_destroy_commit
26
+ after_destroy_commit :redi_search_delete_document
27
+ end
28
+
29
+ class << self
30
+ def search(term = nil, **term_options)
31
+ redi_search_index.search(term, **term_options)
32
+ end
33
+
34
+ def reindex
35
+ redi_search_index.reindex(all)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def redi_search_document
42
+ Document::Converter.new(self.class.redi_search_index, self).document
43
+ end
44
+
45
+ def redi_search_delete_document
46
+ return unless self.class.redi_search_index.exist?
47
+
48
+ self.class.redi_search_index.del(self, delete_document: true)
49
+ end
50
+
51
+ def redi_search_add_document
52
+ return unless self.class.redi_search_index.exist?
53
+
54
+ self.class.redi_search_index.add(self)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RediSearch
4
+ class Result
5
+ class Collection < Array
6
+ def initialize(index, count, records)
7
+ @count = count
8
+ super(Hash[*records].map do |doc_id, fields|
9
+ Document.new(index, doc_id, Hash[*fields])
10
+ end)
11
+ end
12
+
13
+ def count
14
+ @count || super
15
+ end
16
+
17
+ def size
18
+ @count || super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RediSearch
4
+ class Schema
5
+ class Field
6
+ private
7
+
8
+ FALSES = [
9
+ nil, "", false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"
10
+ ].freeze
11
+
12
+ def boolean_options_string
13
+ boolean_options.map do |option|
14
+ unless FALSES.include?(send(option))
15
+ option.to_s.upcase.split("_").join
16
+ end
17
+ end.compact
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/schema/field"
4
+
5
+ module RediSearch
6
+ class Schema
7
+ class GeoField < Field
8
+ def initialize(name, sortable: false, no_index: false)
9
+ @name = name
10
+ @sortable = sortable
11
+ @no_index = no_index
12
+ end
13
+
14
+ def to_a
15
+ query = [name.to_s, "GEO"]
16
+ query += boolean_options_string
17
+
18
+ query
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :name, :sortable, :no_index
24
+
25
+ def boolean_options
26
+ %i(sortable no_index)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/schema/field"
4
+
5
+ module RediSearch
6
+ class Schema
7
+ class NumericField < Field
8
+ def initialize(name, sortable: false, no_index: false)
9
+ @name = name
10
+ @sortable = sortable
11
+ @no_index = no_index
12
+ end
13
+
14
+ def to_a
15
+ query = [name.to_s, "NUMERIC"]
16
+ query += boolean_options_string
17
+
18
+ query
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :name, :sortable, :no_index
24
+
25
+ def boolean_options
26
+ %i(sortable no_index)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/schema/field"
4
+
5
+ module RediSearch
6
+ class Schema
7
+ class TagField < Field
8
+ def initialize(name, separator: ",", sortable: false, no_index: false)
9
+ @name = name
10
+ @separator = separator
11
+ @sortable = sortable
12
+ @no_index = no_index
13
+ end
14
+
15
+ def to_a
16
+ query = [name.to_s, "TAG"]
17
+ query += boolean_options_string
18
+ query += ["SEPARATOR", separator] if separator
19
+
20
+ query
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :name, :separator, :sortable, :no_index
26
+
27
+ def boolean_options
28
+ %i(sortable no_index)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/type"
4
+
5
+ module RediSearch
6
+ class Schema
7
+ class TextField < Field
8
+ def initialize(name, weight: 1.0, phonetic: nil, sortable: false,
9
+ no_index: false, no_stem: false)
10
+ @name = name
11
+ @weight = weight
12
+ @phonetic = phonetic
13
+ @sortable = sortable
14
+ @no_index = no_index
15
+ @no_stem = no_stem
16
+ end
17
+
18
+ def to_a
19
+ query = [name.to_s, "TEXT"]
20
+ query += boolean_options_string
21
+ query += ["WEIGHT", weight] if weight
22
+ query += ["PHONETIC", phonetic] if phonetic
23
+
24
+ query
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :name, :weight, :phonetic, :sortable, :no_index, :no_stem
30
+
31
+ def boolean_options
32
+ %i(sortable no_index no_stem)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/schema/geo_field"
4
+ require "redi_search/schema/numeric_field"
5
+ require "redi_search/schema/tag_field"
6
+ require "redi_search/schema/text_field"
7
+
8
+ module RediSearch
9
+ class Schema
10
+ def initialize(raw)
11
+ @raw = raw
12
+ end
13
+
14
+ def to_a
15
+ raw.map do |field_name, options|
16
+ options = [options] if options.is_a? Symbol
17
+ schema, options = options.to_a.flatten
18
+
19
+ "RediSearch::Schema::#{schema.to_s.capitalize}Field".
20
+ constantize.
21
+ new(field_name, **options.to_h).
22
+ to_a
23
+ end.flatten
24
+ end
25
+
26
+ def fields
27
+ raw.keys
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :raw
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/search/boolean_clause"
4
+
5
+ module RediSearch
6
+ class Search
7
+ class AndClause < BooleanClause
8
+ private
9
+
10
+ def operand
11
+ " "
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RediSearch
4
+ class Search
5
+ class BooleanClause
6
+ def initialize(search, term, prior_clause = nil, **term_options)
7
+ @search = search
8
+ @prior_clause = prior_clause
9
+ @not = false
10
+
11
+ initialize_term(term, **term_options)
12
+ end
13
+
14
+ def to_s
15
+ raise ArgumentError, "missing query terms" if term.blank?
16
+
17
+ [
18
+ prior_clause.presence,
19
+ queryify_term.dup.prepend(not_operator)
20
+ ].compact.join(operand)
21
+ end
22
+
23
+ def inspect
24
+ to_s.inspect
25
+ end
26
+
27
+ def not(term, **term_options)
28
+ @not = true
29
+
30
+ initialize_term(term, **term_options)
31
+
32
+ search
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :prior_clause, :term, :search
38
+
39
+ def operand
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def not_operator
44
+ return "" unless @not
45
+
46
+ "-"
47
+ end
48
+
49
+ def initialize_term(term, **term_options)
50
+ return if term.blank?
51
+
52
+ @term =
53
+ if term.is_a? RediSearch::Search
54
+ term
55
+ else
56
+ Term.new(term, term_options)
57
+ end
58
+ end
59
+
60
+ def queryify_term
61
+ if term.is_a?(RediSearch::Search) &&
62
+ !term.term_clause.is_a?(RediSearch::Search::WhereClause)
63
+ "(#{term.term_clause})"
64
+ elsif term.is_a?(RediSearch::Search)
65
+ term.term_clause
66
+ else
67
+ term
68
+ end.to_s
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/search/term"
4
+ require "redi_search/search/and_clause"
5
+ require "redi_search/search/or_clause"
6
+ require "redi_search/search/where_clause"
7
+
8
+ module RediSearch
9
+ class Search
10
+ module Clauses
11
+ def highlight(**options)
12
+ clauses.push(*HighlightClause.new(**options).clause)
13
+
14
+ self
15
+ end
16
+
17
+ def slop(slop)
18
+ clauses.push("SLOP", slop)
19
+
20
+ self
21
+ end
22
+
23
+ def in_order
24
+ clauses.push("INORDER")
25
+
26
+ self
27
+ end
28
+
29
+ def no_content
30
+ @no_content = true
31
+ clauses.push("NOCONTENT")
32
+
33
+ self
34
+ end
35
+
36
+ def language(language)
37
+ clauses.push("LANGUAGE", language)
38
+
39
+ self
40
+ end
41
+
42
+ def sort_by(field, order: :asc)
43
+ raise ArgumentError unless %i(asc desc).include?(order.to_sym)
44
+
45
+ clauses.push("SORTBY", field, order)
46
+
47
+ self
48
+ end
49
+
50
+ def limit(num, offset = 0)
51
+ clauses.push("LIMIT", offset, num)
52
+
53
+ self
54
+ end
55
+
56
+ def where(**condition)
57
+ @term_clause = WhereClause.new(self, condition, @term_clause)
58
+
59
+ if condition.blank?
60
+ @term_clause
61
+ else
62
+ self
63
+ end
64
+ end
65
+
66
+ def and(new_term = nil, **term_options)
67
+ @term_clause =
68
+ AndClause.new(self, new_term, @term_clause, **term_options)
69
+
70
+ if new_term.blank?
71
+ @term_clause
72
+ else
73
+ self
74
+ end
75
+ end
76
+
77
+ def or(new_term = nil, **term_options)
78
+ @term_clause =
79
+ OrClause.new(self, new_term, @term_clause, **term_options)
80
+
81
+ if new_term.blank?
82
+ @term_clause
83
+ else
84
+ self
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RediSearch
4
+ class Search
5
+ class HighlightClause
6
+ def initialize(**options)
7
+ @options = options.to_h
8
+
9
+ parse_options
10
+ end
11
+
12
+ def clause
13
+ [
14
+ "HIGHLIGHT",
15
+ (fields_clause(**options[:fields]) if options.key?(:fields)),
16
+ (tags_clause(**options[:tags]) if options.key? :tags),
17
+ ].compact.flatten(1)
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :options, :tags_args, :fields_args
23
+
24
+ def parse_options
25
+ return if options.except(:fields, :tags).empty?
26
+
27
+ arg_error "Unsupported argument: #{options}"
28
+ end
29
+
30
+ def tags_clause(open:, close:)
31
+ ["TAGS", open, close]
32
+ end
33
+
34
+ def fields_clause(num:, field:)
35
+ ["FIELDS", num, field]
36
+ end
37
+
38
+ def arg_error(msg)
39
+ raise ArgumentError, "Highlight: #{msg}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redi_search/search/boolean_clause"
4
+
5
+ module RediSearch
6
+ class Search
7
+ class OrClause < BooleanClause
8
+ def where(**condition)
9
+ @term = search.dup.where(condition)
10
+
11
+ search
12
+ end
13
+
14
+ private
15
+
16
+ def operand
17
+ "|"
18
+ end
19
+ end
20
+ end
21
+ end