noiseless 0.7.2 → 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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -2
  3. data/lib/noiseless/adapter.rb +29 -41
  4. data/lib/noiseless/adapters/elasticsearch.rb +0 -1
  5. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
  6. data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
  7. data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
  8. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
  9. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
  10. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +65 -327
  11. data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
  12. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
  13. data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
  14. data/lib/noiseless/adapters/indices_api.rb +3 -1
  15. data/lib/noiseless/adapters/open_search.rb +30 -88
  16. data/lib/noiseless/adapters/postgresql.rb +6 -16
  17. data/lib/noiseless/adapters/typesense.rb +1 -2
  18. data/lib/noiseless/ast/aggregation.rb +0 -23
  19. data/lib/noiseless/ast/aggregation_builder.rb +28 -0
  20. data/lib/noiseless/ast/hybrid.rb +4 -2
  21. data/lib/noiseless/ast/join.rb +4 -5
  22. data/lib/noiseless/ast/vector.rb +25 -4
  23. data/lib/noiseless/bulk_importer.rb +17 -32
  24. data/lib/noiseless/callbacks.rb +4 -30
  25. data/lib/noiseless/connection_manager.rb +9 -3
  26. data/lib/noiseless/controller_runtime.rb +37 -0
  27. data/lib/noiseless/document_manager.rb +22 -41
  28. data/lib/noiseless/embedding.rb +33 -0
  29. data/lib/noiseless/instrumentation.rb +2 -136
  30. data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
  31. data/lib/noiseless/introspection/query_visualizer.rb +3 -228
  32. data/lib/noiseless/introspection.rb +1 -1
  33. data/lib/noiseless/log_subscriber.rb +103 -0
  34. data/lib/noiseless/model.rb +19 -71
  35. data/lib/noiseless/model_registry.rb +1 -7
  36. data/lib/noiseless/multi_search.rb +23 -113
  37. data/lib/noiseless/pagination.rb +14 -40
  38. data/lib/noiseless/query_builder.rb +16 -16
  39. data/lib/noiseless/query_delegation.rb +15 -0
  40. data/lib/noiseless/railtie.rb +0 -2
  41. data/lib/noiseless/record_class_resolver.rb +15 -0
  42. data/lib/noiseless/response/records.rb +9 -3
  43. data/lib/noiseless/runtime_reset_middleware.rb +1 -1
  44. data/lib/noiseless/search_index_update_job.rb +13 -14
  45. data/lib/noiseless/test_case.rb +0 -1
  46. data/lib/noiseless/version.rb +1 -1
  47. data/lib/noiseless.rb +1 -10
  48. metadata +33 -13
  49. data/lib/tasks/benchmark.rake +0 -35
@@ -64,9 +64,9 @@ module Noiseless
64
64
  when :yaml
65
65
  YAML.dump(ast_node.to_h)
66
66
  when :mermaid
67
- ast_to_mermaid_flowchart(ast_node)
67
+ MermaidDiagrams.ast_to_mermaid_flowchart(ast_node)
68
68
  when :mermaid_class
69
- ast_to_mermaid_class_diagram(ast_node)
69
+ MermaidDiagrams.ast_to_mermaid_class_diagram(ast_node)
70
70
  else
71
71
  raise ArgumentError, "Unsupported format: #{format}. Available: :tree, :json, :yaml, :mermaid, :mermaid_class"
72
72
  end
@@ -75,7 +75,7 @@ module Noiseless
75
75
  def self.explain_query_flow(ast_node, adapter)
76
76
  explanation = adapter.explain_query(ast_node)
77
77
 
78
- flow_diagram = generate_flow_diagram(explanation)
78
+ flow_diagram = MermaidDiagrams.generate_flow_diagram(explanation)
79
79
 
80
80
  {
81
81
  explanation: explanation,
@@ -230,68 +230,6 @@ module Noiseless
230
230
  end
231
231
  end
232
232
 
233
- def self.generate_mermaid_diagram(ast_node)
234
- diagram = "graph TD\n".dup
235
-
236
- add_node = lambda do |diagram, node, parent_id, counter|
237
- case node
238
- when Hash
239
- node.each do |key, value|
240
- current_id = "N#{counter[:count]}"
241
- counter[:count] += 1
242
- diagram << " #{current_id}[#{key}]\n"
243
- diagram << " #{parent_id} --> #{current_id}\n" if parent_id
244
- add_node.call(diagram, value, current_id, counter)
245
- end
246
- when Array
247
- node.each_with_index do |item, index|
248
- current_id = "N#{counter[:count]}"
249
- counter[:count] += 1
250
- diagram << " #{current_id}[Item #{index}]\n"
251
- diagram << " #{parent_id} --> #{current_id}\n" if parent_id
252
- add_node.call(diagram, item, current_id, counter)
253
- end
254
- else
255
- current_id = "N#{counter[:count]}"
256
- counter[:count] += 1
257
- diagram << " #{current_id}[#{node}]\n"
258
- diagram << " #{parent_id} --> #{current_id}\n" if parent_id
259
- end
260
- end
261
-
262
- counter = { count: 0 }
263
- root_id = "N#{counter[:count]}"
264
- counter[:count] += 1
265
- diagram << " #{root_id}[Root]\n"
266
-
267
- add_node.call(diagram, ast_node.to_h, root_id, counter)
268
- diagram
269
- end
270
-
271
- def self.generate_flow_diagram(explanation)
272
- # Create a sequence diagram showing the query execution flow
273
- diagram = "sequenceDiagram\n".dup
274
- diagram << " participant Client\n"
275
- diagram << " participant Adapter\n"
276
- diagram << " participant Engine\n"
277
-
278
- explanation[:execution_plan].each do |step|
279
- diagram << case step[:description]
280
- when /validate/i
281
- " Client->>Adapter: #{step[:description]}\n"
282
- when /convert/i, /format/i
283
- " Adapter->>Adapter: #{step[:description]}\n"
284
- when /execute/i, /query/i
285
- " Adapter->>Engine: #{step[:description]}\n"
286
- else
287
- " Engine->>Adapter: #{step[:description]}\n"
288
- end
289
- end
290
-
291
- diagram << " Adapter->>Client: Return results\n"
292
- diagram
293
- end
294
-
295
233
  def self.format_performance_breakdown(performance)
296
234
  performance.map do |metric, value|
297
235
  {
@@ -365,169 +303,6 @@ module Noiseless
365
303
  node.each { |item| count_recursive(item, complexity) }
366
304
  end
367
305
  end
368
-
369
- # New methods using your diagram/mermaid gems
370
- def self.ast_to_mermaid_flowchart(ast_node)
371
- return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
372
-
373
- diagram = Diagrams::FlowchartDiagram.new(version: "1.0")
374
-
375
- # Convert AST structure to flowchart nodes and edges
376
- add_ast_node_to_flowchart(diagram, ast_node, "root")
377
-
378
- diagram.to_mermaid
379
- end
380
-
381
- def self.ast_to_mermaid_class_diagram(ast_node)
382
- return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
383
-
384
- diagram = Diagrams::ClassDiagram.new(version: "1.0")
385
-
386
- # Create a class representation of the AST structure
387
- root_class = Diagrams::Elements::ClassEntity.new(
388
- name: ast_node.class.name.split("::").last,
389
- attributes: ast_node.instance_variables.map do |var|
390
- "#{var.to_s.delete('@')}: #{ast_node.instance_variable_get(var).class.name.split('::').last}"
391
- end,
392
- methods: ast_node.public_methods(false).map { |method| "+#{method}()" }
393
- )
394
-
395
- diagram.add_class(root_class)
396
-
397
- # Add child nodes as related classes
398
- add_ast_children_to_class_diagram(diagram, ast_node, root_class.name)
399
-
400
- diagram.to_mermaid
401
- end
402
-
403
- def self.adapter_capability_matrix_to_mermaid
404
- return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
405
-
406
- # Create an ER diagram showing adapter capabilities
407
- diagram = Diagrams::ERDiagram.new
408
-
409
- # Add adapter entities
410
- diagram.add_entity(
411
- name: "ADAPTER",
412
- attributes: [
413
- { type: "string", name: "type", keys: [:PK] },
414
- { type: "string", name: "execution_mode" },
415
- { type: "string", name: "engine_name" }
416
- ]
417
- )
418
-
419
- diagram.add_entity(
420
- name: "CAPABILITY",
421
- attributes: [
422
- { type: "string", name: "name", keys: [:PK] },
423
- { type: "string", name: "description" }
424
- ]
425
- )
426
-
427
- diagram.add_entity(
428
- name: "ADAPTER_CAPABILITY",
429
- attributes: [
430
- { type: "string", name: "adapter_type", keys: %i[PK FK] },
431
- { type: "string", name: "capability_name", keys: %i[PK FK] }
432
- ]
433
- )
434
-
435
- # Add relationships
436
- diagram.add_relationship(
437
- entity1: "ADAPTER",
438
- entity2: "ADAPTER_CAPABILITY",
439
- cardinality1: :ONE_ONLY,
440
- cardinality2: :ZERO_OR_MORE,
441
- label: "has"
442
- )
443
-
444
- diagram.add_relationship(
445
- entity1: "CAPABILITY",
446
- entity2: "ADAPTER_CAPABILITY",
447
- cardinality1: :ONE_ONLY,
448
- cardinality2: :ZERO_OR_MORE,
449
- label: "provided by"
450
- )
451
-
452
- diagram.to_mermaid
453
- end
454
-
455
- def self.add_ast_node_to_flowchart(diagram, node, node_id)
456
- # Add current node
457
- flowchart_node = Diagrams::Elements::Node.new(
458
- id: node_id,
459
- label: node.class.name.split("::").last.to_s
460
- )
461
- diagram.add_node(flowchart_node)
462
-
463
- # Add child nodes and connect them
464
- return unless node.respond_to?(:instance_variables)
465
-
466
- node.instance_variables.each_with_index do |var, _index|
467
- child_value = node.instance_variable_get(var)
468
-
469
- if child_value.is_a?(Noiseless::AST::Node)
470
- child_id = "#{node_id}_#{var.to_s.delete('@')}"
471
- add_ast_node_to_flowchart(diagram, child_value, child_id)
472
-
473
- edge = Diagrams::Elements::Edge.new(
474
- source_id: node_id,
475
- target_id: child_id,
476
- label: var.to_s.delete("@")
477
- )
478
- diagram.add_edge(edge)
479
- elsif child_value.is_a?(Array) && child_value.any?(Noiseless::AST::Node)
480
- child_value.each_with_index do |item, item_index|
481
- next unless item.is_a?(Noiseless::AST::Node)
482
-
483
- child_id = "#{node_id}_#{var.to_s.delete('@')}_#{item_index}"
484
- add_ast_node_to_flowchart(diagram, item, child_id)
485
-
486
- edge = Diagrams::Elements::Edge.new(
487
- source_id: node_id,
488
- target_id: child_id,
489
- label: "#{var.to_s.delete('@')}[#{item_index}]"
490
- )
491
- diagram.add_edge(edge)
492
- end
493
- end
494
- end
495
- end
496
-
497
- def self.add_ast_children_to_class_diagram(diagram, node, parent_class_name)
498
- return unless node.respond_to?(:instance_variables)
499
-
500
- node.instance_variables.each do |var|
501
- child_value = node.instance_variable_get(var)
502
-
503
- next unless child_value.is_a?(Noiseless::AST::Node)
504
-
505
- child_class_name = child_value.class.name.split("::").last
506
-
507
- # Add child class if not already added
508
- unless diagram.classes.any? { |c| c.name == child_class_name }
509
- child_class = Diagrams::Elements::ClassEntity.new(
510
- name: child_class_name,
511
- attributes: child_value.instance_variables.map do |cv|
512
- "#{cv.to_s.delete('@')}: #{child_value.instance_variable_get(cv).class.name.split('::').last}"
513
- end
514
- )
515
- diagram.add_class(child_class)
516
- end
517
-
518
- # Add relationship
519
- relationship = Diagrams::Elements::Relationship.new(
520
- source_class_name: parent_class_name,
521
- target_class_name: child_class_name,
522
- type: "composition",
523
- label: var.to_s.delete("@")
524
- )
525
- diagram.add_relationship(relationship)
526
-
527
- # Recursively add children
528
- add_ast_children_to_class_diagram(diagram, child_value, child_class_name)
529
- end
530
- end
531
306
  end
532
307
  end
533
308
  end
@@ -45,7 +45,7 @@ module Noiseless
45
45
  # Engine-specific capabilities
46
46
  case self.class.name
47
47
  when /OpenSearch/
48
- capabilities += %i[point_in_time_search search_templates] if respond_to?(:point_in_time_search)
48
+ capabilities += %i[point_in_time_pagination search_templates] if respond_to?(:each_page)
49
49
  when /Typesense/
50
50
  capabilities += %i[typo_tolerance faceted_search] if respond_to?(:faceted_search)
51
51
  when /Elasticsearch/
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ # Log subscriber for Rails integration
5
+ class LogSubscriber < ActiveSupport::LogSubscriber
6
+ def search(event)
7
+ return unless logger.debug?
8
+
9
+ indexes = event.payload[:indexes]&.join(", ") || "unknown"
10
+ duration = event.duration.round(2)
11
+
12
+ debug "Noiseless Search (#{duration}ms) indexes=[#{indexes}] #{query_summary(event.payload[:query])}"
13
+ end
14
+
15
+ def bulk(event)
16
+ return unless logger.debug?
17
+
18
+ actions_count = event.payload[:actions_count] || 0
19
+ duration = event.duration.round(2)
20
+
21
+ debug "Noiseless Bulk (#{duration}ms) actions=#{actions_count}"
22
+ end
23
+
24
+ def index_document(event)
25
+ return unless logger.debug?
26
+
27
+ index = event.payload[:index]
28
+ id = event.payload[:id]
29
+ duration = event.duration.round(2)
30
+
31
+ debug "Noiseless Index Document (#{duration}ms) index=#{index} id=#{id}"
32
+ end
33
+
34
+ def update_document(event)
35
+ return unless logger.debug?
36
+
37
+ index = event.payload[:index]
38
+ id = event.payload[:id]
39
+ changes_count = event.payload[:changes_count] || 0
40
+ duration = event.duration.round(2)
41
+
42
+ debug "Noiseless Update Document (#{duration}ms) index=#{index} id=#{id} changes=#{changes_count}"
43
+ end
44
+
45
+ def delete_document(event)
46
+ return unless logger.debug?
47
+
48
+ index = event.payload[:index]
49
+ id = event.payload[:id]
50
+ duration = event.duration.round(2)
51
+
52
+ debug "Noiseless Delete Document (#{duration}ms) index=#{index} id=#{id}"
53
+ end
54
+
55
+ def create_index(event)
56
+ return unless logger.debug?
57
+
58
+ index = event.payload[:index]
59
+ duration = event.duration.round(2)
60
+
61
+ debug "Noiseless Create Index (#{duration}ms) index=#{index}"
62
+ end
63
+
64
+ def delete_index(event)
65
+ return unless logger.debug?
66
+
67
+ index = event.payload[:index]
68
+ duration = event.duration.round(2)
69
+
70
+ debug "Noiseless Delete Index (#{duration}ms) index=#{index}"
71
+ end
72
+
73
+ private
74
+
75
+ def query_summary(query)
76
+ return "empty" unless query.is_a?(Hash)
77
+
78
+ parts = []
79
+
80
+ if query[:query]&.dig(:bool, :must)&.any?
81
+ must_count = query[:query][:bool][:must].size
82
+ parts << "must:#{must_count}"
83
+ end
84
+
85
+ if query[:query]&.dig(:bool, :filter)&.any?
86
+ filter_count = query[:query][:bool][:filter].size
87
+ parts << "filter:#{filter_count}"
88
+ end
89
+
90
+ if query[:sort]&.any?
91
+ sort_count = query[:sort].size
92
+ parts << "sort:#{sort_count}"
93
+ end
94
+
95
+ if query[:from] || query[:size]
96
+ parts << "from:#{query[:from] || 0}"
97
+ parts << "size:#{query[:size] || 20}"
98
+ end
99
+
100
+ parts.join(" ")
101
+ end
102
+ end
103
+ end
@@ -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(page: nil, per_page: nil)
63
- @builder.paginate(page: page, per_page: per_page)
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,24 +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
- loaded_records = model_class.where(id: ids).to_a
228
-
229
- # Sort by search relevance
230
- sorted_records = model_hits.filter_map do |hit|
231
- loaded_records.find { |record| record.id.to_s == hit["_id"].to_s }
232
- end
144
+ record_class = RecordClassResolver.resolve(model_class)
145
+ next unless record_class.respond_to?(:where)
233
146
 
234
- 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
235
149
  end
236
150
 
237
151
  records
238
152
  end
239
-
240
- def default_index_name(model_class)
241
- model_class.name.demodulize.underscore.pluralize
242
- end
243
153
  end
244
154
  end