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,995 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "pipeline_security"
|
|
5
|
+
require_relative "acl_scope"
|
|
6
|
+
require_relative "clp_scope"
|
|
7
|
+
require_relative "atlas_search/index_manager"
|
|
8
|
+
require_relative "atlas_search/search_builder"
|
|
9
|
+
require_relative "atlas_search/result"
|
|
10
|
+
require_relative "atlas_search/session"
|
|
11
|
+
|
|
12
|
+
module Parse
|
|
13
|
+
# Atlas Search module for MongoDB Atlas full-text search capabilities.
|
|
14
|
+
# Provides direct access to Atlas Search features bypassing Parse Server.
|
|
15
|
+
#
|
|
16
|
+
# @example Enable Atlas Search
|
|
17
|
+
# Parse::MongoDB.configure(uri: "mongodb+srv://...", enabled: true)
|
|
18
|
+
# Parse::AtlasSearch.configure(enabled: true, default_index: "default")
|
|
19
|
+
#
|
|
20
|
+
# @example Full-text search
|
|
21
|
+
# result = Parse::AtlasSearch.search("Song", "love", index: "song_search")
|
|
22
|
+
# result.results.each { |song| puts song.title }
|
|
23
|
+
#
|
|
24
|
+
# @example Autocomplete
|
|
25
|
+
# result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
|
|
26
|
+
# result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]
|
|
27
|
+
#
|
|
28
|
+
# @note Requires the 'mongo' gem and a MongoDB Atlas cluster with Search enabled.
|
|
29
|
+
# Also works with local Atlas deployments created via `atlas deployments setup --type local`.
|
|
30
|
+
module AtlasSearch
|
|
31
|
+
# Error raised when Atlas Search is not available
|
|
32
|
+
class NotAvailable < StandardError; end
|
|
33
|
+
|
|
34
|
+
# Error raised when search index is not found
|
|
35
|
+
class IndexNotFound < StandardError; end
|
|
36
|
+
|
|
37
|
+
# Error raised for invalid search parameters
|
|
38
|
+
class InvalidSearchParameters < StandardError; end
|
|
39
|
+
|
|
40
|
+
# Error raised when the caller did not supply +session_token:+ or
|
|
41
|
+
# +master: true+ and {.require_session_token} is +true+. Atlas
|
|
42
|
+
# Search bypasses Parse Server's ACL evaluation, so the caller
|
|
43
|
+
# must either pass a session token (so the SDK can inject a
|
|
44
|
+
# +_rperm+ +$match+) or explicitly opt into master-key semantics.
|
|
45
|
+
class ACLRequired < StandardError; end
|
|
46
|
+
|
|
47
|
+
# Error raised when {.faceted_search} is called with a +session_token+.
|
|
48
|
+
# +$searchMeta+ returns a single metadata document — bucket
|
|
49
|
+
# counts that include restricted documents and cannot be
|
|
50
|
+
# post-filtered with +$match+ because the matched documents are
|
|
51
|
+
# not in the output stream. ACL-safe faceting requires the search
|
|
52
|
+
# index to tokenize +_rperm+ and a +compound.filter+ injection
|
|
53
|
+
# path; both are deferred to a follow-up release. Callers that
|
|
54
|
+
# need ACL-aware faceting today must either run with +master: true+
|
|
55
|
+
# or implement post-aggregation filtering themselves.
|
|
56
|
+
class FacetedSearchNotACLSafe < StandardError; end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# @!attribute [rw] enabled
|
|
60
|
+
# Feature flag to enable/disable Atlas Search.
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
attr_accessor :enabled
|
|
63
|
+
|
|
64
|
+
# @!attribute [rw] default_index
|
|
65
|
+
# Default search index name to use when none specified.
|
|
66
|
+
# @return [String]
|
|
67
|
+
attr_accessor :default_index
|
|
68
|
+
|
|
69
|
+
# @!attribute [rw] allow_raw
|
|
70
|
+
# Whether `raw: true` is honored on {.search}, {.autocomplete},
|
|
71
|
+
# and {.faceted_search}. When `false` (the default), `raw:` is
|
|
72
|
+
# ignored and callers receive converted Parse-format
|
|
73
|
+
# documents. Even when `true`, internal-fields denylist (cf.
|
|
74
|
+
# {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST}) is
|
|
75
|
+
# ALWAYS stripped — there is no path that returns
|
|
76
|
+
# `_hashed_password`, `_session_token`, etc., regardless of
|
|
77
|
+
# `raw:`.
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
attr_accessor :allow_raw
|
|
80
|
+
|
|
81
|
+
# @!attribute [rw] require_session_token
|
|
82
|
+
# When +true+, {.search}, {.autocomplete}, and
|
|
83
|
+
# {.faceted_search} raise {ACLRequired} unless the caller
|
|
84
|
+
# passes either +session_token:+ or +master: true+. Default:
|
|
85
|
+
# +false+, matching the pre-ACL behavior — a one-time
|
|
86
|
+
# +[Parse::AtlasSearch:SECURITY]+ banner is emitted instead
|
|
87
|
+
# for missing-token calls, the same pattern used by
|
|
88
|
+
# {Parse::Agent} for master-key construction.
|
|
89
|
+
#
|
|
90
|
+
# New deployments are strongly encouraged to flip this to
|
|
91
|
+
# +true+ at startup. The next major release will flip the
|
|
92
|
+
# default.
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
attr_accessor :require_session_token
|
|
95
|
+
|
|
96
|
+
# @!attribute [rw] session_cache_ttl
|
|
97
|
+
# TTL (seconds) for {Session}'s session-token → user-id cache.
|
|
98
|
+
# Default: 3600 (1 hour). Longer values reduce +/users/me+
|
|
99
|
+
# round-trips but extend the window during which a revoked
|
|
100
|
+
# session can still authenticate Atlas Search calls; apps
|
|
101
|
+
# with sub-TTL revocation requirements should call
|
|
102
|
+
# {Session.invalidate} from their logout path.
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
attr_accessor :session_cache_ttl
|
|
105
|
+
|
|
106
|
+
# @!attribute [rw] role_cache_ttl
|
|
107
|
+
# TTL (seconds) for {Session}'s user-id → role-name cache.
|
|
108
|
+
# Default: 120 (2 minutes). Short on purpose: stale role
|
|
109
|
+
# data yields incorrect ACL decisions, so the cache is sized
|
|
110
|
+
# to amortize within a single request/turn but expire well
|
|
111
|
+
# inside the response time the operator notices a role grant.
|
|
112
|
+
# @return [Integer]
|
|
113
|
+
attr_accessor :role_cache_ttl
|
|
114
|
+
|
|
115
|
+
# @!attribute [rw] session_cache
|
|
116
|
+
# Pluggable cache for {Session}'s session-token lookups.
|
|
117
|
+
# Replace with a Redis/Memcached adapter for cross-process
|
|
118
|
+
# sharing; the object must respond to +get(key)+,
|
|
119
|
+
# +set(key, value, ttl:)+, and +invalidate(key)+. Defaults
|
|
120
|
+
# to a process-local {Session::MemoryCache}.
|
|
121
|
+
# @return [#get, #set, #invalidate]
|
|
122
|
+
attr_accessor :session_cache
|
|
123
|
+
|
|
124
|
+
# @!attribute [rw] role_cache
|
|
125
|
+
# Pluggable cache for {Session}'s role-name lookups. See
|
|
126
|
+
# {.session_cache} for the interface contract.
|
|
127
|
+
# @return [#get, #set, #invalidate]
|
|
128
|
+
attr_accessor :role_cache
|
|
129
|
+
|
|
130
|
+
# Configure Atlas Search (uses Parse::MongoDB connection)
|
|
131
|
+
# @param enabled [Boolean] whether to enable Atlas Search (default: true)
|
|
132
|
+
# @param default_index [String] default search index name (default: "default")
|
|
133
|
+
# @param allow_raw [Boolean] whether `raw: true` is honored on
|
|
134
|
+
# search/autocomplete/faceted_search. Defaults to `false`
|
|
135
|
+
# (raw flag ignored) in production-like environments and
|
|
136
|
+
# `true` when RACK_ENV/RAILS_ENV is `development` or `test`.
|
|
137
|
+
# Internal-field stripping runs regardless.
|
|
138
|
+
# @param require_session_token [Boolean] when +true+, library
|
|
139
|
+
# calls without +session_token:+ or +master: true+ raise
|
|
140
|
+
# {ACLRequired}. See {#require_session_token}. Default: +false+.
|
|
141
|
+
# @param session_cache_ttl [Integer] session-token cache TTL
|
|
142
|
+
# (seconds). Default: 3600.
|
|
143
|
+
# @param role_cache_ttl [Integer] role-name cache TTL (seconds).
|
|
144
|
+
# Default: 120.
|
|
145
|
+
# @example
|
|
146
|
+
# Parse::AtlasSearch.configure(enabled: true, default_index: "default")
|
|
147
|
+
def configure(enabled: true,
|
|
148
|
+
default_index: "default",
|
|
149
|
+
allow_raw: nil,
|
|
150
|
+
require_session_token: nil,
|
|
151
|
+
session_cache_ttl: nil,
|
|
152
|
+
role_cache_ttl: nil)
|
|
153
|
+
Parse::MongoDB.require_gem!
|
|
154
|
+
@enabled = enabled
|
|
155
|
+
@default_index = default_index
|
|
156
|
+
@allow_raw = allow_raw.nil? ? default_allow_raw : allow_raw
|
|
157
|
+
@require_session_token = require_session_token unless require_session_token.nil?
|
|
158
|
+
@session_cache_ttl = session_cache_ttl unless session_cache_ttl.nil?
|
|
159
|
+
@role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil?
|
|
160
|
+
IndexManager.clear_cache
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# @!visibility private
|
|
164
|
+
#
|
|
165
|
+
# Default value for {#allow_raw}: permissive only when an
|
|
166
|
+
# explicit non-production environment is signalled. Bare-Ruby
|
|
167
|
+
# processes without `RACK_ENV`/`RAILS_ENV` get the strict
|
|
168
|
+
# default (raw refused) so a forgotten env-var tag can't
|
|
169
|
+
# downgrade security on a production deploy.
|
|
170
|
+
def default_allow_raw
|
|
171
|
+
env = ENV["RACK_ENV"] || ENV["RAILS_ENV"]
|
|
172
|
+
return false if env.nil?
|
|
173
|
+
%w[development test].include?(env)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check if Atlas Search is available and enabled
|
|
177
|
+
# @return [Boolean]
|
|
178
|
+
def available?
|
|
179
|
+
return false unless defined?(Parse::MongoDB)
|
|
180
|
+
Parse::MongoDB.available? && enabled?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check if Atlas Search is enabled
|
|
184
|
+
# @return [Boolean]
|
|
185
|
+
def enabled?
|
|
186
|
+
@enabled == true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Reset Atlas Search configuration to first-load defaults.
|
|
190
|
+
# Clears the session/role caches as well; this is primarily a
|
|
191
|
+
# test helper.
|
|
192
|
+
def reset!
|
|
193
|
+
@enabled = false
|
|
194
|
+
@default_index = "default"
|
|
195
|
+
@allow_raw = default_allow_raw
|
|
196
|
+
@require_session_token = false
|
|
197
|
+
@session_cache_ttl = 3600
|
|
198
|
+
@role_cache_ttl = 120
|
|
199
|
+
@session_cache = Session::MemoryCache.new
|
|
200
|
+
@role_cache = Session::MemoryCache.new
|
|
201
|
+
@master_warned = false
|
|
202
|
+
IndexManager.clear_cache
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# List search indexes for a collection (cached)
|
|
206
|
+
# @param collection_name [String] the Parse collection name
|
|
207
|
+
# @return [Array<Hash>] array of index definitions
|
|
208
|
+
def indexes(collection_name)
|
|
209
|
+
IndexManager.list_indexes(collection_name)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Check if a search index exists and is ready
|
|
213
|
+
# @param collection_name [String] the Parse collection name
|
|
214
|
+
# @param index_name [String] the index name to check (default: default_index)
|
|
215
|
+
# @return [Boolean] true if index exists and is queryable
|
|
216
|
+
def index_ready?(collection_name, index_name = nil)
|
|
217
|
+
IndexManager.index_ready?(collection_name, index_name || @default_index)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Force refresh the index cache for a collection
|
|
221
|
+
# @param collection_name [String] the Parse collection name (nil to clear all)
|
|
222
|
+
def refresh_indexes(collection_name = nil)
|
|
223
|
+
IndexManager.clear_cache(collection_name)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
#----------------------------------------------------------------
|
|
227
|
+
# SEARCH OPERATIONS
|
|
228
|
+
#----------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
# Perform a full-text search using Atlas Search.
|
|
231
|
+
#
|
|
232
|
+
# @param collection_name [String] the Parse collection name (e.g., "Song")
|
|
233
|
+
# @param query [String] the search query text
|
|
234
|
+
# @param options [Hash] search options
|
|
235
|
+
# @option options [String] :index search index name (default: configured default_index)
|
|
236
|
+
# @option options [Array<String>, String, Symbol] :fields fields to search (default: all indexed fields)
|
|
237
|
+
# @option options [Boolean] :fuzzy enable fuzzy matching (default: false)
|
|
238
|
+
# @option options [Integer] :fuzzy_max_edits max edit distance for fuzzy (1 or 2, default: 2)
|
|
239
|
+
# @option options [Symbol, String] :highlight_field field to return highlights for
|
|
240
|
+
# @option options [Integer] :limit max results to return (default: 100)
|
|
241
|
+
# @option options [Integer] :skip number of results to skip (default: 0)
|
|
242
|
+
# @option options [Hash] :filter additional constraints to apply
|
|
243
|
+
# @option options [Hash] :sort sort specification (default: by relevance score)
|
|
244
|
+
# @option options [Boolean] :raw return raw MongoDB documents (default: false)
|
|
245
|
+
# @option options [String] :class_name Parse class name for object conversion
|
|
246
|
+
#
|
|
247
|
+
# @return [Parse::AtlasSearch::SearchResult] search result object
|
|
248
|
+
#
|
|
249
|
+
# @example Basic search
|
|
250
|
+
# result = Parse::AtlasSearch.search("Song", "love ballad")
|
|
251
|
+
# result.results.each { |song| puts song.title }
|
|
252
|
+
#
|
|
253
|
+
# @example Search with fuzzy matching and field restriction
|
|
254
|
+
# result = Parse::AtlasSearch.search("Song", "lvoe",
|
|
255
|
+
# fields: [:title, :lyrics],
|
|
256
|
+
# fuzzy: true,
|
|
257
|
+
# limit: 20
|
|
258
|
+
# )
|
|
259
|
+
def search(collection_name, query, **options)
|
|
260
|
+
require_available!
|
|
261
|
+
validate_search_params!(query)
|
|
262
|
+
|
|
263
|
+
# Wave-3b READPREF-4: read-preference is consumed at the
|
|
264
|
+
# collection-with-read-preference step inside run_atlas_pipeline!.
|
|
265
|
+
# Pop it here so it doesn't surface in `options` for any
|
|
266
|
+
# downstream consumer (SearchBuilder, recursive search()
|
|
267
|
+
# call from faceted_search) that iterates the hash.
|
|
268
|
+
read_preference = options.delete(:read_preference)
|
|
269
|
+
resolution = resolve_scope!(options, method_name: :search)
|
|
270
|
+
|
|
271
|
+
# Enforce CLP `find` (and pointerFields requirement) BEFORE
|
|
272
|
+
# we build / execute the pipeline. Without this, a scoped
|
|
273
|
+
# caller can issue $search against a collection whose CLP
|
|
274
|
+
# would refuse them on the equivalent REST find.
|
|
275
|
+
assert_clp_find!(collection_name, resolution)
|
|
276
|
+
pointer_fields = resolve_pointer_fields!(collection_name, resolution)
|
|
277
|
+
|
|
278
|
+
# Compute the protectedFields strip set early so we can
|
|
279
|
+
# refuse a highlight_field that's in it (ATLAS-4). Avoids
|
|
280
|
+
# the awkward "we return objects but secretly drop their
|
|
281
|
+
# highlights" state — fail loudly instead.
|
|
282
|
+
protected_fields = Parse::CLPScope.protected_fields_for(
|
|
283
|
+
collection_name, resolution.permission_strings,
|
|
284
|
+
)
|
|
285
|
+
assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
|
|
286
|
+
|
|
287
|
+
index_name = options[:index] || @default_index
|
|
288
|
+
fields = normalize_fields(options[:fields])
|
|
289
|
+
limit = options[:limit] || 100
|
|
290
|
+
skip_val = options[:skip] || 0
|
|
291
|
+
|
|
292
|
+
# Build the $search stage
|
|
293
|
+
builder = SearchBuilder.new(index_name: index_name)
|
|
294
|
+
|
|
295
|
+
if fields.present?
|
|
296
|
+
fields.each do |field|
|
|
297
|
+
builder.text(query: query, path: field, fuzzy: options[:fuzzy])
|
|
298
|
+
end
|
|
299
|
+
else
|
|
300
|
+
builder.text(query: query, path: { "wildcard" => "*" }, fuzzy: options[:fuzzy])
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
if options[:highlight_field]
|
|
304
|
+
builder.with_highlight(path: options[:highlight_field])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# CRITICAL: $search MUST be stage 0 of an Atlas Search
|
|
308
|
+
# pipeline. MongoDB Atlas rejects pipelines whose first stage
|
|
309
|
+
# is anything other than $search/$searchMeta. Do NOT route
|
|
310
|
+
# through Parse::MongoDB.aggregate here — that helper prepends
|
|
311
|
+
# the ACL $match to position 0, which Atlas would reject. We
|
|
312
|
+
# build the pipeline manually with $search at index 0 and
|
|
313
|
+
# place the ACL $match AFTER $search (which is correct: $search
|
|
314
|
+
# has already produced its candidate set, the $match narrows it
|
|
315
|
+
# to ACL-readable rows, then the caller filter narrows further).
|
|
316
|
+
pipeline = [builder.build]
|
|
317
|
+
|
|
318
|
+
# Add score projection
|
|
319
|
+
pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
|
|
320
|
+
|
|
321
|
+
# Add highlights projection if requested
|
|
322
|
+
if options[:highlight_field]
|
|
323
|
+
pipeline << { "$addFields" => { "_highlights" => { "$meta" => "searchHighlights" } } }
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Inject ACL $match BEFORE the caller-supplied filter (but AFTER
|
|
327
|
+
# $search and the $addFields stages) so the user-controlled
|
|
328
|
+
# filter cannot exfiltrate restricted documents that passed the
|
|
329
|
+
# $search operator. The $exists: false branch in `read_predicate`
|
|
330
|
+
# covers documents Parse Server treats as public (no _rperm).
|
|
331
|
+
unless resolution.master?
|
|
332
|
+
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
333
|
+
pipeline << acl_match if acl_match
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Add filter stage if provided
|
|
337
|
+
if options[:filter]
|
|
338
|
+
mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
|
|
339
|
+
pipeline << { "$match" => mongo_filter }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Add sort (default by score)
|
|
343
|
+
sort_spec = options[:sort] || { "_score" => -1 }
|
|
344
|
+
pipeline << { "$sort" => sort_spec }
|
|
345
|
+
|
|
346
|
+
# Add pagination
|
|
347
|
+
pipeline << { "$skip" => skip_val } if skip_val > 0
|
|
348
|
+
pipeline << { "$limit" => limit }
|
|
349
|
+
|
|
350
|
+
# Execute directly against the MongoDB collection — bypasses
|
|
351
|
+
# Parse::MongoDB.aggregate so its ACL-prepend doesn't violate
|
|
352
|
+
# the $search-at-stage-0 invariant. We're reproducing the
|
|
353
|
+
# SDK-side enforcement chain (ACL match, protectedFields strip,
|
|
354
|
+
# pointerFields filter, embedded sub-doc redaction) inline below.
|
|
355
|
+
raw_results = run_atlas_pipeline!(
|
|
356
|
+
collection_name, pipeline, options[:max_time_ms],
|
|
357
|
+
read_preference: read_preference,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Post-fetch enforcement: walk the result rows the same way
|
|
361
|
+
# Parse::MongoDB.aggregate would. Master mode is the ACL bypass
|
|
362
|
+
# — skip every redaction layer (matches the helper's behavior).
|
|
363
|
+
unless resolution.master?
|
|
364
|
+
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
365
|
+
Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
|
|
366
|
+
if pointer_fields
|
|
367
|
+
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
368
|
+
raw_results, pointer_fields, resolution.user_id,
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
# ATLAS-4: drop any `_highlights` entry whose `path` names a
|
|
372
|
+
# protected field. `searchHighlights` returns the matched
|
|
373
|
+
# token plus its surrounding text, which would otherwise leak
|
|
374
|
+
# the protected field's value through the snippet.
|
|
375
|
+
strip_protected_highlights!(raw_results, protected_fields) if protected_fields.any?
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Convert results
|
|
379
|
+
class_name = options[:class_name] || collection_name
|
|
380
|
+
process_search_results(raw_results, class_name, options[:raw])
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Perform an autocomplete search for search-as-you-type functionality.
|
|
384
|
+
#
|
|
385
|
+
# @param collection_name [String] the Parse collection name
|
|
386
|
+
# @param query [String] the partial search query (prefix)
|
|
387
|
+
# @param field [Symbol, String] the field configured for autocomplete
|
|
388
|
+
# @param options [Hash] autocomplete options
|
|
389
|
+
# @option options [String] :index search index name (default: configured default_index)
|
|
390
|
+
# @option options [Boolean] :fuzzy enable fuzzy matching (default: false)
|
|
391
|
+
# @option options [Integer] :fuzzy_max_edits max edit distance (1 or 2, default: 1)
|
|
392
|
+
# @option options [String] :token_order "any" or "sequential" (default: "any")
|
|
393
|
+
# @option options [Integer] :limit max suggestions to return (default: 10)
|
|
394
|
+
# @option options [Hash] :filter additional constraints
|
|
395
|
+
# @option options [Boolean] :raw return raw documents (default: false)
|
|
396
|
+
#
|
|
397
|
+
# @return [Parse::AtlasSearch::AutocompleteResult] autocomplete result
|
|
398
|
+
#
|
|
399
|
+
# @example Basic autocomplete
|
|
400
|
+
# result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
|
|
401
|
+
# result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]
|
|
402
|
+
def autocomplete(collection_name, query, field:, **options)
|
|
403
|
+
require_available!
|
|
404
|
+
|
|
405
|
+
raise InvalidSearchParameters, "field is required for autocomplete" if field.nil?
|
|
406
|
+
raise InvalidSearchParameters, "query must be a non-empty string" if query.nil? || query.to_s.strip.empty?
|
|
407
|
+
|
|
408
|
+
# Wave-3b READPREF-4: see #search for rationale.
|
|
409
|
+
read_preference = options.delete(:read_preference)
|
|
410
|
+
resolution = resolve_scope!(options, method_name: :autocomplete)
|
|
411
|
+
|
|
412
|
+
# Enforce CLP `find` (and pointerFields requirement) on the same
|
|
413
|
+
# collection autocomplete is about to scan. Without this an
|
|
414
|
+
# autocomplete UI on a protected class would silently surface
|
|
415
|
+
# the protected field's leading characters to any caller.
|
|
416
|
+
assert_clp_find!(collection_name, resolution)
|
|
417
|
+
pointer_fields = resolve_pointer_fields!(collection_name, resolution)
|
|
418
|
+
|
|
419
|
+
# ATLAS-4: refuse autocomplete on a protected field. The
|
|
420
|
+
# autocomplete operator returns the leading characters of the
|
|
421
|
+
# indexed field value verbatim — running autocomplete on, say,
|
|
422
|
+
# `email` when CLP marks `email` protected would defeat the
|
|
423
|
+
# protectedFields contract.
|
|
424
|
+
protected_fields = Parse::CLPScope.protected_fields_for(
|
|
425
|
+
collection_name, resolution.permission_strings,
|
|
426
|
+
)
|
|
427
|
+
field_str = field.to_s
|
|
428
|
+
if !resolution.master? && protected_fields.include?(field_str)
|
|
429
|
+
raise Parse::CLPScope::Denied.new(
|
|
430
|
+
collection_name, :find,
|
|
431
|
+
"Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
|
|
432
|
+
"protectedFields for the current scope; autocompleting on it would " \
|
|
433
|
+
"leak the protected field's value.",
|
|
434
|
+
)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
index_name = options[:index] || @default_index
|
|
438
|
+
limit = options[:limit] || 10
|
|
439
|
+
|
|
440
|
+
# Build autocomplete search stage
|
|
441
|
+
builder = SearchBuilder.new(index_name: index_name)
|
|
442
|
+
builder.autocomplete(
|
|
443
|
+
query: query.to_s,
|
|
444
|
+
path: field_str,
|
|
445
|
+
fuzzy: options[:fuzzy],
|
|
446
|
+
token_order: options[:token_order],
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# CRITICAL: $search MUST be stage 0 of the pipeline (see
|
|
450
|
+
# comments in #search). Build manually; do NOT route through
|
|
451
|
+
# Parse::MongoDB.aggregate (which would prepend an ACL $match
|
|
452
|
+
# at position 0 and break Atlas's invariant).
|
|
453
|
+
pipeline = [builder.build]
|
|
454
|
+
|
|
455
|
+
# Add score
|
|
456
|
+
pipeline << { "$addFields" => { "_score" => { "$meta" => "searchScore" } } }
|
|
457
|
+
|
|
458
|
+
# Inject ACL $match AFTER $search/$addFields and BEFORE the
|
|
459
|
+
# caller-supplied filter; see {.search} for the rationale.
|
|
460
|
+
unless resolution.master?
|
|
461
|
+
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
462
|
+
pipeline << acl_match if acl_match
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Add filter if provided
|
|
466
|
+
if options[:filter]
|
|
467
|
+
mongo_filter = convert_filter_for_mongodb(options[:filter], collection_name)
|
|
468
|
+
pipeline << { "$match" => mongo_filter }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Sort by score and limit
|
|
472
|
+
pipeline << { "$sort" => { "_score" => -1 } }
|
|
473
|
+
pipeline << { "$limit" => limit }
|
|
474
|
+
|
|
475
|
+
raw_results = run_atlas_pipeline!(
|
|
476
|
+
collection_name, pipeline, options[:max_time_ms],
|
|
477
|
+
read_preference: read_preference,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
unless resolution.master?
|
|
481
|
+
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
482
|
+
Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
|
|
483
|
+
if pointer_fields
|
|
484
|
+
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
485
|
+
raw_results, pointer_fields, resolution.user_id,
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Extract suggestions (the field values). Run after the
|
|
491
|
+
# protectedFields strip / pointerFields filter so a redacted
|
|
492
|
+
# row can't surface its field value through the suggestion list.
|
|
493
|
+
suggestions = raw_results.map { |doc| doc[field_str] }.compact.uniq
|
|
494
|
+
|
|
495
|
+
# Convert to full objects if needed
|
|
496
|
+
class_name = options[:class_name] || collection_name
|
|
497
|
+
results = if raw_mode?(options[:raw])
|
|
498
|
+
sanitize_raw_results(raw_results)
|
|
499
|
+
else
|
|
500
|
+
parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name)
|
|
501
|
+
parse_results.map { |doc| build_parse_object(doc, class_name) }.compact
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
AutocompleteResult.new(suggestions: suggestions, results: results)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Perform a faceted search with category counts.
|
|
508
|
+
#
|
|
509
|
+
# @param collection_name [String] the Parse collection name
|
|
510
|
+
# @param query [String, nil] the search query text (nil for match-all)
|
|
511
|
+
# @param facets [Hash] facet definitions
|
|
512
|
+
# @param options [Hash] search options (same as #search)
|
|
513
|
+
#
|
|
514
|
+
# @return [Parse::AtlasSearch::FacetedResult] faceted result
|
|
515
|
+
#
|
|
516
|
+
# @example Faceted search by genre and year
|
|
517
|
+
# facets = {
|
|
518
|
+
# genre: { type: :string, path: :genre },
|
|
519
|
+
# decade: { type: :number, path: :year, boundaries: [1970, 1980, 1990, 2000, 2010] }
|
|
520
|
+
# }
|
|
521
|
+
# result = Parse::AtlasSearch.faceted_search("Song", "rock", facets)
|
|
522
|
+
# result.facets[:genre] # => [{ value: "Rock", count: 150 }, ...]
|
|
523
|
+
def faceted_search(collection_name, query, facets, **options)
|
|
524
|
+
require_available!
|
|
525
|
+
|
|
526
|
+
# Faceted search uses $searchMeta, which outputs a single
|
|
527
|
+
# metadata document — bucket counts can't be retroactively
|
|
528
|
+
# filtered by a post-$searchMeta $match because the matched
|
|
529
|
+
# documents are not in the output stream. ACL-aware faceting
|
|
530
|
+
# requires either tokenizing _rperm in the search index and
|
|
531
|
+
# injecting a compound.filter inside $searchMeta, or running
|
|
532
|
+
# two passes with manual aggregation. Both are deferred.
|
|
533
|
+
#
|
|
534
|
+
# Library-layer defense: refuse ANY scoped identity kwarg
|
|
535
|
+
# (session_token:, acl_user:, acl_role:) unless the caller
|
|
536
|
+
# explicitly accepts master-key semantics by also passing
|
|
537
|
+
# `master: true`. The original code only checked
|
|
538
|
+
# `session_token:`, leaving `acl_user:` / `acl_role:` callers
|
|
539
|
+
# (ATLAS-10) silently downgraded to the unauthenticated/
|
|
540
|
+
# public-mode banner branch — which on $searchMeta produces
|
|
541
|
+
# bucket counts that include rows the caller cannot read,
|
|
542
|
+
# exfiltrating restricted document counts and category
|
|
543
|
+
# values. Checking the raw options BEFORE resolve_scope!
|
|
544
|
+
# pops them so the error path can name what the caller
|
|
545
|
+
# actually passed.
|
|
546
|
+
scoped_kwargs = %i[session_token acl_user acl_role]
|
|
547
|
+
offending = scoped_kwargs.select { |k| !options[k].nil? }
|
|
548
|
+
if offending.any? && options[:master] != true
|
|
549
|
+
raise FacetedSearchNotACLSafe,
|
|
550
|
+
"Parse::AtlasSearch.faceted_search cannot enforce per-row " \
|
|
551
|
+
"ACL on $searchMeta bucket counts (got #{offending.first}:). " \
|
|
552
|
+
"Pass `master: true` to run with master-key semantics and " \
|
|
553
|
+
"accept that bucket counts include all rows, or use " \
|
|
554
|
+
"#search for ACL-scoped results without facets."
|
|
555
|
+
end
|
|
556
|
+
# Wave-3b READPREF-4: see #search for rationale. Captured
|
|
557
|
+
# before resolve_scope! pops the auth kwargs so the recursive
|
|
558
|
+
# search() call below can re-thread it explicitly (resolve!
|
|
559
|
+
# also strips it during that recursion).
|
|
560
|
+
read_preference = options.delete(:read_preference)
|
|
561
|
+
resolution = resolve_scope!(options, method_name: :faceted_search)
|
|
562
|
+
acl = { master: resolution.master? }
|
|
563
|
+
|
|
564
|
+
index_name = options[:index] || @default_index
|
|
565
|
+
limit = options[:limit] || 100
|
|
566
|
+
skip_val = options[:skip] || 0
|
|
567
|
+
|
|
568
|
+
# Build facet definitions for $searchMeta
|
|
569
|
+
facet_definitions = build_facet_definitions(facets)
|
|
570
|
+
|
|
571
|
+
search_meta_stage = {
|
|
572
|
+
"$searchMeta" => {
|
|
573
|
+
"index" => index_name,
|
|
574
|
+
"facet" => {
|
|
575
|
+
"facets" => facet_definitions,
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
# Add operator for the search query if present
|
|
581
|
+
if query.present?
|
|
582
|
+
fields = normalize_fields(options[:fields])
|
|
583
|
+
if fields.present?
|
|
584
|
+
should_clauses = fields.map do |field|
|
|
585
|
+
{ "text" => { "query" => query, "path" => field } }
|
|
586
|
+
end
|
|
587
|
+
search_meta_stage["$searchMeta"]["facet"]["operator"] = {
|
|
588
|
+
"compound" => { "should" => should_clauses, "minimumShouldMatch" => 1 },
|
|
589
|
+
}
|
|
590
|
+
else
|
|
591
|
+
search_meta_stage["$searchMeta"]["facet"]["operator"] = {
|
|
592
|
+
"text" => { "query" => query, "path" => { "wildcard" => "*" } },
|
|
593
|
+
}
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Execute facet query. $searchMeta MUST be the only / first
|
|
598
|
+
# stage of its pipeline — Atlas rejects anything prepended.
|
|
599
|
+
# Bypass Parse::MongoDB.aggregate (which would prepend a
|
|
600
|
+
# public-mode ACL $match at position 0 under the no-auth-kwargs
|
|
601
|
+
# fallthrough) and call the collection directly. At this point
|
|
602
|
+
# the call is master-only by construction (the offending-kwargs
|
|
603
|
+
# check above ensures any scoped caller bailed out), so no
|
|
604
|
+
# ACL/CLP enforcement runs here either.
|
|
605
|
+
facet_pipeline = [search_meta_stage]
|
|
606
|
+
facet_results_raw = run_atlas_pipeline!(
|
|
607
|
+
collection_name, facet_pipeline, options[:max_time_ms],
|
|
608
|
+
read_preference: read_preference,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Extract facet results
|
|
612
|
+
facet_data = {}
|
|
613
|
+
total_count = 0
|
|
614
|
+
|
|
615
|
+
if facet_results_raw.first
|
|
616
|
+
raw = facet_results_raw.first
|
|
617
|
+
total_count = raw.dig("count", "total") || 0
|
|
618
|
+
|
|
619
|
+
if raw["facet"]
|
|
620
|
+
facets.keys.each do |facet_name|
|
|
621
|
+
bucket_key = facet_name.to_s
|
|
622
|
+
if raw["facet"][bucket_key]
|
|
623
|
+
facet_data[facet_name] = raw["facet"][bucket_key]["buckets"].map do |bucket|
|
|
624
|
+
{ value: bucket["_id"], count: bucket["count"] }
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Get actual results with regular $search. Forward master:
|
|
632
|
+
# explicitly because resolve_acl_options! popped it from the
|
|
633
|
+
# options hash; without re-adding it the recursive call would
|
|
634
|
+
# take the unauthenticated path and emit the banner a second
|
|
635
|
+
# time (or raise ACLRequired under strict mode). Re-thread
|
|
636
|
+
# read_preference: the same way for the same reason — the
|
|
637
|
+
# outer faceted_search popped it before delegating.
|
|
638
|
+
results = if limit > 0 && query.present?
|
|
639
|
+
search_opts = options.merge(limit: limit, skip: skip_val)
|
|
640
|
+
search_opts[:master] = true if acl[:master]
|
|
641
|
+
search_opts[:read_preference] = read_preference if read_preference
|
|
642
|
+
search(collection_name, query, **search_opts).results
|
|
643
|
+
else
|
|
644
|
+
[]
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
FacetedResult.new(results: results, facets: facet_data, total_count: total_count)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
private
|
|
651
|
+
|
|
652
|
+
def require_available!
|
|
653
|
+
Parse::MongoDB.require_gem!
|
|
654
|
+
unless available?
|
|
655
|
+
raise NotAvailable,
|
|
656
|
+
"Atlas Search is not available. Ensure Parse::MongoDB is configured " \
|
|
657
|
+
"and Parse::AtlasSearch.configure(enabled: true) has been called."
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Pop the auth-related kwargs (+:session_token+, +:master+,
|
|
662
|
+
# +:acl_user+, +:acl_role+) off +options+ and return a fully
|
|
663
|
+
# resolved {Parse::ACLScope::Resolution}. Replaces the old
|
|
664
|
+
# +resolve_acl_options!+ shim that returned a bare Hash — the
|
|
665
|
+
# post-fetch enforcement chain ({Parse::ACLScope.redact_results!},
|
|
666
|
+
# {Parse::CLPScope.redact_protected_fields!}, etc.) all consume a
|
|
667
|
+
# Resolution, so producing one here keeps the call sites uniform.
|
|
668
|
+
#
|
|
669
|
+
# Modes match {Parse::ACLScope::Resolution}:
|
|
670
|
+
#
|
|
671
|
+
# * +:session+ — +session_token:+ resolved, or +acl_user:+ /
|
|
672
|
+
# +acl_role:+ supplied. ACL+CLP+protectedFields enforcement
|
|
673
|
+
# runs in full.
|
|
674
|
+
# * +:master+ — +master: true+. ACL/CLP enforcement is bypassed
|
|
675
|
+
# (the caller has explicit master-key intent).
|
|
676
|
+
# * +:public+ — no scope kwargs supplied, +require_session_token+
|
|
677
|
+
# is +false+. A one-time banner is emitted and the call
|
|
678
|
+
# falls through with public-only ACL semantics — public-mode
|
|
679
|
+
# enforcement still runs (refused rows are filtered, the
|
|
680
|
+
# CLP allowlist is consulted), the perms set is just
|
|
681
|
+
# +["*"]+ rather than user-scoped.
|
|
682
|
+
#
|
|
683
|
+
# Raises {ACLRequired} when no scope kwargs are supplied and
|
|
684
|
+
# {.require_session_token} is +true+. The agent-tool path
|
|
685
|
+
# refuses unconditionally regardless of this toggle — see
|
|
686
|
+
# {Parse::Agent::Tools}.
|
|
687
|
+
def resolve_scope!(options, method_name:)
|
|
688
|
+
session_token = options.delete(:session_token)
|
|
689
|
+
master = options.delete(:master)
|
|
690
|
+
acl_user = options.delete(:acl_user)
|
|
691
|
+
acl_role = options.delete(:acl_role)
|
|
692
|
+
|
|
693
|
+
# 4-way mutex. Mirrors Parse::ACLScope.resolve!'s
|
|
694
|
+
# `provided.length > 1` check so an `acl_user:` + `acl_role:`
|
|
695
|
+
# combination, or any other 2-of-N, is refused. Chained `if`
|
|
696
|
+
# branches would silently accept 3-way / 4-way combinations.
|
|
697
|
+
provided = [
|
|
698
|
+
session_token,
|
|
699
|
+
master == true ? master : nil,
|
|
700
|
+
acl_user,
|
|
701
|
+
acl_role,
|
|
702
|
+
].compact
|
|
703
|
+
if provided.length > 1
|
|
704
|
+
raise ArgumentError,
|
|
705
|
+
"Parse::AtlasSearch.#{method_name}: cannot pass more than one of " \
|
|
706
|
+
"session_token:, master: true, acl_user:, or acl_role:. Pick one."
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
if session_token
|
|
710
|
+
resolved = Session.resolve(session_token)
|
|
711
|
+
return Parse::ACLScope::Resolution.new(
|
|
712
|
+
mode: :session,
|
|
713
|
+
permission_strings: resolved.permission_strings,
|
|
714
|
+
user_id: resolved.user_id,
|
|
715
|
+
session: resolved,
|
|
716
|
+
)
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
if acl_user
|
|
720
|
+
return Parse::ACLScope.resolve_for_user(acl_user)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
if acl_role
|
|
724
|
+
return Parse::ACLScope.resolve_for_role(acl_role)
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
if master == true
|
|
728
|
+
return Parse::ACLScope::Resolution.new(
|
|
729
|
+
mode: :master, permission_strings: nil, user_id: nil, session: nil,
|
|
730
|
+
)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
if @require_session_token == true
|
|
734
|
+
raise ACLRequired,
|
|
735
|
+
"Parse::AtlasSearch.#{method_name} requires session_token: or " \
|
|
736
|
+
"master: true (or acl_user:/acl_role:). ACL enforcement is " \
|
|
737
|
+
"disabled when none is supplied; flip " \
|
|
738
|
+
"Parse::AtlasSearch.require_session_token = false to allow " \
|
|
739
|
+
"public-only fallback."
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
warn_no_acl_context_once!(method_name)
|
|
743
|
+
anonymous = Session::Resolved.new(nil, Set.new)
|
|
744
|
+
Parse::ACLScope::Resolution.new(
|
|
745
|
+
mode: :public,
|
|
746
|
+
permission_strings: anonymous.permission_strings,
|
|
747
|
+
user_id: nil,
|
|
748
|
+
session: anonymous,
|
|
749
|
+
)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# CLP `find` boundary check. Master-mode skips; for every other
|
|
753
|
+
# scope, refuse the call when the resolved claim set can't
|
|
754
|
+
# `find` on the collection. Mirrors what Parse::MongoDB.aggregate
|
|
755
|
+
# does inline (we can't reuse that path because of the $search-
|
|
756
|
+
# at-stage-0 invariant).
|
|
757
|
+
def assert_clp_find!(collection_name, resolution)
|
|
758
|
+
return if resolution.nil? || resolution.master?
|
|
759
|
+
unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
|
|
760
|
+
raise Parse::CLPScope::Denied.new(
|
|
761
|
+
collection_name, :find,
|
|
762
|
+
"CLP refuses find on '#{collection_name}' for the current Atlas Search scope.",
|
|
763
|
+
)
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# Resolve and return pointerFields for `find` on the collection.
|
|
768
|
+
# Raises CLPScope::Denied when pointerFields is set but the
|
|
769
|
+
# current scope has no user_id (acl_role-only / public agents).
|
|
770
|
+
# Returns nil when master-mode or no pointerFields entry exists.
|
|
771
|
+
def resolve_pointer_fields!(collection_name, resolution)
|
|
772
|
+
return nil if resolution.nil? || resolution.master?
|
|
773
|
+
pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
|
|
774
|
+
return nil if pointer_fields.nil?
|
|
775
|
+
if resolution.user_id.nil?
|
|
776
|
+
raise Parse::CLPScope::Denied.new(
|
|
777
|
+
collection_name, :find,
|
|
778
|
+
"CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
|
|
779
|
+
"but the current Atlas Search scope has no user_id.",
|
|
780
|
+
)
|
|
781
|
+
end
|
|
782
|
+
pointer_fields
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# ATLAS-4: refuse `highlight_field:` when the field is in the
|
|
786
|
+
# resolved protectedFields set. searchHighlights returns the
|
|
787
|
+
# matched token plus surrounding chars verbatim; running it on
|
|
788
|
+
# a protected field would defeat the protectedFields contract.
|
|
789
|
+
# Master-mode skips (no protectedFields apply).
|
|
790
|
+
def assert_highlight_field_allowed!(highlight_field, protected_fields, resolution)
|
|
791
|
+
return if highlight_field.nil?
|
|
792
|
+
return if resolution.nil? || resolution.master?
|
|
793
|
+
return if protected_fields.nil? || protected_fields.empty?
|
|
794
|
+
path = highlight_field.to_s
|
|
795
|
+
return unless protected_fields.include?(path)
|
|
796
|
+
raise Parse::CLPScope::Denied.new(
|
|
797
|
+
nil, :find,
|
|
798
|
+
"Parse::AtlasSearch.search refused: highlight_field '#{path}' is in " \
|
|
799
|
+
"protectedFields for the current scope; returning highlights would " \
|
|
800
|
+
"leak the protected field's value.",
|
|
801
|
+
)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Drop `_highlights` entries whose `path` matches a
|
|
805
|
+
# protectedFields entry. Defense-in-depth complement to
|
|
806
|
+
# {.assert_highlight_field_allowed!} — that gate refuses the
|
|
807
|
+
# SDK-set highlight_field; this scrubs any highlight payload
|
|
808
|
+
# that arrived through other code paths (e.g., builder reuse
|
|
809
|
+
# or a future caller-supplied highlight Hash).
|
|
810
|
+
def strip_protected_highlights!(documents, protected_fields)
|
|
811
|
+
return if documents.nil? || documents.empty?
|
|
812
|
+
return if protected_fields.nil? || protected_fields.empty?
|
|
813
|
+
protected_set = protected_fields.to_set
|
|
814
|
+
documents.each do |doc|
|
|
815
|
+
next unless doc.is_a?(Hash)
|
|
816
|
+
highlights = doc["_highlights"]
|
|
817
|
+
next unless highlights.is_a?(Array)
|
|
818
|
+
doc["_highlights"] = highlights.reject do |h|
|
|
819
|
+
h.is_a?(Hash) && protected_set.include?((h["path"] || h[:path]).to_s)
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Execute the Atlas Search pipeline directly against the MongoDB
|
|
825
|
+
# collection. Bypasses {Parse::MongoDB.aggregate} (which would
|
|
826
|
+
# prepend the ACL $match at stage 0 — Atlas rejects any pipeline
|
|
827
|
+
# whose stage 0 is not $search/$searchMeta). Timeout translation
|
|
828
|
+
# is preserved to match {Parse::MongoDB.aggregate}'s behavior.
|
|
829
|
+
#
|
|
830
|
+
# Wave-3b READPREF-4: optional `read_preference:` is normalized
|
|
831
|
+
# through the same `Parse::MongoDB.normalize_read_preference`
|
|
832
|
+
# helper {Parse::MongoDB.aggregate} uses so the kwarg semantics
|
|
833
|
+
# are identical on both paths (invalid values warn and route to
|
|
834
|
+
# primary; nil = no override).
|
|
835
|
+
def run_atlas_pipeline!(collection_name, pipeline, max_time_ms = nil, read_preference: nil)
|
|
836
|
+
agg_opts = {}
|
|
837
|
+
agg_opts[:max_time_ms] = max_time_ms if max_time_ms
|
|
838
|
+
coll = Parse::MongoDB.collection(collection_name)
|
|
839
|
+
if (mode = Parse::MongoDB.send(:normalize_read_preference, read_preference))
|
|
840
|
+
coll = coll.with(read: { mode: mode })
|
|
841
|
+
end
|
|
842
|
+
coll.aggregate(pipeline, agg_opts).to_a
|
|
843
|
+
rescue => e
|
|
844
|
+
# `raise_if_timeout!` is module-private on Parse::MongoDB; use
|
|
845
|
+
# `send` so we can reuse the timeout-translation logic without
|
|
846
|
+
# widening its public surface.
|
|
847
|
+
Parse::MongoDB.send(:raise_if_timeout!, e, collection_name, max_time_ms)
|
|
848
|
+
raise
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Emit a one-time +[Parse::AtlasSearch:SECURITY]+ banner the
|
|
852
|
+
# first time an Atlas Search call runs without a session_token
|
|
853
|
+
# and without an explicit +master: true+. Mirrors the
|
|
854
|
+
# warned-once pattern {Parse::Agent} uses for master-key
|
|
855
|
+
# construction so noisy logs don't drown out the warning, but
|
|
856
|
+
# one log line per process is enough to surface the misuse to
|
|
857
|
+
# operators.
|
|
858
|
+
def warn_no_acl_context_once!(method_name)
|
|
859
|
+
return if @master_warned == true
|
|
860
|
+
@master_warned = true
|
|
861
|
+
warn "[Parse::AtlasSearch:SECURITY] #{method_name} called without " \
|
|
862
|
+
"session_token: or master: true. The pipeline will enforce " \
|
|
863
|
+
"public-only ACL semantics (only documents with no _rperm or " \
|
|
864
|
+
"_rperm including \"*\"). Pass session_token: for per-user " \
|
|
865
|
+
"filtering, or master: true to confirm the master-key bypass " \
|
|
866
|
+
"is intentional. Set Parse::AtlasSearch.require_session_token " \
|
|
867
|
+
"= true to make this misuse an error instead of a warning."
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def validate_search_params!(query)
|
|
871
|
+
raise InvalidSearchParameters, "query must be a string" unless query.is_a?(String)
|
|
872
|
+
raise InvalidSearchParameters, "query cannot be empty" if query.strip.empty?
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
def normalize_fields(fields)
|
|
876
|
+
return nil if fields.nil?
|
|
877
|
+
Array(fields).map(&:to_s)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def convert_filter_for_mongodb(filter, collection_name)
|
|
881
|
+
# The filter hash is interpolated directly into a `$match` stage in
|
|
882
|
+
# the search pipeline. A caller forwarding a user-controlled filter
|
|
883
|
+
# (search UI, autocomplete endpoint) must not be able to inject
|
|
884
|
+
# `$where`, `$function`, `$accumulator`, `$out`, or `$merge` here.
|
|
885
|
+
# `Parse::PipelineSecurity.validate_filter!` recurses through the
|
|
886
|
+
# hash and refuses any of those operators at any depth.
|
|
887
|
+
Parse::PipelineSecurity.validate_filter!(filter) if filter
|
|
888
|
+
filter
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def build_facet_definitions(facets)
|
|
892
|
+
definitions = {}
|
|
893
|
+
|
|
894
|
+
facets.each do |name, config|
|
|
895
|
+
path = config[:path].to_s
|
|
896
|
+
facet_def = { "path" => path }
|
|
897
|
+
|
|
898
|
+
case config[:type]
|
|
899
|
+
when :string
|
|
900
|
+
facet_def["type"] = "string"
|
|
901
|
+
facet_def["numBuckets"] = config[:num_buckets] || 10
|
|
902
|
+
when :number
|
|
903
|
+
facet_def["type"] = "number"
|
|
904
|
+
facet_def["boundaries"] = config[:boundaries] if config[:boundaries]
|
|
905
|
+
facet_def["default"] = config[:default] if config[:default]
|
|
906
|
+
when :date
|
|
907
|
+
facet_def["type"] = "date"
|
|
908
|
+
facet_def["boundaries"] = config[:boundaries].map do |d|
|
|
909
|
+
d.respond_to?(:iso8601) ? d.iso8601 : d
|
|
910
|
+
end if config[:boundaries]
|
|
911
|
+
facet_def["default"] = config[:default] if config[:default]
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
definitions[name.to_s] = facet_def
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
definitions
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def build_parse_object(doc, class_name)
|
|
921
|
+
# Try to use Parse::Object.build if available, otherwise return the hash
|
|
922
|
+
if defined?(Parse::Object) && Parse::Object.respond_to?(:build)
|
|
923
|
+
Parse::Object.build(doc, class_name)
|
|
924
|
+
else
|
|
925
|
+
# Fallback: return hash with class info
|
|
926
|
+
doc["className"] ||= class_name
|
|
927
|
+
doc
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def process_search_results(raw_results, class_name, raw_mode)
|
|
932
|
+
sanitized_raw = sanitize_raw_results(raw_results)
|
|
933
|
+
if raw_mode?(raw_mode)
|
|
934
|
+
# The `raw:` channel is the only path callers see the un-
|
|
935
|
+
# converted Mongo shape on. Internal-fields denylist is
|
|
936
|
+
# ALWAYS stripped (cf. INTERNAL_FIELDS_DENYLIST) so a
|
|
937
|
+
# leaked `raw: true` parameter can't surface
|
|
938
|
+
# _hashed_password / _session_token. `raw_results:` on the
|
|
939
|
+
# returned SearchResult mirrors the sanitized form for the
|
|
940
|
+
# same reason.
|
|
941
|
+
SearchResult.new(results: sanitized_raw, raw_results: sanitized_raw)
|
|
942
|
+
else
|
|
943
|
+
parse_results = Parse::MongoDB.convert_documents_to_parse(raw_results, class_name)
|
|
944
|
+
objects = parse_results.each_with_index.map do |doc, idx|
|
|
945
|
+
obj = build_parse_object(doc, class_name)
|
|
946
|
+
raw_doc = raw_results[idx]
|
|
947
|
+
# Attach search metadata from original raw document (scores are stripped during conversion)
|
|
948
|
+
if obj && raw_doc["_score"]
|
|
949
|
+
obj.instance_variable_set(:@_search_score, raw_doc["_score"])
|
|
950
|
+
# Define accessor if not already defined
|
|
951
|
+
unless obj.respond_to?(:search_score)
|
|
952
|
+
obj.define_singleton_method(:search_score) { @_search_score }
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
if obj && raw_doc["_highlights"]
|
|
956
|
+
obj.instance_variable_set(:@_search_highlights, raw_doc["_highlights"])
|
|
957
|
+
unless obj.respond_to?(:search_highlights)
|
|
958
|
+
obj.define_singleton_method(:search_highlights) { @_search_highlights }
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
obj
|
|
962
|
+
end.compact
|
|
963
|
+
SearchResult.new(results: objects, raw_results: sanitized_raw)
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Coerce the `raw:` argument against the module-level
|
|
968
|
+
# {#allow_raw} switch. Returns `true` only when both the caller
|
|
969
|
+
# asked for raw mode AND the runtime permits it.
|
|
970
|
+
def raw_mode?(requested)
|
|
971
|
+
return false unless requested
|
|
972
|
+
@allow_raw.nil? ? default_allow_raw : @allow_raw
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Strip {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST}
|
|
976
|
+
# entries from every document. Unconditional: even when
|
|
977
|
+
# `raw:`-mode is permitted, internal Parse Server columns are
|
|
978
|
+
# never legitimate to return to a search caller.
|
|
979
|
+
def sanitize_raw_results(docs)
|
|
980
|
+
Array(docs).map { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
|
|
981
|
+
end
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# Initialize defaults
|
|
985
|
+
@enabled = false
|
|
986
|
+
@default_index = "default"
|
|
987
|
+
@allow_raw = nil
|
|
988
|
+
@require_session_token = false
|
|
989
|
+
@session_cache_ttl = 3600
|
|
990
|
+
@role_cache_ttl = 120
|
|
991
|
+
@session_cache = Session::MemoryCache.new
|
|
992
|
+
@role_cache = Session::MemoryCache.new
|
|
993
|
+
@master_warned = false
|
|
994
|
+
end
|
|
995
|
+
end
|