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,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
require 'active_support/inflector'
|
|
5
|
+
|
|
6
|
+
module SearchEngine
|
|
7
|
+
class Base
|
|
8
|
+
# Join declarations for server-side joins.
|
|
9
|
+
module Joins
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
# Declare a belongs_to association with auto-resolution of params.
|
|
14
|
+
#
|
|
15
|
+
# Defaults when options are omitted (association-name based):
|
|
16
|
+
# - collection: pluralized first argument (e.g., :brand -> "brands", :promotion_labels -> "promotion_labels")
|
|
17
|
+
# - local_key: singular(name) + "_ids" when the provided name is plural; otherwise singular(name) + "_id"
|
|
18
|
+
# (e.g., :promotion_labels -> :promotion_label_ids, :brand -> :brand_id)
|
|
19
|
+
# - foreign_key: singular(name) + "_id" (e.g., :promotion_labels -> :promotion_label_id)
|
|
20
|
+
#
|
|
21
|
+
# @param name [#to_sym]
|
|
22
|
+
# @param collection [#to_s, nil]
|
|
23
|
+
# @param local_key [#to_sym, nil]
|
|
24
|
+
# @param foreign_key [#to_sym, nil]
|
|
25
|
+
# @param async_ref [Boolean] when true, mark this reference as asynchronous in schema
|
|
26
|
+
# @param optional [Boolean, nil] when set, mark local_key as optional in schema
|
|
27
|
+
# @return [void]
|
|
28
|
+
def belongs_to(name, collection: nil, local_key: nil, foreign_key: nil, async_ref: nil, optional: nil)
|
|
29
|
+
assoc_name = name.to_sym
|
|
30
|
+
raise ArgumentError, 'belongs_to name must be non-empty' if assoc_name.to_s.strip.empty?
|
|
31
|
+
|
|
32
|
+
arg_str = assoc_name.to_s
|
|
33
|
+
arg_plural = ActiveSupport::Inflector.pluralize(arg_str) == arg_str
|
|
34
|
+
|
|
35
|
+
target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
|
|
36
|
+
|
|
37
|
+
assoc_singular = ActiveSupport::Inflector.singularize(arg_str)
|
|
38
|
+
|
|
39
|
+
lk = if local_key
|
|
40
|
+
local_key.to_sym
|
|
41
|
+
else
|
|
42
|
+
base = assoc_singular
|
|
43
|
+
(arg_plural ? "#{base}_ids" : "#{base}_id").to_sym
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
fk = if foreign_key
|
|
47
|
+
foreign_key.to_sym
|
|
48
|
+
else
|
|
49
|
+
"#{assoc_singular}_id".to_sym
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
normalized_async = nil
|
|
53
|
+
unless async_ref.nil?
|
|
54
|
+
normalized_async = async_ref ? true : false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
__se_register_join!(
|
|
58
|
+
name: assoc_name,
|
|
59
|
+
collection: target_collection.to_s,
|
|
60
|
+
local_key: lk,
|
|
61
|
+
foreign_key: fk,
|
|
62
|
+
kind: :belongs_to,
|
|
63
|
+
async_ref: normalized_async
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
unless optional.nil?
|
|
67
|
+
type = (@attributes || {})[lk]
|
|
68
|
+
__se_update_attribute_options!(
|
|
69
|
+
lk,
|
|
70
|
+
type,
|
|
71
|
+
locale: nil,
|
|
72
|
+
optional: optional,
|
|
73
|
+
sort: nil,
|
|
74
|
+
infix: nil,
|
|
75
|
+
empty_filtering: nil,
|
|
76
|
+
facet: nil,
|
|
77
|
+
index: nil
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Define an instance-level association reader for AR-like access.
|
|
82
|
+
# Example: product.brand => returns single associated record or nil; product.brands => Relation
|
|
83
|
+
__se_define_assoc_reader_if_needed!(assoc_name)
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class_methods do
|
|
90
|
+
# Declare a belongs_to_many association with auto-resolution of params.
|
|
91
|
+
#
|
|
92
|
+
# Semantics mirror belongs_to for reference emission, but instance
|
|
93
|
+
# reader always returns a Relation (plural) regardless of local value
|
|
94
|
+
# multiplicity. Use when a single local key may refer to many target
|
|
95
|
+
# records via a shared foreign key.
|
|
96
|
+
#
|
|
97
|
+
# Defaults when options are omitted (association-name based):
|
|
98
|
+
# - collection: pluralized first argument (e.g., :calculated_product -> "calculated_products")
|
|
99
|
+
# - local_key: singular(name) + "_ids" when the provided name is plural; otherwise singular(name) + "_id"
|
|
100
|
+
# - foreign_key: singular(name) + "_id"
|
|
101
|
+
#
|
|
102
|
+
# @param name [#to_sym]
|
|
103
|
+
# @param collection [#to_s, nil]
|
|
104
|
+
# @param local_key [#to_sym, nil]
|
|
105
|
+
# @param foreign_key [#to_sym, nil]
|
|
106
|
+
# @param async_ref [Boolean] when true, mark this reference as asynchronous in schema
|
|
107
|
+
# @return [void]
|
|
108
|
+
def belongs_to_many(name, collection: nil, local_key: nil, foreign_key: nil, async_ref: nil)
|
|
109
|
+
assoc_name = name.to_sym
|
|
110
|
+
raise ArgumentError, 'belongs_to_many name must be non-empty' if assoc_name.to_s.strip.empty?
|
|
111
|
+
|
|
112
|
+
arg_str = assoc_name.to_s
|
|
113
|
+
arg_plural = ActiveSupport::Inflector.pluralize(arg_str) == arg_str
|
|
114
|
+
|
|
115
|
+
target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
|
|
116
|
+
|
|
117
|
+
assoc_singular = ActiveSupport::Inflector.singularize(arg_str)
|
|
118
|
+
|
|
119
|
+
lk = if local_key
|
|
120
|
+
local_key.to_sym
|
|
121
|
+
else
|
|
122
|
+
base = assoc_singular
|
|
123
|
+
(arg_plural ? "#{base}_ids" : "#{base}_id").to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
fk = if foreign_key
|
|
127
|
+
foreign_key.to_sym
|
|
128
|
+
else
|
|
129
|
+
"#{assoc_singular}_id".to_sym
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
normalized_async = nil
|
|
133
|
+
unless async_ref.nil?
|
|
134
|
+
normalized_async = async_ref ? true : false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
__se_register_join!(
|
|
138
|
+
name: assoc_name,
|
|
139
|
+
collection: target_collection.to_s,
|
|
140
|
+
local_key: lk,
|
|
141
|
+
foreign_key: fk,
|
|
142
|
+
kind: :belongs_to_many,
|
|
143
|
+
async_ref: normalized_async
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
__se_define_assoc_reader_if_needed!(assoc_name)
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
class_methods do
|
|
153
|
+
# Declare a has_one association with deterministic defaults (no name-based plurality heuristics).
|
|
154
|
+
#
|
|
155
|
+
# Defaults when options are omitted:
|
|
156
|
+
# - collection: pluralized first argument
|
|
157
|
+
# - local_key: current collection's singular + "_id"
|
|
158
|
+
# - foreign_key: current collection's singular + "_id"
|
|
159
|
+
#
|
|
160
|
+
# Note: `has_one` does not contribute a schema reference; only `belongs_to`/`belongs_to_many` do.
|
|
161
|
+
#
|
|
162
|
+
# @param name [#to_sym]
|
|
163
|
+
# @param collection [#to_s, nil]
|
|
164
|
+
# @param local_key [#to_sym, nil]
|
|
165
|
+
# @param foreign_key [#to_sym, nil]
|
|
166
|
+
# @return [void]
|
|
167
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
168
|
+
def has_one(name, collection: nil, local_key: nil, foreign_key: nil)
|
|
169
|
+
assoc_name = name.to_sym
|
|
170
|
+
raise ArgumentError, 'has_one name must be non-empty' if assoc_name.to_s.strip.empty?
|
|
171
|
+
|
|
172
|
+
arg_str = assoc_name.to_s
|
|
173
|
+
target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
|
|
174
|
+
|
|
175
|
+
current_collection = respond_to?(:collection) ? collection : nil
|
|
176
|
+
if current_collection.nil?
|
|
177
|
+
demod = self.name ? ActiveSupport::Inflector.demodulize(self.name) : ''
|
|
178
|
+
current_collection = ActiveSupport::Inflector.underscore(demod).to_s
|
|
179
|
+
end
|
|
180
|
+
current_singular = ActiveSupport::Inflector.singularize(current_collection.to_s)
|
|
181
|
+
|
|
182
|
+
lk = local_key ? local_key.to_sym : "#{current_singular}_id".to_sym
|
|
183
|
+
fk = foreign_key ? foreign_key.to_sym : "#{current_singular}_id".to_sym
|
|
184
|
+
|
|
185
|
+
__se_register_join!(
|
|
186
|
+
name: assoc_name,
|
|
187
|
+
collection: target_collection.to_s,
|
|
188
|
+
local_key: lk,
|
|
189
|
+
foreign_key: fk,
|
|
190
|
+
kind: :has_one
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
__se_define_assoc_reader_if_needed!(assoc_name)
|
|
194
|
+
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
198
|
+
|
|
199
|
+
# Declare a has_many association with deterministic defaults (no name-based plurality heuristics).
|
|
200
|
+
#
|
|
201
|
+
# Defaults when options are omitted:
|
|
202
|
+
# - collection: pluralized first argument
|
|
203
|
+
# - local_key: current collection's singular + "_id"
|
|
204
|
+
# - foreign_key: current collection's singular + "_ids"
|
|
205
|
+
#
|
|
206
|
+
# Note: `has_many` does not contribute a schema reference; only `belongs_to`/`belongs_to_many` do.
|
|
207
|
+
#
|
|
208
|
+
# @param name [#to_sym]
|
|
209
|
+
# @param collection [#to_s, nil]
|
|
210
|
+
# @param local_key [#to_sym, nil]
|
|
211
|
+
# @param foreign_key [#to_sym, nil]
|
|
212
|
+
# @return [void]
|
|
213
|
+
# rubocop:disable Naming/PredicatePrefix
|
|
214
|
+
def has_many(name, collection: nil, local_key: nil, foreign_key: nil)
|
|
215
|
+
assoc_name = name.to_sym
|
|
216
|
+
raise ArgumentError, 'has_many name must be non-empty' if assoc_name.to_s.strip.empty?
|
|
217
|
+
|
|
218
|
+
arg_str = assoc_name.to_s
|
|
219
|
+
target_collection = (collection ? collection.to_s : ActiveSupport::Inflector.pluralize(arg_str))
|
|
220
|
+
|
|
221
|
+
current_collection = respond_to?(:collection) ? collection : nil
|
|
222
|
+
if current_collection.nil?
|
|
223
|
+
demod = self.name ? ActiveSupport::Inflector.demodulize(self.name) : ''
|
|
224
|
+
current_collection = ActiveSupport::Inflector.underscore(demod).to_s
|
|
225
|
+
end
|
|
226
|
+
current_singular = ActiveSupport::Inflector.singularize(current_collection.to_s)
|
|
227
|
+
|
|
228
|
+
lk = local_key ? local_key.to_sym : "#{current_singular}_id".to_sym
|
|
229
|
+
fk = foreign_key ? foreign_key.to_sym : "#{current_singular}_ids".to_sym
|
|
230
|
+
|
|
231
|
+
__se_register_join!(
|
|
232
|
+
name: assoc_name,
|
|
233
|
+
collection: target_collection.to_s,
|
|
234
|
+
local_key: lk,
|
|
235
|
+
foreign_key: fk,
|
|
236
|
+
kind: :has_many
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
__se_define_assoc_reader_if_needed!(assoc_name)
|
|
240
|
+
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
# rubocop:enable Naming/PredicatePrefix
|
|
244
|
+
|
|
245
|
+
# NOTE: legacy `has` DSL has been removed; no replacement shim is provided.
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class_methods do
|
|
249
|
+
# Define an instance reader for a declared association that resolves
|
|
250
|
+
# the referenced records through the join config.
|
|
251
|
+
#
|
|
252
|
+
# The reader follows these rules:
|
|
253
|
+
# - has_many :name => returns Relation scoped by foreign_key
|
|
254
|
+
# - has_one :name => returns a single record or nil
|
|
255
|
+
# - belongs_to :singular => returns a single record or nil
|
|
256
|
+
# - belongs_to :plural (local_key array) => returns Relation
|
|
257
|
+
#
|
|
258
|
+
# For array-typed foreign keys on the target, a single local scalar
|
|
259
|
+
# value is wrapped into an Array to preserve IN semantics.
|
|
260
|
+
#
|
|
261
|
+
# @param assoc_name [Symbol]
|
|
262
|
+
# @return [void]
|
|
263
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
264
|
+
def __se_define_assoc_reader_if_needed!(assoc_name)
|
|
265
|
+
return if method_defined?(assoc_name)
|
|
266
|
+
|
|
267
|
+
define_method(assoc_name) do
|
|
268
|
+
cfg = self.class.join_for(assoc_name)
|
|
269
|
+
target_klass = SearchEngine.collection_for(cfg[:collection])
|
|
270
|
+
|
|
271
|
+
local_key = cfg[:local_key].to_sym
|
|
272
|
+
foreign_key = cfg[:foreign_key].to_sym
|
|
273
|
+
kind = (cfg[:kind] || :belongs_to).to_sym
|
|
274
|
+
|
|
275
|
+
# Prefer reader when available; fall back to ivar set during hydration
|
|
276
|
+
local_value = if respond_to?(local_key)
|
|
277
|
+
public_send(local_key)
|
|
278
|
+
else
|
|
279
|
+
instance_variable_get("@#{local_key}")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# When foreign key on target is an array type, wrap scalar local values
|
|
283
|
+
begin
|
|
284
|
+
attrs = target_klass.respond_to?(:attributes) ? (target_klass.attributes || {}) : {}
|
|
285
|
+
fk_is_array = attrs[foreign_key].is_a?(Array) && attrs[foreign_key].size == 1
|
|
286
|
+
rescue StandardError
|
|
287
|
+
fk_is_array = false
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
normalized = local_value
|
|
291
|
+
normalized = [normalized] if fk_is_array && !normalized.nil? && !normalized.is_a?(Array)
|
|
292
|
+
|
|
293
|
+
case kind
|
|
294
|
+
when :belongs_to_many
|
|
295
|
+
# For nil local values, return an always-empty relation via id:nil
|
|
296
|
+
return target_klass.where(id: nil) if normalized.nil?
|
|
297
|
+
|
|
298
|
+
target_klass.where(foreign_key => normalized)
|
|
299
|
+
when :has_many
|
|
300
|
+
# For nil local values, return an always-empty relation via id:nil
|
|
301
|
+
return target_klass.where(id: nil) if normalized.nil?
|
|
302
|
+
|
|
303
|
+
target_klass.where(foreign_key => normalized)
|
|
304
|
+
when :has_one
|
|
305
|
+
# Singular has_one: nil => nil; Array => first match via find_by semantics
|
|
306
|
+
return nil if normalized.nil?
|
|
307
|
+
|
|
308
|
+
return target_klass.where(foreign_key => normalized).first if normalized.is_a?(Array)
|
|
309
|
+
|
|
310
|
+
target_klass.find_by(foreign_key => normalized)
|
|
311
|
+
else # :belongs_to
|
|
312
|
+
# Singular belongs_to: nil => nil; Array => Relation
|
|
313
|
+
return nil if normalized.nil?
|
|
314
|
+
|
|
315
|
+
if normalized.is_a?(Array)
|
|
316
|
+
# Empty local array => empty relation
|
|
317
|
+
return target_klass.where(id: nil) if normalized.empty?
|
|
318
|
+
|
|
319
|
+
return target_klass.where(foreign_key => normalized)
|
|
320
|
+
end
|
|
321
|
+
target_klass.find_by(foreign_key => normalized)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
private :__se_define_assoc_reader_if_needed!
|
|
326
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
class_methods do
|
|
330
|
+
# Internal: choose foreign_key for belongs_to when not specified.
|
|
331
|
+
# Prefers `<current>_ids` if declared on target; else `<current>_id`.
|
|
332
|
+
# If target has a matching back association referencing this model's collection,
|
|
333
|
+
# prefer `_ids` when that back association is `has_many`, and `_id` when it is `has_one`.
|
|
334
|
+
def __se_guess_fk_for_belongs_to!(target_collection, current_singular)
|
|
335
|
+
# Try to resolve target model to inspect declared attributes and has-configs.
|
|
336
|
+
target_klass = nil
|
|
337
|
+
begin
|
|
338
|
+
target_klass = SearchEngine.collection_for(target_collection)
|
|
339
|
+
rescue StandardError
|
|
340
|
+
target_klass = nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
ids_candidate = "#{current_singular}_ids".to_sym
|
|
344
|
+
id_candidate = "#{current_singular}_id".to_sym
|
|
345
|
+
|
|
346
|
+
if target_klass.respond_to?(:attributes)
|
|
347
|
+
attrs = target_klass.attributes || {}
|
|
348
|
+
return ids_candidate if attrs.key?(ids_candidate)
|
|
349
|
+
return id_candidate if attrs.key?(id_candidate)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Inspect target joins to see if it declares a has_one/has_many back to our collection.
|
|
353
|
+
if target_klass.respond_to?(:joins_config)
|
|
354
|
+
begin
|
|
355
|
+
cfgs = target_klass.joins_config || {}
|
|
356
|
+
current_coll_name = respond_to?(:collection) ? (collection || '').to_s : ''
|
|
357
|
+
back = cfgs.values.find do |c|
|
|
358
|
+
c[:collection].to_s == current_coll_name && %w[has_one has_many].include?(c[:kind].to_s)
|
|
359
|
+
end
|
|
360
|
+
if back
|
|
361
|
+
k = back[:kind].to_s
|
|
362
|
+
case k
|
|
363
|
+
when 'has_many'
|
|
364
|
+
return ids_candidate
|
|
365
|
+
when 'has_one'
|
|
366
|
+
return id_candidate
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
rescue StandardError
|
|
370
|
+
# ignore
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
id_candidate
|
|
375
|
+
end
|
|
376
|
+
private :__se_guess_fk_for_belongs_to!
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
class_methods do
|
|
380
|
+
# Internal: common registrar used by belongs_to/has and legacy join.
|
|
381
|
+
# Validates, freezes, and stores config with copy-on-write.
|
|
382
|
+
def __se_register_join!(name:, collection:, local_key:, foreign_key:, kind: :belongs_to, async_ref: nil)
|
|
383
|
+
assoc_name = name.to_sym
|
|
384
|
+
raise ArgumentError, 'join name must be non-empty' if assoc_name.to_s.strip.empty?
|
|
385
|
+
|
|
386
|
+
coll = collection.to_s
|
|
387
|
+
raise ArgumentError, 'collection must be a non-empty String' if coll.strip.empty?
|
|
388
|
+
|
|
389
|
+
lk = local_key.to_sym
|
|
390
|
+
fk = foreign_key.to_sym
|
|
391
|
+
|
|
392
|
+
# Validate local_key against declared attributes when available (allow :id implicitly)
|
|
393
|
+
if instance_variable_defined?(:@attributes) && lk != :id && !(@attributes || {}).key?(lk)
|
|
394
|
+
raise SearchEngine::Errors::InvalidField,
|
|
395
|
+
"Unknown local_key :#{lk} for #{self}. Declare 'attribute :#{lk}, :integer' first."
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
rec = {
|
|
399
|
+
name: assoc_name,
|
|
400
|
+
collection: coll,
|
|
401
|
+
local_key: lk,
|
|
402
|
+
foreign_key: fk,
|
|
403
|
+
kind: kind.to_sym
|
|
404
|
+
}.freeze
|
|
405
|
+
|
|
406
|
+
# Extend with async_ref when provided (belongs_to only). Keep record frozen at the end.
|
|
407
|
+
unless async_ref.nil?
|
|
408
|
+
base = rec.dup
|
|
409
|
+
base[:async_ref] = async_ref ? true : false
|
|
410
|
+
rec = base.freeze
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
current = @joins_config || {}
|
|
414
|
+
if current.key?(assoc_name)
|
|
415
|
+
raise ArgumentError,
|
|
416
|
+
"Join :#{assoc_name} already defined for #{self}. " \
|
|
417
|
+
'Use a different name or remove the previous declaration.'
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# copy-on-write write path
|
|
421
|
+
new_map = current.dup
|
|
422
|
+
new_map[assoc_name] = rec
|
|
423
|
+
@joins_config = new_map.freeze
|
|
424
|
+
|
|
425
|
+
# lightweight instrumentation (no-op if AS::N is unavailable)
|
|
426
|
+
SearchEngine::Instrumentation.instrument(
|
|
427
|
+
'search_engine.joins.declared',
|
|
428
|
+
model: self.name, name: assoc_name, collection: coll
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
private :__se_register_join!
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
class_methods do
|
|
437
|
+
# Declare a joinable association for server-side joins.
|
|
438
|
+
# @param name [#to_sym]
|
|
439
|
+
# @param collection [#to_s]
|
|
440
|
+
# @param local_key [#to_sym]
|
|
441
|
+
# @param foreign_key [#to_sym]
|
|
442
|
+
# @return [void]
|
|
443
|
+
def join(name, collection:, local_key:, foreign_key:)
|
|
444
|
+
__se_register_join!(
|
|
445
|
+
name: name,
|
|
446
|
+
collection: collection,
|
|
447
|
+
local_key: local_key,
|
|
448
|
+
foreign_key: foreign_key,
|
|
449
|
+
kind: :belongs_to
|
|
450
|
+
)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
class_methods do
|
|
455
|
+
# Read-only view of join declarations for this class.
|
|
456
|
+
# @return [Hash{Symbol=>Hash}]
|
|
457
|
+
def joins_config
|
|
458
|
+
(@joins_config || {}).dup.freeze
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
class_methods do
|
|
463
|
+
# Lookup a single join configuration by name.
|
|
464
|
+
# @param name [#to_sym]
|
|
465
|
+
# @return [Hash]
|
|
466
|
+
# @raise [SearchEngine::Errors::UnknownJoin]
|
|
467
|
+
def join_for(name)
|
|
468
|
+
key = name.to_sym
|
|
469
|
+
cfg = (@joins_config || {})[key]
|
|
470
|
+
return cfg if cfg
|
|
471
|
+
|
|
472
|
+
available = (@joins_config || {}).keys
|
|
473
|
+
raise SearchEngine::Errors::UnknownJoin,
|
|
474
|
+
"Unknown join :#{key} for #{self}. Available: #{available.inspect}."
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|