noiseless 0.8.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c023b22c96a7186d99210b2a4420fa6661e3be195522cf86dbe9191d5301acfa
4
- data.tar.gz: ef2f26a7f11bad65b6a604f08641d398e9da90e637af8bbd3f9a1750e8119589
3
+ metadata.gz: 195219b6de1210cb1f888a5a322e41c624403b7cab4f07cbd700ca9f5f573196
4
+ data.tar.gz: b92048b781e491ae6c8f0d9114dbf4a4094ba04b7e06bf8394c3a6164e18521a
5
5
  SHA512:
6
- metadata.gz: 272b9ffa8aa20f2719018ca2abbac572640f997742fdbc79c413432b1d35ee97501dd6ea1475ffa02e67d04987cd31595f27c12df824c1c21832acc4f1de875a
7
- data.tar.gz: 00be400acc534f50c40138e57e4c93118ba6829e14e72ebb7a18df546310168b4aaab9e2e421cfb950c6ad6d9b8dc5bcc68b70d38b071fedb9d35fa04c17854c
6
+ metadata.gz: afc592e3f9e505ff2272845eb4b8cddaae23543400fe83b1c11c70895fd0b8a537e670c1cc78103df93999f72f51cc0cdb8c008772a59e1e881df024e5a9cf24
7
+ data.tar.gz: 7b4a4113ea30314fbed156161dfa031344881ba17f422ac233cd5a92b91f2b63ff4f7e07282e3a993e98bf42d48d87813b8c4e3aea23ece3365695a1779b382e
data/README.md CHANGED
@@ -114,6 +114,51 @@ end
114
114
 
115
115
  For best performance, run independent searches concurrently within a single `Async` block rather than creating separate `Sync` blocks per search.
116
116
 
117
+ ### Loading Database Records
118
+
119
+ Search classes return search results by default. To load database records, configure
120
+ the record model explicitly and request `response_type: :records`:
121
+
122
+ ```ruby
123
+ class Company::Search < Noiseless::Model
124
+ index_name 'companies'
125
+ record_class 'Company' # A Class is also accepted; strings resolve lazily.
126
+ end
127
+
128
+ records = Company::Search.search_sync(response_type: :records) do |s|
129
+ s.match(:name, 'tech')
130
+ end
131
+ records.to_a # Company records in search relevance order
132
+
133
+ # The instance API supports the same option.
134
+ Company::Search.new.match(:name, 'tech').execute_sync(response_type: :records)
135
+ ```
136
+
137
+ `record_class` is inherited and does not change the default response type. Index
138
+ names are not used to infer record classes: aliases and versioned indexes need
139
+ not match table names. Requesting `:records` without a model supporting `.where`
140
+ raises `ArgumentError`, even when the search has no hits. Records missing from
141
+ the database are omitted; search totals still describe backend hits.
142
+
143
+ ### Pagination and Multi-model Search
144
+
145
+ The paginator executes lazily and waits for its response when results or totals
146
+ are accessed. Multi-search also waits and returns a `MultiSearchResponse`, not
147
+ an `Async::Task`. Both work inside an existing async context.
148
+
149
+ ```ruby
150
+ page = Company::Search.page(2).per(10)
151
+ page.to_a
152
+ page.total_count
153
+
154
+ response = Noiseless.multi_search(models: [Company::Search, Product::Search]) do |s|
155
+ s.match(:name, 'tech')
156
+ s.limit(10)
157
+ end
158
+ response.results_for_model(Company::Search)
159
+ response.records_for_model(Company::Search) # Uses its configured record_class.
160
+ ```
161
+
117
162
  ### Advanced Queries
118
163
 
119
164
  ```ruby
@@ -179,6 +224,15 @@ end
179
224
 
180
225
  ### Running Tests Locally
181
226
 
227
+ Run the pagination, multi-search, and record-loading unit tests without services:
228
+
229
+ ```bash
230
+ bundle exec ruby -Itest -Ilib -e 'ARGV.each { require_relative it }' \
231
+ test/{search_pagination,multi_search,record_response,active_record_response,postgresql_search_contract,query_contract}_test.rb
232
+ ```
233
+
234
+ For the full integration suite:
235
+
182
236
  ```bash
183
237
  docker compose up -d postgres elasticsearch opensearch typesense
184
238
  bin/test
@@ -227,12 +227,7 @@ module Noiseless
227
227
  end
228
228
 
229
229
  def build_pagination_hash(paginate_node)
230
- return { from: 0, size: 20 } unless paginate_node
231
-
232
- {
233
- from: (paginate_node.page - 1) * paginate_node.per_page,
234
- size: paginate_node.per_page
235
- }
230
+ Pagination.query_options(paginate_node)
236
231
  end
237
232
 
238
233
  # Parses a backend HTTP response, raising when the backend reported an error.
@@ -189,9 +189,8 @@ module Noiseless
189
189
  # Helper methods
190
190
 
191
191
  def resolve_model(indexes, model_class = nil)
192
- # The standard Model#execute path passes the Noiseless::Model search
193
- # class here; only an ActiveRecord model can back a PG search, so
194
- # fall through to index-name resolution for anything else.
192
+ # Respect explicit search-class mappings, including aliased indexes.
193
+ model_class = RecordClassResolver.resolve(model_class)
195
194
  return model_class if active_record_model?(model_class)
196
195
 
197
196
  index_name = indexes&.first
@@ -61,7 +61,7 @@ module Noiseless
61
61
  raw_response,
62
62
  model_class: model_class,
63
63
  response_type: response_type,
64
- query_hash: build_pagination_from_ast(ast_node)
64
+ query_hash: build_pagination_hash(ast_node.paginate)
65
65
  )
66
66
  end
67
67
  end
@@ -156,16 +156,6 @@ module Noiseless
156
156
  Rails.logger.error("Failed to detect PostgreSQL extensions: #{e.message}")
157
157
  []
158
158
  end
159
-
160
- def build_pagination_from_ast(ast_node)
161
- paginate = ast_node.paginate
162
- return { from: 0, size: 20 } unless paginate
163
-
164
- {
165
- from: (paginate.page - 1) * paginate.per_page,
166
- size: paginate.per_page
167
- }
168
- end
169
159
  end
170
160
  end
171
161
  end
@@ -157,11 +157,7 @@ module Noiseless
157
157
  end
158
158
 
159
159
  def index_name
160
- @index_name ||= if model_class.respond_to?(:search_index)
161
- Array(model_class.search_index).first
162
- else
163
- model_class.name.demodulize.underscore.pluralize
164
- end
160
+ @index_name ||= QueryBuilder.indexes_for(model_class).first
165
161
  end
166
162
 
167
163
  def recreate_index
@@ -70,11 +70,7 @@ module Noiseless
70
70
  end
71
71
 
72
72
  def index_name
73
- @index_name ||= if model_instance.class.respond_to?(:search_index)
74
- Array(model_instance.class.search_index).first
75
- else
76
- model_instance.class.name.demodulize.underscore.pluralize
77
- end
73
+ @index_name ||= QueryBuilder.indexes_for(model_instance.class).first
78
74
  end
79
75
 
80
76
  def supports_dirty_tracking?
@@ -3,6 +3,25 @@
3
3
  module Noiseless
4
4
  class Model
5
5
  extend DSL::ClassMethods
6
+ extend QueryDelegation
7
+
8
+ delegate_query_methods :match, :multi_match, :filter, :range, :sort, :limit, :offset, :paginate,
9
+ :aggregation, :geo_distance, :indexes, :remove_duplicates, :facet_sample_slope, :pinned_hits,
10
+ to: :@builder
11
+
12
+ # Explicit mapping: index names may be aliases, not record class names.
13
+ # String names are resolved on read so Rails can reload the record class.
14
+ def self.record_class(value = nil)
15
+ unless value.nil?
16
+ raise ArgumentError, "record_class must be a Class or a class name String" unless value.is_a?(Class) || value.is_a?(String)
17
+
18
+ return @record_class = value
19
+ end
20
+
21
+ configured = @record_class
22
+ configured ||= superclass.record_class unless self == Model
23
+ configured.is_a?(String) ? configured.constantize : configured
24
+ end
6
25
 
7
26
  def initialize
8
27
  @builder = QueryBuilder.new(self.class)
@@ -23,77 +42,6 @@ module Noiseless
23
42
  end
24
43
  end
25
44
 
26
- # Instance methods that delegate to the query builder
27
- def match(field, value, **)
28
- @builder.match(field, value, **)
29
- self
30
- end
31
-
32
- def multi_match(query, fields, **)
33
- @builder.multi_match(query, fields, **)
34
- self
35
- end
36
-
37
- def filter(field, value, **)
38
- @builder.filter(field, value, **)
39
- self
40
- end
41
-
42
- def range(field, gte: nil, lte: nil, gt: nil, lt: nil)
43
- @builder.range(field, gte: gte, lte: lte, gt: gt, lt: lt)
44
- self
45
- end
46
-
47
- def sort(field, direction = :asc, **)
48
- @builder.sort(field, direction, **)
49
- self
50
- end
51
-
52
- def limit(size)
53
- @builder.limit(size)
54
- self
55
- end
56
-
57
- def offset(from)
58
- @builder.offset(from)
59
- self
60
- end
61
-
62
- def paginate(**)
63
- @builder.paginate(**)
64
- self
65
- end
66
-
67
- def aggregation(name, type, **)
68
- @builder.aggregation(name, type, **)
69
- self
70
- end
71
-
72
- def geo_distance(field, lat:, lon:, distance:, **)
73
- @builder.geo_distance(field, lat: lat, lon: lon, distance: distance, **)
74
- self
75
- end
76
-
77
- def indexes(names)
78
- @builder.indexes(names)
79
- self
80
- end
81
-
82
- def remove_duplicates(value: true)
83
- @builder.remove_duplicates(value: value)
84
- self
85
- end
86
-
87
- def facet_sample_slope(value)
88
- @builder.facet_sample_slope(value)
89
- self
90
- end
91
-
92
- def pinned_hits(value)
93
- @builder.pinned_hits(value)
94
- self
95
- end
96
-
97
45
  def execute(connection: nil, response_type: nil)
98
46
  client = Noiseless.connections.client(connection || self.class.connection)
99
47
  ast = @builder.to_ast
@@ -17,7 +17,7 @@ module Noiseless
17
17
  }
18
18
 
19
19
  # Index models by their search index names
20
- index_names = Array(model_class.search_index || default_index_name(model_class))
20
+ index_names = QueryBuilder.indexes_for(model_class)
21
21
  index_names.each do |index_name|
22
22
  @models_by_index[index_name.to_sym] ||= []
23
23
  @models_by_index[index_name.to_sym] << model_class
@@ -67,11 +67,5 @@ module Noiseless
67
67
  @models.clear
68
68
  @models_by_index.clear
69
69
  end
70
-
71
- private
72
-
73
- def default_index_name(model_class)
74
- model_class.name.demodulize.underscore.pluralize
75
- end
76
70
  end
77
71
  end
@@ -2,72 +2,28 @@
2
2
 
3
3
  module Noiseless
4
4
  class MultiSearch
5
+ extend QueryDelegation
6
+
7
+ delegate_query_methods :match, :multi_match, :filter, :sort, :limit, :offset, :aggregation,
8
+ :geo_distance, :remove_duplicates, :facet_sample_slope, :pinned_hits,
9
+ to: :@builder
10
+
5
11
  def initialize(models: nil, indexes: nil, connection: nil)
6
12
  @models = resolve_models(models)
7
13
  @indexes = resolve_indexes(indexes)
8
14
  @connection = connection || Noiseless.config.default_connection
9
- @builder = QueryBuilder.new(nil)
15
+ @builder = QueryBuilder.new(nil).indexes(@indexes)
10
16
  end
11
17
 
12
18
  def search(&block)
13
19
  yield(@builder) if block
14
20
 
15
21
  client = Noiseless.connections.client(@connection)
16
- ast = @builder.to_ast
17
-
18
- # Override indexes in AST with our multi-model indexes
19
- ast_with_indexes = AST::Root.new(
20
- indexes: @indexes,
21
- bool: ast.bool,
22
- sort: ast.sort,
23
- paginate: ast.paginate,
24
- vector: ast.vector,
25
- collapse: ast.collapse,
26
- search_after: ast.search_after,
27
- aggregations: ast.aggregations,
28
- hybrid: ast.hybrid,
29
- pipeline: ast.pipeline,
30
- image_query: ast.image_query,
31
- conversation: ast.conversation,
32
- joins: ast.joins,
33
- remove_duplicates: ast.remove_duplicates,
34
- facet_sample_slope: ast.facet_sample_slope,
35
- pinned_hits: ast.pinned_hits
36
- )
37
-
38
- raw_response = client.search(ast_with_indexes)
39
- MultiSearchResponse.new(raw_response, @models, @indexes)
40
- end
41
-
42
- # Delegate query building methods to the internal builder
43
- def match(field, value, **)
44
- @builder.match(field, value, **)
45
- self
46
- end
47
-
48
- def multi_match(query, fields, **)
49
- @builder.multi_match(query, fields, **)
50
- self
51
- end
52
-
53
- def filter(field, value, **)
54
- @builder.filter(field, value, **)
55
- self
56
- end
57
-
58
- def sort(field, direction = :asc, **)
59
- @builder.sort(field, direction, **)
60
- self
61
- end
62
-
63
- def limit(size)
64
- @builder.limit(size)
65
- self
66
- end
67
-
68
- def offset(from)
69
- @builder.offset(from)
70
- self
22
+ ast = @builder.indexes(@indexes).to_ast
23
+ response = Sync { client.search(ast, response_type: :results).wait }
24
+ MultiSearchResponse.new(response.response, @models, @indexes).tap do |result|
25
+ result.include_pagination_info(Pagination.query_options(ast.paginate))
26
+ end
71
27
  end
72
28
 
73
29
  def paginate(page, per_page)
@@ -75,31 +31,6 @@ module Noiseless
75
31
  self
76
32
  end
77
33
 
78
- def aggregation(name, type, **)
79
- @builder.aggregation(name, type, **)
80
- self
81
- end
82
-
83
- def geo_distance(field, lat:, lon:, distance:, **)
84
- @builder.geo_distance(field, lat: lat, lon: lon, distance: distance, **)
85
- self
86
- end
87
-
88
- def remove_duplicates(value: true)
89
- @builder.remove_duplicates(value: value)
90
- self
91
- end
92
-
93
- def facet_sample_slope(value)
94
- @builder.facet_sample_slope(value)
95
- self
96
- end
97
-
98
- def pinned_hits(value)
99
- @builder.pinned_hits(value)
100
- self
101
- end
102
-
103
34
  private
104
35
 
105
36
  def resolve_models(models)
@@ -125,9 +56,7 @@ module Noiseless
125
56
  def resolve_indexes(indexes)
126
57
  case indexes
127
58
  when nil
128
- @models.flat_map do |model|
129
- Array(model.search_index || default_index_name(model))
130
- end.uniq
59
+ @models.flat_map { |model| QueryBuilder.indexes_for(model) }.uniq
131
60
  when Array
132
61
  indexes.map(&:to_s)
133
62
  when String, Symbol
@@ -136,10 +65,6 @@ module Noiseless
136
65
  []
137
66
  end
138
67
  end
139
-
140
- def default_index_name(model_class)
141
- model_class.name.demodulize.underscore.pluralize
142
- end
143
68
  end
144
69
 
145
70
  # Multi-search response class
@@ -149,6 +74,10 @@ module Noiseless
149
74
  @models = models
150
75
  @indexes = indexes
151
76
  @results_by_model = nil
77
+ @models_by_index = {}
78
+ models.each do |model|
79
+ QueryBuilder.indexes_for(model).each { |index| @models_by_index[index] ||= model }
80
+ end
152
81
  end
153
82
 
154
83
  def each(&)
@@ -203,17 +132,8 @@ module Noiseless
203
132
  end
204
133
 
205
134
  def determine_model_class(hit)
206
- index_name = hit["_index"]
207
-
208
- # First, try to find models registered for this specific index
209
- models_for_index = Noiseless.registry.models_for_index(index_name)
210
- return models_for_index.first if models_for_index.size == 1
211
-
212
- # If multiple models or none found, try to infer from index name
213
- @models.find do |model|
214
- model_indexes = Array(model.search_index || default_index_name(model))
215
- model_indexes.include?(index_name)
216
- end
135
+ # Only group into requested models, using the same index rules as search.
136
+ @models_by_index[hit["_index"]]
217
137
  end
218
138
 
219
139
  def load_records_by_model
@@ -221,22 +141,14 @@ module Noiseless
221
141
 
222
142
  results_by_model.each do |model_key, model_hits|
223
143
  model_class = @models.find { |m| m.name.to_sym == model_key }
224
- next unless model_class.respond_to?(:where)
225
-
226
- ids = model_hits.map { |hit| hit["_id"] }
227
- records_by_id = model_class.where(id: ids).index_by { it.id.to_s }
228
-
229
- # Sort by search relevance
230
- sorted_records = model_hits.filter_map { |hit| records_by_id[hit["_id"].to_s] }
144
+ record_class = RecordClassResolver.resolve(model_class)
145
+ next unless record_class.respond_to?(:where)
231
146
 
232
- records[model_key] = sorted_records
147
+ raw = { "hits" => { "hits" => model_hits, "total" => model_hits.size } }
148
+ records[model_key] = Response::Records.new(raw, record_class).to_a
233
149
  end
234
150
 
235
151
  records
236
152
  end
237
-
238
- def default_index_name(model_class)
239
- model_class.name.demodulize.underscore.pluralize
240
- end
241
153
  end
242
154
  end
@@ -5,6 +5,12 @@ module Noiseless
5
5
  DEFAULT_PER_PAGE = 20
6
6
  MAX_PER_PAGE = 100
7
7
 
8
+ def self.query_options(paginate)
9
+ return { from: 0, size: DEFAULT_PER_PAGE } unless paginate
10
+
11
+ { from: (paginate.page - 1) * paginate.per_page, size: paginate.per_page }
12
+ end
13
+
8
14
  # Simple paginated array wrapper - no external dependencies
9
15
  class PaginatedArray < Array
10
16
  attr_accessor :current_page, :per_page, :total_count
@@ -131,6 +137,10 @@ module Noiseless
131
137
  # Search paginator - builds and executes paginated queries
132
138
  class SearchPaginator
133
139
  include Enumerable
140
+ extend QueryDelegation
141
+
142
+ delegate_query_methods :match, :multi_match, :filter, :sort, :aggregation, :geo_distance, :vector,
143
+ to: :@query_builder
134
144
 
135
145
  def initialize(model_class, page: 1, per_page: nil)
136
146
  @model_class = model_class
@@ -213,42 +223,6 @@ module Noiseless
213
223
  @results.to_a
214
224
  end
215
225
 
216
- # Query building delegation
217
- def match(field, value, **)
218
- @query_builder.match(field, value, **)
219
- self
220
- end
221
-
222
- def multi_match(query, fields, **)
223
- @query_builder.multi_match(query, fields, **)
224
- self
225
- end
226
-
227
- def filter(field, value, **)
228
- @query_builder.filter(field, value, **)
229
- self
230
- end
231
-
232
- def sort(field, direction = :asc, **)
233
- @query_builder.sort(field, direction, **)
234
- self
235
- end
236
-
237
- def aggregation(name, type, **)
238
- @query_builder.aggregation(name, type, **)
239
- self
240
- end
241
-
242
- def geo_distance(field, lat:, lon:, distance:, **)
243
- @query_builder.geo_distance(field, lat: lat, lon: lon, distance: distance, **)
244
- self
245
- end
246
-
247
- def vector(field, embedding, **)
248
- @query_builder.vector(field, embedding, **)
249
- self
250
- end
251
-
252
226
  # Response access
253
227
  def results
254
228
  execute_search unless @executed
@@ -312,7 +286,7 @@ module Noiseless
312
286
 
313
287
  client = Noiseless.connections.client(@model_class.connection)
314
288
  ast = @query_builder.to_ast
315
- @results = client.search(ast, model_class: @model_class)
289
+ @results = Sync { client.search(ast, model_class: @model_class).wait }
316
290
  @total_count = @results.total
317
291
  @executed = true
318
292
  end
@@ -2,9 +2,19 @@
2
2
 
3
3
  module Noiseless
4
4
  class QueryBuilder
5
+ def self.indexes_for(model)
6
+ # Multi-search starts without a model and supplies its own indexes.
7
+ return [] if model.nil?
8
+
9
+ return Array(model.search_index) if model.respond_to?(:search_index) && model.search_index&.any?
10
+ return [model.index_name] if model.respond_to?(:index_name) && model.index_name
11
+
12
+ [model.name.demodulize.underscore.pluralize]
13
+ end
14
+
5
15
  def initialize(model)
6
16
  @model = model
7
- @indexes = determine_indexes(model)
17
+ @indexes = self.class.indexes_for(model)
8
18
  @nodes = []
9
19
  @aggregations = []
10
20
  @collapse = nil
@@ -270,16 +280,5 @@ module Noiseless
270
280
  raise ArgumentError, "pinned_hits must be a String, Hash, or Array of [id, position]"
271
281
  end
272
282
  end
273
-
274
- def determine_indexes(model)
275
- # Check for search_index (plural array) first
276
- return Array(model.search_index) if model.respond_to?(:search_index) && model.search_index&.any?
277
-
278
- # Check for index_name (singular string) next
279
- return [model.index_name] if model.respond_to?(:index_name) && model.index_name
280
-
281
- # Fallback to pluralized model name
282
- [model.name.demodulize.underscore.pluralize]
283
- end
284
283
  end
285
284
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module QueryDelegation
5
+ # Unlike ordinary delegation, fluent query methods return their wrapper.
6
+ def delegate_query_methods(*names, to:)
7
+ names.each do |name|
8
+ define_method(name) do |*args, **options, &block|
9
+ instance_variable_get(to).public_send(name, *args, **options, &block)
10
+ self
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module RecordClassResolver
5
+ # Search definitions and database models are different kinds of classes.
6
+ # Only a Noiseless::Model uses the explicit record_class mapping.
7
+ def self.resolve(model_class)
8
+ if model_class.is_a?(Class) && model_class <= Model
9
+ model_class.record_class
10
+ else
11
+ model_class
12
+ end
13
+ end
14
+ end
15
+ end
@@ -4,7 +4,12 @@ module Noiseless
4
4
  module Response
5
5
  class Records < Base
6
6
  def initialize(raw_response, model_class)
7
- super
7
+ record_class = RecordClassResolver.resolve(model_class)
8
+ unless record_class.respond_to?(:where)
9
+ raise ArgumentError, "#{model_class || 'nil'} cannot load records; configure record_class on your Noiseless::Model search class"
10
+ end
11
+
12
+ super(raw_response, record_class)
8
13
  @records = nil
9
14
  @record_hit_map = nil
10
15
  end
@@ -62,7 +67,7 @@ module Noiseless
62
67
  end
63
68
 
64
69
  def load_records
65
- return [] if hits.empty? || !model_class.respond_to?(:where)
70
+ return [] if hits.empty?
66
71
 
67
72
  # Extract IDs from hits
68
73
  ids = hits.map { |hit| hit["_id"] }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Noiseless
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noiseless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -291,7 +291,9 @@ files:
291
291
  - lib/noiseless/multi_search.rb
292
292
  - lib/noiseless/pagination.rb
293
293
  - lib/noiseless/query_builder.rb
294
+ - lib/noiseless/query_delegation.rb
294
295
  - lib/noiseless/railtie.rb
296
+ - lib/noiseless/record_class_resolver.rb
295
297
  - lib/noiseless/response.rb
296
298
  - lib/noiseless/response/aggregations.rb
297
299
  - lib/noiseless/response/empty.rb