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
data/docs/TUTORIAL.md ADDED
@@ -0,0 +1,327 @@
1
+ # Getting Started with opensearch-sugar
2
+
3
+ Created by various AI agents.
4
+
5
+ By the end of this tutorial you will have a running, searchable book catalog: a live
6
+ OpenSearch index with custom text analysis, structured field mappings, real documents,
7
+ and a working full-text search query.
8
+
9
+ ## What you'll build
10
+
11
+ A Ruby script that connects to a local OpenSearch cluster, configures a `books` index
12
+ with a custom analyzer, adds three documents, and queries them by keyword.
13
+
14
+ ## Before you start
15
+
16
+ - Ruby 3.1 or higher
17
+ - Docker installed and running
18
+ - Basic familiarity with running Ruby scripts from the terminal
19
+
20
+ ---
21
+
22
+ ## Step 1: Start OpenSearch
23
+
24
+ Run a single-node OpenSearch container:
25
+
26
+ ```bash
27
+ docker run -d \
28
+ -p 9200:9200 \
29
+ -e "discovery.type=single-node" \
30
+ -e "OPENSEARCH_INITIAL_ADMIN_PASSWORD=MyPassword123!" \
31
+ --name opensearch-tutorial \
32
+ opensearchproject/opensearch:latest
33
+ ```
34
+
35
+ Wait about 30 seconds, then confirm it is up:
36
+
37
+ ```bash
38
+ curl -sk https://localhost:9200 -u admin:MyPassword123! | ruby -e "require 'json'; puts JSON.parse(STDIN.read)['tagline']"
39
+ ```
40
+
41
+ The output should be:
42
+
43
+ ```
44
+ The OpenSearch Project: https://opensearch.org/
45
+ ```
46
+
47
+ ## Step 2: Install the gem
48
+
49
+ Create a project directory and a `Gemfile`:
50
+
51
+ ```bash
52
+ mkdir book-catalog && cd book-catalog
53
+ ```
54
+
55
+ ```ruby
56
+ # Gemfile
57
+ source "https://rubygems.org"
58
+ gem "opensearch-sugar"
59
+ gem "dotenv"
60
+ ```
61
+
62
+ ```bash
63
+ bundle install
64
+ ```
65
+
66
+ Create a `.env` file with your connection details:
67
+
68
+ ```
69
+ OPENSEARCH_URL=https://localhost:9200
70
+ OPENSEARCH_USER=admin
71
+ OPENSEARCH_PASSWORD=MyPassword123!
72
+ ```
73
+
74
+ ## Step 3: Connect to the cluster
75
+
76
+ Create `catalog.rb`:
77
+
78
+ ```ruby
79
+ require "opensearch/sugar"
80
+ require "dotenv/load"
81
+
82
+ client = OpenSearch::Sugar::Client.new
83
+ puts client.ping ? "Connected!" : "Could not connect"
84
+ puts "Existing indexes: #{client.index_names}"
85
+ ```
86
+
87
+ Run it:
88
+
89
+ ```bash
90
+ bundle exec ruby catalog.rb
91
+ ```
92
+
93
+ You should see:
94
+
95
+ ```
96
+ Connected!
97
+ Existing indexes: []
98
+ ```
99
+
100
+ Notice that `index_names` returns only user-created indexes — system indexes are hidden.
101
+
102
+ ## Step 4: Create the books index
103
+
104
+ Add to `catalog.rb`:
105
+
106
+ ```ruby
107
+ index = client.open_or_create_index("books")
108
+ puts "Index '#{index.name}' ready. Documents: #{index.count}"
109
+ ```
110
+
111
+ The output should be:
112
+
113
+ ```
114
+ Index 'books' ready. Documents: 0
115
+ ```
116
+
117
+ Notice that `open_or_create_index` is safe to call repeatedly — it creates the index on
118
+ the first run and opens it on subsequent runs.
119
+
120
+ ## Step 5: Configure a custom analyzer
121
+
122
+ Add the settings block. This must be done before you add documents:
123
+
124
+ ```ruby
125
+ index.update_settings(
126
+ settings: {
127
+ analysis: {
128
+ filter: {
129
+ book_stop: {
130
+ type: "stop",
131
+ stopwords: ["the", "a", "an", "and", "or", "of"]
132
+ }
133
+ },
134
+ analyzer: {
135
+ book_title: {
136
+ type: "custom",
137
+ tokenizer: "standard",
138
+ filter: ["lowercase", "asciifolding", "book_stop"]
139
+ }
140
+ }
141
+ }
142
+ }
143
+ )
144
+ puts "Settings applied."
145
+ ```
146
+
147
+ ## Step 6: Map the fields
148
+
149
+ ```ruby
150
+ index.update_mappings(
151
+ mappings: {
152
+ properties: {
153
+ title: { type: "text", analyzer: "book_title" },
154
+ author: { type: "text" },
155
+ description: { type: "text", analyzer: "book_title" },
156
+ isbn: { type: "keyword" },
157
+ year: { type: "integer" },
158
+ categories: { type: "keyword" }
159
+ }
160
+ }
161
+ )
162
+ puts "Mappings applied."
163
+ ```
164
+
165
+ ## Step 7: Verify the analyzer
166
+
167
+ Before adding data, confirm the analyzer tokenizes as expected:
168
+
169
+ ```ruby
170
+ tokens = index.test_analyzer_by_name(analyzer: "book_title", text: "The Lord of the Rings")
171
+ puts "Tokens: #{tokens.join(", ")}"
172
+ ```
173
+
174
+ The output should be:
175
+
176
+ ```
177
+ Tokens: lord, rings
178
+ ```
179
+
180
+ Notice that "the", "of", and "a" are removed by the `book_stop` filter — those words
181
+ will not be indexed and do not need to appear in queries.
182
+
183
+ ## Step 8: Add books
184
+
185
+ ```ruby
186
+ books = [
187
+ {
188
+ title: "The Hobbit",
189
+ author: "J.R.R. Tolkien",
190
+ description: "A hobbit's unexpected journey through Middle-earth.",
191
+ isbn: "978-0547928227",
192
+ year: 1937,
193
+ categories: ["fantasy", "adventure"]
194
+ },
195
+ {
196
+ title: "1984",
197
+ author: "George Orwell",
198
+ description: "A dystopian novel about totalitarian surveillance.",
199
+ isbn: "978-0451524935",
200
+ year: 1949,
201
+ categories: ["dystopian", "classic"]
202
+ },
203
+ {
204
+ title: "To Kill a Mockingbird",
205
+ author: "Harper Lee",
206
+ description: "Racial injustice in the American South, seen through a child's eyes.",
207
+ isbn: "978-0061120084",
208
+ year: 1960,
209
+ categories: ["classic", "drama"]
210
+ }
211
+ ]
212
+
213
+ books.each { |b| index.index_document(b, b[:isbn]) }
214
+ index.refresh
215
+ puts "Indexed #{index.count} books."
216
+ ```
217
+
218
+ The output should be:
219
+
220
+ ```
221
+ Indexed 3 books.
222
+ ```
223
+
224
+ Notice the call to `index.refresh` — without it, the documents may not be visible to
225
+ search queries immediately.
226
+
227
+ ## Step 9: Compare index-time and search-time tokenization
228
+
229
+ Before searching, confirm how your analyzer transforms text — this makes unexpected
230
+ search results much easier to diagnose later.
231
+
232
+ ```ruby
233
+ title = "The Fellowship of the Ring"
234
+
235
+ index_tokens = index.test_analyzer_by_name(analyzer: "book_title", text: title)
236
+ search_tokens = index.test_analyzer_by_name(analyzer: "standard", text: title)
237
+
238
+ puts "Indexed as: #{index_tokens.join(", ")}"
239
+ puts "Standard as: #{search_tokens.join(", ")}"
240
+ ```
241
+
242
+ The output should be:
243
+
244
+ ```
245
+ Indexed as: fellowship, ring
246
+ Standard as: the, fellowship, the, ring
247
+ ```
248
+
249
+ Notice that `book_title` removes stop words ("the", "of") while the built-in `standard`
250
+ analyzer keeps them. A query using `standard` would include tokens your index never
251
+ stored — understanding this gap is the key to debugging zero-result searches.
252
+
253
+ ## Step 10: Search the catalog
254
+
255
+
256
+
257
+ ```ruby
258
+ results = client.search(
259
+ index: "books",
260
+ body: {
261
+ query: {
262
+ multi_match: {
263
+ query: "fantasy adventure",
264
+ fields: ["title^2", "description", "categories"]
265
+ }
266
+ }
267
+ }
268
+ )
269
+
270
+ puts "\nSearch results for 'fantasy adventure':"
271
+ results["hits"]["hits"].each do |hit|
272
+ src = hit["_source"]
273
+ puts " #{src["title"]} by #{src["author"]} (score: #{hit["_score"].round(2)})"
274
+ end
275
+ ```
276
+
277
+ The output should be:
278
+
279
+ ```
280
+ Search results for 'fantasy adventure':
281
+ The Hobbit by J.R.R. Tolkien (score: 1.23)
282
+ ```
283
+
284
+ Notice that `client.search` is the standard OpenSearch Ruby client method — opensearch-sugar
285
+ delegates all methods it does not define directly to the underlying client.
286
+
287
+ ## Step 11: Create an alias
288
+
289
+ ```ruby
290
+ index.create_alias("current_catalog")
291
+ puts "Aliases: #{index.aliases.join(", ")}"
292
+
293
+ # Access the same index through the alias
294
+ via_alias = client["current_catalog"]
295
+ puts "Documents via alias: #{via_alias.count}"
296
+ ```
297
+
298
+ The output should be:
299
+
300
+ ```
301
+ Aliases: current_catalog
302
+ Documents via alias: 3
303
+ ```
304
+
305
+ ## What you've built
306
+
307
+ You have connected to OpenSearch, created a custom analyzer, mapped fields, indexed
308
+ documents, run a full-text search, and set up an alias.
309
+
310
+ Your `catalog.rb` is now a working foundation for any Ruby application that needs
311
+ structured, full-text search.
312
+
313
+ ## Next steps
314
+
315
+ - **Solve specific problems** → [How-to Guides](HOWTO.md)
316
+ - **Look up a method** → [API Reference](REFERENCE.md)
317
+ - **Understand the design** → [Explanation](EXPLANATION.md)
318
+
319
+ ## Clean up
320
+
321
+ ```ruby
322
+ index.delete!
323
+ ```
324
+
325
+ ```bash
326
+ docker stop opensearch-tutorial && docker rm opensearch-tutorial
327
+ ```
@@ -0,0 +1,119 @@
1
+ # Index Alias API Design Notes
2
+
3
+ Working notes for expanding alias support in `OpenSearch::Sugar::Index`.
4
+ Not finished — pick up here.
5
+
6
+ ---
7
+
8
+ ## Current state
9
+
10
+ `Index` has two alias methods:
11
+
12
+ ```ruby
13
+ def aliases
14
+ # Returns Array<String> of alias names only
15
+ response = client.indices.get_alias(index: name)
16
+ response.dig(name, "aliases")&.keys || []
17
+ end
18
+
19
+ def create_alias(alias_name)
20
+ # Creates the alias, returns updated Array<String>
21
+ client.indices.put_alias(index: name, name: alias_name)
22
+ aliases
23
+ end
24
+ ```
25
+
26
+ Covered by `spec/opensearch/sugar/index/aliases_spec.rb`.
27
+
28
+ ---
29
+
30
+ ## Critique of current methods
31
+
32
+ **`#aliases`**
33
+ - Name is fine; reads naturally.
34
+ - Lossy: discards all alias metadata (`filter`, `routing`, `is_write_index`). Can't answer "is this a write alias?" without hitting the raw client.
35
+
36
+ **`#create_alias`**
37
+ - Name is clear.
38
+ - The `@raise BadRequest` YARD note is misleading — OpenSearch may silently move the alias rather than error, depending on configuration.
39
+ - No support for alias-level parameters: `filter`, `routing`, `index_routing`, `search_routing`, `is_write_index`.
40
+
41
+ ---
42
+
43
+ ## What the OpenSearch API offers (not yet wrapped)
44
+
45
+ ### Missing operations
46
+
47
+ | Method | HTTP | Notes |
48
+ |---|---|---|
49
+ | `delete_alias(name)` | `DELETE /<index>/_alias/<alias>` | Symmetry with create |
50
+ | `alias?(name)` | `HEAD /<index>/_alias/<alias>` | Returns bool; 200 = exists |
51
+ | Atomic swap | `POST /_aliases` (bulk actions) | add + remove in one atomic call; standard for zero-downtime reindex |
52
+
53
+ ### Missing parameters on `create_alias`
54
+
55
+ | Param | Type | Purpose |
56
+ |---|---|---|
57
+ | `filter:` | Hash (query DSL) | Filtered alias — only surfaces matching docs |
58
+ | `is_write_index:` | Boolean | Designates this index as the write target |
59
+ | `routing:` | String | Routing for both index and search |
60
+ | `index_routing:` | String | Routing for indexing only |
61
+ | `search_routing:` | String | Routing for search only |
62
+
63
+ ---
64
+
65
+ ## Design options
66
+
67
+ ### Option A — Simple completeness (no breaking changes)
68
+
69
+ Keep `aliases` returning `Array<String>`. Add missing operations and params.
70
+
71
+ ```
72
+ #aliases → Array<String> (unchanged)
73
+ #create_alias(name, filter: nil, → Array<String> (add keyword params)
74
+ routing: nil, is_write_index: nil)
75
+ #delete_alias(name) → Array<String> (new)
76
+ #alias?(name) → Boolean (new)
77
+ ```
78
+
79
+ Atomic swap is not covered; caller uses raw client.
80
+
81
+ ### Option B — Richer return values + atomic swap
82
+
83
+ ```
84
+ #aliases → Hash<String, Hash> (BREAKING — name => metadata)
85
+ #add_alias(name, **opts) → Hash (rename of create_alias)
86
+ #remove_alias(name) → Hash
87
+ #alias?(name) → Boolean
88
+ #swap_alias(from:, to:) → result of /_aliases bulk call
89
+ ```
90
+
91
+ Rename `aliases` return type is breaking unless old method is aliased.
92
+
93
+ ### Option C — Separate name list from full detail
94
+
95
+ ```
96
+ #alias_names → Array<String> (rename of current #aliases)
97
+ #aliases → Hash<String, Hash> (full metadata)
98
+ #create_alias(name, **opts) → keep name, add opts
99
+ #delete_alias(name) → new
100
+ #alias?(name) → new
101
+ #swap_alias(old_alias:, new_alias:) → new
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Open questions
107
+
108
+ 1. Should `aliases` stay `Array<String>` or become a richer Hash (with metadata)?
109
+ 2. `delete_alias` vs `remove_alias` — match the API verb (`delete`) or use a softer Ruby name (`remove`)?
110
+ 3. Is `swap_alias` worth adding? It's the standard pattern for zero-downtime index cutover.
111
+ 4. Should `create_alias` grow keyword params (`filter:`, `routing:`, `is_write_index:`) or stay simple?
112
+
113
+ ---
114
+
115
+ ## Recommendation (starting point)
116
+
117
+ Option A is the lowest-risk starting point — no breaking changes, fills the obvious gaps.
118
+ Add `delete_alias`, `alias?`, and keyword params on `create_alias`.
119
+ If `swap_alias` is wanted, add it standalone — it's high value for reindex workflows.