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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/noiseless/adapter.rb +28 -35
- data/lib/noiseless/adapters/elasticsearch.rb +0 -1
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +63 -293
- data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
- data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
- data/lib/noiseless/adapters/indices_api.rb +3 -1
- data/lib/noiseless/adapters/open_search.rb +30 -88
- data/lib/noiseless/adapters/postgresql.rb +5 -5
- data/lib/noiseless/adapters/typesense.rb +1 -2
- data/lib/noiseless/ast/aggregation.rb +0 -23
- data/lib/noiseless/ast/aggregation_builder.rb +28 -0
- data/lib/noiseless/ast/hybrid.rb +4 -2
- data/lib/noiseless/ast/join.rb +4 -5
- data/lib/noiseless/ast/vector.rb +25 -4
- data/lib/noiseless/bulk_importer.rb +16 -27
- data/lib/noiseless/callbacks.rb +4 -30
- data/lib/noiseless/connection_manager.rb +9 -3
- data/lib/noiseless/controller_runtime.rb +37 -0
- data/lib/noiseless/document_manager.rb +21 -36
- data/lib/noiseless/embedding.rb +33 -0
- data/lib/noiseless/instrumentation.rb +2 -136
- data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
- data/lib/noiseless/introspection/query_visualizer.rb +3 -228
- data/lib/noiseless/introspection.rb +1 -1
- data/lib/noiseless/log_subscriber.rb +103 -0
- data/lib/noiseless/model.rb +2 -2
- data/lib/noiseless/multi_search.rb +2 -4
- data/lib/noiseless/pagination.rb +3 -3
- data/lib/noiseless/query_builder.rb +5 -4
- data/lib/noiseless/railtie.rb +0 -2
- data/lib/noiseless/response/records.rb +2 -1
- data/lib/noiseless/runtime_reset_middleware.rb +1 -1
- data/lib/noiseless/search_index_update_job.rb +13 -14
- data/lib/noiseless/test_case.rb +0 -1
- data/lib/noiseless/version.rb +1 -1
- data/lib/noiseless.rb +1 -10
- metadata +31 -13
- 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
|