search-engine-for-typesense 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 (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/named_base'
5
+
6
+ begin
7
+ require 'did_you_mean'
8
+ rescue LoadError
9
+ # did_you_mean is optional; suggestions will be skipped if unavailable
10
+ end
11
+
12
+ module SearchEngine
13
+ module Generators
14
+ # Model generator that creates a minimal SearchEngine model mapping to a
15
+ # Typesense collection.
16
+ #
17
+ # @example
18
+ # rails g search_engine:model Product --collection products --attrs id:integer name:string
19
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx
20
+ class ModelGenerator < Rails::Generators::NamedBase
21
+ source_root File.expand_path('templates', __dir__)
22
+
23
+ class_option :collection, type: :string, desc: 'Logical Typesense collection name (required)'
24
+ class_option :attrs,
25
+ type: :string,
26
+ default: nil,
27
+ desc: 'Attribute declarations as key:type pairs (space/comma-separated)'
28
+
29
+ def validate_options!
30
+ return if options[:collection].to_s.strip.present?
31
+
32
+ raise Thor::Error,
33
+ '--collection is required. See ' \
34
+ 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/dx#generators--console-helpers'
35
+ end
36
+
37
+ def create_model
38
+ @collection_name = options[:collection].to_s.strip
39
+ @attributes = parse_attrs(options[:attrs])
40
+ template 'model.rb.tt', File.join(search_engine_models_path, "#{file_name}.rb")
41
+ end
42
+
43
+ private
44
+
45
+ ALLOWED_TYPES = %w[string integer float boolean datetime json].freeze
46
+
47
+ def parse_attrs(raw)
48
+ return [] if raw.nil?
49
+
50
+ tokens = raw.split(/[\s,]+/).map(&:strip).reject(&:empty?)
51
+ tokens.map do |pair|
52
+ name, type = pair.split(':', 2)
53
+ raise Thor::Error, "invalid attribute token: #{pair.inspect} (expected name:type)" unless name
54
+
55
+ type = (type || 'string').to_s
56
+ normalized = normalize_type(type)
57
+ [name.to_s.underscore, normalized]
58
+ end
59
+ end
60
+
61
+ def normalize_type(type)
62
+ t = type.to_s.strip.downcase
63
+ return t if ALLOWED_TYPES.include?(t)
64
+
65
+ suggestion = suggest_type(t)
66
+ hint = suggestion ? "; did you mean #{suggestion.inspect}?" : ''
67
+ raise Thor::Error, "Unknown attribute type #{t.inspect}; allowed: #{ALLOWED_TYPES.join(', ')}#{hint}"
68
+ end
69
+
70
+ def suggest_type(token)
71
+ return nil unless defined?(DidYouMean::SpellChecker)
72
+
73
+ DidYouMean::SpellChecker.new(dictionary: ALLOWED_TYPES).correct(token).first
74
+ end
75
+
76
+ def search_engine_models_path
77
+ cfg = SearchEngine.respond_to?(:config) ? SearchEngine.config : nil
78
+ raw = cfg.respond_to?(:search_engine_models) ? cfg.search_engine_models : nil
79
+ return 'app/search_engine' if raw.nil? || raw == false
80
+
81
+ path = raw.to_s.strip
82
+ path.empty? ? 'app/search_engine' : path
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal SearchEngine model generated by `search_engine:model`.
4
+ # Docs:
5
+ # - https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/quickstart
6
+ # - https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/field-selection#guardrails--errors
7
+ class SearchEngine::<%= class_name %> < SearchEngine::Base
8
+ collection "<%= @collection_name %>"
9
+ <% Array(@attributes).each do |(name, type)| -%>
10
+ attribute :<%= name %>, :<%= type %>
11
+ <% end -%>
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Compatibility shim for Bundler's `require: true` auto-require using the
4
+ # gem name `search-engine-for-typesense`.
5
+ #
6
+ # This file must not define any constants. It simply requires the proper
7
+ # entrypoint so that `SearchEngine` and its engine/config are loaded.
8
+ #
9
+ # It is intentionally ignored by the engine's Zeitwerk loader to avoid
10
+ # attempts to constantize the hyphenated filename.
11
+
12
+ require 'search_engine'
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module SearchEngine
6
+ # ActiveRecord concern to keep a Typesense collection in sync.
7
+ #
8
+ # Include into an AR model and call {.search_engine_syncable} to install
9
+ # lifecycle callbacks that upsert on create/update and delete on destroy.
10
+ #
11
+ # @example
12
+ # class Product < ApplicationRecord
13
+ # include SearchEngine::ActiveRecordSyncable
14
+ # search_engine_syncable on: %i[create update destroy], collection: :products
15
+ # end
16
+ module ActiveRecordSyncable
17
+ extend ActiveSupport::Concern
18
+
19
+ class_methods do
20
+ # Configure SearchEngine synchronization for this ActiveRecord model.
21
+ #
22
+ # - collection: defaults to the AR class tableized name (snake_case, plural)
23
+ # - on: one or many of :create, :update, :destroy (strings or symbols)
24
+ #
25
+ # Validates that either a physical Typesense collection exists for the
26
+ # given name or a SearchEngine model is registered for it. Mapping for
27
+ # create/update requires a SearchEngine model; when missing, an error is
28
+ # raised with guidance.
29
+ #
30
+ # @param collection [Symbol, String, nil]
31
+ # @param on [Array<Symbol,String>, Symbol, String, nil]
32
+ # @return [Class] self (for macro chaining)
33
+ def search_engine_syncable(collection: nil, on: nil)
34
+ effective_actions = on
35
+
36
+ cfg = SearchEngine::ActiveRecordSyncable.__normalize_config_for(
37
+ self,
38
+ collection: collection,
39
+ actions: effective_actions
40
+ )
41
+
42
+ # Store config on the AR class (used by instance methods)
43
+ instance_variable_set(:@__se_syncable_cfg__, cfg)
44
+
45
+ SearchEngine::ActiveRecordSyncable.__register_callbacks_for(self, cfg)
46
+ self
47
+ end
48
+ end
49
+
50
+ # Upsert this record into the configured Typesense collection using the
51
+ # mapping defined on the corresponding SearchEngine model.
52
+ # @return [void]
53
+ def __se_syncable_upsert!
54
+ cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
55
+ se_klass = cfg[:se_klass]
56
+ unless se_klass
57
+ # Lazy-resolve the SearchEngine model to avoid boot-time ordering issues
58
+ begin
59
+ se_klass = SearchEngine.collection_for(cfg[:logical])
60
+ cfg[:se_klass] = se_klass if se_klass
61
+ rescue StandardError
62
+ se_klass = nil
63
+ end
64
+ end
65
+ unless se_klass
66
+ SearchEngine.config.logger&.warn(
67
+ "search_engine_syncable: no SearchEngine model registered for '#{cfg[:logical]}'; skip upsert"
68
+ )
69
+ return
70
+ end
71
+
72
+ se_klass.upsert(record: self)
73
+ rescue StandardError => error
74
+ SearchEngine.config.logger&.error("search_engine_syncable upsert failed: #{error}")
75
+ end
76
+
77
+ # Delete this record's document from the configured Typesense collection.
78
+ # Uses the SearchEngine model's identity strategy when available.
79
+ # @return [void]
80
+ def __se_syncable_delete!
81
+ cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
82
+ logical = cfg[:logical]
83
+ se_klass = cfg[:se_klass]
84
+
85
+ document_id = if se_klass
86
+ se_klass.compute_document_id(self)
87
+ else
88
+ respond_to?(:id) ? id.to_s : nil
89
+ end
90
+
91
+ if document_id.nil? || document_id.strip.empty?
92
+ SearchEngine.config.logger&.warn(
93
+ "search_engine_syncable: cannot delete without id for '#{logical}'"
94
+ )
95
+ return
96
+ end
97
+
98
+ client = SearchEngine.client
99
+ into = client.resolve_alias(logical) || logical
100
+ client.delete_document(collection: into, id: document_id)
101
+ rescue StandardError => error
102
+ SearchEngine.config.logger&.error("search_engine_syncable delete failed: #{error}")
103
+ end
104
+
105
+ # Return the associated SearchEngine record for this ActiveRecord instance.
106
+ #
107
+ # Resolves the SearchEngine model lazily when necessary and computes the
108
+ # document id via the model's `identify_by` strategy. Returns nil when the
109
+ # model mapping is unavailable or the id cannot be determined.
110
+ #
111
+ # @return [Object, nil] hydrated SearchEngine model instance or nil when not found
112
+ def search_engine_record
113
+ cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
114
+ se_klass = cfg[:se_klass]
115
+ if se_klass.nil?
116
+ begin
117
+ logical = cfg[:logical] || self.class.name.to_s
118
+ se_klass = SearchEngine.collection_for(logical)
119
+ cfg[:se_klass] = se_klass if se_klass
120
+ rescue StandardError
121
+ return nil
122
+ end
123
+ end
124
+
125
+ doc_id = begin
126
+ se_klass.compute_document_id(self)
127
+ rescue StandardError => error
128
+ # When a custom identify_by is configured on the SearchEngine model,
129
+ # do not fall back to the ActiveRecord id on computation errors as it
130
+ # may point to a different document. Only fall back to AR id when
131
+ # identify_by is not defined at all.
132
+ if se_klass.instance_variable_defined?(:@identify_by_proc)
133
+ SearchEngine.config.logger&.warn(
134
+ "search_engine_syncable: identify_by failed to compute id for '#{cfg[:logical]}' (#{error.class})"
135
+ )
136
+ nil
137
+ else
138
+ respond_to?(:id) ? id.to_s : nil
139
+ end
140
+ end
141
+ return nil if doc_id.nil? || doc_id.to_s.strip.empty?
142
+
143
+ se_klass.find(doc_id)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+
148
+ # Map this ActiveRecord instance using the associated SearchEngine model and
149
+ # upsert it into the collection.
150
+ #
151
+ # @return [Integer] number of upserted documents (0 or 1)
152
+ def sync_search_engine_record
153
+ cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
154
+ se_klass = cfg[:se_klass]
155
+ if se_klass.nil?
156
+ begin
157
+ logical = cfg[:logical] || self.class.name.to_s
158
+ se_klass = SearchEngine.collection_for(logical)
159
+ cfg[:se_klass] = se_klass if se_klass
160
+ rescue StandardError => error
161
+ SearchEngine.config.logger&.warn(
162
+ "search_engine_syncable: cannot resolve model for sync (#{error.class})"
163
+ )
164
+ return 0
165
+ end
166
+ end
167
+
168
+ se_klass.upsert(record: self)
169
+ rescue StandardError => error
170
+ SearchEngine.config.logger&.error("search_engine_syncable sync failed: #{error}")
171
+ 0
172
+ end
173
+
174
+ module_function
175
+
176
+ # @api private
177
+ # @param ar_klass [Class]
178
+ # @param collection [String,Symbol,nil]
179
+ # @param actions [Array<String,Symbol>, String, Symbol, nil]
180
+ # @return [Hash]
181
+ def __normalize_config_for(ar_klass, collection:, actions:)
182
+ require 'active_support/inflector'
183
+
184
+ logical = (collection || ActiveSupport::Inflector.tableize(ar_klass.name)).to_s
185
+ normalized_actions = __normalize_actions(actions)
186
+
187
+ # Best-effort resolve SearchEngine model now; fall back to lazy resolution
188
+ se_klass = begin
189
+ SearchEngine.collection_for(logical)
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ if se_klass.nil? && (normalized_actions.include?(:create) || normalized_actions.include?(:update))
195
+ SearchEngine.config.logger&.warn(
196
+ "search_engine_syncable: mapping for '#{logical}' not found at boot; will resolve lazily at runtime"
197
+ )
198
+ end
199
+
200
+ {
201
+ logical: logical,
202
+ actions: normalized_actions,
203
+ se_klass: se_klass
204
+ }
205
+ end
206
+
207
+ # @api private
208
+ # @param actions [Array<String,Symbol>, String, Symbol, nil]
209
+ # @return [Array<Symbol>]
210
+ def __normalize_actions(actions)
211
+ allowed = %i[create update destroy]
212
+ list =
213
+ if actions.nil?
214
+ allowed
215
+ else
216
+ Array(actions).map { |a| a.to_s.downcase.strip.to_sym }
217
+ end
218
+
219
+ invalid = list - allowed
220
+ raise ArgumentError, "search_engine_syncable: actions must be within #{allowed.inspect}" unless invalid.empty?
221
+
222
+ list.uniq
223
+ end
224
+
225
+ # (no-op placeholder kept for backwards compatibility of method table in case of reloads)
226
+
227
+ # @api private
228
+ # @param ar_klass [Class]
229
+ # @param cfg [Hash]
230
+ # @return [void]
231
+ def __register_callbacks_for(ar_klass, cfg)
232
+ if ar_klass.instance_variable_defined?(:@__se_syncable_callbacks_installed__) &&
233
+ ar_klass.instance_variable_get(:@__se_syncable_callbacks_installed__)
234
+ return
235
+ end
236
+
237
+ actions = cfg[:actions]
238
+
239
+ ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
240
+ ar_klass.after_update :__se_syncable_upsert! if actions.include?(:update)
241
+ ar_klass.after_destroy :__se_syncable_delete! if actions.include?(:destroy)
242
+
243
+ ar_klass.instance_variable_set(:@__se_syncable_callbacks_installed__, true)
244
+ nil
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module Admin
5
+ # Manage stopword sets for a collection.
6
+ #
7
+ # @since 0.1.0
8
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords#management
9
+ # @see `https://typesense.org/docs/latest/api/stopwords.html`
10
+ module Stopwords
11
+ class << self
12
+ # Upsert a stopword set by ID.
13
+ #
14
+ # @param collection [String]
15
+ # @param id [String]
16
+ # @param terms [Array<#to_s>]
17
+ # @return [Hash] summary { status: :created|:updated, id:, terms_count: Integer }
18
+ # @example
19
+ # SearchEngine::Admin::Stopwords.upsert!(collection: "products", id: "common", terms: %w[the and])
20
+ # @see SearchEngine::Admin::Synonyms.upsert!
21
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#upsert-a-stopwords`
22
+ def upsert!(collection:, id:, terms:)
23
+ c = normalize_collection!(collection)
24
+ sid = normalize_id!(id)
25
+ list = normalize_terms!(terms)
26
+
27
+ existed = exists?(c, sid)
28
+ ts_res = client.stopwords_upsert(collection: c, id: sid, terms: list)
29
+ status = existed ? :updated : :created
30
+ instrument(:upsert, collection: c, id: sid, terms_count: list.size)
31
+ { status: status, id: sid, terms_count: list.size, response: ts_res }
32
+ end
33
+
34
+ # Retrieve a stopword set by ID.
35
+ # @param collection [String]
36
+ # @param id [String]
37
+ # @return [Hash, nil] { id:, terms: [] } or nil when not found
38
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#retrieve-a-stopword`
39
+ def get(collection:, id:)
40
+ c = normalize_collection!(collection)
41
+ sid = normalize_id!(id)
42
+ res = client.stopwords_get(collection: c, id: sid)
43
+ return nil unless res
44
+
45
+ { id: sid, terms: Array(res[:stopwords] || res['stopwords']).map(&:to_s) }
46
+ rescue SearchEngine::Errors::Api => error
47
+ return nil if error.status.to_i == 404
48
+
49
+ raise
50
+ end
51
+
52
+ # List all stopword sets for a collection.
53
+ # @param collection [String]
54
+ # @return [Array<Hash>] list of { id:, terms: [] }
55
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#list-all-stopwords-of-a-collection`
56
+ def list(collection:)
57
+ c = normalize_collection!(collection)
58
+ res = client.stopwords_list(collection: c)
59
+ Array(res).map do |item|
60
+ { id: (item[:id] || item['id']).to_s, terms: Array(item[:stopwords] || item['stopwords']).map(&:to_s) }
61
+ end
62
+ end
63
+
64
+ # Delete a stopword set by ID (idempotent).
65
+ # @param collection [String]
66
+ # @param id [String]
67
+ # @return [true]
68
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#delete-a-stopword`
69
+ def delete!(collection:, id:)
70
+ c = normalize_collection!(collection)
71
+ sid = normalize_id!(id)
72
+ client.stopwords_delete(collection: c, id: sid)
73
+ instrument(:delete, collection: c, id: sid)
74
+ true
75
+ rescue SearchEngine::Errors::Api => error
76
+ return true if error.status.to_i == 404
77
+
78
+ raise
79
+ end
80
+
81
+ private
82
+
83
+ def client
84
+ @client ||= SearchEngine.client
85
+ end
86
+
87
+ def normalize_collection!(value)
88
+ s = value.to_s
89
+ raise ArgumentError, 'collection must be a non-empty String' if s.strip.empty?
90
+
91
+ s
92
+ end
93
+
94
+ def normalize_id!(value)
95
+ s = value.to_s
96
+ raise ArgumentError, 'id must be a non-empty String' if s.strip.empty?
97
+ raise ArgumentError, 'id too long (max 256)' if s.length > 256
98
+ raise ArgumentError, 'id contains invalid characters' unless s.match?(/\A[\w\-:.]+\z/)
99
+
100
+ s
101
+ end
102
+
103
+ def normalize_terms!(list)
104
+ arr = Array(list).flatten.compact.map { |t| t.to_s.strip.downcase }.reject(&:empty?)
105
+ arr.uniq!
106
+ raise ArgumentError, 'terms must include at least one non-empty String' if arr.empty?
107
+
108
+ arr
109
+ end
110
+
111
+ def exists?(collection, id)
112
+ !!client.stopwords_get(collection: collection, id: id)
113
+ rescue SearchEngine::Errors::Api => error
114
+ return false if error.status.to_i == 404
115
+
116
+ raise
117
+ end
118
+
119
+ def instrument(action, payload)
120
+ SearchEngine::Instrumentation.instrument("search_engine.admin.stopwords.#{action}", payload) {}
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module Admin
5
+ # Manage synonym sets for a collection.
6
+ #
7
+ # @since 0.1.0
8
+ # @see https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords#management
9
+ # @see `https://typesense.org/docs/latest/api/synonyms.html`
10
+ module Synonyms
11
+ class << self
12
+ # Upsert a synonym set by ID.
13
+ #
14
+ # @param collection [String]
15
+ # @param id [String]
16
+ # @param terms [Array<#to_s>]
17
+ # @return [Hash] summary { status: :created|:updated, id:, terms_count: Integer }
18
+ # @example
19
+ # SearchEngine::Admin::Synonyms.upsert!(collection: "products", id: "colors", terms: %w[color colour])
20
+ # @see SearchEngine::Admin::Stopwords.upsert!
21
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#upsert-a-synonym`
22
+ def upsert!(collection:, id:, terms:)
23
+ c = normalize_collection!(collection)
24
+ sid = normalize_id!(id)
25
+ list = normalize_terms!(terms)
26
+
27
+ existed = exists?(c, sid)
28
+ ts_res = client.synonyms_upsert(collection: c, id: sid, terms: list)
29
+ status = existed ? :updated : :created
30
+ instrument(:upsert, collection: c, id: sid, terms_count: list.size)
31
+ { status: status, id: sid, terms_count: list.size, response: ts_res }
32
+ end
33
+
34
+ # Retrieve a synonym set by ID.
35
+ # @param collection [String]
36
+ # @param id [String]
37
+ # @return [Hash, nil] { id:, terms: [] } or nil when not found
38
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#retrieve-a-synonym`
39
+ def get(collection:, id:)
40
+ c = normalize_collection!(collection)
41
+ sid = normalize_id!(id)
42
+ res = client.synonyms_get(collection: c, id: sid)
43
+ return nil unless res
44
+
45
+ { id: sid, terms: Array(res[:synonyms] || res['synonyms']).map(&:to_s) }
46
+ rescue SearchEngine::Errors::Api => error
47
+ return nil if error.status.to_i == 404
48
+
49
+ raise
50
+ end
51
+
52
+ # List all synonym sets for a collection.
53
+ # @param collection [String]
54
+ # @return [Array<Hash>] list of { id:, terms: [] }
55
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#list-all-synonyms-of-a-collection`
56
+ def list(collection:)
57
+ c = normalize_collection!(collection)
58
+ res = client.synonyms_list(collection: c)
59
+ Array(res).map do |item|
60
+ { id: (item[:id] || item['id']).to_s, terms: Array(item[:synonyms] || item['synonyms']).map(&:to_s) }
61
+ end
62
+ end
63
+
64
+ # Delete a synonym set by ID (idempotent).
65
+ # @param collection [String]
66
+ # @param id [String]
67
+ # @return [true]
68
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#delete-a-synonym`
69
+ def delete!(collection:, id:)
70
+ c = normalize_collection!(collection)
71
+ sid = normalize_id!(id)
72
+ client.synonyms_delete(collection: c, id: sid)
73
+ instrument(:delete, collection: c, id: sid)
74
+ true
75
+ rescue SearchEngine::Errors::Api => error
76
+ return true if error.status.to_i == 404
77
+
78
+ raise
79
+ end
80
+
81
+ private
82
+
83
+ def client
84
+ @client ||= SearchEngine.client
85
+ end
86
+
87
+ def normalize_collection!(value)
88
+ s = value.to_s
89
+ raise ArgumentError, 'collection must be a non-empty String' if s.strip.empty?
90
+
91
+ s
92
+ end
93
+
94
+ def normalize_id!(value)
95
+ s = value.to_s
96
+ raise ArgumentError, 'id must be a non-empty String' if s.strip.empty?
97
+ raise ArgumentError, 'id too long (max 256)' if s.length > 256
98
+ raise ArgumentError, 'id contains invalid characters' unless s.match?(/\A[\w\-:.]+\z/)
99
+
100
+ s
101
+ end
102
+
103
+ def normalize_terms!(list)
104
+ arr = Array(list).flatten.compact.map { |t| t.to_s.strip.downcase }.reject(&:empty?)
105
+ arr.uniq!
106
+ raise ArgumentError, 'terms must include at least one non-empty String' if arr.empty?
107
+
108
+ arr
109
+ end
110
+
111
+ def exists?(collection, id)
112
+ !!client.synonyms_get(collection: collection, id: id)
113
+ rescue SearchEngine::Errors::Api => error
114
+ return false if error.status.to_i == 404
115
+
116
+ raise
117
+ end
118
+
119
+ def instrument(action, payload)
120
+ SearchEngine::Instrumentation.instrument("search_engine.admin.synonyms.#{action}", payload) {}
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Admin namespace for management APIs (synonyms/stopwords).
5
+ #
6
+ # Provides thin, validated wrappers over Typesense admin endpoints and
7
+ # emits structured instrumentation. Pure stdlib + typesense gem only.
8
+ module Admin; end
9
+ end
10
+
11
+ require 'search_engine/admin/synonyms'
12
+ require 'search_engine/admin/stopwords'
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ module AST
5
+ # Boolean conjunction over one or more child nodes.
6
+ class And < Node
7
+ attr_reader :children
8
+
9
+ def initialize(*nodes)
10
+ super()
11
+ normalized = normalize(nodes)
12
+ raise ArgumentError, 'and_ requires at least one child node' if normalized.empty?
13
+
14
+ @children = deep_freeze_array(normalized)
15
+ freeze
16
+ end
17
+
18
+ def type = :and
19
+
20
+ def to_s
21
+ "and(#{children.map(&:to_s).join(', ')})"
22
+ end
23
+
24
+ protected
25
+
26
+ def equality_key
27
+ [:and, @children]
28
+ end
29
+
30
+ def inspect_payload
31
+ inner = children.map(&:to_s).join(', ')
32
+ truncate_for_inspect(inner)
33
+ end
34
+
35
+ private
36
+
37
+ def normalize(nodes)
38
+ flat = []
39
+ Array(nodes).flatten.compact.each do |n|
40
+ next unless n.is_a?(Node)
41
+
42
+ if n.is_a?(And)
43
+ flat.concat(n.children)
44
+ else
45
+ flat << n
46
+ end
47
+ end
48
+ flat
49
+ end
50
+ end
51
+ end
52
+ end