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
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.
|