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
|
@@ -1,361 +1,250 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "cgi/escape"
|
|
3
4
|
require "json"
|
|
4
|
-
require_relative "http_transport"
|
|
5
5
|
|
|
6
6
|
module Noiseless
|
|
7
7
|
module Adapters
|
|
8
8
|
module ExecutionModules
|
|
9
9
|
module TypesenseExecution
|
|
10
10
|
include HttpTransport
|
|
11
|
+
include TypesenseQuery
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# Build search query from match nodes
|
|
19
|
-
query_parts = build_search_query(ast_node.bool)
|
|
20
|
-
result[:q] = query_parts unless query_parts.empty?
|
|
21
|
-
|
|
22
|
-
# Build query_by from multi_match nodes
|
|
23
|
-
query_by_fields = build_query_by_fields(ast_node.bool)
|
|
24
|
-
result[:query_by] = query_by_fields unless query_by_fields.empty?
|
|
25
|
-
|
|
26
|
-
# Build filter expressions from filter nodes
|
|
27
|
-
filter_expr = build_filter_expression(ast_node.bool)
|
|
28
|
-
result[:filter_by] = filter_expr unless filter_expr.empty?
|
|
29
|
-
|
|
30
|
-
# Build sort expressions from sort nodes
|
|
31
|
-
sort_expr = build_sort_expression(ast_node.sort)
|
|
32
|
-
result[:sort_by] = sort_expr unless sort_expr.empty?
|
|
33
|
-
|
|
34
|
-
# Add pagination
|
|
35
|
-
pagination = build_pagination_params(ast_node.paginate)
|
|
36
|
-
result.merge!(pagination)
|
|
37
|
-
|
|
38
|
-
# Field collapsing -> Typesense group_by
|
|
39
|
-
if ast_node.collapse
|
|
40
|
-
result[:group_by] = ast_node.collapse.field
|
|
41
|
-
result[:group_limit] = 1 # Collapse shows 1 per group by default
|
|
42
|
-
if ast_node.collapse.max_concurrent_group_searches
|
|
43
|
-
# Typesense v30+: improve found accuracy for grouped results up to this threshold.
|
|
44
|
-
result[:group_max_candidates] = ast_node.collapse.max_concurrent_group_searches
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Aggregations -> Typesense facet_by
|
|
49
|
-
if ast_node.aggregations.any?
|
|
50
|
-
facet_fields = ast_node.aggregations
|
|
51
|
-
.select { |agg| agg.type == :terms }
|
|
52
|
-
.filter_map(&:field)
|
|
53
|
-
|
|
54
|
-
result[:facet_by] = facet_fields.join(",") if facet_fields.any?
|
|
13
|
+
# Typesense stores date fields as int64, so times travel as epoch seconds.
|
|
14
|
+
TYPESENSE_JSON = JSON::Coder.new do |object|
|
|
15
|
+
if object.respond_to?(:to_time) then object.to_time.to_i
|
|
16
|
+
elsif object.is_a?(BigDecimal) then object.to_f
|
|
17
|
+
else object.respond_to?(:as_json) ? object.as_json : object.to_s
|
|
55
18
|
end
|
|
19
|
+
end
|
|
56
20
|
|
|
57
|
-
|
|
58
|
-
if ast_node.vector_search?
|
|
59
|
-
vector = ast_node.vector
|
|
60
|
-
# Typesense uses format: "field_name:([vector], k:N)"
|
|
61
|
-
vector_str = vector.embedding.join(",")
|
|
62
|
-
result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k})"
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Hybrid search -> Typesense native hybrid with q + vector_query
|
|
66
|
-
if ast_node.hybrid_search?
|
|
67
|
-
hybrid = ast_node.hybrid
|
|
68
|
-
vector = hybrid.vector
|
|
69
|
-
vector_str = vector.embedding.join(",")
|
|
70
|
-
|
|
71
|
-
# Typesense natively supports hybrid by combining q and vector_query
|
|
72
|
-
result[:q] = hybrid.text_query
|
|
73
|
-
result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k}, alpha:#{hybrid.vector_weight})"
|
|
74
|
-
end
|
|
21
|
+
private
|
|
75
22
|
|
|
76
|
-
|
|
77
|
-
if ast_node.image_search?
|
|
78
|
-
img = ast_node.image_query
|
|
79
|
-
# Typesense accepts image URL or base64 directly in vector_query
|
|
80
|
-
result[:vector_query] = "#{img.field}:(#{img.image_data}, k:#{img.k})"
|
|
81
|
-
end
|
|
23
|
+
def dump_json(object) = TYPESENSE_JSON.dump(object)
|
|
82
24
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
result[:conversation] = true
|
|
87
|
-
result[:conversation_model_id] = conv.model_id
|
|
88
|
-
result[:conversation_id] = conv.conversation_id if conv.conversation_id
|
|
89
|
-
result[:system_prompt] = conv.system_prompt if conv.system_prompt
|
|
90
|
-
end
|
|
25
|
+
def execute_search(query_hash, indexes: [], **_opts)
|
|
26
|
+
collections = Array(indexes).map(&:to_s)
|
|
27
|
+
raise Noiseless::SearchError, "Typesense search needs a collection" if collections.empty?
|
|
91
28
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
fields = join_node.include_fields.join(", ")
|
|
96
|
-
"$#{join_node.collection}(#{fields})"
|
|
97
|
-
end
|
|
98
|
-
result[:include_fields] = include_fields.join(", ")
|
|
99
|
-
end
|
|
29
|
+
aggregation_names = query_hash[:aggregation_names] || {}
|
|
30
|
+
params = query_hash.except(:aggregation_names, :remove_duplicates)
|
|
31
|
+
context = "search #{collections.join(',')}"
|
|
100
32
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
33
|
+
result = if collections.one?
|
|
34
|
+
body = { searches: [params.merge(collection: collections.first)] }
|
|
35
|
+
single = post_json!("/multi_search", body, context:).fetch("results").first
|
|
36
|
+
raise_search_error!(single, context) if single["error"]
|
|
37
|
+
single
|
|
38
|
+
else
|
|
39
|
+
body = { union: true, searches: collections.map { params.except(:page, :per_page).merge(collection: it) } }
|
|
40
|
+
body[:remove_duplicates] = query_hash[:remove_duplicates] unless query_hash[:remove_duplicates].nil?
|
|
41
|
+
post_json!("/multi_search?page=#{params[:page]}&per_page=#{params[:per_page]}", body, context:)
|
|
42
|
+
end
|
|
105
43
|
|
|
106
|
-
result
|
|
44
|
+
search_response(result, collections.first, aggregation_names)
|
|
107
45
|
end
|
|
108
46
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"#{node.field}:#{node.value}"
|
|
115
|
-
when AST::MultiMatch
|
|
116
|
-
# For Typesense, multi_match becomes a broader search across fields
|
|
117
|
-
node.query
|
|
118
|
-
when AST::Range
|
|
119
|
-
# Range queries are handled in filters, not search
|
|
120
|
-
nil
|
|
121
|
-
else
|
|
122
|
-
node.respond_to?(:value) ? "#{node.field}:#{node.value}" : nil
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
queries.join(" ")
|
|
47
|
+
def post_json!(path, body, context:)
|
|
48
|
+
response = post_request(path, dump_json(body))
|
|
49
|
+
parse_json_response!(response, error_class: Noiseless::SearchError, context:)
|
|
50
|
+
ensure
|
|
51
|
+
response&.close
|
|
126
52
|
end
|
|
127
53
|
|
|
128
|
-
def
|
|
129
|
-
#
|
|
130
|
-
fields = bool_node.must.filter_map do |node|
|
|
131
|
-
case node
|
|
132
|
-
when AST::MultiMatch
|
|
133
|
-
node.fields
|
|
134
|
-
end
|
|
135
|
-
end.flatten.uniq
|
|
136
|
-
|
|
137
|
-
fields.join(",")
|
|
54
|
+
def raise_search_error!(result, context)
|
|
55
|
+
raise Noiseless::SearchError.new("#{context}: #{result['error']}", status: result["code"])
|
|
138
56
|
end
|
|
139
57
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
58
|
+
def search_response(result, default_collection, aggregation_names)
|
|
59
|
+
hits = if result["grouped_hits"]
|
|
60
|
+
result["grouped_hits"].filter_map { it["hits"].first }
|
|
61
|
+
else
|
|
62
|
+
result["hits"] || []
|
|
63
|
+
end
|
|
143
64
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
65
|
+
{
|
|
66
|
+
"took" => result["search_time_ms"] || 0,
|
|
67
|
+
"timed_out" => false,
|
|
68
|
+
"hits" => {
|
|
69
|
+
"total" => { "value" => result["found"] || 0, "relation" => "eq" },
|
|
70
|
+
"max_score" => nil,
|
|
71
|
+
"hits" => hits.map { search_hit(it, default_collection) }
|
|
72
|
+
},
|
|
73
|
+
"aggregations" => facet_aggregations(result["facet_counts"], aggregation_names)
|
|
74
|
+
}
|
|
75
|
+
end
|
|
147
76
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
conditions << "#{node.field}:<=#{node.lte}" if node.lte
|
|
153
|
-
conditions.join(" && ")
|
|
154
|
-
end
|
|
77
|
+
def search_hit(hit, default_collection)
|
|
78
|
+
score = hit.dig("hybrid_search_info", "rank_fusion_score") ||
|
|
79
|
+
(hit["vector_distance"] && (1.0 - hit["vector_distance"])) ||
|
|
80
|
+
hit["text_match"] || 1.0
|
|
155
81
|
|
|
156
|
-
|
|
82
|
+
{
|
|
83
|
+
"_index" => hit["collection"] || default_collection,
|
|
84
|
+
"_id" => hit.dig("document", "id"),
|
|
85
|
+
"_score" => score,
|
|
86
|
+
"_source" => hit["document"]
|
|
87
|
+
}
|
|
157
88
|
end
|
|
158
89
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
90
|
+
def facet_aggregations(facet_counts, aggregation_names)
|
|
91
|
+
Array(facet_counts).each_with_object({}) do |facet, aggregations|
|
|
92
|
+
name = aggregation_names.fetch(facet["field_name"], facet["field_name"])
|
|
93
|
+
buckets = facet["counts"].map { { "key" => it["value"], "doc_count" => it["count"] } }
|
|
94
|
+
aggregations[name] = { "buckets" => buckets }
|
|
164
95
|
end
|
|
165
|
-
sorts.join(",")
|
|
166
96
|
end
|
|
167
97
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
{
|
|
172
|
-
page: paginate_node.page,
|
|
173
|
-
per_page: paginate_node.per_page
|
|
174
|
-
}
|
|
175
|
-
end
|
|
98
|
+
def execute_bulk(actions, **_opts)
|
|
99
|
+
items = Array.new(actions.size)
|
|
100
|
+
indexed = actions.each_with_index.group_by { |action, _| action.dig(:index, :_index) || action.dig(:delete, :_index) }
|
|
176
101
|
|
|
177
|
-
|
|
178
|
-
|
|
102
|
+
indexed.each do |collection, pairs|
|
|
103
|
+
imports, deletes = pairs.partition { |action, _| action[:index] }
|
|
104
|
+
import_documents(collection, imports, items) if imports.any?
|
|
105
|
+
delete_documents(collection, deletes, items) if deletes.any?
|
|
106
|
+
end
|
|
179
107
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
path = "#{collection_path}?#{params}"
|
|
108
|
+
{ "items" => items, "errors" => items.any? { it.values.first["error"] } }
|
|
109
|
+
end
|
|
183
110
|
|
|
184
|
-
|
|
185
|
-
|
|
111
|
+
def import_documents(collection, pairs, items)
|
|
112
|
+
jsonl = pairs.map { |action, _| dump_json(typesense_document(collection, action[:index][:data], action[:index][:_id])) }.join("\n")
|
|
113
|
+
response = post_request("/collections/#{collection}/documents/import?action=upsert", jsonl, content_type: "text/plain")
|
|
114
|
+
body = response.read
|
|
115
|
+
unless response.success?
|
|
116
|
+
raise Noiseless::RequestError.new("import #{collection}: #{error_message(body, response.status)}", status: response.status)
|
|
117
|
+
end
|
|
186
118
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{
|
|
197
|
-
_index: collections.first || "typesense",
|
|
198
|
-
_type: "_doc",
|
|
199
|
-
_id: hit["document"]["id"],
|
|
200
|
-
_score: hit["text_match"] || 1.0,
|
|
201
|
-
_source: hit["document"]
|
|
202
|
-
}
|
|
203
|
-
end
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
rescue StandardError => e
|
|
207
|
-
# Return empty response on error to maintain compatibility
|
|
208
|
-
{
|
|
209
|
-
took: 0,
|
|
210
|
-
timed_out: false,
|
|
211
|
-
_shards: { total: 0, successful: 0, skipped: 0, failed: 0 },
|
|
212
|
-
hits: {
|
|
213
|
-
total: { value: 0, relation: "eq" },
|
|
214
|
-
max_score: nil,
|
|
215
|
-
hits: []
|
|
216
|
-
},
|
|
217
|
-
error: {
|
|
218
|
-
type: e.class.name,
|
|
219
|
-
reason: e.message
|
|
119
|
+
body.each_line.zip(pairs).each do |line, (action, position)|
|
|
120
|
+
result = JSON.parse(line)
|
|
121
|
+
items[position] = {
|
|
122
|
+
"index" => {
|
|
123
|
+
"_index" => collection,
|
|
124
|
+
"_id" => action[:index][:_id].to_s,
|
|
125
|
+
"status" => result["success"] ? 201 : result["code"],
|
|
126
|
+
"error" => (result["error"] unless result["success"])
|
|
127
|
+
}.compact
|
|
220
128
|
}
|
|
221
|
-
|
|
129
|
+
end
|
|
222
130
|
ensure
|
|
223
131
|
response&.close
|
|
224
132
|
end
|
|
225
133
|
|
|
226
|
-
def
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
path = "/collections/#{collection}/documents"
|
|
235
|
-
body = JSON.generate(document.merge(id: id))
|
|
236
|
-
|
|
237
|
-
response = post_request(path, body)
|
|
238
|
-
result = JSON.parse(response.read)
|
|
239
|
-
response.close
|
|
240
|
-
|
|
241
|
-
{ index: { _id: result["id"], status: 201, result: "created" } }
|
|
242
|
-
elsif action[:delete]
|
|
243
|
-
collection = action[:delete][:_index]
|
|
244
|
-
id = action[:delete][:_id]
|
|
245
|
-
|
|
246
|
-
path = "/collections/#{collection}/documents/#{id}"
|
|
247
|
-
|
|
248
|
-
response = delete_request(path)
|
|
249
|
-
response.close
|
|
250
|
-
|
|
251
|
-
{ delete: { _id: id, status: 200, result: "deleted" } }
|
|
252
|
-
else
|
|
253
|
-
{ error: { status: 400, error: "Unsupported action" } }
|
|
254
|
-
end
|
|
134
|
+
def delete_documents(collection, pairs, items)
|
|
135
|
+
ids = pairs.map { |action, _| action[:delete][:_id] }
|
|
136
|
+
filter = CGI.escape("id:#{filter_value(ids.map(&:to_s))}")
|
|
137
|
+
response = delete_request("/collections/#{collection}/documents?filter_by=#{filter}")
|
|
138
|
+
parse_json_response!(response, context: "delete documents #{collection}")
|
|
139
|
+
|
|
140
|
+
pairs.each do |action, position|
|
|
141
|
+
items[position] = { "delete" => { "_index" => collection, "_id" => action[:delete][:_id].to_s, "status" => 200 } }
|
|
255
142
|
end
|
|
143
|
+
ensure
|
|
144
|
+
response&.close
|
|
145
|
+
end
|
|
256
146
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
{ items: [], errors: true, error: { type: e.class.name, reason: e.message } }
|
|
147
|
+
def typesense_document(collection, document, id)
|
|
148
|
+
shape_document(collection, document).merge("id" => id.to_s)
|
|
260
149
|
end
|
|
261
150
|
|
|
262
|
-
def
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
151
|
+
def shape_document(collection, document)
|
|
152
|
+
arrays = array_fields(collection)
|
|
153
|
+
document.to_h.to_h do |key, value|
|
|
154
|
+
key = key.to_s
|
|
155
|
+
[key, arrays.include?(key) && !value.nil? && !value.is_a?(Array) ? [value] : value]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
268
158
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
end
|
|
159
|
+
def array_fields(collection)
|
|
160
|
+
(@array_fields ||= {})[collection.to_s] ||= begin
|
|
161
|
+
response = get_request("/collections/#{collection}")
|
|
162
|
+
fields = response.success? ? JSON.parse(response.read).fetch("fields") : []
|
|
163
|
+
fields.filter_map { it["name"] if it["type"].end_with?("[]") }.to_set
|
|
164
|
+
ensure
|
|
165
|
+
response&.close
|
|
278
166
|
end
|
|
167
|
+
end
|
|
279
168
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
169
|
+
def error_message(body, status)
|
|
170
|
+
parsed = JSON.parse(body)
|
|
171
|
+
parsed.is_a?(Hash) && parsed["message"] ? parsed["message"] : "HTTP #{status}"
|
|
172
|
+
rescue JSON::ParserError
|
|
173
|
+
"HTTP #{status}"
|
|
174
|
+
end
|
|
283
175
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
176
|
+
def execute_create_index(collection_name, mappings: nil, **_opts)
|
|
177
|
+
@array_fields&.delete(collection_name.to_s)
|
|
178
|
+
properties = (mappings || {}).with_indifferent_access[:properties] || {}
|
|
179
|
+
fields = properties.map { |name, config| typesense_field(name, config) }
|
|
180
|
+
fields = [{ name: ".*", type: "auto" }] if fields.empty?
|
|
181
|
+
|
|
182
|
+
response = post_request("/collections", dump_json(name: collection_name, fields:))
|
|
183
|
+
result = parse_json_response!(response, context: "create collection #{collection_name}")
|
|
184
|
+
|
|
185
|
+
{ "acknowledged" => true, "index" => result["name"] }
|
|
287
186
|
ensure
|
|
288
187
|
response&.close
|
|
289
188
|
end
|
|
290
189
|
|
|
190
|
+
def typesense_field(name, config)
|
|
191
|
+
type = map_type_to_typesense(config[:type].to_s)
|
|
192
|
+
field = { name: name.to_s, type: type, optional: true }
|
|
193
|
+
field[:num_dim] = config[:dims] || config[:dimension] if type == "float[]"
|
|
194
|
+
field[:facet] = true if config[:facet] || config[:type].to_s == "keyword"
|
|
195
|
+
field[:reference] = config[:reference] if config[:reference]
|
|
196
|
+
field
|
|
197
|
+
end
|
|
198
|
+
|
|
291
199
|
def execute_delete_index(collection_name, **_opts)
|
|
200
|
+
@array_fields&.delete(collection_name.to_s)
|
|
292
201
|
response = delete_request("/collections/#{collection_name}")
|
|
293
|
-
|
|
202
|
+
return { "acknowledged" => true, "result" => "not_found" } if response.status == 404
|
|
294
203
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
{ acknowledged: false, error: { type: e.class.name, reason: e.message } }
|
|
204
|
+
parse_json_response!(response, context: "delete collection #{collection_name}")
|
|
205
|
+
{ "acknowledged" => true }
|
|
298
206
|
ensure
|
|
299
207
|
response&.close
|
|
300
208
|
end
|
|
301
209
|
|
|
302
210
|
def execute_index_exists?(collection_name)
|
|
303
|
-
response =
|
|
304
|
-
response
|
|
305
|
-
rescue StandardError
|
|
306
|
-
false
|
|
211
|
+
response = get_request("/collections/#{collection_name}")
|
|
212
|
+
exists_response?(response, context: "collection exists #{collection_name}")
|
|
307
213
|
ensure
|
|
308
214
|
response&.close
|
|
309
215
|
end
|
|
310
216
|
|
|
311
217
|
def execute_index_document(collection, id, document, **_opts)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
response = post_request(path, body)
|
|
316
|
-
result = JSON.parse(response.read)
|
|
218
|
+
response = post_request("/collections/#{collection}/documents?action=upsert",
|
|
219
|
+
dump_json(typesense_document(collection, document, id)))
|
|
220
|
+
result = parse_json_response!(response, context: "index document #{collection}/#{id}")
|
|
317
221
|
|
|
318
|
-
{ _index
|
|
319
|
-
rescue StandardError => e
|
|
320
|
-
{ _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
|
|
222
|
+
{ "_index" => collection, "_id" => result["id"], "result" => "upserted" }
|
|
321
223
|
ensure
|
|
322
224
|
response&.close
|
|
323
225
|
end
|
|
324
226
|
|
|
325
227
|
def execute_update_document(collection, id, changes, **_opts)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
document = JSON.parse(get_response.read)
|
|
329
|
-
get_response.close
|
|
330
|
-
|
|
331
|
-
updated_document = document.merge(changes).merge(id: id)
|
|
332
|
-
body = JSON.generate(updated_document)
|
|
333
|
-
|
|
334
|
-
response = put_request("/collections/#{collection}/documents/#{id}", body)
|
|
335
|
-
result = JSON.parse(response.read)
|
|
228
|
+
response = patch_request("/collections/#{collection}/documents/#{id}", dump_json(shape_document(collection, changes)))
|
|
229
|
+
result = parse_json_response!(response, context: "update document #{collection}/#{id}")
|
|
336
230
|
|
|
337
|
-
{ _index
|
|
338
|
-
rescue StandardError => e
|
|
339
|
-
{ _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
|
|
231
|
+
{ "_index" => collection, "_id" => result["id"], "result" => "updated" }
|
|
340
232
|
ensure
|
|
341
|
-
response&.close
|
|
233
|
+
response&.close
|
|
342
234
|
end
|
|
343
235
|
|
|
344
236
|
def execute_delete_document(collection, id, **_opts)
|
|
345
|
-
response = delete_request("/collections/#{collection}/documents/#{id}")
|
|
237
|
+
response = delete_request("/collections/#{collection}/documents/#{id}?ignore_not_found=true")
|
|
238
|
+
parse_json_response!(response, context: "delete document #{collection}/#{id}")
|
|
346
239
|
|
|
347
|
-
{ _index
|
|
348
|
-
rescue StandardError => e
|
|
349
|
-
{ _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
|
|
240
|
+
{ "_index" => collection, "_id" => id.to_s, "result" => "deleted" }
|
|
350
241
|
ensure
|
|
351
242
|
response&.close
|
|
352
243
|
end
|
|
353
244
|
|
|
354
245
|
def execute_document_exists?(collection, id)
|
|
355
|
-
response =
|
|
356
|
-
response
|
|
357
|
-
rescue StandardError
|
|
358
|
-
false
|
|
246
|
+
response = get_request("/collections/#{collection}/documents/#{id}?include_fields=id")
|
|
247
|
+
exists_response?(response, context: "document exists #{collection}/#{id}")
|
|
359
248
|
ensure
|
|
360
249
|
response&.close
|
|
361
250
|
end
|
|
@@ -364,7 +253,6 @@ module Noiseless
|
|
|
364
253
|
response = get_request("/health")
|
|
365
254
|
health_data = JSON.parse(response.read)
|
|
366
255
|
|
|
367
|
-
# Convert Typesense health format to match expected format
|
|
368
256
|
{
|
|
369
257
|
cluster_name: "typesense",
|
|
370
258
|
status: health_data["ok"] ? "green" : "red",
|
|
@@ -392,30 +280,18 @@ module Noiseless
|
|
|
392
280
|
|
|
393
281
|
def default_headers
|
|
394
282
|
headers = super
|
|
395
|
-
|
|
396
|
-
# Add Typesense API key if configured
|
|
397
|
-
if @connection_params && @connection_params[:api_key]
|
|
398
|
-
headers << ["X-TYPESENSE-API-KEY",
|
|
399
|
-
@connection_params[:api_key]]
|
|
400
|
-
end
|
|
401
|
-
|
|
283
|
+
headers << ["X-TYPESENSE-API-KEY", @connection_params[:api_key]] if @connection_params&.dig(:api_key)
|
|
402
284
|
headers
|
|
403
285
|
end
|
|
404
286
|
|
|
405
|
-
# rubocop:disable-next Lint/DuplicateBranch
|
|
406
287
|
def map_type_to_typesense(elasticsearch_type)
|
|
407
|
-
# Map Elasticsearch types to Typesense types
|
|
408
288
|
case elasticsearch_type
|
|
409
|
-
when "
|
|
410
|
-
|
|
411
|
-
when "
|
|
412
|
-
|
|
413
|
-
when "
|
|
414
|
-
|
|
415
|
-
when "boolean"
|
|
416
|
-
"bool"
|
|
417
|
-
else
|
|
418
|
-
"string" # Default to string for unknown types
|
|
289
|
+
when "long", "integer", "short", "byte", "date" then "int64"
|
|
290
|
+
when "double", "float", "half_float", "scaled_float" then "float"
|
|
291
|
+
when "boolean" then "bool"
|
|
292
|
+
when "dense_vector", "knn_vector" then "float[]"
|
|
293
|
+
when "keyword" then "string[]"
|
|
294
|
+
else "string"
|
|
419
295
|
end
|
|
420
296
|
end
|
|
421
297
|
end
|