parse-stack-next 4.5.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/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
module AtlasSearch
|
|
9
|
+
# Builder for constructing $search aggregation pipeline stages.
|
|
10
|
+
# Supports fluent interface for complex queries.
|
|
11
|
+
#
|
|
12
|
+
# @example Simple text search
|
|
13
|
+
# builder = SearchBuilder.new(index_name: "default")
|
|
14
|
+
# builder.text(query: "love", path: :title)
|
|
15
|
+
# stage = builder.build
|
|
16
|
+
# # => { "$search" => { "index" => "default", "text" => { "query" => "love", "path" => "title" } } }
|
|
17
|
+
#
|
|
18
|
+
# @example Complex compound query
|
|
19
|
+
# builder = SearchBuilder.new
|
|
20
|
+
# builder.text(query: "love", path: [:title, :lyrics])
|
|
21
|
+
# builder.phrase(query: "broken heart", path: :lyrics, slop: 2)
|
|
22
|
+
# builder.with_highlight(path: :lyrics)
|
|
23
|
+
# stage = builder.build
|
|
24
|
+
class SearchBuilder
|
|
25
|
+
# Maximum length of a regex or wildcard query string. Atlas Search uses
|
|
26
|
+
# Lucene's bounded regex evaluator; long patterns and full-string
|
|
27
|
+
# wildcards force a state-machine explosion or whole-index scan and can
|
|
28
|
+
# be used to DoS the search node.
|
|
29
|
+
MAX_PATTERN_LENGTH = 256
|
|
30
|
+
|
|
31
|
+
attr_reader :index_name, :operators, :highlight_config, :count_config
|
|
32
|
+
|
|
33
|
+
def initialize(index_name: nil)
|
|
34
|
+
@index_name = index_name || Parse::AtlasSearch.default_index || "default"
|
|
35
|
+
@operators = []
|
|
36
|
+
@highlight_config = nil
|
|
37
|
+
@count_config = nil
|
|
38
|
+
@fuzzy_config = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Add a text search operator
|
|
42
|
+
# @param query [String] the search query
|
|
43
|
+
# @param path [String, Symbol, Array, Hash] field(s) to search
|
|
44
|
+
# @param fuzzy [Boolean, Hash] fuzzy matching options
|
|
45
|
+
# @param score [Hash] custom score modifiers
|
|
46
|
+
# @param synonyms [String] synonym mapping name
|
|
47
|
+
# @return [self] for chaining
|
|
48
|
+
def text(query:, path:, fuzzy: nil, score: nil, synonyms: nil)
|
|
49
|
+
validate_query_length!(query, "text")
|
|
50
|
+
operator = {
|
|
51
|
+
"text" => {
|
|
52
|
+
"query" => query,
|
|
53
|
+
"path" => normalize_path(path),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if fuzzy
|
|
58
|
+
operator["text"]["fuzzy"] = fuzzy.is_a?(Hash) ? fuzzy : { "maxEdits" => 2 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
operator["text"]["score"] = score if score
|
|
62
|
+
operator["text"]["synonyms"] = synonyms if synonyms
|
|
63
|
+
|
|
64
|
+
@operators << operator
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Add a phrase search operator
|
|
69
|
+
# @param query [String] the phrase to search for
|
|
70
|
+
# @param path [String, Symbol, Array] field(s) to search
|
|
71
|
+
# @param slop [Integer] number of words between phrase terms (default: 0)
|
|
72
|
+
# @return [self] for chaining
|
|
73
|
+
def phrase(query:, path:, slop: nil)
|
|
74
|
+
validate_query_length!(query, "phrase")
|
|
75
|
+
operator = {
|
|
76
|
+
"phrase" => {
|
|
77
|
+
"query" => query,
|
|
78
|
+
"path" => normalize_path(path),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
operator["phrase"]["slop"] = slop if slop
|
|
83
|
+
|
|
84
|
+
@operators << operator
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Add an autocomplete operator (requires autocomplete index type)
|
|
89
|
+
# @param query [String] the partial text to autocomplete
|
|
90
|
+
# @param path [String, Symbol] the field with autocomplete index
|
|
91
|
+
# @param fuzzy [Boolean, Hash] fuzzy matching options
|
|
92
|
+
# @param token_order [String] "any" or "sequential"
|
|
93
|
+
# @return [self] for chaining
|
|
94
|
+
def autocomplete(query:, path:, fuzzy: nil, token_order: nil)
|
|
95
|
+
validate_query_length!(query, "autocomplete")
|
|
96
|
+
operator = {
|
|
97
|
+
"autocomplete" => {
|
|
98
|
+
"query" => query,
|
|
99
|
+
"path" => path.to_s,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if fuzzy
|
|
104
|
+
operator["autocomplete"]["fuzzy"] = fuzzy.is_a?(Hash) ? fuzzy : {
|
|
105
|
+
"maxEdits" => 1,
|
|
106
|
+
"prefixLength" => 1,
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
operator["autocomplete"]["tokenOrder"] = token_order if token_order
|
|
111
|
+
|
|
112
|
+
@operators << operator
|
|
113
|
+
self
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Add a wildcard search operator
|
|
117
|
+
# @param query [String] the wildcard pattern (* and ? supported)
|
|
118
|
+
# @param path [String, Symbol, Array] field(s) to search
|
|
119
|
+
# @param allow_analyzed_field [Boolean] allow searching analyzed fields
|
|
120
|
+
# @return [self] for chaining
|
|
121
|
+
# @raise [ArgumentError] if `query` is empty, too long, or begins with
|
|
122
|
+
# a leading wildcard (`*` or `?`) which forces a full-index scan.
|
|
123
|
+
def wildcard(query:, path:, allow_analyzed_field: nil)
|
|
124
|
+
validate_pattern!(query, kind: "wildcard")
|
|
125
|
+
operator = {
|
|
126
|
+
"wildcard" => {
|
|
127
|
+
"query" => query,
|
|
128
|
+
"path" => normalize_path(path),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
operator["wildcard"]["allowAnalyzedField"] = allow_analyzed_field unless allow_analyzed_field.nil?
|
|
133
|
+
|
|
134
|
+
@operators << operator
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Add a regex search operator
|
|
139
|
+
# @param query [String] the regex pattern
|
|
140
|
+
# @param path [String, Symbol, Array] field(s) to search
|
|
141
|
+
# @param allow_analyzed_field [Boolean] allow searching analyzed fields
|
|
142
|
+
# @return [self] for chaining
|
|
143
|
+
# @raise [ArgumentError] if `query` is empty, too long, or starts with
|
|
144
|
+
# an unbounded match (`.*`, `.+`, `*`, `?`) that would scan the full
|
|
145
|
+
# index.
|
|
146
|
+
def regex(query:, path:, allow_analyzed_field: nil)
|
|
147
|
+
validate_pattern!(query, kind: "regex")
|
|
148
|
+
operator = {
|
|
149
|
+
"regex" => {
|
|
150
|
+
"query" => query,
|
|
151
|
+
"path" => normalize_path(path),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
operator["regex"]["allowAnalyzedField"] = allow_analyzed_field unless allow_analyzed_field.nil?
|
|
156
|
+
|
|
157
|
+
@operators << operator
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Add a range search operator for numeric/date fields
|
|
162
|
+
# @param path [String, Symbol] the field to search
|
|
163
|
+
# @param gt [Numeric, Time, Date] greater than value
|
|
164
|
+
# @param gte [Numeric, Time, Date] greater than or equal value
|
|
165
|
+
# @param lt [Numeric, Time, Date] less than value
|
|
166
|
+
# @param lte [Numeric, Time, Date] less than or equal value
|
|
167
|
+
# @return [self] for chaining
|
|
168
|
+
def range(path:, gt: nil, gte: nil, lt: nil, lte: nil)
|
|
169
|
+
operator = {
|
|
170
|
+
"range" => {
|
|
171
|
+
"path" => path.to_s,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
operator["range"]["gt"] = format_range_value(gt) if gt
|
|
176
|
+
operator["range"]["gte"] = format_range_value(gte) if gte
|
|
177
|
+
operator["range"]["lt"] = format_range_value(lt) if lt
|
|
178
|
+
operator["range"]["lte"] = format_range_value(lte) if lte
|
|
179
|
+
|
|
180
|
+
@operators << operator
|
|
181
|
+
self
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Add an exists operator to match documents where field exists
|
|
185
|
+
# @param path [String, Symbol] the field to check
|
|
186
|
+
# @return [self] for chaining
|
|
187
|
+
def exists(path:)
|
|
188
|
+
@operators << { "exists" => { "path" => path.to_s } }
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Atlas Search `geoShape` operator. Filters documents where the
|
|
193
|
+
# indexed geometry has the specified relation to a query geometry.
|
|
194
|
+
# Requires the indexed field to be mapped with `{type: "geo",
|
|
195
|
+
# indexShapes: true}`.
|
|
196
|
+
#
|
|
197
|
+
# Note: Atlas Search uses Cartesian (planar) distance, NOT the
|
|
198
|
+
# 2dsphere geodesic distance used by core MongoDB geo operators.
|
|
199
|
+
# For shapes spanning large areas the two engines can return
|
|
200
|
+
# different result sets.
|
|
201
|
+
#
|
|
202
|
+
# @param path [String, Symbol] the indexed `geo` field.
|
|
203
|
+
# @param relation [Symbol, String] one of :contains, :disjoint,
|
|
204
|
+
# :intersects, :within. `:within` is not valid with LineString /
|
|
205
|
+
# Point query geometries.
|
|
206
|
+
# @param geometry [Hash, Parse::Polygon, Parse::GeoPoint,
|
|
207
|
+
# Parse::GeoJSON::Geometry] the query geometry. Parse-native
|
|
208
|
+
# types are auto-converted to their GeoJSON form.
|
|
209
|
+
# @param score [Hash, nil] optional `score` modifier.
|
|
210
|
+
# @return [self] for chaining
|
|
211
|
+
def geo_shape(path:, relation:, geometry:, score: nil)
|
|
212
|
+
op = {
|
|
213
|
+
"path" => path.to_s,
|
|
214
|
+
"relation" => relation.to_s,
|
|
215
|
+
"geometry" => coerce_geojson_geometry(geometry),
|
|
216
|
+
}
|
|
217
|
+
op["score"] = score if score
|
|
218
|
+
@operators << { "geoShape" => op }
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Atlas Search `geoWithin` operator. Returns documents whose
|
|
223
|
+
# indexed point is inside the supplied region. Exactly one of
|
|
224
|
+
# `box:`, `circle:`, `geometry:` must be provided.
|
|
225
|
+
#
|
|
226
|
+
# - `box`: `[bottom_left, top_right]` — each entry may be a
|
|
227
|
+
# {Parse::GeoPoint} or a GeoJSON Point Hash.
|
|
228
|
+
# - `circle`: `{center: <GeoPoint|Hash>, radius: <meters>}`.
|
|
229
|
+
# Radius is measured in meters and must be non-negative.
|
|
230
|
+
# - `geometry`: a GeoJSON Polygon or MultiPolygon (Hash, a
|
|
231
|
+
# {Parse::Polygon}, or {Parse::GeoJSON::MultiPolygon}).
|
|
232
|
+
#
|
|
233
|
+
# @param path [String, Symbol] the indexed `geo` field.
|
|
234
|
+
# @param box [Array, nil] `[bottom_left, top_right]` point pair.
|
|
235
|
+
# @param circle [Hash, nil] `{center:, radius:}`.
|
|
236
|
+
# @param geometry [Hash, Parse::Polygon, Parse::GeoJSON::Geometry, nil]
|
|
237
|
+
# @param score [Hash, nil] optional `score` modifier.
|
|
238
|
+
# @return [self] for chaining
|
|
239
|
+
def geo_within(path:, box: nil, circle: nil, geometry: nil, score: nil)
|
|
240
|
+
provided = [box, circle, geometry].count { |v| !v.nil? }
|
|
241
|
+
if provided != 1
|
|
242
|
+
raise ArgumentError, "[Parse::AtlasSearch] geo_within requires exactly one of " \
|
|
243
|
+
"box:, circle:, or geometry: (got #{provided})."
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
op = { "path" => path.to_s }
|
|
247
|
+
op["score"] = score if score
|
|
248
|
+
|
|
249
|
+
if box
|
|
250
|
+
unless box.is_a?(Array) && box.length == 2
|
|
251
|
+
raise ArgumentError, "[Parse::AtlasSearch] geo_within `box:` must be [bottom_left, top_right]."
|
|
252
|
+
end
|
|
253
|
+
op["box"] = {
|
|
254
|
+
"bottomLeft" => coerce_geojson_point(box[0]),
|
|
255
|
+
"topRight" => coerce_geojson_point(box[1]),
|
|
256
|
+
}
|
|
257
|
+
elsif circle
|
|
258
|
+
unless circle.is_a?(Hash)
|
|
259
|
+
raise ArgumentError, "[Parse::AtlasSearch] geo_within `circle:` must be a Hash."
|
|
260
|
+
end
|
|
261
|
+
center = circle[:center] || circle["center"]
|
|
262
|
+
radius = circle[:radius] || circle["radius"]
|
|
263
|
+
unless radius.is_a?(Numeric) && radius >= 0
|
|
264
|
+
raise ArgumentError, "[Parse::AtlasSearch] geo_within `circle: { radius: }` must be a non-negative number (meters)."
|
|
265
|
+
end
|
|
266
|
+
op["circle"] = { "center" => coerce_geojson_point(center), "radius" => radius.to_f }
|
|
267
|
+
else
|
|
268
|
+
op["geometry"] = coerce_geojson_geometry(geometry)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
@operators << { "geoWithin" => op }
|
|
272
|
+
self
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Atlas Search `near` operator on a geo path. SCORING operator —
|
|
276
|
+
# blends "distance from origin" into the document score; it does
|
|
277
|
+
# not strictly filter by distance. Combine with a `compound.must`
|
|
278
|
+
# text/exists clause to bound the result set.
|
|
279
|
+
#
|
|
280
|
+
# `pivot` is the distance (in meters) at which the score is halved:
|
|
281
|
+
# `score = pivot / (pivot + distance)`. Smaller pivot = steeper
|
|
282
|
+
# falloff, more weight on the closest hits.
|
|
283
|
+
#
|
|
284
|
+
# @param path [String, Symbol] the indexed `geo` field.
|
|
285
|
+
# @param origin [Parse::GeoPoint, Hash, Array] anchor point.
|
|
286
|
+
# @param pivot [Numeric] half-score distance in meters.
|
|
287
|
+
# @param score [Hash, nil] optional `score` modifier (advanced).
|
|
288
|
+
# @return [self] for chaining
|
|
289
|
+
def near(path:, origin:, pivot:, score: nil)
|
|
290
|
+
unless pivot.is_a?(Numeric) && pivot > 0
|
|
291
|
+
raise ArgumentError, "[Parse::AtlasSearch] near `pivot:` must be a positive number (meters)."
|
|
292
|
+
end
|
|
293
|
+
op = {
|
|
294
|
+
"path" => path.to_s,
|
|
295
|
+
"origin" => coerce_geojson_point(origin),
|
|
296
|
+
"pivot" => pivot.to_f,
|
|
297
|
+
}
|
|
298
|
+
op["score"] = score if score
|
|
299
|
+
@operators << { "near" => op }
|
|
300
|
+
self
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Add global fuzzy configuration for subsequent text operators
|
|
304
|
+
# @param max_edits [Integer] maximum edit distance (1 or 2)
|
|
305
|
+
# @param prefix_length [Integer] number of characters that must match exactly
|
|
306
|
+
# @param max_expansions [Integer] maximum number of variations to generate
|
|
307
|
+
# @return [self] for chaining
|
|
308
|
+
def with_fuzzy(max_edits: 2, prefix_length: 0, max_expansions: 50)
|
|
309
|
+
@fuzzy_config = {
|
|
310
|
+
"maxEdits" => max_edits,
|
|
311
|
+
"prefixLength" => prefix_length,
|
|
312
|
+
"maxExpansions" => max_expansions,
|
|
313
|
+
}
|
|
314
|
+
self
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Enable highlighting for search results
|
|
318
|
+
# @param path [String, Symbol, Array] field(s) to highlight
|
|
319
|
+
# @param max_chars_to_examine [Integer] max characters to analyze for highlights
|
|
320
|
+
# @param max_num_passages [Integer] max number of highlight passages
|
|
321
|
+
# @return [self] for chaining
|
|
322
|
+
def with_highlight(path: nil, max_chars_to_examine: nil, max_num_passages: nil)
|
|
323
|
+
@highlight_config = {}
|
|
324
|
+
@highlight_config["path"] = normalize_path(path) if path
|
|
325
|
+
@highlight_config["maxCharsToExamine"] = max_chars_to_examine if max_chars_to_examine
|
|
326
|
+
@highlight_config["maxNumPassages"] = max_num_passages if max_num_passages
|
|
327
|
+
self
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Enable count metadata in results
|
|
331
|
+
# @param type [String] count type - "total" or "lowerBound"
|
|
332
|
+
# @return [self] for chaining
|
|
333
|
+
def with_count(type: "total")
|
|
334
|
+
@count_config = { "type" => type }
|
|
335
|
+
self
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Build the $search aggregation stage
|
|
339
|
+
# @return [Hash] the $search stage
|
|
340
|
+
# @raise [InvalidSearchParameters] if no operators have been added
|
|
341
|
+
def build
|
|
342
|
+
if @operators.empty?
|
|
343
|
+
raise InvalidSearchParameters, "At least one search operator must be specified"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
search_stage = { "$search" => { "index" => @index_name } }
|
|
347
|
+
|
|
348
|
+
# Single operator or compound
|
|
349
|
+
if @operators.length == 1
|
|
350
|
+
search_stage["$search"].merge!(@operators.first)
|
|
351
|
+
else
|
|
352
|
+
# Multiple operators become a compound query with "must" clauses
|
|
353
|
+
search_stage["$search"]["compound"] = { "must" => @operators }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Add highlight config
|
|
357
|
+
search_stage["$search"]["highlight"] = @highlight_config if @highlight_config
|
|
358
|
+
|
|
359
|
+
# Add count config
|
|
360
|
+
search_stage["$search"]["count"] = @count_config if @count_config
|
|
361
|
+
|
|
362
|
+
search_stage
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Build a compound query explicitly
|
|
366
|
+
# @param must [Array, Hash] operators that must match
|
|
367
|
+
# @param must_not [Array, Hash] operators that must not match
|
|
368
|
+
# @param should [Array, Hash] operators where at least one should match
|
|
369
|
+
# @param filter [Array, Hash] operators for filtering (no scoring impact)
|
|
370
|
+
# @param minimum_should_match [Integer] minimum number of should clauses to match
|
|
371
|
+
# @return [Hash] the $search stage with compound query
|
|
372
|
+
def build_compound(must: nil, must_not: nil, should: nil, filter: nil, minimum_should_match: nil)
|
|
373
|
+
compound = {}
|
|
374
|
+
|
|
375
|
+
compound["must"] = Array.wrap(must).map { |op| extract_operator(op) } if must
|
|
376
|
+
compound["mustNot"] = Array.wrap(must_not).map { |op| extract_operator(op) } if must_not
|
|
377
|
+
compound["should"] = Array.wrap(should).map { |op| extract_operator(op) } if should
|
|
378
|
+
compound["filter"] = Array.wrap(filter).map { |op| extract_operator(op) } if filter
|
|
379
|
+
compound["minimumShouldMatch"] = minimum_should_match if minimum_should_match
|
|
380
|
+
|
|
381
|
+
search_stage = {
|
|
382
|
+
"$search" => {
|
|
383
|
+
"index" => @index_name,
|
|
384
|
+
"compound" => compound,
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
search_stage["$search"]["highlight"] = @highlight_config if @highlight_config
|
|
389
|
+
search_stage["$search"]["count"] = @count_config if @count_config
|
|
390
|
+
|
|
391
|
+
search_stage
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private
|
|
395
|
+
|
|
396
|
+
# Coerce a user-supplied point value to a GeoJSON Point Hash.
|
|
397
|
+
# Accepts Parse::GeoPoint, an already-shaped GeoJSON Point Hash,
|
|
398
|
+
# or a `[longitude, latitude]` Array. The Hash is returned with
|
|
399
|
+
# string keys so it serializes cleanly through `$search`.
|
|
400
|
+
def coerce_geojson_point(value)
|
|
401
|
+
case value
|
|
402
|
+
when Parse::GeoPoint
|
|
403
|
+
{ "type" => "Point", "coordinates" => [value.longitude, value.latitude] }
|
|
404
|
+
when Hash
|
|
405
|
+
h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
|
|
406
|
+
type = h[:type] || h["type"]
|
|
407
|
+
coords = h[:coordinates] || h["coordinates"]
|
|
408
|
+
unless type.to_s == "Point" && coords.is_a?(Array) && coords.length == 2 &&
|
|
409
|
+
coords.all? { |n| n.is_a?(Numeric) }
|
|
410
|
+
raise ArgumentError, "[Parse::AtlasSearch] expected a GeoJSON Point hash."
|
|
411
|
+
end
|
|
412
|
+
{ "type" => "Point", "coordinates" => [coords[0].to_f, coords[1].to_f] }
|
|
413
|
+
when Array
|
|
414
|
+
unless value.length == 2 && value.all? { |n| n.is_a?(Numeric) }
|
|
415
|
+
raise ArgumentError, "[Parse::AtlasSearch] point Array must be [longitude, latitude]."
|
|
416
|
+
end
|
|
417
|
+
{ "type" => "Point", "coordinates" => [value[0].to_f, value[1].to_f] }
|
|
418
|
+
else
|
|
419
|
+
raise ArgumentError, "[Parse::AtlasSearch] cannot coerce #{value.class} to a GeoJSON Point."
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Coerce a user-supplied geometry value to a GeoJSON geometry Hash.
|
|
424
|
+
# Accepts any Parse::GeoJSON::Geometry subclass, Parse::Polygon,
|
|
425
|
+
# Parse::GeoPoint, or a raw GeoJSON Hash (validated minimally).
|
|
426
|
+
def coerce_geojson_geometry(value)
|
|
427
|
+
case value
|
|
428
|
+
when Parse::GeoJSON::Geometry then value.to_geojson
|
|
429
|
+
when Parse::Polygon then value.to_geojson
|
|
430
|
+
when Parse::GeoPoint then value.to_geojson
|
|
431
|
+
when Hash
|
|
432
|
+
h = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
|
|
433
|
+
type = h[:type] || h["type"]
|
|
434
|
+
unless type.is_a?(String) && (h[:coordinates] || h["coordinates"])
|
|
435
|
+
raise ArgumentError, "[Parse::AtlasSearch] GeoJSON geometry hash needs both `type` and `coordinates`."
|
|
436
|
+
end
|
|
437
|
+
{ "type" => type, "coordinates" => (h[:coordinates] || h["coordinates"]) }
|
|
438
|
+
else
|
|
439
|
+
raise ArgumentError, "[Parse::AtlasSearch] cannot coerce #{value.class} to a GeoJSON geometry."
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Reject empty, non-String, or oversized query strings. Applies
|
|
444
|
+
# to every operator that takes a `query:` value (`text`,
|
|
445
|
+
# `autocomplete`, `wildcard`, `regex`, `phrase`). Long patterns
|
|
446
|
+
# are a denial-of-service vector against Atlas Search regardless
|
|
447
|
+
# of operator type.
|
|
448
|
+
def validate_query_length!(query, op_name)
|
|
449
|
+
unless query.is_a?(String) && !query.empty?
|
|
450
|
+
raise ArgumentError, "#{op_name} query must be a non-empty String"
|
|
451
|
+
end
|
|
452
|
+
if query.length > MAX_PATTERN_LENGTH
|
|
453
|
+
raise ArgumentError,
|
|
454
|
+
"#{op_name} query exceeds #{MAX_PATTERN_LENGTH} chars (#{query.length}). " \
|
|
455
|
+
"Long patterns are denial-of-service vectors against Atlas Search."
|
|
456
|
+
end
|
|
457
|
+
nil
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Reject empty, oversized, or leading-wildcard patterns. Leading
|
|
461
|
+
# wildcards on `wildcard` / `regex` operators force Atlas Search to
|
|
462
|
+
# evaluate against every term in the index, which is both very slow
|
|
463
|
+
# and a denial-of-service vector when the input is user-controlled.
|
|
464
|
+
def validate_pattern!(query, kind:)
|
|
465
|
+
validate_query_length!(query, kind)
|
|
466
|
+
if kind == "wildcard"
|
|
467
|
+
if query.start_with?("*") || query.start_with?("?")
|
|
468
|
+
raise ArgumentError,
|
|
469
|
+
"wildcard query may not begin with '*' or '?'; leading wildcards " \
|
|
470
|
+
"force a full-index scan. Anchor the pattern with a literal prefix."
|
|
471
|
+
end
|
|
472
|
+
else # regex
|
|
473
|
+
if query.start_with?(".*") || query.start_with?(".+") ||
|
|
474
|
+
query.start_with?("*") || query.start_with?("?")
|
|
475
|
+
raise ArgumentError,
|
|
476
|
+
"regex query may not begin with '.*', '.+', '*', or '?'; " \
|
|
477
|
+
"unbounded leading matches force a full-index scan. Anchor the " \
|
|
478
|
+
"pattern with a literal prefix."
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
nil
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Reject a `path` value that itself encodes a wildcard scanning
|
|
485
|
+
# every indexed field (`{ "wildcard" => "*" }` or any leading
|
|
486
|
+
# `*`/`?` wildcard). Used to harden pattern operators against
|
|
487
|
+
# the `path` channel — `path: { wildcard: "*" }` reaches every
|
|
488
|
+
# field in the index even when the `query` is anchored. The
|
|
489
|
+
# top-level `Parse::AtlasSearch.search` call uses this for its
|
|
490
|
+
# default-field fallback, but a caller-supplied hash payload
|
|
491
|
+
# to `build_compound` must not be able to opt back in.
|
|
492
|
+
def validate_path_for_pattern!(path, op_name)
|
|
493
|
+
return unless path.is_a?(Hash)
|
|
494
|
+
wildcard = path["wildcard"] || path[:wildcard]
|
|
495
|
+
return if wildcard.nil?
|
|
496
|
+
unless wildcard.is_a?(String) && !wildcard.empty?
|
|
497
|
+
raise ArgumentError, "#{op_name} path.wildcard must be a non-empty String"
|
|
498
|
+
end
|
|
499
|
+
if wildcard.length > MAX_PATTERN_LENGTH
|
|
500
|
+
raise ArgumentError,
|
|
501
|
+
"#{op_name} path.wildcard exceeds #{MAX_PATTERN_LENGTH} chars."
|
|
502
|
+
end
|
|
503
|
+
if wildcard.start_with?("*") || wildcard.start_with?("?")
|
|
504
|
+
raise ArgumentError,
|
|
505
|
+
"#{op_name} path.wildcard may not begin with '*' or '?'; a leading " \
|
|
506
|
+
"wildcard on the path scans every indexed field."
|
|
507
|
+
end
|
|
508
|
+
nil
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Recursively validate the query/path payloads inside an
|
|
512
|
+
# operator hash supplied to {build_compound} via
|
|
513
|
+
# `must:`/`should:`/`filter:`/`must_not:`. The caller-supplied
|
|
514
|
+
# Hash form bypasses {#wildcard}/{#regex}/{#text}/{#autocomplete}
|
|
515
|
+
# entirely, so this is the only gate the structural payload
|
|
516
|
+
# passes through before reaching Atlas Search.
|
|
517
|
+
#
|
|
518
|
+
# Walks the `compound`/`must`/`mustNot`/`should`/`filter`
|
|
519
|
+
# branches one level deep — the SDK-public API does not need
|
|
520
|
+
# deeper-than-one nesting for the compound shapes we support.
|
|
521
|
+
def validate_operator_payload!(op)
|
|
522
|
+
return unless op.is_a?(Hash)
|
|
523
|
+
op.each do |key, value|
|
|
524
|
+
case key.to_s
|
|
525
|
+
when "wildcard"
|
|
526
|
+
next unless value.is_a?(Hash)
|
|
527
|
+
q = value["query"] || value[:query]
|
|
528
|
+
validate_pattern!(q, kind: "wildcard") if q
|
|
529
|
+
validate_path_for_pattern!(value["path"] || value[:path], "wildcard")
|
|
530
|
+
when "regex"
|
|
531
|
+
next unless value.is_a?(Hash)
|
|
532
|
+
q = value["query"] || value[:query]
|
|
533
|
+
validate_pattern!(q, kind: "regex") if q
|
|
534
|
+
validate_path_for_pattern!(value["path"] || value[:path], "regex")
|
|
535
|
+
when "text"
|
|
536
|
+
next unless value.is_a?(Hash)
|
|
537
|
+
q = value["query"] || value[:query]
|
|
538
|
+
validate_query_length!(q, "text") if q
|
|
539
|
+
validate_path_for_pattern!(value["path"] || value[:path], "text")
|
|
540
|
+
when "autocomplete"
|
|
541
|
+
next unless value.is_a?(Hash)
|
|
542
|
+
q = value["query"] || value[:query]
|
|
543
|
+
validate_query_length!(q, "autocomplete") if q
|
|
544
|
+
validate_path_for_pattern!(value["path"] || value[:path], "autocomplete")
|
|
545
|
+
when "phrase"
|
|
546
|
+
next unless value.is_a?(Hash)
|
|
547
|
+
q = value["query"] || value[:query]
|
|
548
|
+
validate_query_length!(q, "phrase") if q
|
|
549
|
+
when "compound"
|
|
550
|
+
next unless value.is_a?(Hash)
|
|
551
|
+
%w[must mustNot should filter].each do |branch|
|
|
552
|
+
Array(value[branch] || value[branch.to_sym]).each { |child| validate_operator_payload!(child) }
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def normalize_path(path)
|
|
559
|
+
case path
|
|
560
|
+
when Array
|
|
561
|
+
path.map(&:to_s)
|
|
562
|
+
when Hash
|
|
563
|
+
# Wildcard path: { "wildcard" => "*" }
|
|
564
|
+
path.transform_keys(&:to_s)
|
|
565
|
+
else
|
|
566
|
+
path.to_s
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def format_range_value(value)
|
|
571
|
+
case value
|
|
572
|
+
when ::Time, ::DateTime
|
|
573
|
+
value.utc.iso8601(3)
|
|
574
|
+
when ::Date
|
|
575
|
+
value.to_time.utc.iso8601(3)
|
|
576
|
+
else
|
|
577
|
+
value
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def extract_operator(op)
|
|
582
|
+
# If it's a SearchBuilder, build it and extract the operator.
|
|
583
|
+
# The nested operators were validated when they were added
|
|
584
|
+
# through #wildcard/#regex/#text/#autocomplete on that builder,
|
|
585
|
+
# so no re-check is required.
|
|
586
|
+
if op.is_a?(SearchBuilder)
|
|
587
|
+
built = op.build
|
|
588
|
+
# Extract the operator from the built stage
|
|
589
|
+
built["$search"].except("index")
|
|
590
|
+
elsif op.is_a?(Hash)
|
|
591
|
+
# Hash operator payload supplied directly by the caller --
|
|
592
|
+
# this is the only path where validate_pattern! has NOT
|
|
593
|
+
# already run. Refuse leading-wildcard regex/wildcard
|
|
594
|
+
# patterns and oversized query strings before forwarding
|
|
595
|
+
# to Atlas Search.
|
|
596
|
+
validate_operator_payload!(op)
|
|
597
|
+
op
|
|
598
|
+
else
|
|
599
|
+
op
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|