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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +148 -0
- data/app/search_engine/search_engine/app_info.rb +11 -0
- data/app/search_engine/search_engine/index_partition_job.rb +170 -0
- data/lib/generators/search_engine/install/install_generator.rb +20 -0
- data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
- data/lib/generators/search_engine/model/model_generator.rb +86 -0
- data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
- data/lib/search-engine-for-typesense.rb +12 -0
- data/lib/search_engine/active_record_syncable.rb +247 -0
- data/lib/search_engine/admin/stopwords.rb +125 -0
- data/lib/search_engine/admin/synonyms.rb +125 -0
- data/lib/search_engine/admin.rb +12 -0
- data/lib/search_engine/ast/and.rb +52 -0
- data/lib/search_engine/ast/binary_op.rb +75 -0
- data/lib/search_engine/ast/eq.rb +19 -0
- data/lib/search_engine/ast/group.rb +18 -0
- data/lib/search_engine/ast/gt.rb +12 -0
- data/lib/search_engine/ast/gte.rb +12 -0
- data/lib/search_engine/ast/in.rb +28 -0
- data/lib/search_engine/ast/lt.rb +12 -0
- data/lib/search_engine/ast/lte.rb +12 -0
- data/lib/search_engine/ast/matches.rb +55 -0
- data/lib/search_engine/ast/node.rb +176 -0
- data/lib/search_engine/ast/not_eq.rb +13 -0
- data/lib/search_engine/ast/not_in.rb +24 -0
- data/lib/search_engine/ast/or.rb +52 -0
- data/lib/search_engine/ast/prefix.rb +51 -0
- data/lib/search_engine/ast/raw.rb +41 -0
- data/lib/search_engine/ast/unary_op.rb +43 -0
- data/lib/search_engine/ast.rb +101 -0
- data/lib/search_engine/base/creation.rb +727 -0
- data/lib/search_engine/base/deletion.rb +80 -0
- data/lib/search_engine/base/display_coercions.rb +36 -0
- data/lib/search_engine/base/hydration.rb +312 -0
- data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
- data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
- data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
- data/lib/search_engine/base/index_maintenance.rb +459 -0
- data/lib/search_engine/base/indexing_dsl.rb +255 -0
- data/lib/search_engine/base/joins.rb +479 -0
- data/lib/search_engine/base/model_dsl.rb +472 -0
- data/lib/search_engine/base/presets.rb +43 -0
- data/lib/search_engine/base/pretty_printer.rb +315 -0
- data/lib/search_engine/base/relation_delegation.rb +42 -0
- data/lib/search_engine/base/scopes.rb +113 -0
- data/lib/search_engine/base/updating.rb +92 -0
- data/lib/search_engine/base.rb +38 -0
- data/lib/search_engine/bulk.rb +284 -0
- data/lib/search_engine/cache.rb +33 -0
- data/lib/search_engine/cascade.rb +531 -0
- data/lib/search_engine/cli/doctor.rb +631 -0
- data/lib/search_engine/cli/support.rb +217 -0
- data/lib/search_engine/cli.rb +222 -0
- data/lib/search_engine/client/http_adapter.rb +63 -0
- data/lib/search_engine/client/request_builder.rb +92 -0
- data/lib/search_engine/client/services/base.rb +74 -0
- data/lib/search_engine/client/services/collections.rb +161 -0
- data/lib/search_engine/client/services/documents.rb +214 -0
- data/lib/search_engine/client/services/operations.rb +152 -0
- data/lib/search_engine/client/services/search.rb +190 -0
- data/lib/search_engine/client/services.rb +29 -0
- data/lib/search_engine/client.rb +765 -0
- data/lib/search_engine/client_options.rb +20 -0
- data/lib/search_engine/collection_resolver.rb +191 -0
- data/lib/search_engine/collections_graph.rb +330 -0
- data/lib/search_engine/compiled_params.rb +143 -0
- data/lib/search_engine/compiler.rb +383 -0
- data/lib/search_engine/config/observability.rb +27 -0
- data/lib/search_engine/config/presets.rb +92 -0
- data/lib/search_engine/config/selection.rb +16 -0
- data/lib/search_engine/config/typesense.rb +48 -0
- data/lib/search_engine/config/validators.rb +97 -0
- data/lib/search_engine/config.rb +917 -0
- data/lib/search_engine/console_helpers.rb +130 -0
- data/lib/search_engine/deletion.rb +103 -0
- data/lib/search_engine/dispatcher.rb +125 -0
- data/lib/search_engine/dsl/parser.rb +582 -0
- data/lib/search_engine/engine.rb +167 -0
- data/lib/search_engine/errors.rb +290 -0
- data/lib/search_engine/filters/sanitizer.rb +189 -0
- data/lib/search_engine/hydration/materializers.rb +808 -0
- data/lib/search_engine/hydration/selection_context.rb +96 -0
- data/lib/search_engine/indexer/batch_planner.rb +76 -0
- data/lib/search_engine/indexer/bulk_import.rb +626 -0
- data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
- data/lib/search_engine/indexer/retry_policy.rb +103 -0
- data/lib/search_engine/indexer.rb +747 -0
- data/lib/search_engine/instrumentation.rb +308 -0
- data/lib/search_engine/joins/guard.rb +202 -0
- data/lib/search_engine/joins/resolver.rb +95 -0
- data/lib/search_engine/logging/color.rb +78 -0
- data/lib/search_engine/logging/format_helpers.rb +92 -0
- data/lib/search_engine/logging/partition_progress.rb +53 -0
- data/lib/search_engine/logging_subscriber.rb +388 -0
- data/lib/search_engine/mapper.rb +785 -0
- data/lib/search_engine/multi.rb +286 -0
- data/lib/search_engine/multi_result.rb +186 -0
- data/lib/search_engine/notifications/compact_logger.rb +675 -0
- data/lib/search_engine/observability.rb +162 -0
- data/lib/search_engine/operations.rb +58 -0
- data/lib/search_engine/otel.rb +227 -0
- data/lib/search_engine/partitioner.rb +128 -0
- data/lib/search_engine/ranking_plan.rb +118 -0
- data/lib/search_engine/registry.rb +158 -0
- data/lib/search_engine/relation/compiler.rb +711 -0
- data/lib/search_engine/relation/deletion.rb +37 -0
- data/lib/search_engine/relation/dsl/filters.rb +624 -0
- data/lib/search_engine/relation/dsl/selection.rb +240 -0
- data/lib/search_engine/relation/dsl.rb +903 -0
- data/lib/search_engine/relation/dx/dry_run.rb +59 -0
- data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
- data/lib/search_engine/relation/dx.rb +231 -0
- data/lib/search_engine/relation/materializers.rb +118 -0
- data/lib/search_engine/relation/options.rb +138 -0
- data/lib/search_engine/relation/state.rb +274 -0
- data/lib/search_engine/relation/updating.rb +44 -0
- data/lib/search_engine/relation.rb +623 -0
- data/lib/search_engine/result.rb +664 -0
- data/lib/search_engine/schema.rb +1083 -0
- data/lib/search_engine/sources/active_record_source.rb +185 -0
- data/lib/search_engine/sources/base.rb +62 -0
- data/lib/search_engine/sources/lambda_source.rb +55 -0
- data/lib/search_engine/sources/sql_source.rb +196 -0
- data/lib/search_engine/sources.rb +71 -0
- data/lib/search_engine/stale_rules.rb +160 -0
- data/lib/search_engine/test/minitest_assertions.rb +57 -0
- data/lib/search_engine/test/offline_client.rb +134 -0
- data/lib/search_engine/test/rspec_matchers.rb +77 -0
- data/lib/search_engine/test/stub_client.rb +201 -0
- data/lib/search_engine/test.rb +66 -0
- data/lib/search_engine/test_autoload.rb +8 -0
- data/lib/search_engine/update.rb +35 -0
- data/lib/search_engine/version.rb +7 -0
- data/lib/search_engine.rb +332 -0
- data/lib/tasks/search_engine.rake +501 -0
- data/lib/tasks/search_engine_doctor.rake +16 -0
- 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
|