noiseless 0.0.0 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +28 -0
  3. data/README.md +214 -0
  4. data/lib/application_search.rb +15 -0
  5. data/lib/noiseless/adapter.rb +313 -0
  6. data/lib/noiseless/adapters/elasticsearch.rb +70 -0
  7. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +188 -0
  8. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +377 -0
  9. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +219 -0
  10. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +461 -0
  11. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +472 -0
  12. data/lib/noiseless/adapters/open_search.rb +208 -0
  13. data/lib/noiseless/adapters/postgresql.rb +171 -0
  14. data/lib/noiseless/adapters/typesense.rb +70 -0
  15. data/lib/noiseless/adapters.rb +14 -0
  16. data/lib/noiseless/ast/aggregation.rb +56 -0
  17. data/lib/noiseless/ast/bool.rb +16 -0
  18. data/lib/noiseless/ast/bulk.rb +18 -0
  19. data/lib/noiseless/ast/collapse.rb +16 -0
  20. data/lib/noiseless/ast/combined_fields.rb +33 -0
  21. data/lib/noiseless/ast/conversation.rb +29 -0
  22. data/lib/noiseless/ast/filter.rb +15 -0
  23. data/lib/noiseless/ast/hybrid.rb +35 -0
  24. data/lib/noiseless/ast/image_query.rb +29 -0
  25. data/lib/noiseless/ast/join.rb +31 -0
  26. data/lib/noiseless/ast/match.rb +15 -0
  27. data/lib/noiseless/ast/multi_match.rb +24 -0
  28. data/lib/noiseless/ast/paginate.rb +15 -0
  29. data/lib/noiseless/ast/prefix.rb +15 -0
  30. data/lib/noiseless/ast/range.rb +18 -0
  31. data/lib/noiseless/ast/root.rb +69 -0
  32. data/lib/noiseless/ast/search_after.rb +14 -0
  33. data/lib/noiseless/ast/sort.rb +15 -0
  34. data/lib/noiseless/ast/vector.rb +27 -0
  35. data/lib/noiseless/ast/wildcard.rb +15 -0
  36. data/lib/noiseless/ast.rb +30 -0
  37. data/lib/noiseless/bulk_importer.rb +195 -0
  38. data/lib/noiseless/callbacks.rb +138 -0
  39. data/lib/noiseless/connection_manager.rb +26 -0
  40. data/lib/noiseless/document_manager.rb +137 -0
  41. data/lib/noiseless/dsl.rb +107 -0
  42. data/lib/noiseless/generators/application_search_generator.rb +24 -0
  43. data/lib/noiseless/instrumentation.rb +174 -0
  44. data/lib/noiseless/introspection/console.rb +228 -0
  45. data/lib/noiseless/introspection/query_visualizer.rb +533 -0
  46. data/lib/noiseless/introspection.rb +221 -0
  47. data/lib/noiseless/mapping.rb +253 -0
  48. data/lib/noiseless/mapping_definition_processor.rb +231 -0
  49. data/lib/noiseless/model.rb +111 -0
  50. data/lib/noiseless/model_registry.rb +77 -0
  51. data/lib/noiseless/multi_search.rb +244 -0
  52. data/lib/noiseless/pagination.rb +375 -0
  53. data/lib/noiseless/query_builder.rb +284 -0
  54. data/lib/noiseless/railtie.rb +35 -0
  55. data/lib/noiseless/response/aggregations.rb +46 -0
  56. data/lib/noiseless/response/empty.rb +20 -0
  57. data/lib/noiseless/response/records.rb +94 -0
  58. data/lib/noiseless/response/results.rb +110 -0
  59. data/lib/noiseless/response/suggestions.rb +55 -0
  60. data/lib/noiseless/response.rb +98 -0
  61. data/lib/noiseless/response_factory.rb +32 -0
  62. data/lib/noiseless/runtime_reset_middleware.rb +15 -0
  63. data/lib/noiseless/search_index_update_job.rb +84 -0
  64. data/lib/noiseless/test_case.rb +230 -0
  65. data/lib/noiseless/test_helper.rb +295 -0
  66. data/lib/noiseless/version.rb +2 -2
  67. data/lib/noiseless.rb +130 -2
  68. data/lib/tasks/benchmark.rake +35 -0
  69. data/lib/tasks/release.rake +22 -0
  70. data/lib/tasks/test.rake +11 -0
  71. metadata +260 -14
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Aggregations
6
+ include Enumerable
7
+
8
+ def initialize(aggs_hash)
9
+ @aggs_hash = aggs_hash || {}
10
+ end
11
+
12
+ def [](key)
13
+ @aggs_hash[key.to_s]
14
+ end
15
+
16
+ delegate :keys, to: :@aggs_hash
17
+
18
+ def each(&)
19
+ return enum_for(__method__) unless block_given?
20
+
21
+ @aggs_hash.each(&)
22
+ end
23
+
24
+ delegate :empty?, to: :@aggs_hash
25
+
26
+ delegate :size, to: :@aggs_hash
27
+
28
+ def to_h
29
+ @aggs_hash
30
+ end
31
+
32
+ # Handle method conflicts with Enumerable methods
33
+ def method_missing(method_name, *args, &)
34
+ if @aggs_hash.key?(method_name.to_s)
35
+ @aggs_hash[method_name.to_s]
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ def respond_to_missing?(method_name, include_private = false)
42
+ @aggs_hash.key?(method_name.to_s) || super
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Empty < Base
6
+ EMPTY_RESPONSE = {
7
+ "hits" => { "total" => { "value" => 0 }, "hits" => [] },
8
+ "took" => 0
9
+ }.freeze
10
+
11
+ def initialize(model_class = nil)
12
+ super(EMPTY_RESPONSE, model_class)
13
+ end
14
+
15
+ def each
16
+ return enum_for(__method__) unless block_given?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Records < Base
6
+ def initialize(raw_response, model_class)
7
+ super
8
+ @records = nil
9
+ @record_hit_map = nil
10
+ end
11
+
12
+ def each(&)
13
+ return enum_for(__method__) unless block_given?
14
+
15
+ records.each(&)
16
+ end
17
+
18
+ def each_with_hit
19
+ return enum_for(__method__) unless block_given?
20
+
21
+ records.each_with_index do |record, index|
22
+ hit = hits[record_hit_map[record] || index]
23
+ yield(record, hit)
24
+ end
25
+ end
26
+
27
+ def map_with_hit(&)
28
+ return enum_for(__method__) unless block_given?
29
+
30
+ each_with_hit.map(&)
31
+ end
32
+
33
+ def records
34
+ @records ||= load_records_with_pagination
35
+ end
36
+
37
+ delegate :first, to: :records
38
+
39
+ delegate :last, to: :records
40
+
41
+ delegate :[], to: :records
42
+
43
+ def to_a
44
+ records
45
+ end
46
+
47
+ private
48
+
49
+ def load_records_with_pagination
50
+ records = load_records
51
+
52
+ # Wrap in PaginatedArray for pagination metadata
53
+ current_page = @from && @per_page ? (@from / @per_page) + 1 : 1
54
+ per_page = @per_page || Pagination::DEFAULT_PER_PAGE
55
+
56
+ Pagination::PaginatedArray.new(
57
+ records,
58
+ current_page: current_page,
59
+ per_page: per_page,
60
+ total_count: total
61
+ )
62
+ end
63
+
64
+ def load_records
65
+ return [] if hits.empty? || !model_class.respond_to?(:where)
66
+
67
+ # Extract IDs from hits
68
+ ids = hits.map { |hit| hit["_id"] }
69
+
70
+ # Load records from database
71
+ loaded_records = model_class.where(id: ids).to_a
72
+
73
+ # Create mapping from record to hit index for correlation
74
+ @record_hit_map = {}
75
+
76
+ # Sort records by the order they appear in search results
77
+ sorted_records = []
78
+ hits.each_with_index do |hit, hit_index|
79
+ record = loaded_records.find { |r| r.id.to_s == hit["_id"].to_s }
80
+ if record
81
+ sorted_records << record
82
+ @record_hit_map[record] = hit_index
83
+ end
84
+ end
85
+
86
+ sorted_records
87
+ end
88
+
89
+ def record_hit_map
90
+ @record_hit_map ||= {}
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Results < Base
6
+ def each
7
+ return enum_for(__method__) unless block_given?
8
+
9
+ hits.each do |hit|
10
+ yield Result.new(hit)
11
+ end
12
+ end
13
+
14
+ def sources
15
+ @sources ||= hits.map { |hit| hit["_source"] }
16
+ end
17
+
18
+ alias records sources
19
+
20
+ def each_source(&)
21
+ return enum_for(__method__) unless block_given?
22
+
23
+ sources.each(&)
24
+ end
25
+
26
+ def map_source(&)
27
+ return enum_for(__method__) unless block_given?
28
+
29
+ sources.map(&)
30
+ end
31
+
32
+ def first
33
+ hit = hits.first
34
+ hit ? Result.new(hit) : nil
35
+ end
36
+
37
+ def last
38
+ hit = hits.last
39
+ hit ? Result.new(hit) : nil
40
+ end
41
+
42
+ def [](index)
43
+ hit = hits[index]
44
+ hit ? Result.new(hit) : nil
45
+ end
46
+
47
+ def to_a
48
+ hits.map { |hit| Result.new(hit) }
49
+ end
50
+
51
+ def map
52
+ return enum_for(__method__) unless block_given?
53
+
54
+ results = []
55
+ each do |result|
56
+ results << yield(result)
57
+ end
58
+ results
59
+ end
60
+
61
+ def select
62
+ return enum_for(__method__) unless block_given?
63
+
64
+ results = []
65
+ each do |result|
66
+ results << result if yield(result)
67
+ end
68
+ results
69
+ end
70
+
71
+ def ids
72
+ @ids ||= hits.map { |hit| hit["_id"] }
73
+ end
74
+
75
+ def scores
76
+ @scores ||= hits.map { |hit| hit["_score"] }
77
+ end
78
+
79
+ # Make results enumerable with source property
80
+ def find
81
+ return enum_for(__method__) unless block_given?
82
+
83
+ hits.each do |hit|
84
+ result = Result.new(hit)
85
+ return result if yield(result)
86
+ end
87
+ nil
88
+ end
89
+
90
+ class Result
91
+ attr_reader :source
92
+
93
+ def initialize(hit)
94
+ @hit = hit
95
+ @source = hit["_source"]
96
+ end
97
+
98
+ def id
99
+ @hit["_id"]
100
+ end
101
+
102
+ def score
103
+ @hit["_score"]
104
+ end
105
+
106
+ delegate :[], to: :@source
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Suggestions
6
+ include Enumerable
7
+
8
+ def initialize(suggestions_hash)
9
+ @suggestions_hash = suggestions_hash || {}
10
+ end
11
+
12
+ def [](key)
13
+ @suggestions_hash[key.to_s]
14
+ end
15
+
16
+ delegate :keys, to: :@suggestions_hash
17
+
18
+ def each(&)
19
+ return enum_for(__method__) unless block_given?
20
+
21
+ @suggestions_hash.each(&)
22
+ end
23
+
24
+ delegate :empty?, to: :@suggestions_hash
25
+
26
+ delegate :size, to: :@suggestions_hash
27
+
28
+ def to_h
29
+ @suggestions_hash
30
+ end
31
+
32
+ def terms
33
+ @terms ||= extract_terms
34
+ end
35
+
36
+ private
37
+
38
+ def extract_terms
39
+ terms = []
40
+ @suggestions_hash.each_value do |suggestion|
41
+ next unless suggestion.is_a?(Array)
42
+
43
+ suggestion.each do |item|
44
+ next unless item.is_a?(Hash) && item["options"]
45
+
46
+ item["options"].each do |option|
47
+ terms << option["text"] if option["text"]
48
+ end
49
+ end
50
+ end
51
+ terms.uniq
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Response
5
+ class Base
6
+ include Enumerable
7
+ include Pagination::ResponsePagination
8
+
9
+ def initialize(raw_response, model_class = nil)
10
+ @raw_response = raw_response
11
+ @model_class = model_class
12
+ end
13
+
14
+ def total
15
+ case @raw_response.dig("hits", "total")
16
+ when Hash
17
+ @raw_response.dig("hits", "total", "value") || 0
18
+ when Integer
19
+ @raw_response.dig("hits", "total") || 0
20
+ else
21
+ 0
22
+ end
23
+ end
24
+
25
+ def hits
26
+ @hits ||= @raw_response.dig("hits", "hits") || []
27
+ end
28
+
29
+ def took
30
+ @raw_response["took"]
31
+ end
32
+
33
+ def aggregations
34
+ @aggregations ||= Aggregations.new(@raw_response["aggregations"] || {})
35
+ end
36
+
37
+ def suggestions
38
+ @suggestions ||= Suggestions.new(@raw_response["suggest"] || {})
39
+ end
40
+
41
+ def each(&)
42
+ raise NotImplementedError, "Subclasses must implement #each"
43
+ end
44
+
45
+ def empty?
46
+ total.zero?
47
+ end
48
+
49
+ delegate :size, to: :hits
50
+
51
+ def length
52
+ size
53
+ end
54
+
55
+ def include_pagination_info(query_hash)
56
+ @from = query_hash[:from] || 0
57
+ @per_page = query_hash[:size] || 20
58
+ end
59
+
60
+ # Compatibility methods for CommonShare
61
+ def response
62
+ @raw_response
63
+ end
64
+
65
+ def result
66
+ # Alias for results - controllers expect .result
67
+ results
68
+ end
69
+
70
+ def results
71
+ # For Results class, return self. For Records, return a Results view
72
+ if is_a?(Results)
73
+ self
74
+ else
75
+ Results.new(@raw_response, @model_class)
76
+ end
77
+ end
78
+
79
+ def count
80
+ size
81
+ end
82
+
83
+ def total_count
84
+ total
85
+ end
86
+
87
+ def each_with_index(&)
88
+ return enum_for(__method__) unless block_given?
89
+
90
+ each.with_index(&)
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :raw_response, :model_class
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ class ResponseFactory
5
+ def self.create(raw_response, model_class: nil, response_type: nil, query_hash: nil)
6
+ # Auto-detect response type based on model class and preferences
7
+ response_type ||= detect_response_type(model_class)
8
+
9
+ response = case response_type
10
+ when :records
11
+ Response::Records.new(raw_response, model_class)
12
+ else # :results or unknown
13
+ Response::Results.new(raw_response, model_class)
14
+ end
15
+
16
+ # Include pagination information if query hash is provided
17
+ response.include_pagination_info(query_hash) if query_hash
18
+
19
+ response
20
+ end
21
+
22
+ def self.detect_response_type(model_class)
23
+ # If model_class responds to ActiveRecord-like methods, default to :records
24
+ if model_class.respond_to?(:where) &&
25
+ model_class.respond_to?(:find)
26
+ :records
27
+ else
28
+ :results
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ class RuntimeResetMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ # Reset runtime tracking at the beginning of each request
11
+ Thread.current[:noiseless_runtime] = 0
12
+ @app.call(env)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ class SearchIndexUpdateJob
5
+ def self.perform_later(model_class_name, record_id, operation, options = {})
6
+ if defined?(ActiveJob::Base)
7
+ ActiveJobSearchIndexUpdateJob.perform_later(model_class_name, record_id, operation, options)
8
+ elsif defined?(Sidekiq)
9
+ SidekiqSearchIndexUpdateJob.perform_async(model_class_name, record_id, operation, options)
10
+ else
11
+ # Fallback to immediate execution
12
+ perform_now(model_class_name, record_id, operation, options)
13
+ end
14
+ end
15
+
16
+ def self.perform_now(model_class_name, record_id, operation, options = {})
17
+ model_class = model_class_name.constantize
18
+
19
+ case operation
20
+ when "update"
21
+ record = model_class.find(record_id)
22
+ record.document_manager.update_document(**options)
23
+ when "delete"
24
+ # For delete operations, we need to construct a minimal object
25
+ # since the record might already be deleted from the database
26
+ document_manager = DocumentManager.new(
27
+ DeletedRecord.new(model_class, record_id)
28
+ )
29
+ document_manager.delete_document(**options)
30
+ else
31
+ raise ArgumentError, "Unknown operation: #{operation}"
32
+ end
33
+ rescue StandardError => e
34
+ if options[:raise_on_error]
35
+ raise e
36
+ elsif (logger = Rails.logger)
37
+ # Log error silently
38
+ logger.error "Noiseless: Background job failed for #{model_class_name}##{record_id}: #{e.message}"
39
+ end
40
+ end
41
+
42
+ # Minimal object for deleted records
43
+ class DeletedRecord
44
+ def initialize(model_class, record_id)
45
+ @model_class = model_class
46
+ @record_id = record_id
47
+ end
48
+
49
+ def id
50
+ @record_id
51
+ end
52
+
53
+ def class
54
+ @model_class
55
+ end
56
+
57
+ def to_search_document
58
+ nil
59
+ end
60
+ end
61
+ end
62
+
63
+ # ActiveJob integration
64
+ if defined?(ActiveJob::Base)
65
+ class ActiveJobSearchIndexUpdateJob < ActiveJob::Base
66
+ queue_as :default
67
+
68
+ def perform(model_class_name, record_id, operation, options = {})
69
+ SearchIndexUpdateJob.perform_now(model_class_name, record_id, operation, options)
70
+ end
71
+ end
72
+ end
73
+
74
+ # Sidekiq integration
75
+ if defined?(Sidekiq)
76
+ class SidekiqSearchIndexUpdateJob
77
+ include Sidekiq::Worker
78
+
79
+ def perform(model_class_name, record_id, operation, options = {})
80
+ SearchIndexUpdateJob.perform_now(model_class_name, record_id, operation, options)
81
+ end
82
+ end
83
+ end
84
+ end