redi_search 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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