opensearch-sugar 1.0.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 +7 -0
- data/.agents/skills/diataxis/SKILL.md +142 -0
- data/.agents/skills/diataxis/references/examples.md +420 -0
- data/.agents/skills/diataxis/references/explanation-template.md +96 -0
- data/.agents/skills/diataxis/references/framework.md +400 -0
- data/.agents/skills/diataxis/references/how-to-guide-template.md +105 -0
- data/.agents/skills/diataxis/references/reference-template.md +110 -0
- data/.agents/skills/diataxis/references/tutorial-template.md +101 -0
- data/.agents/skills/diataxis/scripts/generate_index.py +139 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/AGENTS.md +120 -0
- data/CHANGELOG.md +5 -0
- data/Dockerfile.opensearch +4 -0
- data/Increase_Coverage.md +311 -0
- data/README.md +143 -0
- data/Rakefile +27 -0
- data/Steepfile +23 -0
- data/adrs/ADR-000-template.md +87 -0
- data/adrs/ADR-001-simpledelegator-for-client.md +138 -0
- data/adrs/ADR-002-facade-pattern-for-index.md +126 -0
- data/adrs/ADR-003-repository-pattern-for-models.md +148 -0
- data/adrs/ADR-004-integration-tests-no-mocking.md +91 -0
- data/adrs/ADR-005-exceptions-over-result-objects.md +107 -0
- data/adrs/ADR-006-ssl-on-by-default.md +95 -0
- data/adrs/ADR-007-selective-sugar-surface.md +118 -0
- data/adrs/ADR-008-integration-test-design.md +178 -0
- data/compose.yml +2 -0
- data/compose_opensearch.yml +31 -0
- data/docs/HOWTO.md +844 -0
- data/docs/REFERENCE.md +725 -0
- data/docs/TUTORIAL.md +327 -0
- data/docs/alias-api-design-notes.md +119 -0
- data/lib/opensearch/sugar/client.rb +300 -0
- data/lib/opensearch/sugar/index/include/utilities.rb +6 -0
- data/lib/opensearch/sugar/index.rb +339 -0
- data/lib/opensearch/sugar/models.rb +209 -0
- data/lib/opensearch/sugar/version.rb +8 -0
- data/lib/opensearch/sugar.rb +61 -0
- data/old_docs/DELEGATED_METHODS_ANALYSIS.md +361 -0
- data/old_docs/EXPLANATION.md +685 -0
- data/old_docs/README.md +155 -0
- data/old_docs/docs/CLI-PROPOSAL.md +257 -0
- data/old_docs/docs/HOWTO.md +798 -0
- data/old_docs/docs/REFERENCE.md +901 -0
- data/old_docs/docs/TUTORIAL.md +493 -0
- data/sig/opensearch/sugar.rbs +162 -0
- metadata +240 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "opensearch-ruby"
|
|
5
|
+
require_relative "models"
|
|
6
|
+
require "httpx/adapters/faraday"
|
|
7
|
+
|
|
8
|
+
module OpenSearch::Sugar
|
|
9
|
+
# A wrapper around +OpenSearch::Client+ (via +SimpleDelegator+) that adds
|
|
10
|
+
# index management helpers and object-oriented access to indices and ML models.
|
|
11
|
+
#
|
|
12
|
+
# All methods of the underlying +OpenSearch::Client+ are available directly on
|
|
13
|
+
# this object via delegation. Sugar-specific additions are documented below.
|
|
14
|
+
#
|
|
15
|
+
# @example Connect and work with an index
|
|
16
|
+
# client = OpenSearch::Sugar::Client.new
|
|
17
|
+
# index = client["my_index"]
|
|
18
|
+
# index.count #=> 0
|
|
19
|
+
#
|
|
20
|
+
# @see OpenSearch::Sugar::Index
|
|
21
|
+
# @see OpenSearch::Sugar::Models
|
|
22
|
+
class Client < SimpleDelegator
|
|
23
|
+
# Creates a new raw OpenSearch client instance
|
|
24
|
+
#
|
|
25
|
+
# @param args [Array] Arguments to pass to the OpenSearch::Client constructor
|
|
26
|
+
# @param kwargs [Hash] Keyword arguments to pass to the OpenSearch::Client constructor
|
|
27
|
+
# @return [OpenSearch::Client] A new raw client instance
|
|
28
|
+
def self.raw_client(*args, **kwargs)
|
|
29
|
+
::OpenSearch::Client.new(*args, **kwargs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The underlying raw +OpenSearch::Client+ instance, bypassing the Sugar wrapper.
|
|
33
|
+
# @return [OpenSearch::Client]
|
|
34
|
+
attr_reader :raw_client
|
|
35
|
+
|
|
36
|
+
# The {OpenSearch::Sugar::Models} instance for ML model management on this cluster.
|
|
37
|
+
# @return [OpenSearch::Sugar::Models]
|
|
38
|
+
attr_reader :models
|
|
39
|
+
|
|
40
|
+
# Creates a new {OpenSearch::Sugar::Client}.
|
|
41
|
+
#
|
|
42
|
+
# Falls back to environment variables if keyword arguments are omitted:
|
|
43
|
+
# - +host+ — +OPENSEARCH_URL+ → +OPENSEARCH_HOST+ → +"https://localhost:9000"+
|
|
44
|
+
# - +user+ — +OPENSEARCH_USER+ → +"admin"+
|
|
45
|
+
# - +password+ — +OPENSEARCH_PASSWORD+ → +OPENSEARCH_INITIAL_ADMIN_PASSWORD+
|
|
46
|
+
#
|
|
47
|
+
# All keyword arguments are merged with {#default_args}, with explicit kwargs taking
|
|
48
|
+
# precedence.
|
|
49
|
+
#
|
|
50
|
+
# @param host [String] OpenSearch base URL
|
|
51
|
+
# @param kwargs [Hash] Additional keyword arguments forwarded to +OpenSearch::Client.new+
|
|
52
|
+
# @see https://github.com/opensearch-project/opensearch-ruby OpenSearch Ruby client
|
|
53
|
+
# @example Connect using environment variables
|
|
54
|
+
# client = OpenSearch::Sugar::Client.new
|
|
55
|
+
# @example Connect to a specific host
|
|
56
|
+
# client = OpenSearch::Sugar::Client.new(host: "https://search.example.com:9200")
|
|
57
|
+
def initialize(host: ENV["OPENSEARCH_URL"] || ENV["OPENSEARCH_HOST"] || "https://localhost:9000", **kwargs)
|
|
58
|
+
kwargs[:host] = host
|
|
59
|
+
args = default_args.merge(kwargs)
|
|
60
|
+
@raw_client = self.class.raw_client(**args)
|
|
61
|
+
__setobj__(@raw_client)
|
|
62
|
+
@models = Models.new(self)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the default connection arguments used when building the underlying client.
|
|
66
|
+
#
|
|
67
|
+
# Values are drawn from environment variables where available:
|
|
68
|
+
# - +:user+ — +OPENSEARCH_USER+ (default: +"admin"+)
|
|
69
|
+
# - +:password+ — +OPENSEARCH_PASSWORD+ or +OPENSEARCH_INITIAL_ADMIN_PASSWORD+
|
|
70
|
+
# - +:host+ — +OPENSEARCH_URL+ (default: +"https://localhost:9000"+)
|
|
71
|
+
# - +:retry_on_failure+ — 5
|
|
72
|
+
# - +:request_timeout+ — 5 seconds
|
|
73
|
+
# - +:log+ — +true+
|
|
74
|
+
# - +:trace+ — +false+
|
|
75
|
+
# - +:transport_options+ — SSL verification disabled
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash{Symbol => Object}] Default keyword arguments for +OpenSearch::Client.new+
|
|
78
|
+
def default_args
|
|
79
|
+
{
|
|
80
|
+
user: ENV["OPENSEARCH_USER"] || "admin",
|
|
81
|
+
password: ENV["OPENSEARCH_PASSWORD"] || ENV["OPENSEARCH_INITIAL_ADMIN_PASSWORD"],
|
|
82
|
+
host: ENV["OPENSEARCH_URL"] || "https://localhost:9000",
|
|
83
|
+
retry_on_failure: 5,
|
|
84
|
+
request_timeout: 5,
|
|
85
|
+
log: false,
|
|
86
|
+
trace: false,
|
|
87
|
+
transport_options: {ssl: {verify: false}}
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Sets a cluster-wide log level via the OpenSearch dynamic settings API.
|
|
92
|
+
#
|
|
93
|
+
# Writes a persistent cluster setting, so the change survives restarts.
|
|
94
|
+
#
|
|
95
|
+
# @param logger [String] The logger name to configure (default: +"logger._root"+)
|
|
96
|
+
# @param level [String] Log level — one of +"trace"+, +"debug"+, +"info"+, +"warn"+, +"error"+ (default: +"warn"+)
|
|
97
|
+
# @return [Hash] The OpenSearch response
|
|
98
|
+
# @see https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/logs/ OpenSearch logging docs
|
|
99
|
+
# @example Silence most cluster noise
|
|
100
|
+
# client.set_log_level(level: "error")
|
|
101
|
+
# @example Set a specific logger
|
|
102
|
+
# client.set_log_level(logger: "logger.org.opensearch.discovery", level: "debug")
|
|
103
|
+
def set_log_level(logger: "logger._root", level: "warn")
|
|
104
|
+
http.put("_cluster/settings", body: {persistent: {logger.to_s => level.to_s}})
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Checks if an index exists in OpenSearch
|
|
108
|
+
#
|
|
109
|
+
# @param name [String] The name of the index to check
|
|
110
|
+
# @return [Boolean] True if the index exists, false otherwise
|
|
111
|
+
def has_index?(name)
|
|
112
|
+
indices.exists?(index: name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the names of all non-system indices in the cluster.
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<String>] Index names
|
|
118
|
+
# @example
|
|
119
|
+
# client.index_names #=> ["products", "orders", "users"]
|
|
120
|
+
def index_names
|
|
121
|
+
cluster.state["metadata"]["indices"].keys
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Retrieves an index by name
|
|
125
|
+
#
|
|
126
|
+
# @param index_name [String] The name of the index to retrieve
|
|
127
|
+
# @return [OpenSearch::Sugar::Index] The index instance
|
|
128
|
+
def [](index_name)
|
|
129
|
+
Index.open(client: self, name: index_name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Opens an existing index or creates it if it does not exist.
|
|
133
|
+
#
|
|
134
|
+
# @param index_name [String] The name of the index
|
|
135
|
+
# @return [OpenSearch::Sugar::Index]
|
|
136
|
+
def open_or_create_index(index_name)
|
|
137
|
+
Index.open(client: self, name: index_name)
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
Index.create(client: self, name: index_name)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Deletes an index by name.
|
|
143
|
+
#
|
|
144
|
+
# @param index_name [String] The name of the index to delete
|
|
145
|
+
# @return [Hash] The OpenSearch acknowledgement response
|
|
146
|
+
# @raise [OpenSearch::Transport::Transport::Errors::NotFound] if the index does not exist
|
|
147
|
+
def delete_index!(index_name)
|
|
148
|
+
indices.delete(index: index_name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Uploads settings to an OpenSearch index
|
|
152
|
+
#
|
|
153
|
+
# This method will:
|
|
154
|
+
# 1. Close the index
|
|
155
|
+
# 2. Apply the new settings
|
|
156
|
+
# 3. Reopen the index
|
|
157
|
+
#
|
|
158
|
+
# @param settings [Hash] The settings to upload
|
|
159
|
+
# @param index_name [String] The name of the index to update
|
|
160
|
+
# @return [Hash] The response from OpenSearch on success
|
|
161
|
+
# @raise [OpenSearch::Sugar::Error] If the settings update fails
|
|
162
|
+
# @example
|
|
163
|
+
# settings = {
|
|
164
|
+
# settings: {
|
|
165
|
+
# analysis: {
|
|
166
|
+
# analyzer: {
|
|
167
|
+
# my_analyzer: {
|
|
168
|
+
# type: "custom",
|
|
169
|
+
# tokenizer: "standard"
|
|
170
|
+
# }
|
|
171
|
+
# }
|
|
172
|
+
# }
|
|
173
|
+
# }
|
|
174
|
+
# }
|
|
175
|
+
# client.update_settings(settings, "my_index")
|
|
176
|
+
def update_settings(settings, index_name)
|
|
177
|
+
# Extract the actual OpenSearch settings from our enhanced settings object
|
|
178
|
+
opensearch_settings = if settings.keys.map(&:to_s) == ["settings"]
|
|
179
|
+
settings.values.first
|
|
180
|
+
else
|
|
181
|
+
settings
|
|
182
|
+
end
|
|
183
|
+
indices.close(index: index_name)
|
|
184
|
+
indices.put_settings(index: index_name, body: opensearch_settings)
|
|
185
|
+
indices.open(index: index_name)
|
|
186
|
+
rescue => e
|
|
187
|
+
reopen_index(index_name)
|
|
188
|
+
raise OpenSearch::Sugar::Error, "Failed to update settings for #{index_name}: #{e.message}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Uploads mappings to an OpenSearch index
|
|
192
|
+
#
|
|
193
|
+
# This method will:
|
|
194
|
+
# 1. Close the index
|
|
195
|
+
# 2. Apply the new mappings
|
|
196
|
+
# 3. Reopen the index
|
|
197
|
+
#
|
|
198
|
+
# @param mappings [Hash] The mappings to upload
|
|
199
|
+
# @param index_name [String] The name of the index to update
|
|
200
|
+
# @return [Hash] The response from OpenSearch on success
|
|
201
|
+
# @raise [OpenSearch::Sugar::Error] If the mappings update fails
|
|
202
|
+
# @example
|
|
203
|
+
# mappings = {
|
|
204
|
+
# mappings: {
|
|
205
|
+
# properties: {
|
|
206
|
+
# title: { type: "text" },
|
|
207
|
+
# description: { type: "text" },
|
|
208
|
+
# created_at: { type: "date" }
|
|
209
|
+
# }
|
|
210
|
+
# }
|
|
211
|
+
# }
|
|
212
|
+
# client.update_mappings(mappings, "my_index")
|
|
213
|
+
def update_mappings(mappings, index_name)
|
|
214
|
+
# Extract the actual OpenSearch settings from our enhanced settings object
|
|
215
|
+
opensearch_mappings = if mappings.keys.map(&:to_s) == ["mappings"]
|
|
216
|
+
mappings.values.first
|
|
217
|
+
else
|
|
218
|
+
mappings
|
|
219
|
+
end
|
|
220
|
+
indices.close(index: index_name)
|
|
221
|
+
indices.put_mapping(index: index_name, body: opensearch_mappings)
|
|
222
|
+
indices.open(index: index_name)
|
|
223
|
+
rescue => e
|
|
224
|
+
reopen_index(index_name)
|
|
225
|
+
raise OpenSearch::Sugar::Error, "Failed to update mappings for #{index_name}: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Tests a custom "transient" analyzer defined inline by its components, without
|
|
229
|
+
# requiring the analyzer to be registered on any index. This is useful for
|
|
230
|
+
# prototyping and iterating on analyzer configurations before committing them to
|
|
231
|
+
# index settings.
|
|
232
|
+
#
|
|
233
|
+
# Sends the definition directly to the cluster-level +/_analyze+ endpoint, so no
|
|
234
|
+
# index is involved. You must supply at least a +tokenizer+. The +filter+ (token
|
|
235
|
+
# filters) and +char_filter+ (character filters) keys are optional.
|
|
236
|
+
#
|
|
237
|
+
# @param text [String] The text to analyze
|
|
238
|
+
# @param tokenizer [String] The tokenizer to use (e.g. +"standard"+, +"keyword"+)
|
|
239
|
+
# @param filter [Array<String, Hash>] Optional token filters to apply in order
|
|
240
|
+
# (e.g. +["lowercase", "asciifolding"]+)
|
|
241
|
+
# @param char_filter [Array<String, Hash>] Optional character filters to apply
|
|
242
|
+
# before tokenization (e.g. +["html_strip"]+)
|
|
243
|
+
# @return [Array<String, Array<String>>] A list of tokens produced by the transient
|
|
244
|
+
# analyzer. If multiple tokens share a position in the token stream (e.g. from a
|
|
245
|
+
# synonym filter), they are grouped as a nested Array.
|
|
246
|
+
# @raise [ArgumentError] If +tokenizer+ is nil or empty
|
|
247
|
+
# @see OpenSearch::Sugar::Index#test_analyzer_by_name For testing a named analyzer
|
|
248
|
+
# that is already registered on a specific index.
|
|
249
|
+
# @see OpenSearch::Sugar::Index#test_analyzer_by_fieldname For testing the analyzer
|
|
250
|
+
# configured on a specific index field.
|
|
251
|
+
# @see https://docs.opensearch.org/latest/api-reference/analyze-apis/#apply-a-custom-transient-analyzer
|
|
252
|
+
# OpenSearch Analyze API — Apply a custom transient analyzer
|
|
253
|
+
# @example Basic transient analyzer
|
|
254
|
+
# client.test_analyzer_by_definition(
|
|
255
|
+
# text: "Hello, World!",
|
|
256
|
+
# tokenizer: "standard",
|
|
257
|
+
# filter: ["lowercase"]
|
|
258
|
+
# )
|
|
259
|
+
# #=> ["hello", "world"]
|
|
260
|
+
# @example With a character filter
|
|
261
|
+
# client.test_analyzer_by_definition(
|
|
262
|
+
# text: "<b>Hello</b>",
|
|
263
|
+
# tokenizer: "standard",
|
|
264
|
+
# char_filter: ["html_strip"],
|
|
265
|
+
# filter: ["lowercase"]
|
|
266
|
+
# )
|
|
267
|
+
# #=> ["hello"]
|
|
268
|
+
def test_analyzer_by_definition(text:, tokenizer:, filter: [], char_filter: [])
|
|
269
|
+
raise ArgumentError, "tokenizer cannot be nil or empty" if tokenizer.nil? || tokenizer.to_s.empty?
|
|
270
|
+
|
|
271
|
+
body = {tokenizer: tokenizer, text: text}
|
|
272
|
+
body[:filter] = filter if filter && !filter.empty?
|
|
273
|
+
body[:char_filter] = char_filter if char_filter && !char_filter.empty?
|
|
274
|
+
|
|
275
|
+
response = self.indices.analyze(body: body)
|
|
276
|
+
|
|
277
|
+
tokens = response["tokens"]
|
|
278
|
+
tokens.each_with_index.map do |token, i|
|
|
279
|
+
if i > 0 && token["position"] == tokens[i - 1]["position"]
|
|
280
|
+
[token["token"]]
|
|
281
|
+
else
|
|
282
|
+
token["token"]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
# Helper method to safely reopen an index if it's closed
|
|
290
|
+
#
|
|
291
|
+
# @param index_name [String] The name of the index to reopen
|
|
292
|
+
# @return [void]
|
|
293
|
+
def reopen_index(index_name)
|
|
294
|
+
indices.open(index: index_name)
|
|
295
|
+
rescue => open_error
|
|
296
|
+
# Just log the error without raising
|
|
297
|
+
warn "Warning: Failed to reopen index #{index_name}: #{open_error.message}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "client"
|
|
5
|
+
|
|
6
|
+
module OpenSearch::Sugar
|
|
7
|
+
# Represents a single OpenSearch index and provides methods for CRUD operations
|
|
8
|
+
# on documents, settings, mappings, aliases, and text analysis.
|
|
9
|
+
#
|
|
10
|
+
# Instances are obtained via {OpenSearch::Sugar::Client#[]} or the class-level
|
|
11
|
+
# factory methods {.open} and {.create} — do not call +new+ directly.
|
|
12
|
+
#
|
|
13
|
+
# @example Open an existing index and search
|
|
14
|
+
# index = client["products"]
|
|
15
|
+
# index.count #=> 1500
|
|
16
|
+
#
|
|
17
|
+
# @example Create a new index and add a document
|
|
18
|
+
# index = OpenSearch::Sugar::Index.create(client: client, name: "events", knn: false)
|
|
19
|
+
# index.index_document({ title: "Launch" }, "evt-001")
|
|
20
|
+
class Index
|
|
21
|
+
# The {OpenSearch::Sugar::Client} this index belongs to.
|
|
22
|
+
# @return [OpenSearch::Sugar::Client]
|
|
23
|
+
attr_accessor :client
|
|
24
|
+
|
|
25
|
+
# The name of this index.
|
|
26
|
+
# @return [String]
|
|
27
|
+
attr_accessor :name
|
|
28
|
+
|
|
29
|
+
# Opens an existing index by name.
|
|
30
|
+
#
|
|
31
|
+
# @param client [OpenSearch::Sugar::Client] The client to use
|
|
32
|
+
# @param name [String] The name of the index to open
|
|
33
|
+
# @return [OpenSearch::Sugar::Index]
|
|
34
|
+
# @raise [ArgumentError] If the index does not exist in the cluster
|
|
35
|
+
# @example
|
|
36
|
+
# index = OpenSearch::Sugar::Index.open(client: client, name: "products")
|
|
37
|
+
def self.open(client:, name:)
|
|
38
|
+
raise ArgumentError, "Index #{name} not found" unless client.has_index?(name)
|
|
39
|
+
new(client: client, name: name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Creates a new index.
|
|
43
|
+
#
|
|
44
|
+
# @param client [OpenSearch::Sugar::Client] The client to use
|
|
45
|
+
# @param name [String] The name of the index to create
|
|
46
|
+
# @param knn [Boolean] Whether to enable KNN (k-nearest-neighbor) vector search (default: +true+)
|
|
47
|
+
# @return [OpenSearch::Sugar::Index]
|
|
48
|
+
# @raise [ArgumentError] If an index with the given name already exists
|
|
49
|
+
# @see https://opensearch.org/docs/latest/search-plugins/knn/index/ OpenSearch KNN docs
|
|
50
|
+
# @example Create a plain index
|
|
51
|
+
# index = OpenSearch::Sugar::Index.create(client: client, name: "products", knn: false)
|
|
52
|
+
# @example Create a KNN-enabled index for vector search
|
|
53
|
+
# index = OpenSearch::Sugar::Index.create(client: client, name: "embeddings")
|
|
54
|
+
def self.create(client:, name:, knn: true)
|
|
55
|
+
raise ArgumentError.new("Index #{name} already exists") if client.has_index?(name)
|
|
56
|
+
client.indices.create(index: name, body: {settings: {index: {knn: knn}}})
|
|
57
|
+
new(client: client, name: name)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Applies new settings to this index.
|
|
61
|
+
#
|
|
62
|
+
# Delegates to {OpenSearch::Sugar::Client#update_settings}, which closes the index,
|
|
63
|
+
# applies the settings, then reopens it.
|
|
64
|
+
#
|
|
65
|
+
# @param settings [Hash] Settings hash, with or without a top-level +:settings+ key
|
|
66
|
+
# @return [Hash] The OpenSearch response
|
|
67
|
+
# @raise [OpenSearch::Sugar::Error] If the settings update fails
|
|
68
|
+
# @example
|
|
69
|
+
# index.update_settings(
|
|
70
|
+
# settings: { analysis: { analyzer: { my_analyzer: { type: "standard" } } } }
|
|
71
|
+
# )
|
|
72
|
+
def update_settings(settings)
|
|
73
|
+
client.update_settings(settings, name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the current settings for this index.
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash] The OpenSearch settings response, keyed by index name
|
|
79
|
+
# @example
|
|
80
|
+
# index.settings
|
|
81
|
+
# #=> { "products" => { "settings" => { "index" => { "number_of_shards" => "1", ... } } } }
|
|
82
|
+
def settings
|
|
83
|
+
client.indices.get_settings(index: name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Applies new field mappings to this index.
|
|
87
|
+
#
|
|
88
|
+
# Delegates to {OpenSearch::Sugar::Client#update_mappings}, which closes the index,
|
|
89
|
+
# applies the mappings, then reopens it.
|
|
90
|
+
#
|
|
91
|
+
# @param mappings [Hash] Mappings hash, with or without a top-level +:mappings+ key
|
|
92
|
+
# @return [Hash] The OpenSearch response
|
|
93
|
+
# @raise [OpenSearch::Sugar::Error] If the mappings update fails
|
|
94
|
+
# @example
|
|
95
|
+
# index.update_mappings(
|
|
96
|
+
# mappings: { properties: { title: { type: "text" }, price: { type: "float" } } }
|
|
97
|
+
# )
|
|
98
|
+
def update_mappings(mappings)
|
|
99
|
+
client.update_mappings(mappings, name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the current field mappings for this index.
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] The OpenSearch mappings response, keyed by index name
|
|
105
|
+
# @example
|
|
106
|
+
# index.mappings
|
|
107
|
+
# #=> { "products" => { "mappings" => { "properties" => { "title" => { "type" => "text" } } } } }
|
|
108
|
+
def mappings
|
|
109
|
+
client.indices.get_mapping(index: name)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Permanently deletes this index from the cluster.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] The OpenSearch acknowledgement response
|
|
115
|
+
# @raise [OpenSearch::Transport::Transport::Errors::NotFound] If the index does not exist
|
|
116
|
+
# @example
|
|
117
|
+
# index.delete!
|
|
118
|
+
def delete!
|
|
119
|
+
client.indices.delete(index: name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Forces a refresh of this index, making all recently indexed documents
|
|
123
|
+
# immediately visible to searches. Useful in tests and after bulk indexing.
|
|
124
|
+
#
|
|
125
|
+
# @return [Hash] The OpenSearch response
|
|
126
|
+
def refresh
|
|
127
|
+
client.indices.refresh(index: name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns the number of documents in this index.
|
|
131
|
+
#
|
|
132
|
+
# @return [Integer] Document count
|
|
133
|
+
# @example
|
|
134
|
+
# index.count #=> 42
|
|
135
|
+
def count
|
|
136
|
+
response = client.count(index: name)
|
|
137
|
+
response["count"].to_i
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Get a (potentially empty) list of aliases of this index
|
|
141
|
+
# @return [Array<String>] The aliases for this index
|
|
142
|
+
# @example
|
|
143
|
+
# index.aliases #=> ["products_v1", "products_current"]
|
|
144
|
+
def aliases
|
|
145
|
+
response = client.indices.get_alias(index: name)
|
|
146
|
+
response.dig(name, "aliases")&.keys || []
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Create an alias for this index with the given name
|
|
150
|
+
# @param alias_name [String] the new alias for the index
|
|
151
|
+
# @return [Array<String>] the complete list of aliases for this index after adding the new one
|
|
152
|
+
# @raise [OpenSearch::Transport::Transport::Errors::BadRequest] If the alias already exists on another index
|
|
153
|
+
# @example
|
|
154
|
+
# index.create_alias("products_current")
|
|
155
|
+
# #=> ["products_current"]
|
|
156
|
+
def create_alias(alias_name)
|
|
157
|
+
client.indices.put_alias(index: name, name: alias_name)
|
|
158
|
+
aliases
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get a list of all named analyzers available in this index for use when indexing
|
|
162
|
+
# Include those defined at the cluster level as well as those defined for this
|
|
163
|
+
# particular index
|
|
164
|
+
# @return [Array<String>] List of analyzer names available for this index
|
|
165
|
+
def all_available_analyzers
|
|
166
|
+
settings_response = settings
|
|
167
|
+
index_analyzers = settings_response.dig(name, "settings", "index", "analysis", "analyzer")&.keys || []
|
|
168
|
+
cluster_analyzers = client.cluster.get_settings.dig("persistent", "index", "analysis", "analyzer")&.keys || []
|
|
169
|
+
|
|
170
|
+
(index_analyzers + cluster_analyzers).uniq
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Alias for {#all_available_analyzers}.
|
|
174
|
+
# @return [Array<String>]
|
|
175
|
+
alias_method :analyzers, :all_available_analyzers
|
|
176
|
+
|
|
177
|
+
# Return the tokens that would be created by putting the provided string into the
|
|
178
|
+
# given analyzer, which must already be registered on this index.
|
|
179
|
+
# @param analyzer [String] Name of the analyzer (must be defined on this index)
|
|
180
|
+
# @param text [String] the text to analyze
|
|
181
|
+
# @return [Array<String, Array<String>>] A list of tokens produced by the
|
|
182
|
+
# targeted analyzer. If multiple tokens exist at the same point in the token
|
|
183
|
+
# stream, they are grouped as a nested Array.
|
|
184
|
+
# @raise [ArgumentError] If the analyzer is not defined on this index
|
|
185
|
+
# @see OpenSearch::Sugar::Client#test_analyzer_by_definition For testing an analyzer
|
|
186
|
+
# defined inline by its components, without registering it on an index first.
|
|
187
|
+
# @example
|
|
188
|
+
# index.test_analyzer_by_name(analyzer: "my_analyzer", text: "Hello, world!")
|
|
189
|
+
# #=> ["hello", "world"]
|
|
190
|
+
def test_analyzer_by_name(analyzer:, text:)
|
|
191
|
+
# Check if analyzer exists in index settings
|
|
192
|
+
settings_response = settings
|
|
193
|
+
unless settings_response.dig(name, "settings", "index", "analysis", "analyzer", analyzer)
|
|
194
|
+
raise ArgumentError, "Analyzer '#{analyzer}' does not exist in index '#{name}'"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Analyze the text
|
|
198
|
+
response = client.indices.analyze(
|
|
199
|
+
index: name,
|
|
200
|
+
body: {
|
|
201
|
+
analyzer: analyzer,
|
|
202
|
+
text: text
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Process tokens from response, grouping same-position tokens as arrays
|
|
207
|
+
tokens = response["tokens"]
|
|
208
|
+
tokens.each_with_index.map do |token, i|
|
|
209
|
+
if i > 0 && token["position"] == tokens[i - 1]["position"]
|
|
210
|
+
[token["token"]]
|
|
211
|
+
else
|
|
212
|
+
token["token"]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
alias_method :analyze_text, :test_analyzer_by_name
|
|
218
|
+
|
|
219
|
+
# Analyze text using the analyzer configured for the given field mapping.
|
|
220
|
+
#
|
|
221
|
+
# Looks up the analyzer from the field's mapping definition, then delegates to
|
|
222
|
+
# {#test_analyzer_by_name}. Useful when you want to match the exact tokenization that
|
|
223
|
+
# OpenSearch applies at index time.
|
|
224
|
+
#
|
|
225
|
+
# @param field [String] The field name whose analyzer should be used
|
|
226
|
+
# @param text [String] The text to analyze
|
|
227
|
+
# @return [Array<String, Array<String>>] Tokens produced by the field's analyzer
|
|
228
|
+
# @raise [ArgumentError] If the field does not exist in this index's mappings
|
|
229
|
+
# @raise [ArgumentError] If the field has no analyzer configured
|
|
230
|
+
# @see OpenSearch::Sugar::Client#test_analyzer_by_definition For testing an analyzer
|
|
231
|
+
# defined inline by its components, without registering it on an index first.
|
|
232
|
+
# @example
|
|
233
|
+
# index.test_analyzer_by_fieldname(field: "title", text: "Running fast")
|
|
234
|
+
# #=> ["run", "fast"]
|
|
235
|
+
def test_analyzer_by_fieldname(field:, text:)
|
|
236
|
+
mappings_response = mappings
|
|
237
|
+
field_mapping = mappings_response.dig(name, "mappings", "properties", field)
|
|
238
|
+
raise ArgumentError, "Field '#{field}' does not exist in index '#{name}'" unless field_mapping
|
|
239
|
+
|
|
240
|
+
analyzer = field_mapping["analyzer"]
|
|
241
|
+
raise ArgumentError, "No analyzer specified for field '#{field}'" unless analyzer
|
|
242
|
+
|
|
243
|
+
test_analyzer_by_name(analyzer: analyzer, text: text)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
alias_method :analyze_text_field, :test_analyzer_by_fieldname
|
|
247
|
+
|
|
248
|
+
# Deletes the document with the given ID from this index
|
|
249
|
+
#
|
|
250
|
+
# @param id [String] The ID of the document to delete
|
|
251
|
+
# @return [Hash] The response from OpenSearch containing deletion status
|
|
252
|
+
# @raise [ArgumentError] If the document ID is nil or empty
|
|
253
|
+
def delete_by_id(id)
|
|
254
|
+
raise ArgumentError, "Document ID cannot be nil or empty" if id.nil? || id.empty?
|
|
255
|
+
client.delete(index: name, id: id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Delete all documents from this index by executing a delete_by_query with match_all query.
|
|
259
|
+
# @return [Integer] The number of documents that were deleted
|
|
260
|
+
# @example
|
|
261
|
+
# index = client["my_index"]
|
|
262
|
+
# deleted_count = index.clear! # Deletes all documents and returns count
|
|
263
|
+
def clear!
|
|
264
|
+
response = client.delete_by_query(
|
|
265
|
+
index: name,
|
|
266
|
+
body: {
|
|
267
|
+
query: {
|
|
268
|
+
match_all: {}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
response["deleted"].to_i
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Index a single document into this index.
|
|
276
|
+
#
|
|
277
|
+
# This method is intentionally simple and inefficient — it issues one HTTP request
|
|
278
|
+
# per document. For bulk loading, use the raw +client.bulk+ API instead.
|
|
279
|
+
#
|
|
280
|
+
# @todo Replace with a bulk-API implementation for large-scale use.
|
|
281
|
+
#
|
|
282
|
+
# @param doc [Hash] The document body to index
|
|
283
|
+
# @param id [String] The document ID (_id in OpenSearch)
|
|
284
|
+
# @return [Hash] The OpenSearch response
|
|
285
|
+
def index_document(doc, id)
|
|
286
|
+
# TODO: inefficient for large-scale use; implement bulk upload API
|
|
287
|
+
client.index(index: name, id: id, body: doc)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Index all documents from a JSONL (newline-delimited JSON) file or IO-like object.
|
|
291
|
+
#
|
|
292
|
+
# Each line must be a valid JSON object. The value of +id_field+ in each document
|
|
293
|
+
# is used as the document ID. Raises +ArgumentError+ if any line is missing the
|
|
294
|
+
# specified field.
|
|
295
|
+
#
|
|
296
|
+
# Accepts a file path (String) or any IO-like object (e.g. +File+, +StringIO+),
|
|
297
|
+
# which makes it straightforward to test without touching the filesystem.
|
|
298
|
+
#
|
|
299
|
+
# This method is intentionally simple and inefficient — it calls +#index_document+
|
|
300
|
+
# once per line. For bulk loading, use the raw +client.bulk+ API instead.
|
|
301
|
+
#
|
|
302
|
+
# @todo Replace with a bulk-API implementation for large-scale use.
|
|
303
|
+
#
|
|
304
|
+
# @param source [String, #each_line] A file path or an IO-like object
|
|
305
|
+
# @param id_field [Symbol, String] The key in each document to use as the document ID
|
|
306
|
+
# @return [void]
|
|
307
|
+
# @raise [ArgumentError] If a document does not contain the specified +id_field+
|
|
308
|
+
def index_jsonl_file(source, id_field:)
|
|
309
|
+
# TODO: inefficient for large-scale use; implement bulk upload API
|
|
310
|
+
io = source.is_a?(String) ? File.open(source) : source
|
|
311
|
+
io.each_line do |line|
|
|
312
|
+
doc = JSON.parse(line, symbolize_names: true)
|
|
313
|
+
id = doc.fetch(id_field.to_sym) {
|
|
314
|
+
raise ArgumentError, "id_field :#{id_field} not found in document: #{line.chomp}"
|
|
315
|
+
}
|
|
316
|
+
index_document(doc, id.to_s)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
private
|
|
321
|
+
|
|
322
|
+
def initialize(client:, name:)
|
|
323
|
+
@client = client
|
|
324
|
+
@name = name
|
|
325
|
+
# client.indices.create(index: name, body: default_index_body)
|
|
326
|
+
# client[name]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def default_index_body
|
|
330
|
+
{
|
|
331
|
+
settings: {
|
|
332
|
+
index: {
|
|
333
|
+
number_of_shards: 2
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|