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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.agents/skills/diataxis/SKILL.md +142 -0
  3. data/.agents/skills/diataxis/references/examples.md +420 -0
  4. data/.agents/skills/diataxis/references/explanation-template.md +96 -0
  5. data/.agents/skills/diataxis/references/framework.md +400 -0
  6. data/.agents/skills/diataxis/references/how-to-guide-template.md +105 -0
  7. data/.agents/skills/diataxis/references/reference-template.md +110 -0
  8. data/.agents/skills/diataxis/references/tutorial-template.md +101 -0
  9. data/.agents/skills/diataxis/scripts/generate_index.py +139 -0
  10. data/.rspec +3 -0
  11. data/.standard.yml +3 -0
  12. data/AGENTS.md +120 -0
  13. data/CHANGELOG.md +5 -0
  14. data/Dockerfile.opensearch +4 -0
  15. data/Increase_Coverage.md +311 -0
  16. data/README.md +143 -0
  17. data/Rakefile +27 -0
  18. data/Steepfile +23 -0
  19. data/adrs/ADR-000-template.md +87 -0
  20. data/adrs/ADR-001-simpledelegator-for-client.md +138 -0
  21. data/adrs/ADR-002-facade-pattern-for-index.md +126 -0
  22. data/adrs/ADR-003-repository-pattern-for-models.md +148 -0
  23. data/adrs/ADR-004-integration-tests-no-mocking.md +91 -0
  24. data/adrs/ADR-005-exceptions-over-result-objects.md +107 -0
  25. data/adrs/ADR-006-ssl-on-by-default.md +95 -0
  26. data/adrs/ADR-007-selective-sugar-surface.md +118 -0
  27. data/adrs/ADR-008-integration-test-design.md +178 -0
  28. data/compose.yml +2 -0
  29. data/compose_opensearch.yml +31 -0
  30. data/docs/HOWTO.md +844 -0
  31. data/docs/REFERENCE.md +725 -0
  32. data/docs/TUTORIAL.md +327 -0
  33. data/docs/alias-api-design-notes.md +119 -0
  34. data/lib/opensearch/sugar/client.rb +300 -0
  35. data/lib/opensearch/sugar/index/include/utilities.rb +6 -0
  36. data/lib/opensearch/sugar/index.rb +339 -0
  37. data/lib/opensearch/sugar/models.rb +209 -0
  38. data/lib/opensearch/sugar/version.rb +8 -0
  39. data/lib/opensearch/sugar.rb +61 -0
  40. data/old_docs/DELEGATED_METHODS_ANALYSIS.md +361 -0
  41. data/old_docs/EXPLANATION.md +685 -0
  42. data/old_docs/README.md +155 -0
  43. data/old_docs/docs/CLI-PROPOSAL.md +257 -0
  44. data/old_docs/docs/HOWTO.md +798 -0
  45. data/old_docs/docs/REFERENCE.md +901 -0
  46. data/old_docs/docs/TUTORIAL.md +493 -0
  47. data/sig/opensearch/sugar.rbs +162 -0
  48. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Namespace reserved for future shared utility methods that can be included
4
+ # into {OpenSearch::Sugar::Index} sub-components.
5
+ module OpenSearch::Sugar::Index::Include::Utilities
6
+ 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