noiseless 0.7.1 → 0.8.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/noiseless/adapter.rb +28 -35
  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 +63 -293
  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 +5 -5
  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 +16 -27
  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 +21 -36
  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 +2 -2
  35. data/lib/noiseless/multi_search.rb +2 -4
  36. data/lib/noiseless/pagination.rb +3 -3
  37. data/lib/noiseless/query_builder.rb +5 -4
  38. data/lib/noiseless/railtie.rb +0 -2
  39. data/lib/noiseless/response/records.rb +2 -1
  40. data/lib/noiseless/runtime_reset_middleware.rb +1 -1
  41. data/lib/noiseless/search_index_update_job.rb +13 -14
  42. data/lib/noiseless/test_case.rb +0 -1
  43. data/lib/noiseless/version.rb +1 -1
  44. data/lib/noiseless.rb +1 -10
  45. metadata +31 -13
  46. data/lib/tasks/benchmark.rake +0 -35
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ module ExecutionModules
6
+ # Builds ActiveRecord scopes from the noiseless AST for the PostgreSQL adapter.
7
+ module PostgresqlQuery
8
+ DEFAULT_LIMIT = 20
9
+
10
+ private
11
+
12
+ def build_search_scope(model, query_hash)
13
+ scope = model.all
14
+
15
+ # Apply must clauses (full-text search)
16
+ scope = apply_must_clauses(scope, query_hash[:bool]&.must || [], model)
17
+
18
+ # Apply filter clauses (exact matches)
19
+ scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
20
+
21
+ # Apply sorting (pagination is applied by the caller, after counting)
22
+ apply_sorting(scope, query_hash[:sort] || [], model)
23
+ end
24
+
25
+ def apply_must_clauses(scope, must_nodes, model)
26
+ return scope if must_nodes.empty?
27
+
28
+ must_nodes.each do |node|
29
+ scope = case node
30
+ when AST::Match
31
+ apply_match(scope, node, model)
32
+ when AST::MultiMatch
33
+ apply_multi_match(scope, node, model)
34
+ when AST::Wildcard
35
+ apply_wildcard(scope, node, model)
36
+ when AST::Range
37
+ apply_range(scope, node, model)
38
+ when AST::Prefix
39
+ apply_prefix(scope, node, model)
40
+ else
41
+ scope
42
+ end
43
+ end
44
+
45
+ scope
46
+ end
47
+
48
+ def apply_match(scope, node, model)
49
+ field = node.field.to_s
50
+ value = node.value.to_s
51
+
52
+ # Mapping-only fields (denormalized into a search index, not columns)
53
+ # cannot be satisfied here; fail closed rather than matching everything.
54
+ return scope.none unless column?(model, field)
55
+
56
+ # Use pg_trgm similarity for fuzzy matching, accent-insensitive when
57
+ # the unaccent extension is present.
58
+ if trgm_available? && text_column?(model, field)
59
+ scope.where(
60
+ "#{fuzzy_column(field)} % #{fuzzy_param} OR " \
61
+ "#{fuzzy_column(field)} ILIKE #{fuzzy_param}",
62
+ value,
63
+ "%#{sanitize_like(value)}%"
64
+ )
65
+ else
66
+ # Fallback to ILIKE
67
+ scope.where("#{quoted_column(field)} ILIKE ?", "%#{sanitize_like(value)}%")
68
+ end
69
+ end
70
+
71
+ def apply_multi_match(scope, node, model)
72
+ query = node.query.to_s
73
+ # Drop mapping-only fields; if none of the requested fields are real
74
+ # columns the query cannot be satisfied — fail closed.
75
+ fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
76
+ return scope.none if fields.empty?
77
+
78
+ conditions = fields.map do |field|
79
+ if trgm_available? && text_column?(model, field)
80
+ "(#{fuzzy_column(field)} % #{fuzzy_param} OR " \
81
+ "#{fuzzy_column(field)} ILIKE #{fuzzy_param})"
82
+ else
83
+ "#{quoted_column(field)} ILIKE ?"
84
+ end
85
+ end
86
+
87
+ params = fields.flat_map do |field|
88
+ if trgm_available? && text_column?(model, field)
89
+ [query, "%#{sanitize_like(query)}%"]
90
+ else
91
+ ["%#{sanitize_like(query)}%"]
92
+ end
93
+ end
94
+
95
+ scope.where(conditions.join(" OR "), *params)
96
+ end
97
+
98
+ def apply_wildcard(scope, node, model = nil)
99
+ field = node.field.to_s
100
+ return scope.none if model && !column?(model, field)
101
+
102
+ # Convert OpenSearch wildcards to SQL: * -> %, ? -> _
103
+ pattern = node.value.to_s.tr("*", "%").tr("?", "_")
104
+
105
+ scope.where("#{quoted_column(field)} ILIKE ?", pattern)
106
+ end
107
+
108
+ def apply_range(scope, node, model = nil)
109
+ return scope.none if model && !column?(model, node.field.to_s)
110
+
111
+ field = quoted_column(node.field.to_s)
112
+
113
+ scope = scope.where("#{field} >= ?", node.gte) if node.gte
114
+ scope = scope.where("#{field} <= ?", node.lte) if node.lte
115
+ scope = scope.where("#{field} > ?", node.gt) if node.gt
116
+ scope = scope.where("#{field} < ?", node.lt) if node.lt
117
+
118
+ scope
119
+ end
120
+
121
+ def apply_prefix(scope, node, model = nil)
122
+ return scope.none if model && !column?(model, node.field.to_s)
123
+
124
+ scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
125
+ end
126
+
127
+ def apply_filter_clauses(scope, filter_nodes, model = nil)
128
+ return scope if filter_nodes.empty?
129
+
130
+ filter_nodes.each do |node|
131
+ value = node.value
132
+
133
+ scope = if value.is_a?(Hash) && value.with_indifferent_access.key?(:geo_distance)
134
+ apply_geo_filter(scope, node, model)
135
+ elsif range_filter?(value)
136
+ apply_range(scope, AST::Range.new(node.field, **value.transform_keys(&:to_sym)), model)
137
+ elsif model && !column?(model, node.field.to_s)
138
+ # A filter on a mapping-only field cannot be enforced;
139
+ # silently dropping it would broaden results, so fail closed.
140
+ scope.none
141
+ elsif model && array_column?(model, node.field.to_s)
142
+ apply_array_filter(scope, node.field.to_s, value, model)
143
+ else
144
+ scope.where(node.field => value)
145
+ end
146
+ end
147
+
148
+ scope
149
+ end
150
+
151
+ def apply_array_filter(scope, field, value, model)
152
+ cast = "#{model.columns_hash[field].sql_type.sub(/\[\]\z/, '')}[]"
153
+ operator = value.is_a?(Array) ? "&&" : "@>"
154
+
155
+ scope.where("#{quoted_column(field)} #{operator} ARRAY[?]::#{cast}", value)
156
+ end
157
+
158
+ # Requires PostGIS. A geo filter that cannot be enforced must narrow to
159
+ # nothing: dropping it would return every row regardless of distance.
160
+ def apply_geo_filter(scope, node, model = nil)
161
+ field = node.field.to_s
162
+ return scope.none if model && !column?(model, field)
163
+
164
+ geo_config = node.value.with_indifferent_access[:geo_distance]
165
+ return scope.none unless geo_config.is_a?(Hash)
166
+
167
+ geo_point = geo_config.values.find { |v| v.is_a?(Hash) && v.key?(:lat) && v.key?(:lon) }
168
+ return scope.none unless geo_point
169
+
170
+ scope.where(
171
+ "ST_DWithin(#{quoted_column(field)}::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)",
172
+ Float(geo_point[:lon]),
173
+ Float(geo_point[:lat]),
174
+ parse_distance(geo_config[:distance])
175
+ )
176
+ rescue ArgumentError, TypeError => e
177
+ Rails.logger.warn("Noiseless: geo filter on #{field} has invalid coordinates: #{e.message}")
178
+ scope.none
179
+ end
180
+
181
+ def apply_sorting(scope, sort_nodes, model = nil)
182
+ sorted_fields = []
183
+ order_clauses = sort_nodes.filter_map do |node|
184
+ field = node.field.to_s
185
+ # Sorting on a mapping-only field is cosmetic — drop it instead of
186
+ # erroring the whole query.
187
+ next if model && !column?(model, field)
188
+
189
+ sorted_fields << field
190
+ direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
191
+ "#{quoted_column(field)} #{direction}"
192
+ end
193
+
194
+ primary_key = model.respond_to?(:primary_key) ? model.primary_key : nil
195
+ order_clauses << "#{quoted_column(primary_key)} ASC" if primary_key && !sorted_fields.include?(primary_key.to_s)
196
+ return scope if order_clauses.empty?
197
+
198
+ scope.order(Arel.sql(order_clauses.join(", ")))
199
+ end
200
+
201
+ def apply_pagination(scope, paginate_node)
202
+ page = paginate_node&.page || 1
203
+ per_page = paginate_node&.per_page || DEFAULT_LIMIT
204
+
205
+ offset = (page - 1) * per_page
206
+
207
+ scope.limit(per_page).offset(offset)
208
+ end
209
+
210
+ def trgm_available?
211
+ @trgm_available ||= available_extensions.include?("pg_trgm")
212
+ end
213
+
214
+ def unaccent_available?
215
+ @unaccent_available ||= available_extensions.include?("unaccent")
216
+ end
217
+
218
+ def column?(model, field)
219
+ model.columns_hash.key?(field.to_s)
220
+ end
221
+
222
+ def text_column?(model, field)
223
+ column = model.columns_hash[field.to_s]
224
+ column && %i[string text citext].include?(column.type) && !array_column?(model, field)
225
+ end
226
+
227
+ def array_column?(model, field)
228
+ column = model.columns_hash[field.to_s]
229
+ column.respond_to?(:array) && column.array
230
+ end
231
+
232
+ def range_filter?(value)
233
+ value.is_a?(Hash) && value.any? && (value.keys.map(&:to_sym) - Noiseless::Adapter::RANGE_OPERATORS).empty?
234
+ end
235
+
236
+ def fuzzy_column(field)
237
+ unaccent_available? ? "unaccent(#{quoted_column(field)})" : quoted_column(field)
238
+ end
239
+
240
+ def fuzzy_param
241
+ unaccent_available? ? "unaccent(?)" : "?"
242
+ end
243
+
244
+ def quoted_column(field)
245
+ ActiveRecord::Base.connection.quote_column_name(field)
246
+ end
247
+
248
+ def sanitize_like(value)
249
+ # Escape special LIKE characters
250
+ value.to_s.gsub(/[%_\\]/) { |x| "\\#{x}" }
251
+ end
252
+
253
+ def parse_distance(distance)
254
+ # Parse OpenSearch distance format (e.g., "10km", "5mi")
255
+ case distance.to_s
256
+ when /(\d+(?:\.\d+)?)\s*km/i
257
+ ::Regexp.last_match(1).to_f * 1000
258
+ when /(\d+(?:\.\d+)?)\s*mi/i
259
+ ::Regexp.last_match(1).to_f * 1609.34
260
+ when /(\d+(?:\.\d+)?)\s*m/i
261
+ ::Regexp.last_match(1).to_f
262
+ else
263
+ distance.to_f
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end